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

+12
View File
@@ -0,0 +1,12 @@
# BlitzKit 1.29.0
## Features
- Added the Playlist tool
- Don't know what to play today? Let BlitzKit do the thinking!
- Navigate to [the tool on BlitzKit](https://blitzkit.app/playlist)
- Make sure you login with your Wargaming account to unlock all features like live tracking and filtering by owned tanks
- Use filters to pick tiers, nations, etc. then hit Generate playlist
- BlitzKit will generate a checklist for you to go through as you play
- Don't like the order of tanks? Hit Shuffle, Sort by Last played and Least played, or straight up remove tanks from the list by using the (-) button
- If you're logged in, BlitzKit will automatically check off tanks as you play them
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "blitzkit",
"version": "1.28.4",
"version": "1.29.0",
"license": "BlitzKit License",
"private": true,
"type": "module",
+8
View File
@@ -0,0 +1,8 @@
export function fisherYates<Type extends unknown[]>(array: Type) {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
return array;
}
+8 -7
View File
@@ -1,7 +1,8 @@
export * from './axis';
export * from './formatCompact';
export * from './isNumber';
export * from './NaNFallback';
export * from './normalizeAngleRad';
export * from './normalToEuler';
export * from './numberToRomanNumeral';
export * from "./axis";
export * from "./fisherYates";
export * from "./formatCompact";
export * from "./isNumber";
export * from "./NaNFallback";
export * from "./normalizeAngleRad";
export * from "./normalToEuler";
export * from "./numberToRomanNumeral";
+37
View File
@@ -859,6 +859,43 @@
},
"tools": {
"playlist": {
"reset": "Reset",
"shuffle": "Shuffle",
"remove": "Remove played",
"tracking": "BlitzKit is tracking your battles",
"sort": {
"button": "Sort by",
"last_played": "Last played",
"least_played": "Least played"
},
"table": {
"index": "#",
"never": "Never",
"tank": "Tank",
"last_played": "Last played",
"battles": "Career battles",
"unrecorded": "--",
"wins": "Wins"
},
"large_warning": {
"title": "That's a lot of tanks!",
"description": "Are you sure you want to create a playlist of {count} tanks? That's {time} hours of gameplay at 50% winrate!",
"cancel": "Cancel",
"proceed": "Proceed"
},
"generate": "Generate playlist",
"name": "Playlist",
"description": "Tank playlist generator",
"seo_description": "Don't know what to play in World of Tanks Blitz? Use BlitzKit Playlist to get suggestions!",
"button": "Generate",
"pool_size": "{count} possible tanks"
},
"mixer": {
"name": "Mixer",
"button": "Mix and match",
+32
View File
@@ -762,6 +762,38 @@
}
},
"tools": {
"playlist": {
"reset": "Restablecer",
"shuffle": "Shuffle",
"remove": "Remove played",
"tracking": "BlitzKit is tracking your battles",
"sort": {
"button": "Ordenar por",
"last_played": "Last played",
"least_played": "Least played"
},
"table": {
"index": "#",
"never": "Never",
"tank": "Tanque",
"last_played": "Last played",
"battles": "Career battles",
"unrecorded": "--",
"wins": "Victorias"
},
"large_warning": {
"title": "That's a lot of tanks!",
"description": "Are you sure you want to create a playlist of {count} tanks? That's {time} hours of gameplay at 50% winrate!",
"cancel": "Cancelar",
"proceed": "Proceed"
},
"generate": "Generate playlist",
"name": "Playlist",
"description": "Tank playlist generator",
"seo_description": "Don't know what to play in World of Tanks Blitz? Use BlitzKit Playlist to get suggestions!",
"button": "Generate",
"pool_size": "{count} possible tanks"
},
"mixer": {
"name": "Mixer",
"button": "Mix and match",
+32
View File
@@ -762,6 +762,38 @@
}
},
"tools": {
"playlist": {
"reset": "Reset",
"shuffle": "Shuffle",
"remove": "Remove played",
"tracking": "BlitzKit is tracking your battles",
"sort": {
"button": "Sort by",
"last_played": "Last played",
"least_played": "Least played"
},
"table": {
"index": "#",
"never": "Never",
"tank": "Tank",
"last_played": "Last played",
"battles": "Career battles",
"unrecorded": "--",
"wins": "Victoires"
},
"large_warning": {
"title": "That's a lot of tanks!",
"description": "Are you sure you want to create a playlist of {count} tanks? That's {time} hours of gameplay at 50% winrate!",
"cancel": "Cancel",
"proceed": "Proceed"
},
"generate": "Generate playlist",
"name": "Playlist",
"description": "Tank playlist generator",
"seo_description": "Don't know what to play in World of Tanks Blitz? Use BlitzKit Playlist to get suggestions!",
"button": "Generate",
"pool_size": "{count} possible tanks"
},
"mixer": {
"name": "Mixer",
"button": "Mix and match",
+32
View File
@@ -762,6 +762,38 @@
}
},
"tools": {
"playlist": {
"reset": "リセット",
"shuffle": "Shuffle",
"remove": "Remove played",
"tracking": "BlitzKit is tracking your battles",
"sort": {
"button": "並べ替え項目",
"last_played": "Last played",
"least_played": "Least played"
},
"table": {
"index": "#",
"never": "Never",
"tank": "車輌名",
"last_played": "Last played",
"battles": "Career battles",
"unrecorded": "--",
"wins": "勝利"
},
"large_warning": {
"title": "That's a lot of tanks!",
"description": "Are you sure you want to create a playlist of {count} tanks? That's {time} hours of gameplay at 50% winrate!",
"cancel": "Cancel",
"proceed": "Proceed"
},
"generate": "Generate playlist",
"name": "Playlist",
"description": "Tank playlist generator",
"seo_description": "Don't know what to play in World of Tanks Blitz? Use BlitzKit Playlist to get suggestions!",
"button": "Generate",
"pool_size": "{count} possible tanks"
},
"mixer": {
"name": "Mixer",
"button": "Mix and match",
+32
View File
@@ -762,6 +762,38 @@
}
},
"tools": {
"playlist": {
"reset": "Reset",
"shuffle": "Shuffle",
"remove": "Remove played",
"tracking": "BlitzKit is tracking your battles",
"sort": {
"button": "Sort by",
"last_played": "Last played",
"least_played": "Least played"
},
"table": {
"index": "#",
"never": "Never",
"tank": "Tank",
"last_played": "Last played",
"battles": "Career battles",
"unrecorded": "--",
"wins": "Zwycięstwa"
},
"large_warning": {
"title": "That's a lot of tanks!",
"description": "Are you sure you want to create a playlist of {count} tanks? That's {time} hours of gameplay at 50% winrate!",
"cancel": "Cancel",
"proceed": "Proceed"
},
"generate": "Generate playlist",
"name": "Playlist",
"description": "Tank playlist generator",
"seo_description": "Don't know what to play in World of Tanks Blitz? Use BlitzKit Playlist to get suggestions!",
"button": "Generate",
"pool_size": "{count} possible tanks"
},
"mixer": {
"name": "Mixer",
"button": "Mix and match",
+32
View File
@@ -762,6 +762,38 @@
}
},
"tools": {
"playlist": {
"reset": "Restaurar",
"shuffle": "Shuffle",
"remove": "Remove played",
"tracking": "BlitzKit is tracking your battles",
"sort": {
"button": "Ordenar por",
"last_played": "Last played",
"least_played": "Least played"
},
"table": {
"index": "#",
"never": "Never",
"tank": "Tanque",
"last_played": "Last played",
"battles": "Career battles",
"unrecorded": "--",
"wins": "Vitórias"
},
"large_warning": {
"title": "That's a lot of tanks!",
"description": "Are you sure you want to create a playlist of {count} tanks? That's {time} hours of gameplay at 50% winrate!",
"cancel": "Cancelar",
"proceed": "Proceed"
},
"generate": "Generate playlist",
"name": "Playlist",
"description": "Tank playlist generator",
"seo_description": "Don't know what to play in World of Tanks Blitz? Use BlitzKit Playlist to get suggestions!",
"button": "Generate",
"pool_size": "{count} possible tanks"
},
"mixer": {
"name": "Mixer",
"button": "Mix and match",
+32
View File
@@ -762,6 +762,38 @@
}
},
"tools": {
"playlist": {
"reset": "Сброс",
"shuffle": "Shuffle",
"remove": "Remove played",
"tracking": "BlitzKit is tracking your battles",
"sort": {
"button": "Сортировка",
"last_played": "Last played",
"least_played": "Least played"
},
"table": {
"index": "#",
"never": "Never",
"tank": "Танк",
"last_played": "Last played",
"battles": "Career battles",
"unrecorded": "--",
"wins": "Победы"
},
"large_warning": {
"title": "That's a lot of tanks!",
"description": "Are you sure you want to create a playlist of {count} tanks? That's {time} hours of gameplay at 50% winrate!",
"cancel": "Отмена",
"proceed": "Proceed"
},
"generate": "Generate playlist",
"name": "Playlist",
"description": "Tank playlist generator",
"seo_description": "Don't know what to play in World of Tanks Blitz? Use BlitzKit Playlist to get suggestions!",
"button": "Generate",
"pool_size": "{count} possible tanks"
},
"mixer": {
"name": "Смешать",
"button": "Смешать и собрать",
+32
View File
@@ -762,6 +762,38 @@
}
},
"tools": {
"playlist": {
"reset": "Reset",
"shuffle": "Shuffle",
"remove": "Remove played",
"tracking": "BlitzKit is tracking your battles",
"sort": {
"button": "Сортувати за",
"last_played": "Last played",
"least_played": "Least played"
},
"table": {
"index": "#",
"never": "Never",
"tank": "Танк",
"last_played": "Last played",
"battles": "Career battles",
"unrecorded": "--",
"wins": "Перемоги"
},
"large_warning": {
"title": "That's a lot of tanks!",
"description": "Are you sure you want to create a playlist of {count} tanks? That's {time} hours of gameplay at 50% winrate!",
"cancel": "Cancel",
"proceed": "Proceed"
},
"generate": "Generate playlist",
"name": "Playlist",
"description": "Tank playlist generator",
"seo_description": "Don't know what to play in World of Tanks Blitz? Use BlitzKit Playlist to get suggestions!",
"button": "Generate",
"pool_size": "{count} possible tanks"
},
"mixer": {
"name": "Mixer",
"button": "Mix and match",
+35 -3
View File
@@ -284,7 +284,7 @@
"name": "阶级",
"description": "阶级",
"choices": {
"tier": "Tier {tier_1} - {tier_2}"
"tier": "坦克等级 {tier_1} - {tier_2}"
}
}
},
@@ -296,7 +296,7 @@
},
"filters": {
"none": "无筛选器",
"tier": "Tier {tier}"
"tier": "坦克等级 {tier}"
},
"no_data": {
"battles_in_period": "在这段时间里没有参与任何战斗",
@@ -347,7 +347,7 @@
},
"errors": {
"non_tech_tree": "# {tank} is not on the tech tree\n\nYou cannot research a premium/collector tank.",
"tier_1": "# {tank} is a tier I\n\nTier I tanks are always unlocked for free for all players.",
"tier_1": "# {tank} 是一级坦克\n\n一级坦克一直是对所有玩家免费解锁的。",
"already_researched": "# {tank} is already researched\n\nTry providing a `starting-tank` when using the command to view the totals for a line you have already researched.",
"start_end_equal": "# {tank} was provided twice\n\nMake sure `starting-tank` and `target-tank` are different.",
"tanks_not_on_line": "# {tank_1} and {tank_2} are not on the same line\n\nMake sure `starting-tank` and `target-tank` are on the same tech-tree line.",
@@ -762,6 +762,38 @@
}
},
"tools": {
"playlist": {
"reset": "重置",
"shuffle": "Shuffle",
"remove": "Remove played",
"tracking": "BlitzKit is tracking your battles",
"sort": {
"button": "排序依据",
"last_played": "Last played",
"least_played": "Least played"
},
"table": {
"index": "#",
"never": "Never",
"tank": "战车",
"last_played": "Last played",
"battles": "Career battles",
"unrecorded": "--",
"wins": "胜利"
},
"large_warning": {
"title": "That's a lot of tanks!",
"description": "Are you sure you want to create a playlist of {count} tanks? That's {time} hours of gameplay at 50% winrate!",
"cancel": "取消",
"proceed": "Proceed"
},
"generate": "Generate playlist",
"name": "Playlist",
"description": "Tank playlist generator",
"seo_description": "Don't know what to play in World of Tanks Blitz? Use BlitzKit Playlist to get suggestions!",
"button": "Generate",
"pool_size": "{count} possible tanks"
},
"mixer": {
"name": "Mixer",
"button": "Mix and match",
Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

@@ -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"}
+7 -3
View File
@@ -35,14 +35,18 @@ export const tools: Record<string, Tool> = {
disabled: true,
button: "bronze",
},
guess: {
id: "guess",
button: "cyan",
playlist: {
id: "playlist",
button: "tomato",
},
mixer: {
id: "mixer",
button: "gray",
},
guess: {
id: "guess",
button: "cyan",
},
gallery: {
id: "gallery",
button: "gold",
@@ -0,0 +1,44 @@
import {
fisherYates,
getTankStats,
idToRegion,
type IndividualTankStats,
} from "@blitzkit/core";
import { App } from "../../stores/app";
import { Playlist } from "../../stores/playlist";
import { TankFilters } from "../../stores/tankFilters";
import { awaitableTankDefinitions } from "../awaitables/tankDefinitions";
import { filterTanks } from "./filterTanks";
const tankDefinitions = await awaitableTankDefinitions;
const tanks = Object.values(tankDefinitions.tanks);
export async function generatePlaylist() {
const id = App.state.logins.wargaming?.id;
const filtered = await filterTanks(TankFilters.state, tanks, id);
const statsList = id ? await getTankStats(idToRegion(id), id) : undefined;
const stats: Record<number, IndividualTankStats> = {};
if (statsList) {
for (const tankStat of statsList) {
stats[tankStat.tank_id] = tankStat;
}
}
fisherYates(filtered);
Playlist.mutate((draft) => {
draft.list = filtered.map((tank) => {
const tankStats = stats[tank.id];
const then = tankStats
? {
battles: tankStats.all.battles,
wins: tankStats.all.wins,
last: tankStats.last_battle_time,
}
: undefined;
return { id: tank.id, then, now: then };
});
});
}
Whitespace-only changes.
@@ -0,0 +1,32 @@
import {
getTankStats,
idToRegion,
type IndividualTankStats,
} from "@blitzkit/core";
import { App } from "../../stores/app";
import { Playlist } from "../../stores/playlist";
export async function updatePlaylist() {
const id = App.state.logins.wargaming?.id;
const statsList = id ? await getTankStats(idToRegion(id), id) : undefined;
const stats: Record<number, IndividualTankStats> = {};
if (statsList) {
for (const tankStat of statsList) {
stats[tankStat.tank_id] = tankStat;
}
}
Playlist.mutate((draft) => {
draft.list?.map((item) => {
const tankStats = stats[item.id];
item.now = tankStats
? {
battles: tankStats.all.battles,
wins: tankStats.all.wins,
last: tankStats.last_battle_time,
}
: undefined;
});
});
}
@@ -16,11 +16,11 @@ export interface Props {
noFooter?: boolean;
noNavbar?: boolean;
integrated?: boolean;
locale: string;
}
const servicing = import.meta.env.SERVICING;
const { noFooter, noNavbar, integrated } = Astro.props;
const locale = Astro.params.locale;
const { noFooter, noNavbar, integrated, locale } = Astro.props;
---
<html lang={locale}>
@@ -7,10 +7,12 @@ import BaseLayout from "./BaseLayout.astro";
interface Props {
tool: Tool;
locale: string;
noNavbar?: boolean;
noFooter?: boolean;
}
const { tool } = Astro.props;
const { locale } = Astro.params;
const { tool, locale, noNavbar, noFooter } = Astro.props;
const strings = getStrings(locale);
const toolStrings =
strings.website.tools[
@@ -20,7 +22,7 @@ const toolStrings =
<ToolOpenGraph {tool} />
<BaseLayout>
<BaseLayout {locale} {noNavbar} {noFooter}>
<Box
style={{
backgroundImage: `url(/assets/images/tools/${tool.id}.webp)`,
@@ -12,7 +12,7 @@ const { locale } = Astro.params;
<ToolOpenGraph tool={tools.performance} />
<BaseLayout noFooter>
<BaseLayout {locale} noFooter>
<Page client:only="react" {locale}>
<Page skeleton slot="fallback" {locale} />
</Page>
@@ -0,0 +1,32 @@
import { PageWrapper } from "../../../components/PageWrapper";
import { PlaylistGenerator } from "../../../components/PlaylistGenerator";
import { PlaylistOrder } from "../../../components/PlaylistOrder";
import { LocaleProvider } from "../../../hooks/useLocale";
import { App } from "../../../stores/app";
import { Playlist } from "../../../stores/playlist";
import { TankFilters } from "../../../stores/tankFilters";
import type { MaybeSkeletonComponentProps } from "../../../types/maybeSkeletonComponentProps";
interface Props {
locale: string;
}
export function Page({
locale,
skeleton,
}: Props & MaybeSkeletonComponentProps) {
const generated = Playlist.use((state) => state.list !== undefined);
if (App.state.logins.wargaming && TankFilters.state.ownership !== "owned") {
TankFilters.state.ownership = "owned";
}
return (
<LocaleProvider locale={locale}>
<PageWrapper color="tomato" direction="row" maxWidth="unset" p="0">
{!generated && <PlaylistGenerator skeleton={skeleton} />}
{generated && <PlaylistOrder />}
</PageWrapper>
</LocaleProvider>
);
}
@@ -0,0 +1,18 @@
---
import ToolOpenGraph from "../../../components/ToolOpenGraph.astro";
import { tools } from "../../../constants/tools";
import BaseLayout from "../../../layouts/BaseLayout.astro";
import { Page } from "./_index.tsx";
export { getStaticPaths } from "../index.astro";
const { locale } = Astro.params;
---
<ToolOpenGraph tool={tools.playlist} />
<BaseLayout {locale} noFooter>
<Page {locale} client:only="react">
<Page {locale} skeleton slot="fallback" />
</Page>
</BaseLayout>
+14
View File
@@ -0,0 +1,14 @@
import { Varuna } from "varuna";
export interface PlaylistEntry {
id: number;
then?: { battles: number; wins: number; last: number };
now?: { battles: number; wins: number; last: number };
checked?: boolean;
}
export interface Playlist {
list?: PlaylistEntry[];
}
export const Playlist = new Varuna<Playlist>({}, "playlist");
+3 -3
View File
@@ -2,7 +2,7 @@
"extends": ["../../tsconfig.json", "astro/tsconfigs/base"],
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "react"
},
"include": [".astro/types.d.ts"]
"jsxImportSource": "react",
"types": ["vite/client"]
}
}