Compare commits

...
8 Commits
Author SHA1 Message Date
TrèsAbhi dd96556e2a Change Section import to named import 2026-01-15 14:43:34 -06:00
TrèsAbhi a7ee51526a fix: reset count on change 2026-01-15 14:36:52 -06:00
TrèsAbhi 610b59f4c6 feat: search functionality 2026-01-15 14:30:40 -06:00
TrèsAbhi 63ce327458 feat: avatars search bar ui 2026-01-15 14:13:34 -06:00
TrèsAbhi 6ec7a50320 feat: lint fixes 2026-01-15 13:58:34 -06:00
TrèsAbhi 60a29ef2e7 feat: fancier navbar 2026-01-15 13:48:37 -06:00
TrèsAbhi 52c3a0abe0 feat: number indexed 2026-01-15 12:58:05 -06:00
TrèsAbhi 5bbe5c2720 feat: count badge 2026-01-15 12:55:18 -06:00
33 changed files with 367 additions and 117 deletions

No files matched your search

+6 -1
View File
@@ -16,7 +16,8 @@
"t": "{value}t",
"bhp_t": "{value}bhp/t",
"deg_s": "{value}°/s",
"%": "{value}%"
"%": "{value}%",
"x": "x{value}"
},
"tools": {
@@ -45,5 +46,9 @@
"home": {
"search": "Search tanks, players, tools...",
"credit": "by TrèsAbhi"
},
"avatars": {
"search": "Search avatars..."
}
}
@@ -0,0 +1,3 @@
<svg width="70" height="35" viewBox="0 0 70 35" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M70 32L64 35H0L70 0V32ZM0 32V0H64L0 32Z" fill="#EEEEF0"/>
</svg>

After

Width:  |  Height:  |  Size: 170 B

