Merge branch 'main' into fix-modules

This commit is contained in:
TrèsAbhi committed 2025-10-15 18:17:21 -05:00
commit 501a618d5f
36 files changed
+1051 -82

No files matched your search

@@ -0,0 +1,7 @@
import { Flex } from "@radix-ui/themes";
export function CurrentlyPlaying() {
return <Flex>
</Flex>;
}
@@ -4,11 +4,11 @@
const isAPI = window.location.pathname.startsWith("/api");
const isI18nPath = locales.supported.some(({ locale }) =>
window.location.pathname.startsWith(`/${locale}`)
window.location.pathname.startsWith(`/${locale}/`)
);
const bestLocale = getBestLocale();
const currentPath = locales.supported.find(({ locale }) =>
window.location.pathname.startsWith(`/${locale}`)
window.location.pathname.startsWith(`/${locale}/`)
);
if (
@@ -0,0 +1,106 @@
import { fisherYates } from "@blitzkit/core";
import { CaretDownIcon } from "@radix-ui/react-icons";
import { Button, DropdownMenu, Flex, Spinner } from "@radix-ui/themes";
import { useLocale } from "../hooks/useLocale";
import { App } from "../stores/app";
import { Playlist } from "../stores/playlist";
export function PlaylistActions() {
const { strings } = useLocale();
const wargaming = App.use((state) => state.logins.wargaming);
return (
<>
<Flex gap="2">
<Button
size={{ initial: "1", xs: "2" }}
variant="outline"
color="red"
onClick={() => {
Playlist.mutate((draft) => {
draft.list = undefined;
});
}}
>
{strings.website.tools.playlist.reset}
</Button>
<Button
size={{ initial: "1", xs: "2" }}
variant={wargaming ? "outline" : undefined}
onClick={() => {
Playlist.mutate((draft) => {
if (draft.list) fisherYates(draft.list);
});
}}
>
{strings.website.tools.playlist.shuffle}
</Button>
{wargaming && (
<>
<Button
size={{ initial: "1", xs: "2" }}
variant="outline"
onClick={() => {
Playlist.mutate((draft) => {
draft.list = draft.list?.filter(
(entry) =>
(!entry.now ||
!entry.then ||
entry.now.battles === entry.then.battles) &&
!entry.checked
);
});
}}
>
{strings.website.tools.playlist.remove}
</Button>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button size={{ initial: "1", xs: "2" }}>
{strings.website.tools.playlist.sort.button}
<CaretDownIcon />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item
onClick={() => {
Playlist.mutate((draft) => {
draft.list = draft.list?.sort(
(a, b) => (a.then?.last ?? 0) - (b.then?.last ?? 0)
);
});
}}
>
{strings.website.tools.playlist.sort.last_played}
</DropdownMenu.Item>
<DropdownMenu.Item
onClick={() => {
Playlist.mutate((draft) => {
draft.list = draft.list?.sort(
(a, b) =>
(a.then?.battles ?? 0) - (b.then?.battles ?? 0)
);
});
}}
>
{strings.website.tools.playlist.sort.least_played}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</>
)}
</Flex>
{wargaming && (
<Flex gap="2" align="center">
<Spinner />
{strings.website.tools.playlist.tracking}
</Flex>
)}
</>
);
}
@@ -0,0 +1,174 @@
import { fisherYates, tankIcon } from "@blitzkit/core";
import { literals } from "@blitzkit/i18n";
import { ChevronRightIcon } from "@radix-ui/react-icons";
import {
AlertDialog,
Box,
Button,
Flex,
Grid,
Skeleton,
Text,
} from "@radix-ui/themes";
import { useMemo, useState } from "react";
import usePromise from "react-promise-suspense";
import { awaitableTankDefinitions } from "../core/awaitables/tankDefinitions";
import { filterTanks } from "../core/blitzkit/filterTanks";
import { generatePlaylist } from "../core/blitzkit/generatePlaylist";
import { useLocale } from "../hooks/useLocale";
import { App } from "../stores/app";
import { TankFilters } from "../stores/tankFilters";
import type { MaybeSkeletonComponentProps } from "../types/maybeSkeletonComponentProps";
import { LinkI18n } from "./LinkI18n";
import { FilterControl } from "./TankSearch/components/FilterControl";
const tankDefinitions = await awaitableTankDefinitions;
const POOL_HEIGHT = "16rem";
export function PlaylistGenerator({ skeleton }: MaybeSkeletonComponentProps) {
const [largeWarningOpen, setLargeWarningOpen] = useState(false);
const { strings, locale } = useLocale();
const wargaming = App.use((state) => state.logins.wargaming);
const filters = TankFilters.use();
const randomTankList = useMemo(
() => fisherYates(Object.values(tankDefinitions.tanks)),
[filters]
);
const tanks = usePromise(
() => filterTanks(filters, randomTankList, wargaming?.id),
// react-promise-suspense has awful type annotations
[filters] as any
);
const tanksTrimmed = useMemo(() => tanks.slice(0, 65), [filters]);
return (
<>
<AlertDialog.Root
open={largeWarningOpen}
onOpenChange={setLargeWarningOpen}
>
<AlertDialog.Content>
<AlertDialog.Title>
{strings.website.tools.playlist.large_warning.title}
</AlertDialog.Title>
<AlertDialog.Description>
{literals(
strings.website.tools.playlist.large_warning.description,
{
count: tanks.length,
time: Math.round(tanks.length * ((2 * 4) / 60)),
}
)}
</AlertDialog.Description>
<Flex justify="end" gap="2" mt="4">
<AlertDialog.Cancel>
<Button variant="outline">
{strings.website.tools.playlist.large_warning.cancel}
</Button>
</AlertDialog.Cancel>
<AlertDialog.Action onClick={generatePlaylist}>
<Button color="red">
{strings.website.tools.playlist.large_warning.proceed}
</Button>
</AlertDialog.Action>
</Flex>
</AlertDialog.Content>
</AlertDialog.Root>
<Flex justify="center" width="100%" align="center" px="4">
<Flex
flexGrow="1"
gap="9"
align="center"
justify="center"
maxWidth="80rem"
direction={{ initial: "column-reverse", md: "row" }}
>
<Flex direction="column" gap="4" align="center">
<FilterControl />
<Button
onClick={() => {
if (tanks.length >= 32) {
setLargeWarningOpen(true);
return;
}
generatePlaylist();
}}
size="3"
>
{strings.website.tools.playlist.generate} <ChevronRightIcon />
</Button>
</Flex>
<Flex
direction="column"
flexGrow="1"
flexBasis="0"
align="center"
gap="4"
width="100%"
>
<Grid
columns="repeat(auto-fill, minmax(4rem, 1fr))"
flow="dense"
overflow="hidden"
position="relative"
maxHeight={POOL_HEIGHT}
width="100%"
>
{tanksTrimmed.map((tank) =>
skeleton ? (
<Box
key={tank.id}
width="100%"
height="100%"
style={{ aspectRatio: "4 / 3" }}
p="1"
>
<Skeleton width="100%" height="100%" />
</Box>
) : (
<LinkI18n
key={tank.id}
target="_blank"
locale={locale}
href={`/tanks/${tank.slug}`}
style={{
aspectRatio: "4 / 3",
backgroundSize: "cover",
backgroundPosition: "center",
backgroundImage: `url(${tankIcon(tank.id)})`,
backgroundRepeat: "no-repeat",
}}
/>
)
)}
<Box
position="absolute"
top="0"
left="0"
width="100%"
height="100%"
style={{
pointerEvents: "none",
background: `linear-gradient(transparent, var(--gray-1) ${POOL_HEIGHT})`,
}}
/>
</Grid>
<Text color="gray">
{literals(strings.website.tools.playlist.pool_size, {
count: tanks.length,
})}
</Text>
</Flex>
</Flex>
</Flex>
</>
);
}
@@ -0,0 +1,26 @@
import { Box, Flex } from "@radix-ui/themes";
import { CurrentlyPlaying } from "./CurrentlyPlaying";
import { PlaylistActions } from "./PlaylistActions";
import { PlaylistTable } from "./PlaylistTable";
import { PlaylistTracking } from "./PlaylistTracking";
export function PlaylistOrder() {
return (
<Flex
direction="column"
gap="4"
align="center"
flexGrow="1"
flexBasis="0"
maxHeight="100%"
>
<CurrentlyPlaying />
<PlaylistActions />
<PlaylistTracking />
<Box flexGrow="1" width="100%" position="relative">
<PlaylistTable />
</Box>
</Flex>
);
}
@@ -0,0 +1,64 @@
import { Table, Text } from "@radix-ui/themes";
import { useLocale } from "../hooks/useLocale";
import { Playlist } from "../stores/playlist";
import { PlaylistTankEntry } from "./PlaylistTankEntry";
import { StickyColumnHeaderCell } from "./StickyColumnHeaderCell";
import { StickyTableRoot } from "./StickyTableRoot";
export function PlaylistTable() {
const { strings } = useLocale();
const list = Playlist.use((state) => state.list);
if (!list) return null;
return (
<StickyTableRoot
style={{
position: "absolute",
top: 0,
left: 0,
height: "100%",
width: "100%",
borderRadius: 0,
whiteSpace: "nowrap",
}}
variant="surface"
>
<Table.Header>
<Table.Row>
<StickyColumnHeaderCell width="0">
<Text color="gray">
{strings.website.tools.playlist.table.index}
</Text>
</StickyColumnHeaderCell>
<StickyColumnHeaderCell width="0" />
<StickyColumnHeaderCell width="0" />
<StickyColumnHeaderCell width="1">
{strings.website.tools.playlist.table.tank}
</StickyColumnHeaderCell>
<StickyColumnHeaderCell width="1">
{strings.website.tools.playlist.table.wins}
</StickyColumnHeaderCell>
<StickyColumnHeaderCell width="1">
{strings.website.tools.playlist.table.battles}
</StickyColumnHeaderCell>
<StickyColumnHeaderCell width="1">
{strings.website.tools.playlist.table.last_played}
</StickyColumnHeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{list.map((props, index) => (
<PlaylistTankEntry key={props.id} index={index} {...props} />
))}
</Table.Body>
</StickyTableRoot>
);
}
@@ -0,0 +1,107 @@
import {
MinusCircledIcon,
TriangleDownIcon,
TriangleUpIcon,
} from "@radix-ui/react-icons";
import { Checkbox, Flex, IconButton, Table, Text } from "@radix-ui/themes";
import { memo } from "react";
import { awaitableTankDefinitions } from "../core/awaitables/tankDefinitions";
import { useLocale } from "../hooks/useLocale";
import { Playlist, type PlaylistEntry } from "../stores/playlist";
import { TankRowHeaderCell } from "./TankRowHeaderCell";
interface Props extends PlaylistEntry {
index: number;
}
const tankDefinitions = await awaitableTankDefinitions;
export const PlaylistTankEntry = memo<Props>(
({ id, checked, then, now, index }) => {
const { locale, strings } = useLocale();
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
const dateNow = Date.now();
const tank = tankDefinitions.tanks[id];
return (
<Table.Row>
<Table.Cell>{index + 1}</Table.Cell>
<Table.Cell
onClick={() => {
Playlist.mutate((draft) => {
draft.list![index].checked = !checked;
});
}}
>
<Checkbox
size="3"
variant="classic"
checked={
(checked === undefined &&
now &&
then &&
now.battles > then.battles) ||
checked
}
/>
</Table.Cell>
<Table.Cell>
<IconButton
variant="ghost"
size="1"
onClick={() => {
Playlist.mutate((draft) => {
draft.list!.splice(index, 1);
});
}}
>
<MinusCircledIcon />
</IconButton>
</Table.Cell>
<TankRowHeaderCell tank={tank} />
<Table.Cell>
{now && then && now.battles !== then.battles ? (
<Flex gap="2">
<Text color="green">
<Flex align="center">
<TriangleUpIcon />
{now.wins - then.wins}
</Flex>
</Text>
<Text color="tomato">
<Flex align="center">
<TriangleDownIcon />
{now.battles - then.battles - now.wins + then.wins}
</Flex>
</Text>
</Flex>
) : (
strings.website.tools.playlist.table.unrecorded
)}
</Table.Cell>
<Table.Cell>
{now?.battles ?? strings.website.tools.playlist.table.unrecorded}
</Table.Cell>
<Table.Cell>
{now && now.last !== 0
? rtf.format(
Math.ceil((now.last - dateNow / 1000) / (60 * 60 * 24)),
"day"
)
: strings.website.tools.playlist.table.unrecorded}
</Table.Cell>
</Table.Row>
);
},
(a, b) =>
a.checked === b.checked &&
a.then?.battles === b.then?.battles &&
a.index === b.index
);
@@ -0,0 +1,14 @@
import { useEffect } from "react";
import { updatePlaylist } from "../core/blitzkit/updatePlaylist";
export function PlaylistTracking() {
useEffect(() => {
const interval = setInterval(updatePlaylist, 5000);
return () => {
clearInterval(interval);
};
}, []);
return null;
}
@@ -1,15 +1,13 @@
import { Table } from '@radix-ui/themes';
import type { RowHeaderCellProps } from '@radix-ui/themes/src/components/table.js';
import { Table } from "@radix-ui/themes";
import type { RowHeaderCellProps } from "@radix-ui/themes/src/components/table.js";
export function StickyRowHeaderCell({ style, ...props }: RowHeaderCellProps) {
return (
<Table.RowHeaderCell
style={{
display: 'flex',
position: 'sticky',
position: "sticky",
left: 0,
backgroundColor: 'var(--color-panel)',
boxShadow: 'var(--shadow-6)',
backgroundColor: "var(--color-panel)",
...style,
}}
{...props}
@@ -1,9 +1,10 @@
import { type TankDefinition, tankIcon, TankType } from '@blitzkit/core';
import { useLocale } from '../hooks/useLocale';
import { classIcons } from './ClassIcon';
import { ExperimentIcon } from './ExperimentIcon';
import { LinkI18n } from './LinkI18n';
import { StickyRowHeaderCell } from './StickyRowHeaderCell';
import { type TankDefinition, tankIcon, TankType } from "@blitzkit/core";
import { Box, Flex } from "@radix-ui/themes";
import { useLocale } from "../hooks/useLocale";
import { classIcons } from "./ClassIcon";
import { ExperimentIcon } from "./ExperimentIcon";
import { LinkI18n } from "./LinkI18n";
import { StickyRowHeaderCell } from "./StickyRowHeaderCell";
interface TankRowHeaderCellProps {
tank: TankDefinition;
@@ -14,59 +15,48 @@ export function TankRowHeaderCell({ tank }: TankRowHeaderCellProps) {
const Icon = classIcons[tank.class];
return (
<StickyRowHeaderCell
width={{ initial: '144px', sm: '240px' }}
style={{ overflow: 'hidden' }}
>
<LinkI18n locale={locale} href={`/tanks/${tank.slug}`} tabIndex={-1}>
<img
alt={unwrap(tank.name)}
draggable={false}
src={tankIcon(tank.id)}
style={{
margin: 'calc(-1 * var(--table-cell-padding)) 0',
height: 'calc(100% + 2 * var(--table-cell-padding))',
aspectRatio: '4 / 3',
objectFit: 'cover',
}}
/>
</LinkI18n>
<StickyRowHeaderCell style={{ overflow: "hidden" }}>
<LinkI18n
locale={locale}
color={
tank.type === TankType.COLLECTOR
? 'blue'
? "blue"
: tank.type === TankType.PREMIUM
? 'amber'
: 'gray'
? "amber"
: "gray"
}
highContrast={tank.type === TankType.RESEARCHABLE}
underline="hover"
wrap="nowrap"
href={`/tanks/${tank.slug}`}
style={{
paddingLeft: 'var(--space-2)',
display: 'flex',
alignItems: 'center',
gap: 'var(--space-1)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
size={{ initial: '1', sm: '2' }}
style={{ height: "100%" }}
size={{ initial: "1", sm: "2" }}
>
<Icon
width="1em"
height="1em"
style={{ minWidth: '1em', minHeight: '1em' }}
/>
<Flex height="100%" align="center" gap="1">
<Box
draggable={false}
height="calc(100% + 2 * var(--table-cell-padding))"
style={{
aspectRatio: "4 / 3",
backgroundImage: `url(${tankIcon(tank.id)})`,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
}}
/>
{tank.testing && (
<ExperimentIcon style={{ width: '1em', height: '1em' }} />
)}
<Icon
width="1em"
height="1em"
style={{ minWidth: "1em", minHeight: "1em" }}
/>
{unwrap(tank.name)}
{tank.testing && (
<ExperimentIcon style={{ width: "1em", height: "1em" }} />
)}
{unwrap(tank.name)}
</Flex>
</LinkI18n>
</StickyRowHeaderCell>
);
@@ -19,6 +19,7 @@ import {
Popover,
Text,
Tooltip,
type ButtonProps,
type FlexProps,
} from "@radix-ui/themes";
import { isEqual, times } from "lodash-es";
@@ -37,10 +38,6 @@ import { ResearchedIcon } from "../../ResearchedIcon";
import { ScienceIcon } from "../../ScienceIcon";
import { ScienceOffIcon } from "../../ScienceOffIcon";
interface FilterControlProps {
compact?: boolean;
}
const gameDefinitions = await awaitableGameDefinitions;
const shellTypeIcons: Record<ShellType, string> = {
@@ -50,13 +47,21 @@ const shellTypeIcons: Record<ShellType, string> = {
[ShellType.HEAT]: "hc",
};
const size: ButtonProps["size"] = { initial: "1", xs: "2" };
function ShellFilter({ index, premium }: { index: number; premium?: boolean }) {
const shells = TankFilters.use((state) => state.shells);
return (
<Popover.Root>
<Popover.Trigger>
<IconButton variant="soft" radius="none" color="gray" highContrast>
<IconButton
size={size}
variant="soft"
radius="none"
color="gray"
highContrast
>
{shells[index] === null && (
<Text color="gray" style={{ display: "contents" }}>
<MissingShellIcon width="1em" height="1em" />
@@ -83,6 +88,7 @@ function ShellFilter({ index, premium }: { index: number; premium?: boolean }) {
return (
<IconButton
size={size}
key={shellType}
value={`${shellType}`}
radius="none"
@@ -127,6 +133,7 @@ function OwnershipFilter(props: FlexProps) {
{...props}
>
<IconButton
size={size}
disabled={!wargaming}
variant={tankFilters.ownership === "all" ? "solid" : "soft"}
radius="none"
@@ -142,6 +149,7 @@ function OwnershipFilter(props: FlexProps) {
</IconButton>
<IconButton
size={size}
disabled={!wargaming}
variant={tankFilters.ownership === "owned" ? "solid" : "soft"}
radius="none"
@@ -157,6 +165,7 @@ function OwnershipFilter(props: FlexProps) {
</IconButton>
<IconButton
size={size}
disabled={!wargaming}
variant={tankFilters.ownership === "unowned" ? "solid" : "soft"}
radius="none"
@@ -195,6 +204,7 @@ export function FilterControl() {
return (
<IconButton
size={size}
key={tier}
variant={selected ? "solid" : "soft"}
radius="none"
@@ -233,6 +243,7 @@ export function FilterControl() {
return (
<IconButton
size={size}
key={tier}
variant={selected ? "solid" : "soft"}
radius="none"
@@ -270,6 +281,7 @@ export function FilterControl() {
return (
<IconButton
size={size}
key={nation}
variant={selected ? "solid" : "soft"}
color={selected ? undefined : "gray"}
@@ -312,6 +324,7 @@ export function FilterControl() {
return (
<IconButton
size={size}
key={nation}
style={{ flex: 1 }}
variant={selected ? "solid" : "soft"}
@@ -351,6 +364,7 @@ export function FilterControl() {
return (
<IconButton
size={size}
key={tankClass}
variant={selected ? "solid" : "soft"}
radius="none"
@@ -380,6 +394,7 @@ export function FilterControl() {
direction="column"
>
<IconButton
size={size}
variant={tankFilters.gunType.includes("regular") ? "solid" : "soft"}
radius="none"
color={tankFilters.gunType.includes("regular") ? undefined : "gray"}
@@ -397,6 +412,7 @@ export function FilterControl() {
<GunRegularIcon style={{ width: "1em", height: "1em" }} />
</IconButton>
<IconButton
size={size}
variant={
tankFilters.gunType.includes("auto_loader") ? "solid" : "soft"
}
@@ -420,6 +436,7 @@ export function FilterControl() {
<GunAutoloaderIcon style={{ width: "1em", height: "1em" }} />
</IconButton>
<IconButton
size={size}
variant={
tankFilters.gunType.includes("auto_reloader") ? "solid" : "soft"
}
@@ -460,6 +477,7 @@ export function FilterControl() {
direction="column"
>
<IconButton
size={size}
variant={
tankFilters.types?.includes(TankType.RESEARCHABLE)
? "solid"
@@ -487,6 +505,7 @@ export function FilterControl() {
<ResearchedIcon style={{ width: "1em", height: "1em" }} />
</IconButton>
<IconButton
size={size}
variant={
tankFilters.types?.includes(TankType.PREMIUM) ? "solid" : "soft"
}
@@ -519,6 +538,7 @@ export function FilterControl() {
</Text>
</IconButton>
<IconButton
size={size}
variant={
tankFilters.types?.includes(TankType.COLLECTOR) ? "solid" : "soft"
}
@@ -558,6 +578,7 @@ export function FilterControl() {
direction="column"
>
<IconButton
size={size}
variant={tankFilters.testing === "include" ? "solid" : "soft"}
radius="none"
color={tankFilters.testing === "include" ? undefined : "gray"}
@@ -571,6 +592,7 @@ export function FilterControl() {
<ComponentBooleanIcon />
</IconButton>
<IconButton
size={size}
variant={tankFilters.testing === "exclude" ? "solid" : "soft"}
radius="none"
color={tankFilters.testing === "exclude" ? undefined : "gray"}
@@ -586,6 +608,7 @@ export function FilterControl() {
/>
</IconButton>
<IconButton
size={size}
variant={tankFilters.testing === "only" ? "solid" : "soft"}
radius="none"
color={tankFilters.testing === "only" ? undefined : "gray"}