From 9412be2d7c9b0571f48aa0b27e4deb6e494281d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=C3=A8sAbhi?= <43380238+tresabhi@users.noreply.github.com> Date: Sat, 4 Oct 2025 21:35:12 -0500 Subject: [PATCH] feat: filter owned tanks --- packages/i18n/strings/en.json | 1 + packages/website/src/components/Navbar.astro | 5 +- .../src/components/NavbarAccount/index.tsx | 14 +- .../TankSearch/components/FilterControl.tsx | 630 +++++++++--------- .../src/components/TankSearch/index.tsx | 60 +- .../website/src/core/blitzkit/filterTank.ts | 39 +- .../website/src/core/blitzkit/filterTanks.ts | 26 + packages/website/src/hooks/useAwait.ts | 10 - packages/website/src/stores/app/index.ts | 2 +- packages/website/src/stores/tankFilters.ts | 2 + 10 files changed, 435 insertions(+), 354 deletions(-) create mode 100644 packages/website/src/core/blitzkit/filterTanks.ts delete mode 100644 packages/website/src/hooks/useAwait.ts diff --git a/packages/i18n/strings/en.json b/packages/i18n/strings/en.json index 4fb891092..71571ba75 100644 --- a/packages/i18n/strings/en.json +++ b/packages/i18n/strings/en.json @@ -750,6 +750,7 @@ "select_dialog_title": "Search for tanks", "swap_dialog_title": "Swap tank", "clear_filters": "Clear filters", + "login": "Login to use this", "search_bar_hint": "Search tanks...", "recent": "Recently viewed", "select_all": "Select all", diff --git a/packages/website/src/components/Navbar.astro b/packages/website/src/components/Navbar.astro index becb4cb59..f445ea65a 100644 --- a/packages/website/src/components/Navbar.astro +++ b/packages/website/src/components/Navbar.astro @@ -11,6 +11,7 @@ import { getStrings } from "../core/i18n/getStrings"; import { Var } from "../core/radix/var"; import { LinkI18n } from "./LinkI18n"; import { tools } from "../constants/tools"; +import { NavbarAccount } from "./NavbarAccount"; export interface Props { integrated?: boolean; @@ -182,7 +183,7 @@ const strings = getStrings(locale); - + @@ -200,7 +201,7 @@ const strings = getStrings(locale); display={{ initial: "flex", sm: "none" }} > { - toolsFiltered.map(( tool) => { + toolsFiltered.map((tool) => { const toolStrings = strings.website.tools[ (tool.strings ?? tool.id) as keyof typeof strings.website.tools diff --git a/packages/website/src/components/NavbarAccount/index.tsx b/packages/website/src/components/NavbarAccount/index.tsx index d150be2dd..d649cdc4a 100644 --- a/packages/website/src/components/NavbarAccount/index.tsx +++ b/packages/website/src/components/NavbarAccount/index.tsx @@ -8,13 +8,25 @@ import { Text, } from "@radix-ui/themes"; import { Suspense } from "react"; +import { + LocaleProvider, + type LocaleAcceptorProps, +} from "../../hooks/useLocale"; import { App } from "../../stores/app"; import { BlitzKitTheme } from "../BlitzKitTheme"; import { WargamingAccountName } from "../WargamingAccountName"; import { WargamingIcon } from "../WargamingIcon"; import { WargamingLoginButton } from "../WargamingLoginButton"; -export function NavbarAccount() { +export function NavbarAccount({ locale }: LocaleAcceptorProps) { + return ( + + + + ); +} + +function Content() { const logins = App.use((state) => state.logins); return ( diff --git a/packages/website/src/components/TankSearch/components/FilterControl.tsx b/packages/website/src/components/TankSearch/components/FilterControl.tsx index 805172f92..175e96003 100644 --- a/packages/website/src/components/TankSearch/components/FilterControl.tsx +++ b/packages/website/src/components/TankSearch/components/FilterControl.tsx @@ -5,6 +5,11 @@ import { TankType, TIER_ROMAN_NUMERALS, } from "@blitzkit/core"; +import { + ComponentBooleanIcon, + LockClosedIcon, + LockOpen2Icon, +} from "@radix-ui/react-icons"; import { Box, Flex, @@ -13,15 +18,17 @@ import { Link, Popover, Text, + Tooltip, + type FlexProps, } from "@radix-ui/themes"; import { isEqual, times } from "lodash-es"; import { awaitableGameDefinitions } from "../../../core/awaitables/gameDefinitions"; import { Var } from "../../../core/radix/var"; import { useLocale } from "../../../hooks/useLocale"; +import { App } from "../../../stores/app"; import { TankFilters } from "../../../stores/tankFilters"; import { TankSort } from "../../../stores/tankopediaSort"; import { classIcons } from "../../ClassIcon"; -import { ExperimentIcon } from "../../ExperimentIcon"; import { GunAutoloaderIcon } from "../../GunAutoloaderIcon"; import { GunAutoreloaderIcon } from "../../GunAutoreloaderIcon"; import { GunRegularIcon } from "../../GunRegularIcon"; @@ -108,28 +115,80 @@ function ShellFilter({ index, premium }: { index: number; premium?: boolean }) { ); } -export function FilterControl({ compact }: FilterControlProps) { +function OwnershipFilter(props: FlexProps) { + const tankFilters = TankFilters.use(); + const wargaming = App.use((state) => state.logins.wargaming); + + return ( + + { + TankFilters.mutate((draft) => { + draft.ownership = "all"; + }); + }} + > + + + + { + TankFilters.mutate((draft) => { + draft.ownership = "owned"; + }); + }} + > + + + + { + TankFilters.mutate((draft) => { + draft.ownership = "unowned"; + }); + }} + > + + + + ); +} + +export function FilterControl() { const tankFilters = TankFilters.use(); const { strings } = useLocale(); const clearable = !isEqual(tankFilters, TankFilters.initial); + const wargaming = App.use((state) => state.logins.wargaming); return ( - + - + {times(5, (index) => { const tier = index + 1; const selected = tankFilters.tiers?.includes(tier); @@ -167,9 +226,7 @@ export function FilterControl({ compact }: FilterControlProps) { }} /> - + {times(5, (index) => { const tier = index + 6; const selected = tankFilters.tiers?.includes(tier); @@ -202,13 +259,10 @@ export function FilterControl({ compact }: FilterControlProps) { - + {gameDefinitions.nations .slice(0, Math.ceil(gameDefinitions.nations.length / 2)) .map((nation) => { @@ -250,9 +304,7 @@ export function FilterControl({ compact }: FilterControlProps) { }} /> - + {gameDefinitions.nations .slice(Math.ceil(gameDefinitions.nations.length / 2)) .map((nation) => { @@ -289,298 +341,274 @@ export function FilterControl({ compact }: FilterControlProps) { - - - {TANK_CLASSES.map((tankClass) => { - const Icon = classIcons[tankClass]; - const selected = tankFilters.classes?.includes(tankClass); + {TANK_CLASSES.map((tankClass) => { + const Icon = classIcons[tankClass]; + const selected = tankFilters.classes?.includes(tankClass); - return ( - { - TankFilters.mutate((draft) => { - if (tankFilters.classes.includes(tankClass)) { - draft.classes = draft.classes.filter( - (c) => c !== tankClass - ); - } else { - draft.classes = [...draft.classes, tankClass]; - } - }); - }} - > - - - ); - })} - - - + return ( { TankFilters.mutate((draft) => { - if (tankFilters.gunType.includes("regular")) { - draft.gunType = draft.gunType.filter( - (t) => t !== "regular" + if (tankFilters.classes.includes(tankClass)) { + draft.classes = draft.classes.filter( + (c) => c !== tankClass ); } else { - draft.gunType = [...draft.gunType, "regular"]; + draft.classes = [...draft.classes, tankClass]; } }); }} > - + - { - 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"]; - } - }); - }} - > - - - { - 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"]; - } - }); - }} - > - - - - - - - - - - - - - - { - TankFilters.mutate((draft) => { - if (tankFilters.types.includes(TankType.RESEARCHABLE)) { - draft.types = draft.types.filter( - (t) => t !== TankType.RESEARCHABLE - ); - } else { - draft.types = [...draft.types, TankType.RESEARCHABLE]; - } - }); - }} - > - - - { - 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]; - } - }); - }} - > - - - - - { - 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]; - } - }); - }} - > - - - - - - - - { - TankFilters.mutate((draft) => { - draft.testing = "include"; - }); - }} - > - - - { - TankFilters.mutate((draft) => { - draft.testing = "exclude"; - }); - }} - > - - - { - TankFilters.mutate((draft) => { - draft.testing = "only"; - }); - }} - > - - - - + ); + })} + + + { + TankFilters.mutate((draft) => { + if (tankFilters.gunType.includes("regular")) { + draft.gunType = draft.gunType.filter((t) => t !== "regular"); + } else { + draft.gunType = [...draft.gunType, "regular"]; + } + }); + }} + > + + + { + 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"]; + } + }); + }} + > + + + { + 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"]; + } + }); + }} + > + + + + + + + + + + + + { + TankFilters.mutate((draft) => { + if (tankFilters.types.includes(TankType.RESEARCHABLE)) { + draft.types = draft.types.filter( + (t) => t !== TankType.RESEARCHABLE + ); + } else { + draft.types = [...draft.types, TankType.RESEARCHABLE]; + } + }); + }} + > + + + { + 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]; + } + }); + }} + > + + + + + { + 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]; + } + }); + }} + > + + + + + + + + { + TankFilters.mutate((draft) => { + draft.testing = "include"; + }); + }} + > + + + { + TankFilters.mutate((draft) => { + draft.testing = "exclude"; + }); + }} + > + + + { + TankFilters.mutate((draft) => { + draft.testing = "only"; + }); + }} + > + + + + + {wargaming ? ( + + ) : ( + + + + )} {clearable && ( diff --git a/packages/website/src/components/TankSearch/index.tsx b/packages/website/src/components/TankSearch/index.tsx index 1d58f5d6b..a2a819ac1 100644 --- a/packages/website/src/components/TankSearch/index.tsx +++ b/packages/website/src/components/TankSearch/index.tsx @@ -11,13 +11,15 @@ import { Callout, Flex, Link, Text, type FlexProps } from "@radix-ui/themes"; import fuzzysort from "fuzzysort"; import { times, uniq } from "lodash-es"; import { memo, useEffect, useMemo, useState } from "react"; +import usePromise from "react-promise-suspense"; import { awaitableGameDefinitions } from "../../core/awaitables/gameDefinitions"; import { awaitableModelDefinitions } from "../../core/awaitables/modelDefinitions"; import { awaitableTankDefinitions } from "../../core/awaitables/tankDefinitions"; import { awaitableTankNames } from "../../core/awaitables/tankNames"; -import { filterTank } from "../../core/blitzkit/filterTank"; +import { filterTanks } from "../../core/blitzkit/filterTanks"; import { resolveReload } from "../../core/blitzkit/resolveReload"; import { useLocale } from "../../hooks/useLocale"; +import { App } from "../../stores/app"; import { TankFilters } from "../../stores/tankFilters"; import { TankopediaPersistent } from "../../stores/tankopediaPersistent"; import { SORT_UNITS } from "../../stores/tankopediaPersistent/constants"; @@ -54,15 +56,34 @@ const [gameDefinitions, modelDefinitions, tankDefinitions, tankNames] = export const TankSearch = memo( ({ compact, onSelect, onSelectAll, skeleton, ...props }) => { const { strings, locale } = useLocale(); - + const wargaming = App.use((state) => state.logins.wargaming); const awaitedTanksDefinitionsArray = Object.values(tankDefinitions.tanks); const tankopediaSort = TankSort.use(); const tankFilters = TankFilters.use(); - const tanksFiltered = useMemo(() => { + + const filtered = usePromise( + () => { + if (tankFilters.search === null) { + return filterTanks( + tankFilters, + awaitedTanksDefinitionsArray, + wargaming?.id + ); + } else { + return Promise.resolve( + fuzzysort + .go(tankFilters.search, tankNames, { + keys: SEARCH_KEYS, + }) + .map((result) => tankDefinitions.tanks[result.obj.id]) + ); + } + }, + // react-promise-suspense has awful type annotations + [tankFilters] as any + ); + const sorted = useMemo(() => { if (tankFilters.search === null) { - const filtered = awaitedTanksDefinitionsArray.filter((tank) => - filterTank(tankFilters, tank) - ); let sorted: TankDefinition[]; switch (tankopediaSort.by) { @@ -396,17 +417,12 @@ export const TankSearch = memo( ? sorted : sorted.reverse(); } else { - const searchedRaw = fuzzysort.go(tankFilters.search, tankNames, { - keys: SEARCH_KEYS, - }); - const searchedTanks = searchedRaw.map( - (result) => tankDefinitions.tanks[result.obj.id] - ); - return searchedTanks; + return filtered; } }, [tankFilters, tankopediaSort]); + const [loadedCards, setLoadedCards] = useState(DEFAULT_LOADED_CARDS); - const tanks = tanksFiltered.slice(0, loadedCards); + const tanks = sorted.slice(0, loadedCards); useEffect(() => { setLoadedCards(DEFAULT_LOADED_CARDS); @@ -420,19 +436,17 @@ export const TankSearch = memo( onSelect={onSelect} /> - {!tankFilters.search && !tankFilters.searching && ( - - )} + {!tankFilters.search && !tankFilters.searching && } {!skeleton && !compact && } - {tanksFiltered.length === 1 + {sorted.length === 1 ? strings.website.common.tank_search.count_singular : literals(strings.website.common.tank_search.count_plural, { - count: tanksFiltered.length.toLocaleString(locale), + count: sorted.length.toLocaleString(locale), })} @@ -442,10 +456,10 @@ export const TankSearch = memo( href="#" onClick={(event) => { event.preventDefault(); - onSelectAll(tanksFiltered); + onSelectAll(sorted); TankopediaPersistent.mutate((draft) => { draft.recentlyViewed = uniq([ - ...tanksFiltered.map(({ id }) => id), + ...sorted.map(({ id }) => id), ...draft.recentlyViewed, ]) .filter((id) => id in tankDefinitions.tanks) @@ -499,14 +513,14 @@ export const TankSearch = memo( ))} {times( - Math.min(PREVIEW_COUNT, tanksFiltered.length - loadedCards), + Math.min(PREVIEW_COUNT, sorted.length - loadedCards), (index) => { return ( { setLoadedCards((state) => - Math.min(state + 2, tanksFiltered.length) + Math.min(state + 2, sorted.length) ); }} /> diff --git a/packages/website/src/core/blitzkit/filterTank.ts b/packages/website/src/core/blitzkit/filterTank.ts index 86e90c3c5..69ccf6660 100644 --- a/packages/website/src/core/blitzkit/filterTank.ts +++ b/packages/website/src/core/blitzkit/filterTank.ts @@ -1,30 +1,37 @@ -import type { TankDefinition } from '@blitzkit/core'; -import { times } from 'lodash-es'; -import type { TankFilters } from '../../stores/tankFilters'; +import type { TankDefinition } from "@blitzkit/core"; +import { times } from "lodash-es"; +import type { TankFilters } from "../../stores/tankFilters"; const SHELLS = times(3, (index) => index); -export function filterTank(filters: TankFilters, tank: TankDefinition) { +export async function filterTank( + filters: TankFilters, + tank: TankDefinition, + owned: number[] = [] +) { return ( + (filters.ownership === "all" || + (filters.ownership === "owned" && owned.includes(tank.id)) || + (filters.ownership === "unowned" && !owned.includes(tank.id))) && (filters.tiers.length === 0 || filters.tiers.includes(tank.tier)) && (filters.nations.length === 0 || filters.nations.includes(tank.nation)) && (filters.classes.length === 0 || filters.classes.includes(tank.class)) && (filters.types.length === 0 || filters.types.includes(tank.type)) && - (filters.testing === 'include' || - (filters.testing === 'only' && tank.testing) || - (filters.testing === 'exclude' && !tank.testing)) && + (filters.testing === "include" || + (filters.testing === "only" && tank.testing) || + (filters.testing === "exclude" && !tank.testing)) && (filters.gunType.length === 0 || - (filters.gunType.includes('regular') && + (filters.gunType.includes("regular") && tank.turrets.some((turret) => - turret.guns.some((gun) => gun.gun_type!.$case === 'regular'), + turret.guns.some((gun) => gun.gun_type!.$case === "regular") )) || - (filters.gunType.includes('auto_loader') && + (filters.gunType.includes("auto_loader") && tank.turrets.some((turret) => - turret.guns.some((gun) => gun.gun_type!.$case === 'auto_loader'), + turret.guns.some((gun) => gun.gun_type!.$case === "auto_loader") )) || - (filters.gunType.includes('auto_reloader') && + (filters.gunType.includes("auto_reloader") && tank.turrets.some((turret) => - turret.guns.some((gun) => gun.gun_type!.$case === 'auto_reloader'), + turret.guns.some((gun) => gun.gun_type!.$case === "auto_reloader") ))) && tank.turrets.some((turret) => turret.guns.some((gun) => @@ -32,9 +39,9 @@ export function filterTank(filters: TankFilters, tank: TankDefinition) { (index) => filters.shells[index] === null || gun.shells[index] === undefined || - gun.shells[index].type === filters.shells[index], - ), - ), + gun.shells[index].type === filters.shells[index] + ) + ) ) ); } diff --git a/packages/website/src/core/blitzkit/filterTanks.ts b/packages/website/src/core/blitzkit/filterTanks.ts new file mode 100644 index 000000000..2ae934349 --- /dev/null +++ b/packages/website/src/core/blitzkit/filterTanks.ts @@ -0,0 +1,26 @@ +import { getTankStats, idToRegion, TankDefinition } from "@blitzkit/core"; +import { TankFilters } from "../../stores/tankFilters"; +import { filterTank } from "./filterTank"; + +export async function filterTanks( + filters: TankFilters, + tanks: TankDefinition[], + player?: number +) { + const filtered: TankDefinition[] = []; + let owned: number[] | undefined; + + if (player) { + const tankStats = await getTankStats(idToRegion(player), player, { + fields: "tank_id", + }); + + owned = tankStats?.map(({ tank_id }) => tank_id); + } + + for (const tank of tanks) { + if (await filterTank(filters, tank, owned)) filtered.push(tank); + } + + return filtered; +} diff --git a/packages/website/src/hooks/useAwait.ts b/packages/website/src/hooks/useAwait.ts deleted file mode 100644 index d7a8666c7..000000000 --- a/packages/website/src/hooks/useAwait.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { useRef } from 'react'; -import usePromise from 'react-promise-suspense'; - -/** - * A more stable alternative to `use` using `usePromise`. - */ -export function useAwait(promise: Promise) { - const callback = useRef(async () => await promise); - return usePromise(callback.current, []); -} diff --git a/packages/website/src/stores/app/index.ts b/packages/website/src/stores/app/index.ts index bb441ef0a..6b15394f2 100644 --- a/packages/website/src/stores/app/index.ts +++ b/packages/website/src/stores/app/index.ts @@ -6,7 +6,7 @@ export interface WargamingLogin { expires: number; } -interface App { +export interface App { developerMode: boolean; policiesAgreementIndex: number; logins: { diff --git a/packages/website/src/stores/tankFilters.ts b/packages/website/src/stores/tankFilters.ts index 5968f6395..5cd11c2e3 100644 --- a/packages/website/src/stores/tankFilters.ts +++ b/packages/website/src/stores/tankFilters.ts @@ -22,6 +22,7 @@ export interface TankFilters { searching: boolean; gunType: CaseType[]; shells: [ShellType | null, ShellType | null, ShellType | null]; + ownership: "all" | "owned" | "unowned"; } export const TankFilters = new Varuna({ @@ -34,4 +35,5 @@ export const TankFilters = new Varuna({ searching: false, gunType: [], shells: [null, null, null], + ownership: "all", });