This commit is contained in:
tresabhi committed 2026-08-01 14:55:57 -05:00
1 parent ed92440b94
commit f85d097c16
44 files changed
+5 -5

No files matched your search

@@ -0,0 +1,51 @@
import { useEffect } from "react";
import { App } from "../../../../stores/app";
export const AUTH_PROVIDERS = ["wargaming"] as const;
export type AuthProvider = (typeof AUTH_PROVIDERS)[number];
export const AUTH_PROVIDER_NAMES: Record<AuthProvider, string> = {
wargaming: "Wargaming",
};
interface AuthorizeProps {
provider: AuthProvider;
}
export function Authorize({ provider }: AuthorizeProps) {
useEffect(() => {
(async () => {
const searchParams = new URLSearchParams(window.location.search);
switch (provider) {
case "wargaming": {
if (
!searchParams.has("expires_at") ||
!searchParams.has("access_token") ||
!searchParams.has("account_id")
) {
break;
}
App.mutate((draft) => {
draft.logins.wargaming = {
expires: Number(searchParams.get("expires_at")) * 1000,
token: searchParams.get("access_token")!,
id: Number(searchParams.get("account_id"))!,
};
});
break;
}
default:
throw new Error(`Unknown provider: ${provider}`);
}
window.location.href = searchParams.get("return") ?? "/";
})();
}, []);
return null;
}
@@ -0,0 +1,38 @@
---
import { Flex, Heading, Text } from "@radix-ui/themes";
import type { GetStaticPaths } from "astro";
import { PageWrapper } from "../../../../components/PageWrapper";
import BaseLayout from "../../../../layouts/BaseLayout.astro";
import { getStaticPaths as parentGetStaticPaths } from "../../index.astro";
import { AUTH_PROVIDER_NAMES, AUTH_PROVIDERS, Authorize } from "./_index";
export const getStaticPaths = (() => {
const parentPaths = parentGetStaticPaths();
return AUTH_PROVIDERS.map((provider) =>
parentPaths.map((parent) => ({ params: { ...parent.params, provider } }))
).flat();
}) satisfies GetStaticPaths;
const { provider } = Astro.params;
const { locale } = Astro.props;
---
<BaseLayout {locale}>
<PageWrapper>
<Flex
style={{ flex: 1 }}
align="center"
justify="center"
direction="column"
gap="2"
>
<Heading align="center"
>Authorizing you with {AUTH_PROVIDER_NAMES[provider]}...</Heading
>
<Text color="gray" align="center">You'll be on your way shortly.</Text>
</Flex>
</PageWrapper>
<Authorize client:only="react" {provider} />
</BaseLayout>
@@ -0,0 +1,157 @@
import { createDefaultSkills } from "@blitzkit/core";
import { Flex, Heading, Text } from "@radix-ui/themes";
import { useEffect, useMemo, useState } from "react";
import { CompareTable } from "../../../components/Compare/CompareTable";
import { Controls } from "../../../components/Compare/Controls";
import { DamageWarning } from "../../../components/DamageWarning";
import { PageWrapper } from "../../../components/PageWrapper";
import { awaitableEquipmentDefinitions } from "../../../core/awaitables/equipmentDefinitions";
import { awaitableModelDefinitions } from "../../../core/awaitables/modelDefinitions";
import { awaitableProvisionDefinitions } from "../../../core/awaitables/provisionDefinitions";
import { awaitableSkillDefinitions } from "../../../core/awaitables/skillDefinitions";
import { awaitableTankDefinitions } from "../../../core/awaitables/tankDefinitions";
import { tankCharacteristics } from "../../../core/blitzkit/tankCharacteristics";
import { tankToCompareMember } from "../../../core/blitzkit/tankToCompareMember";
import {
type LocaleAcceptorProps,
LocaleProvider,
useLocale,
} from "../../../hooks/useLocale";
import { CompareEphemeral } from "../../../stores/compareEphemeral";
const [
modelDefinitions,
equipmentDefinitions,
provisionDefinitions,
skillDefinitions,
tankDefinitions,
] = await Promise.all([
awaitableModelDefinitions,
awaitableEquipmentDefinitions,
awaitableProvisionDefinitions,
awaitableSkillDefinitions,
awaitableTankDefinitions,
]);
export function Page({ locale }: LocaleAcceptorProps) {
CompareEphemeral.useInitialization(createDefaultSkills(skillDefinitions));
return (
<LocaleProvider locale={locale}>
<Content />
</LocaleProvider>
);
}
function Content() {
const members = CompareEphemeral.use((state) => state.members);
const equalize = CompareEphemeral.use((state) => state.equalize);
const [addTankDialogOpen, setAddTankDialogOpen] = useState(false);
const crewSkills = CompareEphemeral.use((state) => state.crewSkills);
const { strings } = useLocale();
const stats = useMemo<ReturnType<typeof tankCharacteristics>[]>(
() =>
members.map((thisMember) =>
tankCharacteristics(
{
...thisMember,
crewSkills,
stockEngine: thisMember.tank.engines[0],
stockGun: thisMember.tank.turrets[0].guns[0],
stockTrack: thisMember.tank.tracks[0],
stockTurret: thisMember.tank.turrets[0],
applyReactiveArmor: members.some(
(member) =>
member.key !== thisMember.key &&
member.consumables.includes(33),
),
applyDynamicArmor: members.some(
(member) =>
member.key !== thisMember.key &&
member.consumables.includes(73),
),
applySpallLiner: members.some(
(member) =>
member.key !== thisMember.key &&
member.provisions.includes(101),
),
assaultDistance: thisMember.assaultDistance,
equalize,
},
{
equipmentDefinitions: equipmentDefinitions,
provisionDefinitions: provisionDefinitions,
tankModelDefinition: modelDefinitions.models[thisMember.tank.id],
},
),
),
[members, crewSkills, equalize],
);
useEffect(() => {
const search = new URLSearchParams(window.location.search);
const tanksQuery = search.get("tanks");
if (tanksQuery === null) return;
const tanks = tanksQuery.split(",").map(Number);
CompareEphemeral.mutate((draft) => {
draft.members = [];
tanks.forEach((id) => {
const tank = tankDefinitions.tanks[id];
if (tank === undefined) return;
draft.members.push(tankToCompareMember(tank, provisionDefinitions));
});
});
}, []);
useEffect(() => {
const search = new URLSearchParams(window.location.search);
search.set("tanks", members.map((member) => member.tank.id).join(","));
window.history.replaceState(
{},
"",
`${window.location.pathname}?${search.toString()}`,
);
}, [members]);
return (
<PageWrapper color="crimson" maxWidth="100%" px="0" pb="0">
<Flex justify="center" gap="4" align="center" direction="column">
<DamageWarning />
<Controls
addTankDialogOpen={addTankDialogOpen}
onAddTankDialogOpenChange={setAddTankDialogOpen}
/>
</Flex>
{members.length > 0 && (
<Flex justify="center" flexGrow="1" position="relative">
<CompareTable stats={stats} />
</Flex>
)}
{members.length === 0 && (
<Flex
align="center"
justify="center"
direction="column"
style={{ flex: 1 }}
>
<Heading color="gray">
{strings.website.tools.compare.no_tanks.title}
</Heading>
<Text color="gray">
{strings.website.tools.compare.no_tanks.body}
</Text>
</Flex>
)}
</PageWrapper>
);
}
@@ -0,0 +1,16 @@
---
import ToolOpenGraph from "../../../components/ToolOpenGraph.astro";
import { tools } from "../../../constants/tools";
import BaseLayout from "../../../layouts/BaseLayout.astro";
import { Page } from "./_index";
export { getStaticPaths } from "../index.astro";
const { locale } = Astro.props;
---
<ToolOpenGraph {locale} tool={tools.compare} />
<BaseLayout {locale} noFooter>
<Page {locale} client:load />
</BaseLayout>
@@ -0,0 +1,14 @@
import { Markdown } from "../../../../../components/Markdown";
import { PageWrapper } from "../../../../../components/PageWrapper";
interface PageProps {
content: string;
}
export function Page({ content }: PageProps) {
return (
<PageWrapper maxWidth="50rem">
<Markdown>{content}</Markdown>
</PageWrapper>
);
}
@@ -0,0 +1,58 @@
---
import { AstroSeo } from "@astrolib/seo";
import type { GetStaticPaths } from "astro";
import { readFile } from "fs/promises";
import BaseLayout from "../../../../../layouts/BaseLayout.astro";
import { getStaticPaths as parentGetStaticPaths } from "../../../index.astro";
import { Page } from "./_index";
export const getStaticPaths = (async () => {
const parentPaths = parentGetStaticPaths();
const ROOT = "../../../../../../../../docs/";
const files = import.meta.glob("../../../../../../../../docs/**/*.md", {
eager: true,
});
return (
await Promise.all(
Object.keys(files).map(async (file) => {
const couple = file.replace(ROOT, "").split("/");
couple[1] = couple[1].replace(".md", "");
const content = await readFile(
`../../docs/${couple[0]}/${couple[1]}.md`
).then((buffer) => buffer.toString());
return parentPaths.map((parent) => ({
params: {
...parent.params,
directory: couple[0],
document: couple[1],
},
props: { ...parent.props, content },
}));
})
)
).flat();
}) satisfies GetStaticPaths;
const { content, locale } = Astro.props;
const title = content.split("\n")[0].replace("# ", "");
---
<AstroSeo
title={title}
description={title}
canonical={Astro.url.href}
openGraph={{
url: Astro.url.href,
title,
description: title,
site_name: "BlitzKit",
}}
twitter={{ cardType: "summary_large_image" }}
/>
<BaseLayout {locale}>
<Page content={content} />
</BaseLayout>
@@ -0,0 +1,67 @@
---
import { AstroSeo } from "@astrolib/seo";
import { MagicWandIcon, StarFilledIcon } from "@radix-ui/react-icons";
import { Flex, Heading, Link } from "@radix-ui/themes";
import { compareVersions } from "compare-versions";
import { PageWrapper } from "../../../../components/PageWrapper";
import { getStrings } from "../../../../core/i18n/getStrings";
import BaseLayout from "../../../../layouts/BaseLayout.astro";
export { getStaticPaths } from "../../index.astro";
// this can't be used in template literal :(
const ROOT = "../../../../../../../docs/changelogs/";
const { locale } = Astro.props;
const strings = getStrings(locale);
const versions = Object.keys(
import.meta.glob("../../../../../../../docs/changelogs/*.md", {
eager: true,
})
)
.map((file) => file.replace(ROOT, "").replace(".md", ""))
.sort(compareVersions)
.reverse();
---
<AstroSeo
title={strings.website.docs.patches.name}
description={strings.website.docs.patches.seo_description}
canonical={Astro.url.href}
openGraph={{
url: Astro.url.href,
title: strings.website.docs.patches.name,
description: strings.website.docs.patches.seo_description,
site_name: "BlitzKit",
}}
twitter={{ cardType: "summary_large_image" }}
/>
<BaseLayout {locale}>
<PageWrapper align="center">
<Heading mt="6" mb="4">BlitzKit patches</Heading>
<Flex direction="column" gap="2" align="center">
{
versions.map((version) => {
const split = version.split(".").map(Number);
const isMajor = split[2] === 0;
return (
<Link
size="5"
href={`/docs/changelogs/${version}`}
mb={isMajor ? "6" : "0"}
>
<Flex align="center" gap="2">
{!isMajor && <MagicWandIcon />}
{isMajor && <StarFilledIcon />}
{version}
</Flex>
</Link>
);
})
}
</Flex>
</PageWrapper>
</BaseLayout>
@@ -0,0 +1,288 @@
import {
CodeIcon,
HeightIcon,
ImageIcon,
ResetIcon,
TimerIcon,
WidthIcon,
} from "@radix-ui/react-icons";
import { Box, Button, Flex, Heading, ScrollArea, Text } from "@radix-ui/themes";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { CopyButton } from "../../../../components/CopyButton";
import { Boolean } from "../../../../components/Embeds/Boolean";
import { Color } from "../../../../components/Embeds/Color";
import { Enum } from "../../../../components/Embeds/Enum";
import { GenerateURL } from "../../../../components/Embeds/GenerateURL";
import { Import } from "../../../../components/Embeds/Import";
import { PreviewWrapper } from "../../../../components/Embeds/PreviewWrapper";
import { Radius } from "../../../../components/Embeds/Radius";
import { RichText } from "../../../../components/Embeds/RichText";
import { Size } from "../../../../components/Embeds/Size";
import { SizeWithout0 } from "../../../../components/Embeds/SizeWithout0";
import { Slider } from "../../../../components/Embeds/Slider";
import { TextController } from "../../../../components/Embeds/TextController";
import { PageWrapper } from "../../../../components/PageWrapper";
import {
embedConfigurations,
extractEmbedConfigDefaults,
} from "../../../../constants/embeds";
import { NAVBAR_HEIGHT } from "../../../../constants/navbar";
import {
LocaleProvider,
useLocale,
type LocaleAcceptorProps,
} from "../../../../hooks/useLocale";
import { EmbedState, type EmbedConfig } from "../../../../stores/embedState";
import { EmbedItemType } from "../../../../stores/embedState/constants";
export interface EmbedPreviewControllerProps {
configKey: string;
}
interface PageProps {
embed: keyof typeof embedConfigurations;
}
export function Page({ embed, locale }: PageProps & LocaleAcceptorProps) {
const config = embedConfigurations[embed];
EmbedState.useInitialization(extractEmbedConfigDefaults(config));
return (
<LocaleProvider locale={locale}>
<Content embed={embed} />
</LocaleProvider>
);
}
function Content({ embed }: PageProps) {
const config = embedConfigurations[embed] as EmbedConfig;
const [backgroundImage, setBackgroundImage] = useState(
"/assets/images/backgrounds/embed-default.webp"
);
const fileInput = useRef<HTMLInputElement>(null!);
const { strings } = useLocale();
useEffect(() => {
fileInput.current = document.createElement("input");
fileInput.current.type = "file";
fileInput.current.style.display = "none";
fileInput.current.accept = "image/*";
fileInput.current.onchange = (event) => {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
setBackgroundImage(URL.createObjectURL(file));
};
}, []);
return (
<PageWrapper padding="0" maxWidth="104rem" color="red">
<Flex>
<ScrollArea
scrollbars="vertical"
style={{ height: "100%", maxWidth: 320 }}
>
<Flex direction="column" gap="2" p="4">
<Import />
<Heading>
{strings.website.tools.embed.configuration.export.title}
</Heading>
<Text size="2" color="gray" mb="2">
{strings.website.tools.embed.configuration.export.description}
</Text>
<Flex mb="6" gap="2" wrap="wrap">
<GenerateURL embed={embed} />
<CopyButton
variant="outline"
copy={() =>
"body { margin: 0; background-color: transparent; overflow: hidden; }"
}
>
<CodeIcon />
{strings.website.tools.embed.configuration.export.css}
</CopyButton>
<CopyButton
variant="outline"
copy={() => `${EmbedState.state.width}`}
>
<WidthIcon />
{strings.website.tools.embed.configuration.export.width}
</CopyButton>
<CopyButton
variant="outline"
copy={() => `${EmbedState.state.height}`}
>
<HeightIcon />
{strings.website.tools.embed.configuration.export.height}
</CopyButton>
<CopyButton variant="outline" copy={() => "1"}>
<TimerIcon />
{strings.website.tools.embed.configuration.export.framerate}
</CopyButton>
</Flex>
<Flex justify="between" align="center">
<Heading>
{strings.website.tools.embed.configuration.customize.title}
</Heading>
<Button
color="red"
variant="ghost"
onClick={() => {
EmbedState.set(EmbedState.initial);
}}
>
<ResetIcon />{" "}
{strings.website.tools.embed.configuration.customize.reset}
</Button>
</Flex>
<Text size="2" color="gray" mb="2">
{strings.website.tools.embed.configuration.customize.description}
</Text>
{Object.keys(config).map((configKey) => {
const item = config[configKey];
let control: ReactNode;
let oneLiner = false;
switch (item.type) {
case EmbedItemType.Boolean: {
control = <Boolean configKey={configKey} />;
oneLiner = true;
break;
}
case EmbedItemType.Color: {
control = <Color configKey={configKey} />;
break;
}
case EmbedItemType.Radius: {
control = <Radius configKey={configKey} />;
oneLiner = true;
break;
}
case EmbedItemType.Size: {
control = <Size configKey={configKey} />;
oneLiner = true;
break;
}
case EmbedItemType.SizeWithout0: {
control = <SizeWithout0 configKey={configKey} />;
oneLiner = true;
break;
}
case EmbedItemType.Slider: {
control = <Slider configKey={configKey} config={item} />;
oneLiner = true;
break;
}
case EmbedItemType.String: {
control = <TextController configKey={configKey} />;
break;
}
case EmbedItemType.RichText: {
control = <RichText configKey={configKey} />;
break;
}
case EmbedItemType.Enum: {
control = <Enum configKey={configKey} config={item} />;
oneLiner = true;
break;
}
}
return (
<Flex
key={configKey}
direction={oneLiner ? undefined : "column"}
gap={oneLiner ? "2" : "1"}
justify={oneLiner ? "between" : undefined}
mb={oneLiner ? "2" : "4"}
pb={item.pad ? "6" : undefined}
>
<Text>
{
(
strings.website.tools.embed.types[embed]
.options as Record<string, string>
)[configKey]
}
</Text>
<Flex
gap="2"
align="center"
flexGrow="1"
justify={oneLiner ? "end" : undefined}
>
{control}
</Flex>
</Flex>
);
})}
</Flex>
</ScrollArea>
<Box
p="4"
flexGrow="1"
position="sticky"
top={`${NAVBAR_HEIGHT}px`}
height={`calc(100vh - ${NAVBAR_HEIGHT}px)`}
>
<Flex
direction="column"
height="100%"
style={{
borderRadius: "var(--radius-3)",
overflow: "hidden",
boxShadow: "var(--shadow-3)",
}}
>
<Flex
justify="between"
align="center"
p="2"
pl="3"
style={{
backgroundColor: "var(--color-panel-solid)",
}}
>
<Text color="gray">
{strings.website.tools.embed.preview.title}
</Text>
<Button
variant="outline"
color="gray"
onClick={() => fileInput.current?.click()}
>
<ImageIcon /> {strings.website.tools.embed.preview.upload}
</Button>
</Flex>
<Box
flexGrow="1"
style={{
background: `url(${backgroundImage}) center / cover no-repeat`,
}}
position="relative"
overflow="hidden"
>
<PreviewWrapper name={embed} />
</Box>
</Flex>
</Box>
</Flex>
</PageWrapper>
);
}
@@ -0,0 +1,60 @@
import { Box } from "@radix-ui/themes";
import { useMemo } from "react";
import { parse } from "urlon";
import { BlitzKitTheme } from "../../../../../components/BlitzKitTheme";
import {
embedConfigurations,
embedRenderers,
extractEmbedConfigDefaults,
} from "../../../../../constants/embeds";
import {
LocaleProvider,
type LocaleAcceptorProps,
} from "../../../../../hooks/useLocale";
import {
EmbedState,
type EmbedConfig,
type ExtractEmbedConfigTypes,
} from "../../../../../stores/embedState";
import { useEmbedStateCurry } from "../../../../../stores/embedState/utilities";
interface PageProps {
embed: keyof typeof embedConfigurations;
}
export function Page({ embed, locale }: PageProps & LocaleAcceptorProps) {
const state = useMemo(() => {
const params = new URLSearchParams(window.location.search);
const stateRaw = params.get("state");
const partialState = stateRaw === null ? {} : parse(stateRaw);
const state = {
...extractEmbedConfigDefaults(embedConfigurations[embed]),
...partialState,
} as ExtractEmbedConfigTypes<EmbedConfig>;
return state;
}, [embed]);
EmbedState.useInitialization(state);
return (
<LocaleProvider locale={locale}>
<Content embed={embed} />
</LocaleProvider>
);
}
function Content({ embed }: PageProps) {
const Renderer = embedRenderers[embed];
const { useEmbedState } = useEmbedStateCurry();
const width = useEmbedState("width");
const height = useEmbedState("height");
return (
<BlitzKitTheme style={{ background: "transparent" }}>
<Box width={`${width}px`} height={`${height}px`} overflow="hidden">
<Renderer />
</Box>
</BlitzKitTheme>
);
}
@@ -0,0 +1,13 @@
---
import EmbedLayout from "../../../../../layouts/EmbedLayout.astro";
import type { getStaticPaths } from "../index.astro";
import { Page } from "./_index";
export { getStaticPaths } from "../index.astro";
const { locale } = Astro.props;
---
<EmbedLayout>
<Page {locale} client:only="react" embed={Astro.params.embed} />
</EmbedLayout>
@@ -0,0 +1,65 @@
---
import { AstroSeo } from "@astrolib/seo";
import type { GetStaticPaths } from "astro";
import { capitalize } from "lodash-es";
import { embedConfigurations } from "../../../../constants/embeds";
import BaseLayout from "../../../../layouts/BaseLayout.astro";
import { getStaticPaths as parentGetStaticPaths } from "../index.astro";
import { Page } from "./_index";
export const getStaticPaths = (() => {
const parentPaths = parentGetStaticPaths();
return Object.keys(embedConfigurations)
.map((embed) =>
parentPaths.map((parent) => ({
params: {
...parent.params,
embed: embed as keyof typeof embedConfigurations,
},
}))
)
.flat();
}) satisfies GetStaticPaths;
const name = capitalize(Astro.params.embed);
const title = `BlitzKit - ${name} embed`;
const description = `BlitzKit ${name} embeds for streaming World of Tanks Blitz with extreme customization`;
const { locale } = Astro.props;
---
<AstroSeo
title={title}
description={description}
canonical={Astro.url.href}
openGraph={{
url: Astro.url.href,
title,
description,
site_name: "BlitzKit",
}}
twitter={{ cardType: "summary_large_image" }}
/>
<style is:global>
@keyframes fade-out-border {
from {
outline: 1rem solid #ff0000ff;
}
to {
outline: 0.75rem solid #ff000000;
}
}
.preview-container-animated {
animation: fade-out-border 0.5s linear;
}
.preview-container-animated:hover {
outline: 1rem solid #ff000080;
}
</style>
<BaseLayout {locale}>
<Page {locale} client:load embed={Astro.params.embed} />
</BaseLayout>
@@ -0,0 +1,92 @@
import { Box, Flex, Link, Text } from "@radix-ui/themes";
import { PreviewWrapper } from "../../../components/Embeds/PreviewWrapper";
import { LinkI18n } from "../../../components/LinkI18n";
import { PageWrapper } from "../../../components/PageWrapper";
import { embedConfigurations } from "../../../constants/embeds";
import { Var } from "../../../core/radix/var";
import {
type LocaleAcceptorProps,
LocaleProvider,
useLocale,
} from "../../../hooks/useLocale";
export function Page({ locale }: LocaleAcceptorProps) {
return (
<LocaleProvider locale={locale}>
<Content />
</LocaleProvider>
);
}
function Content() {
const { locale, strings } = useLocale();
return (
<PageWrapper color="red">
<Text align="center" mt="3">
{strings.website.tools.embed.coming_soon}{" "}
<Link
underline="always"
href="https://discord.gg/nDt7AjGJQH"
target="_blank"
>
{strings.website.tools.embed.suggest}
</Link>
</Text>
<Flex justify="center">
{Object.keys(embedConfigurations).map((embed) => (
<LinkI18n
locale={locale}
href={`/embed/${embed}`}
color="gray"
highContrast
style={{
width: "fit-content",
height: "fit-content",
}}
underline="hover"
>
<Flex
direction="column"
overflow="hidden"
style={{
borderRadius: Var("radius-4"),
backgroundColor: Var("color-panel-solid"),
boxShadow: Var("shadow-3"),
}}
>
<Flex justify="center" p="2">
<Text>
{
strings.website.tools.embed.types[
embed as keyof typeof strings.website.tools.embed.types
].name
}
</Text>
</Flex>
<Box
width="20rem"
height="20rem"
overflow="hidden"
position="relative"
style={{
backgroundImage:
"url(/assets/images/backgrounds/embed-default.webp)",
backgroundSize: "cover",
backgroundPosition: "center",
}}
>
<PreviewWrapper
naked
name={embed as keyof typeof embedConfigurations}
/>
</Box>
</Flex>
</LinkI18n>
))}
</Flex>
</PageWrapper>
);
}
@@ -0,0 +1,13 @@
---
import { tools } from "../../../constants/tools";
import ToolLayout from "../../../layouts/ToolLayout.astro";
import { Page } from "./_index";
export { getStaticPaths } from "../index.astro";
const { locale } = Astro.props;
---
<ToolLayout {locale} tool={tools.embed}>
<Page {locale} />
</ToolLayout>
@@ -0,0 +1,28 @@
import { GalleryList } from "../../../components/Gallery/List";
import { GallerySearch } from "../../../components/Gallery/Search";
import { PageWrapper } from "../../../components/PageWrapper";
import {
LocaleProvider,
type LocaleAcceptorProps,
} from "../../../hooks/useLocale";
import type { MaybeSkeletonComponentProps } from "../../../types/maybeSkeletonComponentProps";
export function Page({
locale,
...props
}: LocaleAcceptorProps & MaybeSkeletonComponentProps) {
return (
<LocaleProvider locale={locale}>
<Content {...props} />
</LocaleProvider>
);
}
function Content({ skeleton }: MaybeSkeletonComponentProps) {
return (
<PageWrapper maxWidth="80rem" color="gold">
<GallerySearch skeleton={skeleton} />
<GalleryList skeleton={skeleton} />
</PageWrapper>
);
}
@@ -0,0 +1,16 @@
---
import { tools } from "../../../constants/tools";
import ToolLayout from "../../../layouts/ToolLayout.astro";
import type { getStaticPaths } from "../index.astro";
import { Page } from "./_index";
export { getStaticPaths } from "../index.astro";
const { locale } = Astro.props;
---
<ToolLayout {locale} tool={tools.gallery}>
<Page {locale} client:only="react">
<Page {locale} skeleton slot="fallback" />
</Page>
</ToolLayout>
@@ -0,0 +1,147 @@
import { Box, Flex, Heading } from "@radix-ui/themes";
import { Suspense, useEffect } from "react";
import { GuessBackground } from "../../../components/GuessBackground";
import { Guesser } from "../../../components/Guesser";
import { GuessRenderer } from "../../../components/GuessRenderer";
import { GuessRendererLoader } from "../../../components/GuessRendererLoader";
import { PageWrapper } from "../../../components/PageWrapper";
import { awaitableModelDefinitions } from "../../../core/awaitables/modelDefinitions";
import { awaitableTankDefinitions } from "../../../core/awaitables/tankDefinitions";
import {
type LocaleAcceptorProps,
LocaleProvider,
useLocale,
} from "../../../hooks/useLocale";
import { Duel } from "../../../stores/duel";
import { Guess, GuessState } from "../../../stores/guess";
import { Tankopedia } from "../../../stores/tankopedia";
import type { MaybeSkeletonComponentProps } from "../../../types/maybeSkeletonComponentProps";
const [tankDefinitions, modelDefinitions] = await Promise.all([
awaitableTankDefinitions,
awaitableModelDefinitions,
]);
const ids = Object.keys(tankDefinitions.tanks);
export function Page({
locale,
skeleton,
}: LocaleAcceptorProps & MaybeSkeletonComponentProps) {
const id = Number(ids[Math.floor(Math.random() * ids.length)]);
const tank = tankDefinitions.tanks[id];
Guess.useInitialization(tank);
return (
<LocaleProvider locale={locale}>
<Container skeleton={skeleton} />
</LocaleProvider>
);
}
function Container({ skeleton }: MaybeSkeletonComponentProps) {
const tank = Guess.use((state) => state.tank);
const model = modelDefinitions.models[tank.id];
Tankopedia.useInitialization(model);
Duel.useInitialization({ tank, model });
return (
<PageWrapper p="0" maxWidth="unset" color="cyan">
<Content skeleton={skeleton} />
</PageWrapper>
);
}
function Content({ skeleton }: MaybeSkeletonComponentProps) {
const { unwrap } = useLocale();
const tank = Guess.use((state) => state.tank);
const guessState = Guess.use((state) => state.guessState);
const isRevealed = guessState !== GuessState.NotGuessed;
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">
<GuessBackground />
<Heading
style={{
transitionDuration,
opacity: isRevealed ? 1 : 0,
fontWeight: 900,
position: "absolute",
left: "50%",
top: "50%",
transform: "translate(-50%, -50%)",
fontSize,
userSelect: "none",
whiteSpace: "nowrap",
pointerEvents: "none",
}}
>
{name}
</Heading>
<Box
position="absolute"
top="0"
left="0"
width="100%"
height="100%"
style={{ transitionDuration }}
>
<Suspense fallback={<GuessRendererLoader />}>
{!skeleton && <GuessRenderer />}
</Suspense>
</Box>
<Heading
style={{
transitionDuration,
opacity: isRevealed ? 0.5 : 0,
fontWeight: 900,
position: "absolute",
left: "50%",
top: "50%",
transform: "translate(-50%, -50%)",
fontSize,
userSelect: "none",
whiteSpace: "nowrap",
pointerEvents: "none",
}}
>
{name}
</Heading>
<Guesser />
</Flex>
);
}
@@ -0,0 +1,18 @@
---
import ToolOpenGraph from "../../../components/ToolOpenGraph.astro";
import { tools } from "../../../constants/tools";
import BaseLayout from "../../../layouts/BaseLayout.astro";
import { Page } from "./_index";
export { getStaticPaths } from "../index.astro";
const { locale } = Astro.props;
---
<ToolOpenGraph {locale} tool={tools.guess} />
<BaseLayout {locale} noFooter>
<Page {locale} client:only="react">
<Page {locale} skeleton slot="fallback" />
</Page>
</BaseLayout>
@@ -0,0 +1,116 @@
---
import { AstroSeo } from "@astrolib/seo";
import { assertSecret } from "@blitzkit/core";
import locales from "@blitzkit/i18n/locales.json";
import { literals } from "@blitzkit/i18n/src/literals";
import { Box, Flex, Link } from "@radix-ui/themes";
import type { GetStaticPaths } from "astro";
import { AesonPlug } from "../../../components/AesonPlug";
import HomeHero from "../../../components/HomeHero.astro";
import { Plugs } from "../../../components/Plugs";
import ToolCard from "../../../components/ToolCard.astro";
import { tools, type Tool } from "../../../constants/tools";
import { getStrings } from "../../../core/i18n/getStrings";
import BaseLayout from "../../../layouts/BaseLayout.astro";
const MAX_TOOLS_PER_ROW = 2;
const PUBLIC_ASSET_BRANCH = assertSecret(import.meta.env.PUBLIC_BRANCH);
const { locale } = Astro.props;
const strings = getStrings(locale);
const rows: Tool[][] = [];
for (const id in tools) {
const tool = tools[id];
if ("disabled" in tool && tool.disabled) continue;
const compatibleRow = rows.find(
(row) =>
row.length < MAX_TOOLS_PER_ROW &&
!("significant" in row[0] && row[0].significant),
);
if (compatibleRow) {
compatibleRow.push(tool);
} else {
rows.push([tool]);
}
}
export const getStaticPaths = (() => {
return [
...locales.supported
.filter(({ locale }) => locale !== locales.default)
.map(({ locale }) => ({ params: { locale }, props: { locale } })),
{ params: { locale: undefined }, props: { locale: locales.default } },
];
}) satisfies GetStaticPaths;
---
<AstroSeo
title="BlitzKit"
description={strings.website.home.seo_description}
canonical={Astro.url.href}
openGraph={{
url: Astro.url.href,
title: "BlitzKit",
description: strings.website.home.seo_description,
site_name: "BlitzKit",
}}
twitter={{ cardType: "summary_large_image" }}
/>
<BaseLayout {locale}>
<HomeHero />
{
locale !== locales.default && (
<Flex justify="center" mb="4">
<Link
href="https://crowdin.com/project/blitzkrieg"
target="_blank"
underline="always"
>
{literals(strings.website.home.help_translate, {
name: "BlitzKit",
language:
strings.common.locales[
locale as keyof typeof strings.common.locales
],
})}
</Link>
</Flex>
)
}
<Plugs client:idle />
{
PUBLIC_ASSET_BRANCH === "preview" && (
<Flex justify="center" my="8" px="4">
<AesonPlug />
</Flex>
)
}
<Flex p="4" justify="center" gap="4" mt="3">
<Flex maxWidth="80rem" flexGrow="1" gap="4" direction="column">
{
rows.map((row) => (
<Flex gap="4" direction={{ initial: "column", sm: "row" }}>
{row.map((tool) => (
<ToolCard {locale} {tool} />
))}
{row.length < MAX_TOOLS_PER_ROW &&
!("significant" in row[0] && row[0].significant) && (
<Box flexGrow="1" />
)}
</Flex>
))
}
</Flex>
</Flex>
<div style={{ flex: 1 }}></div>
</BaseLayout>
@@ -0,0 +1,311 @@
import {
asset,
GunDefinition,
TankDefinition,
TIER_ROMAN_NUMERALS,
TurretDefinition,
} from "@blitzkit/core";
import { literals } from "@blitzkit/i18n";
import { CaretRightIcon, CaretUpIcon, UpdateIcon } from "@radix-ui/react-icons";
import {
Box,
Button,
Dialog,
Flex,
IconButton,
Popover,
Skeleton,
Text,
type ButtonProps,
} from "@radix-ui/themes";
import { useRef, useState } from "react";
import { MixerScene } from "../../../components/MixerScene";
import { ModuleButton } from "../../../components/ModuleButtons/ModuleButton";
import { PageWrapper } from "../../../components/PageWrapper";
import { ScreenshotButton } from "../../../components/ScreenshotButton";
import { TankSearch } from "../../../components/TankSearch";
import { awaitableModelDefinitions } from "../../../core/awaitables/modelDefinitions";
import { awaitableTankDefinitions } from "../../../core/awaitables/tankDefinitions";
import { curateMixer } from "../../../core/blitzkit/curateMixer";
import {
LocaleProvider,
useLocale,
type LocaleAcceptorProps,
} from "../../../hooks/useLocale";
import { Mixer } from "../../../stores/mixer";
import { Tankopedia } from "../../../stores/tankopedia";
import type { MaybeSkeletonComponentProps } from "../../../types/maybeSkeletonComponentProps";
const modelDefinition = await awaitableModelDefinitions.then(
({ models }) => models[1]
);
const tankDefinitions = await awaitableTankDefinitions;
export function Page({
locale,
skeleton,
}: LocaleAcceptorProps & MaybeSkeletonComponentProps) {
const initial = useRef(curateMixer());
Mixer.useInitialization(initial.current);
Tankopedia.useInitialization(modelDefinition);
return (
<PageWrapper color="gray" p="0" maxWidth="unset">
<LocaleProvider locale={locale}>
<Content skeleton={skeleton} />
</LocaleProvider>
</PageWrapper>
);
}
type ModuleButtonProps = Omit<ButtonProps, "onSelect"> &
MaybeSkeletonComponentProps & {
tank: number;
onSelect: (
tank: TankDefinition,
turret?: TurretDefinition,
gun?: GunDefinition
) => void;
turret?: number;
gun?: number;
};
function ModuleChooser({
skeleton,
tank,
onSelect,
turret,
gun,
...props
}: ModuleButtonProps) {
const { strings } = useLocale();
const [open, setOpen] = useState(false);
const tankDefinition = tankDefinitions.tanks[tank];
const turretDefinition = tankDefinition.turrets.find((t) => t.id === turret);
const gunDefinition = turretDefinition?.guns.find((g) => g.id === gun);
const subSelector =
turret !== undefined &&
(tankDefinition.turrets.length > 1 ||
(gun !== undefined && turretDefinition!.guns.length > 1));
return (
<Flex>
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger>
<Button
variant="surface"
size={{ initial: "3", md: "4" }}
style={{
overflow: "clip",
borderTopRightRadius: subSelector ? 0 : undefined,
borderBottomRightRadius: subSelector ? 0 : undefined,
}}
{...props}
>
{skeleton ? (
<Skeleton width="4em" height="2em" />
) : (
<img
style={{
width: "4em",
height: "4em",
objectFit: "contain",
}}
src={asset(`icons/tanks/big/${tank}.webp`)}
/>
)}
</Button>
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Title>
{strings.website.common.tank_search.select_dialog_title}
</Dialog.Title>
<TankSearch
compact
onSelect={(tank) => {
onSelect?.(tank);
setOpen(false);
}}
/>
</Dialog.Content>
</Dialog.Root>
{subSelector && (
<Popover.Root>
<Popover.Trigger>
<IconButton
variant="surface"
size={{ initial: "3", md: "4" }}
style={{
overflow: "clip",
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
marginLeft: -1,
}}
{...props}
>
<CaretUpIcon />
</IconButton>
</Popover.Trigger>
<Popover.Content>
<Flex direction="column" gap="2">
{tankDefinition.turrets.length > 1 && (
<Flex gap="2">
{tankDefinition.turrets.map((turret) => (
<ModuleButton
variant={
turret.id === turretDefinition?.id ? "solid" : undefined
}
key={turret.id}
module="turret"
discriminator={TIER_ROMAN_NUMERALS[turret.tier]}
onClick={() => {
onSelect?.(tankDefinition, turret);
}}
/>
))}
</Flex>
)}
{turretDefinition && turretDefinition.guns.length > 1 && (
<Flex gap="2">
{turretDefinition.guns.map((gun) => (
<ModuleButton
variant={
gun.id === gunDefinition?.id ? "solid" : undefined
}
key={gun.id}
module="gun"
discriminator={TIER_ROMAN_NUMERALS[gun.tier]}
secondaryDiscriminator={
<Text style={{ fontSize: "0.75em" }}>
{literals(strings.common.units.mm, {
value: gun.shells[0].caliber.toFixed(0),
})}
</Text>
}
onClick={() => {
onSelect?.(tankDefinition, turretDefinition, gun);
}}
/>
))}
</Flex>
)}
</Flex>
</Popover.Content>
</Popover.Root>
)}
</Flex>
);
}
function ModuleSeparator() {
return (
<Text size="5" color="gray" trim="end">
<CaretRightIcon width="1em" height="1em" />
</Text>
);
}
function Content({ skeleton }: MaybeSkeletonComponentProps) {
const hullTankId = Mixer.use((state) => state.hull);
const turretTankId = Mixer.use((state) => state.turret.tank.id);
const turretTurretId = Mixer.use((state) => state.turret.turret.id);
const gunTankId = Mixer.use((state) => state.gun.tank.id);
const gunTurretId = Mixer.use((state) => state.gun.turret.id);
const gunGunId = Mixer.use((state) => state.gun.gun.id);
const canvas = useRef<HTMLCanvasElement>(null!);
const { strings } = useLocale();
return (
<Box flexGrow="1" position="relative">
<Box position="absolute" width="100%" height="100%" top="0" left="0">
{!skeleton && <MixerScene ref={canvas} />}
</Box>
<Flex
align="center"
direction="column"
position="absolute"
bottom="4"
left="50%"
style={{ transform: "translateX(-50%)" }}
gap="4"
>
<Flex gap="2">
<ScreenshotButton
name={hullTankId.slug}
canvas={canvas}
highContrast
variant="surface"
>
{strings.website.tools.mixer.screenshot}
</ScreenshotButton>
<Button
highContrast
variant="surface"
onClick={() => {
const mixed = curateMixer();
Mixer.mutate((draft) => {
Object.assign(draft, mixed);
});
}}
>
<UpdateIcon />
{strings.website.tools.mixer.randomize}
</Button>
</Flex>
<Flex gap="2" align="center">
<ModuleChooser
skeleton={skeleton}
tank={hullTankId.id}
onSelect={(tank) => {
Mixer.mutate((draft) => {
draft.hull = tank;
});
}}
/>
<ModuleSeparator />
<ModuleChooser
skeleton={skeleton}
tank={turretTankId}
turret={turretTurretId}
onSelect={(tank, turret) => {
Mixer.mutate((draft) => {
draft.turret = {
tank,
turret: turret ?? tank.turrets.at(-1)!,
};
});
}}
/>
<ModuleSeparator />
<ModuleChooser
skeleton={skeleton}
tank={gunTankId}
turret={gunTurretId}
gun={gunGunId}
onSelect={(tank, turret, gun) => {
Mixer.mutate((draft) => {
draft.gun = {
tank,
turret: turret ?? tank.turrets.at(-1)!,
gun:
gun ??
turret?.guns.at(-1) ??
tank.turrets.at(-1)!.guns.at(-1)!,
};
});
}}
/>
</Flex>
</Flex>
</Box>
);
}
@@ -0,0 +1,18 @@
---
import ToolOpenGraph from "../../../components/ToolOpenGraph.astro";
import { tools } from "../../../constants/tools";
import BaseLayout from "../../../layouts/BaseLayout.astro";
import { Page } from "./_index";
export { getStaticPaths } from "../index.astro";
const { locale } = Astro.props;
---
<ToolOpenGraph {locale} tool={tools.mixer} />
<BaseLayout {locale} integrated>
<Page {locale} client:only="react">
<Page {locale} skeleton slot="fallback" />
</Page>
</BaseLayout>
@@ -0,0 +1,30 @@
import { PageWrapper } from "../../../components/PageWrapper";
import { PlayerCountControl } from "../../../components/Performance/PlayerCountControl";
import { TankTable } from "../../../components/Performance/Table";
import { FilterControl } from "../../../components/TankSearch/components/FilterControl";
import {
LocaleProvider,
type LocaleAcceptorProps,
} from "../../../hooks/useLocale";
import type { MaybeSkeletonComponentProps } from "../../../types/maybeSkeletonComponentProps";
type PageProps = MaybeSkeletonComponentProps & LocaleAcceptorProps;
export function Page({ skeleton, locale }: PageProps) {
return (
<LocaleProvider locale={locale}>
<Content skeleton={skeleton} />
</LocaleProvider>
);
}
function Content({ skeleton }: MaybeSkeletonComponentProps) {
return (
<PageWrapper pb="0" px="0" color="jade" maxWidth="100%">
<FilterControl />
<PlayerCountControl />
<TankTable skeleton={skeleton} />
</PageWrapper>
);
}
@@ -0,0 +1,19 @@
---
import ToolOpenGraph from "../../../../components/ToolOpenGraph.astro";
import { tools } from "../../../../constants/tools";
import BaseLayout from "../../../../layouts/BaseLayout.astro";
import type { getStaticPaths } from "../index.astro";
import { Page } from "./_index";
export { getStaticPaths } from "../index.astro";
const { locale } = Astro.props;
---
<ToolOpenGraph {locale} tool={tools.performance} />
<BaseLayout {locale} noFooter>
<Page client:only="react" {locale}>
<Page skeleton slot="fallback" {locale} />
</Page>
</BaseLayout>
@@ -0,0 +1,30 @@
import { PageWrapper } from "../../../components/PageWrapper";
import { PlaylistGenerator } from "../../../components/PlaylistGenerator";
import { PlaylistOrder } from "../../../components/PlaylistOrder";
import { LocaleProvider } from "../../../hooks/useLocale";
import { App } from "../../../stores/app";
import { Playlist } from "../../../stores/playlist";
import { TankFilters } from "../../../stores/tankFilters";
import type { MaybeSkeletonComponentProps } from "../../../types/maybeSkeletonComponentProps";
interface Props {
locale: string;
}
export function Page({
locale,
skeleton,
}: Props & MaybeSkeletonComponentProps) {
const generated = Playlist.use((state) => state.list !== undefined);
if (App.state.logins.wargaming) TankFilters.state.showUnowned = false;
return (
<LocaleProvider locale={locale}>
<PageWrapper color="tomato" direction="row" maxWidth="unset" p="0">
{!generated && <PlaylistGenerator skeleton={skeleton} />}
{generated && <PlaylistOrder />}
</PageWrapper>
</LocaleProvider>
);
}
@@ -0,0 +1,18 @@
---
import ToolOpenGraph from "../../../components/ToolOpenGraph.astro";
import { tools } from "../../../constants/tools";
import BaseLayout from "../../../layouts/BaseLayout.astro";
import { Page } from "./_index.tsx";
export { getStaticPaths } from "../index.astro";
const { locale } = Astro.props;
---
<ToolOpenGraph {locale} tool={tools.playlist} />
<BaseLayout {locale} noFooter>
<Page {locale} client:only="react">
<Page {locale} skeleton slot="fallback" />
</Page>
</BaseLayout>
@@ -0,0 +1,487 @@
import {
type AccountListWithServer,
type CompositeStatsKey,
type IndividualAccountInfo,
type IndividualTankStats,
compositeStats,
compositeStatsKeys,
deltaTankStats,
formatCompositeStat,
getAccountInfo,
getTankStats,
idToRegion,
searchPlayersAcrossRegions,
sumCompositeStats,
} from "@blitzkit/core";
import { literals } from "@blitzkit/i18n";
import {
ArrowDownIcon,
MagnifyingGlassIcon,
PlusIcon,
} from "@radix-ui/react-icons";
import {
AlertDialog,
Button,
DropdownMenu,
Flex,
Heading,
IconButton,
Select,
Table,
Text,
TextField,
} from "@radix-ui/themes";
import { debounce } from "lodash-es";
import { useEffect, useMemo, useRef, useState } from "react";
import { PageWrapper } from "../../../components/PageWrapper";
import { StickyRowHeaderCell } from "../../../components/StickyRowHeaderCell";
import { StickyTableRoot } from "../../../components/StickyTableRoot";
import { TankRowHeaderCell } from "../../../components/TankRowHeaderCell";
import { awaitableAverageDefinitions } from "../../../core/awaitables/averageDefinitions";
import { awaitableTankDefinitions } from "../../../core/awaitables/tankDefinitions";
import {
type LocaleAcceptorProps,
LocaleProvider,
useLocale,
} from "../../../hooks/useLocale";
import { Session, type SessionTracking } from "../../../stores/session";
const [tankDefinitions, averageDefinitions] = await Promise.all([
awaitableTankDefinitions,
awaitableAverageDefinitions,
]);
export function Page({ locale }: LocaleAcceptorProps) {
return (
<LocaleProvider locale={locale}>
<Content />
</LocaleProvider>
);
}
function Content() {
const { locale, strings } = useLocale();
const [showSearch, setShowSearch] = useState(false);
const [searching, setSearching] = useState(false);
const [searchResults, setSearchResults] = useState<AccountListWithServer>([]);
const [showCCInaccessibilityPrompt, setShowCCInaccessibilityPrompt] =
useState(false);
const input = useRef<HTMLInputElement>(null);
const session = Session.use();
const [tankStatsB, setTankStatsB] = useState<IndividualTankStats[] | null>(
null,
);
const [accountInfo, setAccountInfo] = useState<
IndividualAccountInfo | null | undefined
>(undefined);
useEffect(() => {
(async () => {
const id = session.tracking ? session.player.id : undefined;
if (!id) return setAccountInfo(null);
setAccountInfo(
session.tracking ? await getAccountInfo(idToRegion(id), id) : null,
);
})();
}, [session.tracking && session.player.id]);
const delta = useMemo(
() =>
session.tracking && tankStatsB
? deltaTankStats(session.player.stats, tankStatsB)
.sort((a, b) => b.last_battle_time - a.last_battle_time)
.map((entry) => {
const tank = tankDefinitions.tanks[entry.tank_id];
const average = averageDefinitions.averages[tank.id];
const composite = compositeStats(
{ ...entry.all, battle_life_time: entry.battle_life_time },
average?.mu!,
);
return { tank, composite };
})
: undefined,
[session.tracking && session.player, tankStatsB],
);
const total = useMemo(
() =>
delta
? sumCompositeStats(delta.map(({ composite }) => composite))
: undefined,
[delta],
);
const search = debounce(async () => {
if (!input.current) return;
setSearchResults(await searchPlayersAcrossRegions(input.current.value, 15));
setSearching(false);
}, 500);
async function track(id: number) {
const region = idToRegion(id);
const player = await getAccountInfo(region, id);
const stats = await getTankStats(region, id);
if (stats === null) {
setShowCCInaccessibilityPrompt(true);
return;
}
setShowSearch(false);
Session.mutate((draft) => {
if (!input.current) return;
draft.tracking = true;
(draft as SessionTracking).player = {
id,
region,
since: Date.now(),
stats,
};
input.current.value = player.nickname;
});
}
useEffect(() => {
async function update() {
if (!session.tracking) return;
const tankStats = await getTankStats(
idToRegion(session.player.id),
session.player.id,
);
setTankStatsB(tankStats);
}
update();
const interval = setInterval(update, 5 * 1000);
return () => clearInterval(interval);
}, [session.tracking && session.player.id]);
return (
<PageWrapper color="blue">
<AlertDialog.Root
open={showCCInaccessibilityPrompt}
onOpenChange={setShowCCInaccessibilityPrompt}
>
<AlertDialog.Content>
<AlertDialog.Title>
{strings.website.tools.session.no_cc.title}
</AlertDialog.Title>
<AlertDialog.Description>
{strings.website.tools.session.no_cc.body}
</AlertDialog.Description>
<Flex mt="4" justify="end">
<AlertDialog.Action>
<Button variant="solid">
{strings.website.tools.session.no_cc.alright}
</Button>
</AlertDialog.Action>
</Flex>
</AlertDialog.Content>
</AlertDialog.Root>
{!session.tracking && (
<Flex
style={{ flex: 1 }}
align="center"
justify="center"
direction="column"
gap="3"
>
<Flex gap="2" align="center">
<Text color="gray">
{strings.website.tools.session.search.lookup}
</Text>
<ArrowDownIcon />
</Flex>
<TextField.Root
variant="classic"
size="3"
style={{
position: "relative",
width: "75vw",
maxWidth: 480,
}}
ref={input}
placeholder={strings.website.tools.session.search.hint}
onChange={() => {
if (!input.current) return;
const sanitized = input.current.value.trim();
if (sanitized.length > 0) {
setShowSearch(true);
setSearching(true);
search();
} else {
setShowSearch(false);
}
}}
>
<TextField.Slot>
<MagnifyingGlassIcon />
</TextField.Slot>
</TextField.Root>
{showSearch && (
<Flex mt="2">
{searching && (
<Flex justify="center">
<Text color="gray">
{strings.website.tools.session.search.searching}
</Text>
</Flex>
)}
{!searching && searchResults.length === 0 && (
<Flex justify="center">
<Text color="gray">
{strings.website.tools.session.search.no_results}
</Text>
</Flex>
)}
{!searching && searchResults.length > 0 && (
<Flex direction="column" gap="2">
{searchResults.map((player) => (
<Button
key={player.account_id}
variant="ghost"
onClick={() => track(player.account_id)}
>
{player.nickname} (
{strings.common.regions.short[player.region]})
</Button>
))}
</Flex>
)}
</Flex>
)}
</Flex>
)}
{session.tracking && (
<Flex
mt="2"
mb="2"
direction={{ initial: "column", sm: "row" }}
align="center"
justify="between"
gap="2"
>
<Flex mt="2" mb="2" direction="column" gap="2">
<Heading size="5">
{literals(strings.website.tools.session.controls.title, {
name: accountInfo?.nickname,
})}
</Heading>
<Text color="gray">
{literals(strings.website.tools.session.controls.subtitle, {
time: new Date(session.player.since).toLocaleString(locale),
})}
</Text>
</Flex>
<Flex gap="2" align="center" justify="center">
<Button
color="red"
onClick={async () => {
const stats = await getTankStats(
session.player.region,
session.player.id,
);
if (stats === null) {
setShowCCInaccessibilityPrompt(true);
return;
}
Session.mutate((draft) => {
(draft as SessionTracking).player.stats = stats;
});
}}
>
{strings.website.tools.session.controls.reset}
</Button>
<Button
onClick={() => {
Session.mutate((draft) => {
draft.tracking = false;
});
}}
>
{strings.website.tools.session.controls.change}
</Button>
</Flex>
</Flex>
)}
{session.tracking && delta && delta.length > 0 && (
<Flex justify="center">
<StickyTableRoot variant="surface">
<Table.Header>
<Table.Row>
<Table.ColumnHeaderCell>
{strings.website.tools.session.table.tank}
</Table.ColumnHeaderCell>
{session.columns.map((column, index) => (
<Table.ColumnHeaderCell
align="center"
width="0"
key={column}
style={{ position: "relative" }}
>
{index === 0 && session.columns.length < 4 && (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<IconButton
size="1"
variant="surface"
style={{
position: "absolute",
left: 0,
top: "50%",
transform: "translate(-100%, -50%)",
zIndex: 1,
}}
>
<PlusIcon />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{compositeStatsKeys
.filter((item) => !session.columns.includes(item))
.map((key) => (
<DropdownMenu.Item
key={key}
onClick={() => {
Session.mutate((draft) => {
(draft as SessionTracking).columns.unshift(
key,
);
});
}}
>
{strings.common.composite_stats[key]}
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
)}
<Select.Root
value={column}
onValueChange={(value) => {
Session.mutate((draft) => {
if (value === "remove") {
(draft as SessionTracking).columns.splice(index, 1);
} else {
(draft as SessionTracking).columns[index] =
value as CompositeStatsKey;
}
});
}}
>
<Select.Trigger variant="ghost" />
<Select.Content>
<Select.Item value="remove">
{strings.website.tools.session.table.remove}
</Select.Item>
<Select.Separator />
{compositeStatsKeys
.filter(
(item) =>
!session.columns.includes(item) ||
item === column,
)
.map((key) => (
<Select.Item key={key} value={key}>
{strings.common.composite_stats[key]}
</Select.Item>
))}
</Select.Content>
</Select.Root>
</Table.ColumnHeaderCell>
))}
</Table.Row>
</Table.Header>
<Table.Body>
<Table.Row
style={{
overflow: "hidden",
}}
>
<StickyRowHeaderCell
style={{
paddingLeft: 32,
position: "relative",
overflowY: "hidden",
}}
>
{strings.website.tools.session.table.total}
</StickyRowHeaderCell>
{session.columns.map((column) => (
<Table.Cell align="center" key={column}>
{formatCompositeStat(total![column], column, total!)}
</Table.Cell>
))}
</Table.Row>
{delta.map(({ tank, composite }) => {
return (
<Table.Row
key={tank.id}
style={{
overflow: "hidden",
}}
>
<TankRowHeaderCell tank={tank} />
{session.columns.map((column) => (
<Table.Cell align="center" key={column}>
{formatCompositeStat(
composite[column],
column,
composite,
)}
</Table.Cell>
))}
</Table.Row>
);
})}
</Table.Body>
</StickyTableRoot>
</Flex>
)}
{session.tracking && delta && delta.length === 0 && (
<Flex
align="center"
justify="center"
direction="column"
style={{
flex: 1,
}}
>
<Heading color="gray">
{strings.website.tools.session.no_battles.title}
</Heading>
<Text color="gray">
{strings.website.tools.session.no_battles.hint}
</Text>
</Flex>
)}
</PageWrapper>
);
}
@@ -0,0 +1,14 @@
---
import { tools } from "../../../constants/tools";
import ToolLayout from "../../../layouts/ToolLayout.astro";
import type { getStaticPaths } from "../index.astro";
import { Page } from "./_index";
export { getStaticPaths } from "../index.astro";
const { locale } = Astro.props;
---
<ToolLayout {locale} tool={tools.session}>
<Page client:load {locale} />
</ToolLayout>
@@ -0,0 +1,46 @@
import { Flex, Switch, Text } from "@radix-ui/themes";
import { LocaleSwitcher } from "../../../components/LocaleSwitcher";
import { PageWrapper } from "../../../components/PageWrapper";
import {
LocaleProvider,
useLocale,
type LocaleAcceptorProps,
} from "../../../hooks/useLocale";
import { App } from "../../../stores/app";
export function Page({ locale }: LocaleAcceptorProps) {
return (
<LocaleProvider locale={locale}>
<Content locale={locale} />
</LocaleProvider>
);
}
function Content({ locale }: LocaleAcceptorProps) {
const developerMode = App.useDeferred((state) => state.developerMode, false);
const { strings } = useLocale();
return (
<PageWrapper justify="center" align="center">
<Flex gap="3" direction="column" width="100%" maxWidth="20rem">
<Flex align="center" gap="2" justify="between">
<Text>{strings.website.settings.dev_mode}</Text>
<Switch
variant="classic"
checked={developerMode}
onCheckedChange={(checked) =>
App.mutate((draft) => {
draft.developerMode = checked;
})
}
/>
</Flex>
<Flex align="center" gap="2" justify="between">
<Text>{strings.website.settings.language}</Text>
<LocaleSwitcher locale={locale} />
</Flex>
</Flex>
</PageWrapper>
);
}
@@ -0,0 +1,21 @@
---
import { AstroSeo } from "@astrolib/seo";
import { getStrings } from "../../../core/i18n/getStrings";
import BaseLayout from "../../../layouts/BaseLayout.astro";
import { Page } from "./_index";
export { getStaticPaths } from "../index.astro";
const { locale } = Astro.props;
const strings = getStrings(locale);
---
<AstroSeo
title={strings.website.settings.title}
description={strings.website.settings.description}
/>
<BaseLayout {locale}>
<Page {locale} client:load />
</BaseLayout>
@@ -0,0 +1,56 @@
import { TankType } from "@blitzkit/core";
import { PageWrapper } from "../../../../components/PageWrapper";
import { CalloutsSection } from "../../../../components/Tankopedia/CalloutsSection";
import { CharacteristicsSection } from "../../../../components/Tankopedia/CharacteristicsSection";
import { GameModeSection } from "../../../../components/Tankopedia/GameModeSection";
import { GuideSection } from "../../../../components/Tankopedia/GuideSection";
import { HeroSection } from "../../../../components/Tankopedia/HeroSection";
import { MetaSection } from "../../../../components/Tankopedia/MetaSection";
import { TechTreeSection } from "../../../../components/Tankopedia/TechTreeSection";
import { VideoSection } from "../../../../components/Tankopedia/VideoSection";
import { awaitableModelDefinitions } from "../../../../core/awaitables/modelDefinitions";
import { awaitableTankDefinitions } from "../../../../core/awaitables/tankDefinitions";
import {
LocaleProvider,
type LocaleAcceptorProps,
} from "../../../../hooks/useLocale";
import { Duel } from "../../../../stores/duel";
import { Tankopedia } from "../../../../stores/tankopedia";
import type { MaybeSkeletonComponentProps } from "../../../../types/maybeSkeletonComponentProps";
import type { TankGuide } from "../../../../types/tankGuide";
type PageProps = MaybeSkeletonComponentProps &
LocaleAcceptorProps & {
id: number;
guide?: TankGuide;
};
const [tankDefinitions, modelDefinitions] = await Promise.all([
awaitableTankDefinitions,
awaitableModelDefinitions,
]);
export function Page({ id, skeleton, locale, guide }: PageProps) {
const tank = tankDefinitions.tanks[id];
const model = modelDefinitions.models[id];
Tankopedia.useInitialization(model);
Duel.useInitialization({ tank, model });
return (
<LocaleProvider locale={locale}>
<PageWrapper p="0" maxWidth="unset" color="purple" gap="9" pb="9">
<HeroSection skeleton={skeleton} />
<MetaSection />
<CalloutsSection />
{tank.type === TankType.TANK_TYPE_RESEARCHABLE && !tank.deprecated && (
<TechTreeSection skeleton={skeleton} />
)}
<CharacteristicsSection skeleton={skeleton} />
<GameModeSection />
{guide && <GuideSection guide={guide} />}
<VideoSection skeleton={skeleton} />
</PageWrapper>
</LocaleProvider>
);
}
@@ -0,0 +1,139 @@
---
import { AstroSeo } from "@astrolib/seo";
import {
assertSecret,
asset,
createDefaultProvisions,
createDefaultSkills,
fetchEquipmentDefinitions,
fetchModelDefinitions,
fetchProvisionDefinitions,
fetchSkillDefinitions,
fetchTankDefinitions,
TIER_ROMAN_NUMERALS,
} from "@blitzkit/core";
import { unwrapper } from "@blitzkit/i18n";
import { literals } from "@blitzkit/i18n/src/literals";
import type { GetStaticPaths } from "astro";
import { load } from "cheerio";
import { tankCharacteristics } from "../../../../core/blitzkit/tankCharacteristics";
import { getStrings } from "../../../../core/i18n/getStrings";
import BaseLayout from "../../../../layouts/BaseLayout.astro";
import { genericDefaultEquipmentMatrix } from "../../../../stores/duel/constants";
import type { TankGuide } from "../../../../types/tankGuide";
import { getStaticPaths as parentGetStaticPaths } from "../index.astro";
import { Page } from "./_index";
export const getStaticPaths = (async () => {
const tankDefinitions = await fetchTankDefinitions();
const parentPaths = parentGetStaticPaths();
return Object.values(tankDefinitions.tanks)
.map((tank) =>
parentPaths.map((parent) => ({
params: { ...parent.params, slug: tank.slug },
props: { ...parent.props, id: tank.id },
})),
)
.flat();
}) satisfies GetStaticPaths;
const { id, locale } = Astro.props;
const strings = getStrings(locale);
const tankDefinitions = await fetchTankDefinitions();
const skillDefinitions = await fetchSkillDefinitions();
const provisionDefinitions = await fetchProvisionDefinitions();
const equipmentDefinitions = await fetchEquipmentDefinitions();
const tankModelDefinitions = await fetchModelDefinitions();
const tank = tankDefinitions.tanks[id];
const tankModelDefinition = tankModelDefinitions.models[tank.id];
const unwrap = unwrapper(locale);
const title = literals(strings.website.tools.tankopedia.tank_seo_title, {
name: unwrap(tank.name!),
tier: TIER_ROMAN_NUMERALS[tank.tier],
nationality:
strings.common.nations_adjectives[
tank.nation as keyof typeof strings.common.nations_adjectives
],
class: strings.common.tank_class_short[tank.class],
website: "BlitzKit",
});
const url = import.meta.env.SITE + Astro.url.pathname;
const icon = asset(`icons/tanks/big/${id}.webp`);
const turret = tank.turrets.at(-1)!;
const gun = turret.guns.at(-1)!;
const shell = gun.shells[0];
const characteristics = tankCharacteristics(
{
tank,
applyDynamicArmor: false,
applyReactiveArmor: false,
applySpallLiner: false,
camouflage: true,
consumables: [],
crewSkills: createDefaultSkills(skillDefinitions),
engine: tank.engines.at(-1)!,
equipmentMatrix: genericDefaultEquipmentMatrix,
turret,
gun,
provisions: createDefaultProvisions(tank, gun, provisionDefinitions),
shell,
stockEngine: tank.engines[0],
stockGun: tank.turrets[0].guns[0],
stockTurret: tank.turrets[0],
stockTrack: tank.tracks[0],
track: tank.tracks.at(-1)!,
assaultDistance: 0,
equalize: false,
},
{ equipmentDefinitions, provisionDefinitions, tankModelDefinition },
);
const description = literals(
strings.website.tools.tankopedia.tank_seo_description,
{
name: unwrap(tank.name!),
dpm: Math.round(characteristics.dpm).toLocaleString(locale),
alpha: Math.round(characteristics.damage).toLocaleString(locale),
penetration: Math.round(characteristics.penetration).toLocaleString(locale),
speed: Math.round(characteristics.speedForwards).toLocaleString(locale),
health: Math.round(characteristics.health).toLocaleString(locale),
},
);
const guideUrl = `${assertSecret(import.meta.env.PUBLIC_GUIDE_WEBSITE)}/${tank.slug}`;
let guide: TankGuide | undefined = undefined;
const guideResponse = await fetch(guideUrl);
if (guideResponse.ok) {
const $ = await guideResponse.text().then(load);
const description = $(".entry-content p").first().text();
const image = $(".entry-content img").first().attr("src")?.toString();
if (description !== undefined || image !== undefined) {
guide = {
url: guideUrl,
description,
image,
};
}
}
---
<AstroSeo
{title}
{description}
openGraph={{ title, description, images: [{ url: icon }], url }}
/>
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:card" content="summary" />
<meta name="twitter:image:src" content={icon} />
<BaseLayout {locale} integrated>
<Page {locale} id={id} {guide} client:only="react">
<Page {locale} id={id} {guide} skeleton slot="fallback" />
</Page>
</BaseLayout>
@@ -0,0 +1,20 @@
import { PageWrapper } from "../../../../components/PageWrapper";
import { TankSearch } from "../../../../components/TankSearch";
import {
LocaleProvider,
type LocaleAcceptorProps,
} from "../../../../hooks/useLocale";
import type { MaybeSkeletonComponentProps } from "../../../../types/maybeSkeletonComponentProps";
export function Page({
locale,
skeleton,
}: LocaleAcceptorProps & MaybeSkeletonComponentProps) {
return (
<LocaleProvider locale={locale}>
<PageWrapper color="purple" maxWidth="80rem">
<TankSearch skeleton={skeleton} />
</PageWrapper>
</LocaleProvider>
);
}
@@ -0,0 +1,15 @@
---
import { tools } from "../../../../constants/tools";
import ToolLayout from "../../../../layouts/ToolLayout.astro";
import { Page } from "./_index";
export { getStaticPaths } from "../index.astro";
const { locale } = Astro.props;
---
<ToolLayout {locale} tool={tools.tanks}>
<Page {locale} client:only="react">
<Page {locale} skeleton slot="fallback" />
</Page>
</ToolLayout>
@@ -0,0 +1,30 @@
import { PageWrapper } from "../../../components/PageWrapper";
import { FilterControl } from "../../../components/TankSearch/components/FilterControl";
import { TierListControls } from "../../../components/TierList/Controls";
import { TierListTable } from "../../../components/TierList/Table";
import { TierListTiles } from "../../../components/TierList/Tiles";
import { URLManager } from "../../../components/TierList/URLManager";
import {
LocaleProvider,
type LocaleAcceptorProps,
} from "../../../hooks/useLocale";
export function Page({ locale }: LocaleAcceptorProps) {
return (
<LocaleProvider locale={locale}>
<Content />
</LocaleProvider>
);
}
function Content() {
return (
<PageWrapper color="orange" maxWidth="100rem">
<URLManager />
<TierListControls />
<TierListTable />
<FilterControl />
<TierListTiles />
</PageWrapper>
);
}
@@ -0,0 +1,13 @@
---
import { tools } from "../../../constants/tools";
import ToolLayout from "../../../layouts/ToolLayout.astro";
import { Page } from "./_index";
export { getStaticPaths } from "../index.astro";
const { locale } = Astro.props;
---
<ToolLayout {locale} tool={tools.tier_list}>
<Page {locale} client:load />
</ToolLayout>
@@ -0,0 +1,9 @@
---
import LegacyRedirect from "../../../../layouts/LegacyRedirect.astro";
export { getStaticPaths } from "../../index.astro";
const { locale } = Astro.props;
---
<LegacyRedirect {locale} path="compare" />
@@ -0,0 +1,26 @@
---
import type { GetStaticPaths } from "astro";
import { embedConfigurations } from "../../../../../constants/embeds";
import LegacyRedirect from "../../../../../layouts/LegacyRedirect.astro";
import { getStaticPaths as parentGetStaticPaths } from "../index.astro";
export const getStaticPaths = (() => {
const parentPaths = parentGetStaticPaths();
return Object.keys(embedConfigurations)
.map((embed) =>
parentPaths.map((parent) => ({
params: {
...parent.params,
embed: embed as keyof typeof embedConfigurations,
},
}))
)
.flat();
}) satisfies GetStaticPaths;
const { embed } = Astro.params;
const { locale } = Astro.props;
---
<LegacyRedirect {locale} path={`embed/${embed}`} />
@@ -0,0 +1,9 @@
---
import LegacyRedirect from "../../../../layouts/LegacyRedirect.astro";
export { getStaticPaths } from "../../index.astro";
const { locale } = Astro.props;
---
<LegacyRedirect {locale} path="embed" />
@@ -0,0 +1,9 @@
---
import LegacyRedirect from "../../../../layouts/LegacyRedirect.astro";
export { getStaticPaths } from "../../index.astro";
const { locale } = Astro.props;
---
<LegacyRedirect {locale} path="gallery" />
@@ -0,0 +1,9 @@
---
import LegacyRedirect from "../../../../layouts/LegacyRedirect.astro";
export { getStaticPaths } from "../../index.astro";
const { locale } = Astro.props;
---
<LegacyRedirect {locale} path="performance" />
@@ -0,0 +1,9 @@
---
import LegacyRedirect from "../../../../layouts/LegacyRedirect.astro";
export { getStaticPaths } from "../../index.astro";
const { locale } = Astro.props;
---
<LegacyRedirect {locale} path="session" />
@@ -0,0 +1,27 @@
---
import { fetchTankDefinitions } from "@blitzkit/core";
import type { GetStaticPaths } from "astro";
import LegacyRedirect from "../../../../../layouts/LegacyRedirect.astro";
import { getStaticPaths as parentGetStaticPaths } from "../index.astro";
export const getStaticPaths = (async () => {
const tankDefinitions = await fetchTankDefinitions();
const parentPaths = parentGetStaticPaths();
return Object.values(tankDefinitions.tanks)
.map((tank) =>
parentPaths.map((parent) => ({
params: { ...parent.params, id: tank.id },
props: parent.props,
})),
)
.flat();
}) satisfies GetStaticPaths;
const { id } = Astro.params;
const { locale } = Astro.props;
const tankDefinitions = await fetchTankDefinitions();
const tank = tankDefinitions.tanks[id];
---
<LegacyRedirect {locale} path={`tanks/${tank.slug}`} />
@@ -0,0 +1,9 @@
---
import LegacyRedirect from "../../../../layouts/LegacyRedirect.astro";
export { getStaticPaths } from "../../index.astro";
const { locale } = Astro.props;
---
<LegacyRedirect {locale} path="tanks" />
@@ -0,0 +1,9 @@
---
import LegacyRedirect from "../../../../layouts/LegacyRedirect.astro";
export { getStaticPaths } from "../../index.astro";
const { locale } = Astro.props;
---
<LegacyRedirect {locale} path="tier-list" />