Compare commits

...
7 Commits
Author SHA1 Message Date
TrèsAbhi c0c6a21e43 feat: groups 2026-01-14 13:58:53 -06:00
TrèsAbhi a7f93698a6 feat: tool banners 2026-01-14 13:30:12 -06:00
TrèsAbhi 1a18058228 feat: split hooks 2026-01-14 12:41:51 -06:00
TrèsAbhi 67024daa25 fix: some cleanup 2026-01-14 12:37:42 -06:00
TrèsAbhi de6137d1a5 feat: incremtal loading 2026-01-14 12:34:28 -06:00
TrèsAbhi 11e87bd519 feat: sort 2026-01-14 11:56:44 -06:00
TrèsAbhi 3b4e089756 fix: store trimmed only in cache 2026-01-14 11:48:55 -06:00
22 changed files with 373 additions and 70 deletions

No files matched your search

@@ -0,0 +1,3 @@
<svg width="30" height="52" viewBox="0 0 30 52" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.8564 44V52H0V36L13.8564 44ZM30 52H16.1436V44L30 36V52ZM28.8564 18V34L15 42L1.14355 34V18L15 10L28.8564 18ZM13.8564 8L0 16V0H13.8564V8ZM30 16L16.1436 8V0H30V16Z" fill="#EEEEF0"/>
</svg>

After

Width:  |  Height:  |  Size: 294 B

