feat: spotify like table

This commit is contained in:
TrèsAbhi committed 2025-10-14 12:11:31 -05:00
1 parent 37adc04cda
commit e1a6e4e38d
12 files changed
+146 -101

No files matched your search

+4 -1
View File
@@ -862,7 +862,10 @@
"playlist": { "playlist": {
"clear": "Clear", "clear": "Clear",
"table": { "table": {
"tank": "Tank" "index": "#",
"never": "Never",
"tank": "Tank",
"last_played": "Last played"
}, },
"no_login_warning": "You are not logged in. BlitzKit will not keep track of your played tanks.", "no_login_warning": "You are not logged in. BlitzKit will not keep track of your played tanks.",
"generate": "Generate playlist", "generate": "Generate playlist",
@@ -0,0 +1,7 @@
import { Flex } from "@radix-ui/themes";
export function CurrentlyPlaying() {
return <Flex>
</Flex>;
}
@@ -1,16 +1,23 @@
import { Button, Flex, Table } from "@radix-ui/themes"; import { Box, Button, Flex } from "@radix-ui/themes";
import { useLocale } from "../hooks/useLocale"; import { useLocale } from "../hooks/useLocale";
import { Playlist } from "../stores/playlist"; import { Playlist } from "../stores/playlist";
import { PlaylistTankEntry } from "./PlaylistTankEntry"; import { CurrentlyPlaying } from "./CurrentlyPlaying";
import { PlaylistTable } from "./PlaylistTable";
export function PlaylistOrder() { export function PlaylistOrder() {
const { strings } = useLocale(); const { strings } = useLocale();
const list = Playlist.use((state) => state.list);
if (!list) return null;
return ( return (
<Flex direction="column" gap="4" align="center" flexGrow="1" flexBasis="0"> <Flex
direction="column"
gap="4"
align="center"
flexGrow="1"
flexBasis="0"
maxHeight="100%"
>
<CurrentlyPlaying />
<Button <Button
onClick={() => { onClick={() => {
Playlist.mutate((draft) => { Playlist.mutate((draft) => {
@@ -21,28 +28,9 @@ export function PlaylistOrder() {
{strings.website.tools.playlist.clear} {strings.website.tools.playlist.clear}
</Button> </Button>
<Table.Root variant="surface"> <Box flexGrow="1" width="100%" position="relative">
<Table.Header> <PlaylistTable />
<Table.Row> </Box>
<Table.ColumnHeaderCell />
<Table.ColumnHeaderCell>
{strings.website.tools.playlist.table.tank}
</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell />
</Table.Row>
</Table.Header>
<Table.Body>
{list.map(({ tank, checked }, index) => (
<PlaylistTankEntry
key={tank.id}
tank={tank}
checked={checked}
index={index}
/>
))}
</Table.Body>
</Table.Root>
</Flex> </Flex>
); );
} }
@@ -0,0 +1,55 @@
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,
}}
variant="surface"
>
<Table.Header>
<Table.Row>
<StickyColumnHeaderCell width="0">
<Text color="gray">
{strings.website.tools.playlist.table.index}
</Text>
</StickyColumnHeaderCell>
<StickyColumnHeaderCell width="1">
{strings.website.tools.playlist.table.tank}
</StickyColumnHeaderCell>
<StickyColumnHeaderCell width="1">
{strings.website.tools.playlist.table.last_played}
</StickyColumnHeaderCell>
<StickyColumnHeaderCell width="0" />
<StickyColumnHeaderCell width="0" />
</Table.Row>
</Table.Header>
<Table.Body>
{list.map((props, index) => (
<PlaylistTankEntry key={props.tank.id} index={index} {...props} />
))}
</Table.Body>
</StickyTableRoot>
);
}
@@ -1,18 +1,31 @@
import type { TankDefinition } from "@blitzkit/core"; import { MinusCircledIcon } from "@radix-ui/react-icons";
import { DragHandleDots2Icon, MinusCircledIcon } from "@radix-ui/react-icons"; import { Checkbox, IconButton, Table } from "@radix-ui/themes";
import { Checkbox, Flex, IconButton, Table, Text } from "@radix-ui/themes"; import { useLocale } from "../hooks/useLocale";
import { Playlist } from "../stores/playlist"; import { Playlist, type PlaylistEntry } from "../stores/playlist";
import { TankRowHeaderCell } from "./TankRowHeaderCell"; import { TankRowHeaderCell } from "./TankRowHeaderCell";
interface Props { interface Props extends PlaylistEntry {
tank: TankDefinition;
checked: boolean;
index: number; index: number;
} }
export function PlaylistTankEntry({ tank, checked, index }: Props) { export function PlaylistTankEntry({ tank, checked, last, index }: Props) {
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;
return ( return (
<Table.Row> <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 <Table.Cell
onClick={() => { onClick={() => {
Playlist.mutate((draft) => { Playlist.mutate((draft) => {
@@ -23,17 +36,7 @@ export function PlaylistTankEntry({ tank, checked, index }: Props) {
<Checkbox variant="classic" checked={checked} /> <Checkbox variant="classic" checked={checked} />
</Table.Cell> </Table.Cell>
<TankRowHeaderCell tank={tank} />
<Table.Cell> <Table.Cell>
<Flex
align="center"
height="calc(100% + 2 * var(--space-3) - 2px)"
gap="4"
pl="2"
my="-3"
mr="-3"
>
<IconButton <IconButton
variant="ghost" variant="ghost"
size="1" size="1"
@@ -45,19 +48,6 @@ export function PlaylistTankEntry({ tank, checked, index }: Props) {
> >
<MinusCircledIcon /> <MinusCircledIcon />
</IconButton> </IconButton>
<Flex
width="5rem"
justify="center"
align="center"
height="100%"
style={{ cursor: "grab", backgroundColor: "var(--gray-1)" }}
>
<Text color="gray" trim="end">
<DragHandleDots2Icon />
</Text>
</Flex>
</Flex>
</Table.Cell> </Table.Cell>
</Table.Row> </Table.Row>
); );
@@ -1,15 +1,14 @@
import { Table } from '@radix-ui/themes'; import { Table } from "@radix-ui/themes";
import type { RowHeaderCellProps } from '@radix-ui/themes/src/components/table.js'; import type { RowHeaderCellProps } from "@radix-ui/themes/src/components/table.js";
export function StickyRowHeaderCell({ style, ...props }: RowHeaderCellProps) { export function StickyRowHeaderCell({ style, ...props }: RowHeaderCellProps) {
return ( return (
<Table.RowHeaderCell <Table.RowHeaderCell
style={{ style={{
display: 'flex', display: "flex",
position: 'sticky', position: "sticky",
left: 0, left: 0,
backgroundColor: 'var(--color-panel)', backgroundColor: "var(--color-panel)",
boxShadow: 'var(--shadow-6)',
...style, ...style,
}} }}
{...props} {...props}
@@ -1,9 +1,9 @@
import { type TankDefinition, tankIcon, TankType } from '@blitzkit/core'; import { type TankDefinition, tankIcon, TankType } from "@blitzkit/core";
import { useLocale } from '../hooks/useLocale'; import { useLocale } from "../hooks/useLocale";
import { classIcons } from './ClassIcon'; import { classIcons } from "./ClassIcon";
import { ExperimentIcon } from './ExperimentIcon'; import { ExperimentIcon } from "./ExperimentIcon";
import { LinkI18n } from './LinkI18n'; import { LinkI18n } from "./LinkI18n";
import { StickyRowHeaderCell } from './StickyRowHeaderCell'; import { StickyRowHeaderCell } from "./StickyRowHeaderCell";
interface TankRowHeaderCellProps { interface TankRowHeaderCellProps {
tank: TankDefinition; tank: TankDefinition;
@@ -14,20 +14,17 @@ export function TankRowHeaderCell({ tank }: TankRowHeaderCellProps) {
const Icon = classIcons[tank.class]; const Icon = classIcons[tank.class];
return ( return (
<StickyRowHeaderCell <StickyRowHeaderCell style={{ overflow: "hidden" }}>
width={{ initial: '144px', sm: '240px' }}
style={{ overflow: 'hidden' }}
>
<LinkI18n locale={locale} href={`/tanks/${tank.slug}`} tabIndex={-1}> <LinkI18n locale={locale} href={`/tanks/${tank.slug}`} tabIndex={-1}>
<img <img
alt={unwrap(tank.name)} alt={unwrap(tank.name)}
draggable={false} draggable={false}
src={tankIcon(tank.id)} src={tankIcon(tank.id)}
style={{ style={{
margin: 'calc(-1 * var(--table-cell-padding)) 0', margin: "calc(-1 * var(--table-cell-padding)) 0",
height: 'calc(100% + 2 * var(--table-cell-padding))', height: "calc(100% + 2 * var(--table-cell-padding))",
aspectRatio: '4 / 3', aspectRatio: "4 / 3",
objectFit: 'cover', objectFit: "cover",
}} }}
/> />
</LinkI18n> </LinkI18n>
@@ -36,34 +33,34 @@ export function TankRowHeaderCell({ tank }: TankRowHeaderCellProps) {
locale={locale} locale={locale}
color={ color={
tank.type === TankType.COLLECTOR tank.type === TankType.COLLECTOR
? 'blue' ? "blue"
: tank.type === TankType.PREMIUM : tank.type === TankType.PREMIUM
? 'amber' ? "amber"
: 'gray' : "gray"
} }
highContrast={tank.type === TankType.RESEARCHABLE} highContrast={tank.type === TankType.RESEARCHABLE}
underline="hover" underline="hover"
wrap="nowrap" wrap="nowrap"
href={`/tanks/${tank.slug}`} href={`/tanks/${tank.slug}`}
style={{ style={{
paddingLeft: 'var(--space-2)', paddingLeft: "var(--space-2)",
display: 'flex', display: "flex",
alignItems: 'center', alignItems: "center",
gap: 'var(--space-1)', gap: "var(--space-1)",
whiteSpace: 'nowrap', whiteSpace: "nowrap",
overflow: 'hidden', overflow: "hidden",
textOverflow: 'ellipsis', textOverflow: "ellipsis",
}} }}
size={{ initial: '1', sm: '2' }} size={{ initial: "1", sm: "2" }}
> >
<Icon <Icon
width="1em" width="1em"
height="1em" height="1em"
style={{ minWidth: '1em', minHeight: '1em' }} style={{ minWidth: "1em", minHeight: "1em" }}
/> />
{tank.testing && ( {tank.testing && (
<ExperimentIcon style={{ width: '1em', height: '1em' }} /> <ExperimentIcon style={{ width: "1em", height: "1em" }} />
)} )}
{unwrap(tank.name)} {unwrap(tank.name)}
@@ -35,6 +35,8 @@ export async function generatePlaylist() {
tank, tank,
battles: stats[tank.id]?.all.battles, battles: stats[tank.id]?.all.battles,
wins: stats[tank.id]?.all.wins, wins: stats[tank.id]?.all.wins,
last: stats[tank.id]?.last_battle_time,
checked: false,
})); }));
}); });
} }
@@ -8,9 +8,11 @@ import BaseLayout from "./BaseLayout.astro";
interface Props { interface Props {
tool: Tool; tool: Tool;
locale: string; locale: string;
noNavbar?: boolean;
noFooter?: boolean;
} }
const { tool, locale } = Astro.props; const { tool, locale, noNavbar, noFooter } = Astro.props;
const strings = getStrings(locale); const strings = getStrings(locale);
const toolStrings = const toolStrings =
strings.website.tools[ strings.website.tools[
@@ -20,7 +22,7 @@ const toolStrings =
<ToolOpenGraph {tool} /> <ToolOpenGraph {tool} />
<BaseLayout {locale}> <BaseLayout {locale} {noNavbar} {noFooter}>
<Box <Box
style={{ style={{
backgroundImage: `url(/assets/images/tools/${tool.id}.webp)`, backgroundImage: `url(/assets/images/tools/${tool.id}.webp)`,
@@ -29,7 +29,7 @@ export function Page({
return ( return (
<LocaleProvider locale={locale}> <LocaleProvider locale={locale}>
<PageWrapper color="tomato" direction="row"> <PageWrapper color="tomato" direction="row" maxWidth="unset" p="0">
{!list && <PlaylistGenerator />} {!list && <PlaylistGenerator />}
{list && <PlaylistOrder />} {list && <PlaylistOrder />}
</PageWrapper> </PageWrapper>
@@ -8,7 +8,7 @@ export { getStaticPaths } from "../index.astro";
const { locale } = Astro.params; const { locale } = Astro.params;
--- ---
<ToolLayout {locale} tool={tools.playlist}> <ToolLayout {locale} tool={tools.playlist} noFooter>
<Page {locale} client:only="react"> <Page {locale} client:only="react">
<Page {locale} skeleton slot="fallback" /> <Page {locale} skeleton slot="fallback" />
</Page> </Page>
+6 -4
View File
@@ -1,14 +1,16 @@
import type { TankDefinition } from "@blitzkit/core"; import type { TankDefinition } from "@blitzkit/core";
import { Varuna } from "varuna"; import { Varuna } from "varuna";
export interface Playlist { export interface PlaylistEntry {
list?: {
index: number;
tank: TankDefinition; tank: TankDefinition;
battles?: number; battles?: number;
wins?: number; wins?: number;
checked: boolean; checked: boolean;
}[]; last?: number;
}
export interface Playlist {
list?: PlaylistEntry[];
} }
export const Playlist = new Varuna<Playlist>({}, "playlist"); export const Playlist = new Varuna<Playlist>({}, "playlist");