@@ -18,6 +18,12 @@
background-size: cover;
background-position: center;
}
.count {
position: absolute;
bottom: var(--space-3);
right: var(--space-3);
}
}
.avatar-label {
@@ -1,5 +1,8 @@
import { literals } from "@blitzkit/i18n";
import { useStrings } from "../../hooks/useStrings";
import type { Avatar } from "../../protos/avatar";
import type { SkeletonProps } from "../../types/SkeletonProps";
import { Badge } from "../Badge";
import { Text } from "../Text";
import "./index.css";
@@ -29,6 +32,7 @@ interface SeriesProps {
function Series({ avatars }: SeriesProps) {
const count = Math.min(avatars.length, MAX_CARDS);
const strings = useStrings();
return (
<div className="avatar-series">
@@ -45,6 +49,12 @@ function Series({ avatars }: SeriesProps) {
}}
/>
))}
{avatars.length > 1 && (
<Badge color="gray" highContrast className="count">
{literals(strings.units.x, { value: avatars.length })}
</Badge>
)}
</div>
);
}
@@ -0,0 +1,7 @@
.avatar-controls {
display: flex;
.search {
flex: 1;
}
}
@@ -0,0 +1,34 @@
import { MagnifyingGlassIcon } from "@radix-ui/react-icons";
import { useRef } from "react";
import { useStrings } from "../../hooks/useStrings";
import { Avatars } from "../../stores/avatars";
import { TextField } from "../TextField";
import "./index.css";
export function AvatarControls() {
const strings = useStrings();
const search = useRef<HTMLInputElement>(null);
return (
<div className="avatar-controls">
<TextField
ref={search}
className="search"
placeholder={strings.avatars.search}
onChange={(event) => {
const trimmed = event.target.value.trim();
Avatars.mutate((draft) => {
if (trimmed.length === 0) {
draft.search = null;
} else {
draft.search = trimmed;
}
});
}}
>
<MagnifyingGlassIcon />
</TextField>
</div>
);
}
@@ -0,0 +1,5 @@
.avatars-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--space-7), 1fr));
gap: var(--space-5) var(--space-4);
}
@@ -0,0 +1,85 @@
import fuzzysort from "fuzzysort";
import { useMemo } from "react";
import { api } from "../../core/api/dynamic";
import { useAwait } from "../../hooks/useAwait";
import { useGameStrings } from "../../hooks/useGameStrings";
import { useLocale } from "../../hooks/useLocale";
import type { Avatar } from "../../protos/avatar";
import { Avatars } from "../../stores/avatars";
import { AvatarCard } from "../AvatarCard";
import { IncrementalLoader } from "../IncrementalLoader";
import "./index.css";
const { go } = fuzzysort;
const indexingPattern = /.+_(\d+)$/;
export function AvatarsList() {
const locale = useLocale();
const search = Avatars.use((state) => state.search);
const { avatars } = useAwait(() => api.avatars(), "avatars");
const profileAvatarEntityStrings = useGameStrings("ProfileAvatarEntity");
const groups = useMemo(() => {
const groups = new Map<string, Avatar[]>();
for (const avatar of avatars) {
const name =
profileAvatarEntityStrings[avatar.stuff_ui!.display_name] ??
avatar.stuff_ui!.display_name;
if (groups.has(name)) {
groups.get(name)!.push(avatar);
} else {
groups.set(name, [avatar]);
}
}
return groups;
}, []);
const ordered = useMemo(() => {
const array = Array.from(groups.entries());
return array
.sort((a, b) => a[0].localeCompare(b[0], locale))
.map(([name, avatars]) => ({
key: name,
name,
avatars: avatars.sort((a, b) => {
const gradeA = a.stuff_ui!.grade;
const gradeB = b.stuff_ui!.grade;
let diff = gradeA - gradeB;
if (diff === 0) {
const matchesA = a.name.match(indexingPattern);
const matchesB = b.name.match(indexingPattern);
if (matchesA && matchesB) {
const indexA = Number(matchesA[1]);
const indexB = Number(matchesB[1]);
diff = indexA - indexB;
}
}
return diff;
}),
}));
}, []);
const filtered = useMemo(() => {
if (search === null) return ordered;
const searched = go(search, ordered, { keys: ["name"] });
return searched.map((result) => result.obj);
}, [search]);
return (
<div className="avatars-list">
<IncrementalLoader
initial={40}
intermediate={10}
data={filtered}
Component={AvatarCard}
/>
</div>
);
}
@@ -0,0 +1,9 @@
.badge {
border-radius: var(--max-size);
box-shadow: var(--shadow-aggressive);
padding: var(--space-2) var(--space-3);
font-size: var(--font-size-minor);
font-weight: var(--weight-medium);
}
@@ -0,0 +1,30 @@
import type { ComponentProps } from "react";
import { classNames } from "../../core/ui/classNames";
import type { Color } from "../../core/ui/color";
import "./index.css";
export interface BadgeProps extends ComponentProps<"div"> {
color?: Color | "accent";
highContrast?: boolean;
}
export function Badge({
className,
style,
color = "accent",
highContrast,
...props
}: BadgeProps) {
return (
<div
className={classNames("badge", className)}
style={{
backgroundColor: `var(--${color}-${highContrast ? 12 : 9})`,
color: `var(--gray-${highContrast ? 1 : 12})`,
...style,
}}
{...props}
/>
);
}
@@ -16,7 +16,7 @@ import "@fontsource-variable/roboto-flex";
--space-8: 16rem;
--space-9: 32rem;
--page-width: 52rem;
--page-width: 64rem;
--max-size: max(100vw, 100vh);
@@ -74,8 +74,11 @@ import "@fontsource-variable/roboto-flex";
--shadow-aggressive: var(--space-1) var(--space-1) var(--space-4)
var(--black-a11);
--shadow-subtle: 0 var(--space-1) var(--space-3) var(--black-a5);
--shadow-none: 0 0 0 transparent;
--blur-glass: blur(4rem);
--blur-tint: blur(16rem);
--blur-tint: blur(8rem);
--duration-brief: 200ms;
}
</style>
@@ -3,17 +3,17 @@ import { useEffect, useRef, useState, type ReactNode } from "react";
import type { SkeletonProps } from "../../types/SkeletonProps";
import "./index.css";
export interface IncrementalLoaderProps<Props> {
export interface IncrementalLoaderProps<Props extends { key: string }> {
initial: number;
intermediate: number;
rate?: number;
skeleton?: boolean;
data: Props[];
Component: (props: SkeletonProps<Props>) => ReactNode;
Component: (props: SkeletonProps<Omit<Props, "key">>) => ReactNode;
}
export function IncrementalLoader<Props>({
export function IncrementalLoader<Props extends { key: string }>({
initial,
rate = 2,
data,
@@ -24,10 +24,12 @@ export function IncrementalLoader<Props>({
const [count, setCount] = useState(initial);
const sliced = data.slice(0, count);
useEffect(() => setCount(initial), [data, initial]);
return (
<>
{!skeleton &&
sliced.map((item, index) => <Component key={index} {...item} />)}
sliced.map(({ key, ...props }) => <Component key={key} {...props} />)}
{times(
skeleton
@@ -11,7 +11,7 @@ const { locale } = Astro.props;
---
<style>
.navbar {
#navbar {
position: sticky;
top: 0;
left: 0;
@@ -21,7 +21,15 @@ const { locale } = Astro.props;
z-index: 1;
backdrop-filter: var(--blur-glass) brightness(25%);
box-shadow: var(--shadow-subtle);
background-color: var(--accent-2);
box-shadow: var(--shadow-none);
transition-duration: var(--duration-brief);
&[data-sticky="true"] {
background-color: var(--gray-a2);
box-shadow: var(--shadow-subtle);
}
.content {
flex: 1;
@@ -55,7 +63,18 @@ const { locale } = Astro.props;
}
</style>
<div class="navbar">
<script>
const navbar = document.getElementById("navbar")!;
function handleScroll() {
navbar.dataset.sticky = `${window.scrollY > 0}`;
}
handleScroll();
window.addEventListener("scroll", handleScroll);
</script>
<div id="navbar">
<div class="content">
<div class="side">
<LinkI18n {locale} href="/" className="action" tabIndex={0}>
@@ -1,22 +0,0 @@
<style>
.section {
display: flex;
justify-content: center;
padding: 0 var(--space-4);
.content {
flex: 1;
max-width: var(--page-width);
display: flex;
flex-direction: column;
gap: var(--space-4);
}
}
</style>
<div class="section">
<div class="content">
<slot />
</div>
</div>
@@ -0,0 +1,14 @@
.section {
display: flex;
justify-content: center;
padding: 0 var(--space-4);
.content {
flex: 1;
max-width: var(--page-width);
display: flex;
flex-direction: column;
gap: var(--space-4);
}
}
@@ -0,0 +1,15 @@
import type { ComponentProps } from "react";
import { classNames } from "../../core/ui/classNames";
import "./index.css";
export function Section({
className,
children,
...props
}: ComponentProps<"div">) {
return (
<div className={classNames("section", className)} {...props}>
<div className="content">{children}</div>
</div>
);
}
@@ -4,7 +4,7 @@ import type { Color } from "../../core/ui/color";
import "./index.css";
export interface TextProps extends ComponentProps<"span"> {
color?: Color;
color?: Color | "accent";
weight?: "light" | "regular" | "medium" | "bold" | "black";
lowContrast?: boolean;
size?: "regular" | "minor" | "major";
@@ -4,7 +4,6 @@
box-sizing: border-box;
gap: var(--space-3);
margin: 0 var(--space-4);
border-radius: var(--max-size);
@@ -16,6 +15,10 @@
&[data-has-icon="true"] {
padding-left: var(--space-3);
input {
padding-left: 0;
}
}
&:focus-within {
@@ -38,6 +41,7 @@
font-size: inherit;
font-weight: medium;
padding: var(--space-3) 0;
padding-left: var(--space-4);
color: var(--gray-12);
@@ -15,8 +15,9 @@ const toolStrings = strings.tools[tool.id as keyof typeof strings.tools];
---
<style>
.tool-hero {
.tool-banner {
background-size: cover;
margin-bottom: calc(var(--space-5) - var(--space-4));
.content {
width: 100%;
@@ -30,6 +31,10 @@ const toolStrings = strings.tools[tool.id as keyof typeof strings.tools];
backdrop-filter: var(--blur-tint);
@media (width > 40rem) {
padding: var(--space-6) var(--space-4);
}
.centered {
flex: 1;
max-width: var(--page-width);
@@ -45,7 +50,6 @@ const toolStrings = strings.tools[tool.id as keyof typeof strings.tools];
width: 100%;
height: 100%;
background-image: url(/assets/patterns/avatars.svg);
mask-image: linear-gradient(
90deg,
transparent 25%,
@@ -60,14 +64,20 @@ const toolStrings = strings.tools[tool.id as keyof typeof strings.tools];
</style>
<div
class="tool-hero"
class="tool-banner"
style={{
backgroundImage: `url(/assets/tools/${tool.id}.webp)`,
backgroundPosition: tool.background_position ?? "top",
backgroundPosition: tool.banner_background_position ?? "top",
}}
>
<div class="content">
<div class="pattern"></div>
<div
class="pattern"
style={{
backgroundImage: `url(/assets/patterns/${tool.id}.svg)`,
}}
>
</div>
<div class="centered">
<Heading weight="medium" size="2">
@@ -3,6 +3,7 @@ import type { Strings } from "@blitzkit/i18n";
import { Avatars } from "../../protos/avatars";
import { AbstractAPI } from "./abstract";
//@ts-expect-error
export class ClientAPI extends AbstractAPI {
protected _avatars() {
return fetchPB("/api/avatars/all.pb", Avatars);
@@ -0,0 +1,32 @@
import type {
GetStaticPathsItem,
GetStaticPathsOptions,
GetStaticPathsResult,
} from "astro";
type Promisable<T> = T | Promise<T>;
export function mixStaticPaths<
TypeA extends GetStaticPathsItem,
TypeB extends GetStaticPathsItem
>(
a: (options: GetStaticPathsOptions) => Promisable<TypeA[]>,
b: (options: GetStaticPathsOptions) => Promisable<TypeB[]>
) {
return async function (options: GetStaticPathsOptions) {
const pathsA = await a(options);
const pathsB = await b(options);
const paths: GetStaticPathsResult = [];
for (const pathA of pathsA) {
for (const pathB of pathsB) {
paths.push({
params: { ...pathA.params, ...pathB.params },
props: { ...pathA.props, ...pathB.props },
});
}
}
return paths as (TypeA & TypeB)[];
};
}
@@ -252,7 +252,7 @@ export const characteristics = {
);
},
gun_depression({ parameters, tank }) {
gun_depression({ parameters }) {
return -radToDeg(
applyPitchYawLimits(
-Math.PI / 2,
+7 -3
View File
@@ -9,7 +9,7 @@ export interface Tool {
disabled?: boolean;
href?: string;
background_position: "top" | "center" | "bottom";
banner_background_position: "top" | "center" | "bottom";
}
export const tools: Record<string, Tool> = {
@@ -19,24 +19,28 @@ export const tools: Record<string, Tool> = {
button: "purple",
significant: true,
background_position: "top",
banner_background_position: "top",
},
avatars: {
id: "avatars",
button: "gold",
background_position: "bottom",
banner_background_position: "bottom",
},
backgrounds: {
id: "backgrounds",
button: "gold",
banner_background_position: "bottom",
},
api: {
id: "api",
disabled: true,
button: "lime",
banner_background_position: "bottom",
},
};
@@ -29,7 +29,10 @@ const { accent, noNavbar, locale } = Astro.props;
<ColorScale color="white" alpha />
<ColorScale color={accent} />
<ColorScale color={accent} alpha />
<ColorAlias color={accent} alias="accent" />
<ColorAlias color={accent} alpha alias="accent" />
<BaseStyles />
<style>
@@ -10,6 +10,7 @@ import BaseLayout from "../../../layouts/BaseLayout.astro";
export { getStaticPaths } from "../index.astro";
const { locale } = Astro.props;
const root = `/src/pages/api`;
const globbed = import.meta.glob("/src/pages/api/**/*", {
eager: true,
@@ -47,7 +48,7 @@ for (const fullPath in globbed) {
paths.sort((a, b) => a.path.localeCompare(b.path));
---
<BaseLayout accent="amber">
<BaseLayout {locale} accent="amber">
<h1>api</h1>
{
@@ -1,11 +1,7 @@
import { useMemo } from "react";
import { AvatarCard } from "../../../components/AvatarCard";
import { IncrementalLoader } from "../../../components/IncrementalLoader";
import { api } from "../../../core/api/dynamic";
import { useAwait } from "../../../hooks/useAwait";
import { useGameStrings } from "../../../hooks/useGameStrings";
import { LocaleProvider, useLocale } from "../../../hooks/useLocale";
import type { Avatar } from "../../../protos/avatar";
import { AvatarControls } from "../../../components/AvatarControls";
import { AvatarsList } from "../../../components/AvatarsList";
import { Section } from "../../../components/Section";
import { LocaleProvider } from "../../../hooks/useLocale";
interface PageProps extends ContentProps {
locale: string;
@@ -23,47 +19,16 @@ interface ContentProps {
skeleton?: boolean;
}
function Content({ skeleton }: ContentProps) {
const locale = useLocale();
const { avatars } = useAwait(() => api.avatars(), "avatars");
const profileAvatarEntityStrings = useGameStrings("ProfileAvatarEntity");
const groups = useMemo(() => {
const groups = new Map<string, Avatar[]>();
for (const avatar of avatars) {
const name =
profileAvatarEntityStrings[avatar.stuff_ui!.display_name] ??
avatar.stuff_ui!.display_name;
if (groups.has(name)) {
groups.get(name)!.push(avatar);
} else {
groups.set(name, [avatar]);
}
}
return groups;
}, []);
const ordered = useMemo(() => {
const array = Array.from(groups.entries());
return array
.sort((a, b) => a[0].localeCompare(b[0], locale))
.map(([name, avatars]) => ({
name,
avatars: avatars.sort((a, b) => {
return a.stuff_ui!.grade - b.stuff_ui!.grade;
}),
}));
}, []);
function Content({}: ContentProps) {
return (
<div className="avatars">
<IncrementalLoader
initial={40}
intermediate={10}
data={ordered}
Component={AvatarCard}
/>
</div>
<>
<Section>
<AvatarControls />
</Section>
<Section>
<AvatarsList />
</Section>
</>
);
}
@@ -1,5 +1,4 @@
---
import Section from "../../../components/Section.astro";
import ToolBanner from "../../../components/ToolBanner.astro";
import { tools } from "../../../core/tools/tools";
import BaseLayout from "../../../layouts/BaseLayout.astro";
@@ -10,20 +9,10 @@ export { getStaticPaths } from "../index.astro";
const { locale } = Astro.props;
---
<style is:global>
.avatars {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--space-7), 1fr));
gap: var(--space-5) var(--space-4);
}
</style>
<BaseLayout {locale} accent="gold">
<ToolBanner {locale} tool={tools.avatars} slot="pre-app" />
<Section>
<Page {locale} client:only="react">
<Page {locale} skeleton slot="fallback" />
</Page>
</Section>
<Page {locale} client:only="react">
<Page {locale} skeleton slot="fallback" />
</Page>
</BaseLayout>
@@ -5,10 +5,11 @@ import BaseLayout from "../../../layouts/BaseLayout.astro";
export { getStaticPaths } from "../index.astro";
const { locale } = Astro.props;
const { backgrounds } = await api.backgrounds();
---
<BaseLayout accent="amber">
<BaseLayout {locale} accent="amber">
{
backgrounds.map((background) => {
return (
@@ -4,7 +4,7 @@ import "@radix-ui/colors/amber-dark.css";
import type { GetStaticPaths } from "astro";
import ColorScale from "../../components/ColorScale.astro";
import HomeHero from "../../components/HomeHero.astro";
import Section from "../../components/Section.astro";
import { Section } from "../../components/Section";
import ToolCard from "../../components/ToolCard.astro";
import { tools } from "../../core/tools/tools";
import BaseLayout from "../../layouts/BaseLayout.astro";
@@ -19,8 +19,8 @@ export function Page({ id }: { id: string }) {
function Content({ id }: { id: string }) {
const tank = useAwait(() => api.tank(id), `tank-${id}`);
const tankList = useAwait(() => api.tankList(), "tank-list");
const otherTanks = useAwait(() => api.tanks(), "tanks");
// const tankList = useAwait(() => api.tankList(), "tank-list");
// const otherTanks = useAwait(() => api.tanks(), "tanks");
Tankopedia.useInitialization(tank.tank!);
@@ -1,22 +1,23 @@
---
import "@radix-ui/colors/violet-dark.css";
import type { GetStaticPaths } from "astro";
import { api } from "../../../../core/api/dynamic";
import { mixStaticPaths } from "../../../../core/astro/mixStaticPaths";
import BaseLayout from "../../../../layouts/BaseLayout.astro";
import { getStaticPaths as _getStaticPaths } from "../index.astro";
import { Page } from "./_index";
export const getStaticPaths = (async () => {
export const getStaticPaths = mixStaticPaths(_getStaticPaths, async () => {
const { list } = await api.tankList();
return list.map((tank) => ({
params: { slug: tank.slug },
props: { id: tank.id },
}));
}) satisfies GetStaticPaths;
});
const { id } = Astro.props;
const { id, locale } = Astro.props;
---
<BaseLayout accent="violet">
<BaseLayout {locale} accent="violet">
<Page {id} client:load />
</BaseLayout>
@@ -1,14 +1,19 @@
---
import "@radix-ui/colors/violet-dark.css";
import ToolBanner from "../../../components/ToolBanner.astro";
import { api } from "../../../core/api/dynamic";
import { tools } from "../../../core/tools/tools";
import BaseLayout from "../../../layouts/BaseLayout.astro";
export { getStaticPaths } from "../index.astro";
const { locale } = Astro.props;
const tankList = await api.tankList();
---
<BaseLayout accent="violet">
<BaseLayout {locale} accent="purple">
<ToolBanner {locale} tool={tools.tanks} slot="pre-app" />
<h1>tanks</h1>
{
@@ -0,0 +1,9 @@
import { Varuna } from "varuna";
interface Avatars {
search: string | null;
}
export const Avatars = new Varuna<Avatars>({
search: null,
});