mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-10-21 10:47:04 +02:00
feat: tracking
This commit is contained in:
1 parent
263f292a59
commit
ea048da7a2
11 files changed
+272
-106
No files matched your search
@@ -862,14 +862,22 @@
|
||||
"playlist": {
|
||||
"reset": "Reset",
|
||||
"shuffle": "Shuffle",
|
||||
"sort": "Sort by",
|
||||
"remove": "Remove played",
|
||||
|
||||
"sort": {
|
||||
"button": "Sort by",
|
||||
"last_played": "Last played",
|
||||
"least_played": "Least played"
|
||||
},
|
||||
|
||||
"table": {
|
||||
"index": "#",
|
||||
"never": "Never",
|
||||
"tank": "Tank",
|
||||
"last_played": "Last played",
|
||||
"battles": "Battles"
|
||||
"battles": "Career battles",
|
||||
"unrecorded": "--",
|
||||
"wins": "Wins"
|
||||
},
|
||||
"no_login_warning": "You are not logged in. BlitzKit will not keep track of your played tanks.",
|
||||
"generate": "Generate playlist",
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { fisherYates } from "@blitzkit/core";
|
||||
import { CaretDownIcon } from "@radix-ui/react-icons";
|
||||
import { Button, Flex } from "@radix-ui/themes";
|
||||
import { Button, DropdownMenu, Flex } 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">
|
||||
@@ -22,7 +24,7 @@ export function PlaylistActions() {
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
variant={wargaming ? "outline" : undefined}
|
||||
onClick={() => {
|
||||
Playlist.mutate((draft) => {
|
||||
if (draft.list) fisherYates(draft.list);
|
||||
@@ -32,10 +34,59 @@ export function PlaylistActions() {
|
||||
{strings.website.tools.playlist.shuffle}
|
||||
</Button>
|
||||
|
||||
<Button>
|
||||
{strings.website.tools.playlist.sort}
|
||||
<CaretDownIcon />
|
||||
</Button>
|
||||
{wargaming && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
Playlist.mutate((draft) => {
|
||||
draft.list = draft.list?.filter(
|
||||
(entry) =>
|
||||
!entry.now ||
|
||||
!entry.then ||
|
||||
entry.now.battles === entry.then.battles
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{strings.website.tools.playlist.remove}
|
||||
</Button>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Button>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -33,73 +33,81 @@ export function PlaylistGenerator() {
|
||||
const tanksTrimmed = useMemo(() => tanks.slice(0, 65), [filters]);
|
||||
|
||||
return (
|
||||
<Flex justify="center" width="100%" gap="9" align="center">
|
||||
<Flex direction="column" gap="4" align="center">
|
||||
<FilterControl />
|
||||
|
||||
<Button onClick={generatePlaylist} size="3">
|
||||
{strings.website.tools.playlist.generate}
|
||||
</Button>
|
||||
|
||||
{!wargaming && (
|
||||
<Text color="gray">
|
||||
<Flex align="center" gap="1">
|
||||
<ExclamationTriangleIcon />
|
||||
{strings.website.tools.playlist.no_login_warning}
|
||||
</Flex>
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
<Flex justify="center" width="100%" align="center">
|
||||
<Flex
|
||||
direction="column"
|
||||
flexGrow="1"
|
||||
flexBasis="0"
|
||||
gap="9"
|
||||
align="center"
|
||||
gap="4"
|
||||
justify="center"
|
||||
maxWidth="80rem"
|
||||
>
|
||||
<Grid
|
||||
columns="repeat(auto-fill, minmax(4rem, 1fr))"
|
||||
flow="dense"
|
||||
overflow="hidden"
|
||||
position="relative"
|
||||
maxHeight={POOL_HEIGHT}
|
||||
width="100%"
|
||||
<Flex direction="column" gap="4" align="center">
|
||||
<FilterControl />
|
||||
|
||||
<Button onClick={generatePlaylist} size="3">
|
||||
{strings.website.tools.playlist.generate}
|
||||
</Button>
|
||||
|
||||
{!wargaming && (
|
||||
<Text color="gray">
|
||||
<Flex align="center" gap="1">
|
||||
<ExclamationTriangleIcon />
|
||||
{strings.website.tools.playlist.no_login_warning}
|
||||
</Flex>
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
<Flex
|
||||
direction="column"
|
||||
flexGrow="1"
|
||||
flexBasis="0"
|
||||
align="center"
|
||||
gap="4"
|
||||
>
|
||||
{tanksTrimmed.map((tank) => (
|
||||
<LinkI18n
|
||||
target="_blank"
|
||||
locale={locale}
|
||||
href={`/tanks/${tank.slug}`}
|
||||
key={tank.id}
|
||||
<Grid
|
||||
columns="repeat(auto-fill, minmax(4rem, 1fr))"
|
||||
flow="dense"
|
||||
overflow="hidden"
|
||||
position="relative"
|
||||
maxHeight={POOL_HEIGHT}
|
||||
width="100%"
|
||||
>
|
||||
{tanksTrimmed.map((tank) => (
|
||||
<LinkI18n
|
||||
target="_blank"
|
||||
locale={locale}
|
||||
href={`/tanks/${tank.slug}`}
|
||||
key={tank.id}
|
||||
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={{
|
||||
aspectRatio: "4 / 3",
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
backgroundImage: `url(${tankIcon(tank.id)})`,
|
||||
backgroundRepeat: "no-repeat",
|
||||
pointerEvents: "none",
|
||||
background: `linear-gradient(transparent, var(--gray-1) ${POOL_HEIGHT})`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
<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>
|
||||
<Text color="gray">
|
||||
{literals(strings.website.tools.playlist.pool_size, {
|
||||
count: tanks.length,
|
||||
})}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 (
|
||||
@@ -15,6 +16,7 @@ export function PlaylistOrder() {
|
||||
>
|
||||
<CurrentlyPlaying />
|
||||
<PlaylistActions />
|
||||
<PlaylistTracking />
|
||||
|
||||
<Box flexGrow="1" width="100%" position="relative">
|
||||
<PlaylistTable />
|
||||
|
||||
@@ -31,27 +31,31 @@ export function PlaylistTable() {
|
||||
</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.last_played}
|
||||
{strings.website.tools.playlist.table.wins}
|
||||
</StickyColumnHeaderCell>
|
||||
|
||||
<StickyColumnHeaderCell width="1">
|
||||
{strings.website.tools.playlist.table.battles}
|
||||
</StickyColumnHeaderCell>
|
||||
|
||||
<StickyColumnHeaderCell width="0" />
|
||||
|
||||
<StickyColumnHeaderCell width="0" />
|
||||
<StickyColumnHeaderCell width="1">
|
||||
{strings.website.tools.playlist.table.last_played}
|
||||
</StickyColumnHeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body>
|
||||
{list.map((props, index) => (
|
||||
<PlaylistTankEntry key={props.tank.id} index={index} {...props} />
|
||||
<PlaylistTankEntry key={props.id} index={index} {...props} />
|
||||
))}
|
||||
</Table.Body>
|
||||
</StickyTableRoot>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { MinusCircledIcon } from "@radix-ui/react-icons";
|
||||
import { Checkbox, IconButton, Table } from "@radix-ui/themes";
|
||||
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";
|
||||
@@ -9,27 +14,19 @@ interface Props extends PlaylistEntry {
|
||||
index: number;
|
||||
}
|
||||
|
||||
const tankDefinitions = await awaitableTankDefinitions;
|
||||
|
||||
export const PlaylistTankEntry = memo<Props>(
|
||||
({ tank, checked, last, battles, index }) => {
|
||||
({ id, checked, then, now, index }) => {
|
||||
const { locale, strings } = useLocale();
|
||||
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
|
||||
const nowDays = Date.now() / (1000 * 60 * 60 * 24);
|
||||
const lastDays = last ? last / (60 * 60 * 24) : undefined;
|
||||
const dateNow = Date.now();
|
||||
const tank = tankDefinitions.tanks[id];
|
||||
|
||||
return (
|
||||
<Table.Row>
|
||||
<Table.Cell>{index + 1}</Table.Cell>
|
||||
|
||||
<TankRowHeaderCell tank={tank} />
|
||||
|
||||
<Table.Cell>
|
||||
{lastDays
|
||||
? rtf.format(Math.floor(lastDays - nowDays), "day")
|
||||
: strings.website.tools.playlist.table.never}
|
||||
</Table.Cell>
|
||||
|
||||
<Table.Cell>{battles}</Table.Cell>
|
||||
|
||||
<Table.Cell
|
||||
onClick={() => {
|
||||
Playlist.mutate((draft) => {
|
||||
@@ -37,7 +34,16 @@ export const PlaylistTankEntry = memo<Props>(
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Checkbox variant="classic" checked={checked} />
|
||||
<Checkbox
|
||||
variant="classic"
|
||||
checked={
|
||||
(checked === undefined &&
|
||||
now &&
|
||||
then &&
|
||||
now.battles > then.battles) ||
|
||||
checked
|
||||
}
|
||||
/>
|
||||
</Table.Cell>
|
||||
|
||||
<Table.Cell>
|
||||
@@ -53,13 +59,48 @@ export const PlaylistTankEntry = memo<Props>(
|
||||
<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.battles === b.battles &&
|
||||
a.last === b.last &&
|
||||
a.wins === b.wins &&
|
||||
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;
|
||||
}
|
||||
@@ -28,12 +28,17 @@ export async function generatePlaylist() {
|
||||
fisherYates(filtered);
|
||||
|
||||
Playlist.mutate((draft) => {
|
||||
draft.list = filtered.map((tank) => ({
|
||||
tank,
|
||||
battles: stats[tank.id]?.all.battles,
|
||||
wins: stats[tank.id]?.all.wins,
|
||||
last: stats[tank.id]?.last_battle_time,
|
||||
checked: false,
|
||||
}));
|
||||
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 };
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
import ToolOpenGraph from "../../../components/ToolOpenGraph.astro";
|
||||
import { tools } from "../../../constants/tools";
|
||||
import ToolLayout from "../../../layouts/ToolLayout.astro";
|
||||
import BaseLayout from "../../../layouts/BaseLayout.astro";
|
||||
import { Page } from "./_index.tsx";
|
||||
|
||||
export { getStaticPaths } from "../index.astro";
|
||||
@@ -8,8 +9,10 @@ export { getStaticPaths } from "../index.astro";
|
||||
const { locale } = Astro.params;
|
||||
---
|
||||
|
||||
<ToolLayout {locale} tool={tools.playlist} noFooter>
|
||||
<ToolOpenGraph tool={tools.playlist} />
|
||||
|
||||
<BaseLayout {locale} noFooter>
|
||||
<Page {locale} client:only="react">
|
||||
<Page {locale} skeleton slot="fallback" />
|
||||
</Page>
|
||||
</ToolLayout>
|
||||
</BaseLayout>
|
||||
@@ -1,12 +1,10 @@
|
||||
import type { TankDefinition } from "@blitzkit/core";
|
||||
import { Varuna } from "varuna";
|
||||
|
||||
export interface PlaylistEntry {
|
||||
tank: TankDefinition;
|
||||
battles?: number;
|
||||
wins?: number;
|
||||
checked: boolean;
|
||||
last?: number;
|
||||
id: number;
|
||||
then?: { battles: number; wins: number; last: number };
|
||||
now?: { battles: number; wins: number; last: number };
|
||||
checked?: boolean;
|
||||
}
|
||||
|
||||
export interface Playlist {
|
||||
|
||||
Reference in new issue
Block a user