feat: compact nations selector

This commit is contained in:
TrèsAbhi committed 2025-12-30 19:53:06 -06:00
1 parent b6d6fa7212
commit 06ce263668
2 files changed
+98 -623

No files matched your search

+2
View File
@@ -759,11 +759,13 @@
"count_singular": "1 tank", "count_singular": "1 tank",
"count_plural": "{count} tanks", "count_plural": "{count} tanks",
"sorting_by": "Sorting by {name}", "sorting_by": "Sorting by {name}",
"no_tanks_found": { "no_tanks_found": {
"body": "No tanks found.", "body": "No tanks found.",
"search_again": "Try searching again.", "search_again": "Try searching again.",
"clear_filters": "Try clearing filters." "clear_filters": "Try clearing filters."
}, },
"sort": { "sort": {
"meta.none": "none", "meta.none": "none",
"survivability.health": "health", "survivability.health": "health",
@@ -1,42 +1,28 @@
import { asset, ShellType } from "@blitzkit/core";
import { import {
asset, LockClosedIcon,
ShellType, LockOpen2Icon,
TANK_CLASSES, TrashIcon,
TankType, } from "@radix-ui/react-icons";
TIER_ROMAN_NUMERALS,
} from "@blitzkit/core";
import { LockClosedIcon, LockOpen2Icon } from "@radix-ui/react-icons";
import { import {
Box, Box,
Button,
DropdownMenu,
Flex, Flex,
IconButton, IconButton,
Inset, Inset,
Link,
Popover, Popover,
Text, Text,
Tooltip,
type ButtonProps,
type FlexProps, type FlexProps,
} from "@radix-ui/themes"; } from "@radix-ui/themes";
import { isEqual, times, chunk } from "lodash-es"; import { isEqual } from "lodash-es";
import { awaitableGameDefinitions } from "../../../core/awaitables/gameDefinitions"; import { awaitableGameDefinitions } from "../../../core/awaitables/gameDefinitions";
import { awaitableConsumableDefinitions } from "../../../core/awaitables/consumableDefinitions";
import { Var } from "../../../core/radix/var";
import { useLocale } from "../../../hooks/useLocale"; import { useLocale } from "../../../hooks/useLocale";
import { App } from "../../../stores/app"; import { App } from "../../../stores/app";
import { TankFilters } from "../../../stores/tankFilters"; import { TankFilters } from "../../../stores/tankFilters";
import { TankSort } from "../../../stores/tankopediaSort";
import { classIcons } from "../../ClassIcon";
import { GunAutoloaderIcon } from "../../GunAutoloaderIcon";
import { GunAutoreloaderIcon } from "../../GunAutoreloaderIcon";
import { GunRegularIcon } from "../../GunRegularIcon";
import { MissingShellIcon } from "../../MissingShellIcon"; import { MissingShellIcon } from "../../MissingShellIcon";
import { ResearchedIcon } from "../../ResearchedIcon";
import { ScienceIcon } from "../../ScienceIcon";
import { ScienceOffIcon } from "../../ScienceOffIcon";
const gameDefinitions = await awaitableGameDefinitions; const gameDefinitions = await awaitableGameDefinitions;
const consumableDefinitions = await awaitableConsumableDefinitions;
const shellTypeIcons: Record<ShellType, string> = { const shellTypeIcons: Record<ShellType, string> = {
[ShellType.AP]: "ap", [ShellType.AP]: "ap",
@@ -45,26 +31,7 @@ const shellTypeIcons: Record<ShellType, string> = {
[ShellType.HEAT]: "hc", [ShellType.HEAT]: "hc",
}; };
// Get consumables const MAX_NATIONS = 3;
const regularConsumables = Object.values(
consumableDefinitions.consumables
).filter(
(consumable) =>
!consumable.game_mode_exclusive && consumable.name.locales.en !== ""
);
// Split consumables into chunks of 5 for columns
const consumableColumns = chunk(regularConsumables, 5);
// Get game mode exclusive abilities
const gameModeAbilities = Object.values(
consumableDefinitions.consumables
).filter((consumable) => consumable.game_mode_exclusive);
// Split abilities into chunks of 5 for columns
const abilityColumns = chunk(gameModeAbilities, 5);
const size: ButtonProps["size"] = { initial: "1", xs: "2" };
function ShellFilter({ index, premium }: { index: number; premium?: boolean }) { function ShellFilter({ index, premium }: { index: number; premium?: boolean }) {
const shells = TankFilters.use((state) => state.shells); const shells = TankFilters.use((state) => state.shells);
@@ -72,13 +39,7 @@ function ShellFilter({ index, premium }: { index: number; premium?: boolean }) {
return ( return (
<Popover.Root> <Popover.Root>
<Popover.Trigger> <Popover.Trigger>
<IconButton <IconButton variant="soft" radius="none" color="gray" highContrast>
size={size}
variant="soft"
radius="none"
color="gray"
highContrast
>
{shells[index] === null && ( {shells[index] === null && (
<Text color="gray" style={{ display: "contents" }}> <Text color="gray" style={{ display: "contents" }}>
<MissingShellIcon width="1em" height="1em" /> <MissingShellIcon width="1em" height="1em" />
@@ -107,7 +68,6 @@ function ShellFilter({ index, premium }: { index: number; premium?: boolean }) {
return ( return (
<IconButton <IconButton
size={size}
key={shellType} key={shellType}
value={`${shellType}`} value={`${shellType}`}
radius="none" radius="none"
@@ -154,7 +114,6 @@ function OwnershipFilter(props: FlexProps) {
{...props} {...props}
> >
<IconButton <IconButton
size={size}
disabled={!wargaming} disabled={!wargaming}
variant={tankFilters.showOwned ? "solid" : "soft"} variant={tankFilters.showOwned ? "solid" : "soft"}
radius="none" radius="none"
@@ -170,7 +129,6 @@ function OwnershipFilter(props: FlexProps) {
</IconButton> </IconButton>
<IconButton <IconButton
size={size}
disabled={!wargaming} disabled={!wargaming}
variant={tankFilters.showUnowned ? "solid" : "soft"} variant={tankFilters.showUnowned ? "solid" : "soft"}
radius="none" radius="none"
@@ -194,598 +152,113 @@ export function FilterControl() {
const clearable = !isEqual(tankFilters, TankFilters.initial); const clearable = !isEqual(tankFilters, TankFilters.initial);
const wargaming = App.use((state) => state.logins.wargaming); const wargaming = App.use((state) => state.logins.wargaming);
const nations =
tankFilters.nations.length === 0
? gameDefinitions.nations
: tankFilters.nations;
return ( return (
<Flex direction="column" align="center"> <Flex align="center" gap="2">
<Flex height="fit-content" gap="1" align="start" justify="center"> <DropdownMenu.Root>
<Flex <DropdownMenu.Trigger>
flexShrink="0" <Button color="gray" variant="surface">
overflow="hidden" Nations
style={{ borderRadius: "var(--radius-4)" }} <Flex>
> {nations.slice(0, MAX_NATIONS).map((nation, index) => (
<Flex direction="column"> <img
{times(5, (index) => { style={{
const tier = index + 1; marginLeft: index > 0 ? "-0.5em" : undefined,
const selected = tankFilters.tiers?.includes(tier); width: "1.25em",
height: "1.25em",
return (
<IconButton
size={size}
key={tier}
variant={selected ? "solid" : "soft"}
radius="none"
color={selected ? undefined : "gray"}
highContrast
onClick={() => {
if (tankFilters.tiers.includes(tier)) {
TankFilters.mutate((draft) => {
draft.tiers = draft.tiers.filter((t) => t !== tier);
});
} else {
TankFilters.mutate((draft) => {
draft.tiers = [...draft.tiers, tier];
});
}
}} }}
> src={asset(`flags/circle/${nation}.webp`)}
<Text size="2">{TIER_ROMAN_NUMERALS[tier]}</Text> />
</IconButton> ))}
);
})}
</Flex>
<Box {nations.length > MAX_NATIONS && (
display={{ initial: "none", md: "block" }} <Text size="1" ml="1">
style={{ +{nations.length - MAX_NATIONS}
height: Var("space-1"), </Text>
backgroundColor: Var("gray-a3"), )}
}} </Flex>
/> </Button>
</DropdownMenu.Trigger>
<Flex direction="column"> <DropdownMenu.Content>
{times(5, (index) => { {gameDefinitions.nations.map((nation) => {
const tier = index + 6; const selected = tankFilters.nations.includes(nation);
const selected = tankFilters.tiers?.includes(tier);
return (
<IconButton
size={size}
key={tier}
variant={selected ? "solid" : "soft"}
radius="none"
color={selected ? undefined : "gray"}
highContrast
onClick={() => {
if (tankFilters.tiers.includes(tier)) {
TankFilters.mutate((draft) => {
draft.tiers = draft.tiers.filter((t) => t !== tier);
});
} else {
TankFilters.mutate((draft) => {
draft.tiers = [...draft.tiers, tier];
});
}
}}
>
<Text size="2">{TIER_ROMAN_NUMERALS[tier]}</Text>
</IconButton>
);
})}
</Flex>
</Flex>
<Flex
flexShrink="0"
overflow="hidden"
style={{ borderRadius: "var(--radius-4)" }}
>
<Flex direction="column">
{gameDefinitions.nations
.slice(0, Math.ceil(gameDefinitions.nations.length / 2))
.map((nation) => {
const selected = tankFilters.nations?.includes(nation);
return (
<IconButton
size={size}
key={nation}
variant={selected ? "solid" : "soft"}
color={selected ? undefined : "gray"}
highContrast
radius="none"
onClick={() => {
TankFilters.mutate((draft) => {
if (tankFilters.nations.includes(nation)) {
draft.nations = draft.nations.filter(
(n) => n !== nation
);
} else {
draft.nations = [...draft.nations, nation];
}
});
}}
>
<img
style={{ width: "1em", height: "1em" }}
src={asset(`flags/circle/${nation}.webp`)}
/>
</IconButton>
);
})}
</Flex>
<Box
display={{ initial: "none", md: "block" }}
style={{
height: Var("space-1"),
backgroundColor: Var("gray-a3"),
}}
/>
<Flex direction="column">
{gameDefinitions.nations
.slice(Math.ceil(gameDefinitions.nations.length / 2))
.map((nation) => {
const selected = tankFilters.nations?.includes(nation);
return (
<IconButton
size={size}
key={nation}
style={{ flex: 1 }}
variant={selected ? "solid" : "soft"}
color={selected ? undefined : "gray"}
highContrast
radius="none"
onClick={() => {
TankFilters.mutate((draft) => {
if (tankFilters.nations.includes(nation)) {
draft.nations = draft.nations.filter(
(n) => n !== nation
);
} else {
draft.nations = [...draft.nations, nation];
}
});
}}
>
<img
style={{ width: "1em", height: "1em" }}
src={asset(`flags/circle/${nation}.webp`)}
/>
</IconButton>
);
})}
</Flex>
</Flex>
{/* Consumables Filter - RIGHT AFTER NATIONS */}
<Flex
flexShrink="0"
overflow="hidden"
style={{ borderRadius: "var(--radius-4)" }}
>
<Flex direction="column">
{consumableColumns[0]?.map((consumable) => {
const selected = tankFilters.consumables?.includes(consumable.id);
return (
<IconButton
size={size}
key={consumable.id}
variant={selected ? "solid" : "soft"}
color={selected ? undefined : "gray"}
highContrast
radius="none"
onClick={() => {
TankFilters.mutate((draft) => {
if (tankFilters.consumables.includes(consumable.id)) {
draft.consumables = draft.consumables.filter(
(c) => c !== consumable.id
);
} else {
draft.consumables = [
...draft.consumables,
consumable.id,
];
}
});
}}
>
<img
style={{ width: "1em", height: "1em" }}
src={asset(`icons/consumables/${consumable.id}.webp`)}
/>
</IconButton>
);
})}
</Flex>
{consumableColumns[1] && (
<>
<Box
display={{ initial: "none", md: "block" }}
style={{
height: Var("space-1"),
backgroundColor: Var("gray-a3"),
}}
/>
<Flex direction="column">
{consumableColumns[1].map((consumable) => {
const selected = tankFilters.consumables?.includes(
consumable.id
);
return (
<IconButton
size={size}
key={consumable.id}
style={{ flex: 1 }}
variant={selected ? "solid" : "soft"}
color={selected ? undefined : "gray"}
highContrast
radius="none"
onClick={() => {
TankFilters.mutate((draft) => {
if (tankFilters.consumables.includes(consumable.id)) {
draft.consumables = draft.consumables.filter(
(c) => c !== consumable.id
);
} else {
draft.consumables = [
...draft.consumables,
consumable.id,
];
}
});
}}
>
<img
style={{ width: "1em", height: "1em" }}
src={asset(`icons/consumables/${consumable.id}.webp`)}
/>
</IconButton>
);
})}
</Flex>
</>
)}
{consumableColumns[2] && (
<>
<Box
display={{ initial: "none", md: "block" }}
style={{
height: Var("space-1"),
backgroundColor: Var("gray-a3"),
}}
/>
<Flex direction="column">
{consumableColumns[2].map((consumable) => {
const selected = tankFilters.consumables?.includes(
consumable.id
);
return (
<IconButton
size={size}
key={consumable.id}
style={{ flex: 1 }}
variant={selected ? "solid" : "soft"}
color={selected ? undefined : "gray"}
highContrast
radius="none"
onClick={() => {
TankFilters.mutate((draft) => {
if (tankFilters.consumables.includes(consumable.id)) {
draft.consumables = draft.consumables.filter(
(c) => c !== consumable.id
);
} else {
draft.consumables = [
...draft.consumables,
consumable.id,
];
}
});
}}
>
<img
style={{ width: "1em", height: "1em" }}
src={asset(`icons/consumables/${consumable.id}.webp`)}
/>
</IconButton>
);
})}
</Flex>
</>
)}
</Flex>
<Flex
overflow="hidden"
style={{ borderRadius: "var(--radius-full)" }}
direction="column"
>
{TANK_CLASSES.map((tankClass) => {
const Icon = classIcons[tankClass];
const selected = tankFilters.classes?.includes(tankClass);
return ( return (
<IconButton <DropdownMenu.CheckboxItem
size={size} onClick={(event) => {
key={tankClass} event.preventDefault();
variant={selected ? "solid" : "soft"}
radius="none"
color={selected ? undefined : "gray"}
highContrast
onClick={() => {
TankFilters.mutate((draft) => { TankFilters.mutate((draft) => {
if (tankFilters.classes.includes(tankClass)) { if (selected) {
draft.classes = draft.classes.filter( draft.nations = draft.nations.filter((n) => n !== nation);
(c) => c !== tankClass
);
} else { } else {
draft.classes = [...draft.classes, tankClass]; draft.nations = [...draft.nations, nation];
} }
}); });
}} }}
checked={selected}
key={nation}
style={{
position: "relative",
}}
> >
<Icon style={{ width: "1em", height: "1em" }} /> <Box
</IconButton> width="calc(100% + calc(4 * var(--space-2)))"
height="100%"
position="absolute"
right="0"
mr="-2"
style={{
backgroundImage: `url(${asset(
`flags/scratched/${nation}.webp`
)})`,
backgroundSize: "100%",
backgroundRepeat: "no-repeat",
backgroundPosition: "5rem center",
}}
>
<Box
width="100%"
height="100%"
style={{
background: `linear-gradient(90deg, var(--gray-2) 50%, transparent)`,
}}
/>
</Box>
<Text style={{ zIndex: 1 }}>
{
strings.common.nations[
nation as keyof typeof strings.common.nations
]
}
</Text>
</DropdownMenu.CheckboxItem>
); );
})} })}
</Flex>
<Flex <DropdownMenu.Separator />
overflow="hidden"
style={{ borderRadius: "var(--radius-full)" }}
direction="column"
>
<IconButton
size={size}
variant={tankFilters.gunType.includes("regular") ? "solid" : "soft"}
radius="none"
color={tankFilters.gunType.includes("regular") ? undefined : "gray"}
highContrast
onClick={() => {
TankFilters.mutate((draft) => {
if (tankFilters.gunType.includes("regular")) {
draft.gunType = draft.gunType.filter((t) => t !== "regular");
} else {
draft.gunType = [...draft.gunType, "regular"];
}
});
}}
>
<GunRegularIcon style={{ width: "1em", height: "1em" }} />
</IconButton>
<IconButton
size={size}
variant={
tankFilters.gunType.includes("auto_loader") ? "solid" : "soft"
}
radius="none"
color={
tankFilters.gunType.includes("auto_loader") ? undefined : "gray"
}
highContrast
onClick={() => {
TankFilters.mutate((draft) => {
if (tankFilters.gunType.includes("auto_loader")) {
draft.gunType = draft.gunType.filter(
(t) => t !== "auto_loader"
);
} else {
draft.gunType = [...draft.gunType, "auto_loader"];
}
});
}}
>
<GunAutoloaderIcon style={{ width: "1em", height: "1em" }} />
</IconButton>
<IconButton
size={size}
variant={
tankFilters.gunType.includes("auto_reloader") ? "solid" : "soft"
}
radius="none"
color={
tankFilters.gunType.includes("auto_reloader") ? undefined : "gray"
}
highContrast
onClick={() => {
TankFilters.mutate((draft) => {
if (tankFilters.gunType.includes("auto_reloader")) {
draft.gunType = draft.gunType.filter(
(t) => t !== "auto_reloader"
);
} else {
draft.gunType = [...draft.gunType, "auto_reloader"];
}
});
}}
>
<GunAutoreloaderIcon style={{ width: "1em", height: "1em" }} />
</IconButton>
</Flex>
<Flex <DropdownMenu.Item
overflow="hidden" color="red"
style={{ borderRadius: "var(--radius-full)" }} onClick={(event) => {
direction="column" event.preventDefault();
>
<ShellFilter index={0} />
<ShellFilter index={1} premium />
<ShellFilter index={2} />
</Flex>
<Flex
overflow="hidden"
style={{ borderRadius: "var(--radius-full)" }}
direction="column"
>
<IconButton
size={size}
variant={
tankFilters.types?.includes(TankType.RESEARCHABLE)
? "solid"
: "soft"
}
radius="none"
color={
tankFilters.types?.includes(TankType.RESEARCHABLE)
? undefined
: "gray"
}
highContrast
onClick={() => {
TankFilters.mutate((draft) => { TankFilters.mutate((draft) => {
if (tankFilters.types.includes(TankType.RESEARCHABLE)) { draft.nations = [];
draft.types = draft.types.filter(
(t) => t !== TankType.RESEARCHABLE
);
} else {
draft.types = [...draft.types, TankType.RESEARCHABLE];
}
}); });
}} }}
> >
<ResearchedIcon style={{ width: "1em", height: "1em" }} /> <TrashIcon />
</IconButton> Clear
<IconButton </DropdownMenu.Item>
size={size} </DropdownMenu.Content>
variant={ </DropdownMenu.Root>
tankFilters.types?.includes(TankType.PREMIUM) ? "solid" : "soft"
}
radius="none"
color={
tankFilters.types?.includes(TankType.PREMIUM) ? "amber" : "gray"
}
highContrast
onClick={() => {
TankFilters.mutate((draft) => {
if (tankFilters.types.includes(TankType.PREMIUM)) {
draft.types = draft.types.filter(
(t) => t !== TankType.PREMIUM
);
} else {
draft.types = [...draft.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
size={size}
variant={
tankFilters.types?.includes(TankType.COLLECTOR) ? "solid" : "soft"
}
radius="none"
color={
tankFilters.types?.includes(TankType.COLLECTOR) ? "blue" : "gray"
}
highContrast
onClick={() => {
TankFilters.mutate((draft) => {
if (tankFilters.types.includes(TankType.COLLECTOR)) {
draft.types = draft.types.filter(
(t) => t !== TankType.COLLECTOR
);
} else {
draft.types = [...draft.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="column"
>
<IconButton
size={size}
variant={tankFilters.showNonTesting ? "solid" : "soft"}
radius="none"
color={tankFilters.showNonTesting ? undefined : "gray"}
highContrast
onClick={() => {
TankFilters.mutate((draft) => {
draft.showNonTesting = !draft.showNonTesting;
});
}}
>
<ScienceOffIcon
style={{ width: "1em", height: "1em", color: "currentColor" }}
/>
</IconButton>
<IconButton
size={size}
variant={tankFilters.showTesting ? "solid" : "soft"}
radius="none"
color={tankFilters.showTesting ? undefined : "gray"}
highContrast
onClick={() => {
TankFilters.mutate((draft) => {
draft.showTesting = !draft.showTesting;
});
}}
>
<ScienceIcon
style={{ width: "1em", height: "1em", color: "currentColor" }}
/>
</IconButton>
</Flex>
{wargaming ? (
<OwnershipFilter />
) : (
<Tooltip content={strings.website.common.tank_search.login}>
<OwnershipFilter />
</Tooltip>
)}
</Flex>
{clearable && (
<Link
color="red"
underline="always"
mt="4"
href="#"
onClick={(event) => {
event.preventDefault();
TankFilters.set(TankFilters.initial);
TankSort.set(TankSort.initial);
}}
>
{strings.website.common.tank_search.clear_filters}
</Link>
)}
</Flex> </Flex>
); );
} }