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
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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'});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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}),
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user