feat(tiles): let users arrange the dashboard and profile tiles
Build, Test & Push Frontend / quality-check (push) Successful in 18s
Build, Test & Push Frontend / docker (push) Successful in 28s

This commit is contained in:
lobo committed 2026-09-28 15:03:16 +02:00
1 parent b55fcfec92
commit aa9e83e0cb
24 files changed
+792 -426

No files matched your search

+6 -2
View File
@@ -4,10 +4,14 @@ import {ArrowRight, Gamepad2} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {cn} from '@/lib/utils';
import GameCover from './GameCover.jsx';
import {formatPlaytime} from '../utils/games.js';
export default function CurrentlyPlayingCard({games, syncing}) {
/**
* @param wide four games per row instead of two, for the large tile
*/
export default function CurrentlyPlayingCard({games, syncing, wide}) {
const {t} = useTranslation();
return (
<Card className="gap-0 py-0">
@@ -26,7 +30,7 @@ export default function CurrentlyPlayingCard({games, syncing}) {
{syncing ? t('LIBRARY.SYNCING_FIRST_TIME') : t('DASHBOARD.NOTHING_PLAYING')}
</p>
) : (
<ul className="grid grid-cols-2 gap-3 p-4">
<ul className={cn('grid grid-cols-2 gap-3 p-4', wide && 'sm:grid-cols-4')}>
{games.map((g) => (
<li key={g.appid}>
<Link to={`/library/${g.appid}`} className="group block space-y-1.5">
+3 -44
View File
@@ -1,11 +1,9 @@
import {useTranslation} from 'react-i18next';
import {ArrowDown, ArrowUp, ImageOff} from 'lucide-react';
import {ImageOff} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Label} from '@/components/ui/label';
import {Switch} from '@/components/ui/switch';
import {AccentPicker, CornersPicker} from './StylePickers.jsx';
import {PROFILE_SECTIONS} from '../utils/profileStyle.js';
import {coverUrl} from '../utils/games.js';
function ArtRow({id, label, appid, onRemove, disabled}) {
@@ -31,24 +29,14 @@ function ArtRow({id, label, appid, onRemove, disabled}) {
}
/**
* The look of the public profile: accent, corners, banner, background and the sections in their order.
* The look of the public profile: accent, corners, banner and background. The tiles are arranged on the profile
* page itself.
*
* @param disabled the profile is private, or a save runs
* @param onChange gets the changed fields only
*/
export default function ProfileStyleSettings({style, disabled, onChange}) {
const {t} = useTranslation();
const shown = style.sections;
const rows = [...shown, ...PROFILE_SECTIONS.filter((s) => !shown.includes(s))];
const move = (index, by) => {
const next = [...shown];
[next[index], next[index + by]] = [next[index + by], next[index]];
onChange({sections: next});
};
const toggle = (section) => onChange({
sections: shown.includes(section) ? shown.filter((s) => s !== section) : [...shown, section],
});
return (
<div className="space-y-6">
<div className="space-y-1">
@@ -63,35 +51,6 @@ export default function ProfileStyleSettings({style, disabled, onChange}) {
disabled={disabled} onRemove={() => onChange({bannerAppid: null})}/>
<ArtRow id="profile-background" label={t('SETTINGS.PROFILE_BACKGROUND')} appid={style.backgroundAppid}
disabled={disabled} onRemove={() => onChange({backgroundAppid: null})}/>
<div className="space-y-3">
<span id="profile-sections-label" className="text-sm font-medium">{t('SETTINGS.PROFILE_SECTIONS')}</span>
<ul aria-labelledby="profile-sections-label" className="space-y-2">
{rows.map((section) => {
const index = shown.indexOf(section);
const name = t(`SETTINGS.PROFILE_SECTION.${section}`);
return (
<li key={section} className="flex items-center justify-between gap-4">
<Label htmlFor={`profileSection-${section}`}>{name}</Label>
<div className="flex items-center gap-1">
<Button type="button" variant="ghost" size="icon" className="size-7"
aria-label={t('SETTINGS.PROFILE_SECTION_UP', {name})}
disabled={disabled || index <= 0} onClick={() => move(index, -1)}>
<ArrowUp/>
</Button>
<Button type="button" variant="ghost" size="icon" className="size-7"
aria-label={t('SETTINGS.PROFILE_SECTION_DOWN', {name})}
disabled={disabled || index < 0 || index === shown.length - 1}
onClick={() => move(index, 1)}>
<ArrowDown/>
</Button>
<Switch id={`profileSection-${section}`} checked={index >= 0} disabled={disabled}
className="ml-2" onCheckedChange={() => toggle(section)}/>
</div>
</li>
);
})}
</ul>
</div>
</div>
);
}
+126
View File
@@ -0,0 +1,126 @@
import {useState} from 'react';
import {useTranslation} from 'react-i18next';
import {ArrowDown, ArrowUp, Check, EyeOff, Loader2, Plus, RotateCcw, X} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {cn} from '@/lib/utils';
import {SIZES, spanClass} from '../utils/tiles.js';
/**
* The tiles of a page in their order, in a 12 column grid (one column on a phone). In edit mode each tile gets
* move, size and hide buttons, and the hidden tiles can be added again; nothing is saved before "Done".
*
* @param tiles the tile table (utils/tiles.js)
* @param titles tile id -> i18n key of its name in the edit mode
* @param renderTile (id, size) => the tile, or null when it has nothing to show (then it is left out, and in edit
* mode it shows a placeholder so it can still be moved)
* @param defaultLayout offered as "Reset"; none: no reset
* @param onSave (layout) => promise; the page ends the edit mode when the save worked
*/
export default function TileGrid({tiles, titles, layout, renderTile, editing, defaultLayout, onSave, onCancel}) {
if (!editing) {
return (
<div className="grid grid-cols-1 gap-6 md:grid-cols-12">
{layout.map((tile) => {
const content = renderTile(tile.id, tile.size);
return content && (
// *:h-full: the card fills the grid row, as it did as a direct grid item.
<div key={tile.id} data-tile={tile.id} className={cn('min-w-0 *:h-full', spanClass(tiles, tile))}>{content}</div>
);
})}
</div>
);
}
return <TileEditor tiles={tiles} titles={titles} layout={layout} renderTile={renderTile} defaultLayout={defaultLayout}
onSave={onSave} onCancel={onCancel}/>;
}
// Mounted per edit session, so every session starts from the saved layout.
function TileEditor({tiles, titles, layout, renderTile, defaultLayout, onSave, onCancel}) {
const {t} = useTranslation();
const [shown, setShown] = useState(layout);
const [saving, setSaving] = useState(false);
const hidden = Object.keys(tiles).filter((id) => !shown.some((tile) => tile.id === id));
const move = (index, by) => {
const next = [...shown];
[next[index], next[index + by]] = [next[index + by], next[index]];
setShown(next);
};
const save = async () => {
setSaving(true);
await onSave(shown);
setSaving(false);
};
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-dashed border-primary/50 px-4 py-3">
<p className="text-sm text-muted-foreground">{t('TILES.EDIT_HINT')}</p>
<div className="flex flex-wrap gap-2">
{defaultLayout && (
<Button variant="ghost" size="sm" disabled={saving} onClick={() => setShown(defaultLayout)}>
<RotateCcw/>{t('TILES.RESET')}
</Button>
)}
<Button variant="outline" size="sm" disabled={saving} onClick={onCancel}><X/>{t('TILES.CANCEL')}</Button>
<Button size="sm" disabled={saving} onClick={save}>
{saving ? <Loader2 className="animate-spin"/> : <Check/>}{t('TILES.DONE')}
</Button>
</div>
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-12">
{shown.map((tile, index) => {
const name = t(titles[tile.id]);
const sizes = SIZES.filter((size) => tiles[tile.id][size]);
return (
<div key={tile.id} data-tile={tile.id} className={cn('min-w-0 space-y-2', spanClass(tiles, tile))}>
<div className="flex flex-wrap items-center gap-1 rounded-md bg-muted px-2 py-1">
<span className="mr-auto truncate text-sm font-medium">{name}</span>
<Button variant="ghost" size="icon" className="size-7" disabled={index === 0}
aria-label={t('TILES.MOVE_UP', {name})} onClick={() => move(index, -1)}><ArrowUp/></Button>
<Button variant="ghost" size="icon" className="size-7" disabled={index === shown.length - 1}
aria-label={t('TILES.MOVE_DOWN', {name})} onClick={() => move(index, 1)}><ArrowDown/></Button>
{sizes.length > 1 && (
<div role="group" aria-label={t('TILES.SIZE', {name})} className="flex">
{sizes.map((size) => (
<Button key={size} variant={tile.size === size ? 'default' : 'ghost'} size="sm"
className="h-7 px-2" aria-pressed={tile.size === size}
aria-label={t(`TILES.SIZE_${size}`)}
onClick={() => setShown(shown.map((other) => other.id === tile.id ? {...other, size} : other))}>
{size}
</Button>
))}
</div>
)}
<Button variant="ghost" size="icon" className="size-7" aria-label={t('TILES.HIDE', {name})}
onClick={() => setShown(shown.filter((other) => other.id !== tile.id))}><EyeOff/></Button>
</div>
{/* inert: a click on a game in the tile must not leave the edit mode. */}
<div inert className="opacity-80">
{renderTile(tile.id, tile.size) || (
<Card className="items-center p-6 text-sm text-muted-foreground">{t('TILES.NO_DATA')}</Card>
)}
</div>
</div>
);
})}
</div>
{hidden.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium">{t('TILES.HIDDEN')}</p>
<div className="flex flex-wrap gap-2">
{hidden.map((id) => (
<Button key={id} variant="outline" size="sm" onClick={() => setShown([...shown, {id, size: 'M'}])}>
<Plus/>{t(titles[id])}
</Button>
))}
</div>
</div>
)}
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import TileGrid from './TileGrid.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
const TILES = {A: {S: 3, M: 6, L: 12}, B: {M: 6}, C: {M: 12}};
const TITLES = {A: 'TILES.A', B: 'TILES.B', C: 'TILES.C'};
const LAYOUT = [{id: 'A', size: 'M'}, {id: 'B', size: 'M'}];
const renderTile = (id, size) => (id === 'C' ? null : <p>{`tile ${id} ${size}`}</p>);
const renderGrid = (props) => render(
<TileGrid tiles={TILES} titles={TITLES} layout={LAYOUT} renderTile={renderTile} editing={false} onSave={vi.fn()}
onCancel={vi.fn()} {...props}/>,
);
const order = (container) => [...container.querySelectorAll('[data-tile]')].map((el) => el.dataset.tile);
describe('TileGrid', () => {
afterEach(cleanup);
it('shows the tiles in order with their spans and leaves out a tile without content', () => {
const {container} = renderGrid({layout: [...LAYOUT, {id: 'C', size: 'M'}]});
expect(order(container)).toEqual(['A', 'B']);
expect(container.querySelector('[data-tile="A"]').className).toContain('md:col-span-6');
});
it('moves, resizes, hides and adds tiles, and saves only on "Done"', async () => {
const onSave = vi.fn(async () => true);
const {container} = renderGrid({editing: true, onSave});
fireEvent.click(screen.getAllByRole('button', {name: 'TILES.MOVE_DOWN'})[0]);
expect(order(container)).toEqual(['B', 'A']);
fireEvent.click(screen.getByRole('button', {name: 'TILES.SIZE_L'}));
fireEvent.click(screen.getAllByRole('button', {name: 'TILES.HIDE'})[0]);
fireEvent.click(screen.getByRole('button', {name: 'TILES.C'}));
expect(onSave).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', {name: 'TILES.DONE'}));
await waitFor(() => expect(onSave).toHaveBeenCalledWith([{id: 'A', size: 'L'}, {id: 'C', size: 'M'}]));
});
it('offers sizes only for a tile with more than one, and shows a placeholder for a tile without content', () => {
renderGrid({editing: true, layout: [{id: 'B', size: 'M'}, {id: 'C', size: 'M'}]});
expect(screen.queryByRole('button', {name: 'TILES.SIZE_S'})).toBeNull();
expect(screen.getByText('TILES.NO_DATA')).toBeTruthy();
});
it('resets to the default layout and cancels without a save', () => {
const onSave = vi.fn();
const onCancel = vi.fn();
const {container} = renderGrid({editing: true, defaultLayout: [{id: 'C', size: 'M'}], onSave, onCancel});
fireEvent.click(screen.getByRole('button', {name: 'TILES.RESET'}));
expect(order(container)).toEqual(['C']);
fireEvent.click(screen.getByRole('button', {name: 'TILES.CANCEL'}));
expect(onCancel).toHaveBeenCalled();
expect(onSave).not.toHaveBeenCalled();
});
it('offers no reset without a default layout', () => {
renderGrid({editing: true});
expect(screen.queryByRole('button', {name: 'TILES.RESET'})).toBeNull();
});
});
@@ -1,40 +0,0 @@
import {useTranslation} from 'react-i18next';
import {Label} from '@/components/ui/label';
import {Switch} from '@/components/ui/switch';
import {PROFILE_STAT_GROUPS} from '../../utils/stats.js';
/**
* One switch per stat group the public profile can show.
*
* @param disabled the profile is private
* @param saving a save runs; the switches wait for it
* @param onChange gets the whole new list of groups
*/
export default function ProfileStatsSettings({selected, disabled, saving, onChange}) {
const {t} = useTranslation();
const toggle = (group) => onChange(selected.includes(group)
? selected.filter((g) => g !== group)
: PROFILE_STAT_GROUPS.filter((g) => g === group || selected.includes(g)));
return (
<div className="space-y-3">
<div className="space-y-1">
<span className="text-sm font-medium">{t('SETTINGS.PROFILE_STATS')}</span>
<p className="text-xs text-muted-foreground">
{disabled ? t('SETTINGS.PROFILE_STATS_NEEDS_PUBLIC') : t('SETTINGS.PROFILE_STATS_HINT')}
</p>
</div>
<ul className="space-y-2">
{PROFILE_STAT_GROUPS.map((group) => (
<li key={group} className="flex items-center justify-between gap-4">
<Label htmlFor={`profileStats-${group}`}>{t(`STATS.${group}.TITLE`)}</Label>
<Switch id={`profileStats-${group}`} checked={selected.includes(group)}
disabled={disabled || saving}
onCheckedChange={() => toggle(group)}/>
</li>
))}
</ul>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
import OverviewStats from './OverviewStats.jsx';
import PileStats from './PileStats.jsx';
import TrendStats from './TrendStats.jsx';
import TopGamesStats from './TopGamesStats.jsx';
import TasteStats from './TasteStats.jsx';
import HabitsStats from './HabitsStats.jsx';
import FinishedStats from './FinishedStats.jsx';
/**
* One stat group as a tile; null while its data is not there. A function, not a component: a tile grid leaves out a
* null tile, and an element is never null.
*
* @param stats the answer of GET /api/library/stats, or the stats of a public profile
* @param linked the game rows link to the game pages; false on the public profile
*/
export default function statGroupTile(id, stats, linked = true) {
switch (id) {
case 'STATS_OVERVIEW': return stats?.overview ? <OverviewStats overview={stats.overview}/> : null;
case 'STATS_PILE': return stats?.pile ? <PileStats pile={stats.pile} linked={linked}/> : null;
case 'STATS_TREND': return stats?.trend ? <TrendStats trend={stats.trend}/> : null;
case 'STATS_TOP_GAMES': return stats?.topGames ? <TopGamesStats topGames={stats.topGames} linked={linked}/> : null;
case 'STATS_TASTE': return stats?.taste ? <TasteStats taste={stats.taste}/> : null;
case 'STATS_HABITS': return stats?.habits ? <HabitsStats habits={stats.habits}/> : null;
case 'STATS_FINISHED': return stats?.finished ? <FinishedStats finished={stats.finished}/> : null;
default: return null;
}
}