mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-10-21 02:37:05 +02:00
feat: migrate settins
This commit is contained in:
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];
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
-38
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
-26
@@ -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',
|
||||
},
|
||||
});
|
||||
-36
@@ -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>;
|
||||
}
|
||||
-634
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
-10
@@ -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>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user