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 -4
View File
@@ -19,7 +19,9 @@ To Be Played is split into three repositories:
## Features
- **Dashboard**: most played games, games in progress, "Pile of Shame" with backlog hours, its trend of the last 90
- **Dashboard** with an edit mode ("Customize"): the user orders, hides and resizes the tiles (small = half width
and less content, large = double width and more), and can add the stat groups of "Your stats" as tiles. Stored
in Auth, so it follows the user to the phone; "Reset" goes back to the default. Tiles: most played games, games in progress, "Pile of Shame" with backlog hours, its trend of the last 90
days ("this month: +N added, -N off the pile") and a random suggestion,
Steam avatar and a banner from the most played game of the last two weeks, and an achievement card: unlocked
total, perfect games, average completion and the 5 games closest to 100 %.
@@ -50,9 +52,9 @@ To Be Played is split into three repositories:
the proved Steam ID, never a typed-in one. A Steam ID typed in before shows as "not verified" with a button to
verify it; unlinking is possible.
- **Public profile** at `/u/<display name>`, opt-in in the settings, with perfect games and average completion.
The user picks in the settings which stat groups of "Your stats" it shows (none by default), and designs its
look: accent, corners, banner and background from own game art ("Profile art" on a game's page), and which
sections it shows in which order. Visitors see the owner's look on that page only; light or dark mode stays theirs.
The owner arranges it with the same edit mode on the profile page itself (the tiles, incl. the stat groups of
"Your stats"), and sets its look in the settings: accent, corners, banner and background from own game art
("Profile art" on a game's page). Visitors see the owner's look on that page only; light or dark mode stays theirs.
- **Appearance**: accent color, corner style, wide layout, page background and banner from your own game art.
Saved in Auth, so it follows you to every device.
- **Broken pictures**: a Steam cover or achievement icon that does not load shows a placeholder and is reported
+20 -14
View File
@@ -35,25 +35,12 @@
"ERROR_TOO_MANY_ATTEMPTS": "Zu viele fehlgeschlagene Versuche. Bitte in {{minutes}} Minuten erneut versuchen."
},
"SETTINGS": {
"PROFILE_STATS": "Statistik im öffentlichen Profil",
"PROFILE_STATS_HINT": "Wähle die Statistiken, die Besucher sehen. Sie erscheinen, wenn der Abschnitt Statistiken unten an ist.",
"PROFILE_STATS_NEEDS_PUBLIC": "Mach dein Profil zuerst öffentlich.",
"PROFILE_STYLE": "Aussehen des Profils",
"PROFILE_STYLE_HINT": "So sehen Besucher dein öffentliches Profil. Das Aussehen deiner App bleibt, wie es ist.",
"PROFILE_BANNER": "Banner",
"PROFILE_BACKGROUND": "Hintergrund",
"PROFILE_ART_HINT": "Wähle das Bild auf der Seite eines Spiels: \"Profilbild\".",
"PROFILE_ART_REMOVE": "Entfernen",
"PROFILE_SECTIONS": "Abschnitte und Reihenfolge",
"PROFILE_SECTION": {
"SUMMARY": "Summen",
"STATUS": "Statusleiste",
"PLAYING": "Spielt gerade",
"FINISHED": "Beendete Spiele",
"STATS": "Statistiken"
},
"PROFILE_SECTION_UP": "{{name}} nach oben",
"PROFILE_SECTION_DOWN": "{{name}} nach unten",
"TITLE": "Einstellungen",
"LOADING": "Einstellungen werden geladen …",
"ACCOUNT": "Konto",
@@ -137,7 +124,8 @@
"WRONG_PASSWORD": "Das Passwort ist falsch.",
"TOO_MANY": "Zu viele falsche Passwörter. Bitte in {{minutes}} Minuten erneut versuchen.",
"ERROR": "Das Konto konnte nicht gelöscht werden. Bitte später erneut versuchen."
}
},
"PROFILE_TILES_HINT": "Wähle, was dein Profil zeigt und wo: öffne dein Profil und klicke auf \"Anpassen\"."
},
"VALIDATION": {
"EMAIL_REQUIRED": "Email ist erforderlich.",
@@ -615,5 +603,23 @@
"RANGE": "{{start}} bis {{end}}",
"DONE_COUNT_one": "Von {{count}} Nutzer für immer geschlossen",
"DONE_COUNT_other": "Von {{count}} Nutzern für immer geschlossen"
},
"TILES": {
"CUSTOMIZE": "Anpassen",
"EDIT_HINT": "Verschiebe, vergrößere oder verstecke die Kacheln. Gespeichert wird erst mit \"Fertig\".",
"DONE": "Fertig",
"CANCEL": "Abbrechen",
"RESET": "Zurücksetzen",
"HIDDEN": "Versteckte Kacheln",
"NO_DATA": "Noch nichts zu zeigen.",
"MOVE_UP": "{{name}} nach oben",
"MOVE_DOWN": "{{name}} nach unten",
"SIZE": "Größe von {{name}}",
"HIDE": "{{name}} verstecken",
"SIZE_S": "Klein",
"SIZE_M": "Normal",
"SIZE_L": "Groß",
"SUMMARY": "Summen",
"STATUS": "Statusleiste"
}
}
+20 -14
View File
@@ -35,25 +35,12 @@
"ERROR_TOO_MANY_ATTEMPTS": "Too many failed attempts. Please try again in {{minutes}} minutes."
},
"SETTINGS": {
"PROFILE_STATS": "Stats on the public profile",
"PROFILE_STATS_HINT": "Pick the stat groups visitors see. They show when the Stats section below is on.",
"PROFILE_STATS_NEEDS_PUBLIC": "Make your profile public first.",
"PROFILE_STYLE": "Profile look",
"PROFILE_STYLE_HINT": "How visitors see your public profile. Your own app look stays as it is.",
"PROFILE_BANNER": "Banner",
"PROFILE_BACKGROUND": "Background",
"PROFILE_ART_HINT": "Pick the art on a game's page: \"Profile art\".",
"PROFILE_ART_REMOVE": "Remove",
"PROFILE_SECTIONS": "Sections and their order",
"PROFILE_SECTION": {
"SUMMARY": "Totals",
"STATUS": "Status bar",
"PLAYING": "Playing",
"FINISHED": "Finished games",
"STATS": "Stats"
},
"PROFILE_SECTION_UP": "Move {{name}} up",
"PROFILE_SECTION_DOWN": "Move {{name}} down",
"TITLE": "Settings",
"LOADING": "Loading settings …",
"ACCOUNT": "Account",
@@ -137,7 +124,8 @@
"WRONG_PASSWORD": "The password is wrong.",
"TOO_MANY": "Too many wrong passwords. Please try again in {{minutes}} minutes.",
"ERROR": "The account could not be deleted. Please try again later."
}
},
"PROFILE_TILES_HINT": "Choose what your profile shows, and where: open your profile and click \"Customize\"."
},
"VALIDATION": {
"EMAIL_REQUIRED": "Email is required.",
@@ -615,5 +603,23 @@
"RANGE": "{{start}} to {{end}}",
"DONE_COUNT_one": "Closed for good by {{count}} user",
"DONE_COUNT_other": "Closed for good by {{count}} users"
},
"TILES": {
"CUSTOMIZE": "Customize",
"EDIT_HINT": "Move, resize or hide the tiles. Nothing is saved before \"Done\".",
"DONE": "Done",
"CANCEL": "Cancel",
"RESET": "Reset",
"HIDDEN": "Hidden tiles",
"NO_DATA": "Nothing to show yet.",
"MOVE_UP": "Move {{name}} up",
"MOVE_DOWN": "Move {{name}} down",
"SIZE": "Size of {{name}}",
"HIDE": "Hide {{name}}",
"SIZE_S": "Small",
"SIZE_M": "Normal",
"SIZE_L": "Large",
"SUMMARY": "Totals",
"STATUS": "Status bar"
}
}
+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;
}
}
+3 -3
View File
@@ -4,8 +4,8 @@ import {sessionStamp} from '../utils/auth.js';
import {normalizeProfileStyle} from '../utils/profileStyle.js';
/**
* The stored role, the active role ("view as") and the public profile style of the logged-in user, or null while
* loading and on any error.
* The stored role, the active role ("view as"), the display name and the public profile style of the logged-in
* user, or null while loading and on any error.
* Loads again after a new login or a role switch (see sessionStamp) on the next render.
*/
export default function useCurrentUser(enabled) {
@@ -22,7 +22,7 @@ export default function useCurrentUser(enabled) {
try {
const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`);
const data = response.ok ? await response.json() : null;
if (!cancelled) setUser(data && {role: data.role, activeRole: data.activeRole ?? data.role,
if (!cancelled) setUser(data && {role: data.role, activeRole: data.activeRole ?? data.role, displayName: data.displayName,
profileStyle: normalizeProfileStyle(data.settings?.profileStyle)});
} catch {
if (!cancelled) setUser(null);
+9 -23
View File
@@ -1,7 +1,5 @@
import {useCallback} from 'react';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
import useSessionExpired from './useSessionExpired.js';
import useSaveResponse from './useSaveResponse.js';
import {loadProfileStyle, saveProfileStyle} from '../utils/profileStyle.js';
/**
@@ -10,27 +8,15 @@ import {loadProfileStyle, saveProfileStyle} from '../utils/profileStyle.js';
* style. Resolves to the saved style, or null after the error message (or the session end on a 401).
*/
export default function useSaveProfileStyle() {
const {t} = useTranslation();
const expireSession = useSessionExpired();
const run = useSaveResponse();
return useCallback(async (changes, shown) => {
try {
let style = null;
const saved = await run(async () => {
const stored = await loadProfileStyle() ?? shown;
const style = {...stored, ...(typeof changes === 'function' ? changes(stored) : changes)};
const response = await saveProfileStyle(style);
if (response.status === 401) {
expireSession();
return null;
}
if (!response.ok) {
toast.error(t('SETTINGS.ERROR_SAVE'));
return null;
}
return style;
} catch (err) {
console.error('Profile style save error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
return null;
}
}, [expireSession, t]);
style = {...stored, ...(typeof changes === 'function' ? changes(stored) : changes)};
return saveProfileStyle(style);
});
return saved ? style : null;
}, [run]);
}
+32
View File
@@ -0,0 +1,32 @@
import {useCallback} from 'react';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
import useSessionExpired from './useSessionExpired.js';
/**
* Returns `run(send)` for a settings save: it awaits `send()` (a fetch) and shows the error message, or ends the
* session on a 401. Resolves to true when saved.
*/
export default function useSaveResponse() {
const {t} = useTranslation();
const expireSession = useSessionExpired();
return useCallback(async (send) => {
try {
const response = await send();
if (response.status === 401) {
expireSession();
return false;
}
if (!response.ok) {
toast.error(t('SETTINGS.ERROR_SAVE'));
return false;
}
return true;
} catch (err) {
console.error('Settings save error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
return false;
}
}, [expireSession, t]);
}
+135 -98
View File
@@ -2,7 +2,7 @@ import React, {useCallback, useEffect, useState} from 'react';
import {Link, useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
import {ArrowRight, Hash, Link2, RefreshCw, Trophy} from 'lucide-react';
import {ArrowRight, Hash, LayoutGrid, Link2, RefreshCw, Trophy} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
@@ -25,18 +25,22 @@ import HeroBanner from '../components/HeroBanner.jsx';
import SteamAvatar from '../components/SteamAvatar.jsx';
import useSteamProfile from '../hooks/useSteamProfile.js';
import useUserStats from '../hooks/useUserStats.js';
import useSaveResponse from '../hooks/useSaveResponse.js';
import TileGrid from '../components/TileGrid.jsx';
import statGroupTile from '../components/stats/statGroupTile.jsx';
import {DASHBOARD_TILES, DASHBOARD_TITLES, DEFAULT_DASHBOARD, normalizeLayout, saveTiles, statGroup} from '../utils/tiles.js';
import {usePreferences} from '../context/PreferencesContext.jsx';
import {apiFetch} from '../utils/api.js';
import {hasSession} from '../utils/auth.js';
const TOP_GAMES = 5;
// Games per tile size; the page loads the most for the large tile.
const TOP_GAMES = {S: 3, M: 5, L: 10};
const PLAYING_GAMES = {S: 2, M: 4, L: 8};
// The games endpoint accepts only the page sizes 20, 40 and 80.
const TOP_GAMES_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc';
const PLAYING_GAMES = 4;
const PLAYING_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc&status=PLAYING';
const JSON_HEADERS = {'Content-Type': 'application/json'};
const TREND_GROUPS = ['TREND'];
const Dashboard = () => {
const {t} = useTranslation();
@@ -53,8 +57,18 @@ const Dashboard = () => {
const [shuffling, setShuffling] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const pileTrend = useUserStats(TREND_GROUPS, expireSession, hasSession());
const reloadTrend = pileTrend.reload;
const [editing, setEditing] = useState(false);
const runSave = useSaveResponse();
const layout = normalizeLayout(userData?.settings?.dashboardTiles, DASHBOARD_TILES, DEFAULT_DASHBOARD);
// The stat groups of the shown stat tiles, and TREND for the pile card. Waits for the saved layout in /me.
const statGroups = [...new Set([
...layout.map((tile) => statGroup(tile.id)).filter(Boolean),
...(layout.some((tile) => tile.id === 'PILE_OF_SHAME') ? ['TREND'] : []),
])].sort();
// No groups would mean "all groups" to the stats endpoint.
const hasStatGroups = statGroups.length > 0;
const stats = useUserStats(statGroups, expireSession, hasSession() && userData != null && hasStatGroups);
const reloadStats = stats.reload;
const fetchSteamCount = useCallback(async () => {
try {
@@ -80,7 +94,7 @@ const Dashboard = () => {
}
if (r.ok) {
const page = await r.json();
setTopGames((page.content ?? []).slice(0, TOP_GAMES));
setTopGames((page.content ?? []).slice(0, TOP_GAMES.L));
}
} catch (err) {
console.error('Error fetching top games:', err);
@@ -96,7 +110,7 @@ const Dashboard = () => {
}
if (r.ok) {
const page = await r.json();
setPlaying((page.content ?? []).slice(0, PLAYING_GAMES));
setPlaying((page.content ?? []).slice(0, PLAYING_GAMES.L));
}
} catch (err) {
console.error('Error fetching playing games:', err);
@@ -172,8 +186,8 @@ const Dashboard = () => {
fetchSummary();
fetchActivity();
fetchAchievements();
reloadTrend();
}, [fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements, reloadTrend]);
if (hasStatGroups) reloadStats();
}, [fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements, hasStatGroups, reloadStats]);
useEffect(() => {
const fetchUserData = async () => {
@@ -240,7 +254,43 @@ const Dashboard = () => {
}
const syncBusy = forceSync.busy || sync.isRunning;
const maxMinutes = topGames[0]?.playtimeMinutes || 1;
const saveLayout = async (next) => {
// The default is saved as "never changed", so a later change of the default reaches this user too.
const tiles = JSON.stringify(next) === JSON.stringify(DEFAULT_DASHBOARD) ? null : next;
if (!await runSave(() => saveTiles('dashboard-tiles', tiles))) return;
setUserData((d) => ({...d, settings: {...d.settings, dashboardTiles: tiles}}));
setEditing(false);
};
const renderTile = (id, size) => {
switch (id) {
case 'GAME_COUNT': return showLibrary && <SteamGameCount targetCount={steamData.count}/>;
case 'MOST_PLAYED':
return showLibrary && <MostPlayedCard games={topGames.slice(0, TOP_GAMES[size])} syncing={sync.isRunning}/>;
case 'PLAYING':
return showLibrary && (
<CurrentlyPlayingCard games={playing.slice(0, PLAYING_GAMES[size])} syncing={sync.isRunning} wide={size === 'L'}/>
);
case 'PILE_OF_SHAME':
if (!showLibrary) return null;
if (summary) {
return <PileOfShameCard summary={summary} trend={stats.stats?.trend} syncing={sync.isRunning}
onShuffle={shuffleSuggestion} shuffling={shuffling}/>;
}
return summaryFailed && (
<Card className="items-center justify-center gap-3 p-6 text-center text-sm text-muted-foreground">
{t('DASHBOARD.SUMMARY_FAILED')}
<Button variant="outline" size="sm" onClick={() => fetchSummary()}>
<RefreshCw/>{t('DASHBOARD.RETRY')}
</Button>
</Card>
);
case 'ACTIVITY': return hasSteamId && activity && <ActivityCard activity={activity}/>;
case 'ACHIEVEMENTS': return hasSteamId && achievements && <AchievementsCard summary={achievements}/>;
default: return statGroupTile(id, stats.stats);
}
};
return (
<PageContainer className="p-4 md:p-8 space-y-6">
@@ -272,101 +322,88 @@ const Dashboard = () => {
</div>
</div>
{hasSteamId && (
<div className="relative flex flex-col items-start md:items-end gap-3 shrink-0">
<SyncStatusBadge
status={sync.status}
lastSyncedAt={sync.lastSyncedAt}
isRunning={sync.isRunning}
/>
<Button onClick={forceSync.trigger} disabled={syncBusy}>
<RefreshCw className={syncBusy ? 'animate-spin' : ''}/>
{t('LIBRARY.RESYNC')}
</Button>
</div>
)}
<div className="relative flex flex-col items-start md:items-end gap-3 shrink-0">
{hasSteamId && (
<>
<SyncStatusBadge
status={sync.status}
lastSyncedAt={sync.lastSyncedAt}
isRunning={sync.isRunning}
/>
<Button onClick={forceSync.trigger} disabled={syncBusy}>
<RefreshCw className={syncBusy ? 'animate-spin' : ''}/>
{t('LIBRARY.RESYNC')}
</Button>
</>
)}
<Button variant="ghost" size="sm" disabled={editing} onClick={() => setEditing(true)}>
<LayoutGrid/>{t('TILES.CUSTOMIZE')}
</Button>
</div>
</Card>
{showLibrary && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<SteamGameCount targetCount={steamData.count}/>
<Card className="md:col-span-2 gap-0 py-0">
<div className="flex items-center justify-between px-5 py-4 border-b">
<h2 className="flex items-center gap-2 font-semibold">
<Trophy className="size-4 text-amber-500"/>
{t('DASHBOARD.MOST_PLAYED')}
</h2>
<Button asChild variant="ghost" size="sm">
<Link to="/library">{t('DASHBOARD.SHOW_ALL')}<ArrowRight/></Link>
</Button>
</div>
{topGames.length === 0 ? (
<p className="px-5 py-10 text-center text-sm text-muted-foreground">
{sync.isRunning ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')}
</p>
) : (
<ol className="divide-y">
{topGames.map((g, index) => (
<li key={g.appid}>
<Link
to={`/library/${g.appid}`}
className="group flex items-center gap-3 sm:gap-4 px-4 sm:px-5 py-3 transition-colors hover:bg-muted/50"
>
<span className="w-4 text-right text-sm font-semibold text-muted-foreground tabular-nums">
{index + 1}
</span>
<GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-16 sm:w-24 shrink-0"/>
<div className="min-w-0 flex-1">
<div className="truncate font-medium group-hover:text-primary transition-colors">
{g.name ?? `App ${g.appid}`}
</div>
<div className="mt-2 h-1.5 rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary"
style={{width: `${Math.max(2, (g.playtimeMinutes / maxMinutes) * 100)}%`}}
/>
</div>
</div>
<span className="w-12 sm:w-14 text-right text-sm font-medium text-muted-foreground tabular-nums">
{formatPlaytime(g.playtimeMinutes)}
</span>
</Link>
</li>
))}
</ol>
)}
</Card>
</div>
)}
{showLibrary && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<CurrentlyPlayingCard games={playing} syncing={sync.isRunning}/>
{summary && (
<PileOfShameCard summary={summary} trend={pileTrend.stats?.trend} syncing={sync.isRunning}
onShuffle={shuffleSuggestion} shuffling={shuffling}/>
)}
{!summary && summaryFailed && (
<Card className="items-center justify-center gap-3 p-6 text-center text-sm text-muted-foreground">
{t('DASHBOARD.SUMMARY_FAILED')}
<Button variant="outline" size="sm" onClick={() => fetchSummary()}>
<RefreshCw/>{t('DASHBOARD.RETRY')}
</Button>
</Card>
)}
</div>
)}
{hasSteamId && activity && <ActivityCard activity={activity}/>}
{hasSteamId && achievements && <AchievementsCard summary={achievements}/>}
<TileGrid tiles={DASHBOARD_TILES} titles={DASHBOARD_TITLES} layout={layout} renderTile={renderTile} editing={editing}
defaultLayout={DEFAULT_DASHBOARD} onSave={saveLayout} onCancel={() => setEditing(false)}/>
{/* Last child: space-y gives every other child a bottom margin, which would move the fixed card. */}
<DashboardHint/>
</PageContainer>
);
};
function MostPlayedCard({games, syncing}) {
const {t} = useTranslation();
const maxMinutes = games[0]?.playtimeMinutes || 1;
return (
<Card className="gap-0 py-0">
<div className="flex items-center justify-between px-5 py-4 border-b">
<h2 className="flex items-center gap-2 font-semibold">
<Trophy className="size-4 text-amber-500"/>
{t('DASHBOARD.MOST_PLAYED')}
</h2>
<Button asChild variant="ghost" size="sm">
<Link to="/library">{t('DASHBOARD.SHOW_ALL')}<ArrowRight/></Link>
</Button>
</div>
{games.length === 0 ? (
<p className="px-5 py-10 text-center text-sm text-muted-foreground">
{syncing ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')}
</p>
) : (
<ol className="divide-y">
{games.map((g, index) => (
<li key={g.appid}>
<Link
to={`/library/${g.appid}`}
className="group flex items-center gap-3 sm:gap-4 px-4 sm:px-5 py-3 transition-colors hover:bg-muted/50"
>
<span className="w-4 text-right text-sm font-semibold text-muted-foreground tabular-nums">
{index + 1}
</span>
<GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-16 sm:w-24 shrink-0"/>
<div className="min-w-0 flex-1">
<div className="truncate font-medium group-hover:text-primary transition-colors">
{g.name ?? `App ${g.appid}`}
</div>
<div className="mt-2 h-1.5 rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary"
style={{width: `${Math.max(2, (g.playtimeMinutes / maxMinutes) * 100)}%`}}
/>
</div>
</div>
<span className="w-12 sm:w-14 text-right text-sm font-medium text-muted-foreground tabular-nums">
{formatPlaytime(g.playtimeMinutes)}
</span>
</Link>
</li>
))}
</ol>
)}
</Card>
);
}
function DashboardSkeleton() {
return (
<PageContainer className="p-4 md:p-8 space-y-6">
+57 -4
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render, screen, waitFor} from '@testing-library/react';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter, Route, Routes, useLocation} from 'react-router-dom';
import Dashboard from './Dashboard.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
@@ -49,9 +49,13 @@ describe('Dashboard', () => {
});
const avatar = 'https://avatars.steamstatic.com/abc_full.jpg';
const mockDashboard = () => {
globalThis.fetch = vi.fn(async (url) => {
if (url.includes('/api/users/me')) return ok({email: 'a@b.de', displayName: 'Lobo', steamId: '76561198000000000'});
const games = Array.from({length: 12}, (_, i) => ({appid: 100 + i, name: `Game ${i}`, playtimeMinutes: 1000 - i}));
const mockDashboard = (settings) => {
globalThis.fetch = vi.fn(async (url, init) => {
if (init?.method === 'PUT') return {ok: true, status: 204, json: async () => ({})};
if (url.includes('/api/users/me')) return ok({email: 'a@b.de', displayName: 'Lobo', steamId: '76561198000000000', settings});
if (url.includes('sort=playtimeMinutes') && !url.includes('status=')) return ok({content: games, total: 12});
if (url.includes('/api/library/stats')) return ok({trend: null, habits: {moonTrips: 1, longestDay: null, underOneHourGames: 0, underOneHourShare: null}});
if (url.includes('/api/library/steam-profile')) return ok({personaName: 'Lobo', avatarUrl: avatar});
if (url.includes('/api/library/activity')) return ok({totalMinutes: 90, days: [{day: '2026-09-23', minutes: 0}, {day: '2026-09-24', minutes: 90}], recentGames: [
{appid: 1086940, name: "Baldur's Gate 3", playtimeMinutes: 900, playtime2Weeks: 90, status: 'PLAYING'}]});
@@ -74,6 +78,55 @@ describe('Dashboard', () => {
expect(container.querySelector('img[src$="/1086940/library_hero.jpg"]')).not.toBeNull();
});
const order = (container) => [...container.querySelectorAll('[data-tile]')].map((el) => el.dataset.tile);
it('shows today\'s tiles without a saved layout', async () => {
const container = mockDashboard();
await screen.findByText('DASHBOARD.ACHIEVEMENTS.TITLE');
expect(order(container)).toEqual(['GAME_COUNT', 'MOST_PLAYED', 'PLAYING', 'PILE_OF_SHAME', 'ACTIVITY', 'ACHIEVEMENTS']);
expect(screen.getByText('Game 4')).toBeTruthy();
expect(screen.queryByText('Game 5')).toBeNull();
});
it('shows a saved layout with its sizes and loads the stats of its stat tiles', async () => {
const container = mockDashboard({dashboardTiles: [{id: 'STATS_HABITS', size: 'L'}, {id: 'MOST_PLAYED', size: 'S'}]});
await screen.findByText('STATS.HABITS.TITLE');
expect(order(container)).toEqual(['STATS_HABITS', 'MOST_PLAYED']);
expect(screen.getByText('Game 2')).toBeTruthy();
expect(screen.queryByText('Game 3')).toBeNull();
// No pile tile, so no TREND.
expect(fetch).toHaveBeenCalledWith(expect.stringMatching(/\/api\/library\/stats\?groups=HABITS$/), expect.anything());
});
it('leaves out a stat tile whose data did not come', async () => {
const container = mockDashboard({dashboardTiles: [{id: 'STATS_PILE', size: 'M'}, {id: 'MOST_PLAYED', size: 'M'}]});
await screen.findByText('Game 0');
expect(order(container)).toEqual(['MOST_PLAYED']);
});
it('saves the default layout as "never changed"', async () => {
mockDashboard({dashboardTiles: [{id: 'MOST_PLAYED', size: 'S'}]});
fireEvent.click(await screen.findByRole('button', {name: 'TILES.CUSTOMIZE'}));
fireEvent.click(screen.getByRole('button', {name: 'TILES.RESET'}));
fireEvent.click(screen.getByRole('button', {name: 'TILES.DONE'}));
await waitFor(() => expect(fetch.mock.calls.some(([, init]) => init?.method === 'PUT')).toBe(true));
const [url, init] = fetch.mock.calls.find(([, i]) => i?.method === 'PUT');
expect(url).toMatch(/\/api\/users\/settings\/dashboard-tiles$/);
expect(JSON.parse(init.body)).toEqual({tiles: null});
expect(await screen.findByRole('button', {name: 'TILES.CUSTOMIZE'})).toBeTruthy();
});
it('puts "Customize" into the welcome card', async () => {
mockDashboard();
const button = await screen.findByRole('button', {name: 'TILES.CUSTOMIZE'});
expect(button.closest('[data-slot="card"]')?.textContent ?? 'not in a card').toContain('DASHBOARD.WELCOME_BACK');
});
it('shows the achievement summary', async () => {
mockDashboard();
+8 -8
View File
@@ -242,30 +242,30 @@ describe('GameDetail of a game added by hand', () => {
expect(screen.queryByText('MANUAL_GAME.TITLE')).toBeNull();
});
it('keeps a section hidden after the page loaded when it sets the profile art', async () => {
let sections = ['PLAYING'];
it('keeps a background set in another tab when it sets the profile banner', async () => {
let backgroundAppid = null;
globalThis.fetch = vi.fn(async (url, init) => ({ok: true, status: init?.method === 'PUT' ? 204 : 200,
json: async () => (url.includes('/api/users/me')
? {role: 'USER', settings: {profileStyle: {accent: 'red', corners: 'round', sections}}}
? {role: 'USER', settings: {profileStyle: {accent: 'red', corners: 'round', backgroundAppid}}}
: {...game, source: 'STEAM'})}));
renderDetail();
const menu = await screen.findByRole('button', {name: 'GAME_DETAIL.PROFILE_ART'});
sections = []; // the owner hides PLAYING in Settings, e.g. in a second tab
backgroundAppid = 620; // set in a second tab
fireEvent.keyDown(menu, {key: 'Enter'});
fireEvent.click(await screen.findByRole('menuitemcheckbox', {name: 'GAME_DETAIL.PROFILE_BANNER'}));
await waitFor(() => expect(fetch.mock.calls.some(([u, init]) => init?.method === 'PUT' && u.endsWith('/profile-style'))).toBe(true));
const sent = fetch.mock.calls.filter(([u, init]) => init?.method === 'PUT' && u.endsWith('/profile-style'))
.map(([, init]) => JSON.parse(init.body));
expect(sent.filter((body) => body.sections?.includes('PLAYING'))).toEqual([]);
expect(sent).toEqual([{accent: 'red', corners: 'round', bannerAppid: 220, backgroundAppid: 620}]);
});
it('keeps a banner picked in another tab when it sets this game as banner', async () => {
let bannerAppid = 220;
globalThis.fetch = vi.fn(async (url, init) => ({ok: true, status: init?.method === 'PUT' ? 204 : 200,
json: async () => (url.includes('/api/users/me')
? {role: 'USER', settings: {profileStyle: {bannerAppid, sections: ['PLAYING']}}}
? {role: 'USER', settings: {profileStyle: {bannerAppid}}}
: {...game, source: 'STEAM'})}));
renderDetail();
const menu = await screen.findByRole('button', {name: 'GAME_DETAIL.PROFILE_ART'});
@@ -280,7 +280,7 @@ describe('GameDetail of a game added by hand', () => {
});
it('uses the art as profile banner and removes it on the second click', async () => {
let stored = {accent: 'red', corners: 'round', sections: ['PLAYING']};
let stored = {accent: 'red', corners: 'round'};
globalThis.fetch = vi.fn(async (url, init) => {
if (init?.method === 'PUT') stored = JSON.parse(init.body);
return {ok: true, status: init?.method === 'PUT' ? 204 : 200,
@@ -296,7 +296,7 @@ describe('GameDetail of a game added by hand', () => {
await openMenu();
fireEvent.click(await screen.findByRole('menuitemcheckbox', {name: 'GAME_DETAIL.PROFILE_BANNER'}));
await waitFor(() => expect(styleBodies()).toEqual([{accent: 'red', corners: 'round', bannerAppid: 220,
backgroundAppid: null, sections: ['PLAYING']}]));
backgroundAppid: null}]));
await openMenu();
const banner = await screen.findByRole('menuitemcheckbox', {name: 'GAME_DETAIL.PROFILE_BANNER'});
+54 -36
View File
@@ -1,24 +1,30 @@
import {useEffect, useState} from 'react';
import {Link, useParams} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {CircleCheck, Clock, Gamepad2, Library, Percent, Star, Trophy, UserRoundX} from 'lucide-react';
import {CircleCheck, Clock, Gamepad2, LayoutGrid, Library, Percent, Star, Trophy, UserRoundX} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {Skeleton} from '@/components/ui/skeleton';
import {cn} from '@/lib/utils';
import GameCover from '../components/GameCover.jsx';
import GameStatusBadge, {STATUS_META} from '../components/GameStatusBadge.jsx';
import {GAME_STATUSES} from '../utils/backlog.js';
import {formatPlaytime} from '../utils/games.js';
import {hasSession} from '../utils/auth.js';
import {normalizeProfileStyle} from '../utils/profileStyle.js';
import {DEFAULT_PROFILE, PROFILE_TILES, PROFILE_TITLES, normalizeLayout, saveTiles} from '../utils/tiles.js';
import useCurrentUser from '../hooks/useCurrentUser.js';
import useSaveResponse from '../hooks/useSaveResponse.js';
import HeroBanner from '../components/HeroBanner.jsx';
import PageBackground from '../components/PageBackground.jsx';
import PageContainer from '../components/PageContainer.jsx';
import PileStats from '../components/stats/PileStats.jsx';
import TrendStats from '../components/stats/TrendStats.jsx';
import TopGamesStats from '../components/stats/TopGamesStats.jsx';
import TasteStats from '../components/stats/TasteStats.jsx';
import HabitsStats from '../components/stats/HabitsStats.jsx';
import FinishedStats from '../components/stats/FinishedStats.jsx';
import TileGrid from '../components/TileGrid.jsx';
import statGroupTile from '../components/stats/statGroupTile.jsx';
// Games per tile size; Core sends at most 8 playing and 12 finished games.
const PLAYING_GAMES = {S: 4, M: 8};
const FINISHED_GAMES = {S: 6, M: 12};
/**
* Public, no login. Auth returns a short-lived ticket for a public profile; Core returns the data for it.
@@ -29,6 +35,10 @@ export default function PublicProfilePage() {
const [profile, setProfile] = useState(null);
const [library, setLibrary] = useState(null);
const [error, setError] = useState(null);
const [editing, setEditing] = useState(false);
const [reloads, setReloads] = useState(0);
const viewer = useCurrentUser(hasSession());
const runSave = useSaveResponse();
useEffect(() => {
let cancelled = false;
@@ -70,7 +80,7 @@ export default function PublicProfilePage() {
return () => {
cancelled = true;
};
}, [name, t]);
}, [name, t, reloads]);
if (error) {
return (
@@ -97,13 +107,31 @@ export default function PublicProfilePage() {
const memberSince = profile.memberSince
? new Date(`${profile.memberSince}T12:00:00`).toLocaleDateString(i18n.language, {year: 'numeric', month: 'long'})
: null;
const sections = {
SUMMARY: library.totalGames != null && <Summary library={library}/>,
STATUS: library.byStatus && Object.values(library.byStatus).some((count) => count > 0)
&& <StatusBar byStatus={library.byStatus}/>,
PLAYING: library.playing?.length > 0 && <Playing games={library.playing}/>,
FINISHED: library.finished?.length > 0 && <Finished games={library.finished}/>,
STATS: library.stats && <SharedStats stats={library.stats}/>,
const layout = normalizeLayout(profile.style?.tiles, PROFILE_TILES, DEFAULT_PROFILE);
const owner = viewer?.displayName?.toLowerCase() === profile.displayName.toLowerCase();
const renderTile = (id, size) => {
switch (id) {
case 'SUMMARY': return library.totalGames != null && <Summary library={library} compact={size === 'S'}/>;
case 'STATUS':
return library.byStatus && Object.values(library.byStatus).some((count) => count > 0)
&& <StatusBar byStatus={library.byStatus}/>;
case 'PLAYING':
return library.playing?.length > 0
&& <Playing games={library.playing.slice(0, PLAYING_GAMES[size])} compact={size === 'S'}/>;
case 'FINISHED':
return library.finished?.length > 0 && <Finished games={library.finished.slice(0, FINISHED_GAMES[size])}/>;
default: return statGroupTile(id, library.stats, false);
}
};
// Core sends only the data of the shown tiles (the ticket says which), so a new tile needs a new load.
const saveLayout = async (next) => {
if (!await runSave(() => saveTiles('profile-tiles', next))) return;
setEditing(false);
if (next.some((tile) => !layout.some((shown) => shown.id === tile.id))) {
setReloads((count) => count + 1);
} else {
setProfile((p) => ({...p, style: {...p.style, tiles: next}}));
}
};
// The owner's accent and corners apply to this page only; light or dark mode stays the viewer's.
@@ -120,16 +148,20 @@ export default function PublicProfilePage() {
<p className="mt-2 text-sm text-muted-foreground">{t('PROFILE.MEMBER_SINCE', {date: memberSince})}</p>
)}
</div>
{owner && !editing && (
<Button variant="outline" size="sm" className="relative mt-4" onClick={() => setEditing(true)}>
<LayoutGrid/>{t('TILES.CUSTOMIZE')}
</Button>
)}
</Card>
{style.sections.map((section) => sections[section] && (
<section key={section} data-section={section}>{sections[section]}</section>
))}
<TileGrid tiles={PROFILE_TILES} titles={PROFILE_TITLES} layout={layout} renderTile={renderTile} editing={editing}
defaultLayout={DEFAULT_PROFILE} onSave={saveLayout} onCancel={() => setEditing(false)}/>
</PageContainer>
</div>
);
}
function Summary({library}) {
function Summary({library, compact}) {
const {t, i18n} = useTranslation();
const stats = [
[Library, library.totalGames.toLocaleString(i18n.language), t('PROFILE.GAMES')],
@@ -143,7 +175,7 @@ function Summary({library}) {
);
}
return (
<Card className={`grid gap-3 p-5 ${stats.length > 3 ? 'grid-cols-2 sm:grid-cols-5' : 'grid-cols-3'}`}>
<Card className={cn('grid gap-3 p-5', stats.length > 3 && !compact ? 'grid-cols-2 sm:grid-cols-5' : 'grid-cols-3')}>
{stats.map(([Icon, value, label]) => (
<div key={label} className="rounded-lg bg-muted/50 p-3 text-center">
<Icon className="mx-auto mb-1 size-4 text-primary"/>
@@ -178,14 +210,14 @@ function StatusBar({byStatus}) {
);
}
function Playing({games}) {
function Playing({games, compact}) {
const {t} = useTranslation();
return (
<Card className="gap-4 p-5">
<h2 className="flex items-center gap-2 font-semibold">
<Gamepad2 className="size-4 text-primary"/>{t('PROFILE.PLAYING')}
</h2>
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<ul className={cn('grid grid-cols-2 gap-3', !compact && 'sm:grid-cols-4')}>
{games.map((g) => (
<li key={g.appid} className="space-y-1.5">
<GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-full"/>
@@ -224,17 +256,3 @@ function Finished({games}) {
</Card>
);
}
// Only the groups the user shares come back; the game rows do not link, the library pages are private.
function SharedStats({stats}) {
return (
<div className="grid gap-6 md:grid-cols-2">
{stats.pile && <PileStats pile={stats.pile} linked={false}/>}
{stats.trend && <TrendStats trend={stats.trend}/>}
{stats.topGames && <TopGamesStats topGames={stats.topGames} linked={false}/>}
{stats.taste && <TasteStats taste={stats.taste}/>}
{stats.habits && <HabitsStats habits={stats.habits}/>}
{stats.finished && <FinishedStats finished={stats.finished}/>}
</div>
);
}
+84 -9
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render, screen, waitFor} from '@testing-library/react';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter, Route, Routes} from 'react-router-dom';
import PublicProfilePage from './PublicProfilePage.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
@@ -14,8 +14,10 @@ const library = {
finished: [{appid: 620, name: 'Portal 2', playtimeMinutes: 900, status: 'COMPLETED', rating: 5}],
};
function mockBackends({profileStatus = 200, libraryStatus = 200, style, answer = library} = {}) {
globalThis.fetch = vi.fn(async (url) => {
function mockBackends({profileStatus = 200, libraryStatus = 200, style, answer = library, me} = {}) {
globalThis.fetch = vi.fn(async (url, init) => {
if (init?.method === 'PUT') return {ok: true, status: 204, json: async () => ({})};
if (url.includes('/api/users/me')) return {ok: !!me, status: me ? 200 : 401, json: async () => me};
if (url.includes('/api/public/profiles/library')) {
return {ok: libraryStatus === 200, status: libraryStatus, json: async () => answer};
}
@@ -65,7 +67,7 @@ describe('PublicProfilePage', () => {
it('shows only the stat groups the user shares, without links', async () => {
library.stats = {pile: null, trend: null, topGames: null, taste: null, finished: null,
habits: {moonTrips: 2.5, longestDay: null, underOneHourGames: 0, underOneHourShare: null}};
mockBackends();
mockBackends({style: {tiles: [{id: 'STATS_HABITS', size: 'M'}, {id: 'STATS_PILE', size: 'M'}]}});
renderPage();
expect(await screen.findByText('STATS.HABITS.TITLE')).toBeTruthy();
@@ -75,7 +77,7 @@ describe('PublicProfilePage', () => {
});
it('applies the owner\'s accent and corners to the page only, with banner and background', async () => {
mockBackends({style: {accent: 'red', corners: 'round', bannerAppid: 620, backgroundAppid: 220, sections: ['PLAYING']}});
mockBackends({style: {accent: 'red', corners: 'round', bannerAppid: 620, backgroundAppid: 220, tiles: [{id: 'PLAYING', size: 'M'}]}});
const {container} = renderPage();
await screen.findByText('Half-Life 2');
@@ -88,13 +90,13 @@ describe('PublicProfilePage', () => {
it('shows the sections in the owner\'s order and leaves out hidden ones', async () => {
mockBackends({
style: {sections: ['FINISHED', 'PLAYING', 'SUMMARY']},
style: {tiles: [{id: 'FINISHED', size: 'M'}, {id: 'PLAYING', size: 'S'}, {id: 'SUMMARY', size: 'M'}]},
answer: {...library, byStatus: null, finishedGames: 60},
});
const {container} = renderPage();
await screen.findByText('Portal 2');
expect([...container.querySelectorAll('[data-section]')].map((el) => el.dataset.section))
expect([...container.querySelectorAll('[data-tile]')].map((el) => el.dataset.tile))
.toEqual(['FINISHED', 'PLAYING', 'SUMMARY']);
expect(screen.getByText('60')).toBeTruthy();
expect(screen.queryByText('BACKLOG.STATUS.BACKLOG')).toBeNull();
@@ -105,7 +107,7 @@ describe('PublicProfilePage', () => {
const {container} = renderPage();
await screen.findByText('PROFILE.GAMES');
expect([...container.querySelectorAll('[data-section]')].map((el) => el.dataset.section)).toEqual(['SUMMARY']);
expect([...container.querySelectorAll('[data-tile]')].map((el) => el.dataset.tile)).toEqual(['SUMMARY']);
});
it('shows every section for an Auth without a style', async () => {
@@ -113,11 +115,84 @@ describe('PublicProfilePage', () => {
const {container} = renderPage();
await screen.findByText('Portal 2');
expect([...container.querySelectorAll('[data-section]')].map((el) => el.dataset.section))
expect([...container.querySelectorAll('[data-tile]')].map((el) => el.dataset.tile))
.toEqual(['SUMMARY', 'STATUS', 'PLAYING', 'FINISHED']);
expect(container.querySelector('[data-accent="violet"]')).toBeTruthy();
});
it('shows the small playing tile with half the games and at half width', async () => {
const games = Array.from({length: 8}, (_, i) => ({appid: 100 + i, name: `Game ${i}`, playtimeMinutes: 60, status: 'PLAYING'}));
mockBackends({style: {tiles: [{id: 'PLAYING', size: 'S'}]}, answer: {...library, playing: games}});
const {container} = renderPage();
await screen.findByText('Game 0');
expect(screen.queryByText('Game 4')).toBeNull();
expect(container.querySelector('[data-tile="PLAYING"]').className).toContain('md:col-span-6');
});
const libraryLoads = () => fetch.mock.calls.filter(([url]) => url.includes('/api/public/profiles/library')).length;
const putBody = () => JSON.parse(fetch.mock.calls.find(([, init]) => init?.method === 'PUT')[1].body);
it('offers "Customize" to the owner and hides a tile without a new load', async () => {
localStorage.setItem('tbp_session', '1');
try {
mockBackends({me: {displayName: 'lobo', role: 'USER'}});
const {container} = renderPage();
fireEvent.click(await screen.findByRole('button', {name: 'TILES.CUSTOMIZE'}));
// The test texts carry no tile name; the first tile is SUMMARY.
fireEvent.click(screen.getAllByRole('button', {name: 'TILES.HIDE'})[0]);
fireEvent.click(screen.getByRole('button', {name: 'TILES.DONE'}));
expect(await screen.findByRole('button', {name: 'TILES.CUSTOMIZE'})).toBeTruthy();
expect(fetch.mock.calls.find(([, init]) => init?.method === 'PUT')[0]).toMatch(/\/api\/users\/settings\/profile-tiles$/);
expect(putBody().tiles.map((tile) => tile.id)).toEqual(['STATUS', 'PLAYING', 'FINISHED']);
expect([...container.querySelectorAll('[data-tile]')].map((el) => el.dataset.tile)).not.toContain('SUMMARY');
expect(libraryLoads()).toBe(1);
} finally {
localStorage.clear();
}
});
it('loads again after adding a tile, because Core sent no data for it', async () => {
localStorage.setItem('tbp_session', '1');
try {
mockBackends({me: {displayName: 'Lobo', role: 'USER'}});
renderPage();
fireEvent.click(await screen.findByRole('button', {name: 'TILES.CUSTOMIZE'}));
fireEvent.click(screen.getByRole('button', {name: 'STATS.PILE.TITLE'}));
fireEvent.click(screen.getByRole('button', {name: 'TILES.DONE'}));
await waitFor(() => expect(libraryLoads()).toBe(2));
expect(putBody().tiles.at(-1)).toEqual({id: 'STATS_PILE', size: 'M'});
} finally {
localStorage.clear();
}
});
it('shows no "Customize" to a visitor', async () => {
localStorage.setItem('tbp_session', '1');
try {
mockBackends({me: {displayName: 'Someone', role: 'USER'}});
renderPage();
await screen.findByText('Portal 2');
await waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/users/me'), expect.anything()));
expect(screen.queryByRole('button', {name: 'TILES.CUSTOMIZE'})).toBeNull();
} finally {
localStorage.clear();
}
});
it('keeps three numbers in a row on a phone', async () => {
mockBackends();
const {container} = renderPage();
await screen.findByText('PROFILE.GAMES');
const summary = container.querySelector('[data-tile="SUMMARY"] [data-slot="card"]');
expect(summary.className.split(' ')).toContain('grid-cols-3');
});
it('says the profile is unknown or private on a 404', async () => {
mockBackends({profileStatus: 404});
renderPage();
+4 -31
View File
@@ -18,7 +18,6 @@ import BuildVersionCard from '../components/BuildVersionCard.jsx';
import DeleteAccountCard from '../components/DeleteAccountCard.jsx';
import FormField from '../components/FormField.jsx';
import SteamAccountCard from '../components/SteamAccountCard.jsx';
import ProfileStatsSettings from '../components/stats/ProfileStatsSettings.jsx';
import {settingsSchema} from '../utils/validation.js';
import {usePreferences} from '../context/PreferencesContext.jsx';
import useSessionExpired from '../hooks/useSessionExpired.js';
@@ -28,7 +27,6 @@ import ProfileStyleSettings from '../components/ProfileStyleSettings.jsx';
import {normalizeProfileStyle} from '../utils/profileStyle.js';
import useSaveProfileStyle from '../hooks/useSaveProfileStyle.js';
import {coverUrl} from '../utils/games.js';
import {saveProfileStats} from '../utils/stats.js';
import {apiFetch, refreshSession} from '../utils/api.js';
import {hasSession, markSession} from '../utils/auth.js';
@@ -66,7 +64,6 @@ export default function SettingsPage() {
const [profile, setProfile] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState(null);
const [savingProfileStats, setSavingProfileStats] = useState(false);
const [savingProfileStyle, setSavingProfileStyle] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
// Read once: removing the parameter below must not load the profile again.
@@ -206,31 +203,7 @@ export default function SettingsPage() {
}
};
// Shown at once; the switches wait for the answer, so the saves reach Auth one after the other.
const handleProfileStatsChange = async (groups) => {
const previous = profile?.settings?.profileStats ?? [];
const showProfileStats = (next) => setProfile((p) => ({...p, settings: {...p.settings, profileStats: next}}));
showProfileStats(groups);
setSavingProfileStats(true);
try {
const response = await saveProfileStats(groups);
if (response.status === 401) {
expireSession();
return;
}
if (!response.ok) {
showProfileStats(previous);
toast.error(t('SETTINGS.ERROR_SAVE'));
}
} catch (err) {
console.error('Profile stats save error:', err);
showProfileStats(previous);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setSavingProfileStats(false);
}
};
// Shown at once; the pickers wait for the answer, so the saves reach Auth one after the other.
const handleProfileStyleChange = async (changes) => {
const previous = normalizeProfileStyle(profile?.settings?.profileStyle);
const showProfileStyle = (next) => setProfile((p) => ({...p, settings: {...p.settings, profileStyle: next}}));
@@ -447,9 +420,9 @@ export default function SettingsPage() {
</Button>
</div>
)}
<ProfileStatsSettings selected={profile?.settings?.profileStats ?? []}
disabled={!publicProfile} saving={savingProfileStats}
onChange={handleProfileStatsChange}/>
{publicProfile && profileLink && (
<p className="text-xs text-muted-foreground">{t('SETTINGS.PROFILE_TILES_HINT')}</p>
)}
</div>
<div className="mt-6 border-t pt-6">
+7 -66
View File
@@ -57,80 +57,27 @@ describe('SettingsPage public profile', () => {
await waitFor(() => expect(screen.queryByText(/\/u\/Lobo$/)).toBeNull());
});
it('saves the stat groups the public profile shows, in a fixed order', async () => {
renderPage(profile({settings: {useDarkTheme: true, language: 'de', publicProfile: true, profileStats: ['TREND']}}));
fireEvent.click(await screen.findByRole('switch', {name: 'STATS.PILE.TITLE'}));
await waitFor(() => expect(puts()).toHaveLength(1));
expect(puts()[0]).toEqual({groups: ['PILE', 'TREND']});
const [url] = fetch.mock.calls.find(([, init]) => init?.method === 'PUT');
expect(url).toMatch(/\/api\/users\/settings\/profile-stats$/);
await waitFor(() => expect(screen.getByRole('switch', {name: 'STATS.PILE.TITLE'}).getAttribute('aria-checked'))
.toBe('true'));
});
it('waits for a save before the next switch click', async () => {
const answers = [];
const me = profile({settings: {useDarkTheme: true, language: 'de', publicProfile: true, profileStats: []}});
globalThis.fetch = vi.fn((url, init) => (init?.method === 'PUT'
? new Promise((resolve) => answers.push(() => resolve({ok: true, status: 204, json: async () => ({})})))
: Promise.resolve({ok: true, status: 200, json: async () => me})));
render(<MemoryRouter><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>);
fireEvent.click(await screen.findByRole('switch', {name: 'STATS.PILE.TITLE'}));
await waitFor(() => expect(screen.getByRole('switch', {name: 'STATS.TASTE.TITLE'}).disabled).toBe(true));
answers.forEach((answer) => answer());
await waitFor(() => expect(screen.getByRole('switch', {name: 'STATS.TASTE.TITLE'}).disabled).toBe(false));
fireEvent.click(screen.getByRole('switch', {name: 'STATS.TASTE.TITLE'}));
await waitFor(() => expect(puts()).toHaveLength(2));
expect(puts()[1]).toEqual({groups: ['PILE', 'TASTE']});
});
it('switches the group back when the save fails', async () => {
const me = profile({settings: {useDarkTheme: true, language: 'de', publicProfile: true, profileStats: []}});
globalThis.fetch = vi.fn(async (url, init) => (init?.method === 'PUT'
? {ok: false, status: 500, json: async () => ({})}
: {ok: true, status: 200, json: async () => me}));
render(<MemoryRouter><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>);
fireEvent.click(await screen.findByRole('switch', {name: 'STATS.PILE.TITLE'}));
await waitFor(() => expect(screen.getByRole('switch', {name: 'STATS.PILE.TITLE'}).getAttribute('aria-checked'))
.toBe('false'));
});
const styled = (profileStyle) => profile({settings: {useDarkTheme: true, language: 'de', publicProfile: true, profileStyle}});
const styleGroup = (label) => within(screen.getAllByRole('group', {name: label})[1]);
const stylePuts = () => fetch.mock.calls.filter(([url, init]) => init?.method === 'PUT' && url.endsWith('/profile-style'))
.map(([, init]) => JSON.parse(init.body));
it('saves the profile accent apart from the app accent', async () => {
renderPage(styled({accent: 'blue', corners: 'sharp', bannerAppid: 620, backgroundAppid: null,
sections: ['PLAYING', 'SUMMARY']}));
renderPage(styled({accent: 'blue', corners: 'sharp', bannerAppid: 620, backgroundAppid: null}));
await screen.findByText('SETTINGS.PROFILE_STYLE');
fireEvent.click(styleGroup('SETTINGS.ACCENT').getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.RED'}));
await waitFor(() => expect(stylePuts()).toEqual([{accent: 'red', corners: 'sharp', bannerAppid: 620,
backgroundAppid: null, sections: ['PLAYING', 'SUMMARY']}]));
backgroundAppid: null}]));
expect(styleGroup('SETTINGS.ACCENT').getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.RED'})
.getAttribute('aria-pressed')).toBe('true');
});
it('moves, hides and shows profile sections', async () => {
renderPage(styled({sections: ['PLAYING', 'SUMMARY']}));
it('points to the profile page for the tiles', async () => {
renderPage(profile());
await screen.findByText('SETTINGS.PROFILE_STYLE');
// The first row is the first shown section, PLAYING.
fireEvent.click(screen.getAllByRole('button', {name: 'SETTINGS.PROFILE_SECTION_DOWN'})[0]);
await waitFor(() => expect(stylePuts()).toHaveLength(1));
expect(stylePuts()[0].sections).toEqual(['SUMMARY', 'PLAYING']);
fireEvent.click(screen.getByRole('switch', {name: 'SETTINGS.PROFILE_SECTION.STATS'}));
await waitFor(() => expect(stylePuts()).toHaveLength(2));
expect(stylePuts()[1].sections).toEqual(['SUMMARY', 'PLAYING', 'STATS']);
expect(await screen.findByText('SETTINGS.PROFILE_TILES_HINT')).toBeTruthy();
});
it('removes the profile banner', async () => {
@@ -145,17 +92,11 @@ describe('SettingsPage public profile', () => {
it('offers the profile look only while the profile is public', async () => {
renderPage(profile({settings: {useDarkTheme: true, language: 'de', publicProfile: false}}));
expect((await screen.findByRole('switch', {name: 'SETTINGS.PROFILE_SECTION.STATS'})).disabled).toBe(true);
await screen.findByText('SETTINGS.PROFILE_STYLE');
expect(screen.queryByText('SETTINGS.PROFILE_TILES_HINT')).toBeNull();
expect(styleGroup('SETTINGS.ACCENT').getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.RED'}).disabled).toBe(true);
});
it('offers the stat groups only while the profile is public', async () => {
renderPage(profile({settings: {useDarkTheme: true, language: 'de', publicProfile: false}}));
expect((await screen.findByRole('switch', {name: 'STATS.PILE.TITLE'})).disabled).toBe(true);
expect(screen.getByText('SETTINGS.PROFILE_STATS_NEEDS_PUBLIC')).toBeTruthy();
});
it('needs a display name first', async () => {
renderPage(profile({displayName: null, settings: {useDarkTheme: true, language: 'de', publicProfile: false}}));
-7
View File
@@ -1,12 +1,8 @@
import {apiFetch} from './api.js';
import {ACCENTS, CORNERS, DEFAULT_APPEARANCE, appid} from './appearance.js';
// Auth accepts only these names (ProfileStyleRequest); Core sends no data for a section that is not listed.
export const PROFILE_SECTIONS = ['SUMMARY', 'STATUS', 'PLAYING', 'FINISHED', 'STATS'];
export const DEFAULT_PROFILE_STYLE = {
accent: DEFAULT_APPEARANCE.accent, corners: DEFAULT_APPEARANCE.corners, bannerAppid: null, backgroundAppid: null,
sections: PROFILE_SECTIONS,
};
export const normalizeProfileStyle = (value) => ({
@@ -14,9 +10,6 @@ export const normalizeProfileStyle = (value) => ({
corners: CORNERS.includes(value?.corners) ? value.corners : DEFAULT_PROFILE_STYLE.corners,
bannerAppid: appid(value?.bannerAppid),
backgroundAppid: appid(value?.backgroundAppid),
sections: Array.isArray(value?.sections)
? value.sections.filter((s, i) => PROFILE_SECTIONS.includes(s) && value.sections.indexOf(s) === i)
: PROFILE_SECTIONS,
});
/**
+3 -8
View File
@@ -6,13 +6,8 @@ describe('normalizeProfileStyle', () => {
expect(normalizeProfileStyle(undefined)).toEqual(DEFAULT_PROFILE_STYLE);
});
it('keeps the order, drops unknown and repeated sections and bad values', () => {
expect(normalizeProfileStyle({accent: 'hotpink', corners: 'round', bannerAppid: -5, backgroundAppid: 620,
sections: ['STATS', 'NOTES', 'PLAYING', 'STATS']}))
.toEqual({accent: 'violet', corners: 'round', bannerAppid: null, backgroundAppid: 620, sections: ['STATS', 'PLAYING']});
});
it('keeps an empty list: every section hidden', () => {
expect(normalizeProfileStyle({sections: []}).sections).toEqual([]);
it('drops bad values', () => {
expect(normalizeProfileStyle({accent: 'hotpink', corners: 'round', bannerAppid: -5, backgroundAppid: 620}))
.toEqual({accent: 'violet', corners: 'round', bannerAppid: null, backgroundAppid: 620});
});
});
-15
View File
@@ -1,18 +1,3 @@
import {apiFetch} from './api.js';
// The groups a user can show on the public profile; the overview numbers are always there.
export const PROFILE_STAT_GROUPS = ['PILE', 'TREND', 'TOP_GAMES', 'TASTE', 'HABITS', 'FINISHED'];
/**
* PUT /api/users/settings/profile-stats on Auth. The caller handles the response status.
*/
export const saveProfileStats = (groups) =>
apiFetch(`${import.meta.env.VITE_API_URL}/api/users/settings/profile-stats`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({groups}),
});
/**
* @param groups comma separated stat groups of Core (StatGroup); empty for all
*/
+91
View File
@@ -0,0 +1,91 @@
import {apiFetch} from './api.js';
/**
* The stat group of Core a STATS_ tile shows, e.g. STATS_TOP_GAMES -> TOP_GAMES; null for any other tile.
*/
export function statGroup(id) {
return id.startsWith('STATS_') ? id.slice('STATS_'.length) : null;
}
// Tile id -> its column span per size, in a 12 column grid. A size not listed is not offered. M is the look from
// before the layouts. Auth accepts only these ids (DashboardTilesRequest, ProfileTilesRequest).
const STAT_TILES = {
STATS_PILE: {M: 6, L: 12},
STATS_TREND: {M: 6, L: 12},
STATS_TOP_GAMES: {M: 6, L: 12},
STATS_TASTE: {M: 6, L: 12},
STATS_HABITS: {M: 6, L: 12},
STATS_FINISHED: {M: 6, L: 12},
};
export const DASHBOARD_TILES = {
GAME_COUNT: {S: 2, M: 4, L: 8},
MOST_PLAYED: {S: 4, M: 8, L: 12},
PLAYING: {S: 3, M: 6, L: 12},
PILE_OF_SHAME: {M: 6, L: 12},
ACTIVITY: {S: 6, M: 12},
ACHIEVEMENTS: {S: 6, M: 12},
STATS_OVERVIEW: {M: 12},
...STAT_TILES,
};
export const PROFILE_TILES = {
SUMMARY: {S: 6, M: 12},
STATUS: {S: 6, M: 12},
PLAYING: {S: 6, M: 12},
FINISHED: {S: 6, M: 12},
...STAT_TILES,
};
// The i18n key of each tile's name in the edit mode: the heading of its card where it has one.
const STAT_TITLES = Object.fromEntries(Object.keys(STAT_TILES).map((id) => [id, `STATS.${statGroup(id)}.TITLE`]));
export const DASHBOARD_TITLES = {
GAME_COUNT: 'DASHBOARD.STEAM_GAMES', MOST_PLAYED: 'DASHBOARD.MOST_PLAYED', PLAYING: 'DASHBOARD.CURRENTLY_PLAYING',
PILE_OF_SHAME: 'DASHBOARD.PILE_OF_SHAME', ACTIVITY: 'DASHBOARD.ACTIVITY', ACHIEVEMENTS: 'DASHBOARD.ACHIEVEMENTS.TITLE',
STATS_OVERVIEW: 'STATS.OVERVIEW.TITLE', ...STAT_TITLES,
};
export const PROFILE_TITLES = {
SUMMARY: 'TILES.SUMMARY', STATUS: 'TILES.STATUS', PLAYING: 'PROFILE.PLAYING', FINISHED: 'PROFILE.FINISHED_GAMES',
...STAT_TITLES,
};
const all = (ids) => ids.map((id) => ({id, size: 'M'}));
export const DEFAULT_DASHBOARD = all(['GAME_COUNT', 'MOST_PLAYED', 'PLAYING', 'PILE_OF_SHAME', 'ACTIVITY', 'ACHIEVEMENTS']);
export const DEFAULT_PROFILE = all(['SUMMARY', 'STATUS', 'PLAYING', 'FINISHED']);
export const SIZES = ['S', 'M', 'L'];
// Written out, so Tailwind finds every class.
const SPANS = {
2: 'md:col-span-2', 3: 'md:col-span-3', 4: 'md:col-span-4', 6: 'md:col-span-6', 8: 'md:col-span-8',
12: 'md:col-span-12',
};
export const spanClass = (tiles, {id, size}) => SPANS[tiles[id][size]];
/**
* A saved layout as the page can use it: unknown and repeated ids are left out, a size the tile does not offer
* becomes M. A saved layout of a tile removed later so breaks nothing.
*
* @param saved list of {id, size}; null or not a list gives the fallback
*/
export const normalizeLayout = (saved, tiles, fallback) => {
if (!Array.isArray(saved)) return fallback;
const seen = new Set();
return saved.filter((tile) => tiles[tile?.id] && !seen.has(tile.id) && seen.add(tile.id))
.map(({id, size}) => ({id, size: tiles[id][size] ? size : 'M'}));
};
/**
* @param path dashboard-tiles or profile-tiles
* @param layout null resets the dashboard to its default
*/
export const saveTiles = (path, layout) =>
apiFetch(`${import.meta.env.VITE_API_URL}/api/users/settings/${path}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({tiles: layout}),
});
+27
View File
@@ -0,0 +1,27 @@
import {describe, expect, it} from 'vitest';
import {DASHBOARD_TILES, DEFAULT_DASHBOARD, normalizeLayout, spanClass} from './tiles.js';
describe('normalizeLayout', () => {
it('is the fallback without a saved list', () => {
expect(normalizeLayout(null, DASHBOARD_TILES, DEFAULT_DASHBOARD)).toBe(DEFAULT_DASHBOARD);
expect(normalizeLayout(undefined, DASHBOARD_TILES, DEFAULT_DASHBOARD)).toBe(DEFAULT_DASHBOARD);
});
it('drops unknown and repeated tiles and makes a size the tile does not offer M', () => {
expect(normalizeLayout([
{id: 'STATS_TREND', size: 'L'}, {id: 'WISHLIST', size: 'M'}, {id: 'PILE_OF_SHAME', size: 'S'},
{id: 'STATS_TREND', size: 'M'}, null,
], DASHBOARD_TILES, DEFAULT_DASHBOARD)).toEqual([{id: 'STATS_TREND', size: 'L'}, {id: 'PILE_OF_SHAME', size: 'M'}]);
});
it('keeps an empty list: every tile hidden', () => {
expect(normalizeLayout([], DASHBOARD_TILES, DEFAULT_DASHBOARD)).toEqual([]);
});
});
describe('spanClass', () => {
it('halves and doubles the normal width', () => {
expect(['S', 'M', 'L'].map((size) => spanClass(DASHBOARD_TILES, {id: 'MOST_PLAYED', size})))
.toEqual(['md:col-span-4', 'md:col-span-8', 'md:col-span-12']);
});
});