mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-11-12 20:26:10 +01:00
feat: move to locale
This commit is contained in:
1 parent
60ab845326
commit
1843eb3bcf
44 files changed
+234
-223
No files matched your search
@@ -0,0 +1,61 @@
|
||||
import { useEffect } from 'react';
|
||||
import { App } from '../../../../stores/app';
|
||||
|
||||
export const AUTH_PROVIDERS = ['wargaming'] as const;
|
||||
|
||||
type AuthProvider = (typeof AUTH_PROVIDERS)[number];
|
||||
|
||||
export const AUTH_PROVIDER_NAMES: Record<AuthProvider, string> = {
|
||||
wargaming: 'Wargaming',
|
||||
};
|
||||
|
||||
interface AuthorizeProps {
|
||||
provider: AuthProvider;
|
||||
}
|
||||
|
||||
export function Authorize(props: AuthorizeProps) {
|
||||
return (
|
||||
<App.Provider>
|
||||
<Content {...props} />
|
||||
</App.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function Content({ provider }: AuthorizeProps) {
|
||||
const mutateApp = App.useMutation();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
mutateApp((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,32 @@
|
||||
---
|
||||
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 { AUTH_PROVIDERS, AUTH_PROVIDER_NAMES, Authorize } from './_index';
|
||||
|
||||
export const getStaticPaths = (() => {
|
||||
return AUTH_PROVIDERS.map((provider) => ({ params: { provider } }));
|
||||
}) satisfies GetStaticPaths;
|
||||
|
||||
const { provider } = Astro.params;
|
||||
---
|
||||
|
||||
<BaseLayout>
|
||||
<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,14 @@
|
||||
import { Markdown } from '../../../../../components/Markdown';
|
||||
import { PageWrapper } from '../../../../../components/PageWrapper';
|
||||
|
||||
interface PageProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function Page({ content }: PageProps) {
|
||||
return (
|
||||
<PageWrapper>
|
||||
<Markdown>{content}</Markdown>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
import type { GetStaticPaths } from 'astro';
|
||||
import { readFile } from 'fs/promises';
|
||||
import BaseLayout from '../../../../layouts/BaseLayout.astro';
|
||||
import { Page } from './_index';
|
||||
|
||||
export const getStaticPaths = (async () => {
|
||||
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 {
|
||||
params: { directory: couple[0], document: couple[1] },
|
||||
props: { content },
|
||||
};
|
||||
}),
|
||||
);
|
||||
}) satisfies GetStaticPaths;
|
||||
|
||||
const { content } = Astro.props;
|
||||
|
||||
const title = content.split('\n')[0].replace('# ', '');
|
||||
---
|
||||
|
||||
<title>{title}</title>
|
||||
|
||||
<BaseLayout>
|
||||
<Page content={content} />
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
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 BaseLayout from '../../../layouts/BaseLayout.astro';
|
||||
|
||||
// this can't be used in template literal :(
|
||||
const ROOT = '../../../../../../docs/changelogs/';
|
||||
|
||||
const versions = Object.keys(
|
||||
import.meta.glob(`../../../../../../docs/changelogs/*.md`, {
|
||||
eager: true,
|
||||
}),
|
||||
)
|
||||
.map((file) => file.replace(ROOT, '').replace('.md', ''))
|
||||
.sort(compareVersions)
|
||||
.reverse();
|
||||
---
|
||||
|
||||
<title>BlitzKit Patches</title>
|
||||
|
||||
<BaseLayout>
|
||||
<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,113 @@
|
||||
---
|
||||
import { assertSecret, SUPPORTED_LOCALES } from '@blitzkit/core';
|
||||
import { Box, Flex, Heading } from '@radix-ui/themes';
|
||||
import type { GetStaticPaths } from 'astro';
|
||||
import { AesonPlug } from '../../components/AesonPlug';
|
||||
import HomeHero from '../../components/HomeHero.astro';
|
||||
import { HomePageHotTanks } from '../../components/HomePageHotTanks';
|
||||
import { Plugs } from '../../components/Plugs';
|
||||
import ToolCard from '../../components/ToolCard.astro';
|
||||
import {
|
||||
compareTool,
|
||||
discordTool,
|
||||
embedTool,
|
||||
galleryTool,
|
||||
moreTool,
|
||||
performanceTool,
|
||||
sessionTool,
|
||||
tankopediaTool,
|
||||
tierListTool,
|
||||
} from '../../constants/tools';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
|
||||
const branch = assertSecret(import.meta.env.PUBLIC_ASSET_BRANCH);
|
||||
|
||||
export const getStaticPaths = (() => {
|
||||
return [
|
||||
...SUPPORTED_LOCALES.map((locale) => ({ params: { locale } })),
|
||||
{ params: { locale: undefined } },
|
||||
];
|
||||
}) satisfies GetStaticPaths;
|
||||
---
|
||||
|
||||
<BaseLayout>
|
||||
<HomeHero />
|
||||
|
||||
<Plugs client:idle />
|
||||
|
||||
{
|
||||
branch === 'preview' && (
|
||||
<Flex justify="center" my="8" px="4">
|
||||
<AesonPlug />
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
<Flex p="4" justify="center" gap="4" mt="3">
|
||||
<Flex maxWidth="64rem" flexGrow="1" gap="4" direction="column">
|
||||
<ToolCard tool={tankopediaTool} />
|
||||
|
||||
<HomePageHotTanks />
|
||||
|
||||
<Flex gap="4" direction={{ initial: 'column', sm: 'row' }}>
|
||||
{/* <ToolCard tool={playerStatsTool} /> */}
|
||||
<ToolCard tool={compareTool} />
|
||||
<ToolCard tool={performanceTool} />
|
||||
</Flex>
|
||||
|
||||
<Flex gap="4" direction={{ initial: 'column', sm: 'row' }}>
|
||||
<!-- <ToolCard tool={chartsTool} /> -->
|
||||
<ToolCard tool={galleryTool} />
|
||||
<ToolCard tool={sessionTool} />
|
||||
</Flex>
|
||||
|
||||
<Flex gap="4" direction={{ initial: 'column', sm: 'row' }}>
|
||||
<ToolCard tool={discordTool} />
|
||||
<ToolCard tool={tierListTool} />
|
||||
</Flex>
|
||||
|
||||
<Flex gap="4" direction={{ initial: 'column', sm: 'row' }}>
|
||||
<ToolCard tool={embedTool} />
|
||||
<ToolCard tool={moreTool} />
|
||||
</Flex>
|
||||
|
||||
<Flex gap="4" direction={{ initial: 'column', sm: 'row' }}>
|
||||
<Box flexGrow="1" />
|
||||
</Flex>
|
||||
|
||||
<Heading align="center" mt="6">Other cool projects</Heading>
|
||||
|
||||
<Flex gap="4" mb="6" direction={{ initial: 'column', sm: 'row' }}>
|
||||
<ToolCard
|
||||
tool={{
|
||||
button: {
|
||||
text: 'Analyze',
|
||||
color: 'blue',
|
||||
},
|
||||
description: 'Rich historic average stats',
|
||||
id: 'blitz-analysis',
|
||||
image: 'j76YXGl',
|
||||
title: 'BlitzAnalysis[]',
|
||||
href: 'https://blitzanalysiz.com/',
|
||||
}}
|
||||
/>
|
||||
<ToolCard
|
||||
tool={{
|
||||
button: {
|
||||
color: 'purple',
|
||||
text: 'Install',
|
||||
highContrast: true,
|
||||
},
|
||||
image: 'zYrrYbR',
|
||||
description: 'In-game session stats',
|
||||
id: 'blitz-insider',
|
||||
title: 'Blitz Insider',
|
||||
href: 'https://discord.gg/UjWf9eGgtR',
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<div style={{ flex: 1 }}></div>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Flex, Heading, Switch, Text } from '@radix-ui/themes';
|
||||
import { PageWrapper } from '../../../components/PageWrapper';
|
||||
import { App } from '../../../stores/app';
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<App.Provider>
|
||||
<Content />
|
||||
</App.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function Content() {
|
||||
const developerMode = App.useDeferred((state) => state.developerMode, false);
|
||||
const appStore = App.useStore();
|
||||
|
||||
return (
|
||||
<PageWrapper justify="center" align="center">
|
||||
<Flex direction="column" gap="4">
|
||||
<Flex gap="3" direction="column">
|
||||
<Heading size="5">Advanced</Heading>
|
||||
|
||||
<Flex align="center" gap="2">
|
||||
<Text>Developer mode</Text>
|
||||
<Switch
|
||||
checked={developerMode}
|
||||
onCheckedChange={(checked) =>
|
||||
appStore.setState({ developerMode: checked })
|
||||
}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import { Page } from './_index';
|
||||
---
|
||||
|
||||
<BaseLayout>
|
||||
<Page client:load />
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Flex } from '@radix-ui/themes';
|
||||
import { ChartsControl } from '../../../../components/Charts/ChartsControl';
|
||||
import { PlayersInPeriod } from '../../../../components/Charts/PlayersInPeriod';
|
||||
import { PageWrapper } from '../../../../components/PageWrapper';
|
||||
import { FilterControl } from '../../../../components/TankSearch/components/FilterControl';
|
||||
import { Charts } from '../../../../stores/charts';
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<Charts.Provider>
|
||||
<Content />
|
||||
</Charts.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function Content() {
|
||||
return (
|
||||
<PageWrapper maxWidth="unset">
|
||||
<FilterControl />
|
||||
<ChartsControl />
|
||||
|
||||
<Flex justify="center">
|
||||
<Flex direction="column" maxWidth="45rem" flexGrow="1" py="6" gap="9">
|
||||
<PlayersInPeriod />
|
||||
</Flex>
|
||||
</Flex>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
import { chartsTool } from '../../../../constants/tools';
|
||||
import ToolLayout from '../../../layouts/ToolLayout.astro';
|
||||
import { Page } from './_index';
|
||||
---
|
||||
|
||||
<ToolLayout tool={chartsTool}>
|
||||
<Page client:load />
|
||||
</ToolLayout>
|
||||
@@ -0,0 +1,170 @@
|
||||
import { createDefaultSkills } from '@blitzkit/core';
|
||||
import { PlusIcon } from '@radix-ui/react-icons';
|
||||
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 { App } from '../../../../stores/app';
|
||||
import { CompareEphemeral } from '../../../../stores/compareEphemeral';
|
||||
import { ComparePersistent } from '../../../../stores/comparePersistent';
|
||||
import { TankopediaPersistent } from '../../../../stores/tankopediaPersistent';
|
||||
|
||||
const [
|
||||
modelDefinitions,
|
||||
equipmentDefinitions,
|
||||
provisionDefinitions,
|
||||
skillDefinitions,
|
||||
tankDefinitions,
|
||||
] = await Promise.all([
|
||||
awaitableModelDefinitions,
|
||||
awaitableEquipmentDefinitions,
|
||||
awaitableProvisionDefinitions,
|
||||
awaitableSkillDefinitions,
|
||||
awaitableTankDefinitions,
|
||||
]);
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<App.Provider>
|
||||
<TankopediaPersistent.Provider>
|
||||
<ComparePersistent.Provider>
|
||||
<CompareEphemeral.Provider
|
||||
data={createDefaultSkills(skillDefinitions)}
|
||||
>
|
||||
<Content />
|
||||
</CompareEphemeral.Provider>
|
||||
</ComparePersistent.Provider>
|
||||
</TankopediaPersistent.Provider>
|
||||
</App.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function Content() {
|
||||
const members = CompareEphemeral.use((state) => state.members);
|
||||
const [addTankDialogOpen, setAddTankDialogOpen] = useState(false);
|
||||
const crewSkills = CompareEphemeral.use((state) => state.crewSkills);
|
||||
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),
|
||||
),
|
||||
},
|
||||
{
|
||||
equipmentDefinitions: equipmentDefinitions,
|
||||
provisionDefinitions: provisionDefinitions,
|
||||
tankModelDefinition: modelDefinitions.models[thisMember.tank.id],
|
||||
},
|
||||
),
|
||||
),
|
||||
[members, crewSkills],
|
||||
);
|
||||
const mutateCompareEphemeral = CompareEphemeral.useMutation();
|
||||
|
||||
useEffect(() => {
|
||||
const search = new URLSearchParams(window.location.search);
|
||||
const tanksQuery = search.get('tanks');
|
||||
|
||||
if (tanksQuery === null) return;
|
||||
|
||||
const tanks = tanksQuery.split(',').map(Number);
|
||||
|
||||
mutateCompareEphemeral((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%">
|
||||
<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} />
|
||||
|
||||
{/* <IconButton
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 73,
|
||||
transform: 'translate(50%, -50%)',
|
||||
zIndex: 1,
|
||||
}}
|
||||
onClick={() => setAddTankDialogOpen(true)}
|
||||
>
|
||||
<PlusIcon />
|
||||
</IconButton> */}
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{members.length === 0 && (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
direction="column"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Heading color="gray">No tanks selected</Heading>
|
||||
<Text color="gray">
|
||||
Press the <PlusIcon /> Add button to get started
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
import { compareTool } from '../../../../constants/tools';
|
||||
import ToolOpenGraph from '../../../components/ToolOpenGraph.astro';
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro';
|
||||
import { Page } from './_index';
|
||||
---
|
||||
|
||||
<ToolOpenGraph tool={compareTool} />
|
||||
|
||||
<BaseLayout noFooter>
|
||||
<Page client:load />
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,275 @@
|
||||
import { imgur } from '@blitzkit/core';
|
||||
import {
|
||||
CodeIcon,
|
||||
HeightIcon,
|
||||
ImageIcon,
|
||||
ResetIcon,
|
||||
TimerIcon,
|
||||
WidthIcon,
|
||||
} from '@radix-ui/react-icons';
|
||||
import { Box, Button, Flex, Heading, ScrollArea, Text } from '@radix-ui/themes';
|
||||
import { capitalize, startCase } from 'lodash-es';
|
||||
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 { App } from '../../../../../stores/app';
|
||||
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 }: PageProps) {
|
||||
const config = embedConfigurations[embed];
|
||||
|
||||
return (
|
||||
<App.Provider>
|
||||
<EmbedState.Provider data={extractEmbedConfigDefaults(config)}>
|
||||
<Content embed={embed} />
|
||||
</EmbedState.Provider>
|
||||
</App.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function Content({ embed }: PageProps) {
|
||||
const embedStateStore = EmbedState.useStore();
|
||||
const config = embedConfigurations[embed] as EmbedConfig;
|
||||
const [backgroundImage, setBackgroundImage] = useState(
|
||||
imgur('SO13zur', { format: 'jpeg' }),
|
||||
);
|
||||
const fileInput = useRef<HTMLInputElement>();
|
||||
|
||||
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>Export</Heading>
|
||||
<Text size="2" color="gray" mb="2">
|
||||
Copy all properties for you streaming software
|
||||
</Text>
|
||||
|
||||
<Flex mb="6" gap="2" wrap="wrap">
|
||||
<GenerateURL embed={embed} />
|
||||
|
||||
<CopyButton
|
||||
variant="outline"
|
||||
copy={() =>
|
||||
'body { margin: 0; background-color: transparent; overflow: hidden; }'
|
||||
}
|
||||
>
|
||||
<CodeIcon />
|
||||
Copy CSS
|
||||
</CopyButton>
|
||||
<CopyButton
|
||||
variant="outline"
|
||||
copy={() => `${embedStateStore.getState().width}`}
|
||||
>
|
||||
<WidthIcon />
|
||||
Copy width
|
||||
</CopyButton>
|
||||
<CopyButton
|
||||
variant="outline"
|
||||
copy={() => `${embedStateStore.getState().height}`}
|
||||
>
|
||||
<HeightIcon />
|
||||
Copy height
|
||||
</CopyButton>
|
||||
<CopyButton variant="outline" copy={() => '1'}>
|
||||
<TimerIcon />
|
||||
Copy framerate
|
||||
</CopyButton>
|
||||
</Flex>
|
||||
|
||||
<Flex justify="between" align="center">
|
||||
<Heading>Customize</Heading>
|
||||
<Button
|
||||
color="red"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
embedStateStore.setState(
|
||||
embedStateStore.getInitialState(),
|
||||
true,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<ResetIcon /> Reset
|
||||
</Button>
|
||||
</Flex>
|
||||
<Text size="2" color="gray" mb="2">
|
||||
Bells and whistles of your embed
|
||||
</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>{capitalize(startCase(configKey as string))}</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">Example preview</Text>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
color="gray"
|
||||
onClick={() => fileInput.current?.click()}
|
||||
>
|
||||
<ImageIcon /> Upload test background
|
||||
</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,54 @@
|
||||
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 {
|
||||
EmbedState,
|
||||
type EmbedConfig,
|
||||
type ExtractEmbedConfigTypes,
|
||||
} from '../../../../../../stores/embedState';
|
||||
import { useEmbedStateCurry } from '../../../../../../stores/embedState/utilities';
|
||||
|
||||
interface PageProps {
|
||||
embed: keyof typeof embedConfigurations;
|
||||
}
|
||||
|
||||
export function Page({ embed }: PageProps) {
|
||||
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]);
|
||||
|
||||
return (
|
||||
<EmbedState.Provider data={state}>
|
||||
<Content embed={embed} />
|
||||
</EmbedState.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
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 { capitalize } from 'lodash-es';
|
||||
import EmbedLayout from '../../../../../layouts/EmbedLayout.astro';
|
||||
import { Page } from './_index';
|
||||
|
||||
export { getStaticPaths } from '../index.astro';
|
||||
---
|
||||
|
||||
<title>BlitzKit - {capitalize(Astro.params.embed)} embed</title>
|
||||
|
||||
<EmbedLayout>
|
||||
<Page client:only="react" embed={Astro.params.embed} />
|
||||
</EmbedLayout>
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
import type { GetStaticPaths } from 'astro';
|
||||
import { capitalize } from 'lodash-es';
|
||||
import { embedConfigurations } from '../../../../../constants/embeds';
|
||||
import BaseLayout from '../../../../layouts/BaseLayout.astro';
|
||||
import { Page } from './_index';
|
||||
|
||||
export const getStaticPaths = (() => {
|
||||
return Object.keys(embedConfigurations).map((embed) => ({
|
||||
params: { embed: embed as keyof typeof embedConfigurations },
|
||||
}));
|
||||
}) satisfies GetStaticPaths;
|
||||
---
|
||||
|
||||
<title>BlitzKit - {capitalize(Astro.params.embed)} embed</title>
|
||||
|
||||
<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>
|
||||
<Page client:load embed={Astro.params.embed} />
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,110 @@
|
||||
import { imgur, ImgurSize } from '@blitzkit/core';
|
||||
import { Box, Flex, Link, Text } from '@radix-ui/themes';
|
||||
import { capitalize } from 'lodash-es';
|
||||
import { PreviewWrapper } from '../../../../components/Embeds/PreviewWrapper';
|
||||
import { PageWrapper } from '../../../../components/PageWrapper';
|
||||
import {
|
||||
embedConfigurations,
|
||||
extractEmbedConfigDefaults,
|
||||
} from '../../../../constants/embeds';
|
||||
import { Var } from '../../../../core/radix/var';
|
||||
import { App } from '../../../../stores/app';
|
||||
import { EmbedState } from '../../../../stores/embedState';
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<App.Provider>
|
||||
<Content />
|
||||
</App.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function Content() {
|
||||
// const wargaming = App.useDeferred((state) => state.logins.wargaming, {
|
||||
// token: '',
|
||||
// id: 0,
|
||||
// expires: 0,
|
||||
// });
|
||||
|
||||
return (
|
||||
<PageWrapper color="red">
|
||||
{/* {!wargaming && (
|
||||
<Flex direction="column" align="center" gap="2">
|
||||
<Callout.Root>
|
||||
<Callout.Icon>
|
||||
<ExclamationTriangleIcon />
|
||||
</Callout.Icon>
|
||||
<Callout.Text>
|
||||
<Flex align="center">You must log in to generate embeds.</Flex>
|
||||
</Callout.Text>
|
||||
</Callout.Root>
|
||||
|
||||
<WargamingLoginButton>Log in with Wargaming</WargamingLoginButton>
|
||||
</Flex>
|
||||
)} */}
|
||||
|
||||
<Text align="center" mt="3">
|
||||
More embeds coming soon.{' '}
|
||||
<Link
|
||||
underline="always"
|
||||
href="https://discord.gg/nDt7AjGJQH"
|
||||
target="_blank"
|
||||
>
|
||||
Suggest ideas
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
|
||||
<Flex justify="center">
|
||||
{Object.entries(embedConfigurations).map(([embed, config]) => (
|
||||
<EmbedState.Provider
|
||||
key={embed}
|
||||
data={extractEmbedConfigDefaults(config)}
|
||||
>
|
||||
<Link
|
||||
href={`/tools/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>{capitalize(embed)}</Text>
|
||||
</Flex>
|
||||
|
||||
<Box
|
||||
width="20rem"
|
||||
height="20rem"
|
||||
overflow="hidden"
|
||||
position="relative"
|
||||
style={{
|
||||
backgroundImage: `url(${imgur('SO13zur', { format: 'jpeg', size: ImgurSize.SmallThumbnail })})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
>
|
||||
<PreviewWrapper
|
||||
naked
|
||||
name={embed as keyof typeof embedConfigurations}
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Link>
|
||||
</EmbedState.Provider>
|
||||
))}
|
||||
</Flex>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
import { embedTool } from '../../../../constants/tools';
|
||||
import ToolLayout from '../../../layouts/ToolLayout.astro';
|
||||
import { Page } from './_index';
|
||||
---
|
||||
|
||||
<ToolLayout tool={embedTool}>
|
||||
<Page />
|
||||
</ToolLayout>
|
||||
@@ -0,0 +1,31 @@
|
||||
import { CalendarIcon } from '@radix-ui/react-icons';
|
||||
import { Callout, Flex } from '@radix-ui/themes';
|
||||
import { GalleryList, type Avatar } from '../../../../components/Gallery/List';
|
||||
import { GallerySearch } from '../../../../components/Gallery/Search';
|
||||
import { PageWrapper } from '../../../../components/PageWrapper';
|
||||
import { GalleryEphemeral } from '../../../../stores/galleryEphemeral';
|
||||
|
||||
interface PageProps {
|
||||
avatars: Avatar[];
|
||||
lastUpdated: string;
|
||||
}
|
||||
|
||||
export function Page({ avatars, lastUpdated }: PageProps) {
|
||||
return (
|
||||
<PageWrapper maxWidth="80rem" color="gold">
|
||||
<Flex justify="center">
|
||||
<Callout.Root size="1">
|
||||
<Callout.Icon>
|
||||
<CalendarIcon />
|
||||
</Callout.Icon>
|
||||
<Callout.Text>Last updated on {lastUpdated}</Callout.Text>
|
||||
</Callout.Root>
|
||||
</Flex>
|
||||
|
||||
<GalleryEphemeral.Provider>
|
||||
<GallerySearch />
|
||||
<GalleryList avatars={avatars} />
|
||||
</GalleryEphemeral.Provider>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
import type { Avatar } from '../../../../components/Gallery/List';
|
||||
import { galleryTool } from '../../../../constants/tools';
|
||||
import { fetchGlossary } from '../../../../core/blitz/fetchGlossary';
|
||||
import ToolLayout from '../../../layouts/ToolLayout.astro';
|
||||
import { Page } from './_index';
|
||||
|
||||
const glossary = await fetchGlossary();
|
||||
const avatars = Object.entries(glossary)
|
||||
.filter(
|
||||
([key, value]) =>
|
||||
key.startsWith('avatar') &&
|
||||
!key.endsWith('_part') &&
|
||||
value.image_url !== null,
|
||||
)
|
||||
.map(
|
||||
([key, value]) =>
|
||||
({ id: key.replace('avatar_', ''), name: value.title }) satisfies Avatar,
|
||||
)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const lastUpdated = new Date().toLocaleDateString();
|
||||
---
|
||||
|
||||
<ToolLayout tool={galleryTool}>
|
||||
<Page {avatars} {lastUpdated} client:load />
|
||||
</ToolLayout>
|
||||
@@ -0,0 +1,30 @@
|
||||
import { PageWrapper } from '../../../../components/PageWrapper';
|
||||
import { PerformanceInfo } from '../../../../components/Performance/PerformanceInfo';
|
||||
import { PlayerCountControl } from '../../../../components/Performance/PlayerCountControl';
|
||||
import { TankTable } from '../../../../components/Performance/Table';
|
||||
import { FilterControl } from '../../../../components/TankSearch/components/FilterControl';
|
||||
import { TankPerformanceEphemeral } from '../../../../stores/tankPerformanceEphemeral';
|
||||
import { TankPerformanceSort } from '../../../../stores/tankPerformanceSort';
|
||||
import type { MaybeSkeletonComponentProps } from '../../../../types/maybeSkeletonComponentProps';
|
||||
|
||||
export function Page({ skeleton }: MaybeSkeletonComponentProps) {
|
||||
return (
|
||||
<TankPerformanceSort.Provider>
|
||||
<TankPerformanceEphemeral.Provider>
|
||||
<Content skeleton={skeleton} />
|
||||
</TankPerformanceEphemeral.Provider>
|
||||
</TankPerformanceSort.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function Content({ skeleton }: MaybeSkeletonComponentProps) {
|
||||
return (
|
||||
<PageWrapper color="jade" maxWidth="100%">
|
||||
<PerformanceInfo skeleton={skeleton} />
|
||||
<FilterControl />
|
||||
<PlayerCountControl />
|
||||
|
||||
<TankTable skeleton={skeleton} />
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
import { performanceTool } from '../../../../constants/tools';
|
||||
import ToolOpenGraph from '../../../components/ToolOpenGraph.astro';
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro';
|
||||
import { Page } from './_index';
|
||||
---
|
||||
|
||||
<ToolOpenGraph tool={performanceTool} />
|
||||
|
||||
<BaseLayout noFooter>
|
||||
<Page client:only="react">
|
||||
<Page skeleton slot="fallback" />
|
||||
</Page>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,460 @@
|
||||
import {
|
||||
compositeStats,
|
||||
compositeStatsKeys,
|
||||
deltaTankStats,
|
||||
formatCompositeStat,
|
||||
getAccountInfo,
|
||||
getTankStats,
|
||||
idToRegion,
|
||||
searchPlayersAcrossRegions,
|
||||
sumCompositeStats,
|
||||
type AccountListWithServer,
|
||||
type CompositeStatsKey,
|
||||
type IndividualAccountInfo,
|
||||
type IndividualTankStats,
|
||||
} from '@blitzkit/core';
|
||||
import strings from '@blitzkit/core/lang/en-US.json';
|
||||
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 { Session, type SessionTracking } from '../../../../stores/session';
|
||||
|
||||
const [tankDefinitions, averageDefinitions] = await Promise.all([
|
||||
awaitableTankDefinitions,
|
||||
awaitableAverageDefinitions,
|
||||
]);
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<Session.Provider>
|
||||
<Content />
|
||||
</Session.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function Content() {
|
||||
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 mutateSession = Session.useMutation();
|
||||
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);
|
||||
mutateSession((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>
|
||||
You're attempting to track a CC account
|
||||
</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
CC (community contributor) accounts do not have public tank
|
||||
statistics. We are in the process of adding embeds for CC accounts,
|
||||
stay tuned.
|
||||
</AlertDialog.Description>
|
||||
|
||||
<Flex mt="4" justify="end">
|
||||
<AlertDialog.Action>
|
||||
<Button variant="solid">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">Look up a player to get started</Text>
|
||||
<ArrowDownIcon />
|
||||
</Flex>
|
||||
|
||||
<TextField.Root
|
||||
size="3"
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '75vw',
|
||||
maxWidth: 480,
|
||||
}}
|
||||
ref={input}
|
||||
placeholder="Search players..."
|
||||
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">Searching...</Text>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{!searching && searchResults.length === 0 && (
|
||||
<Flex justify="center">
|
||||
<Text color="gray">No players found</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">Tracking {accountInfo?.nickname}</Heading>
|
||||
<Text color="gray">
|
||||
Since {new Date(session.player.since).toLocaleString()}
|
||||
</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;
|
||||
}
|
||||
|
||||
mutateSession((draft) => {
|
||||
(draft as SessionTracking).player.stats = stats;
|
||||
});
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
mutateSession((draft) => {
|
||||
draft.tracking = false;
|
||||
});
|
||||
}}
|
||||
>
|
||||
Change
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{session.tracking && delta && delta.length > 0 && (
|
||||
<Flex justify="center">
|
||||
<StickyTableRoot variant="surface">
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeaderCell>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={() => {
|
||||
mutateSession((draft) => {
|
||||
(draft as SessionTracking).columns.unshift(
|
||||
key,
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{strings.common.composite_stats[key]}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
)}
|
||||
|
||||
<Select.Root
|
||||
value={column}
|
||||
onValueChange={(value) => {
|
||||
mutateSession((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>
|
||||
{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.Separator />
|
||||
|
||||
<Select.Item value="remove">Remove</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',
|
||||
}}
|
||||
>
|
||||
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">No battles</Heading>
|
||||
<Text color="gray">Go ahead and play a game</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
import { sessionTool } from '../../../../constants/tools';
|
||||
import ToolLayout from '../../../layouts/ToolLayout.astro';
|
||||
import { Page } from './_index';
|
||||
---
|
||||
|
||||
<ToolLayout tool={sessionTool}>
|
||||
<Page client:load />
|
||||
</ToolLayout>
|
||||
@@ -0,0 +1,61 @@
|
||||
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 { HeroSection } from '../../../../../components/Tankopedia/HeroSection';
|
||||
import { HistorySection } from '../../../../../components/Tankopedia/HistorySection';
|
||||
import { MetaSection } from '../../../../../components/Tankopedia/MetaSection';
|
||||
import { TankopediaCharts } from '../../../../../components/Tankopedia/TankopediaCharts';
|
||||
import { TechTreeSection } from '../../../../../components/Tankopedia/TechTreeSection';
|
||||
import { VideoSection } from '../../../../../components/Tankopedia/VideoSection';
|
||||
import { awaitableModelDefinitions } from '../../../../../core/awaitables/modelDefinitions';
|
||||
import { awaitableProvisionDefinitions } from '../../../../../core/awaitables/provisionDefinitions';
|
||||
import { awaitableTankDefinitions } from '../../../../../core/awaitables/tankDefinitions';
|
||||
import { App } from '../../../../../stores/app';
|
||||
import { Duel } from '../../../../../stores/duel';
|
||||
import { TankopediaEphemeral } from '../../../../../stores/tankopediaEphemeral';
|
||||
import { TankopediaPersistent } from '../../../../../stores/tankopediaPersistent';
|
||||
import type { MaybeSkeletonComponentProps } from '../../../../../types/maybeSkeletonComponentProps';
|
||||
|
||||
type PageProps = MaybeSkeletonComponentProps & {
|
||||
id: number;
|
||||
};
|
||||
|
||||
const [tankDefinitions, provisionDefinitions, modelDefinitions] =
|
||||
await Promise.all([
|
||||
awaitableTankDefinitions,
|
||||
awaitableProvisionDefinitions,
|
||||
awaitableModelDefinitions,
|
||||
]);
|
||||
|
||||
export function Page({ id, skeleton }: PageProps) {
|
||||
const tank = tankDefinitions.tanks[id];
|
||||
const model = modelDefinitions.models[id];
|
||||
|
||||
return (
|
||||
<TankopediaEphemeral.Provider data={model}>
|
||||
<App.Provider>
|
||||
<TankopediaPersistent.Provider>
|
||||
<Duel.Provider
|
||||
data={{ tank, provisionDefinitions: provisionDefinitions }}
|
||||
>
|
||||
<PageWrapper p="0" maxWidth="unset" color="purple" gap="9" pb="9">
|
||||
<HeroSection skeleton={skeleton} />
|
||||
<CalloutsSection />
|
||||
<MetaSection />
|
||||
{tank.type === TankType.RESEARCHABLE && !tank.deprecated && (
|
||||
<TechTreeSection skeleton={skeleton} />
|
||||
)}
|
||||
<CharacteristicsSection />
|
||||
<GameModeSection />
|
||||
<TankopediaCharts />
|
||||
<VideoSection skeleton={skeleton} />
|
||||
<HistorySection />
|
||||
</PageWrapper>
|
||||
</Duel.Provider>
|
||||
</TankopediaPersistent.Provider>
|
||||
</App.Provider>
|
||||
</TankopediaEphemeral.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
import {
|
||||
asset,
|
||||
createDefaultProvisions,
|
||||
createDefaultSkills,
|
||||
fetchEquipmentDefinitions,
|
||||
fetchModelDefinitions,
|
||||
fetchProvisionDefinitions,
|
||||
fetchSkillDefinitions,
|
||||
fetchTankDefinitions,
|
||||
TIER_ROMAN_NUMERALS,
|
||||
} from '@blitzkit/core';
|
||||
import strings from '@blitzkit/core/lang/en-US.json';
|
||||
import type { GetStaticPaths } from 'astro';
|
||||
import { tankCharacteristics } from '../../../../../core/blitzkit/tankCharacteristics';
|
||||
import BaseLayout from '../../../../layouts/BaseLayout.astro';
|
||||
import { genericDefaultEquipmentMatrix } from '../../../../../stores/duel/constants';
|
||||
import { Page } from './_index';
|
||||
|
||||
export const getStaticPaths = (async () => {
|
||||
const tankDefinitions = await fetchTankDefinitions();
|
||||
|
||||
return Object.values(tankDefinitions.tanks).map((tank) => ({
|
||||
params: { id: tank.id },
|
||||
}));
|
||||
}) satisfies GetStaticPaths;
|
||||
|
||||
const { id } = Astro.params;
|
||||
|
||||
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 name = tank.name_full ?? tank.name;
|
||||
const title = `${name} - Tier ${TIER_ROMAN_NUMERALS[tank.tier]} ${
|
||||
strings.common.nations_adjectives[
|
||||
tank.nation as keyof typeof strings.common.nations_adjectives
|
||||
]
|
||||
} ${strings.common.tank_class_short[tank.class]} | BlitzKit Tankopedia | World of Tanks Blitz (WoTB)`;
|
||||
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 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: gun.gun_type!.value.base.shells[0],
|
||||
stockEngine: tank.engines[0],
|
||||
stockGun: tank.turrets[0].guns[0],
|
||||
stockTurret: tank.turrets[0],
|
||||
stockTrack: tank.tracks[0],
|
||||
track: tank.tracks.at(-1)!,
|
||||
},
|
||||
{ equipmentDefinitions, provisionDefinitions, tankModelDefinition },
|
||||
);
|
||||
const description = `${
|
||||
name
|
||||
} armor, statistics, equipment, provisions, and more. ${Math.round(
|
||||
characteristics.dpm,
|
||||
).toLocaleString()} DPM, ${characteristics.damage.toFixed(0)}hp alpha, ${characteristics.penetration.toFixed(
|
||||
0,
|
||||
)}mm penetration, ${characteristics.speedForwards.toFixed(
|
||||
0,
|
||||
)}kph top speed, ${characteristics.health}hp health.`;
|
||||
---
|
||||
|
||||
<title>{title}</title>
|
||||
<meta property="og:title" content={title} />
|
||||
<meta name="description" content={description} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:image" content={icon} />
|
||||
<meta property="og:url" content={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>
|
||||
<Page id={id} client:only="react">
|
||||
<Page id={id} skeleton slot="fallback" />
|
||||
</Page>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,16 @@
|
||||
import { PageWrapper } from '../../../../components/PageWrapper';
|
||||
import { TankSearch } from '../../../../components/TankSearch';
|
||||
import { App } from '../../../../stores/app';
|
||||
import { TankopediaPersistent } from '../../../../stores/tankopediaPersistent';
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<PageWrapper color="purple" maxWidth="80rem">
|
||||
<TankopediaPersistent.Provider>
|
||||
<App.Provider>
|
||||
<TankSearch />
|
||||
</App.Provider>
|
||||
</TankopediaPersistent.Provider>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
import { tankopediaTool } from '../../../../constants/tools';
|
||||
import ToolLayout from '../../../layouts/ToolLayout.astro';
|
||||
import { Page } from './_index';
|
||||
---
|
||||
|
||||
<ToolLayout tool={tankopediaTool}>
|
||||
<Page client:only="react">
|
||||
<Page 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 { TankopediaPersistent } from '../../../../stores/tankopediaPersistent';
|
||||
import { TierList } from '../../../../stores/tierList';
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<TierList.Provider>
|
||||
<TankopediaPersistent.Provider>
|
||||
<Content />
|
||||
</TankopediaPersistent.Provider>
|
||||
</TierList.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function Content() {
|
||||
return (
|
||||
<PageWrapper color="orange" maxWidth="100rem">
|
||||
<URLManager />
|
||||
<TierListControls />
|
||||
<TierListTable />
|
||||
<FilterControl />
|
||||
<TierListTiles />
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
import { tierListTool } from '../../../../constants/tools';
|
||||
import ToolLayout from '../../../layouts/ToolLayout.astro';
|
||||
import { Page } from './_index';
|
||||
---
|
||||
|
||||
<ToolLayout tool={tierListTool}>
|
||||
<Page client:load />
|
||||
</ToolLayout>
|
||||
Reference in new issue
Block a user