Compare commits

...
5 Commits
Author SHA1 Message Date
TrèsAbhi d6e37119a8 feat: search 2026-04-27 19:57:44 -05:00
TrèsAbhi 385cc3b6d2 feat: tier sorting 2026-04-27 19:34:06 -05:00
TrèsAbhi b4936bf13b feat: smaller tank icons 2026-04-27 14:43:49 -05:00
TrèsAbhiandCopilot e1e9403c60 fix: home pag credit
Co-authored-by: Copilot <[email protected]>
2026-04-27 14:41:00 -05:00
TrèsAbhiandCopilot 0828d50830 feat: less spamming api
Co-authored-by: Copilot <[email protected]>
2026-04-27 14:39:47 -05:00
25 changed files with 182 additions and 60 deletions

No files matched your search

+1
View File
@@ -67,6 +67,7 @@
"DEBUFF",
"debugflags",
"deburr",
"deburred",
"decalmask",
"decompressor",
"décor",
+6
View File
@@ -121,5 +121,11 @@
"no_docs": "This method has no documentation.",
"test": "Test",
"view_full": "View full types"
},
"tanks": {
"search": {
"placeholder": "Search tanks..."
}
}
}
@@ -43,13 +43,14 @@ import "@fontsource-variable/roboto-flex";
--weight-bold: 700;
--weight-black: 900;
--outline-interactive: var(--space-1) solid var(--gray-4);
--outline-focus: var(--space-1) solid var(--accent-6);
--shadow-cavity:
/* --shadow-cavity:
inset calc(var(--space-1) * sin(45deg)) calc(var(--space-1) * sin(45deg))
var(--space-1) var(--black-a3),
inset calc(var(--space-1) * sin(45deg) * -1)
calc(var(--space-1) * sin(45deg) * -1) var(--space-1) var(--white-a1);
calc(var(--space-1) * sin(45deg) * -1) var(--space-1) var(--white-a1); */
--shadow-bump:
inset calc(var(--space-1) * sin(45deg)) calc(var(--space-1) * sin(45deg))
@@ -60,7 +61,7 @@ import "@fontsource-variable/roboto-flex";
--shadow-outline-width: 1.5px;
--shadow-outline-width-negative: calc(var(--shadow-outline-width) * -1);
--shadow-interactive-background: 0 0 0 var(--shadow-outline-width)
/* --shadow-interactive-background: 0 0 0 var(--shadow-outline-width)
var(--gray-6);
--shadow-interactive-shine: var(--shadow-outline-width-negative)
var(--shadow-outline-width-negative) var(--shadow-outline-width)
@@ -69,7 +70,7 @@ import "@fontsource-variable/roboto-flex";
var(--shadow-outline-width) var(--shadow-outline-width) var(--black-a4);
--shadow-interactive:
var(--shadow-interactive-shine), var(--shadow-interactive-shadow),
var(--shadow-interactive-background);
var(--shadow-interactive-background); */
--shadow-aggressive: var(--space-1) var(--space-1) var(--space-4)
var(--black-a11);
@@ -82,6 +83,6 @@ import "@fontsource-variable/roboto-flex";
--duration-brief: 200ms;
--duration-subtle: 2s;
--tank-card-min-width: 11rem;
--tank-card-min-width: 8rem;
}
</style>
@@ -80,8 +80,12 @@ const strings = await api.strings(locale);
}
}
.name {
.credit {
position: relative;
:root.light & {
color: var(--black-a11) !important;
}
}
}
@@ -104,7 +108,7 @@ const strings = await api.strings(locale);
<Code className="version">2.0</Code>
</div>
<Text className="name" lowContrast>
<Text className="credit" lowContrast>
{strings.home.credit}
</Text>
</div>
@@ -4,6 +4,6 @@
.content {
display: flex;
gap: 2rem;
gap: var(--space-4);
}
}
@@ -6,7 +6,6 @@
.content {
flex: 1;
max-width: var(--page-width);
overflow: hidden;
display: flex;
flex-direction: column;
@@ -26,7 +26,7 @@
.icon {
min-width: var(--tank-card-min-width);
height: 7rem;
aspect-ratio: 4 / 3;
position: relative;
background-size: 140%;
@@ -0,0 +1,28 @@
import { useStrings } from "../hooks/useStrings";
import { TankFilters } from "../stores/tankFilters";
import { TextField } from "./TextField";
export function TankSearch() {
const strings = useStrings();
return (
<TextField
placeholder={strings.tanks.search.placeholder}
onChange={(event) => {
const trimmed = event.target.value.trim();
if (trimmed.length === 0) {
TankFilters.mutate((draft) => {
draft.search = undefined;
});
return;
}
TankFilters.mutate((draft) => {
draft.search = trimmed;
});
}}
/>
);
}
@@ -14,7 +14,7 @@
border-radius: var(--max-size);
background-color: var(--gray-1);
box-shadow: var(--shadow-interactive), var(--shadow-cavity);
outline: var(--outline-interactive);
input {
padding: var(--space-3) var(--space-4);
@@ -8,7 +8,7 @@ import {
type ReactNode,
} from "react";
import { classNames } from "../../core/ui/classNames";
import "./index.css";
import styles from "./index.module.css";
export interface TextFieldProps extends ComponentProps<"input"> {
children?: ReactNode;
@@ -51,7 +51,7 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
return (
<div
{...props}
className={classNames("text-field", className)}
className={classNames(styles["text-field"], className)}
data-has-icon={children !== undefined}
data-disabled={disabled}
data-variant={variant}
@@ -43,10 +43,6 @@ const toolStrings = strings.tools[tool.id as keyof typeof strings.tools];
background-size: cover;
background-position: center;
:root.light & {
filter: brightness(1.25);
}
.gradient {
width: 100%;
height: 100%;
@@ -62,10 +58,6 @@ const toolStrings = strings.tools[tool.id as keyof typeof strings.tools];
backdrop-filter: blur(var(--space-6));
:root.light & {
backdrop-filter: blur(var(--space-6)) brightness(1.25);
}
.readable {
display: flex;
flex-direction: column;
@@ -9,6 +9,7 @@ import type { PopularTanks } from "@protos/blitzkit/popular_tanks";
import type { Tank } from "@protos/blitzkit/tank";
import type { TankList } from "@protos/blitzkit/tank_list";
import type { Tanks } from "@protos/blitzkit/tanks";
import type { Tiers } from "@protos/blitzkit/tiers";
export abstract class AbstractAPI {
abstract tankList(): Promise<TankList>;
@@ -16,6 +17,8 @@ export abstract class AbstractAPI {
abstract tanks(): Promise<Tanks>;
abstract popularTanks(): Promise<PopularTanks>;
abstract tiers(): Promise<Tiers>;
abstract strings(locale: string): Promise<Strings>;
abstract gameStrings(locale: string): Promise<Record<string, string>>;
protected abstract _groupedGameStrings(
+12 -5
View File
@@ -2,8 +2,9 @@ import { fetchPB } from "@blitzkit/core";
import type { Strings } from "@blitzkit/i18n";
import { Avatars } from "@protos/blitzkit/avatars";
import { PopularTanks } from "@protos/blitzkit/popular_tanks";
import { Tank } from "@protos/blitzkit/tank";
import { TankList } from "@protos/blitzkit/tank_list";
import { Tanks } from "@protos/blitzkit/tanks";
import { Tiers } from "@protos/blitzkit/tiers";
import { AbstractAPI, Cache } from "./abstract";
const rejected = Promise.reject("Not implemented on client");
@@ -15,13 +16,14 @@ export class ClientAPI extends AbstractAPI {
}
@Cache()
tank(id: string) {
return fetchPB(`/api/tanks/${id}.pb`, Tank);
async tank(id: string) {
const tanks = await this.tanks();
return tanks.tanks[id];
}
@UnimplementedOnClient
@Cache()
tanks() {
return rejected;
return fetchPB("/api/tanks/all.pb", Tanks);
}
@Cache()
@@ -29,6 +31,11 @@ export class ClientAPI extends AbstractAPI {
return fetchPB("/api/tanks/popular.pb", PopularTanks);
}
@Cache()
tiers() {
return fetchPB("/api/tiers.pb", Tiers);
}
@Cache()
async strings(locale: string) {
const response = await fetch(`/api/strings/${locale}.json`);
@@ -8,6 +8,7 @@ import type { PopularTanks } from "@protos/blitzkit/popular_tanks";
import type { Tank } from "@protos/blitzkit/tank";
import type { TankListEntry } from "@protos/blitzkit/tank_list";
import type { Tanks } from "@protos/blitzkit/tanks";
import type { Tiers } from "@protos/blitzkit/tiers";
import type { RemoteStorageComponent } from "@protos/game/proto/legacy/blitz_static_remote_storage_component";
import { existsSync } from "fs";
import { mkdir, readFile, writeFile } from "fs/promises";
@@ -173,6 +174,18 @@ export class ServerAPI extends AbstractAPI {
return JSON.parse(data) as PopularTanks;
}
@Cache()
async tiers() {
const group = this.metadata.group("TankTierEntity");
const tiers: Tiers = { tiers: {} };
for (const item of group) {
tiers.tiers[item.name] = item.TankTier();
}
return tiers;
}
@Cache()
async strings(locale: string) {
const localized = (await globbedStrings[
@@ -0,0 +1,17 @@
import { deburr } from "lodash-es";
import { api } from "../core/api/dynamic";
import { useAwait } from "./useAwait";
import { useGameStrings } from "./useGameStrings";
export function useSearchableTanks() {
const gameStrings = useGameStrings("TankEntity");
const tanks = useAwait(() => api.tanks(), "tanks");
return Object.values(tanks.tanks).map((tank) => {
const { id } = tank;
const name = gameStrings[tank.tank!.name!.value];
const deburred = deburr(name);
return { id, name, deburred };
});
}
@@ -0,0 +1,17 @@
import { api } from "../core/api/dynamic";
import { useAwait } from "./useAwait";
export function useTierCompare() {
const tiers = useAwait(() => api.tiers(), "tiers");
function compare(a: string, b: string) {
const [, tierNameA] = a.split(".");
const [, tierNameB] = b.split(".");
const tierA = tiers.tiers[tierNameA];
const tierB = tiers.tiers[tierNameB];
return tierB.carousel_level - tierA.carousel_level;
}
return compare;
}
@@ -1,8 +1,16 @@
import fuzzysort from "fuzzysort";
import { useMemo } from "react";
import { TankCard } from "../../../components/TankCard";
import { TankCards } from "../../../components/TankCards";
import { TankSearch } from "../../../components/TankSearch";
import { api } from "../../../core/api/dynamic";
import { useAwait } from "../../../hooks/useAwait";
import { LocaleProvider } from "../../../hooks/useLocale";
import { useSearchableTanks } from "../../../hooks/useSearchableTanks";
import { useTierCompare } from "../../../hooks/useTierCompare";
import { TankFilters } from "../../../stores/tankFilters";
const { go } = fuzzysort;
interface PageProps {
locale: string;
@@ -17,13 +25,39 @@ export function Page({ locale, ...props }: PageProps) {
}
function Content() {
const tankList = useAwait(() => api.tankList(), "tank-list");
const tanks = useAwait(() => api.tanks(), "tanks");
const compareTiers = useTierCompare();
const searchableTanks = useSearchableTanks();
const search = TankFilters.use((state) => state.search);
const tanksOrdered = useMemo(() => {
let list = Object.values(tanks.tanks);
list = list.sort((a, b) =>
compareTiers(a.tank!.tier_catalog_id, b.tank!.tier_catalog_id),
);
return list;
}, []);
const tanksFiltered = useMemo(() => {
if (!search) return tanksOrdered;
const results = go(search, searchableTanks, {
keys: ["id", "name", "deburred"],
});
return results.map(({ obj }) => obj);
}, [search]);
return (
<TankCards>
{tankList.list.slice(0, 32).map(({ id }) => (
<TankCard key={id} id={id} />
))}
</TankCards>
<>
<TankSearch />
<TankCards>
{tanksFiltered.slice(0, 32).map(({ id }) => (
<TankCard key={id} id={id} />
))}
</TankCards>
</>
);
}
@@ -2,7 +2,6 @@
import "@radix-ui/colors/violet-dark.css";
import { Section } from "../../../components/Section";
import ToolBanner from "../../../components/ToolBanner.astro";
import { api } from "../../../core/api/dynamic";
import { tools } from "../../../core/tools/tools";
import BaseLayout from "../../../layouts/BaseLayout.astro";
import { Page } from "./_index";
@@ -10,7 +9,6 @@ import { Page } from "./_index";
export { getStaticPaths } from "../index.astro";
const { locale } = Astro.props;
const tankList = await api.tankList();
---
<BaseLayout {locale} accent="purple">
@@ -19,8 +17,4 @@ const tankList = await api.tankList();
<Section>
<Page {locale} client:load />
</Section>
<!-- {
} -->
</BaseLayout>
@@ -1,7 +1,10 @@
import type { APIContext } from "astro";
import type { APIContext, GetStaticPaths } from "astro";
import { api } from "../../../core/api/dynamic";
export { getStaticPaths } from "./[id].pb";
export const getStaticPaths = (async () => {
const { list } = await api.tankList();
return list.map(({ id }) => ({ params: { id } }));
}) satisfies GetStaticPaths;
/**
* Even Noah can't save these animals.
@@ -1,16 +0,0 @@
import { Tank } from "@protos/blitzkit/tank";
import type { APIContext, GetStaticPaths } from "astro";
import { api } from "../../../core/api/dynamic";
export const getStaticPaths = (async () => {
const { list } = await api.tankList();
return list.map(({ id }) => ({ params: { id } }));
}) satisfies GetStaticPaths;
/**
* Even Noah can't save these animals.
*/
export async function GET({ params }: APIContext<never, { id: string }>) {
const tank = await api.tank(params.id);
return new Response(Tank.encode(tank).finish());
}
@@ -2,7 +2,7 @@ import type { APIContext } from "astro";
import { api } from "../../../core/api/dynamic";
import { game } from "../../../core/game/game";
export { getStaticPaths } from "./[id].pb";
export { getStaticPaths } from "./[id].json";
export async function GET({ params }: APIContext<never, { id: string }>) {
const tank = await api.tank(params.id);
@@ -0,0 +1,10 @@
import { Tiers } from "@protos/blitzkit/tiers";
import { api } from "../../core/api/dynamic";
/**
* Woah!
*/
export async function GET() {
const tiers = await api.tiers();
return new Response(Tiers.encode(tiers).finish());
}
@@ -0,0 +1,9 @@
import { Varuna } from "varuna";
interface TankFilters {
search?: string;
}
export const TankFilters = new Varuna<TankFilters>({
search: undefined,
});
+2 -2
View File
@@ -1,4 +1,4 @@
import type { TankCatalogComponent } from "@protos/blitz_static_tank_component";
import type { TankCatalogComponent } from "@protos/game/proto/legacy/blitz_static_tank_component";
import { Varuna } from "varuna";
import { createTankState } from "../core/tankopedia/createTankState";
import { type TankState } from "../core/tankopedia/tankState";
@@ -18,5 +18,5 @@ export const Tankopedia = new Varuna<Tankopedia, TankCatalogComponent>(
(tank) => ({
compare: TankopediaCompare.TierAndClass,
protagonist: createTankState(tank),
})
}),
);