mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-10-24 12:09:51 +02:00
feat: spotify like table
This commit is contained in:
1 parent
37adc04cda
commit
e1a6e4e38d
12 files changed
+146
-101
No files matched your search
@@ -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>
|
||||||
|
|||||||
@@ -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");
|
||||||
Reference in new issue
Block a user