feat: shell filters

This commit is contained in:
tresabhi committed 2025-04-03 16:33:48 -05:00
1 parent 92399de5e2
commit df0dc4f118
5 files changed
+437 -250

No files matched your search

+1
View File
@@ -756,6 +756,7 @@
},
"tank_search": {
"clear_filters": "Clear",
"search_bar_hint": "Search tanks...",
"recent": "Recently viewed",
"select_all": "Select all",
@@ -0,0 +1,22 @@
import type { ComponentProps } from 'react';
export function MissingShellIcon(props: ComponentProps<'svg'>) {
return (
<svg
width="64"
height="64"
viewBox="0 0 64 64"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M40.8038 14L50 23.1962M13.5 38.5L25.5 50.5M60 6.77686L57.2231 4C32.2774 18.0987 20.4352 28.1445 4 50.7438C4.15427 51.3609 4.7405 53.3355 7.70248 56.2975C10.6645 59.2595 12.6391 59.8457 13.2562 60C35.8555 43.5648 45.9013 31.7226 60 6.77686Z"
stroke="currentColor"
stroke-width="4"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
);
}
@@ -1,15 +1,21 @@
import {
asset,
ShellType,
TANK_CLASSES,
TankType,
TIER_ROMAN_NUMERALS,
} from '@blitzkit/core';
import { useStore } from '@nanostores/react';
import { ReloadIcon } from '@radix-ui/react-icons';
import { Box, Flex, IconButton, Text } from '@radix-ui/themes';
import { Box, Button, Flex, IconButton, Popover, Text } from '@radix-ui/themes';
import { times } from 'lodash-es';
import { awaitableGameDefinitions } from '../../../core/awaitables/gameDefinitions';
import { $tankFilters, initialTankFilters } from '../../../stores/tankFilters';
import { useLocale } from '../../../hooks/useLocale';
import {
$tankFilters,
initialTankFilters,
type TankFilters,
} from '../../../stores/tankFilters';
import {
$tankopediaSort,
initialTankopediaSort,
@@ -19,6 +25,7 @@ import { ExperimentIcon } from '../../ExperimentIcon';
import { GunAutoloaderIcon } from '../../GunAutoloaderIcon';
import { GunAutoreloaderIcon } from '../../GunAutoreloaderIcon';
import { GunRegularIcon } from '../../GunRegularIcon';
import { MissingShellIcon } from '../../MissingShellIcon';
import { ResearchedIcon } from '../../ResearchedIcon';
import { ScienceIcon } from '../../ScienceIcon';
import { ScienceOffIcon } from '../../ScienceOffIcon';
@@ -29,14 +36,97 @@ interface FilterControlProps {
const gameDefinitions = await awaitableGameDefinitions;
export function FilterControl({ compact }: FilterControlProps) {
const shellTypeIcons: Record<ShellType, string> = {
[ShellType.AP]: 'ap',
[ShellType.APCR]: 'ap_cr',
[ShellType.HE]: 'he',
[ShellType.HEAT]: 'hc',
};
function ShellFilter({ index, premium }: { index: number; premium?: boolean }) {
const tankFilters = useStore($tankFilters);
return (
<Flex height="fit-content" gap="2" align="start" justify="center">
<Popover.Root>
<Popover.Trigger>
<IconButton variant="soft" radius="none" color="gray" highContrast>
{tankFilters.shells[index] === null && (
<Text color="gray" style={{ display: 'contents' }}>
<MissingShellIcon width="1em" height="1em" />
</Text>
)}
{tankFilters.shells[index] !== null && (
<img
style={{ width: '1em', height: '1em' }}
src={asset(
`icons/shells/${shellTypeIcons[tankFilters.shells[index]]}${premium ? '_premium' : ''}.webp`,
)}
/>
)}
</IconButton>
</Popover.Trigger>
<Popover.Content>
<Flex
style={{
borderRadius: 'var(--radius-full)',
overflow: 'hidden',
}}
>
{Object.values(ShellType).map((shellType) => {
if (typeof shellType === 'string') return null;
const selected = tankFilters.shells[index] === shellType;
return (
<IconButton
value={`${shellType}`}
radius="none"
variant={selected ? 'solid' : 'soft'}
onClick={() => {
const mutated = [
tankFilters.shells[0],
tankFilters.shells[1],
tankFilters.shells[2],
] as TankFilters['shells'];
mutated[index] = selected ? null : shellType;
$tankFilters.setKey('shells', mutated);
}}
highContrast={selected}
color={selected ? undefined : 'gray'}
>
<img
src={asset(
`icons/shells/${shellTypeIcons[shellType]}${premium ? '_premium' : ''}.webp`,
)}
style={{ width: '1em', height: '1em' }}
/>
</IconButton>
);
})}
</Flex>
</Popover.Content>
</Popover.Root>
);
}
export function FilterControl({ compact }: FilterControlProps) {
const tankFilters = useStore($tankFilters);
const { strings } = useLocale();
return (
<Flex
height="fit-content"
gap="2"
align={{ initial: 'start', md: 'center' }}
justify="center"
>
<Flex
direction={compact ? 'row' : { initial: 'row', md: 'column' }}
overflow="hidden"
gap={{ initial: '0', md: '1' }}
style={{ borderRadius: 'var(--radius-5)' }}
>
<Flex direction={compact ? 'column' : { md: 'row', initial: 'column' }}>
@@ -101,6 +191,7 @@ export function FilterControl({ compact }: FilterControlProps) {
direction={compact ? 'row' : { initial: 'row', md: 'column' }}
overflow="hidden"
style={{ borderRadius: 'var(--radius-5)' }}
gap={{ initial: '0', md: '1' }}
>
<Flex direction={compact ? 'column' : { md: 'row', initial: 'column' }}>
{gameDefinitions.nations
@@ -176,270 +267,315 @@ export function FilterControl({ compact }: FilterControlProps) {
</Flex>
<Flex
overflow="hidden"
style={{ borderRadius: 'var(--radius-full)' }}
direction={compact ? 'column' : { md: 'row', initial: 'column' }}
direction={{ initial: 'row', md: 'column' }}
align="start"
gap={{ initial: '2', md: '1' }}
>
{TANK_CLASSES.map((tankClass) => {
const Icon = classIcons[tankClass];
const selected = tankFilters.classes?.includes(tankClass);
<Flex gap="2" align="start">
<Flex
overflow="hidden"
style={{ borderRadius: 'var(--radius-full)' }}
direction={compact ? 'column' : { md: 'row', initial: 'column' }}
>
{TANK_CLASSES.map((tankClass) => {
const Icon = classIcons[tankClass];
const selected = tankFilters.classes?.includes(tankClass);
return (
return (
<IconButton
key={tankClass}
variant={selected ? 'solid' : 'soft'}
radius="none"
color={selected ? undefined : 'gray'}
highContrast
onClick={() => {
if (tankFilters.classes.includes(tankClass)) {
$tankFilters.setKey(
'classes',
tankFilters.classes.filter((c) => c !== tankClass),
);
} else {
$tankFilters.setKey('classes', [
...tankFilters.classes,
tankClass,
]);
}
}}
>
<Icon style={{ width: '1em', height: '1em' }} />
</IconButton>
);
})}
</Flex>
<Flex
overflow="hidden"
style={{ borderRadius: 'var(--radius-full)' }}
direction={compact ? 'column' : { md: 'row', initial: 'column' }}
>
<IconButton
key={tankClass}
variant={selected ? 'solid' : 'soft'}
variant={
tankFilters.gunType.includes('regular') ? 'solid' : 'soft'
}
radius="none"
color={selected ? undefined : 'gray'}
color={
tankFilters.gunType.includes('regular') ? undefined : 'gray'
}
highContrast
onClick={() => {
if (tankFilters.classes.includes(tankClass)) {
if (tankFilters.gunType.includes('regular')) {
$tankFilters.setKey(
'classes',
tankFilters.classes.filter((c) => c !== tankClass),
'gunType',
tankFilters.gunType.filter((t) => t !== 'regular'),
);
} else {
$tankFilters.setKey('classes', [
...tankFilters.classes,
tankClass,
$tankFilters.setKey('gunType', [
...tankFilters.gunType,
'regular',
]);
}
}}
>
<Icon style={{ width: '1em', height: '1em' }} />
<GunRegularIcon style={{ width: '1em', height: '1em' }} />
</IconButton>
);
})}
</Flex>
<IconButton
variant={
tankFilters.gunType.includes('auto_loader') ? 'solid' : 'soft'
}
radius="none"
color={
tankFilters.gunType.includes('auto_loader') ? undefined : 'gray'
}
highContrast
onClick={() => {
if (tankFilters.gunType.includes('auto_loader')) {
$tankFilters.setKey(
'gunType',
tankFilters.gunType.filter((t) => t !== 'auto_loader'),
);
} else {
$tankFilters.setKey('gunType', [
...tankFilters.gunType,
'auto_loader',
]);
}
}}
>
<GunAutoloaderIcon style={{ width: '1em', height: '1em' }} />
</IconButton>
<IconButton
variant={
tankFilters.gunType.includes('auto_reloader') ? 'solid' : 'soft'
}
radius="none"
color={
tankFilters.gunType.includes('auto_reloader')
? undefined
: 'gray'
}
highContrast
onClick={() => {
if (tankFilters.gunType.includes('auto_reloader')) {
$tankFilters.setKey(
'gunType',
tankFilters.gunType.filter((t) => t !== 'auto_reloader'),
);
} else {
$tankFilters.setKey('gunType', [
...tankFilters.gunType,
'auto_reloader',
]);
}
}}
>
<GunAutoreloaderIcon style={{ width: '1em', height: '1em' }} />
</IconButton>
</Flex>
<Flex
overflow="hidden"
style={{ borderRadius: 'var(--radius-full)' }}
direction={compact ? 'column' : { md: 'row', initial: 'column' }}
>
<IconButton
variant={tankFilters.gunType.includes('regular') ? 'solid' : 'soft'}
radius="none"
color={tankFilters.gunType.includes('regular') ? undefined : 'gray'}
highContrast
onClick={() => {
if (tankFilters.gunType.includes('regular')) {
$tankFilters.setKey(
'gunType',
tankFilters.gunType.filter((t) => t !== 'regular'),
);
} else {
$tankFilters.setKey('gunType', [
...tankFilters.gunType,
'regular',
]);
}
}}
>
<GunRegularIcon style={{ width: '1em', height: '1em' }} />
</IconButton>
<IconButton
variant={
tankFilters.gunType.includes('auto_loader') ? 'solid' : 'soft'
}
radius="none"
color={
tankFilters.gunType.includes('auto_loader') ? undefined : 'gray'
}
highContrast
onClick={() => {
if (tankFilters.gunType.includes('auto_loader')) {
$tankFilters.setKey(
'gunType',
tankFilters.gunType.filter((t) => t !== 'auto_loader'),
);
} else {
$tankFilters.setKey('gunType', [
...tankFilters.gunType,
'auto_loader',
]);
}
}}
>
<GunAutoloaderIcon style={{ width: '1em', height: '1em' }} />
</IconButton>
<IconButton
variant={
tankFilters.gunType.includes('auto_reloader') ? 'solid' : 'soft'
}
radius="none"
color={
tankFilters.gunType.includes('auto_reloader') ? undefined : 'gray'
}
highContrast
onClick={() => {
if (tankFilters.gunType.includes('auto_reloader')) {
$tankFilters.setKey(
'gunType',
tankFilters.gunType.filter((t) => t !== 'auto_reloader'),
);
} else {
$tankFilters.setKey('gunType', [
...tankFilters.gunType,
'auto_reloader',
]);
}
}}
>
<GunAutoreloaderIcon style={{ width: '1em', height: '1em' }} />
</IconButton>
</Flex>
<Box display={{ initial: 'none', md: 'block' }}>
<Button
color="red"
onClick={() => {
$tankFilters.set(initialTankFilters);
$tankopediaSort.set(initialTankopediaSort);
}}
>
<ReloadIcon />
{strings.website.common.tank_search.clear_filters}
</Button>
</Box>
</Flex>
<Flex
overflow="hidden"
style={{ borderRadius: 'var(--radius-full)' }}
direction={compact ? 'column' : { md: 'row', initial: 'column' }}
>
<IconButton
variant={
tankFilters.types?.includes(TankType.RESEARCHABLE)
? 'solid'
: 'soft'
}
radius="none"
color={
tankFilters.types?.includes(TankType.RESEARCHABLE)
? undefined
: 'gray'
}
highContrast
onClick={() => {
if (tankFilters.types.includes(TankType.RESEARCHABLE)) {
$tankFilters.setKey(
'types',
tankFilters.types.filter((t) => t !== TankType.RESEARCHABLE),
);
} else {
$tankFilters.setKey('types', [
...tankFilters.types,
TankType.RESEARCHABLE,
]);
}
}}
>
<ResearchedIcon style={{ width: '1em', height: '1em' }} />
</IconButton>
<IconButton
variant={
tankFilters.types?.includes(TankType.PREMIUM) ? 'solid' : 'soft'
}
radius="none"
color={
tankFilters.types?.includes(TankType.PREMIUM) ? 'amber' : 'gray'
}
highContrast
onClick={() => {
if (tankFilters.types.includes(TankType.PREMIUM)) {
$tankFilters.setKey(
'types',
tankFilters.types.filter((t) => t !== TankType.PREMIUM),
);
} else {
$tankFilters.setKey('types', [
...tankFilters.types,
TankType.PREMIUM,
]);
}
}}
>
<Text
color={
tankFilters.types?.includes(TankType.PREMIUM)
? undefined
: 'amber'
}
style={{ display: 'flex', justifyContent: 'center' }}
<Flex gap="2" align="start">
<Flex
overflow="hidden"
style={{ borderRadius: 'var(--radius-full)' }}
direction={compact ? 'column' : { md: 'row', initial: 'column' }}
>
<ResearchedIcon style={{ width: '1em', height: '1em' }} />
</Text>
</IconButton>
<IconButton
variant={
tankFilters.types?.includes(TankType.COLLECTOR) ? 'solid' : 'soft'
}
radius="none"
color={
tankFilters.types?.includes(TankType.COLLECTOR) ? 'blue' : 'gray'
}
highContrast
onClick={() => {
if (tankFilters.types.includes(TankType.COLLECTOR)) {
$tankFilters.setKey(
'types',
tankFilters.types.filter((t) => t !== TankType.COLLECTOR),
);
} else {
$tankFilters.setKey('types', [
...tankFilters.types,
TankType.COLLECTOR,
]);
}
}}
>
<Text
color={
tankFilters.types?.includes(TankType.COLLECTOR)
? undefined
: 'blue'
}
style={{ display: 'flex', justifyContent: 'center' }}
<IconButton
variant={
tankFilters.types?.includes(TankType.RESEARCHABLE)
? 'solid'
: 'soft'
}
radius="none"
color={
tankFilters.types?.includes(TankType.RESEARCHABLE)
? undefined
: 'gray'
}
highContrast
onClick={() => {
if (tankFilters.types.includes(TankType.RESEARCHABLE)) {
$tankFilters.setKey(
'types',
tankFilters.types.filter(
(t) => t !== TankType.RESEARCHABLE,
),
);
} else {
$tankFilters.setKey('types', [
...tankFilters.types,
TankType.RESEARCHABLE,
]);
}
}}
>
<ResearchedIcon style={{ width: '1em', height: '1em' }} />
</IconButton>
<IconButton
variant={
tankFilters.types?.includes(TankType.PREMIUM) ? 'solid' : 'soft'
}
radius="none"
color={
tankFilters.types?.includes(TankType.PREMIUM) ? 'amber' : 'gray'
}
highContrast
onClick={() => {
if (tankFilters.types.includes(TankType.PREMIUM)) {
$tankFilters.setKey(
'types',
tankFilters.types.filter((t) => t !== TankType.PREMIUM),
);
} else {
$tankFilters.setKey('types', [
...tankFilters.types,
TankType.PREMIUM,
]);
}
}}
>
<Text
color={
tankFilters.types?.includes(TankType.PREMIUM)
? undefined
: 'amber'
}
style={{ display: 'flex', justifyContent: 'center' }}
>
<ResearchedIcon style={{ width: '1em', height: '1em' }} />
</Text>
</IconButton>
<IconButton
variant={
tankFilters.types?.includes(TankType.COLLECTOR)
? 'solid'
: 'soft'
}
radius="none"
color={
tankFilters.types?.includes(TankType.COLLECTOR)
? 'blue'
: 'gray'
}
highContrast
onClick={() => {
if (tankFilters.types.includes(TankType.COLLECTOR)) {
$tankFilters.setKey(
'types',
tankFilters.types.filter((t) => t !== TankType.COLLECTOR),
);
} else {
$tankFilters.setKey('types', [
...tankFilters.types,
TankType.COLLECTOR,
]);
}
}}
>
<Text
color={
tankFilters.types?.includes(TankType.COLLECTOR)
? undefined
: 'blue'
}
style={{ display: 'flex', justifyContent: 'center' }}
>
<ResearchedIcon style={{ width: '1em', height: '1em' }} />
</Text>
</IconButton>
</Flex>
<Flex
overflow="hidden"
style={{ borderRadius: 'var(--radius-full)' }}
direction={compact ? 'column' : { md: 'row', initial: 'column' }}
>
<ResearchedIcon style={{ width: '1em', height: '1em' }} />
</Text>
</IconButton>
<IconButton
variant={tankFilters.testing === 'include' ? 'solid' : 'soft'}
radius="none"
color={tankFilters.testing === 'include' ? undefined : 'gray'}
highContrast
onClick={() => {
$tankFilters.setKey('testing', 'include');
}}
>
<ExperimentIcon
style={{ width: '1em', height: '1em', color: 'currentColor' }}
/>
</IconButton>
<IconButton
variant={tankFilters.testing === 'exclude' ? 'solid' : 'soft'}
radius="none"
color={tankFilters.testing === 'exclude' ? undefined : 'gray'}
highContrast
onClick={() => {
$tankFilters.setKey('testing', 'exclude');
}}
>
<ScienceOffIcon
style={{ width: '1em', height: '1em', color: 'currentColor' }}
/>
</IconButton>
<IconButton
variant={tankFilters.testing === 'only' ? 'solid' : 'soft'}
radius="none"
color={tankFilters.testing === 'only' ? undefined : 'gray'}
highContrast
onClick={() => {
$tankFilters.setKey('testing', 'only');
}}
>
<ScienceIcon
style={{ width: '1em', height: '1em', color: 'currentColor' }}
/>
</IconButton>
</Flex>
<Flex
overflow="hidden"
style={{ borderRadius: 'var(--radius-full)' }}
direction={compact ? 'column' : { md: 'row', initial: 'column' }}
>
<ShellFilter index={0} />
<ShellFilter index={1} premium />
<ShellFilter index={2} />
</Flex>
</Flex>
</Flex>
<Flex
overflow="hidden"
style={{ borderRadius: 'var(--radius-full)' }}
direction={compact ? 'column' : { md: 'row', initial: 'column' }}
>
<IconButton
variant={tankFilters.testing === 'include' ? 'solid' : 'soft'}
radius="none"
color={tankFilters.testing === 'include' ? undefined : 'gray'}
highContrast
onClick={() => {
$tankFilters.setKey('testing', 'include');
}}
>
<ExperimentIcon
style={{ width: '1em', height: '1em', color: 'currentColor' }}
/>
</IconButton>
<IconButton
variant={tankFilters.testing === 'exclude' ? 'solid' : 'soft'}
radius="none"
color={tankFilters.testing === 'exclude' ? undefined : 'gray'}
highContrast
onClick={() => {
$tankFilters.setKey('testing', 'exclude');
}}
>
<ScienceOffIcon
style={{ width: '1em', height: '1em', color: 'currentColor' }}
/>
</IconButton>
<IconButton
variant={tankFilters.testing === 'only' ? 'solid' : 'soft'}
radius="none"
color={tankFilters.testing === 'only' ? undefined : 'gray'}
highContrast
onClick={() => {
$tankFilters.setKey('testing', 'only');
}}
>
<ScienceIcon
style={{ width: '1em', height: '1em', color: 'currentColor' }}
/>
</IconButton>
</Flex>
<Box>
<Box display={{ initial: 'block', md: 'none' }} ml="4">
<IconButton
color="red"
onClick={() => {
@@ -22,6 +22,27 @@ export function filterTank(filters: TankFilters, tank: TankDefinition) {
(filters.gunType.includes('auto_reloader') &&
tank.turrets.some((turret) =>
turret.guns.some((gun) => gun.gun_type!.$case === 'auto_reloader'),
)))
))) &&
(filters.shells[0] === null ||
tank.turrets.some((turret) =>
turret.guns.some(
(gun) =>
gun.gun_type!.value.base.shells[0]?.type === filters.shells[0],
),
)) &&
(filters.shells[1] === null ||
tank.turrets.some((turret) =>
turret.guns.some(
(gun) =>
gun.gun_type!.value.base.shells[1]?.type === filters.shells[1],
),
)) &&
(filters.shells[2] === null ||
tank.turrets.some((turret) =>
turret.guns.some(
(gun) =>
gun.gun_type!.value.base.shells[2]?.type === filters.shells[2],
),
))
);
}
+8 -1
View File
@@ -1,4 +1,9 @@
import type { GunDefinition, TankClass, TankType } from '@blitzkit/core';
import type {
GunDefinition,
ShellType,
TankClass,
TankType,
} from '@blitzkit/core';
import { map } from 'nanostores';
export type CaseType<T> = T extends {
@@ -16,6 +21,7 @@ export interface TankFilters {
search?: string;
searching: boolean;
gunType: CaseType<GunDefinition>[];
shells: [ShellType | null, ShellType | null, ShellType | null];
}
export const initialTankFilters: TankFilters = {
@@ -27,6 +33,7 @@ export const initialTankFilters: TankFilters = {
search: undefined,
searching: false,
gunType: [],
shells: [null, null, null],
};
export const $tankFilters = map<TankFilters>(initialTankFilters);