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",
});