feat: migrate settins

This commit is contained in:
TrèsAbhi committed 2024-10-07 17:52:45 -05:00
1 parent 746dd998aa
commit dc37b23727
39 files changed
+44 -2297

No files matched your search

@@ -1,17 +0,0 @@
import { ReactNode } from 'react';
import * as CompareEphemeral from '../../../stores/compareEphemeral';
import * as ComparePersistent from '../../../stores/comparePersistent';
import * as TankFilters from '../../../stores/tankFilters';
import * as TankopediaSort from '../../../stores/tankopediaSort';
export default function Layout({ children }: { children: ReactNode }) {
return (
<TankopediaSort.Provider>
<TankFilters.Provider>
<ComparePersistent.Provider>
<CompareEphemeral.Provider>{children}</CompareEphemeral.Provider>
</ComparePersistent.Provider>
</TankFilters.Provider>
</TankopediaSort.Provider>
);
}
@@ -1,147 +0,0 @@
import { createDefaultSkills } from '@blitzkit/core';
import { PlusIcon } from '@radix-ui/react-icons';
import { Box, Flex, Heading, IconButton, Text } from '@radix-ui/themes';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { use, useCallback, useEffect, useMemo, useState } from 'react';
export default function Page() {
const pathname = usePathname();
const router = useRouter();
const awaitedTankDefinitions = use(tankDefinitions);
const awaitedSkillDefinitions = use(skillDefinitions);
const searchParams = useSearchParams();
const members = CompareEphemeral.use((state) => state.members);
const [addTankDialogOpen, setAddTankDialogOpen] = useState(false);
const awaitedModelDefinitions = use(modelDefinitions);
const awaitedEquipmentDefinitions = use(equipmentDefinitions);
const awaitedProvisionDefinitions = use(provisionDefinitions);
const crewSkills = CompareEphemeral.use((state) => state.crewSkills);
const mutateCompareEphemeral = CompareEphemeral.useMutation();
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: awaitedEquipmentDefinitions,
provisionDefinitions: awaitedProvisionDefinitions,
tankModelDefinition: awaitedModelDefinitions[thisMember.tank.id],
},
),
),
[members, crewSkills],
);
const createQueryString = useCallback(
(name: string, value: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set(name, value);
return params.toString();
},
[searchParams],
);
useEffect(() => {
router.push(
pathname +
'?' +
createQueryString(
'tanks',
members.map(({ tank }) => tank.id).join(','),
),
);
}, [members]);
useEffect(() => {
const tanksParam = searchParams.get('tanks');
if (tanksParam) {
mutateCompareEphemeral((draft) => {
draft.members = tanksParam
.split(',')
.map(Number)
.map((id) =>
tankToCompareMember(
awaitedTankDefinitions[id],
awaitedProvisionDefinitions,
),
);
});
}
if (Object.keys(crewSkills).length === 0) {
mutateCompareEphemeral((draft) => {
draft.crewSkills = createDefaultSkills(awaitedSkillDefinitions);
});
}
}, []);
return (
<PageWrapper color="crimson" size="100%">
<Flex justify="center" gap="4" align="center" direction="column">
<DamageWarning />
<CompareControls
addTankDialogOpen={addTankDialogOpen}
onAddTankDialogOpenChange={setAddTankDialogOpen}
/>
</Flex>
{members.length > 0 && (
<Flex justify="center">
<Box maxWidth="100%" position="relative">
<CompareTable stats={stats} />
<IconButton
style={{
position: 'absolute',
right: 0,
top: 73,
transform: 'translate(50%, -50%)',
zIndex: 1,
}}
onClick={() => setAddTankDialogOpen(true)}
>
<PlusIcon />
</IconButton>
</Box>
</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>
);
}
@@ -1,19 +0,0 @@
import { Switch } from '@radix-ui/themes';
import * as EmbedState from '../../../../../stores/embedState';
import { EmbedPreviewControllerProps } from '../page';
export function Boolean({ configKey }: EmbedPreviewControllerProps) {
const mutateEmbedState = EmbedState.useMutation();
return (
<Switch
variant="soft"
checked={EmbedState.use((state) => state[configKey] as boolean)}
onCheckedChange={() => {
mutateEmbedState((draft) => {
draft[configKey] = !draft[configKey];
});
}}
/>
);
}
@@ -1,38 +0,0 @@
import { Slider } from '@radix-ui/themes';
interface ColorControllerRawProps {
value: string;
onValueChange: (value: string) => void;
}
export function ColorControllerRaw({
value,
onValueChange,
}: ColorControllerRawProps) {
const tintString = value.slice(0, 7);
const alphaString = value.slice(7, 9);
const alpha = alphaString.length === 0 ? 255 : parseInt(alphaString, 16);
return (
<>
<input
type="color"
value={value.slice(0, 7)}
onChange={(event) => {
let alpha = value.slice(7, 9);
if (alpha.length === 0) alpha = 'ff';
onValueChange(`${event.target.value}${alpha}`);
}}
/>
<Slider
variant="soft"
min={0}
max={255}
value={[alpha]}
onValueChange={([value]) => {
onValueChange(`${tintString}${value.toString(16)}`);
}}
/>
</>
);
}
@@ -1,19 +0,0 @@
import * as EmbedState from '../../../../../../stores/embedState';
import { EmbedPreviewControllerProps } from '../../page';
import { ColorControllerRaw } from './components/ColorControllerRaw';
export function Color({ configKey }: EmbedPreviewControllerProps) {
const mutateEmbedState = EmbedState.useMutation();
const color = EmbedState.use((state) => state[configKey] as string);
return (
<ColorControllerRaw
value={color}
onValueChange={(value) => {
mutateEmbedState((draft) => {
draft[configKey] = value;
});
}}
/>
);
}
@@ -1,36 +0,0 @@
import { Select } from '@radix-ui/themes';
import * as EmbedState from '../../../../../stores/embedState';
import {
EmbedConfigItemType,
EmbedItemType,
} from '../../../../../stores/embedState/constants';
import { EmbedPreviewControllerProps } from '../page';
export function Enum({
configKey,
config,
}: EmbedPreviewControllerProps & {
config: EmbedConfigItemType<EmbedItemType.Enum>;
}) {
const mutateEmbedState = EmbedState.useMutation();
return (
<Select.Root
value={EmbedState.use((state) => state[configKey] as string)}
onValueChange={(value) => {
mutateEmbedState((draft) => {
draft[configKey] = value;
});
}}
>
<Select.Trigger />
<Select.Content>
{config.options.map((option) => (
<Select.Item key={option.value} value={option.value}>
{option.label}
</Select.Item>
))}
</Select.Content>
</Select.Root>
);
}
@@ -1,80 +0,0 @@
import {
AlertDialog,
Button,
Flex,
Heading,
Text,
TextField,
Tooltip,
} from '@radix-ui/themes';
import { useRef, useState } from 'react';
import * as EmbedState from '../../../../../stores/embedState';
export function Import() {
const importInput = useRef<HTMLInputElement>(null);
const [showInvalidURLPrompt, setShowInvalidURLPrompt] = useState(false);
const [showConfirmation, setConfirmation] = useState(false);
const mutateEmbedState = EmbedState.useMutation();
return (
<>
<Heading>Import</Heading>
<Text size="2" color="gray" mb="2">
Paste your current URL if you have one already to import your settings
</Text>
<AlertDialog.Root
open={showInvalidURLPrompt}
onOpenChange={setShowInvalidURLPrompt}
>
<AlertDialog.Content>
<AlertDialog.Title>Invalid URL</AlertDialog.Title>
<AlertDialog.Description>
Try pasting the URL that BlitzKit previously generated, you silly
goose!
</AlertDialog.Description>
<Flex gap="3" mt="4" justify="end">
<AlertDialog.Cancel>
<Button>Okay, my bad</Button>
</AlertDialog.Cancel>
</Flex>
</AlertDialog.Content>
</AlertDialog.Root>
<Flex gap="2" mb="6">
<TextField.Root
ref={importInput}
placeholder="Paste URL..."
style={{ flex: 1 }}
/>
<Tooltip content="Applied!" open={showConfirmation}>
<Button
onClick={() => {
if (!importInput.current) return;
try {
const url = new URL(importInput.current.value);
const state = url.searchParams.get('state');
if (state === null) throw undefined;
const json = JSON.parse(state);
mutateEmbedState((draft) => {
Object.assign(draft, json);
});
importInput.current.value = '';
setConfirmation(true);
setTimeout(() => setConfirmation(false), 2000);
} catch {
setShowInvalidURLPrompt(true);
}
}}
>
Apply
</Button>
</Tooltip>
</Flex>
</>
);
}
@@ -1,26 +0,0 @@
import { keyframes, style } from '@vanilla-extract/css';
const fadeOutBorder = keyframes({
from: {
outline: '1rem solid #ff0000ff',
},
to: {
outline: '1rem solid #ff000000',
},
});
export const wrapper = style({
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
overflow: 'hidden',
});
export const animated = style({
animation: `${fadeOutBorder} 0.5s linear`,
':hover': {
outline: '1rem solid #ff000080',
},
});
@@ -1,36 +0,0 @@
import { Box } from '@radix-ui/themes';
import { useEffect, useRef } from 'react';
import { previews } from '../../../../../../../../website/src/constants/embeds';
import { useEmbedStateCurry } from '../../../../../../stores/embedState/utilities';
import * as styles from './index.css';
interface PreviewWrapperProps {
name: keyof typeof previews;
}
export function PreviewWrapper({ name }: PreviewWrapperProps) {
const { useState } = useEmbedStateCurry();
const width = useState('width');
const height = useState('height');
const Preview = previews[name];
const wrapper = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!wrapper.current) return;
wrapper.current.classList.remove(styles.animated);
void wrapper.current.offsetWidth;
wrapper.current.classList.add(styles.animated);
}, [width, height]);
return (
<Box
ref={wrapper}
className={styles.wrapper}
width={`${width}px`}
height={`${height}px`}
>
<Preview />
</Box>
);
}
@@ -1,26 +0,0 @@
import { Slider } from '@radix-ui/themes';
import * as EmbedState from '../../../../../stores/embedState';
import { EmbedPreviewControllerProps } from '../page';
export function Radius({ configKey }: EmbedPreviewControllerProps) {
const mutateEmbedState = EmbedState.useMutation();
return (
<Slider
variant="soft"
min={0}
max={5}
value={[
EmbedState.use((state) => {
const radius = state[configKey] as EmbedState.RadixRadius;
return radius === 'full' ? 5 : parseInt(radius);
}),
]}
onValueChange={([value]) => {
mutateEmbedState((draft) => {
draft[configKey] = value === 5 ? 'full' : `${value}`;
});
}}
/>
);
}
@@ -1,32 +0,0 @@
import * as EmbedState from '../../../../../stores/embedState';
import {
EmbedConfigItemType,
EmbedItemType,
} from '../../../../../stores/embedState/constants';
import { EmbedPreviewControllerProps } from '../page';
import { ColorControllerRaw } from './Color/components/ColorControllerRaw';
export function RichText({ configKey }: EmbedPreviewControllerProps) {
const mutateEmbedState = EmbedState.useMutation();
const state = EmbedState.use(
(state) =>
state[
configKey
] as EmbedConfigItemType<EmbedItemType.RichText>['default'],
);
return (
<ColorControllerRaw
value={state.color}
onValueChange={(value) => {
mutateEmbedState((draft) => {
(
draft[
configKey
] as EmbedConfigItemType<EmbedItemType.RichText>['default']
).color = value;
});
}}
/>
);
}
@@ -1,25 +0,0 @@
import { Slider } from '@radix-ui/themes';
import * as EmbedState from '../../../../../stores/embedState';
import { EmbedPreviewControllerProps } from '../page';
export function Size({ configKey }: EmbedPreviewControllerProps) {
const mutateEmbedState = EmbedState.useMutation();
return (
<Slider
variant="soft"
min={0}
max={9}
value={[
EmbedState.use((state) =>
parseInt(state[configKey] as EmbedState.RadixSize),
),
]}
onValueChange={([value]) => {
mutateEmbedState((draft) => {
draft[configKey] = `${value}`;
});
}}
/>
);
}
@@ -1,25 +0,0 @@
import { Slider } from '@radix-ui/themes';
import * as EmbedState from '../../../../../stores/embedState';
import { EmbedPreviewControllerProps } from '../page';
export function SizeWithout0({ configKey }: EmbedPreviewControllerProps) {
const mutateEmbedState = EmbedState.useMutation();
return (
<Slider
variant="soft"
min={1}
max={9}
value={[
EmbedState.use((state) =>
parseInt(state[configKey] as EmbedState.RadixSizeWithout0),
),
]}
onValueChange={([value]) => {
mutateEmbedState((draft) => {
draft[configKey] = `${value}`;
});
}}
/>
);
}
@@ -1,34 +0,0 @@
import { Slider as RadixSlider } from '@radix-ui/themes';
import * as EmbedState from '../../../../../stores/embedState';
import {
EmbedConfigItemType,
EmbedItemType,
} from '../../../../../stores/embedState/constants';
import { EmbedPreviewControllerProps } from '../page';
export function Slider({
configKey,
config,
}: EmbedPreviewControllerProps & {
config: EmbedConfigItemType<EmbedItemType.Slider>;
}) {
const mutateEmbedState = EmbedState.useMutation();
return (
<RadixSlider
variant="soft"
min={config.min}
max={config.max}
value={[
EmbedState.use((state) =>
parseInt(state[configKey] as EmbedState.RadixSize),
),
]}
onValueChange={([value]) => {
mutateEmbedState((draft) => {
draft[configKey] = value;
});
}}
/>
);
}
@@ -1,19 +0,0 @@
import { TextField } from '@radix-ui/themes';
import * as EmbedState from '../../../../../stores/embedState';
import { EmbedPreviewControllerProps } from '../page';
export function Text({ configKey }: EmbedPreviewControllerProps) {
const mutateEmbedState = EmbedState.useMutation();
return (
<TextField.Root
placeholder="Empty"
value={EmbedState.use((state) => state[configKey] as string)}
onChange={(event) => {
mutateEmbedState((draft) => {
draft[configKey] = event.target.value;
});
}}
/>
);
}
@@ -1,45 +0,0 @@
import { Code, Heading } from '@radix-ui/themes';
import { useSearchParams } from 'next/navigation';
import { useMemo } from 'react';
import {
configurations,
extractEmbedConfigDefaults,
} from '../../../../../../../website/src/constants/embeds';
import { PageWrapper } from '../../../../../components/PageWrapper';
import * as EmbedState from '../../../../../stores/embedState';
export interface EmbedPreviewControllerProps {
configKey: string;
}
export default function Page({
params,
}: {
params: { embed: keyof typeof configurations };
}) {
const searchParams = useSearchParams();
// TODO: check for this? idk lol
const stateParam = searchParams.get('state')!;
const config = configurations[params.embed];
const initialState = useMemo(
() => ({
...extractEmbedConfigDefaults(config),
...(JSON.parse(stateParam) as EmbedState.EmbedState),
}),
[params.embed],
);
return (
<EmbedState.Provider data={initialState}>
<PageWrapper>
<Heading>
<Code>{params.embed}</Code>
</Heading>
<Code color="gray">
<pre>{JSON.stringify(initialState, null, 2)}</pre>
</Code>
</PageWrapper>
</EmbedState.Provider>
);
}
@@ -1,28 +0,0 @@
import { useMemo } from 'react';
import {
configurations,
extractEmbedConfigDefaults,
} from '../../../../../../website/src/constants/embeds';
import * as EmbedState from '../../../../stores/embedState';
export function generateStaticParams() {
return Object.keys(configurations).map((embed) => ({ embed }));
}
export default function Layout({
params,
children,
}: {
params: { embed: keyof typeof configurations };
children: React.ReactNode;
}) {
const config = configurations[params.embed];
const initialState = useMemo(
() => extractEmbedConfigDefaults(config),
[params.embed],
);
return (
<EmbedState.Provider data={initialState}>{children}</EmbedState.Provider>
);
}
@@ -1,287 +0,0 @@
import { imgur } from '@blitzkit/core';
import {
CodeIcon,
HeightIcon,
ImageIcon,
Link2Icon,
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 { ReactNode, useEffect, useRef, useState } from 'react';
import { stringify } from 'urlon';
import { configurations } from '../../../../../../website/src/constants/embeds';
import { CopyButton } from '../../../../components/CopyButton';
import { PageWrapper } from '../../../../components/PageWrapper';
import * as App from '../../../../stores/app';
import * as EmbedState from '../../../../stores/embedState';
import { EmbedConfig } from '../../../../stores/embedState';
import { EmbedItemType } from '../../../../stores/embedState/constants';
import { Boolean } from './components/Boolean';
import { Color } from './components/Color';
import { Enum } from './components/Enum';
import { Import } from './components/Import';
import { PreviewWrapper } from './components/PreviewWrapper';
import { Radius } from './components/Radius';
import { RichText } from './components/RichText';
import { Size } from './components/Size';
import { SizeWithout0 } from './components/SizeWithout0';
import { Slider } from './components/Slider';
import { Text as TextController } from './components/Text';
export interface EmbedPreviewControllerProps {
configKey: string;
}
export default function Page({
params,
}: {
params: { embed: keyof typeof configurations };
}) {
const embedStateStore = EmbedState.useStore();
const appStore = App.useStore();
const config = configurations[params.embed] as EmbedConfig;
const [backgroundImage, setBackgroundImage] = useState(imgur('SO13zur'));
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" size={1028 + 640}>
<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">
<CopyButton
copy={() => {
const { wargaming } = appStore.getState().logins;
if (!wargaming) return;
const state = embedStateStore.getState();
const initial = embedStateStore.getInitialState();
const shallowState: EmbedState.EmbedState = {};
Object.entries(state).forEach(([key, value]) => {
if (value !== initial[key]) {
shallowState[key] = value;
}
});
const searchParams = new URLSearchParams({
id: `${wargaming.id}`,
state: stringify(shallowState),
});
return `${location.origin}/tools/embed/${
params.embed
}/host?${searchParams.toString()}`;
}}
>
<Link2Icon />
Copy URL
</CopyButton>
<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={() => '`'}>
<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
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"
// TODO: re-implement this sticky preview
// 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={params.embed} />
</Box>
</Flex>
</Box>
</Flex>
</PageWrapper>
);
}
@@ -1,5 +0,0 @@
import { PageWrapper } from '../../../components/PageWrapper';
export default function Page() {
return <PageWrapper></PageWrapper>;
}
@@ -1,95 +0,0 @@
import { imgur, ImgurSize } from '@blitzkit/core';
import { Link } from '@radix-ui/themes';
import { usePathname } from 'next/navigation';
import { ReactNode } from 'react';
import { TOOLS } from '../../../../website/src/constants/tools';
export default function Layout({ children }: { children: ReactNode }) {
const pathname = usePathname();
const pathSplit = pathname.split('/');
const toolId = pathSplit.at(2);
const isToolRoot = pathname === `/tools/${toolId}`;
const tool = TOOLS.find((tool) => tool.id === toolId);
return (
<>
{tool && (
<>
{isToolRoot && (
<>
<title>{tool.title}</title>
<meta property="og:image" content={imgur(tool.image)} />
{tool.pageDescription !== undefined && (
<>
<meta name="description" content={tool.pageDescription} />
<meta
property="og:description"
content={tool.pageDescription}
/>
</>
)}
</>
)}
</>
)}
{pathSplit.length === 3 && (
<div
style={{
height: 128,
boxShadow: `inset 0 -192px 128px -128px red`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
position: 'relative',
overflow: 'hidden',
}}
>
<div
style={{
position: 'absolute',
backgroundImage: `url(${imgur(tool!.image, {
format: 'jpeg',
size: ImgurSize.HugeThumbnail,
})})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
width: '100%',
height: '100%',
top: 0,
left: 0,
zIndex: -1,
// filter: 'blur(4px)',
transform: 'scale(calc((128 + 8) / 128))',
}}
/>
<Link
href={`/tools/${toolId}`}
style={{
textDecoration: 'none',
fontWeight: 900,
color: 'red',
fontSize: 32,
letterSpacing: -1,
}}
>
{tool?.title}
</Link>
<span
style={{
color: 'red',
fontSize: 16,
}}
>
{tool?.description}
</span>
</div>
)}
{children}
</>
);
}
@@ -1,14 +0,0 @@
import { ReactNode } from 'react';
import * as TankFilters from '../../../stores/tankFilters';
import * as TankPerformancePersistent from '../../../stores/tankPerformancePersistent';
import * as TankPerformanceSort from '../../../stores/tankPerformanceSort';
export default function Layout({ children }: { children: ReactNode }) {
return (
<TankPerformancePersistent.Provider>
<TankPerformanceSort.Provider>
<TankFilters.Provider>{children}</TankFilters.Provider>
</TankPerformanceSort.Provider>
</TankPerformancePersistent.Provider>
);
}
@@ -1,15 +0,0 @@
import { Controls } from '../../../../../website/src/components/Performance/Controls';
import { Info } from '../../../../../website/src/components/Performance/PerformanceInfo';
import { TankTable } from '../../../../../website/src/components/Performance/Table';
import { PageWrapper } from '../../../components/PageWrapper';
export default function Page() {
return (
<PageWrapper color="jade" noMaxWidth>
<Info />
{/* <TierBreakdown /> */}
<Controls />
<TankTable />
</PageWrapper>
);
}
@@ -1,13 +0,0 @@
import { keyframes, style } from '@vanilla-extract/css';
const spinKeyframes = keyframes({
from: { transform: 'rotate(0deg)' },
to: { transform: 'rotate(360deg)' },
});
export const infiniteSpin = style({
animationName: spinKeyframes,
animationIterationCount: 'infinite',
animationTimingFunction: 'linear',
animationDuration: '1s',
});
@@ -1,94 +0,0 @@
import {
CaretLeftIcon,
CaretRightIcon,
SymbolIcon,
} from '@radix-ui/react-icons';
import { Flex, IconButton, TextField } from '@radix-ui/themes';
import { range } from 'lodash-es';
import { useEffect, useRef } from 'react';
import { infiniteSpin } from './index.css';
interface PageTurnerProps {
leaderboard: Record<number, number>;
page: number;
rowsPerPage: number;
pages: number;
onPageChange: (page: number, isButtonClick: boolean) => void;
totalPlayers: number;
}
export function PageTurner({
page,
rowsPerPage,
onPageChange,
pages,
leaderboard,
totalPlayers,
}: PageTurnerProps) {
const pageInput = useRef<HTMLInputElement>(null);
const rightLoading = range(
page * rowsPerPage,
Math.min(totalPlayers - 1, page * rowsPerPage + rowsPerPage + 1),
).some((index) => !(index in leaderboard));
const leftLoading = range(
Math.max(0, page * rowsPerPage - 1),
Math.max(0, page * rowsPerPage - rowsPerPage - 1) + 1,
).some((index) => !(index in leaderboard));
useEffect(() => {
if (pageInput.current) pageInput.current.value = `${page + 1}`;
}, [page]);
return (
<Flex justify="center" wrap="wrap" gap="2">
<Flex gap="2">
<IconButton
variant="soft"
onClick={() => onPageChange(Math.max(page - 1, 0), true)}
disabled={page === 0 || leftLoading}
>
{leftLoading ? (
<SymbolIcon className={infiniteSpin} />
) : (
<CaretLeftIcon />
)}
</IconButton>
<TextField.Root
type="number"
ref={pageInput}
style={{ textAlign: 'center' }}
min={1}
max={pages}
onBlur={(event) => {
onPageChange(
Math.max(0, Math.min(pages - 1, event.target.valueAsNumber - 1)),
false,
);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
(event.target as HTMLInputElement).blur();
}
}}
>
<TextField.Slot>Page</TextField.Slot>
<TextField.Slot>out of {pages}</TextField.Slot>
</TextField.Root>
<IconButton
variant="soft"
onClick={() => {
onPageChange(Math.min(page + 1, pages - 1), true);
}}
disabled={page === pages - 1 || rightLoading}
>
{rightLoading ? (
<SymbolIcon className={infiniteSpin} />
) : (
<CaretRightIcon />
)}
</IconButton>
</Flex>
</Flex>
);
}
Whitespace-only changes.
@@ -1,6 +0,0 @@
import { ReactNode } from 'react';
import * as Session from '../../../stores/session';
export default function Layout({ children }: { children: ReactNode }) {
return <Session.Provider>{children}</Session.Provider>;
}
@@ -1,634 +0,0 @@
import {
asset,
coefficient,
CREW_MEMBER_NAMES,
fetchEquipmentDefinitions,
fetchProvisionDefinitions,
GUN_TYPE_NAMES,
isExplosive,
resolvePenetrationCoefficient,
} from '@blitzkit/core';
import { AccessibilityIcon, InfoCircledIcon } from '@radix-ui/react-icons';
import {
Flex,
Heading,
IconButton,
Popover,
Slider,
Text,
TextField,
Tooltip,
} from '@radix-ui/themes';
import { debounce } from 'lodash-es';
import { Fragment, useEffect, useRef, useState } from 'react';
import { Ad, AdType } from '../../../../../../../../website/src/components/Ad';
import { InfoWithDelta } from '../../../../../../../../website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/InfoWithDelta';
import { applyPitchYawLimits } from '../../../../../../../../website/src/core/blitz/applyPitchYawLimits';
import { tankCharacteristics } from '../../../../../../../../website/src/core/blitzkit/tankCharacteristics';
import { useAdExempt } from '../../../../../../../../website/src/hooks/useAdExempt';
import { useEquipment } from '../../../../../../../../website/src/hooks/useEquipment';
import { useTankModelDefinition } from '../../../../../../../../website/src/hooks/useTankModelDefinition';
import { Duel } from '../../../../../../../../website/src/stores/duel';
import { TankopediaEphemeral } from '../../../../../../../../website/src/stores/tankopediaEphemeral';
const equipmentDefinitions = await fetchEquipmentDefinitions();
const provisionDefinitions = await fetchProvisionDefinitions();
export function Characteristics() {
const mutateDuel = Duel.useMutation();
const exempt = useAdExempt();
const crewSkills = TankopediaEphemeral.use((state) => state.skills);
const penetrationDistanceInput = useRef<HTMLInputElement>(null);
const hasImprovedVentilation = useEquipment(102);
const crewMastery = Duel.use((state) => state.protagonist.crewMastery);
const [penetrationDistance, setPenetrationDistance] = useState(250);
const setPenetrationDistanceDebounced = debounce((value: number) => {
setPenetrationDistance(value);
}, 500);
const provisions = Duel.use((state) => state.protagonist.provisions);
const provisionCrewBonus =
provisions.reduce(
(total, provision) =>
provisionDefinitions.provisions[provision].crew
? total + provisionDefinitions.provisions[provision].crew! / 100
: total,
0,
) + (hasImprovedVentilation ? 0.08 : 0);
const commanderMastery = crewMastery + provisionCrewBonus;
const consumables = Duel.use((state) => state.protagonist.consumables);
const camouflage = Duel.use((state) => state.protagonist.camouflage);
const equipmentMatrix = Duel.use(
(state) => state.protagonist.equipmentMatrix,
);
const { tank, turret, gun, engine, track, shell } = Duel.use(
(state) => state.protagonist,
);
const stockEngine = tank.engines[0];
const stockTrack = tank.tracks[0];
const stockTurret = tank.turrets[0];
const stockGun = stockTurret.guns[0];
const tankModelDefinition = useTankModelDefinition();
const turretModelDefinition = tankModelDefinition.turrets[turret.id];
const gunModelDefinition =
turretModelDefinition.guns[gun.gunType!.value.base.id];
const stats = tankCharacteristics(
{
tank,
camouflage,
consumables,
crewMastery,
crewSkills,
engine,
equipmentMatrix,
gun,
provisions,
shell,
turret,
track,
stockEngine,
stockGun,
stockTrack,
stockTurret,
applyReactiveArmor: false,
applyDynamicArmor: false,
applySpallLiner: false,
},
{
tankModelDefinition,
equipmentDefinitions: equipmentDefinitions,
provisionDefinitions: provisionDefinitions,
},
);
const hasSupercharger = useEquipment(107);
const hasCalibratedShells = useEquipment(103);
const hasImprovedVerticalStabilizer = useEquipment(122);
const penetrationCoefficient = coefficient([
hasCalibratedShells,
resolvePenetrationCoefficient(true, shell.type) - 1,
]);
const penetrationLossOverDistanceCoefficient = coefficient([
hasSupercharger,
-0.6,
]);
useEffect(() => {
if (penetrationDistanceInput.current) {
penetrationDistanceInput.current.value = `${penetrationDistance}`;
}
}, [penetrationDistance]);
useEffect(() => {
mutateDuel((draft) => {
[draft.protagonist.pitch, draft.protagonist.yaw] = applyPitchYawLimits(
draft.protagonist.pitch,
draft.protagonist.yaw,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
});
}, [hasImprovedVerticalStabilizer]);
return (
<Flex
gap="8"
direction={{
initial: 'column',
md: 'row',
}}
>
<Flex direction="column" gap="8" style={{ flex: 1 }}>
<Flex direction="column" gap="2">
<Flex align="center" gap="4">
<Heading size="5">Firepower</Heading>
<Flex>
{gun.gunType!.value.base.shells.map((thisShell, shellIndex) => (
<Tooltip content={thisShell.name} key={thisShell.id}>
<IconButton
color={thisShell.id === shell.id ? undefined : 'gray'}
variant="soft"
style={{
borderTopLeftRadius: shellIndex === 0 ? undefined : 0,
borderBottomLeftRadius: shellIndex === 0 ? undefined : 0,
borderTopRightRadius:
shellIndex === gun.gunType!.value.base.shells.length - 1
? undefined
: 0,
borderBottomRightRadius:
shellIndex === gun.gunType!.value.base.shells.length - 1
? undefined
: 0,
marginLeft: shellIndex === 0 ? 0 : -1,
}}
onClick={() => {
mutateDuel((draft) => {
draft.protagonist.shell = thisShell;
});
}}
>
<img
alt={thisShell.name}
width={16}
height={16}
src={asset(`icons/shells/${thisShell.icon}.webp`)}
/>
</IconButton>
</Tooltip>
))}
</Flex>
</Flex>
<Info name="Gun type">{GUN_TYPE_NAMES[gun.gunType!.$case]}</Info>
<InfoWithDelta name="DPM" decimals={0} unit="hp / min">
{stats.dpm}
</InfoWithDelta>
{gun.type === 'autoReloader' && (
<InfoWithDelta
decimals={0}
indent
name="Effective at 60s"
unit="hp / min"
>
{stats.dpmEffective!}
</InfoWithDelta>
)}
{gun.type !== 'regular' && (
<InfoWithDelta name="Shells">{stats.shells}</InfoWithDelta>
)}
{gun.type === 'autoReloader' ? (
<>
<Info indent name="Most optimal shell index">
{stats.mostOptimalShellIndex! + 1}
</Info>
<Info name="Shell reloads" unit="s" />
{stats.shellReloads!.map((reload, index) => (
<InfoWithDelta
key={index}
indent
name={`Shell ${index + 1}`}
decimals={2}
deltaType="lowerIsBetter"
>
{reload}
</InfoWithDelta>
))}
</>
) : (
<InfoWithDelta
decimals={2}
name="Reload"
unit="s"
deltaType="lowerIsBetter"
>
{stats.shellReload!}
</InfoWithDelta>
)}
{(gun.type === 'autoLoader' || gun.type === 'autoReloader') && (
<InfoWithDelta
indent
decimals={2}
name="Intra-clip"
unit="s"
deltaType="lowerIsBetter"
>
{stats.intraClip!}
</InfoWithDelta>
)}
<InfoWithDelta decimals={0} name="Penetration" unit="mm">
{stats.penetration}
</InfoWithDelta>
{typeof shell.penetration !== 'number' && (
<>
<InfoWithDelta
indent
decimals={0}
name={`At ${penetrationDistance}m`}
>
{lerp(
shell.penetration[0],
shell.penetration[1],
(penetrationDistance *
penetrationLossOverDistanceCoefficient) /
500,
) * penetrationCoefficient}
</InfoWithDelta>
<Flex align="center" gap="2" style={{ paddingLeft: 24 }}>
<Text color="gray">Distance</Text>
<Slider
key={penetrationDistance}
min={0}
max={500}
style={{ flex: 1 }}
defaultValue={[penetrationDistance]}
onValueChange={([value]) => {
setPenetrationDistanceDebounced(value);
if (!penetrationDistanceInput.current) return;
penetrationDistanceInput.current.value = value.toString();
}}
/>
<TextField.Root
style={{ width: 64 }}
ref={penetrationDistanceInput}
defaultValue={penetrationDistance}
onBlur={() => {
setPenetrationDistance(
Math.min(
parseInt(penetrationDistanceInput.current!.value),
500,
),
);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.currentTarget.blur();
}
}}
>
<TextField.Slot side="right">m</TextField.Slot>
</TextField.Root>
</Flex>
</>
)}
<InfoWithDelta name="Damage" unit="hp" decimals={0}>
{stats.damage}
</InfoWithDelta>
{gun.type !== 'regular' && (
<InfoWithDelta name="Clipping potential" indent>
{stats.clipDamage!}
</InfoWithDelta>
)}
<InfoWithDelta name="Module damage" unit="hp" decimals={0}>
{stats.moduleDamage}
</InfoWithDelta>
{isExplosive(shell.type) && (
<InfoWithDelta name="Splash radius" unit="m" decimals={0}>
{stats.explosionRadius!}
</InfoWithDelta>
)}
<InfoWithDelta name="Caliber" decimals={0} unit="mm">
{stats.caliber}
</InfoWithDelta>
<InfoWithDelta name="Normalization" decimals={0} unit="°">
{stats.shellNormalization}
</InfoWithDelta>
{!isExplosive(shell.type) && (
<InfoWithDelta
name="Ricochet"
decimals={0}
deltaType="lowerIsBetter"
unit="°"
>
{stats.shellRicochet!}
</InfoWithDelta>
)}
<InfoWithDelta name="Shell velocity" unit="m/s" decimals={0}>
{stats.shellVelocity}
</InfoWithDelta>
<InfoWithDelta
decimals={2}
name="Aim time"
unit="s"
deltaType="lowerIsBetter"
>
{stats.aimTime}
</InfoWithDelta>
<Info name="Dispersion at 100m" />
<InfoWithDelta
decimals={3}
indent
name="Still"
unit="m"
deltaType="lowerIsBetter"
>
{stats.dispersion}
</InfoWithDelta>
<InfoWithDelta
prefix="+"
decimals={3}
indent
name="Moving"
deltaType="lowerIsBetter"
>
{stats.dispersionMoving}
</InfoWithDelta>
<InfoWithDelta
decimals={3}
prefix="+"
indent
name="Hull traversing"
deltaType="lowerIsBetter"
>
{stats.dispersionHullTraversing}
</InfoWithDelta>
<InfoWithDelta
decimals={3}
prefix="+"
indent
name="Turret traversing"
deltaType="lowerIsBetter"
>
{stats.dispersionTurretTraversing}
</InfoWithDelta>
<InfoWithDelta
decimals={3}
prefix="+"
indent
name="After shooting"
deltaType="lowerIsBetter"
>
{stats.dispersionShooting}
</InfoWithDelta>
<InfoWithDelta
decimals={1}
prefix="x "
indent
name="Gun damaged"
unit="scalar"
deltaType="lowerIsBetter"
>
{stats.dispersionGunDamaged}
</InfoWithDelta>
<InfoWithDelta
decimals={3}
indent
name="Maximum"
unit="m"
deltaType="lowerIsBetter"
>
{stats.maxDispersion}
</InfoWithDelta>
<Info name="Gun flexibility" unit="°" />
<InfoWithDelta decimals={1} indent name="Depression">
{stats.gunDepression}
</InfoWithDelta>
<InfoWithDelta decimals={1} indent name="Elevation">
{stats.gunElevation}
</InfoWithDelta>
{gunModelDefinition.pitch.front && (
<>
<InfoWithDelta decimals={1} indent name="Frontal depression">
{stats.gunFrontalDepression!}
</InfoWithDelta>
<InfoWithDelta decimals={1} indent name="Frontal elevation">
{stats.gunFrontalElevation!}
</InfoWithDelta>
</>
)}
{gunModelDefinition.pitch.back && (
<>
<InfoWithDelta decimals={1} indent name="Rear depression">
{stats.gunRearDepression!}
</InfoWithDelta>
<InfoWithDelta decimals={1} indent name="Rear elevation">
{stats.gunRearElevation!}
</InfoWithDelta>
</>
)}
{turretModelDefinition.yaw && (
<>
<InfoWithDelta decimals={1} indent name="Azimuth left">
{stats.azimuthLeft!}
</InfoWithDelta>
<InfoWithDelta decimals={1} indent name="Azimuth right">
{stats.azimuthRight!}
</InfoWithDelta>
</>
)}
</Flex>
<Flex direction="column" gap="2">
<Flex gap="2" align="center">
<Heading size="5">Crew training</Heading>
<Popover.Root>
<Popover.Trigger>
<IconButton variant="ghost">
<InfoCircledIcon />
</IconButton>
</Popover.Trigger>
<Popover.Content>
<Flex gap="1" align="center">
<AccessibilityIcon />
<Text>
represents the other roles a crew member can take.
</Text>
</Flex>
</Popover.Content>
</Popover.Root>
</Flex>
{tank.crew.map((member) => {
const count = member.count ?? 1;
return (
<Fragment key={member.type}>
<InfoWithDelta
key={`${member.type}-root`}
name={`${CREW_MEMBER_NAMES[member.type]}${
count > 1 ? ` x ${count}` : ''
}`}
unit="%"
decimals={0}
>
{(member.type === 'commander'
? commanderMastery
: commanderMastery * 1.1) * 100}
</InfoWithDelta>
{member.substitute && (
<InfoWithDelta
key={`${member.type}-substitute`}
decimals={0}
unit="%"
indent
name={
<>
<Flex align="center" gap="1" display="inline-flex">
<AccessibilityIcon />
{member.substitute
.map((sub, index) =>
index === 0 ? CREW_MEMBER_NAMES[sub] : sub,
)
.join(', ')}
</Flex>
</>
}
>
{commanderMastery * 1.05 * 100}
</InfoWithDelta>
)}
</Fragment>
);
})}
</Flex>
</Flex>
{!exempt && (
<Flex
justify="center"
display={{
initial: 'flex',
xs: 'none',
}}
>
<Ad type={AdType.MediumRectangleHorizontalPurple} />
</Flex>
)}
<Flex direction="column" gap="8" style={{ flex: 1 }}>
<Flex direction="column" gap="2">
<Heading size="5">Maneuverability</Heading>
<Info name="Speed" unit="kph" />
<InfoWithDelta decimals={0} indent name="Forwards">
{stats.speedForwards}
</InfoWithDelta>
<InfoWithDelta decimals={0} indent name="Backwards">
{stats.speedBackwards}
</InfoWithDelta>
<InfoWithDelta decimals={0} name="Engine power" unit="hp">
{stats.enginePower}
</InfoWithDelta>
<InfoWithDelta
decimals={1}
name="Weight"
unit="tn"
deltaType="lowerIsBetter"
>
{stats.weight}
</InfoWithDelta>
<Info name="Terrain coefficients" />
<InfoWithDelta decimals={0} unit="%" indent name="Hard">
{stats.hardTerrainCoefficient}
</InfoWithDelta>
<InfoWithDelta decimals={0} unit="%" indent name="Medium">
{stats.mediumTerrainCoefficient}
</InfoWithDelta>
<InfoWithDelta decimals={0} unit="%" indent name="Soft">
{stats.softTerrainCoefficient}
</InfoWithDelta>
<Info name="Raw terrain coefficients" />
<InfoWithDelta decimals={2} unit="%" indent name="Hard">
{stats.hardTerrainCoefficientRaw}
</InfoWithDelta>
<InfoWithDelta decimals={2} unit="%" indent name="Medium">
{stats.mediumTerrainCoefficientRaw}
</InfoWithDelta>
<InfoWithDelta decimals={2} unit="%" indent name="Soft">
{stats.softTerrainCoefficientRaw}
</InfoWithDelta>
<Info name="Power to weight ratio" unit="hp/tn" />
<InfoWithDelta decimals={1} indent name="On hard terrain">
{stats.powerToWeightRatioHardTerrain}
</InfoWithDelta>
<InfoWithDelta decimals={1} indent name="On medium terrain">
{stats.powerToWeightRatioMediumTerrain}
</InfoWithDelta>
<InfoWithDelta decimals={1} indent name="On soft terrain">
{stats.powerToWeightRatioSoftTerrain}
</InfoWithDelta>
<InfoWithDelta name="Turret traverse speed" unit="°/s" decimals={1}>
{stats.turretTraverseSpeed}
</InfoWithDelta>
<Info name="Hull traverse speed" unit="°/s" />
<InfoWithDelta decimals={1} indent name="On hard terrain">
{stats.hullTraverseHardTerrain}
</InfoWithDelta>
<InfoWithDelta decimals={1} indent name="On medium terrain">
{stats.hullTraverseMediumTerrain}
</InfoWithDelta>
<InfoWithDelta decimals={1} indent name="On soft terrain">
{stats.hullTraverseSoftTerrain}
</InfoWithDelta>
</Flex>
<Flex direction="column" gap="2">
<Heading size="5">Survivability</Heading>
<InfoWithDelta name="Health" unit="hp" decimals={0}>
{stats.health}
</InfoWithDelta>
<InfoWithDelta
name="Fire chance"
unit="%"
deltaType="lowerIsBetter"
decimals={0}
>
{stats.fireChance * 100}
</InfoWithDelta>
<InfoWithDelta name="View range" unit="m" decimals={0}>
{stats.viewRange}
</InfoWithDelta>
<Info name="Camouflage" unit="%" />
<InfoWithDelta indent name="Still" decimals={0}>
{stats.camouflageStill * 100}
</InfoWithDelta>
<InfoWithDelta indent name="Moving" decimals={0}>
{stats.camouflageMoving * 100}
</InfoWithDelta>
<InfoWithDelta indent name="Shooting still" decimals={0}>
{stats.camouflageShootingStill * 100}
</InfoWithDelta>
<InfoWithDelta indent name="Shooting moving" decimals={0}>
{stats.camouflageShootingMoving * 100}
</InfoWithDelta>
<InfoWithDelta indent name="Caught on fire" decimals={0}>
{stats.camouflageCaughtOnFire * 100}
</InfoWithDelta>
<Info name="Width" unit="m" decimals={0} deltaType="lowerIsBetter">
{stats.width}
</Info>
<Info name="Height" unit="m" decimals={0} deltaType="lowerIsBetter">
{stats.height}
</Info>
<Info name="Length" unit="m" decimals={0} deltaType="lowerIsBetter">
{stats.length}
</Info>
<Info name="Volume" unit="m" decimals={0} deltaType="lowerIsBetter">
{stats.volume}
</Info>
</Flex>
</Flex>
</Flex>
);
}
@@ -1,105 +0,0 @@
import { TIER_ROMAN_NUMERALS } from '@blitzkit/core';
import strings from '@blitzkit/core/lang/en-US.json';
import { invalidate } from '@react-three/fiber';
import { useEffect } from 'react';
import { CalloutsSection } from '../../../../../../../website/src/components/Tankopedia/CalloutsSection';
import { CharacteristicsSection } from '../../../../../../../website/src/components/Tankopedia/CharacteristicsSection';
import { GameModeSection } from '../../../../../../../website/src/components/Tankopedia/GameModeSection';
import { HeroSection } from '../../../../../../../website/src/components/Tankopedia/HeroSection';
import { HistorySection } from '../../../../../../../website/src/components/Tankopedia/HistorySection';
import { MetaSection } from '../../../../../../../website/src/components/Tankopedia/MetaSection';
import { ShotDisplaySection } from '../../../../../../../website/src/components/Tankopedia/ShotDisplaySection';
import { TechTreeSection } from '../../../../../../../website/src/components/Tankopedia/TechTreeSection';
import { AdMidSectionResponsive } from '../../../../../components/AdMidSectionResponsive';
import { PageWrapper } from '../../../../../components/PageWrapper';
import { useAdExempt } from '../../../../../hooks/useAdExempt';
import * as Duel from '../../../../../stores/duel';
import * as TankopediaEphemeral from '../../../../../stores/tankopediaEphemeral';
import { VideoSection } from '../../../../../../../website/src/components/Tankopedia/VideoSection';
export function Content() {
const exempt = useAdExempt();
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const mutateDuel = Duel.useMutation();
const duelStore = Duel.useStore();
const tank = Duel.use((state) => state.protagonist.tank);
const title = `${tank.name} - Tier ${TIER_ROMAN_NUMERALS[tank.tier]} ${
(strings.common.nations_adjectives as Record<string, string>)[tank.nation]
} ${strings.common.tank_class_short[tank.class]}`;
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (document.activeElement instanceof HTMLInputElement) return;
function wipeShot() {
mutateTankopediaEphemeral((draft) => {
draft.shot = undefined;
});
}
if (event.key === '1') {
invalidate();
wipeShot();
mutateDuel((draft) => {
draft.antagonist.shell = draft.antagonist.gun.shells[0];
});
} else if (event.key === '2') {
invalidate();
wipeShot();
mutateDuel((draft) => {
if (draft.antagonist.gun.shells[1]) {
draft.antagonist.shell = draft.antagonist.gun.shells[1];
}
});
} else if (event.key === '3') {
invalidate();
wipeShot();
mutateDuel((draft) => {
if (draft.antagonist.gun.shells[2]) {
draft.antagonist.shell = draft.antagonist.gun.shells[2];
}
});
}
}
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, []);
useEffect(() => {
const unsubscribe = duelStore.subscribe(
(state) => state.protagonist?.tank.id,
() => {
mutateTankopediaEphemeral((draft) => {
draft.shot = undefined;
});
},
);
return unsubscribe;
}, []);
return (
<>
<title>{title}</title>
<PageWrapper p="0" noMaxWidth color="purple" size={1600} gap="9">
<HeroSection />
<ShotDisplaySection />
<MetaSection />
<TechTreeSection />
{!exempt && <AdMidSectionResponsive />}
<CalloutsSection />
<CharacteristicsSection />
{!exempt && <AdMidSectionResponsive />}
<GameModeSection />
<VideoSection />
<HistorySection />
{!exempt && <AdMidSectionResponsive mb="6" />}
</PageWrapper>
</>
);
}
@@ -1,10 +0,0 @@
import { Separator } from '@radix-ui/themes';
import { PageWrapper } from '../../../../../components/PageWrapper';
export function TankopediaSeparator() {
return (
<PageWrapper>
<Separator mt="4" style={{ width: '100%' }} />
</PageWrapper>
);
}
@@ -1,107 +0,0 @@
import { provisionDefinitions, TREE_TYPE_ICONS } from '@blitzkit/core';
import { CaretRightIcon, UpdateIcon } from '@radix-ui/react-icons';
import { Button, Dialog, Flex, Heading, Link } from '@radix-ui/themes';
import { use, useState } from 'react';
import { pushTankopediaPath } from '../../../../../core/blitzkit/pushTankopediaPath';
import { tankToDuelMember } from '../../../../../core/blitzkit/tankToDuelMember';
import * as Duel from '../../../../../stores/duel';
import { TankSearch } from '../../components/TankSearch';
export function Title() {
const awaitedProvisionDefinitions = use(provisionDefinitions);
const protagonist = Duel.use((state) => state.protagonist);
const antagonist = Duel.use((state) => state.antagonist);
const compareTanks =
protagonist.tank.id === antagonist.tank.id
? [protagonist.tank.id]
: [protagonist.tank.id, antagonist.tank.id];
const [changeTankDialogOpen, setChangeTankDialogOpen] = useState(false);
const mutateDuel = Duel.useMutation();
return (
<>
<Flex
gap="6"
justify="center"
align="center"
style={{ padding: 16 }}
wrap="wrap"
>
<Flex gap="2" align="center">
<Heading
color={
protagonist.tank.treeType === 'premium'
? 'amber'
: protagonist.tank.treeType === 'collector'
? 'blue'
: undefined
}
>
<Flex align="center" gap="2">
<img
alt={protagonist.tank.name}
src={
TREE_TYPE_ICONS[protagonist.tank.treeType][
protagonist.tank.class
]
}
style={{
width: '1em',
height: '1em',
}}
/>{' '}
{protagonist.tank.name}
</Flex>
</Heading>
</Flex>
<Flex gap="6">
<Dialog.Root
open={changeTankDialogOpen}
onOpenChange={setChangeTankDialogOpen}
>
<Dialog.Trigger>
<Button variant="ghost">
Change <UpdateIcon />
</Button>
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Title align="center">Change tank</Dialog.Title>
<Flex gap="4" direction="column">
<Flex
direction="column"
gap="4"
style={{ flex: 1 }}
justify="center"
>
<TankSearch
compact
onSelect={(tank) => {
setChangeTankDialogOpen(false);
pushTankopediaPath(tank.id);
mutateDuel((draft) => {
draft.protagonist = tankToDuelMember(
tank,
awaitedProvisionDefinitions,
);
});
}}
/>
</Flex>
</Flex>
</Dialog.Content>
</Dialog.Root>
<Link href={`/tools/compare?tanks=${compareTanks.join('%2C')}`}>
<Button variant="ghost">
Compare <CaretRightIcon />
</Button>
</Link>
</Flex>
</Flex>
</>
);
}
@@ -1,53 +0,0 @@
import {
fetchModelDefinitions,
fetchTankDefinitions,
tankIcon,
TIER_ROMAN_NUMERALS,
} from '@blitzkit/core';
import strings from '@blitzkit/core/lang/en-US.json';
import { ReactNode } from 'react';
export async function generateStaticParams() {
const tankDefinitions = await fetchTankDefinitions();
const tanks = Object.values(tankDefinitions.tanks);
return tanks.map((tank) => ({ id: `${tank.id}` }));
}
const tankDefinitions = await fetchTankDefinitions();
const definitions = await fetchModelDefinitions();
export default async function Layout({
children,
params,
}: {
children: ReactNode;
params: { id: string };
}) {
const id = Number(params.id);
const tankModelDefinition = definitions[id];
const awaitedProvisionDefinitions = await provisionDefinitions;
const tank = tankDefinitions[id];
const title = `${tank.name} - Tier ${TIER_ROMAN_NUMERALS[tank.tier]} ${
(strings.common.nations_adjectives as Record<string, string>)[tank.nation]
} ${strings.common.tank_class_short[tank.class]}`;
const description = `Statistics, armor, and equipment for ${tank.name} in World of Tanks Blitz (WoTB)`;
return (
<TankopediaEphemeral.Provider data={tankModelDefinition}>
<Duel.Provider
data={{
tank: tankDefinitions[id],
provisionDefinitions: awaitedProvisionDefinitions,
}}
>
<title>{title}</title>
<meta name="description" content={description} />
<meta property="og:title" content={title} />
<meta property="og:image" content={tankIcon(id)} />
<meta property="og:description" content={description} />
{children}
</Duel.Provider>
</TankopediaEphemeral.Provider>
);
}
@@ -1,105 +0,0 @@
import { TIER_ROMAN_NUMERALS } from '@blitzkit/core';
import strings from '@blitzkit/core/lang/en-US.json';
import { invalidate } from '@react-three/fiber';
import { useEffect } from 'react';
import { CalloutsSection } from '../../../../../../website/src/components/Tankopedia/CalloutsSection';
import { CharacteristicsSection } from '../../../../../../website/src/components/Tankopedia/CharacteristicsSection';
import { GameModeSection } from '../../../../../../website/src/components/Tankopedia/GameModeSection';
import { HeroSection } from '../../../../../../website/src/components/Tankopedia/HeroSection';
import { HistorySection } from '../../../../../../website/src/components/Tankopedia/HistorySection';
import { MetaSection } from '../../../../../../website/src/components/Tankopedia/MetaSection';
import { ShotDisplaySection } from '../../../../../../website/src/components/Tankopedia/ShotDisplaySection';
import { TechTreeSection } from '../../../../../../website/src/components/Tankopedia/TechTreeSection';
import { AdMidSectionResponsive } from '../../../../components/AdMidSectionResponsive';
import { PageWrapper } from '../../../../components/PageWrapper';
import { useAdExempt } from '../../../../hooks/useAdExempt';
import * as Duel from '../../../../stores/duel';
import * as TankopediaEphemeral from '../../../../stores/tankopediaEphemeral';
import { VideoSection } from '../../../../../../website/src/components/Tankopedia/VideoSection';
export default function Page() {
const exempt = useAdExempt();
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const mutateDuel = Duel.useMutation();
const duelStore = Duel.useStore();
const tank = Duel.use((state) => state.protagonist.tank);
const title = `${tank.name} - Tier ${TIER_ROMAN_NUMERALS[tank.tier]} ${
(strings.common.nations_adjectives as Record<string, string>)[tank.nation]
} ${strings.common.tank_class_short[tank.class]}`;
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (document.activeElement instanceof HTMLInputElement) return;
function wipeShot() {
mutateTankopediaEphemeral((draft) => {
draft.shot = undefined;
});
}
if (event.key === '1') {
invalidate();
wipeShot();
mutateDuel((draft) => {
draft.antagonist.shell = draft.antagonist.gun.shells[0];
});
} else if (event.key === '2') {
invalidate();
wipeShot();
mutateDuel((draft) => {
if (draft.antagonist.gun.shells[1]) {
draft.antagonist.shell = draft.antagonist.gun.shells[1];
}
});
} else if (event.key === '3') {
invalidate();
wipeShot();
mutateDuel((draft) => {
if (draft.antagonist.gun.shells[2]) {
draft.antagonist.shell = draft.antagonist.gun.shells[2];
}
});
}
}
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, []);
useEffect(() => {
const unsubscribe = duelStore.subscribe(
(state) => state.protagonist?.tank.id,
() => {
mutateTankopediaEphemeral((draft) => {
draft.shot = undefined;
});
},
);
return unsubscribe;
}, []);
return (
<>
<title>{title}</title>
<PageWrapper p="0" noMaxWidth color="purple" size={1600} gap="9">
<HeroSection />
<ShotDisplaySection />
<MetaSection />
<TechTreeSection />
{!exempt && <AdMidSectionResponsive />}
<CalloutsSection />
<CharacteristicsSection />
{!exempt && <AdMidSectionResponsive />}
<GameModeSection />
<VideoSection />
<HistorySection />
{!exempt && <AdMidSectionResponsive mb="6" />}
</PageWrapper>
</>
);
}
@@ -1,79 +0,0 @@
import { CaretLeftIcon, CaretRightIcon } from '@radix-ui/react-icons';
import { Flex, IconButton, TextField } from '@radix-ui/themes';
import { useEffect, useRef } from 'react';
import * as TankopediaPersistent from '../../../../stores/tankopediaPersistent';
import { TankDefinition } from '@blitzkit/core';
interface PageTurnerProps {
searchedList: TankDefinition[];
tanksPerPage: number;
}
export function PageTurner({ searchedList, tanksPerPage }: PageTurnerProps) {
const page = TankopediaPersistent.use((state) => state.filters.page);
const mutateTankopediaPersistent = TankopediaPersistent.useMutation();
const pageInput = useRef<HTMLInputElement>(null);
useEffect(() => {
if (pageInput.current) pageInput.current.value = `${page + 1}`;
}, [page]);
return (
<Flex align="center" justify="center" gap="2">
<IconButton
variant="soft"
disabled={page === 0}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.filters.page = Math.max(0, page - 1);
});
}}
>
<CaretLeftIcon />
</IconButton>
<TextField.Root
defaultValue={1}
type="number"
ref={pageInput}
min={1}
max={Math.floor(searchedList.length / tanksPerPage) + 1}
style={{ textAlign: 'center' }}
onBlur={(event) => {
mutateTankopediaPersistent((draft) => {
draft.filters.page = Math.max(
0,
Math.min(
Math.floor(searchedList.length / tanksPerPage),
event.target.valueAsNumber - 1,
),
);
});
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
(event.target as HTMLInputElement).blur();
}
}}
>
<TextField.Slot>Page</TextField.Slot>
<TextField.Slot>
out of {Math.floor(searchedList.length / tanksPerPage) + 1}
</TextField.Slot>
</TextField.Root>
<IconButton
variant="soft"
disabled={Math.floor(searchedList.length / tanksPerPage) === page}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.filters.page = Math.min(
Math.floor(searchedList.length / tanksPerPage),
page + 1,
);
});
}}
>
<CaretRightIcon />
</IconButton>
</Flex>
);
}
@@ -1,13 +0,0 @@
import * as TankFilters from '../../../stores/tankFilters';
import * as TankopediaPersistent from '../../../stores/tankopediaPersistent';
import * as TankopediaSort from '../../../stores/tankopediaSort';
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<TankFilters.Provider>
<TankopediaPersistent.Provider>
<TankopediaSort.Provider>{children}</TankopediaSort.Provider>
</TankopediaPersistent.Provider>
</TankFilters.Provider>
);
}
@@ -1,10 +0,0 @@
import { PageWrapper } from '../../../components/PageWrapper';
import { TankSearch } from './components/TankSearch';
export default function Page() {
return (
<PageWrapper color="purple" size={1028}>
<TankSearch />
</PageWrapper>
);
}
File renamed without changes.
@@ -0,0 +1,8 @@
---
import { Page } from '.';
import RootLayout from '../../layouts/RootLayout.astro';
---
<RootLayout>
<Page client:load />
</RootLayout>
@@ -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>
);
}