feat: upgrade TankFilters

This commit is contained in:
TrèsAbhi committed 2025-09-01 19:13:08 -05:00
1 parent d287e66265
commit 6d24ed92d0
10 files changed
+475 -501

No files matched your search

@@ -1,17 +1,16 @@
import { useStore } from '@nanostores/react'; import { Table } from "@radix-ui/themes";
import { Table } from '@radix-ui/themes'; import { times } from "lodash-es";
import { times } from 'lodash-es'; import { Fragment, Suspense, useEffect, useMemo, useState } from "react";
import { Fragment, Suspense, useEffect, useMemo, useState } from 'react'; import { awaitableAverageDefinitions } from "../../core/awaitables/averageDefinitions";
import { awaitableAverageDefinitions } from '../../core/awaitables/averageDefinitions'; import { awaitableTankDefinitions } from "../../core/awaitables/tankDefinitions";
import { awaitableTankDefinitions } from '../../core/awaitables/tankDefinitions'; import { filterTank } from "../../core/blitzkit/filterTank";
import { filterTank } from '../../core/blitzkit/filterTank'; import { Performance } from "../../stores/performance";
import { Performance } from '../../stores/performance'; import { TankFilters } from "../../stores/tankFilters";
import { $tankFilters } from '../../stores/tankFilters'; import { TankPerformanceSort } from "../../stores/tankPerformanceSort";
import { TankPerformanceSort } from '../../stores/tankPerformanceSort'; import type { MaybeSkeletonComponentProps } from "../../types/maybeSkeletonComponentProps";
import type { MaybeSkeletonComponentProps } from '../../types/maybeSkeletonComponentProps'; import { RowLoader } from "./RowLoader";
import { RowLoader } from './RowLoader'; import { TankRow } from "./TankRow";
import { TankRow } from './TankRow'; import { Total } from "./Total";
import { Total } from './Total';
const PREVIEW_COUNT = 10; const PREVIEW_COUNT = 10;
const DEFAULT_LOADED_ROWS = 25; const DEFAULT_LOADED_ROWS = 25;
@@ -25,7 +24,7 @@ const averageDefinitionsArray = Object.entries(averageDefinitions.averages).map(
([id, average]) => ({ ([id, average]) => ({
id: Number(id), id: Number(id),
...average, ...average,
}), })
); );
export function Tanks({ skeleton }: MaybeSkeletonComponentProps) { export function Tanks({ skeleton }: MaybeSkeletonComponentProps) {
@@ -40,97 +39,97 @@ export function Tanks({ skeleton }: MaybeSkeletonComponentProps) {
} }
const sort = TankPerformanceSort.use(); const sort = TankPerformanceSort.use();
const filters = useStore($tankFilters);
const tanksSorted = useMemo(() => { const tanksSorted = useMemo(() => {
const { playerCountPeriod } = Performance.state; const { playerCountPeriod } = Performance.state;
switch (sort.type) { switch (sort.type) {
case 'accuracy': case "accuracy":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * (a.mu.hits / a.mu.shots - b.mu.hits / b.mu.shots), sort.direction * (a.mu.hits / a.mu.shots - b.mu.hits / b.mu.shots)
); );
case 'capture_points': case "capture_points":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * sort.direction *
(a.mu.capture_points / a.mu.battles - (a.mu.capture_points / a.mu.battles -
b.mu.capture_points / b.mu.battles), b.mu.capture_points / b.mu.battles)
); );
case 'damage': case "damage":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * sort.direction *
(a.mu.damage_dealt / a.mu.battles - (a.mu.damage_dealt / a.mu.battles -
b.mu.damage_dealt / b.mu.battles), b.mu.damage_dealt / b.mu.battles)
); );
case 'damage_ratio': case "damage_ratio":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * sort.direction *
(a.mu.damage_dealt / a.mu.damage_received - (a.mu.damage_dealt / a.mu.damage_received -
b.mu.damage_dealt / b.mu.damage_received), b.mu.damage_dealt / b.mu.damage_received)
); );
case 'damage_taken': case "damage_taken":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * sort.direction *
(a.mu.damage_received / a.mu.battles - (a.mu.damage_received / a.mu.battles -
b.mu.damage_received / b.mu.battles), b.mu.damage_received / b.mu.battles)
); );
case 'hits': case "hits":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * sort.direction *
(a.mu.hits / a.mu.battles - b.mu.hits / b.mu.battles), (a.mu.hits / a.mu.battles - b.mu.hits / b.mu.battles)
); );
case 'kills': case "kills":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * sort.direction *
(a.mu.frags / a.mu.battles - b.mu.frags / b.mu.battles), (a.mu.frags / a.mu.battles - b.mu.frags / b.mu.battles)
); );
case 'shots': case "shots":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * sort.direction *
(a.mu.shots / a.mu.battles - b.mu.shots / b.mu.battles), (a.mu.shots / a.mu.battles - b.mu.shots / b.mu.battles)
); );
case 'players': case "players":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * sort.direction *
(a.samples[playerCountPeriod] - b.samples[playerCountPeriod]), (a.samples[playerCountPeriod] - b.samples[playerCountPeriod])
); );
case 'spots': case "spots":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * sort.direction *
(a.mu.spotted / a.mu.battles - b.mu.spotted / b.mu.battles), (a.mu.spotted / a.mu.battles - b.mu.spotted / b.mu.battles)
); );
case 'survival': case "survival":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * sort.direction *
(a.mu.survived_battles / a.mu.battles - (a.mu.survived_battles / a.mu.battles -
b.mu.survived_battles / b.mu.battles), b.mu.survived_battles / b.mu.battles)
); );
case 'winrate': case "winrate":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * sort.direction *
(a.mu.wins / a.mu.battles - b.mu.wins / b.mu.battles), (a.mu.wins / a.mu.battles - b.mu.wins / b.mu.battles)
); );
case 'xp': case "xp":
return averageDefinitionsArray.sort( return averageDefinitionsArray.sort(
(a, b) => (a, b) =>
sort.direction * (a.mu.xp / a.mu.battles - b.mu.xp / b.mu.battles), sort.direction * (a.mu.xp / a.mu.battles - b.mu.xp / b.mu.battles)
); );
} }
}, [sort]); }, [sort]);
const filters = TankFilters.use();
const tanks = useMemo(() => { const tanks = useMemo(() => {
return tanksSorted.filter((tank) => return tanksSorted.filter((tank) =>
filterTank(filters, tankDefinitions.tanks[tank.id]), filterTank(filters, tankDefinitions.tanks[tank.id])
); );
}, [filters, sort]); }, [filters, sort]);
const [loadedRows, setLoadedRows] = useState(DEFAULT_LOADED_ROWS); const [loadedRows, setLoadedRows] = useState(DEFAULT_LOADED_ROWS);
@@ -4,8 +4,7 @@ import {
TANK_CLASSES, TANK_CLASSES,
TankType, TankType,
TIER_ROMAN_NUMERALS, TIER_ROMAN_NUMERALS,
} from '@blitzkit/core'; } from "@blitzkit/core";
import { useStore } from '@nanostores/react';
import { import {
Box, Box,
Flex, Flex,
@@ -14,26 +13,22 @@ import {
Link, Link,
Popover, Popover,
Text, Text,
} from '@radix-ui/themes'; } from "@radix-ui/themes";
import { isEqual, times } from 'lodash-es'; import { isEqual, times } from "lodash-es";
import { awaitableGameDefinitions } from '../../../core/awaitables/gameDefinitions'; import { awaitableGameDefinitions } from "../../../core/awaitables/gameDefinitions";
import { Var } from '../../../core/radix/var'; import { Var } from "../../../core/radix/var";
import { useLocale } from '../../../hooks/useLocale'; import { useLocale } from "../../../hooks/useLocale";
import { import { TankFilters } from "../../../stores/tankFilters";
$tankFilters, import { TankSort } from "../../../stores/tankopediaSort";
initialTankFilters, import { classIcons } from "../../ClassIcon";
type TankFilters, import { ExperimentIcon } from "../../ExperimentIcon";
} from '../../../stores/tankFilters'; import { GunAutoloaderIcon } from "../../GunAutoloaderIcon";
import { TankSort } from '../../../stores/tankopediaSort'; import { GunAutoreloaderIcon } from "../../GunAutoreloaderIcon";
import { classIcons } from '../../ClassIcon'; import { GunRegularIcon } from "../../GunRegularIcon";
import { ExperimentIcon } from '../../ExperimentIcon'; import { MissingShellIcon } from "../../MissingShellIcon";
import { GunAutoloaderIcon } from '../../GunAutoloaderIcon'; import { ResearchedIcon } from "../../ResearchedIcon";
import { GunAutoreloaderIcon } from '../../GunAutoreloaderIcon'; import { ScienceIcon } from "../../ScienceIcon";
import { GunRegularIcon } from '../../GunRegularIcon'; import { ScienceOffIcon } from "../../ScienceOffIcon";
import { MissingShellIcon } from '../../MissingShellIcon';
import { ResearchedIcon } from '../../ResearchedIcon';
import { ScienceIcon } from '../../ScienceIcon';
import { ScienceOffIcon } from '../../ScienceOffIcon';
interface FilterControlProps { interface FilterControlProps {
compact?: boolean; compact?: boolean;
@@ -42,29 +37,29 @@ interface FilterControlProps {
const gameDefinitions = await awaitableGameDefinitions; const gameDefinitions = await awaitableGameDefinitions;
const shellTypeIcons: Record<ShellType, string> = { const shellTypeIcons: Record<ShellType, string> = {
[ShellType.AP]: 'ap', [ShellType.AP]: "ap",
[ShellType.APCR]: 'ap_cr', [ShellType.APCR]: "ap_cr",
[ShellType.HE]: 'he', [ShellType.HE]: "he",
[ShellType.HEAT]: 'hc', [ShellType.HEAT]: "hc",
}; };
function ShellFilter({ index, premium }: { index: number; premium?: boolean }) { function ShellFilter({ index, premium }: { index: number; premium?: boolean }) {
const tankFilters = useStore($tankFilters); const shells = TankFilters.use((state) => state.shells);
return ( return (
<Popover.Root> <Popover.Root>
<Popover.Trigger> <Popover.Trigger>
<IconButton variant="soft" radius="none" color="gray" highContrast> <IconButton variant="soft" radius="none" color="gray" highContrast>
{tankFilters.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" />
</Text> </Text>
)} )}
{tankFilters.shells[index] !== null && ( {shells[index] !== null && (
<img <img
style={{ width: '1em', height: '1em' }} style={{ width: "1em", height: "1em" }}
src={asset( src={asset(
`icons/shells/${shellTypeIcons[tankFilters.shells[index]]}${premium ? '_premium' : ''}.webp`, `icons/shells/${shellTypeIcons[shells[index]]}${premium ? "_premium" : ""}.webp`
)} )}
/> />
)} )}
@@ -75,35 +70,33 @@ function ShellFilter({ index, premium }: { index: number; premium?: boolean }) {
<Inset> <Inset>
<Flex> <Flex>
{Object.values(ShellType).map((shellType) => { {Object.values(ShellType).map((shellType) => {
if (typeof shellType === 'string') return null; if (typeof shellType === "string") return null;
const selected = tankFilters.shells[index] === shellType; const selected = shells[index] === shellType;
return ( return (
<IconButton <IconButton
key={shellType} key={shellType}
value={`${shellType}`} value={`${shellType}`}
radius="none" radius="none"
variant={selected ? 'solid' : 'soft'} variant={selected ? "solid" : "soft"}
onClick={() => { onClick={() => {
const mutated = [ const mutated = [...shells] as TankFilters["shells"];
tankFilters.shells[0],
tankFilters.shells[1],
tankFilters.shells[2],
] as TankFilters['shells'];
mutated[index] = selected ? null : shellType; mutated[index] = selected ? null : shellType;
$tankFilters.setKey('shells', mutated); TankFilters.mutate((draft) => {
draft.shells = mutated;
});
}} }}
highContrast={selected} highContrast={selected}
color={selected ? undefined : 'gray'} color={selected ? undefined : "gray"}
> >
<img <img
src={asset( src={asset(
`icons/shells/${shellTypeIcons[shellType]}${premium ? '_premium' : ''}.webp`, `icons/shells/${shellTypeIcons[shellType]}${premium ? "_premium" : ""}.webp`
)} )}
style={{ width: '1em', height: '1em' }} style={{ width: "1em", height: "1em" }}
/> />
</IconButton> </IconButton>
); );
@@ -116,26 +109,26 @@ function ShellFilter({ index, premium }: { index: number; premium?: boolean }) {
} }
export function FilterControl({ compact }: FilterControlProps) { export function FilterControl({ compact }: FilterControlProps) {
const tankFilters = useStore($tankFilters); const tankFilters = TankFilters.use();
const { strings } = useLocale(); const { strings } = useLocale();
const clearable = !isEqual(tankFilters, initialTankFilters); const clearable = !isEqual(tankFilters, TankFilters.initial);
return ( return (
<Flex direction="column" align="center"> <Flex direction="column" align="center">
<Flex <Flex
height="fit-content" height="fit-content"
gap="2" gap="2"
align={{ initial: 'start', md: 'center' }} align={{ initial: "start", md: "center" }}
justify="center" justify="center"
> >
<Flex <Flex
flexShrink="0" flexShrink="0"
direction={compact ? 'row' : { initial: 'row', md: 'column' }} direction={compact ? "row" : { initial: "row", md: "column" }}
overflow="hidden" overflow="hidden"
style={{ borderRadius: 'var(--radius-5)' }} style={{ borderRadius: "var(--radius-5)" }}
> >
<Flex <Flex
direction={compact ? 'column' : { md: 'row', initial: 'column' }} direction={compact ? "column" : { md: "row", initial: "column" }}
> >
{times(5, (index) => { {times(5, (index) => {
const tier = index + 1; const tier = index + 1;
@@ -144,21 +137,19 @@ export function FilterControl({ compact }: FilterControlProps) {
return ( return (
<IconButton <IconButton
key={tier} key={tier}
variant={selected ? 'solid' : 'soft'} variant={selected ? "solid" : "soft"}
radius="none" radius="none"
color={selected ? undefined : 'gray'} color={selected ? undefined : "gray"}
highContrast highContrast
onClick={() => { onClick={() => {
if (tankFilters.tiers.includes(tier)) { if (tankFilters.tiers.includes(tier)) {
$tankFilters.setKey( TankFilters.mutate((draft) => {
'tiers', draft.tiers = draft.tiers.filter((t) => t !== tier);
tankFilters.tiers.filter((t) => t !== tier), });
);
} else { } else {
$tankFilters.setKey('tiers', [ TankFilters.mutate((draft) => {
...tankFilters.tiers, draft.tiers = [...draft.tiers, tier];
tier, });
]);
} }
}} }}
> >
@@ -169,15 +160,15 @@ export function FilterControl({ compact }: FilterControlProps) {
</Flex> </Flex>
<Box <Box
display={{ initial: 'none', md: 'block' }} display={{ initial: "none", md: "block" }}
style={{ style={{
height: Var('space-1'), height: Var("space-1"),
backgroundColor: Var('gray-a3'), backgroundColor: Var("gray-a3"),
}} }}
/> />
<Flex <Flex
direction={compact ? 'column' : { md: 'row', initial: 'column' }} direction={compact ? "column" : { md: "row", initial: "column" }}
> >
{times(5, (index) => { {times(5, (index) => {
const tier = index + 6; const tier = index + 6;
@@ -186,21 +177,19 @@ export function FilterControl({ compact }: FilterControlProps) {
return ( return (
<IconButton <IconButton
key={tier} key={tier}
variant={selected ? 'solid' : 'soft'} variant={selected ? "solid" : "soft"}
radius="none" radius="none"
color={selected ? undefined : 'gray'} color={selected ? undefined : "gray"}
highContrast highContrast
onClick={() => { onClick={() => {
if (tankFilters.tiers.includes(tier)) { if (tankFilters.tiers.includes(tier)) {
$tankFilters.setKey( TankFilters.mutate((draft) => {
'tiers', draft.tiers = draft.tiers.filter((t) => t !== tier);
tankFilters.tiers.filter((t) => t !== tier), });
);
} else { } else {
$tankFilters.setKey('tiers', [ TankFilters.mutate((draft) => {
...tankFilters.tiers, draft.tiers = [...draft.tiers, tier];
tier, });
]);
} }
}} }}
> >
@@ -213,12 +202,12 @@ export function FilterControl({ compact }: FilterControlProps) {
<Flex <Flex
flexShrink="0" flexShrink="0"
direction={compact ? 'row' : { initial: 'row', md: 'column' }} direction={compact ? "row" : { initial: "row", md: "column" }}
overflow="hidden" overflow="hidden"
style={{ borderRadius: 'var(--radius-5)' }} style={{ borderRadius: "var(--radius-5)" }}
> >
<Flex <Flex
direction={compact ? 'column' : { md: 'row', initial: 'column' }} direction={compact ? "column" : { md: "row", initial: "column" }}
> >
{gameDefinitions.nations {gameDefinitions.nations
.slice(0, Math.ceil(gameDefinitions.nations.length / 2)) .slice(0, Math.ceil(gameDefinitions.nations.length / 2))
@@ -228,26 +217,24 @@ export function FilterControl({ compact }: FilterControlProps) {
return ( return (
<IconButton <IconButton
key={nation} key={nation}
variant={selected ? 'solid' : 'soft'} variant={selected ? "solid" : "soft"}
color={selected ? undefined : 'gray'} color={selected ? undefined : "gray"}
highContrast highContrast
radius="none" radius="none"
onClick={() => { onClick={() => {
if (tankFilters.nations.includes(nation)) { TankFilters.mutate((draft) => {
$tankFilters.setKey( if (tankFilters.nations.includes(nation)) {
'nations', draft.nations = draft.nations.filter(
tankFilters.nations.filter((n) => n !== nation), (n) => n !== nation
); );
} else { } else {
$tankFilters.setKey('nations', [ draft.nations = [...draft.nations, nation];
...tankFilters.nations, }
nation, });
]);
}
}} }}
> >
<img <img
style={{ width: '1em', height: '1em' }} style={{ width: "1em", height: "1em" }}
src={asset(`flags/circle/${nation}.webp`)} src={asset(`flags/circle/${nation}.webp`)}
/> />
</IconButton> </IconButton>
@@ -256,15 +243,15 @@ export function FilterControl({ compact }: FilterControlProps) {
</Flex> </Flex>
<Box <Box
display={{ initial: 'none', md: 'block' }} display={{ initial: "none", md: "block" }}
style={{ style={{
height: Var('space-1'), height: Var("space-1"),
backgroundColor: Var('gray-a3'), backgroundColor: Var("gray-a3"),
}} }}
/> />
<Flex <Flex
direction={compact ? 'column' : { md: 'row', initial: 'column' }} direction={compact ? "column" : { md: "row", initial: "column" }}
> >
{gameDefinitions.nations {gameDefinitions.nations
.slice(Math.ceil(gameDefinitions.nations.length / 2)) .slice(Math.ceil(gameDefinitions.nations.length / 2))
@@ -275,26 +262,24 @@ export function FilterControl({ compact }: FilterControlProps) {
<IconButton <IconButton
key={nation} key={nation}
style={{ flex: 1 }} style={{ flex: 1 }}
variant={selected ? 'solid' : 'soft'} variant={selected ? "solid" : "soft"}
color={selected ? undefined : 'gray'} color={selected ? undefined : "gray"}
highContrast highContrast
radius="none" radius="none"
onClick={() => { onClick={() => {
if (tankFilters.nations.includes(nation)) { TankFilters.mutate((draft) => {
$tankFilters.setKey( if (tankFilters.nations.includes(nation)) {
'nations', draft.nations = draft.nations.filter(
tankFilters.nations.filter((n) => n !== nation), (n) => n !== nation
); );
} else { } else {
$tankFilters.setKey('nations', [ draft.nations = [...draft.nations, nation];
...tankFilters.nations, }
nation, });
]);
}
}} }}
> >
<img <img
style={{ width: '1em', height: '1em' }} style={{ width: "1em", height: "1em" }}
src={asset(`flags/circle/${nation}.webp`)} src={asset(`flags/circle/${nation}.webp`)}
/> />
</IconButton> </IconButton>
@@ -304,15 +289,15 @@ export function FilterControl({ compact }: FilterControlProps) {
</Flex> </Flex>
<Flex <Flex
direction={compact ? 'row' : { initial: 'row', md: 'column' }} direction={compact ? "row" : { initial: "row", md: "column" }}
align="start" align="start"
gap={{ initial: '2', md: '1' }} gap={{ initial: "2", md: "1" }}
> >
<Flex gap="2" align="start"> <Flex gap="2" align="start">
<Flex <Flex
overflow="hidden" overflow="hidden"
style={{ borderRadius: 'var(--radius-full)' }} style={{ borderRadius: "var(--radius-full)" }}
direction={compact ? 'column' : { md: 'row', initial: 'column' }} direction={compact ? "column" : { md: "row", initial: "column" }}
> >
{TANK_CLASSES.map((tankClass) => { {TANK_CLASSES.map((tankClass) => {
const Icon = classIcons[tankClass]; const Icon = classIcons[tankClass];
@@ -321,25 +306,23 @@ export function FilterControl({ compact }: FilterControlProps) {
return ( return (
<IconButton <IconButton
key={tankClass} key={tankClass}
variant={selected ? 'solid' : 'soft'} variant={selected ? "solid" : "soft"}
radius="none" radius="none"
color={selected ? undefined : 'gray'} color={selected ? undefined : "gray"}
highContrast highContrast
onClick={() => { onClick={() => {
if (tankFilters.classes.includes(tankClass)) { TankFilters.mutate((draft) => {
$tankFilters.setKey( if (tankFilters.classes.includes(tankClass)) {
'classes', draft.classes = draft.classes.filter(
tankFilters.classes.filter((c) => c !== tankClass), (c) => c !== tankClass
); );
} else { } else {
$tankFilters.setKey('classes', [ draft.classes = [...draft.classes, tankClass];
...tankFilters.classes, }
tankClass, });
]);
}
}} }}
> >
<Icon style={{ width: '1em', height: '1em' }} /> <Icon style={{ width: "1em", height: "1em" }} />
</IconButton> </IconButton>
); );
})} })}
@@ -347,96 +330,90 @@ export function FilterControl({ compact }: FilterControlProps) {
<Flex <Flex
overflow="hidden" overflow="hidden"
style={{ borderRadius: 'var(--radius-full)' }} style={{ borderRadius: "var(--radius-full)" }}
direction={compact ? 'column' : { md: 'row', initial: 'column' }} direction={compact ? "column" : { md: "row", initial: "column" }}
> >
<IconButton <IconButton
variant={ variant={
tankFilters.gunType.includes('regular') ? 'solid' : 'soft' tankFilters.gunType.includes("regular") ? "solid" : "soft"
} }
radius="none" radius="none"
color={ color={
tankFilters.gunType.includes('regular') ? undefined : 'gray' tankFilters.gunType.includes("regular") ? undefined : "gray"
} }
highContrast highContrast
onClick={() => { onClick={() => {
if (tankFilters.gunType.includes('regular')) { TankFilters.mutate((draft) => {
$tankFilters.setKey( if (tankFilters.gunType.includes("regular")) {
'gunType', draft.gunType = draft.gunType.filter(
tankFilters.gunType.filter((t) => t !== 'regular'), (t) => t !== "regular"
); );
} else { } else {
$tankFilters.setKey('gunType', [ draft.gunType = [...draft.gunType, "regular"];
...tankFilters.gunType, }
'regular', });
]);
}
}} }}
> >
<GunRegularIcon style={{ width: '1em', height: '1em' }} /> <GunRegularIcon style={{ width: "1em", height: "1em" }} />
</IconButton> </IconButton>
<IconButton <IconButton
variant={ variant={
tankFilters.gunType.includes('auto_loader') ? 'solid' : 'soft' tankFilters.gunType.includes("auto_loader") ? "solid" : "soft"
} }
radius="none" radius="none"
color={ color={
tankFilters.gunType.includes('auto_loader') tankFilters.gunType.includes("auto_loader")
? undefined ? undefined
: 'gray' : "gray"
} }
highContrast highContrast
onClick={() => { onClick={() => {
if (tankFilters.gunType.includes('auto_loader')) { TankFilters.mutate((draft) => {
$tankFilters.setKey( if (tankFilters.gunType.includes("auto_loader")) {
'gunType', draft.gunType = draft.gunType.filter(
tankFilters.gunType.filter((t) => t !== 'auto_loader'), (t) => t !== "auto_loader"
); );
} else { } else {
$tankFilters.setKey('gunType', [ draft.gunType = [...draft.gunType, "auto_loader"];
...tankFilters.gunType, }
'auto_loader', });
]);
}
}} }}
> >
<GunAutoloaderIcon style={{ width: '1em', height: '1em' }} /> <GunAutoloaderIcon style={{ width: "1em", height: "1em" }} />
</IconButton> </IconButton>
<IconButton <IconButton
variant={ variant={
tankFilters.gunType.includes('auto_reloader') tankFilters.gunType.includes("auto_reloader")
? 'solid' ? "solid"
: 'soft' : "soft"
} }
radius="none" radius="none"
color={ color={
tankFilters.gunType.includes('auto_reloader') tankFilters.gunType.includes("auto_reloader")
? undefined ? undefined
: 'gray' : "gray"
} }
highContrast highContrast
onClick={() => { onClick={() => {
if (tankFilters.gunType.includes('auto_reloader')) { TankFilters.mutate((draft) => {
$tankFilters.setKey( if (tankFilters.gunType.includes("auto_reloader")) {
'gunType', draft.gunType = draft.gunType.filter(
tankFilters.gunType.filter((t) => t !== 'auto_reloader'), (t) => t !== "auto_reloader"
); );
} else { } else {
$tankFilters.setKey('gunType', [ draft.gunType = [...draft.gunType, "auto_reloader"];
...tankFilters.gunType, }
'auto_reloader', });
]);
}
}} }}
> >
<GunAutoreloaderIcon style={{ width: '1em', height: '1em' }} /> <GunAutoreloaderIcon style={{ width: "1em", height: "1em" }} />
</IconButton> </IconButton>
</Flex> </Flex>
<Flex <Flex
overflow="hidden" overflow="hidden"
style={{ borderRadius: 'var(--radius-full)' }} style={{ borderRadius: "var(--radius-full)" }}
direction={compact ? 'column' : { md: 'row', initial: 'column' }} direction={compact ? "column" : { md: "row", initial: "column" }}
> >
<ShellFilter index={0} /> <ShellFilter index={0} />
<ShellFilter index={1} premium /> <ShellFilter index={1} premium />
@@ -447,160 +424,158 @@ export function FilterControl({ compact }: FilterControlProps) {
<Flex gap="2" align="start"> <Flex gap="2" align="start">
<Flex <Flex
overflow="hidden" overflow="hidden"
style={{ borderRadius: 'var(--radius-full)' }} style={{ borderRadius: "var(--radius-full)" }}
direction={compact ? 'column' : { md: 'row', initial: 'column' }} direction={compact ? "column" : { md: "row", initial: "column" }}
> >
<IconButton <IconButton
variant={ variant={
tankFilters.types?.includes(TankType.RESEARCHABLE) tankFilters.types?.includes(TankType.RESEARCHABLE)
? 'solid' ? "solid"
: 'soft' : "soft"
} }
radius="none" radius="none"
color={ color={
tankFilters.types?.includes(TankType.RESEARCHABLE) tankFilters.types?.includes(TankType.RESEARCHABLE)
? undefined ? undefined
: 'gray' : "gray"
} }
highContrast highContrast
onClick={() => { onClick={() => {
if (tankFilters.types.includes(TankType.RESEARCHABLE)) { TankFilters.mutate((draft) => {
$tankFilters.setKey( if (tankFilters.types.includes(TankType.RESEARCHABLE)) {
'types', draft.types = draft.types.filter(
tankFilters.types.filter( (t) => t !== TankType.RESEARCHABLE
(t) => t !== TankType.RESEARCHABLE, );
), } else {
); draft.types = [...draft.types, TankType.RESEARCHABLE];
} else { }
$tankFilters.setKey('types', [ });
...tankFilters.types,
TankType.RESEARCHABLE,
]);
}
}} }}
> >
<ResearchedIcon style={{ width: '1em', height: '1em' }} /> <ResearchedIcon style={{ width: "1em", height: "1em" }} />
</IconButton> </IconButton>
<IconButton <IconButton
variant={ variant={
tankFilters.types?.includes(TankType.PREMIUM) tankFilters.types?.includes(TankType.PREMIUM)
? 'solid' ? "solid"
: 'soft' : "soft"
} }
radius="none" radius="none"
color={ color={
tankFilters.types?.includes(TankType.PREMIUM) tankFilters.types?.includes(TankType.PREMIUM)
? 'amber' ? "amber"
: 'gray' : "gray"
} }
highContrast highContrast
onClick={() => { onClick={() => {
if (tankFilters.types.includes(TankType.PREMIUM)) { TankFilters.mutate((draft) => {
$tankFilters.setKey( if (tankFilters.types.includes(TankType.PREMIUM)) {
'types', draft.types = draft.types.filter(
tankFilters.types.filter((t) => t !== TankType.PREMIUM), (t) => t !== TankType.PREMIUM
); );
} else { } else {
$tankFilters.setKey('types', [ draft.types = [...draft.types, TankType.PREMIUM];
...tankFilters.types, }
TankType.PREMIUM, });
]);
}
}} }}
> >
<Text <Text
color={ color={
tankFilters.types?.includes(TankType.PREMIUM) tankFilters.types?.includes(TankType.PREMIUM)
? undefined ? undefined
: 'amber' : "amber"
} }
style={{ display: 'flex', justifyContent: 'center' }} style={{ display: "flex", justifyContent: "center" }}
> >
<ResearchedIcon style={{ width: '1em', height: '1em' }} /> <ResearchedIcon style={{ width: "1em", height: "1em" }} />
</Text> </Text>
</IconButton> </IconButton>
<IconButton <IconButton
variant={ variant={
tankFilters.types?.includes(TankType.COLLECTOR) tankFilters.types?.includes(TankType.COLLECTOR)
? 'solid' ? "solid"
: 'soft' : "soft"
} }
radius="none" radius="none"
color={ color={
tankFilters.types?.includes(TankType.COLLECTOR) tankFilters.types?.includes(TankType.COLLECTOR)
? 'blue' ? "blue"
: 'gray' : "gray"
} }
highContrast highContrast
onClick={() => { onClick={() => {
if (tankFilters.types.includes(TankType.COLLECTOR)) { TankFilters.mutate((draft) => {
$tankFilters.setKey( if (tankFilters.types.includes(TankType.COLLECTOR)) {
'types', draft.types = draft.types.filter(
tankFilters.types.filter((t) => t !== TankType.COLLECTOR), (t) => t !== TankType.COLLECTOR
); );
} else { } else {
$tankFilters.setKey('types', [ draft.types = [...draft.types, TankType.COLLECTOR];
...tankFilters.types, }
TankType.COLLECTOR, });
]);
}
}} }}
> >
<Text <Text
color={ color={
tankFilters.types?.includes(TankType.COLLECTOR) tankFilters.types?.includes(TankType.COLLECTOR)
? undefined ? undefined
: 'blue' : "blue"
} }
style={{ display: 'flex', justifyContent: 'center' }} style={{ display: "flex", justifyContent: "center" }}
> >
<ResearchedIcon style={{ width: '1em', height: '1em' }} /> <ResearchedIcon style={{ width: "1em", height: "1em" }} />
</Text> </Text>
</IconButton> </IconButton>
</Flex> </Flex>
<Flex <Flex
overflow="hidden" overflow="hidden"
style={{ borderRadius: 'var(--radius-full)' }} style={{ borderRadius: "var(--radius-full)" }}
direction={compact ? 'column' : { md: 'row', initial: 'column' }} direction={compact ? "column" : { md: "row", initial: "column" }}
> >
<IconButton <IconButton
variant={tankFilters.testing === 'include' ? 'solid' : 'soft'} variant={tankFilters.testing === "include" ? "solid" : "soft"}
radius="none" radius="none"
color={tankFilters.testing === 'include' ? undefined : 'gray'} color={tankFilters.testing === "include" ? undefined : "gray"}
highContrast highContrast
onClick={() => { onClick={() => {
$tankFilters.setKey('testing', 'include'); TankFilters.mutate((draft) => {
draft.testing = "include";
});
}} }}
> >
<ExperimentIcon <ExperimentIcon
style={{ width: '1em', height: '1em', color: 'currentColor' }} style={{ width: "1em", height: "1em", color: "currentColor" }}
/> />
</IconButton> </IconButton>
<IconButton <IconButton
variant={tankFilters.testing === 'exclude' ? 'solid' : 'soft'} variant={tankFilters.testing === "exclude" ? "solid" : "soft"}
radius="none" radius="none"
color={tankFilters.testing === 'exclude' ? undefined : 'gray'} color={tankFilters.testing === "exclude" ? undefined : "gray"}
highContrast highContrast
onClick={() => { onClick={() => {
$tankFilters.setKey('testing', 'exclude'); TankFilters.mutate((draft) => {
draft.testing = "exclude";
});
}} }}
> >
<ScienceOffIcon <ScienceOffIcon
style={{ width: '1em', height: '1em', color: 'currentColor' }} style={{ width: "1em", height: "1em", color: "currentColor" }}
/> />
</IconButton> </IconButton>
<IconButton <IconButton
variant={tankFilters.testing === 'only' ? 'solid' : 'soft'} variant={tankFilters.testing === "only" ? "solid" : "soft"}
radius="none" radius="none"
color={tankFilters.testing === 'only' ? undefined : 'gray'} color={tankFilters.testing === "only" ? undefined : "gray"}
highContrast highContrast
onClick={() => { onClick={() => {
$tankFilters.setKey('testing', 'only'); TankFilters.mutate((draft) => {
draft.testing = "only";
});
}} }}
> >
<ScienceIcon <ScienceIcon
style={{ width: '1em', height: '1em', color: 'currentColor' }} style={{ width: "1em", height: "1em", color: "currentColor" }}
/> />
</IconButton> </IconButton>
</Flex> </Flex>
@@ -616,7 +591,7 @@ export function FilterControl({ compact }: FilterControlProps) {
href="#" href="#"
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
$tankFilters.set(initialTankFilters); TankFilters.set(TankFilters.initial);
TankSort.set(TankSort.initial); TankSort.set(TankSort.initial);
}} }}
> >
@@ -1,27 +1,27 @@
import { Flex, Link, Text } from '@radix-ui/themes'; import { Flex, Link, Text } from "@radix-ui/themes";
import { useLocale } from '../../../hooks/useLocale'; import { useLocale } from "../../../hooks/useLocale";
import { $tankFilters, initialTankFilters } from '../../../stores/tankFilters'; import { TankFilters } from "../../../stores/tankFilters";
interface NoResultsProps { interface NoResultsProps {
type?: 'filters' | 'search'; type?: "filters" | "search";
} }
export function NoResults({ type = 'filters' }: NoResultsProps) { export function NoResults({ type = "filters" }: NoResultsProps) {
const { strings } = useLocale(); const { strings } = useLocale();
return ( return (
<Flex flexGrow="1" align="center" justify="center"> <Flex flexGrow="1" align="center" justify="center">
<Text color="gray"> <Text color="gray">
{strings.website.common.tank_search.no_tanks_found.body}{' '} {strings.website.common.tank_search.no_tanks_found.body}{" "}
<Link <Link
href="#" href="#"
underline="always" underline="always"
color="red" color="red"
onClick={() => { onClick={() => {
$tankFilters.set(initialTankFilters); TankFilters.set(TankFilters.initial);
}} }}
> >
{type === 'filters' {type === "filters"
? strings.website.common.tank_search.no_tanks_found.clear_filters ? strings.website.common.tank_search.no_tanks_found.clear_filters
: strings.website.common.tank_search.no_tanks_found.search_again} : strings.website.common.tank_search.no_tanks_found.search_again}
</Link> </Link>
@@ -1,20 +1,20 @@
import { type TankDefinition } from '@blitzkit/core'; import { type TankDefinition } from "@blitzkit/core";
import { useStore } from '@nanostores/react'; import { CaretRightIcon } from "@radix-ui/react-icons";
import { CaretRightIcon } from '@radix-ui/react-icons'; import { Button, TextField } from "@radix-ui/themes";
import { Button, TextField } from '@radix-ui/themes'; import { useLocale } from "../../../hooks/useLocale";
import { useLocale } from '../../../hooks/useLocale'; import { TankFilters } from "../../../stores/tankFilters";
import { $tankFilters } from '../../../stores/tankFilters'; import { LinkI18n } from "../../LinkI18n";
import { LinkI18n } from '../../LinkI18n';
interface QuickLinkProps { interface QuickLinkProps {
topResult?: TankDefinition; topResult?: TankDefinition;
} }
export function QuickLink({ topResult }: QuickLinkProps) { export function QuickLink({ topResult }: QuickLinkProps) {
const tankFilters = useStore($tankFilters);
const { locale, unwrap } = useLocale(); const { locale, unwrap } = useLocale();
const search = TankFilters.use((state) => state.search);
const searching = TankFilters.use((state) => state.searching);
if (!tankFilters.search || !topResult || tankFilters.searching) return null; if (!search || !topResult || searching) return null;
return ( return (
<TextField.Slot> <TextField.Slot>
@@ -1,20 +1,19 @@
import { useStore } from '@nanostores/react'; import { Flex, Separator, Text } from "@radix-ui/themes";
import { Flex, Separator, Text } from '@radix-ui/themes'; import { isEqual } from "lodash-es";
import { isEqual } from 'lodash-es'; import { useMemo } from "react";
import { useMemo } from 'react'; import { awaitableTankDefinitions } from "../../../core/awaitables/tankDefinitions";
import { awaitableTankDefinitions } from '../../../core/awaitables/tankDefinitions'; import { useLocale } from "../../../hooks/useLocale";
import { useLocale } from '../../../hooks/useLocale'; import { TankFilters } from "../../../stores/tankFilters";
import { $tankFilters, initialTankFilters } from '../../../stores/tankFilters'; import { TankopediaPersistent } from "../../../stores/tankopediaPersistent";
import { TankopediaPersistent } from '../../../stores/tankopediaPersistent'; import { TankSort } from "../../../stores/tankopediaSort";
import { TankSort } from '../../../stores/tankopediaSort'; import { TankCard } from "../../TankCard";
import { TankCard } from '../../TankCard'; import { TankCardWrapper } from "./TankCardWrapper";
import { TankCardWrapper } from './TankCardWrapper';
const tankDefinitions = await awaitableTankDefinitions; const tankDefinitions = await awaitableTankDefinitions;
export function RecentlyViewed() { export function RecentlyViewed() {
const tankopediaPersistentStore = TankopediaPersistent.useStore(); const tankopediaPersistentStore = TankopediaPersistent.useStore();
const filters = useStore($tankFilters); const filters = TankFilters.use();
// non-reactive because it is a little weird that it updates instantly even before the page loads // non-reactive because it is a little weird that it updates instantly even before the page loads
const recentlyViewed = tankopediaPersistentStore const recentlyViewed = tankopediaPersistentStore
.getState() .getState()
@@ -24,15 +23,15 @@ export function RecentlyViewed() {
Object.entries(filters).some(([key, value]) => { Object.entries(filters).some(([key, value]) => {
return !isEqual( return !isEqual(
value, value,
initialTankFilters[key as keyof typeof initialTankFilters], TankFilters.initial[key as keyof typeof TankFilters.initial]
); );
}), }),
[filters], [filters]
); );
const { strings } = useLocale(); const { strings } = useLocale();
const by = TankSort.use((state) => state.by); const by = TankSort.use((state) => state.by);
if (recentlyViewed.length === 0 || by !== 'meta.none' || hasFilters) { if (recentlyViewed.length === 0 || by !== "meta.none" || hasFilters) {
return null; return null;
} }
@@ -1,14 +1,13 @@
import type { TankDefinition } from '@blitzkit/core'; import type { TankDefinition } from "@blitzkit/core";
import { useStore } from '@nanostores/react'; import { MagnifyingGlassIcon } from "@radix-ui/react-icons";
import { MagnifyingGlassIcon } from '@radix-ui/react-icons'; import { Flex, Spinner, TextField } from "@radix-ui/themes";
import { Flex, Spinner, TextField } from '@radix-ui/themes'; import { debounce } from "lodash-es";
import { debounce } from 'lodash-es'; import { type KeyboardEventHandler, useCallback, useRef } from "react";
import { type KeyboardEventHandler, useCallback, useRef } from 'react'; import { useLocale } from "../../../hooks/useLocale";
import { useLocale } from '../../../hooks/useLocale'; import { TankFilters } from "../../../stores/tankFilters";
import { $tankFilters } from '../../../stores/tankFilters'; import type { MaybeSkeletonComponentProps } from "../../../types/maybeSkeletonComponentProps";
import type { MaybeSkeletonComponentProps } from '../../../types/maybeSkeletonComponentProps'; import { QuickLink } from "./QuickLink";
import { QuickLink } from './QuickLink'; import { Sort } from "./Sort";
import { Sort } from './Sort';
type SearchBarProps = MaybeSkeletonComponentProps & { type SearchBarProps = MaybeSkeletonComponentProps & {
topResult?: TankDefinition; topResult?: TankDefinition;
@@ -17,30 +16,37 @@ type SearchBarProps = MaybeSkeletonComponentProps & {
export function SearchBar({ topResult, skeleton, onSelect }: SearchBarProps) { export function SearchBar({ topResult, skeleton, onSelect }: SearchBarProps) {
const { strings } = useLocale(); const { strings } = useLocale();
const tankFilters = useStore($tankFilters); const search = TankFilters.use((state) => state.search);
const searching = TankFilters.use((state) => state.searching);
const input = useRef<HTMLInputElement>(null); const input = useRef<HTMLInputElement>(null);
const performSearch = useCallback( const performSearch = useCallback(
debounce(() => { debounce(() => {
$tankFilters.setKey('searching', false); TankFilters.mutate((draft) => {
draft.searching = false;
});
if (!input.current) return; if (!input.current) return;
const sanitized = input.current.value.trim(); const sanitized = input.current.value.trim();
$tankFilters.setKey('search', sanitized.length === 0 ? null : sanitized); TankFilters.mutate((draft) => {
draft.search = sanitized.length === 0 ? null : sanitized;
});
}, 500), }, 500),
[], []
); );
const handleChange = useCallback(() => { const handleChange = useCallback(() => {
if (!tankFilters.searching) { if (!searching) {
$tankFilters.setKey('searching', true); TankFilters.mutate((draft) => {
draft.searching = true;
});
} }
performSearch(); performSearch();
}, [tankFilters]); }, [searching]);
const handleKeyDown = useCallback<KeyboardEventHandler>( const handleKeyDown = useCallback<KeyboardEventHandler>(
(event) => { (event) => {
if (event.key !== 'Enter' || !topResult || tankFilters.searching) return; if (event.key !== "Enter" || !topResult || searching) return;
event.preventDefault(); event.preventDefault();
@@ -50,7 +56,7 @@ export function SearchBar({ topResult, skeleton, onSelect }: SearchBarProps) {
window.location.href = `/tanks/${topResult.slug}`; window.location.href = `/tanks/${topResult.slug}`;
} }
}, },
[topResult], [topResult]
); );
return ( return (
@@ -59,7 +65,7 @@ export function SearchBar({ topResult, skeleton, onSelect }: SearchBarProps) {
<TextField.Root <TextField.Root
variant="classic" variant="classic"
disabled={skeleton} disabled={skeleton}
defaultValue={tankFilters.search ?? undefined} defaultValue={search ?? undefined}
style={{ flex: 1 }} style={{ flex: 1 }}
ref={input} ref={input}
placeholder={strings.website.common.tank_search.search_bar_hint} placeholder={strings.website.common.tank_search.search_bar_hint}
@@ -67,7 +73,7 @@ export function SearchBar({ topResult, skeleton, onSelect }: SearchBarProps) {
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
> >
<TextField.Slot> <TextField.Slot>
{tankFilters.searching ? <Spinner /> : <MagnifyingGlassIcon />} {searching ? <Spinner /> : <MagnifyingGlassIcon />}
</TextField.Slot> </TextField.Slot>
<QuickLink topResult={topResult} /> <QuickLink topResult={topResult} />
@@ -1,44 +1,43 @@
import { TIER_ROMAN_NUMERALS } from '@blitzkit/core'; import { TIER_ROMAN_NUMERALS } from "@blitzkit/core";
import { useStore } from '@nanostores/react'; import { Flex, IconButton, Text } from "@radix-ui/themes";
import { Flex, IconButton, Text } from '@radix-ui/themes'; import { times } from "lodash-es";
import { times } from 'lodash-es'; import { memo } from "react";
import { memo } from 'react'; import { TankFilters } from "../../../stores/tankFilters";
import { $tankFilters } from '../../../stores/tankFilters'; import { TankSort } from "../../../stores/tankopediaSort";
import { TankSort } from '../../../stores/tankopediaSort';
export const TierFilter = memo(() => { export const TierFilter = memo(() => {
const tankFilters = useStore($tankFilters);
const by = TankSort.use((state) => state.by); const by = TankSort.use((state) => state.by);
const search = TankFilters.use((state) => state.search);
const tiers = TankFilters.use((state) => state.tiers);
if (by !== 'meta.none' || tankFilters.search) return null; if (by !== "meta.none" || search) return null;
return ( return (
<Flex justify="center"> <Flex justify="center">
<Flex <Flex
direction="row" direction="row"
overflow="hidden" overflow="hidden"
style={{ borderRadius: 'var(--radius-full)' }} style={{ borderRadius: "var(--radius-full)" }}
> >
{times(10, (index) => { {times(10, (index) => {
const tier = 10 - index; const tier = 10 - index;
const selected = tankFilters.tiers.includes(tier); const selected = tiers.includes(tier);
return ( return (
<IconButton <IconButton
key={tier} key={tier}
variant={selected ? 'solid' : 'soft'} variant={selected ? "solid" : "soft"}
radius="none" radius="none"
color={selected ? undefined : 'gray'} color={selected ? undefined : "gray"}
highContrast highContrast
onClick={() => { onClick={() => {
if (tankFilters.tiers.includes(tier)) { TankFilters.mutate((draft) => {
$tankFilters.setKey( if (tiers.includes(tier)) {
'tiers', draft.tiers = tiers.filter((t) => t !== tier);
tankFilters.tiers.filter((t) => t !== tier), } else {
); draft.tiers = [...tiers, tier];
} else { }
$tankFilters.setKey('tiers', [...tankFilters.tiers, tier]); });
}
}} }}
> >
<Text size="2">{TIER_ROMAN_NUMERALS[tier]}</Text> <Text size="2">{TIER_ROMAN_NUMERALS[tier]}</Text>
@@ -5,37 +5,36 @@ import {
SEARCH_KEYS, SEARCH_KEYS,
unionBoundingBox, unionBoundingBox,
type TankDefinition, type TankDefinition,
} from '@blitzkit/core'; } from "@blitzkit/core";
import { literals } from '@blitzkit/i18n/src/literals'; import { literals } from "@blitzkit/i18n/src/literals";
import { useStore } from '@nanostores/react'; import { Callout, Flex, Link, Text, type FlexProps } from "@radix-ui/themes";
import { Callout, Flex, Link, Text, type FlexProps } from '@radix-ui/themes'; import fuzzysort from "fuzzysort";
import fuzzysort from 'fuzzysort'; import { times, uniq } from "lodash-es";
import { times, uniq } from 'lodash-es'; import { memo, useEffect, useMemo, useState } from "react";
import { memo, useEffect, useMemo, useState } from 'react'; import { awaitableGameDefinitions } from "../../core/awaitables/gameDefinitions";
import { awaitableGameDefinitions } from '../../core/awaitables/gameDefinitions'; import { awaitableModelDefinitions } from "../../core/awaitables/modelDefinitions";
import { awaitableModelDefinitions } from '../../core/awaitables/modelDefinitions'; import { awaitableTankDefinitions } from "../../core/awaitables/tankDefinitions";
import { awaitableTankDefinitions } from '../../core/awaitables/tankDefinitions'; import { awaitableTankNames } from "../../core/awaitables/tankNames";
import { awaitableTankNames } from '../../core/awaitables/tankNames'; import { filterTank } from "../../core/blitzkit/filterTank";
import { filterTank } from '../../core/blitzkit/filterTank'; import { resolveReload } from "../../core/blitzkit/resolveReload";
import { resolveReload } from '../../core/blitzkit/resolveReload'; import { useLocale } from "../../hooks/useLocale";
import { useLocale } from '../../hooks/useLocale'; import { TankFilters } from "../../stores/tankFilters";
import { $tankFilters } from '../../stores/tankFilters'; import { TankopediaPersistent } from "../../stores/tankopediaPersistent";
import { TankopediaPersistent } from '../../stores/tankopediaPersistent'; import { SORT_UNITS } from "../../stores/tankopediaPersistent/constants";
import { SORT_UNITS } from '../../stores/tankopediaPersistent/constants'; import { TankSort } from "../../stores/tankopediaSort";
import { TankSort } from '../../stores/tankopediaSort'; import type { MaybeSkeletonComponentProps } from "../../types/maybeSkeletonComponentProps";
import type { MaybeSkeletonComponentProps } from '../../types/maybeSkeletonComponentProps'; import { ExperimentIcon } from "../ExperimentIcon";
import { ExperimentIcon } from '../ExperimentIcon'; import { TankSearchCard } from "./components/Card";
import { TankSearchCard } from './components/Card'; import { FilterControl } from "./components/FilterControl";
import { FilterControl } from './components/FilterControl'; import { NoResults } from "./components/NoResults";
import { NoResults } from './components/NoResults'; import { RecentlyViewed } from "./components/RecentlyViewed";
import { RecentlyViewed } from './components/RecentlyViewed'; import { SearchBar } from "./components/SearchBar";
import { SearchBar } from './components/SearchBar'; import { SkeletonTankCard } from "./components/SkeletonTankCard";
import { SkeletonTankCard } from './components/SkeletonTankCard'; import { TankCardWrapper } from "./components/TankCardWrapper";
import { TankCardWrapper } from './components/TankCardWrapper'; import { MAX_RECENTLY_VIEWED } from "./constants";
import { MAX_RECENTLY_VIEWED } from './constants';
type TankSearchProps = MaybeSkeletonComponentProps & type TankSearchProps = MaybeSkeletonComponentProps &
Omit<FlexProps, 'onSelect'> & { Omit<FlexProps, "onSelect"> & {
compact?: boolean; compact?: boolean;
onSelect?: (tank: TankDefinition) => void; onSelect?: (tank: TankDefinition) => void;
onSelectAll?: (tanks: TankDefinition[]) => void; onSelectAll?: (tanks: TankDefinition[]) => void;
@@ -57,21 +56,21 @@ export const TankSearch = memo<TankSearchProps>(
const { strings, locale } = useLocale(); const { strings, locale } = useLocale();
const mutateTankopediaPersistent = TankopediaPersistent.useMutation(); const mutateTankopediaPersistent = TankopediaPersistent.useMutation();
const awaitedTanksDefinitionsArray = Object.values(tankDefinitions.tanks); const awaitedTanksDefinitionsArray = Object.values(tankDefinitions.tanks);
const tankFilters = useStore($tankFilters);
const tankopediaSort = TankSort.use(); const tankopediaSort = TankSort.use();
const tankFilters = TankFilters.use();
const tanksFiltered = useMemo(() => { const tanksFiltered = useMemo(() => {
if (tankFilters.search === null) { if (tankFilters.search === null) {
const filtered = awaitedTanksDefinitionsArray.filter((tank) => const filtered = awaitedTanksDefinitionsArray.filter((tank) =>
filterTank(tankFilters, tank), filterTank(tankFilters, tank)
); );
let sorted: TankDefinition[]; let sorted: TankDefinition[];
switch (tankopediaSort.by) { switch (tankopediaSort.by) {
case 'meta.none': case "meta.none":
sorted = metaSortTank(filtered, gameDefinitions); sorted = metaSortTank(filtered, gameDefinitions);
break; break;
case 'survivability.health': { case "survivability.health": {
sorted = filtered.sort((a, b) => { sorted = filtered.sort((a, b) => {
const aHealth = a.health + a.turrets.at(-1)!.health; const aHealth = a.health + a.turrets.at(-1)!.health;
const bHealth = b.health + b.turrets.at(-1)!.health; const bHealth = b.health + b.turrets.at(-1)!.health;
@@ -81,36 +80,36 @@ export const TankSearch = memo<TankSearchProps>(
break; break;
} }
case 'survivability.viewRange': case "survivability.viewRange":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.turrets.at(-1)!.view_range - b.turrets.at(-1)!.view_range, a.turrets.at(-1)!.view_range - b.turrets.at(-1)!.view_range
); );
break; break;
case 'survivability.camouflageStill': case "survivability.camouflageStill":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => a.camouflage_still - b.camouflage_still, (a, b) => a.camouflage_still - b.camouflage_still
); );
break; break;
case 'survivability.camouflageMoving': case "survivability.camouflageMoving":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => a.camouflage_moving - b.camouflage_moving, (a, b) => a.camouflage_moving - b.camouflage_moving
); );
break; break;
case 'survivability.camouflageShooting': case "survivability.camouflageShooting":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.camouflage_still * a.camouflage_still *
a.turrets.at(-1)!.guns.at(-1)!.camouflage_loss - a.turrets.at(-1)!.guns.at(-1)!.camouflage_loss -
b.camouflage_still * b.camouflage_still *
b.turrets.at(-1)!.guns.at(-1)!.camouflage_loss, b.turrets.at(-1)!.guns.at(-1)!.camouflage_loss
); );
break; break;
case 'survivability.volume': case "survivability.volume":
sorted = filtered.sort((a, b) => { sorted = filtered.sort((a, b) => {
const aTankModelDefinition = modelDefinitions.models[a.id]; const aTankModelDefinition = modelDefinitions.models[a.id];
const bTankModelDefinition = modelDefinitions.models[b.id]; const bTankModelDefinition = modelDefinitions.models[b.id];
@@ -121,14 +120,14 @@ export const TankSearch = memo<TankSearchProps>(
const aSize = normalizeBoundingBox( const aSize = normalizeBoundingBox(
unionBoundingBox( unionBoundingBox(
aTankModelDefinition.bounding_box, aTankModelDefinition.bounding_box,
aTurretModelDefinition.bounding_box, aTurretModelDefinition.bounding_box
), )
); );
const bSize = normalizeBoundingBox( const bSize = normalizeBoundingBox(
unionBoundingBox( unionBoundingBox(
bTankModelDefinition.bounding_box, bTankModelDefinition.bounding_box,
bTurretModelDefinition.bounding_box, bTurretModelDefinition.bounding_box
), )
); );
const aVolume = aSize.x * aSize.y * aSize.z; const aVolume = aSize.x * aSize.y * aSize.z;
const bVolume = bSize.x * bSize.y * bSize.z; const bVolume = bSize.x * bSize.y * bSize.z;
@@ -137,7 +136,7 @@ export const TankSearch = memo<TankSearchProps>(
}); });
break; break;
case 'survivability.length': case "survivability.length":
sorted = filtered.sort((a, b) => { sorted = filtered.sort((a, b) => {
const aTankModelDefinition = modelDefinitions.models[a.id]; const aTankModelDefinition = modelDefinitions.models[a.id];
const bTankModelDefinition = modelDefinitions.models[b.id]; const bTankModelDefinition = modelDefinitions.models[b.id];
@@ -148,14 +147,14 @@ export const TankSearch = memo<TankSearchProps>(
const aSize = normalizeBoundingBox( const aSize = normalizeBoundingBox(
unionBoundingBox( unionBoundingBox(
aTankModelDefinition.bounding_box, aTankModelDefinition.bounding_box,
aTurretModelDefinition.bounding_box, aTurretModelDefinition.bounding_box
), )
); );
const bSize = normalizeBoundingBox( const bSize = normalizeBoundingBox(
unionBoundingBox( unionBoundingBox(
bTankModelDefinition.bounding_box, bTankModelDefinition.bounding_box,
bTurretModelDefinition.bounding_box, bTurretModelDefinition.bounding_box
), )
); );
const aLength = Math.max(aSize.x, aSize.y, aSize.z); const aLength = Math.max(aSize.x, aSize.y, aSize.z);
const bLength = Math.max(bSize.x, bSize.y, bSize.z); const bLength = Math.max(bSize.x, bSize.y, bSize.z);
@@ -164,147 +163,147 @@ export const TankSearch = memo<TankSearchProps>(
}); });
break; break;
case 'fire.dpm': case "fire.dpm":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
resolveDpm( resolveDpm(
a.turrets.at(-1)!.guns.at(-1)!, a.turrets.at(-1)!.guns.at(-1)!,
a.turrets.at(-1)!.guns.at(-1)!.shells[0], a.turrets.at(-1)!.guns.at(-1)!.shells[0]
) - ) -
resolveDpm( resolveDpm(
b.turrets.at(-1)!.guns.at(-1)!, b.turrets.at(-1)!.guns.at(-1)!,
b.turrets.at(-1)!.guns.at(-1)!.shells[0], b.turrets.at(-1)!.guns.at(-1)!.shells[0]
), )
); );
break; break;
case 'fire.dpmPremium': case "fire.dpmPremium":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
(a.turrets.at(-1)!.guns.at(-1)!.shells[1] (a.turrets.at(-1)!.guns.at(-1)!.shells[1]
? resolveDpm( ? resolveDpm(
a.turrets.at(-1)!.guns.at(-1)!, a.turrets.at(-1)!.guns.at(-1)!,
a.turrets.at(-1)!.guns.at(-1)!.shells[1], a.turrets.at(-1)!.guns.at(-1)!.shells[1]
) )
: 0) - : 0) -
(b.turrets.at(-1)!.guns.at(-1)!.shells[1] (b.turrets.at(-1)!.guns.at(-1)!.shells[1]
? resolveDpm( ? resolveDpm(
b.turrets.at(-1)!.guns.at(-1)!, b.turrets.at(-1)!.guns.at(-1)!,
b.turrets.at(-1)!.guns.at(-1)!.shells[1], b.turrets.at(-1)!.guns.at(-1)!.shells[1]
) )
: 0), : 0)
); );
break; break;
case 'fire.reload': case "fire.reload":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
resolveReload(a.turrets.at(-1)!.guns.at(-1)!) - resolveReload(a.turrets.at(-1)!.guns.at(-1)!) -
resolveReload(b.turrets.at(-1)!.guns.at(-1)!), resolveReload(b.turrets.at(-1)!.guns.at(-1)!)
); );
break; break;
case 'fire.caliber': case "fire.caliber":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.shells[0].caliber - a.turrets.at(-1)!.guns.at(-1)!.shells[0].caliber -
b.turrets.at(-1)!.guns.at(-1)!.shells[0].caliber, b.turrets.at(-1)!.guns.at(-1)!.shells[0].caliber
); );
break; break;
case 'fire.standardPenetration': case "fire.standardPenetration":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.shells[0].penetration.near - a.turrets.at(-1)!.guns.at(-1)!.shells[0].penetration.near -
b.turrets.at(-1)!.guns.at(-1)!.shells[0].penetration.near, b.turrets.at(-1)!.guns.at(-1)!.shells[0].penetration.near
); );
break; break;
case 'fire.premiumPenetration': case "fire.premiumPenetration":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
(a.turrets.at(-1)!.guns.at(-1)!.shells[1]?.penetration.near ?? (a.turrets.at(-1)!.guns.at(-1)!.shells[1]?.penetration.near ??
0) - 0) -
(b.turrets.at(-1)!.guns.at(-1)!.shells[1]?.penetration.near ?? (b.turrets.at(-1)!.guns.at(-1)!.shells[1]?.penetration.near ??
0), 0)
); );
break; break;
case 'fire.tertiaryPenetration': case "fire.tertiaryPenetration":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
(a.turrets.at(-1)!.guns.at(-1)!.shells[2]?.penetration.near ?? (a.turrets.at(-1)!.guns.at(-1)!.shells[2]?.penetration.near ??
0) - 0) -
(b.turrets.at(-1)!.guns.at(-1)!.shells[2]?.penetration.near ?? (b.turrets.at(-1)!.guns.at(-1)!.shells[2]?.penetration.near ??
0), 0)
); );
break; break;
case 'fire.damage': case "fire.damage":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.shells[0].armor_damage - a.turrets.at(-1)!.guns.at(-1)!.shells[0].armor_damage -
b.turrets.at(-1)!.guns.at(-1)!.shells[0].armor_damage, b.turrets.at(-1)!.guns.at(-1)!.shells[0].armor_damage
); );
break; break;
case 'fire.moduleDamage': case "fire.moduleDamage":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.shells[0].module_damage - a.turrets.at(-1)!.guns.at(-1)!.shells[0].module_damage -
b.turrets.at(-1)!.guns.at(-1)!.shells[0].module_damage, b.turrets.at(-1)!.guns.at(-1)!.shells[0].module_damage
); );
break; break;
case 'fire.shellVelocity': case "fire.shellVelocity":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.shells[0].velocity - a.turrets.at(-1)!.guns.at(-1)!.shells[0].velocity -
b.turrets.at(-1)!.guns.at(-1)!.shells[0].velocity, b.turrets.at(-1)!.guns.at(-1)!.shells[0].velocity
); );
break; break;
case 'fire.shellRange': case "fire.shellRange":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.shells[0].range - a.turrets.at(-1)!.guns.at(-1)!.shells[0].range -
b.turrets.at(-1)!.guns.at(-1)!.shells[0].range, b.turrets.at(-1)!.guns.at(-1)!.shells[0].range
); );
break; break;
case 'fire.shellCapacity': case "fire.shellCapacity":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.shell_capacity - a.turrets.at(-1)!.guns.at(-1)!.shell_capacity -
b.turrets.at(-1)!.guns.at(-1)!.shell_capacity, b.turrets.at(-1)!.guns.at(-1)!.shell_capacity
); );
break; break;
case 'fire.aimTime': case "fire.aimTime":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.aim_time - a.turrets.at(-1)!.guns.at(-1)!.aim_time -
b.turrets.at(-1)!.guns.at(-1)!.aim_time, b.turrets.at(-1)!.guns.at(-1)!.aim_time
); );
break; break;
case 'fire.dispersionStill': case "fire.dispersionStill":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.dispersion_base - a.turrets.at(-1)!.guns.at(-1)!.dispersion_base -
b.turrets.at(-1)!.guns.at(-1)!.dispersion_base, b.turrets.at(-1)!.guns.at(-1)!.dispersion_base
); );
break; break;
case 'fire.dispersionMoving': case "fire.dispersionMoving":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.tracks.at(-1)!.dispersion_move - a.tracks.at(-1)!.dispersion_move -
b.tracks.at(-1)!.dispersion_move, b.tracks.at(-1)!.dispersion_move
); );
break; break;
case 'fire.gunDepression': case "fire.gunDepression":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
modelDefinitions.models[a.id].turrets[a.turrets.at(-1)!.id] modelDefinitions.models[a.id].turrets[a.turrets.at(-1)!.id]
@@ -314,11 +313,11 @@ export const TankSearch = memo<TankSearchProps>(
modelDefinitions.models[b.id].turrets[b.turrets.at(-1)!.id] modelDefinitions.models[b.id].turrets[b.turrets.at(-1)!.id]
.guns[b.turrets.at(-1)!.guns.at(-1)!.id].pitch.max - .guns[b.turrets.at(-1)!.guns.at(-1)!.id].pitch.max -
(modelDefinitions.models[b.id].initial_turret_rotation?.pitch ?? (modelDefinitions.models[b.id].initial_turret_rotation?.pitch ??
0), 0)
); );
break; break;
case 'fire.gunElevation': case "fire.gunElevation":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
modelDefinitions.models[b.id].turrets[b.turrets.at(-1)!.id] modelDefinitions.models[b.id].turrets[b.turrets.at(-1)!.id]
@@ -328,29 +327,29 @@ export const TankSearch = memo<TankSearchProps>(
modelDefinitions.models[a.id].turrets[a.turrets.at(-1)!.id] modelDefinitions.models[a.id].turrets[a.turrets.at(-1)!.id]
.guns[a.turrets.at(-1)!.guns.at(-1)!.id].pitch.min - .guns[a.turrets.at(-1)!.guns.at(-1)!.id].pitch.min -
(modelDefinitions.models[a.id].initial_turret_rotation?.pitch ?? (modelDefinitions.models[a.id].initial_turret_rotation?.pitch ??
0), 0)
); );
break; break;
case 'maneuverability.forwardsSpeed': case "maneuverability.forwardsSpeed":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => a.speed_forwards - b.speed_forwards, (a, b) => a.speed_forwards - b.speed_forwards
); );
break; break;
case 'maneuverability.backwardsSpeed': case "maneuverability.backwardsSpeed":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => a.speed_backwards - b.speed_backwards, (a, b) => a.speed_backwards - b.speed_backwards
); );
break; break;
case 'maneuverability.power': case "maneuverability.power":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => a.engines.at(-1)!.power - b.engines.at(-1)!.power, (a, b) => a.engines.at(-1)!.power - b.engines.at(-1)!.power
); );
break; break;
case 'maneuverability.powerToWeight': case "maneuverability.powerToWeight":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.engines.at(-1)!.power / a.engines.at(-1)!.power /
@@ -364,11 +363,11 @@ export const TankSearch = memo<TankSearchProps>(
b.engines.at(-1)!.weight + b.engines.at(-1)!.weight +
b.tracks.at(-1)!.weight + b.tracks.at(-1)!.weight +
b.turrets.at(-1)!.weight + b.turrets.at(-1)!.weight +
b.turrets.at(-1)!.guns.at(-1)!.weight), b.turrets.at(-1)!.guns.at(-1)!.weight)
); );
break; break;
case 'maneuverability.weight': case "maneuverability.weight":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.weight + a.weight +
@@ -380,20 +379,20 @@ export const TankSearch = memo<TankSearchProps>(
b.engines.at(-1)!.weight + b.engines.at(-1)!.weight +
b.tracks.at(-1)!.weight + b.tracks.at(-1)!.weight +
b.turrets.at(-1)!.weight + b.turrets.at(-1)!.weight +
b.turrets.at(-1)!.guns.at(-1)!.weight), b.turrets.at(-1)!.guns.at(-1)!.weight)
); );
break; break;
case 'maneuverability.traverseSpeed': case "maneuverability.traverseSpeed":
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
a.tracks.at(-1)!.traverse_speed - a.tracks.at(-1)!.traverse_speed -
b.tracks.at(-1)!.traverse_speed, b.tracks.at(-1)!.traverse_speed
); );
break; break;
} }
return tankopediaSort.direction === 'ascending' return tankopediaSort.direction === "ascending"
? sorted ? sorted
: sorted.reverse(); : sorted.reverse();
} else { } else {
@@ -401,7 +400,7 @@ export const TankSearch = memo<TankSearchProps>(
keys: SEARCH_KEYS, keys: SEARCH_KEYS,
}); });
const searchedTanks = searchedRaw.map( const searchedTanks = searchedRaw.map(
(result) => tankDefinitions.tanks[result.obj.id], (result) => tankDefinitions.tanks[result.obj.id]
); );
return searchedTanks; return searchedTanks;
} }
@@ -459,24 +458,24 @@ export const TankSearch = memo<TankSearchProps>(
)} )}
</Flex> </Flex>
{tankopediaSort.by !== 'meta.none' && ( {tankopediaSort.by !== "meta.none" && (
<Text color="gray"> <Text color="gray">
{literals(strings.website.common.tank_search.sorting_by, [ {literals(strings.website.common.tank_search.sorting_by, [
strings.website.common.tank_search.sort[tankopediaSort.by], strings.website.common.tank_search.sort[tankopediaSort.by],
])} ])}
{SORT_UNITS[tankopediaSort.by] === undefined {SORT_UNITS[tankopediaSort.by] === undefined
? '' ? ""
: ` (${SORT_UNITS[tankopediaSort.by]})`} : ` (${SORT_UNITS[tankopediaSort.by]})`}
, {tankopediaSort.direction} , {tankopediaSort.direction}
</Text> </Text>
)} )}
</Flex> </Flex>
{tankFilters.testing === 'only' && ( {tankFilters.testing === "only" && (
<Flex justify="center" mt="4"> <Flex justify="center" mt="4">
<Callout.Root color="amber"> <Callout.Root color="amber">
<Callout.Icon> <Callout.Icon>
<ExperimentIcon style={{ width: '1em', height: '1em' }} /> <ExperimentIcon style={{ width: "1em", height: "1em" }} />
</Callout.Icon> </Callout.Icon>
<Callout.Text> <Callout.Text>
{strings.website.common.warnings.test} {strings.website.common.warnings.test}
@@ -505,12 +504,12 @@ export const TankSearch = memo<TankSearchProps>(
key={index} key={index}
onIntersection={() => { onIntersection={() => {
setLoadedCards((state) => setLoadedCards((state) =>
Math.min(state + 2, tanksFiltered.length), Math.min(state + 2, tanksFiltered.length)
); );
}} }}
/> />
); );
}, }
)} )}
</TankCardWrapper> </TankCardWrapper>
)} )}
@@ -523,15 +522,15 @@ export const TankSearch = memo<TankSearchProps>(
<TankCardWrapper> <TankCardWrapper>
{times( {times(
Math.round( Math.round(
skeleton ? DEFAULT_LOADED_CARDS : 10 + 10 * Math.random(), skeleton ? DEFAULT_LOADED_CARDS : 10 + 10 * Math.random()
), ),
(index) => ( (index) => (
<SkeletonTankCard key={index} /> <SkeletonTankCard key={index} />
), )
)} )}
</TankCardWrapper> </TankCardWrapper>
)} )}
</Flex> </Flex>
); );
}, }
); );
@@ -1,15 +1,14 @@
import { metaSortTank } from '@blitzkit/core'; import { metaSortTank } from "@blitzkit/core";
import { useStore } from '@nanostores/react'; import { times } from "lodash-es";
import { times } from 'lodash-es'; import { useMemo, useState } from "react";
import { useMemo, useState } from 'react'; import { awaitableGameDefinitions } from "../../core/awaitables/gameDefinitions";
import { awaitableGameDefinitions } from '../../core/awaitables/gameDefinitions'; import { awaitableTankDefinitions } from "../../core/awaitables/tankDefinitions";
import { awaitableTankDefinitions } from '../../core/awaitables/tankDefinitions'; import { filterTank } from "../../core/blitzkit/filterTank";
import { filterTank } from '../../core/blitzkit/filterTank'; import { TankFilters } from "../../stores/tankFilters";
import { $tankFilters } from '../../stores/tankFilters'; import { TierList } from "../../stores/tierList";
import { TierList } from '../../stores/tierList'; import { SkeletonTankCard } from "../TankSearch/components/SkeletonTankCard";
import { SkeletonTankCard } from '../TankSearch/components/SkeletonTankCard'; import { TankCardWrapper } from "../TankSearch/components/TankCardWrapper";
import { TankCardWrapper } from '../TankSearch/components/TankCardWrapper'; import { TierListTile } from "./Tile";
import { TierListTile } from './Tile';
const [tankDefinitions, gameDefinitions] = await Promise.all([ const [tankDefinitions, gameDefinitions] = await Promise.all([
awaitableTankDefinitions, awaitableTankDefinitions,
@@ -22,17 +21,17 @@ const PREVIEW_COUNT = 32;
const DEFAULT_LOADED_CARDS = 75; const DEFAULT_LOADED_CARDS = 75;
export function TierListTiles() { export function TierListTiles() {
const filters = useStore($tankFilters); const filters = TankFilters.use();
const placedTanks = TierList.use((state) => state.placedTanks); const placedTanks = TierList.use((state) => state.placedTanks);
const sorted = useMemo( const sorted = useMemo(
() => () =>
metaSortTank( metaSortTank(
tanks.filter( tanks.filter(
(tank) => filterTank(filters, tank) && !placedTanks.has(tank.id), (tank) => filterTank(filters, tank) && !placedTanks.has(tank.id)
), ),
gameDefinitions, gameDefinitions
).reverse(), ).reverse(),
[filters, placedTanks], [filters, placedTanks]
); );
const [loadedTiles, setLoadedTiles] = useState(DEFAULT_LOADED_CARDS); const [loadedTiles, setLoadedTiles] = useState(DEFAULT_LOADED_CARDS);
+6 -8
View File
@@ -3,8 +3,8 @@ import type {
ShellType, ShellType,
TankClass, TankClass,
TankType, TankType,
} from '@blitzkit/core'; } from "@blitzkit/core";
import { map } from 'nanostores'; import { Varuna } from "varuna";
export type CaseType<T> = T extends { export type CaseType<T> = T extends {
gun_type?: { $case: infer U; value: any }; gun_type?: { $case: infer U; value: any };
@@ -17,23 +17,21 @@ export interface TankFilters {
nations: string[]; nations: string[];
classes: TankClass[]; classes: TankClass[];
types: TankType[]; types: TankType[];
testing: 'include' | 'exclude' | 'only'; testing: "include" | "exclude" | "only";
search: string | null; search: string | null;
searching: boolean; searching: boolean;
gunType: CaseType<GunDefinition>[]; gunType: CaseType<GunDefinition>[];
shells: [ShellType | null, ShellType | null, ShellType | null]; shells: [ShellType | null, ShellType | null, ShellType | null];
} }
export const initialTankFilters: TankFilters = { export const TankFilters = new Varuna<TankFilters>({
tiers: [], tiers: [],
nations: [], nations: [],
classes: [], classes: [],
types: [], types: [],
testing: 'include', testing: "include",
search: null, search: null,
searching: false, searching: false,
gunType: [], gunType: [],
shells: [null, null, null], shells: [null, null, null],
}; });
export const $tankFilters = map<TankFilters>(initialTankFilters);