Compare commits

...
27 Commits
Author SHA1 Message Date
TrèsAbhi d42fc0e3f6 Merge branch 'main' into ue 2026-01-09 14:06:57 -06:00
TrèsAbhi dc15df2a40 New Crowdin updates (#206) 2026-01-09 14:06:11 -06:00
TrèsAbhi 17d1526b6f Merge branch 'main' into ue 2026-01-09 14:05:43 -06:00
TrèsAbhi db8ac195d4 feat: start of cards 2026-01-09 14:04:59 -06:00
TrèsAbhi c5f5f69fd6 feat: more home page 2026-01-09 13:57:28 -06:00
TrèsAbhi 44203223fd feat: server side strings 2026-01-09 13:38:00 -06:00
TrèsAbhi bdabadcd49 feat: some more styles 2026-01-09 13:02:58 -06:00
TrèsAbhi 7c838aaf06 New Crowdin updates (#205)
* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Japanese)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)
2026-01-08 10:13:55 -06:00
TrèsAbhi f181c96518 Merge pull request #204 from blitzkit/dev
dev
2026-01-08 03:48:00 +00:00
TrèsAbhi 6fbfa2c70b Merge branch 'main' into dev 2026-01-07 20:16:06 -06:00
TrèsAbhi 17dd50b902 feat: logs 2026-01-07 20:15:45 -06:00
TrèsAbhi 9061335292 feat: deduplicate game mode consumales and powers 2026-01-07 20:11:54 -06:00
TrèsAbhi c27d3a3b6a Merge pull request #203 from Hitjack007/filter-by-module-damage
Fixed Module dmage filter showing alpha damage
2026-01-07 03:22:49 +00:00
Mark Greene c37acf4a93 Fixed Module dmage filter showing alpha damage 2026-01-07 15:41:55 +13:00
TrèsAbhi 0c1cef87f6 Merge pull request #200 from blitzkit/tresabhi-patch-1
Rename build job to 'Build website'
2026-01-05 05:18:30 +00:00
TrèsAbhi e8b777be8a Rename build job to 'Build bot' 2026-01-04 19:08:35 -06:00
TrèsAbhi 2941348412 Rename build job to 'Build website' 2026-01-04 19:08:04 -06:00
TrèsAbhi 68b2ecfb6d Merge branch 'main' into dev 2026-01-04 15:19:35 -06:00
TrèsAbhi d5526da1fb Merge pull request #198 from Hitjack007/dev 2026-01-04 21:18:31 +00:00
TrèsAbhi 091305a20f feat: break streak on tier change 2026-01-04 15:18:00 -06:00
TrèsAbhi 85cd70b2d5 feat: disable selecting 0 tiers 2026-01-04 15:16:57 -06:00
TrèsAbhi 7997f490fc feat: clean up tiers ui 2026-01-04 15:11:20 -06:00
TrèsAbhi 5a0b309b0b feat: combine tabs 2026-01-04 14:58:59 -06:00
Mark Greene 43c14665de Fix 2026-01-04 14:50:11 +13:00
Mark Greene e249408360 Fixed Filters not centering 2026-01-04 14:22:20 +13:00
Mark Greene 24e57d7125 Added Tier Filter to Guesser 2026-01-04 10:18:48 +13:00
Mark Greene 636a2934a2 Reworked Game Mode Ability Filters 2026-01-03 22:07:01 +13:00
41 changed files with 554 additions and 325 deletions

No files matched your search