@@ -4,15 +4,20 @@
align-items: center;
gap: var(--space-4);
.avatar-image {
.avatar-series {
position: relative;
width: 100%;
aspect-ratio: 7 / 8;
border-radius: var(--space-3);
background-size: cover;
background-position: center;
.avatar-entry {
position: absolute;
box-shadow: var(--shadow-interactive), var(--shadow-subtle);
border-radius: var(--space-3);
box-shadow: var(--shadow-interactive), var(--shadow-aggressive);
background-size: cover;
background-position: center;
}
}
.avatar-label {
@@ -1,24 +1,50 @@
import { useGameStrings } from "../../hooks/useGameStrings";
import type { Avatar } from "../../protos/avatar";
import type { SkeletonProps } from "../../types/SkeletonProps";
import { Text } from "../Text";
import "./index.css";
interface Props {
avatar: Avatar;
name: string;
avatars: Avatar[];
}
export function AvatarCard({ avatar }: Props) {
const profileAvatarEntityStrings = useGameStrings("ProfileAvatarEntity");
const MAX_CARDS = 3;
const CARD_TRIM = "var(--space-3)";
export function AvatarCard(props: SkeletonProps<Props>) {
return (
<div className="avatar-card">
<div
className="avatar-image"
style={{ backgroundImage: `url(/api/avatars/${avatar.name}.webp)` }}
/>
{!props.skeleton && <Series avatars={props.avatars} />}
<Text align="center" className="avatar-label">
{profileAvatarEntityStrings[avatar.stuff_ui!.display_name]}
{props.skeleton ? "LOADING" : props.name}
</Text>
</div>
);
}
interface SeriesProps {
avatars: Avatar[];
}
function Series({ avatars }: SeriesProps) {
const count = Math.min(avatars.length, MAX_CARDS);
return (
<div className="avatar-series">
{avatars.slice(-MAX_CARDS).map((avatar, index) => (
<div
className="avatar-entry"
style={{
filter: `brightness(${(index + 1) / count})`,
top: `calc(${index} * ${CARD_TRIM})`,
left: `calc(${index} * ${CARD_TRIM})`,
width: `calc(100% - ${CARD_TRIM} * ${count - 1})`,
height: `calc(100% - ${CARD_TRIM} * ${count - 1})`,
backgroundImage: `url(/api/avatars/${avatar.name}.webp)`,
}}
/>
))}
</div>
);
}
@@ -24,6 +24,13 @@ import "@fontsource-variable/roboto-flex";
--font-size-minor: 0.75rem;
--font-size-major: 1.25rem;
--font-size-heading-1: 3rem;
--font-size-heading-2: 2rem;
--font-size-heading-3: 1.5rem;
--font-size-heading-4: 1.25rem;
--font-size-heading-5: 1rem;
--font-size-heading-6: 0.75rem;
--font-family-regular: "Roboto Flex Variable", sans-serif;
--font-family-mono: "JetBrains Mono Variable", monospace;
@@ -64,9 +71,11 @@ import "@fontsource-variable/roboto-flex";
var(--shadow-interactive-shine), var(--shadow-interactive-shadow),
var(--shadow-interactive-background);
--shadow-subtle: var(--space-1) var(--space-1) var(--space-4)
--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);
--blur-glass: blur(4rem);
--blur-tint: blur(16rem);
}
</style>
@@ -19,7 +19,6 @@ const strings = await api.strings(locale);
display: flex;
justify-content: center;
margin-top: calc(var(--space-4) * -1);
padding: var(--space-5) var(--space-5);
background-color: var(--gray-3);
@@ -48,14 +47,8 @@ const strings = await api.strings(locale);
}
}
}
.footer-spacer {
flex: 1;
}
</style>
<div class="footer-spacer"></div>
<div class="footer">
<div class="content">
<div class="logo">
@@ -0,0 +1,25 @@
.heading {
&[data-size="1"] {
font-size: var(--font-size-heading-1);
}
&[data-size="2"] {
font-size: var(--font-size-heading-2);
}
&[data-size="3"] {
font-size: var(--font-size-heading-3);
}
&[data-size="4"] {
font-size: var(--font-size-heading-4);
}
&[data-size="5"] {
font-size: var(--font-size-heading-5);
}
&[data-size="6"] {
font-size: var(--font-size-heading-6);
}
}
@@ -0,0 +1,23 @@
import { classNames } from "../../core/ui/classNames";
import { Text, type TextProps } from "../Text";
import "./index.css";
export interface HeadingProps extends Omit<TextProps, "size"> {
size?: `${1 | 2 | 3 | 4 | 5 | 6}`;
}
export function Heading({
className,
weight,
size = "1",
...props
}: HeadingProps) {
return (
<Text
{...props}
weight={weight ?? "medium"}
className={classNames("heading", className)}
data-size={size}
/>
);
}
@@ -0,0 +1,2 @@
.incremental-loader {
}
@@ -0,0 +1,81 @@
import { times } from "lodash-es";
import { useEffect, useRef, useState, type ReactNode } from "react";
import type { SkeletonProps } from "../../types/SkeletonProps";
import "./index.css";
export interface IncrementalLoaderProps<Props> {
initial: number;
intermediate: number;
rate?: number;
skeleton?: boolean;
data: Props[];
Component: (props: SkeletonProps<Props>) => ReactNode;
}
export function IncrementalLoader<Props>({
initial,
rate = 2,
data,
skeleton,
intermediate,
Component,
}: IncrementalLoaderProps<Props>) {
const [count, setCount] = useState(initial);
const sliced = data.slice(0, count);
return (
<>
{!skeleton &&
sliced.map((item, index) => <Component key={index} {...item} />)}
{times(
skeleton
? initial + intermediate
: Math.min(intermediate, data.length - count),
(index) => (
<Item key={index} rate={rate} setCount={setCount}>
<Component skeleton />
</Item>
)
)}
</>
);
}
interface ItemProps {
children: ReactNode;
rate: number;
setCount: (setter: (state: number) => number) => void;
}
function Item({ setCount, rate, children }: ItemProps) {
const wrapper = useRef<HTMLDivElement>(null);
const observer = useRef<IntersectionObserver | null>(null);
useEffect(() => {
function dispose() {
observer.current?.disconnect();
observer.current = null;
}
if (!wrapper.current) return;
if (observer.current === null) {
observer.current = new IntersectionObserver(([entry]) => {
if (!entry.isIntersecting) return;
setCount((state) => state + rate);
});
observer.current.observe(wrapper.current);
}
return dispose;
}, []);
return (
<div ref={wrapper} className="incremental-loader">
{children}
</div>
);
}
@@ -20,8 +20,8 @@ const { locale } = Astro.props;
justify-content: center;
z-index: 1;
backdrop-filter: var(--blur-glass);
background-color: var(--black-a5);
backdrop-filter: var(--blur-glass) brightness(25%);
box-shadow: var(--shadow-subtle);
.content {
flex: 1;
@@ -0,0 +1,81 @@
---
import { api } from "../core/api/dynamic";
import type { Tool } from "../core/tools/tools";
import { Heading } from "./Heading";
import { Text } from "./Text";
interface Props {
locale: string;
tool: Tool;
}
const { tool, locale } = Astro.props;
const strings = await api.strings(locale);
const toolStrings = strings.tools[tool.id as keyof typeof strings.tools];
---
<style>
.tool-hero {
background-size: cover;
.content {
width: 100%;
height: 100%;
position: relative;
display: flex;
justify-content: center;
padding: var(--space-5) var(--space-4);
box-sizing: border-box;
backdrop-filter: var(--blur-tint);
.centered {
flex: 1;
max-width: var(--page-width);
display: flex;
flex-direction: column;
}
.pattern {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: url(/assets/patterns/avatars.svg);
mask-image: linear-gradient(
90deg,
transparent 25%,
var(--black-a1),
transparent
);
mask-repeat: no-repeat;
mask-size: cover;
}
}
}
</style>
<div
class="tool-hero"
style={{
backgroundImage: `url(/assets/tools/${tool.id}.webp)`,
backgroundPosition: tool.background_position ?? "top",
}}
>
<div class="content">
<div class="pattern"></div>
<div class="centered">
<Heading weight="medium" size="2">
{toolStrings.name}
</Heading>
<Text>
{toolStrings.slogan}
</Text>
</div>
</div>
</div>
+13 -5
View File
@@ -83,10 +83,9 @@ export abstract class AbstractAPI {
> = {};
protected abstract _groupedGameStrings(
locale: string,
group: string,
prefix: boolean
group: string
): Promise<Record<string, string>>;
async groupedGameStrings(locale: string, group: string, prefix = true) {
async groupedGameStrings(locale: string, group: string, prefix: boolean) {
this.assertLocale(locale);
if (this._groupedGameStringsCache[locale] === undefined) {
@@ -95,10 +94,19 @@ export abstract class AbstractAPI {
if (this._groupedGameStringsCache[locale][group] === undefined) {
this._groupedGameStringsCache[locale][group] =
await this._groupedGameStrings(locale, group, prefix);
await this._groupedGameStrings(locale, group);
}
return this._groupedGameStringsCache[locale][group];
if (!prefix) return this._groupedGameStringsCache[locale][group];
const strings: Record<string, string> = {};
for (const key in this._groupedGameStringsCache[locale][group]) {
strings[`${group}__${key}`] =
this._groupedGameStringsCache[locale][group][key];
}
return strings;
}
private _avatarCache: Record<string, Avatar> = {};
+2 -16
View File
@@ -12,29 +12,15 @@ export class ClientAPI extends AbstractAPI {
throw new Error("Cannot fetch full game strings on client");
}
protected async _groupedGameStrings(
locale: string,
group: string,
prefix: boolean
) {
protected async _groupedGameStrings(locale: string, group: string) {
const response = await fetch(`/api/game-strings/${locale}/${group}.json`);
const json = (await response.json()) as Record<string, string>;
if (!prefix) return json;
const strings: Record<string, string> = {};
for (const key in json) {
strings[`${group}__${key}`] = json[key];
}
return strings;
return json;
}
protected async _strings(locale: string) {
const response = await fetch(`/api/strings/${locale}.json`);
const json = (await response.json()) as Strings;
return json;
}
}
+4 -7
View File
@@ -101,17 +101,14 @@ export class ServerAPI extends AbstractAPI {
return strings;
}
protected async _groupedGameStrings(
locale: string,
group: string,
prefix: boolean
) {
protected async _groupedGameStrings(locale: string, group: string) {
const strings = await this.gameStrings(locale);
const filtered: Record<string, string> = {};
for (const key in strings) {
if (key.startsWith(`${group}__`)) {
filtered[prefix ? key : key.substring(group.length + 2)] = strings[key];
const trimmed = key.substring(group.length + 2);
filtered[trimmed] = strings[key];
}
}
@@ -133,7 +130,7 @@ export class ServerAPI extends AbstractAPI {
protected async _tankList() {
const group = this.metadata.group("TankEntity");
const list: TankListEntry[] = [];
const strings = await this.groupedGameStrings("en", "TankEntity");
const strings = await this.groupedGameStrings("en", "TankEntity", true);
for (const item of group) {
const tankCatalog = item.TankCatalog();
@@ -8,6 +8,8 @@ export interface Tool {
significant?: boolean;
disabled?: boolean;
href?: string;
background_position: "top" | "center" | "bottom";
}
export const tools: Record<string, Tool> = {
@@ -16,11 +18,15 @@ export const tools: Record<string, Tool> = {
strings: "tankopedia",
button: "purple",
significant: true,
background_position: "top",
},
avatars: {
id: "avatars",
button: "gold",
background_position: "bottom",
},
backgrounds: {
@@ -3,11 +3,10 @@ import { useAwait } from "./useAwait";
import { useLocale } from "./useLocale";
export function useGameStrings(group: string) {
const { locale } = useLocale();
const locale = useLocale();
const strings = useAwait(
() => api.groupedGameStrings(locale, group),
() => api.groupedGameStrings(locale, group, true),
`game-strings-${locale}-${group}`
);
return strings;
}
+1 -8
View File
@@ -1,7 +1,5 @@
import locales from "@blitzkit/i18n/locales.json";
import { createContext, useContext, type ReactNode } from "react";
import { api } from "../core/api/dynamic";
import { useAwait } from "./useAwait";
const LocaleContext = createContext<{
locale: string;
@@ -26,12 +24,7 @@ export function useLocale() {
throw new Error("useLocale must be used within a LocaleProvider");
}
const strings = useAwait(
() => api.strings(context.locale),
`strings-${context.locale}`
);
return { locale: context.locale, strings };
return context.locale;
}
export interface LocaleAcceptorProps {
@@ -0,0 +1,9 @@
import { api } from "../core/api/dynamic";
import { useAwait } from "./useAwait";
import { useLocale } from "./useLocale";
export function useStrings() {
const locale = useLocale();
const strings = useAwait(() => api.strings(locale), `strings-${locale}`);
return strings;
}
@@ -16,12 +16,14 @@ interface Props {
const { accent, noNavbar, locale } = Astro.props;
---
<html>
<html lang={locale}>
<head>
<BaseMeta />
<ColorScale color="mauve" />
<ColorScale color="mauve" alpha />
<ColorAlias color="mauve" alias="gray" />
<ColorAlias color="mauve" alpha alias="gray" />
<ColorScale color="black" alpha />
<ColorScale color="white" alpha />
@@ -35,12 +37,19 @@ const { accent, noNavbar, locale } = Astro.props;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-5);
min-height: 100dvh;
background-color: var(--gray-1);
color: var(--gray-12);
font-family: var(--font-family-regular);
.app {
display: flex;
flex: 1;
flex-direction: column;
gap: var(--space-5);
padding: var(--space-4) 0;
}
}
</style>
@@ -50,7 +59,11 @@ const { accent, noNavbar, locale } = Astro.props;
<body class="dark">
{!noNavbar && <Navbar {locale} />}
<slot />
<slot name="pre-app" />
<div class="app">
<slot />
</div>
<Footer {locale} />
</body>
@@ -1,7 +1,11 @@
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 { LocaleProvider } from "../../../hooks/useLocale";
import { useGameStrings } from "../../../hooks/useGameStrings";
import { LocaleProvider, useLocale } from "../../../hooks/useLocale";
import type { Avatar } from "../../../protos/avatar";
interface PageProps extends ContentProps {
locale: string;
@@ -20,13 +24,46 @@ interface ContentProps {
}
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;
}),
}));
}, []);
return (
<div className="avatars">
{avatars.slice(0, 12).map((avatar) => (
<AvatarCard key={avatar.name} avatar={avatar} />
))}
<IncrementalLoader
initial={40}
intermediate={10}
data={ordered}
Component={AvatarCard}
/>
</div>
);
}
@@ -1,5 +1,7 @@
---
import Section from "../../../components/Section.astro";
import ToolBanner from "../../../components/ToolBanner.astro";
import { tools } from "../../../core/tools/tools";
import BaseLayout from "../../../layouts/BaseLayout.astro";
import { Page } from "./_index";
@@ -17,6 +19,8 @@ const { locale } = Astro.props;
</style>
<BaseLayout {locale} accent="gold">
<ToolBanner {locale} tool={tools.avatars} slot="pre-app" />
<Section>
<Page {locale} client:only="react">
<Page {locale} skeleton slot="fallback" />
@@ -0,0 +1,3 @@
export type SkeletonProps<Props> =
| { skeleton: true }
| (Props & { skeleton?: false });