Public Access
feat(tiles): let users arrange the dashboard and profile tiles
This commit is contained in:
1 parent
b55fcfec92
commit
aa9e83e0cb
24 files changed
+792
-426
No files matched your search
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user