+1 -1
View File
@@ -10,7 +10,7 @@ concurrency:
jobs:
build:
name: Build
name: Build bot
runs-on: ubuntu-latest
steps:
+1 -1
View File
@@ -35,7 +35,7 @@ env:
jobs:
build:
name: Build
name: Build website
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
+3
View File
@@ -4,6 +4,7 @@
"": {
"name": "blitzkit",
"dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@prisma/client": "^6.18.0",
"prisma": "^6.18.0",
"typescript": "^5.9.3",
@@ -545,6 +546,8 @@
"@floating-ui/utils": ["@floating-ui/[email protected]", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
"@fontsource-variable/jetbrains-mono": ["@fontsource-variable/[email protected]", "", {}, "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q=="],
"@fontsource-variable/roboto-flex": ["@fontsource-variable/[email protected]", "", {}, "sha512-Q9zbz+P2VrDeIxBu9YAIG+eYpLmQF5FIJEEwAWn8En9Lao1TOISnoYcywGV+jSv7LGsHEndCR8hveXPnvCBEQQ=="],
"@fontsource-variable/roboto-mono": ["@fontsource-variable/[email protected]", "", {}, "sha512-6M2U3wGIUxYNKRrUoKls8BRRIPDA57T8J0agqwyDkiEHrLEEAqptsxcUl3eTm6tnRNEn6yEm4pCefvtnujebDA=="],
+14
View File
@@ -0,0 +1,14 @@
# BlitzKit 1.29.10
Thank you to [Mark Greene](https://github.com/Hitjack007) for your contributions this update.
## Features
- Combined game mode passive and active abilities into a single dropdown
- [Mark Greene] Added tier filters to the Guess game
- Changing tiers will break your streak
## Fixes
- [Mark Greene] module damage is now properly shown when sorting with it in the tank search
- Duplicate abilities in the tank filters are now removed
+1
View File
@@ -43,6 +43,7 @@
"packages/*"
],
"dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@prisma/client": "^6.18.0",
"prisma": "^6.18.0",
"typescript": "^5.9.3"
-2
View File
@@ -1,4 +1,2 @@
export * from "./literals";
export * from "./strings";
export * from "./unwrapper";
export * from "./wrapper";
+2 -67
View File
@@ -1,68 +1,3 @@
import { merge } from "ts-deepmerge";
import locales from "../locales.json";
import en from "../strings/en.json";
import es from "../strings/es.json";
import fr from "../strings/fr.json";
import ja from "../strings/ja.json";
import pl from "../strings/pl.json";
import pt from "../strings/pt.json";
import ru from "../strings/ru.json";
import uk from "../strings/uk.json";
import zh from "../strings/zh.json";
import type en from "../strings/en.json";
export type BlitzKitStrings = typeof en;
const stringsRaw: Record<string, TranslationTree> = {
en,
es,
fr,
ja,
pl,
pt,
ru,
uk,
zh,
};
export const STRINGS: Record<string, BlitzKitStrings> = {};
for (const locale in stringsRaw) {
if (locale === locales.default) {
STRINGS[locales.default] = stringsRaw[locales.default] as BlitzKitStrings;
continue;
}
const localeStrings = stringsRaw[locale];
STRINGS[locale] = merge(
stringsRaw[locales.default],
localeStrings
) as BlitzKitStrings;
}
export const SUPPORTED_LOCALE_BLITZ_MAP: Record<string, string> = {
en: "en",
es: "es",
fr: "fr",
ja: "ja",
pt: "pt",
ru: "ru",
uk: "uk",
zh: "zh-Hans",
pl: "pl",
};
export const SUPPORTED_LOCALE_FLAGS: Record<string, string> = {
en: "🇺🇸",
es: "🇪🇸",
fr: "🇫🇷",
ja: "🇯🇵",
pt: "🇵🇹",
ru: "🇷🇺",
uk: "🇺🇦",
zh: "🇨🇳",
pl: "🇵🇱",
};
export type TranslationTree = { [key: string]: TranslationNode };
export type TranslationNode = string | TranslationTree;
export type Strings = typeof en;
-12
View File
@@ -1,12 +0,0 @@
import { I18nString } from "../../core/src";
import locales from "../locales.json";
export function unwrapper(locale = locales.default) {
return function (i18nString: I18nString) {
if (locale in i18nString.locales) {
return i18nString.locales[locale];
}
return i18nString.locales[locales.default];
};
}
-15
View File
@@ -1,15 +0,0 @@
import locales from "../locales.json";
import { type BlitzKitStrings, STRINGS } from "./strings";
export function wrapper(slicer: (strings: BlitzKitStrings) => string) {
const defaultString = slicer(STRINGS[locales.default] as BlitzKitStrings);
const object = { [locales.default]: defaultString };
for (const { locale } of locales.supported) {
const string = slicer(STRINGS[locale] as BlitzKitStrings);
if (string !== defaultString) object[locale] = string;
}
return object;
}
+2 -1
View File
@@ -27,6 +27,7 @@
},
"home": {
"search": "Search tanks, players, tools..."
"search": "Search tanks, players, tools...",
"credit": "by TrèsAbhi"
}
}
+6 -1
View File
@@ -671,6 +671,8 @@
"sorting_by": "Sorting by {name}",
"clear": "Limpiar",
"tier": "Tier {tier}",
"passive": "{name} (Passive)",
"active": "{name} (Active)",
"no_tanks_found": {
"body": "No se encontraron tanques.",
"search_again": "Intenta buscar de nuevo.",
@@ -823,7 +825,10 @@
"description": "Reveals the tank without showing the name. This round will be considered incorrect and break your streak!",
"cancel": "Cancelar",
"reveal": "Reveal"
}
},
"clear": "Limpiar",
"select_all": "Seleccionar todo",
"tier": "Tier {tier}"
},
"tankopedia": {
"search": "Search a stat...",
+6 -1
View File
@@ -671,6 +671,8 @@
"sorting_by": "Sorting by {name}",
"clear": "Clear",
"tier": "Tier {tier}",
"passive": "{name} (Passive)",
"active": "{name} (Active)",
"no_tanks_found": {
"body": "No tanks found.",
"search_again": "Try searching again.",
@@ -823,7 +825,10 @@
"description": "Reveals the tank without showing the name. This round will be considered incorrect and break your streak!",
"cancel": "Cancel",
"reveal": "Reveal"
}
},
"clear": "Clear",
"select_all": "Select all",
"tier": "Tier {tier}"
},
"tankopedia": {
"search": "Search a stat...",
+12 -7
View File
@@ -187,11 +187,11 @@
"bot": {
"common": {
"errors": {
"clan_not_found": "# Could not find clan\nCouldn't find \"{search}\". Try selecting a clan from the search result.",
"player_not_found": "# Could not find user\nI couldn't find user \"{search}\". Try picking an user from the search result, typing in a valid username, or using the `/link` command.",
"clan_not_found": "",
"player_not_found": "# ユーザーが見つかりませんでした\nユーザー「{search}」が見つかりませんでした。検索結果からユーザーを選択するか、有効なユーザー名を入力するか、/link コマンドを使用してください。",
"player_not_linked": "# `/link`を使用して始めましょう\nはじめまして!あなたが誰か、私に教えてください。",
"tank_name_not_found": "# Tank not found\nCould not find tank by the name \"{search}\".",
"tank_id_not_found": "# Tank not found\nCould not find tank by the ID \"{search}\". Try typing the name of the tank instead of a number.",
"tank_id_not_found": "# 車両が見つかりません\nID「{search}」の戦車が見つかりませんでした。数字ではなく戦車の名前を入力してみてください。",
"no_tank_stats": "# 利用可能な車輌統計がありません\nこのプレイヤーには利用可能な統計がありません。あなたが探しているプレイヤーではない可能性があります。",
"get_help": "Discordサーバーに参加する",
"uncaught_error": "# BlitzKitがエラーに遭遇しました!\n申し訳ありません。お気軽に[公式Discordサーバー](https://discord.gg/nDt7AjGJQH)にお問い合わせください。",
@@ -395,7 +395,7 @@
"wn8": "WN8",
"winrate": "勝率",
"damage": "ダメージ",
"unknown_tank": "Unknown tank {id}"
"unknown_tank": "不明な車両{id}"
}
},
"debug": {
@@ -560,7 +560,7 @@
"description": "Today's played tanks and statistics"
},
"stats": {
"name": "stats",
"name": "",
"description": "Quick statistics for a period"
},
"owned_tanks": {
@@ -671,6 +671,8 @@
"sorting_by": "Sorting by {name}",
"clear": "クリア",
"tier": "Tier {tier}",
"passive": "{name} (Passive)",
"active": "{name} (Active)",
"no_tanks_found": {
"body": "該当する車輌が存在しません。",
"search_again": "もう一度検索してください。",
@@ -781,7 +783,7 @@
"tank": "車輌名",
"last_played": "Last played",
"battles": "Career battles",
"unrecorded": "--",
"unrecorded": "",
"wins": "勝利"
},
"large_warning": {
@@ -823,7 +825,10 @@
"description": "Reveals the tank without showing the name. This round will be considered incorrect and break your streak!",
"cancel": "Cancel",
"reveal": "Reveal"
}
},
"clear": "クリア",
"select_all": "全て選択",
"tier": "Tier {tier}"
},
"tankopedia": {
"search": "Search a stat...",
+6 -1
View File
@@ -671,6 +671,8 @@
"sorting_by": "Sorting by {name}",
"clear": "Clear",
"tier": "Tier {tier}",
"passive": "{name} (Passive)",
"active": "{name} (Active)",
"no_tanks_found": {
"body": "No tanks found.",
"search_again": "Try searching again.",
@@ -823,7 +825,10 @@
"description": "Reveals the tank without showing the name. This round will be considered incorrect and break your streak!",
"cancel": "Cancel",
"reveal": "Reveal"
}
},
"clear": "Clear",
"select_all": "Select all",
"tier": "Tier {tier}"
},
"tankopedia": {
"search": "Search a stat...",
+6 -1
View File
@@ -671,6 +671,8 @@
"sorting_by": "Sorting by {name}",
"clear": "Limpar",
"tier": "Tier {tier}",
"passive": "{name} (Passive)",
"active": "{name} (Active)",
"no_tanks_found": {
"body": "Nenhum tanque encontrado.",
"search_again": "Tente pesquisar novamente.",
@@ -823,7 +825,10 @@
"description": "Reveals the tank without showing the name. This round will be considered incorrect and break your streak!",
"cancel": "Cancelar",
"reveal": "Reveal"
}
},
"clear": "Limpar",
"select_all": "Selecionar todos",
"tier": "Tier {tier}"
},
"tankopedia": {
"search": "Search a stat...",
+6 -1
View File
@@ -671,6 +671,8 @@
"sorting_by": "Сортировать по {name}",
"clear": "Очистить",
"tier": " {tier} уровня",
"passive": "{name} (Passive)",
"active": "{name} (Active)",
"no_tanks_found": {
"body": "Танков не найдено.",
"search_again": "Попробуйте повторить поиск.",
@@ -823,7 +825,10 @@
"description": "Показывает танк не показывая название. Этот раунд будет считаться проигранным и прекратит серию!",
"cancel": "Отмена",
"reveal": "Раскрыть"
}
},
"clear": "Очистить",
"select_all": "Выбрать всё",
"tier": " {tier} уровня"
},
"tankopedia": {
"search": "Поиск характеристик...",
+6 -1
View File
@@ -671,6 +671,8 @@
"sorting_by": "Відсортувати за {name}",
"clear": "Clear",
"tier": "Рівень {tier}",
"passive": "{name} (Passive)",
"active": "{name} (Active)",
"no_tanks_found": {
"body": "Нічого не знайдено.",
"search_again": "Спробуйте шукати заново.",
@@ -823,7 +825,10 @@
"description": "Reveals the tank without showing the name. This round will be considered incorrect and break your streak!",
"cancel": "Cancel",
"reveal": "Reveal"
}
},
"clear": "Clear",
"select_all": "Вибрати все",
"tier": "Рівень {tier}"
},
"tankopedia": {
"search": "Search a stat...",
+6 -1
View File
@@ -671,6 +671,8 @@
"sorting_by": "按 {name} 排序",
"clear": "清除",
"tier": "{tier} 阶",
"passive": "{name} (Passive)",
"active": "{name} (Active)",
"no_tanks_found": {
"body": "找不到战车。",
"search_again": "请尝试重新搜索。",
@@ -823,7 +825,10 @@
"description": "Reveals the tank without showing the name. This round will be considered incorrect and break your streak!",
"cancel": "取消",
"reveal": "Reveal"
}
},
"clear": "清除",
"select_all": "全选",
"tier": "{tier} 阶"
},
"tankopedia": {
"search": "搜索属性……",
@@ -1,12 +1,9 @@
---
import "@fontsource-variable/jetbrains-mono";
import "@fontsource-variable/roboto-flex";
import "@fontsource-variable/roboto-mono";
---
<style is:global>
@import "@radix-ui/colors/mauve-dark.css";
@import "@radix-ui/colors/mauve-alpha.css";
<style>
body {
--space-0: 0.0625rem;
--space-1: 0.125rem;
@@ -19,6 +16,13 @@ import "@fontsource-variable/roboto-mono";
--space-8: 16rem;
--space-9: 32rem;
--max-size: max(100vw, 100vh);
--font-size-regular: var(--space-4);
--font-family-regular: "Roboto Flex Variable", sans-serif;
--font-family-mono: "JetBrains Mono Variable", monospace;
--weight-thin: 100;
--weight-extra-light: 200;
--weight-light: 300;
@@ -29,10 +33,6 @@ import "@fontsource-variable/roboto-mono";
--weight-extra-bold: 800;
--weight-black: 900;
--font-size-regular: var(--space-4);
--max-size: max(100vw, 100vh);
--outline-interactive: solid var(--space-1) var(--gray-5);
--outline-focus: var(--space-1) solid var(--accent-6);
@@ -44,7 +44,7 @@ import "@fontsource-variable/roboto-mono";
background-color: var(--gray-1);
color: var(--gray-12);
font-family: "Roboto Flex";
font-family: var(--font-family-regular);
display: flex;
flex-direction: column;
@@ -0,0 +1,2 @@
.button {
}
@@ -0,0 +1,9 @@
import type { ComponentProps } from "react";
import { classNames } from "../../core/ui/classNames";
import "./index.css";
export interface ButtonProps extends ComponentProps<"button"> {}
export function Button({ className, ...props }: ButtonProps) {
return <button className={classNames("button", className)} {...props} />;
}
@@ -1,4 +1,3 @@
.code {
font-family: "Roboto Mono";
color: red;
font-family: var(--font-family-mono);
}
@@ -1,8 +1,9 @@
import type { ComponentProps } from "react";
import { classNames } from "../../core/ui/classNames";
import "./index.css";
export interface CodeProps extends ComponentProps<"code"> {}
export function Code({ className }: CodeProps) {
return <code className={className?????} {...props} />;
export function Code({ className, ...props }: CodeProps) {
return <code className={classNames("code", className)} {...props} />;
}
@@ -1,6 +1,6 @@
---
import * as colors from "@radix-ui/colors";
import type { Color } from "../pages/styles/colors/[accent].css";
import type { Color } from "../core/ui/color";
interface Props {
color: Color;
@@ -1,10 +1,18 @@
---
import { MagnifyingGlassIcon } from "@radix-ui/react-icons";
import { api } from "../core/api/dynamic";
import { BlitzKitIcon } from "../icons/BlitzKit";
import { Code } from "./Code";
import { Text } from "./Text";
import { TextField } from "./TextField";
const strings = { home: { search: "test" } };
interface Props {
locale: string;
}
const { locale } = Astro.props;
const strings = await api.strings(locale);
---
<style>
@@ -39,7 +47,10 @@ const strings = { home: { search: "test" } };
width: 100%;
height: 100%;
background: linear-gradient(transparent, var(--gray-1));
background: linear-gradient(
color-mix(in srgb, transparent 50%, var(--gray-1)),
var(--gray-1)
);
}
}
@@ -61,13 +72,18 @@ const strings = { home: { search: "test" } };
position: absolute;
top: 0;
right: 0;
transform: translateX(100%);
transform: translate(100%, 0);
padding-left: var(--space-2);
font-weight: var(--weight-bold);
letter-spacing: -0.15em;
}
}
}
.text-field {
width: 100%;
max-width: 25rem;
}
}
</style>
@@ -82,6 +98,10 @@ const strings = { home: { search: "test" } };
<BlitzKitIcon className="icon" />
<Code className="version">2.0</Code>
</div>
<Text lowContrast>
{strings.home.credit}
</Text>
</div>
<TextField placeholder={strings.home.search}>
@@ -0,0 +1,3 @@
.text {
font-size: var(--font-size-regular);
}
@@ -0,0 +1,24 @@
import type { ComponentProps } from "react";
import { classNames } from "../../core/ui/classNames";
import type { Color } from "../../core/ui/color";
import "./index.css";
export interface TextProps extends ComponentProps<"span"> {
color?: Color;
lowContrast?: boolean;
}
export function Text({
color = "gray",
lowContrast,
className,
...props
}: TextProps) {
return (
<span
className={classNames("text", className)}
style={{ color: `var(--${color}-${lowContrast ? 11 : 12})` }}
{...props}
/>
);
}
@@ -30,6 +30,7 @@
input {
min-width: 0;
flex: 1;
background: none;
border: none;
@@ -1,13 +1,17 @@
import type { ComponentProps, ReactNode } from "react";
import { classNames } from "../../core/ui/classNames";
import "./index.css";
export interface TextFieldProps extends ComponentProps<"input"> {
children?: ReactNode;
}
export function TextField({ children, ...props }: TextFieldProps) {
export function TextField({ children, className, ...props }: TextFieldProps) {
return (
<div className="text-field" data-has-icon={children !== undefined}>
<div
className={classNames("text-field", className)}
data-has-icon={children !== undefined}
>
{children}
<input {...props} />
</div>
@@ -0,0 +1,31 @@
---
import { CaretRightIcon } from "@radix-ui/react-icons";
import type { Tool } from "../core/tools/tools";
import { Button } from "./Button";
import { Text } from "./Text";
interface Props {
tool: Tool;
}
const { tool } = Astro.props;
---
<style>
.tool-card {
}
</style>
<div class="tool-card">
<div>
<div>
<Text>{tool.id}</Text>
<Text>{tool.id}</Text>
</div>
<Button>
{tool.id}
<CaretRightIcon />
</Button>
</div>
</div>
+28 -2
View File
@@ -1,15 +1,41 @@
import type { CatalogItemAccessor } from "@blitzkit/closed";
import type { Strings } from "@blitzkit/i18n";
import locales from "@blitzkit/i18n/locales.json";
import type { Tank } from "../../protos/tank";
import type { TankList } from "../../protos/tank_list";
import type { Tanks } from "../../protos/tanks";
export abstract class AbstractAPI {
abstract tanks(): Promise<Tanks>;
abstract tankList(): Promise<TankList>;
abstract strings(
abstract _strings(locale: string): Promise<Strings>;
abstract _gameStrings(
locale: string,
prefix?: string
prefix: string
): Promise<Record<string, string>>;
private assertLocale(locale: string) {
const supported = locales.supported.find(
(supported) => supported.locale === locale
);
if (supported === undefined) {
throw new Error(`Unsupported locale: ${locale}`);
}
return supported;
}
async strings(locale: string) {
this.assertLocale(locale);
return await this._strings(locale);
}
async gameStrings(locale: string, prefix: string) {
this.assertLocale(locale);
return await this._gameStrings(locale, prefix);
}
abstract tank(id: string): Promise<Tank>;
abstract avatars(): Promise<CatalogItemAccessor[]>;
+31 -4
View File
@@ -1,6 +1,9 @@
import type { MetadataAccessor } from "@blitzkit/closed";
import { sluggify } from "@blitzkit/core";
import { sluggify, type DeepPartial } from "@blitzkit/core";
import type { Strings } from "@blitzkit/i18n";
import locales from "@blitzkit/i18n/locales.json";
import type { RemoteStorageComponent } from "@protos/blitz_static_remote_storage_component";
import { merge } from "lodash-es";
import { parse } from "yaml";
import type { Tank } from "../../protos/tank";
import type { TankList, TankListEntry } from "../../protos/tank_list";
@@ -11,6 +14,10 @@ if (typeof window !== "undefined") {
throw new Error("ServerAPI is being evaluated in the browser");
}
const globbedStrings = import.meta.glob("../../../../i18n/strings/*.json", {
import: "default",
});
interface LocalizationConfig {
namespaces: string[];
}
@@ -20,7 +27,7 @@ export class ServerAPI extends AbstractAPI {
super();
}
async strings(locale: string) {
async _gameStrings(locale: string, prefix: string) {
const group = this.metadata.group("ClientConfigsEntity");
if (group.length !== 1) {
@@ -67,7 +74,7 @@ export class ServerAPI extends AbstractAPI {
Object.assign(
strings,
await fetch(
`${remoteStorage.url}/${remoteStorage.relative_path}/${namespace}/${locale}.yaml`
`${remoteStorage.url}${remoteStorage.relative_path}/${namespace}/${locale}.yaml`
)
.then((response) => response.text())
.then((text) => {
@@ -89,6 +96,26 @@ export class ServerAPI extends AbstractAPI {
);
}
const filtered: Record<string, string> = {};
for (const key in strings) {
if (key.startsWith(`${prefix}__`)) {
filtered[key] = strings[key];
}
}
return filtered;
}
async _strings(locale: string) {
const localized = (await globbedStrings[
`../../../../i18n/strings/${locale}.json`
]()) as DeepPartial<Strings>;
const defaults = (await globbedStrings[
`../../../../i18n/strings/${locales.default}.json`
]()) as Strings;
const strings = merge({}, defaults, localized);
return strings;
}
@@ -97,7 +124,7 @@ export class ServerAPI extends AbstractAPI {
if (this._tankList === undefined) {
const group = this.metadata.group("TankEntity");
const list: TankListEntry[] = [];
const strings = await this.strings("en");
const strings = await this.gameStrings("en", "TankEntity");
for (const item of group) {
const tankCatalog = item.TankCatalog();
@@ -0,0 +1,3 @@
export function classNames(...classes: (string | undefined)[]) {
return classes.filter(Boolean).join(" ");
}
+7
View File
@@ -0,0 +1,7 @@
import type * as colors from "@radix-ui/colors";
const ignoreEndings = ["A", "P3", "Dark", "DarkA", "DarkP3"] as const;
export type Color = Exclude<
keyof typeof colors,
`${string}${(typeof ignoreEndings)[number]}` | "__esModule"
>;
@@ -4,6 +4,7 @@ import "@radix-ui/colors/amber-dark.css";
import type { GetStaticPaths } from "astro";
import packageJson from "../../../../../package.json";
import HomeHero from "../../components/HomeHero.astro";
import ToolCard from "../../components/ToolCard.astro";
import { tools } from "../../core/tools/tools";
import BaseLayout from "../../layouts/BaseLayout.astro";
@@ -15,18 +16,14 @@ export const getStaticPaths = (() => {
{ params: { locale: undefined }, props: { locale: locales.default } },
];
}) satisfies GetStaticPaths;
const { locale } = Astro.props;
---
<BaseLayout accent="amber">
<HomeHero />
<HomeHero {locale} />
<h1>blitzkit {packageJson.version}</h1>
{
Object.values(tools).map((tool) => (
<p>
<a href={`/${tool.id}`}>{tool.id}</a>
</p>
))
}
{Object.values(tools).map((tool) => <ToolCard {tool} />)}
</BaseLayout>
@@ -0,0 +1,15 @@
import locales from "@blitzkit/i18n/locales.json";
import type { APIContext, GetStaticPaths } from "astro";
import { api } from "../../../core/api/dynamic";
export const getStaticPaths = (() => {
return locales.supported.map(({ locale }) => ({ params: { locale } }));
}) satisfies GetStaticPaths;
/**
* BlitzKit strings.
*/
export async function GET({ params }: APIContext<never, { locale: string }>) {
const strings = await api.strings(params.locale);
return Response.json(strings);
}
+131 -51
View File
@@ -9,13 +9,17 @@ import {
ArrowRightIcon,
EyeOpenIcon,
MagnifyingGlassIcon,
MixerVerticalIcon,
PaperPlaneIcon,
StarFilledIcon,
TrashIcon,
} from "@radix-ui/react-icons";
import {
AlertDialog,
Box,
Button,
Card,
DropdownMenu,
Flex,
IconButton,
Spinner,
@@ -23,7 +27,7 @@ import {
TextField,
} from "@radix-ui/themes";
import fuzzysort from "fuzzysort";
import { debounce } from "lodash-es";
import { debounce, times } from "lodash-es";
import { useCallback, useEffect, useRef, useState } from "react";
import { awaitableTankDefinitions } from "../core/awaitables/tankDefinitions";
import { awaitableTankNames } from "../core/awaitables/tankNames";
@@ -53,6 +57,7 @@ export function Guesser() {
const [searching, setSearching] = useState(false);
const [results, setResults] = useState<TankDefinition[] | null>(null);
const [selected, setSelected] = useState<TankDefinition | null>(null);
const tiers = Guess.use((state) => state.tiers);
const requestSearch = useCallback(() => {
setSelected(null);
@@ -97,58 +102,60 @@ export function Guesser() {
width="100%"
p="4"
maxWidth="25rem"
gap="4"
gap="3"
>
{results !== null && (
<Card variant="classic">
<Box py="2" px="3">
{results.length === 0 && (
<Flex justify="center">
<Text color="gray">
{strings.website.tools.guess.search.no_results}
</Text>
</Flex>
)}
<Box position="relative">
{results !== null && (
<Card variant="classic">
<Box py="2" px="3">
{results.length === 0 && (
<Flex justify="center">
<Text color="gray">
{strings.website.tools.guess.search.no_results}
</Text>
</Flex>
)}
<SearchResults.Root>
{results.map((result) => {
const Icon = classIcons[result.class];
<SearchResults.Root>
{results.map((result) => {
const Icon = classIcons[result.class];
return (
<SearchResults.Item
key={result.id}
onClick={() => {
if (!input.current) return;
return (
<SearchResults.Item
key={result.id}
onClick={() => {
if (!input.current) return;
input.current.value = unwrap(result.name);
setSelected(result);
setResults(null);
}}
text={unwrap(result.name)}
prefix={
<img
style={{ width: "1em", height: "1em" }}
src={asset(`flags/circle/${result.nation}.webp`)}
/>
}
discriminator={
<Flex
align="center"
gap="1"
width="38px"
justify="center"
>
<Icon width="1em" height="1em" />
{TIER_ROMAN_NUMERALS[result.tier]}
</Flex>
}
/>
);
})}
</SearchResults.Root>
</Box>
</Card>
)}
input.current.value = unwrap(result.name);
setSelected(result);
setResults(null);
}}
text={unwrap(result.name)}
prefix={
<img
style={{ width: "1em", height: "1em" }}
src={asset(`flags/circle/${result.nation}.webp`)}
/>
}
discriminator={
<Flex
align="center"
gap="1"
width="38px"
justify="center"
>
<Icon width="1em" height="1em" />
{TIER_ROMAN_NUMERALS[result.tier]}
</Flex>
}
/>
);
})}
</SearchResults.Root>
</Box>
</Card>
)}
</Box>
<Flex justify="center" style={{ userSelect: "none" }}>
<Text>
@@ -161,11 +168,78 @@ export function Guesser() {
</Flex>
<Flex gap="3">
<DropdownMenu.Root modal={false}>
<DropdownMenu.Trigger>
<IconButton size="3" variant="surface" color="gray">
<MixerVerticalIcon />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{times(10, (index) => {
const tier = 10 - index;
const isSelected = tiers.includes(tier);
return (
<DropdownMenu.CheckboxItem
checked={isSelected}
key={tier}
onClick={(event) => {
event.preventDefault();
Guess.mutate((draft) => {
if (isSelected) {
if (draft.tiers.length === 1) return;
draft.tiers = draft.tiers.filter((t) => t !== tier);
} else {
draft.tiers = [...draft.tiers, tier];
}
draft.streak = 0;
});
}}
>
{literals(strings.website.tools.guess.tier, {
tier: TIER_ROMAN_NUMERALS[tier],
})}
</DropdownMenu.CheckboxItem>
);
})}
<DropdownMenu.Separator />
<DropdownMenu.Item
onClick={(event) => {
event.preventDefault();
Guess.mutate((draft) => {
draft.tiers = Guess.initial.tiers;
});
}}
>
<StarFilledIcon /> {strings.website.tools.guess.select_all}
</DropdownMenu.Item>
<DropdownMenu.Item
color="red"
onClick={(event) => {
event.preventDefault();
Guess.mutate((draft) => {
draft.tiers = [];
});
}}
>
<TrashIcon /> {strings.website.tools.guess.clear}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
<TextField.Root
disabled={guessState !== GuessState.NotGuessed}
ref={input}
onChange={requestSearch}
style={{ flex: 1 }}
style={{ flex: 1, minWidth: "14rem" }}
placeholder={strings.website.tools.guess.search.placeholder}
size="3"
variant="classic"
@@ -238,7 +312,13 @@ export function Guesser() {
draft.streak = correct ? draft.streak + 1 : 0;
});
} else {
const id = Number(ids[Math.floor(Math.random() * ids.length)]);
const filteredIds = ids.filter((id) => {
const tank = tankDefinitions.tanks[Number(id)];
return tiers.includes(tank.tier);
});
const id = Number(
filteredIds[Math.floor(Math.random() * filteredIds.length)]
);
const tank = tankDefinitions.tanks[id];
Guess.mutate((draft) => {
@@ -41,7 +41,7 @@ export function TankSearchCard({ tank, onSelect }: TankSearchCardProps) {
case 'fire.damage':
return shell0.armor_damage.toFixed(0);
case 'fire.moduleDamage':
return shell0.armor_damage.toFixed(0);
return shell0.module_damage.toFixed(0);
case 'fire.dispersionMoving':
return tracks.dispersion_move.toFixed(3);
case 'fire.dispersionStill':
@@ -83,11 +83,36 @@ for (const gameModeIdString in gameModeRoleSets) {
for (const roleId of roles) {
const role = gameDefinitions.roles[roleId];
for (const id of role.consumables) {
outerLoop: for (const id of role.consumables) {
const name =
consumableDefinitions.consumables[id].name.locales[locales.default];
for (const otherConsumable of consumables.values()) {
const otherName =
consumableDefinitions.consumables[otherConsumable].name.locales[
locales.default
];
if (name === otherName) continue outerLoop;
}
consumables.add(id);
allGameModeConsumables.add(id);
}
for (const id of role.provisions) {
outerLoop: for (const id of role.provisions) {
const name =
provisionDefinitions.provisions[id].name.locales[locales.default];
for (const otherProvision of provisions.values()) {
const otherName =
provisionDefinitions.provisions[otherProvision].name.locales[
locales.default
];
if (name === otherName) continue outerLoop;
}
provisions.add(id);
allGameModeProvisions.add(id);
}
@@ -155,7 +180,7 @@ const TIERS = times(10, (i) => 10 - i);
export function FilterControl() {
return (
<Flex align="center" gap="2" wrap="wrap">
<Flex align="center" justify="center" gap="2" wrap="wrap">
<TiersFilter />
<ClassFilter />
<TypeFilter />
@@ -168,8 +193,7 @@ export function FilterControl() {
<ShellFilter />
<ConsumablesFilter />
<ProvisionsFilter />
<AbilitiesFilter />
<PowersFilter />
<GameModeAbilitiesFilter />
<ResetButton />
</Flex>
@@ -1031,22 +1055,33 @@ function ProvisionsFilter() {
);
}
function PowersFilter() {
function GameModeAbilitiesFilter() {
const { unwrap, strings } = useLocale();
const rawAbilities = TankFilters.use((state) => state.abilities);
const rawPowers = TankFilters.use((state) => state.powers);
const abilities =
rawAbilities.length === 0 && rawPowers.length === 0
? Array.from(allGameModeConsumables.values())
: rawAbilities;
const powers =
rawPowers.length === 0
rawPowers.length === 0 && rawAbilities.length === 0
? Array.from(allGameModeProvisions.values())
: rawPowers;
const icons = [
...abilities.map((ability) => `icons/consumables/${ability}.webp`),
...powers.map((power) => `icons/provisions/${power}.webp`),
];
return (
<DropdownMenu.Root modal={false}>
<DropdownMenu.Trigger>
<Button color="gray" variant="surface">
<Flex>
{powers.slice(0, MAX_ICONS).map((power, index) => (
{icons.slice(0, MAX_ICONS).map((icon, index) => (
<img
key={power}
key={icon}
style={{
filter: "drop-shadow(0 0 var(--space-1) var(--black-a11))",
marginLeft: index > 0 ? "-0.5em" : undefined,
@@ -1054,14 +1089,14 @@ function PowersFilter() {
height: "1.25em",
objectFit: "contain",
}}
src={asset(`icons/provisions/${power}.webp`)}
src={asset(icon)}
/>
))}
{powers.length > MAX_ICONS && (
{icons.length > MAX_ICONS && (
<Text size="1" ml="1">
{literals(strings.common.units.plus, {
value: powers.length - MAX_ICONS,
value: icons.length - MAX_ICONS,
})}
</Text>
)}
@@ -1070,14 +1105,75 @@ function PowersFilter() {
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{gameModeRoles.map(({ gameModeId, provisions }) => {
{gameModeRoles.map(({ gameModeId, consumables, provisions }) => {
const gameMode = gameDefinitions.gameModes[gameModeId];
const hasNeither =
consumables.length === 0 && provisions.length === 0;
if (provisions.length === 0) return null;
if (hasNeither) return null;
const hasBoth = consumables.length > 0 && provisions.length > 0;
return (
<Fragment key={gameModeId}>
<DropdownMenu.Label>{unwrap(gameMode.name)}</DropdownMenu.Label>
{consumables.length > 0 && (
<DropdownMenu.Label>
{hasBoth
? literals(strings.website.common.tank_search.active, {
name: unwrap(gameMode.name),
})
: unwrap(gameMode.name)}
</DropdownMenu.Label>
)}
{consumables.map((consumable) => {
{
const selected = rawAbilities.includes(consumable);
const abilityDefinition =
consumableDefinitions.consumables[consumable];
return (
<DropdownMenu.CheckboxItem
onClick={(event) => {
event.preventDefault();
TankFilters.mutate((draft) => {
if (selected) {
draft.abilities = draft.abilities.filter(
(n) => n !== consumable
);
} else {
draft.abilities = [...draft.abilities, consumable];
}
});
}}
checked={selected}
key={consumable}
>
<img
style={{
width: "1.25em",
height: "1.25em",
objectFit: "contain",
}}
src={asset(`icons/consumables/${consumable}.webp`)}
/>
{unwrap(abilityDefinition.name)}
</DropdownMenu.CheckboxItem>
);
}
})}
{provisions.length > 0 && (
<DropdownMenu.Label>
{hasBoth
? literals(strings.website.common.tank_search.passive, {
name: unwrap(gameMode.name),
})
: unwrap(gameMode.name)}
</DropdownMenu.Label>
)}
{provisions.map((provision) => {
{
@@ -1129,6 +1225,7 @@ function PowersFilter() {
event.preventDefault();
TankFilters.mutate((draft) => {
draft.abilities = [];
draft.powers = [];
});
}}
@@ -1140,113 +1237,3 @@ function PowersFilter() {
</DropdownMenu.Root>
);
}
function AbilitiesFilter() {
const { unwrap, strings } = useLocale();
const rawAbilities = TankFilters.use((state) => state.abilities);
const abilities =
rawAbilities.length === 0
? Array.from(allGameModeConsumables.values())
: rawAbilities;
return (
<DropdownMenu.Root modal={false}>
<DropdownMenu.Trigger>
<Button color="gray" variant="surface">
<Flex>
{abilities.slice(0, MAX_ICONS).map((ability, index) => (
<img
key={ability}
style={{
filter: "drop-shadow(0 0 var(--space-1) var(--black-a11))",
marginLeft: index > 0 ? "-0.5em" : undefined,
width: "1.25em",
height: "1.25em",
objectFit: "contain",
}}
src={asset(`icons/consumables/${ability}.webp`)}
/>
))}
{abilities.length > MAX_ICONS && (
<Text size="1" ml="1">
{literals(strings.common.units.plus, {
value: abilities.length - MAX_ICONS,
})}
</Text>
)}
</Flex>
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{gameModeRoles.map(({ gameModeId, consumables }) => {
const gameMode = gameDefinitions.gameModes[gameModeId];
if (consumables.length === 0) return null;
return (
<Fragment key={gameModeId}>
<DropdownMenu.Label>{unwrap(gameMode.name)}</DropdownMenu.Label>
{consumables.map((consumable) => {
{
const selected = rawAbilities.includes(consumable);
const abilityDefinition =
consumableDefinitions.consumables[consumable];
return (
<DropdownMenu.CheckboxItem
onClick={(event) => {
event.preventDefault();
TankFilters.mutate((draft) => {
if (selected) {
draft.abilities = draft.abilities.filter(
(n) => n !== consumable
);
} else {
draft.abilities = [...draft.abilities, consumable];
}
});
}}
checked={selected}
key={consumable}
>
<img
style={{
width: "1.25em",
height: "1.25em",
objectFit: "contain",
}}
src={asset(`icons/consumables/${consumable}.webp`)}
/>
{unwrap(abilityDefinition.name)}
</DropdownMenu.CheckboxItem>
);
}
})}
</Fragment>
);
})}
<DropdownMenu.Separator />
<DropdownMenu.Item
color="red"
onClick={(event) => {
event.preventDefault();
TankFilters.mutate((draft) => {
draft.abilities = [];
});
}}
>
<TrashIcon />
{strings.website.common.tank_search.clear}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
);
}
@@ -1,5 +1,5 @@
import { Box, Flex, Heading } from "@radix-ui/themes";
import { Suspense } from "react";
import { Suspense, useEffect } from "react";
import { GuessBackground } from "../../../components/GuessBackground";
import { Guesser } from "../../../components/Guesser";
import { GuessRenderer } from "../../../components/GuessRenderer";
@@ -62,6 +62,31 @@ function Content({ skeleton }: MaybeSkeletonComponentProps) {
const name = unwrap(tank.name);
const fontSize = `min(48vh, ${55 / name.length}vw)`;
const transitionDuration = isRevealed ? "2s" : undefined;
const tiers = Guess.use((state) => state.tiers);
useEffect(() => {
const isTankAlreadyCompliant = tiers.includes(tank.tier);
if (isTankAlreadyCompliant) return;
const filteredIds = ids.filter((id) => {
const tank = tankDefinitions.tanks[Number(id)];
return tiers.includes(tank.tier);
});
if (filteredIds.length === 0) return;
const id = Number(
filteredIds[Math.floor(Math.random() * filteredIds.length)]
);
const newTank = tankDefinitions.tanks[id];
Guess.mutate((draft) => {
draft.tank = newTank;
draft.guessState = GuessState.NotGuessed;
draft.helpingReveal = false;
});
}, [tiers]);
return (
<Flex flexGrow="1" position="relative" overflow="hidden">
+3
View File
@@ -1,4 +1,5 @@
import type { TankDefinition } from "@blitzkit/core";
import { times } from "lodash-es";
import { Varuna } from "varuna";
export enum GuessState {
@@ -14,6 +15,7 @@ export interface Guess {
correctGuesses: number;
streak: number;
helpingReveal: boolean;
tiers: number[];
}
export const Guess = new Varuna<Guess, TankDefinition>((tank) => ({
@@ -23,4 +25,5 @@ export const Guess = new Varuna<Guess, TankDefinition>((tank) => ({
correctGuesses: 0,
streak: 0,
helpingReveal: false,
tiers: times(10, (index) => index + 1),
}));