Compare commits

..
4 Commits
Author SHA1 Message Date
lobo aa9e83e0cb feat(tiles): let users arrange the dashboard and profile tiles
Build, Test & Push Frontend / docker (push) Successful in 28s
Build, Test & Push Frontend / quality-check (push) Successful in 18s
2026-09-28 15:03:16 +02:00
lobo b55fcfec92 feat(profile): let users design their public profile 2026-09-28 14:20:14 +02:00
lobo a5458b3f86 feat(game): play confetti for finished games 2026-09-28 13:36:36 +02:00
lobo 09e1f767d8 docs(readme): describe the local setup 2026-09-28 13:07:40 +02:00
36 changed files with 1473 additions and 300 deletions

No files matched your search

+29 -2
View File
@@ -19,7 +19,9 @@ To Be Played is split into three repositories:
## Features ## 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, 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 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 %. total, perfect games, average completion and the 5 games closest to 100 %.
@@ -37,6 +39,9 @@ To Be Played is split into three repositories:
a link to the game's speedrun.com page when it has verified runs, a hint when Steam shows recent playtime for a a link to the game's speedrun.com page when it has verified runs, a hint when Steam shows recent playtime for a
game still in the backlog, and one to set the status to 100 % when game still in the backlog, and one to set the status to 100 % when
every achievement is unlocked. every achievement is unlocked.
- **Confetti**: opening a finished game plays a small confetti burst, a finished game with every achievement a big
one; setting the status to finished plays it at once. Logo and accent colors, a canvas of our own (no library),
nothing with "reduce motion".
- **Finished?**: Core marks a game as finished by itself when the ending achievement is unlocked and the status was - **Finished?**: Core marks a game as finished by itself when the ending achievement is unlocked and the status was
never set by hand. Otherwise the game page asks: "You unlocked the ending, finished?" or "You played longer than never set by hand. Otherwise the game page asks: "You unlocked the ending, finished?" or "You played longer than
most players need, finished?", with "Mark as finished" and "Not yet". most players need, finished?", with "Mark as finished" and "Not yet".
@@ -47,7 +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 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. verify it; unlinking is possible.
- **Public profile** at `/u/<display name>`, opt-in in the settings, with perfect games and average completion. - **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). 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. - **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. 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 - **Broken pictures**: a Steam cover or achievement icon that does not load shows a placeholder and is reported
@@ -130,6 +137,26 @@ Tests sit next to the code they test (`Component.test.jsx`).
## Run locally ## Run locally
### Local development without keys (profile `local`)
The whole stack runs on your machine with no API keys and no calls to Steam, Twitch/IGDB or speedrun.com. Needs
Java 25, Node 24 and Docker. Clone `ToBePlayed-Auth`, `ToBePlayed-Core` and `ToBePlayed-Frontend` next to each
other, then start them in this order (Core also starts without Auth and finds it later):
1. **Auth**: in `ToBePlayed-Auth`, `./mvnw spring-boot:run -Dspring-boot.run.profiles=local`
(IDE: active profile `local`). Port 8080.
2. **Core**: in `ToBePlayed-Core`, `./mvnw spring-boot:run -Dspring-boot.run.profiles=local`. Port 8090.
3. **Frontend**: in `ToBePlayed-Frontend`, `npm install`, then `npm run dev`, and open `http://localhost:5173`
(`localhost`, not `127.0.0.1`, so the browser sends the login cookies to every local port). The development
values must be `VITE_API_URL=http://localhost:8080` and `VITE_CORE_API_URL=http://localhost:8090`.
4. **Log in** with `dev@localhost` / `password123`. On the dashboard, start a Steam sync: the library fills with 8
games, achievements, time to beat, moods, a wishlist and one speedrun game from recorded answers.
The dev user is `PLATFORM_ADMIN`, so user, auditor and admin pages can all be tried ("View as" in the menu
switches down). What the profile does in Auth and Core: their readmes, "Run locally".
### Against other services
Needs Node 24 and running Auth and Core services. Needs Node 24 and running Auth and Core services.
```bash ```bash
+29 -4
View File
@@ -35,9 +35,12 @@
"ERROR_TOO_MANY_ATTEMPTS": "Zu viele fehlgeschlagene Versuche. Bitte in {{minutes}} Minuten erneut versuchen." "ERROR_TOO_MANY_ATTEMPTS": "Zu viele fehlgeschlagene Versuche. Bitte in {{minutes}} Minuten erneut versuchen."
}, },
"SETTINGS": { "SETTINGS": {
"PROFILE_STATS": "Statistik im öffentlichen Profil", "PROFILE_STYLE": "Aussehen des Profils",
"PROFILE_STATS_HINT": "Wähle, was Besucher sehen. Deine Summen und Status sind immer da.", "PROFILE_STYLE_HINT": "So sehen Besucher dein öffentliches Profil. Das Aussehen deiner App bleibt, wie es ist.",
"PROFILE_STATS_NEEDS_PUBLIC": "Mach dein Profil zuerst öffentlich.", "PROFILE_BANNER": "Banner",
"PROFILE_BACKGROUND": "Hintergrund",
"PROFILE_ART_HINT": "Wähle das Bild auf der Seite eines Spiels: \"Profilbild\".",
"PROFILE_ART_REMOVE": "Entfernen",
"TITLE": "Einstellungen", "TITLE": "Einstellungen",
"LOADING": "Einstellungen werden geladen …", "LOADING": "Einstellungen werden geladen …",
"ACCOUNT": "Konto", "ACCOUNT": "Konto",
@@ -121,7 +124,8 @@
"WRONG_PASSWORD": "Das Passwort ist falsch.", "WRONG_PASSWORD": "Das Passwort ist falsch.",
"TOO_MANY": "Zu viele falsche Passwörter. Bitte in {{minutes}} Minuten erneut versuchen.", "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." "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": { "VALIDATION": {
"EMAIL_REQUIRED": "Email ist erforderlich.", "EMAIL_REQUIRED": "Email ist erforderlich.",
@@ -238,6 +242,9 @@
"TTB_COMPLETELY": "100 %", "TTB_COMPLETELY": "100 %",
"SET_BACKGROUND": "Als Hintergrund", "SET_BACKGROUND": "Als Hintergrund",
"REMOVE_BACKGROUND": "Hintergrund entfernen", "REMOVE_BACKGROUND": "Hintergrund entfernen",
"PROFILE_ART": "Profilbild",
"PROFILE_BANNER": "Banner im Profil",
"PROFILE_BACKGROUND": "Hintergrund im Profil",
"SET_BANNER": "Als Banner", "SET_BANNER": "Als Banner",
"ALL_UNLOCKED": "Du hast alle Erfolge dieses Spiels freigeschaltet.", "ALL_UNLOCKED": "Du hast alle Erfolge dieses Spiels freigeschaltet.",
"SET_COMPLETED": "Auf 100 % setzen", "SET_COMPLETED": "Auf 100 % setzen",
@@ -596,5 +603,23 @@
"RANGE": "{{start}} bis {{end}}", "RANGE": "{{start}} bis {{end}}",
"DONE_COUNT_one": "Von {{count}} Nutzer für immer geschlossen", "DONE_COUNT_one": "Von {{count}} Nutzer für immer geschlossen",
"DONE_COUNT_other": "Von {{count}} Nutzern 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"
} }
} }
+29 -4
View File
@@ -35,9 +35,12 @@
"ERROR_TOO_MANY_ATTEMPTS": "Too many failed attempts. Please try again in {{minutes}} minutes." "ERROR_TOO_MANY_ATTEMPTS": "Too many failed attempts. Please try again in {{minutes}} minutes."
}, },
"SETTINGS": { "SETTINGS": {
"PROFILE_STATS": "Stats on the public profile", "PROFILE_STYLE": "Profile look",
"PROFILE_STATS_HINT": "Pick what visitors see. Your totals and statuses are always there.", "PROFILE_STYLE_HINT": "How visitors see your public profile. Your own app look stays as it is.",
"PROFILE_STATS_NEEDS_PUBLIC": "Make your profile public first.", "PROFILE_BANNER": "Banner",
"PROFILE_BACKGROUND": "Background",
"PROFILE_ART_HINT": "Pick the art on a game's page: \"Profile art\".",
"PROFILE_ART_REMOVE": "Remove",
"TITLE": "Settings", "TITLE": "Settings",
"LOADING": "Loading settings …", "LOADING": "Loading settings …",
"ACCOUNT": "Account", "ACCOUNT": "Account",
@@ -121,7 +124,8 @@
"WRONG_PASSWORD": "The password is wrong.", "WRONG_PASSWORD": "The password is wrong.",
"TOO_MANY": "Too many wrong passwords. Please try again in {{minutes}} minutes.", "TOO_MANY": "Too many wrong passwords. Please try again in {{minutes}} minutes.",
"ERROR": "The account could not be deleted. Please try again later." "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": { "VALIDATION": {
"EMAIL_REQUIRED": "Email is required.", "EMAIL_REQUIRED": "Email is required.",
@@ -238,6 +242,9 @@
"TTB_COMPLETELY": "100%", "TTB_COMPLETELY": "100%",
"SET_BACKGROUND": "Use as background", "SET_BACKGROUND": "Use as background",
"REMOVE_BACKGROUND": "Remove background", "REMOVE_BACKGROUND": "Remove background",
"PROFILE_ART": "Profile art",
"PROFILE_BANNER": "Profile banner",
"PROFILE_BACKGROUND": "Profile background",
"SET_BANNER": "Use as banner", "SET_BANNER": "Use as banner",
"ALL_UNLOCKED": "You unlocked every achievement of this game.", "ALL_UNLOCKED": "You unlocked every achievement of this game.",
"SET_COMPLETED": "Set to 100 %", "SET_COMPLETED": "Set to 100 %",
@@ -596,5 +603,23 @@
"RANGE": "{{start}} to {{end}}", "RANGE": "{{start}} to {{end}}",
"DONE_COUNT_one": "Closed for good by {{count}} user", "DONE_COUNT_one": "Closed for good by {{count}} user",
"DONE_COUNT_other": "Closed for good by {{count}} users" "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"
} }
} }
+2 -1
View File
@@ -57,7 +57,8 @@ function App() {
<TooltipProvider> <TooltipProvider>
{/* isolate: the background layer sits behind the content but in front of this div's own background. */} {/* isolate: the background layer sits behind the content but in front of this div's own background. */}
<div className="isolate min-h-screen flex flex-col bg-background text-foreground font-sans"> <div className="isolate min-h-screen flex flex-col bg-background text-foreground font-sans">
{isAuthenticated && <PageBackground appid={appearance.backgroundAppid}/>} {/* A public profile shows the owner's background instead. */}
{isAuthenticated && !location.pathname.startsWith('/u/') && <PageBackground appid={appearance.backgroundAppid}/>}
<AppHeader isAuthenticated={isAuthenticated} onLogout={handleLogout}/> <AppHeader isAuthenticated={isAuthenticated} onLogout={handleLogout}/>
<main className="grow flex flex-col"> <main className="grow flex flex-col">
+2 -3
View File
@@ -7,6 +7,7 @@ import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card'; import {Card} from '@/components/ui/card';
import useSessionExpired from '../hooks/useSessionExpired.js'; import useSessionExpired from '../hooks/useSessionExpired.js';
import {saveGameStatus} from '../utils/backlog.js'; import {saveGameStatus} from '../utils/backlog.js';
import {allAchievementsUnlocked} from '../utils/confetti.js';
/** /**
* Shown for a game with every achievement unlocked that is not marked as 100 % yet. * Shown for a game with every achievement unlocked that is not marked as 100 % yet.
@@ -18,9 +19,7 @@ export default function CompletedHint({game, onChanged}) {
const expireSession = useSessionExpired(); const expireSession = useSessionExpired();
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const allUnlocked = game.hasAchievements && game.statsVisible && game.achievementsTotal > 0 if (!allAchievementsUnlocked(game) || game.status === 'COMPLETED') return null;
&& game.achievementsUnlocked >= game.achievementsTotal;
if (!allUnlocked || game.status === 'COMPLETED') return null;
const setCompleted = async () => { const setCompleted = async () => {
setBusy(true); setBusy(true);
+97
View File
@@ -0,0 +1,97 @@
import {useEffect, useRef, useState} from 'react';
import {LOGO_COLORS} from './Logo.jsx';
const DURATION_MS = 2800;
const PIECES = {small: 70, big: 240};
const GRAVITY = 0.12;
// The speeds are per frame at 60 Hz; a faster screen moves the same distance in the same time.
const FRAME_MS = 1000 / 60;
const reducedMotion = () => window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
// The logo colors of the chosen accent and the accent itself.
function colors() {
const style = getComputedStyle(document.documentElement);
const logo = LOGO_COLORS.map(([name, hex]) => style.getPropertyValue(name).trim() || hex);
const accent = style.getPropertyValue('--primary').trim();
return accent ? [...logo, accent] : logo;
}
// Small: one burst from the top center. Big: two cannons from the lower corners.
function pieces(size, width, height) {
const palette = colors();
const big = size === 'big';
return Array.from({length: PIECES[size]}, (_, i) => {
const left = i % 2 === 0;
const angle = big ? (left ? -Math.PI / 3 : -2 * Math.PI / 3) + (Math.random() - 0.5) * 0.6
: Math.PI / 2 + (Math.random() - 0.5) * 2.4;
const speed = big ? 9 + Math.random() * 9 : 2 + Math.random() * 5;
return {
x: big ? (left ? 0 : width) : width / 2,
y: big ? height : height * 0.1,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
spin: Math.random() * Math.PI,
spinSpeed: (Math.random() - 0.5) * 0.3,
w: 6 + Math.random() * 6,
h: 3 + Math.random() * 4,
color: palette[i % palette.length],
};
});
}
/**
* Confetti over the whole page for about 3 s after mount, then onDone. Remount (a new key) to play again.
*
* @param size 'small' or 'big'
*/
export default function Confetti({size, onDone}) {
const canvasRef = useRef(null);
const [reduce] = useState(reducedMotion);
useEffect(() => {
const canvas = canvasRef.current;
const context = canvas?.getContext('2d');
if (!context) return undefined;
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const all = pieces(size, canvas.width, canvas.height);
const start = performance.now();
let last = start;
let frame;
const draw = (now) => {
const elapsed = now - start;
const steps = (now - last) / FRAME_MS;
last = now;
context.setTransform(1, 0, 0, 1, 0, 0);
context.clearRect(0, 0, canvas.width, canvas.height);
if (elapsed >= DURATION_MS) {
onDone?.();
return;
}
context.globalAlpha = 1 - elapsed / DURATION_MS;
for (const piece of all) {
piece.vy += GRAVITY * steps;
piece.vx *= 0.99 ** steps;
piece.x += piece.vx * steps;
piece.y += piece.vy * steps;
piece.spin += piece.spinSpeed * steps;
if (piece.y > canvas.height + 20) continue;
const cos = Math.cos(piece.spin);
const sin = Math.sin(piece.spin);
context.setTransform(cos, sin, -sin, cos, piece.x, piece.y);
context.fillStyle = piece.color;
context.fillRect(-piece.w / 2, -piece.h / 2, piece.w, piece.h * Math.abs(cos));
}
frame = requestAnimationFrame(draw);
};
frame = requestAnimationFrame(draw);
return () => cancelAnimationFrame(frame);
// Not on onDone: a new function from a re-render must not restart the animation.
}, [size]);
if (reduce) return null;
return <canvas ref={canvasRef} aria-hidden="true" data-size={size}
className="pointer-events-none fixed inset-0 z-50 size-full"/>;
}
+37
View File
@@ -0,0 +1,37 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render} from '@testing-library/react';
import Confetti from './Confetti.jsx';
// jsdom has no canvas drawing.
beforeEach(() => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
const reducedMotion = (reduce) => vi.stubGlobal('matchMedia', (query) => ({
matches: reduce && query.includes('prefers-reduced-motion'),
}));
describe('Confetti', () => {
it('draws on a canvas hidden from screen readers', () => {
reducedMotion(false);
const {container} = render(<Confetti size="big"/>);
const canvas = container.querySelector('canvas');
expect(canvas.getAttribute('aria-hidden')).toBe('true');
expect(canvas.dataset.size).toBe('big');
});
it('draws nothing when the user asked for reduced motion', () => {
reducedMotion(true);
const {container} = render(<Confetti size="small"/>);
expect(container.querySelector('canvas')).toBeNull();
});
});
+6 -2
View File
@@ -4,10 +4,14 @@ import {ArrowRight, Gamepad2} from 'lucide-react';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card'; import {Card} from '@/components/ui/card';
import {cn} from '@/lib/utils';
import GameCover from './GameCover.jsx'; import GameCover from './GameCover.jsx';
import {formatPlaytime} from '../utils/games.js'; 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(); const {t} = useTranslation();
return ( return (
<Card className="gap-0 py-0"> <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')} {syncing ? t('LIBRARY.SYNCING_FIRST_TIME') : t('DASHBOARD.NOTHING_PLAYING')}
</p> </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) => ( {games.map((g) => (
<li key={g.appid}> <li key={g.appid}>
<Link to={`/library/${g.appid}`} className="group block space-y-1.5"> <Link to={`/library/${g.appid}`} className="group block space-y-1.5">
+17
View File
@@ -0,0 +1,17 @@
import {cn} from '@/lib/utils';
import HeroArt from './HeroArt.jsx';
/**
* The art of a game behind a card's content, or the accent gradient without one. Put it first in a card with
* `relative overflow-hidden`; the gradient keeps the text readable.
*/
export default function HeroBanner({appid}) {
return (
<>
<HeroArt key={appid} appid={appid} className="pointer-events-none absolute inset-0 size-full object-cover opacity-60"/>
<div className={cn('pointer-events-none absolute inset-0', appid
? 'bg-gradient-to-r from-card via-card/85 to-card/60'
: 'bg-gradient-to-br from-primary/15 via-transparent to-transparent')}/>
</>
);
}
+8 -5
View File
@@ -2,11 +2,14 @@ import {useId} from 'react';
import {BODY, CHECKLIST, DPAD, OUTLINE, PROGRESS_BAR} from './logoPaths.js'; import {BODY, CHECKLIST, DPAD, OUTLINE, PROGRESS_BAR} from './logoPaths.js';
// Each color is [themed CSS, fixed hex]. The hex is the logo as designed; it also stands in where a browser /**
// cannot read color-mix, which would otherwise make that stop black. * The three logo colors as [CSS variable, hex as designed]; style.css sets the variables per accent.
const violet = ['var(--logo-violet, #7467f9)', '#7467f9']; */
const blue = ['var(--logo-blue, #0192ef)', '#0192ef']; export const LOGO_COLORS = [['--logo-violet', '#7467f9'], ['--logo-blue', '#0192ef'], ['--logo-teal', '#17c7a4']];
const teal = ['var(--logo-teal, #17c7a4)', '#17c7a4'];
// Each color is [themed CSS, fixed hex]. The hex also stands in where a browser cannot read color-mix, which
// would otherwise make that stop black.
const [violet, blue, teal] = LOGO_COLORS.map(([name, hex]) => [`var(${name}, ${hex})`, hex]);
const mix = ([a], [b], percentOfB, hex) => [`color-mix(in oklch, ${a}, ${b} ${percentOfB}%)`, hex]; const mix = ([a], [b], percentOfB, hex) => [`color-mix(in oklch, ${a}, ${b} ${percentOfB}%)`, hex];
function Gradient({id, from, to, stops}) { function Gradient({id, from, to, stops}) {
+47
View File
@@ -0,0 +1,47 @@
import {useState} from 'react';
import {useTranslation} from 'react-i18next';
import {UserRound} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import useSaveProfileStyle from '../hooks/useSaveProfileStyle.js';
/**
* Uses the art of this Steam game as banner or background of the public profile; a checked item removes it again.
*
* @param onSaved gets the whole saved style
*/
export default function ProfileArtMenu({appid, style, onSaved}) {
const {t} = useTranslation();
const save = useSaveProfileStyle();
const [saving, setSaving] = useState(false);
const toggle = async (field) => {
setSaving(true);
const saved = await save((stored) => ({[field]: stored[field] === appid ? null : appid}), style);
if (saved) onSaved(saved);
setSaving(false);
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={saving}><UserRound/>{t('GAME_DETAIL.PROFILE_ART')}</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuCheckboxItem checked={style.bannerAppid === appid} onCheckedChange={() => toggle('bannerAppid')}>
{t('GAME_DETAIL.PROFILE_BANNER')}
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem checked={style.backgroundAppid === appid}
onCheckedChange={() => toggle('backgroundAppid')}>
{t('GAME_DETAIL.PROFILE_BACKGROUND')}
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
+56
View File
@@ -0,0 +1,56 @@
import {useTranslation} from 'react-i18next';
import {ImageOff} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Label} from '@/components/ui/label';
import {AccentPicker, CornersPicker} from './StylePickers.jsx';
import {coverUrl} from '../utils/games.js';
function ArtRow({id, label, appid, onRemove, disabled}) {
const {t} = useTranslation();
return (
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<Label id={`${id}-label`}>{label}</Label>
{!appid && <p className="text-xs text-muted-foreground">{t('SETTINGS.PROFILE_ART_HINT')}</p>}
</div>
{appid && (
<div className="flex items-center gap-2">
<img src={coverUrl(appid)} alt="" onError={(event) => { event.currentTarget.hidden = true; }}
className="h-10 w-20 rounded-md object-cover"/>
<Button type="button" variant="outline" size="sm" aria-describedby={`${id}-label`}
disabled={disabled} onClick={onRemove}>
<ImageOff/>{t('SETTINGS.PROFILE_ART_REMOVE')}
</Button>
</div>
)}
</div>
);
}
/**
* 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();
return (
<div className="space-y-6">
<div className="space-y-1">
<span className="text-sm font-medium">{t('SETTINGS.PROFILE_STYLE')}</span>
<p className="text-xs text-muted-foreground">{t('SETTINGS.PROFILE_STYLE_HINT')}</p>
</div>
<AccentPicker id="profile-accent" value={style.accent} disabled={disabled}
onChange={(accent) => onChange({accent})}/>
<CornersPicker id="profile-corners" value={style.corners} disabled={disabled}
onChange={(corners) => onChange({corners})}/>
<ArtRow id="profile-banner" label={t('SETTINGS.PROFILE_BANNER')} appid={style.bannerAppid}
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>
);
}
+53
View File
@@ -0,0 +1,53 @@
import {useTranslation} from 'react-i18next';
import {Button} from '@/components/ui/button';
import {Label} from '@/components/ui/label';
import {cn} from '@/lib/utils';
import {ACCENTS, CORNERS} from '../utils/appearance.js';
/**
* One round button per accent preset; each button carries its own preset, so it shows its own color.
*
* @param id prefix for the label id, unique on the page
*/
export function AccentPicker({id, value, onChange, disabled}) {
const {t} = useTranslation();
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Label id={`${id}-label`}>{t('SETTINGS.ACCENT')}</Label>
<div role="group" aria-labelledby={`${id}-label`} className="flex flex-wrap gap-2">
{ACCENTS.map((accent) => (
<button key={accent} type="button" data-accent={accent} disabled={disabled}
aria-label={t(`SETTINGS.ACCENT_OPTIONS.${accent.toUpperCase()}`)}
title={t(`SETTINGS.ACCENT_OPTIONS.${accent.toUpperCase()}`)}
aria-pressed={value === accent}
onClick={() => onChange(accent)}
className={cn('size-8 rounded-full bg-primary ring-offset-2 ring-offset-background transition hover:scale-110 disabled:opacity-50 disabled:hover:scale-100',
value === accent && 'ring-2 ring-foreground')}/>
))}
</div>
</div>
);
}
/**
* @param id prefix for the label id, unique on the page
*/
export function CornersPicker({id, value, onChange, disabled}) {
const {t} = useTranslation();
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Label id={`${id}-label`}>{t('SETTINGS.CORNERS')}</Label>
<div role="group" aria-labelledby={`${id}-label`} className="flex gap-2">
{CORNERS.map((corners) => (
<Button key={corners} type="button" size="sm" data-corners={corners} disabled={disabled}
variant={value === corners ? 'default' : 'outline'}
aria-pressed={value === corners}
onClick={() => onChange(corners)}>
{t(`SETTINGS.CORNER_OPTIONS.${corners.toUpperCase()}`)}
</Button>
))}
</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;
}
}
+5 -2
View File
@@ -1,9 +1,11 @@
import {useEffect, useState} from 'react'; import {useEffect, useState} from 'react';
import {apiFetch} from '../utils/api.js'; import {apiFetch} from '../utils/api.js';
import {sessionStamp} from '../utils/auth.js'; import {sessionStamp} from '../utils/auth.js';
import {normalizeProfileStyle} from '../utils/profileStyle.js';
/** /**
* The stored role and the active role ("view as") 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. * Loads again after a new login or a role switch (see sessionStamp) on the next render.
*/ */
export default function useCurrentUser(enabled) { export default function useCurrentUser(enabled) {
@@ -20,7 +22,8 @@ export default function useCurrentUser(enabled) {
try { try {
const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`); const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`);
const data = response.ok ? await response.json() : null; 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 { } catch {
if (!cancelled) setUser(null); if (!cancelled) setUser(null);
} }
+22
View File
@@ -0,0 +1,22 @@
import {useCallback} from 'react';
import useSaveResponse from './useSaveResponse.js';
import {loadProfileStyle, saveProfileStyle} from '../utils/profileStyle.js';
/**
* Returns `save(changes, shown)`. It merges the changes into the stored style, loaded just before, so a change from
* another tab or page stays; `shown` is the fallback when the load fails. `changes` can be a function of that
* style. Resolves to the saved style, or null after the error message (or the session end on a 401).
*/
export default function useSaveProfileStyle() {
const run = useSaveResponse();
return useCallback(async (changes, shown) => {
let style = null;
const saved = await run(async () => {
const stored = await loadProfileStyle() ?? shown;
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]);
}
+87 -55
View File
@@ -2,7 +2,7 @@ import React, {useCallback, useEffect, useState} from 'react';
import {Link, useNavigate} from 'react-router-dom'; import {Link, useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {toast} from 'sonner'; 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 {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
@@ -21,23 +21,26 @@ import useForceSync from '../hooks/useForceSync.js';
import {formatPlaytime} from '../utils/games.js'; import {formatPlaytime} from '../utils/games.js';
import useSessionExpired from '../hooks/useSessionExpired.js'; import useSessionExpired from '../hooks/useSessionExpired.js';
import PageContainer from '../components/PageContainer.jsx'; import PageContainer from '../components/PageContainer.jsx';
import HeroArt from '../components/HeroArt.jsx'; import HeroBanner from '../components/HeroBanner.jsx';
import SteamAvatar from '../components/SteamAvatar.jsx'; import SteamAvatar from '../components/SteamAvatar.jsx';
import useSteamProfile from '../hooks/useSteamProfile.js'; import useSteamProfile from '../hooks/useSteamProfile.js';
import useUserStats from '../hooks/useUserStats.js'; import useUserStats from '../hooks/useUserStats.js';
import {cn} from '@/lib/utils'; 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 {usePreferences} from '../context/PreferencesContext.jsx';
import {apiFetch} from '../utils/api.js'; import {apiFetch} from '../utils/api.js';
import {hasSession} from '../utils/auth.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. // 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 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 PLAYING_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc&status=PLAYING';
const JSON_HEADERS = {'Content-Type': 'application/json'}; const JSON_HEADERS = {'Content-Type': 'application/json'};
const TREND_GROUPS = ['TREND'];
const Dashboard = () => { const Dashboard = () => {
const {t} = useTranslation(); const {t} = useTranslation();
@@ -54,8 +57,18 @@ const Dashboard = () => {
const [shuffling, setShuffling] = useState(false); const [shuffling, setShuffling] = useState(false);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const pileTrend = useUserStats(TREND_GROUPS, expireSession, hasSession()); const [editing, setEditing] = useState(false);
const reloadTrend = pileTrend.reload; 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 () => { const fetchSteamCount = useCallback(async () => {
try { try {
@@ -81,7 +94,7 @@ const Dashboard = () => {
} }
if (r.ok) { if (r.ok) {
const page = await r.json(); const page = await r.json();
setTopGames((page.content ?? []).slice(0, TOP_GAMES)); setTopGames((page.content ?? []).slice(0, TOP_GAMES.L));
} }
} catch (err) { } catch (err) {
console.error('Error fetching top games:', err); console.error('Error fetching top games:', err);
@@ -97,7 +110,7 @@ const Dashboard = () => {
} }
if (r.ok) { if (r.ok) {
const page = await r.json(); const page = await r.json();
setPlaying((page.content ?? []).slice(0, PLAYING_GAMES)); setPlaying((page.content ?? []).slice(0, PLAYING_GAMES.L));
} }
} catch (err) { } catch (err) {
console.error('Error fetching playing games:', err); console.error('Error fetching playing games:', err);
@@ -173,8 +186,8 @@ const Dashboard = () => {
fetchSummary(); fetchSummary();
fetchActivity(); fetchActivity();
fetchAchievements(); fetchAchievements();
reloadTrend(); if (hasStatGroups) reloadStats();
}, [fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements, reloadTrend]); }, [fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements, hasStatGroups, reloadStats]);
useEffect(() => { useEffect(() => {
const fetchUserData = async () => { const fetchUserData = async () => {
@@ -241,17 +254,49 @@ const Dashboard = () => {
} }
const syncBusy = forceSync.busy || sync.isRunning; 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 ( return (
<PageContainer className="p-4 md:p-8 space-y-6"> <PageContainer className="p-4 md:p-8 space-y-6">
<Card className="relative overflow-hidden p-6 md:p-8 flex flex-col md:flex-row md:items-center md:justify-between gap-6"> <Card className="relative overflow-hidden p-6 md:p-8 flex flex-col md:flex-row md:items-center md:justify-between gap-6">
{/* A picked game, else the game played most in the last two weeks; the gradient keeps the text readable. */} {/* A picked game, else the game played most in the last two weeks. */}
<HeroArt key={bannerAppid} appid={bannerAppid} <HeroBanner appid={bannerAppid}/>
className="pointer-events-none absolute inset-0 size-full object-cover opacity-60"/>
<div className={cn('pointer-events-none absolute inset-0', bannerAppid
? 'bg-gradient-to-r from-card via-card/85 to-card/60'
: 'bg-gradient-to-br from-primary/15 via-transparent to-transparent')}/>
<div className="relative flex min-w-0 items-center gap-4"> <div className="relative flex min-w-0 items-center gap-4">
{/* The slot is there before the avatar loads, so the title does not jump. */} {/* The slot is there before the avatar loads, so the title does not jump. */}
{hasSteamId && ( {hasSteamId && (
@@ -277,8 +322,9 @@ const Dashboard = () => {
</div> </div>
</div> </div>
{hasSteamId && (
<div className="relative flex flex-col items-start md:items-end gap-3 shrink-0"> <div className="relative flex flex-col items-start md:items-end gap-3 shrink-0">
{hasSteamId && (
<>
<SyncStatusBadge <SyncStatusBadge
status={sync.status} status={sync.status}
lastSyncedAt={sync.lastSyncedAt} lastSyncedAt={sync.lastSyncedAt}
@@ -288,15 +334,27 @@ const Dashboard = () => {
<RefreshCw className={syncBusy ? 'animate-spin' : ''}/> <RefreshCw className={syncBusy ? 'animate-spin' : ''}/>
{t('LIBRARY.RESYNC')} {t('LIBRARY.RESYNC')}
</Button> </Button>
</div> </>
)} )}
<Button variant="ghost" size="sm" disabled={editing} onClick={() => setEditing(true)}>
<LayoutGrid/>{t('TILES.CUSTOMIZE')}
</Button>
</div>
</Card> </Card>
{showLibrary && ( <TileGrid tiles={DASHBOARD_TILES} titles={DASHBOARD_TITLES} layout={layout} renderTile={renderTile} editing={editing}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> defaultLayout={DEFAULT_DASHBOARD} onSave={saveLayout} onCancel={() => setEditing(false)}/>
<SteamGameCount targetCount={steamData.count}/> {/* Last child: space-y gives every other child a bottom margin, which would move the fixed card. */}
<DashboardHint/>
</PageContainer>
);
};
<Card className="md:col-span-2 gap-0 py-0"> 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"> <div className="flex items-center justify-between px-5 py-4 border-b">
<h2 className="flex items-center gap-2 font-semibold"> <h2 className="flex items-center gap-2 font-semibold">
<Trophy className="size-4 text-amber-500"/> <Trophy className="size-4 text-amber-500"/>
@@ -307,13 +365,13 @@ const Dashboard = () => {
</Button> </Button>
</div> </div>
{topGames.length === 0 ? ( {games.length === 0 ? (
<p className="px-5 py-10 text-center text-sm text-muted-foreground"> <p className="px-5 py-10 text-center text-sm text-muted-foreground">
{sync.isRunning ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')} {syncing ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')}
</p> </p>
) : ( ) : (
<ol className="divide-y"> <ol className="divide-y">
{topGames.map((g, index) => ( {games.map((g, index) => (
<li key={g.appid}> <li key={g.appid}>
<Link <Link
to={`/library/${g.appid}`} to={`/library/${g.appid}`}
@@ -343,34 +401,8 @@ const Dashboard = () => {
</ol> </ol>
)} )}
</Card> </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}/>}
{/* Last child: space-y gives every other child a bottom margin, which would move the fixed card. */}
<DashboardHint/>
</PageContainer>
); );
}; }
function DashboardSkeleton() { function DashboardSkeleton() {
return ( return (
+57 -4
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; 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 {MemoryRouter, Route, Routes, useLocation} from 'react-router-dom';
import Dashboard from './Dashboard.jsx'; import Dashboard from './Dashboard.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx'; import {PreferencesProvider} from '../context/PreferencesContext.jsx';
@@ -49,9 +49,13 @@ describe('Dashboard', () => {
}); });
const avatar = 'https://avatars.steamstatic.com/abc_full.jpg'; const avatar = 'https://avatars.steamstatic.com/abc_full.jpg';
const mockDashboard = () => { const games = Array.from({length: 12}, (_, i) => ({appid: 100 + i, name: `Game ${i}`, playtimeMinutes: 1000 - i}));
globalThis.fetch = vi.fn(async (url) => { const mockDashboard = (settings) => {
if (url.includes('/api/users/me')) return ok({email: 'a@b.de', displayName: 'Lobo', steamId: '76561198000000000'}); 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/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: [ 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'}]}); {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(); 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 () => { it('shows the achievement summary', async () => {
mockDashboard(); mockDashboard();
+22 -2
View File
@@ -1,4 +1,4 @@
import {useEffect, useMemo, useState} from 'react'; import {useEffect, useMemo, useRef, useState} from 'react';
import {Link, useNavigate, useParams} from 'react-router-dom'; import {Link, useNavigate, useParams} from 'react-router-dom';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {ArrowLeft, Clock, ExternalLink, Eye, EyeOff, Gamepad2, Image as ImageIcon, ImageOff, Merge, PanelTop, PencilLine, RotateCcw, ShieldOff, Timer, Trophy} from 'lucide-react'; import {ArrowLeft, Clock, ExternalLink, Eye, EyeOff, Gamepad2, Image as ImageIcon, ImageOff, Merge, PanelTop, PencilLine, RotateCcw, ShieldOff, Timer, Trophy} from 'lucide-react';
@@ -10,6 +10,7 @@ import {Card} from '@/components/ui/card';
import {Skeleton} from '@/components/ui/skeleton'; import {Skeleton} from '@/components/ui/skeleton';
import {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
import BacklogCard from '../components/BacklogCard.jsx'; import BacklogCard from '../components/BacklogCard.jsx';
import Confetti from '../components/Confetti.jsx';
import GameCover from '../components/GameCover.jsx'; import GameCover from '../components/GameCover.jsx';
import GameStatusBadge from '../components/GameStatusBadge.jsx'; import GameStatusBadge from '../components/GameStatusBadge.jsx';
import ManualGameCard from '../components/ManualGameCard.jsx'; import ManualGameCard from '../components/ManualGameCard.jsx';
@@ -25,9 +26,11 @@ import useSessionExpired from '../hooks/useSessionExpired.js';
import useSaveAppearance from '../hooks/useSaveAppearance.js'; import useSaveAppearance from '../hooks/useSaveAppearance.js';
import {usePreferences} from '../context/PreferencesContext.jsx'; import {usePreferences} from '../context/PreferencesContext.jsx';
import PageContainer from '../components/PageContainer.jsx'; import PageContainer from '../components/PageContainer.jsx';
import ProfileArtMenu from '../components/ProfileArtMenu.jsx';
import {apiFetch} from '../utils/api.js'; import {apiFetch} from '../utils/api.js';
import {ACHIEVEMENT_ICONS, reportBrokenImage} from '../utils/brokenImages.js'; import {ACHIEVEMENT_ICONS, reportBrokenImage} from '../utils/brokenImages.js';
import {canCorrect} from '../utils/corrections.js'; import {canCorrect} from '../utils/corrections.js';
import {celebrationFor, celebrationLevel} from '../utils/confetti.js';
import {setGameHidden} from '../utils/hiddenGames.js'; import {setGameHidden} from '../utils/hiddenGames.js';
import {hasSession} from '../utils/auth.js'; import {hasSession} from '../utils/auth.js';
import {isSteamKey} from '../utils/manualGames.js'; import {isSteamKey} from '../utils/manualGames.js';
@@ -70,7 +73,20 @@ export default function GameDetail() {
const [correcting, setCorrecting] = useState(false); const [correcting, setCorrecting] = useState(false);
const [merging, setMerging] = useState(false); const [merging, setMerging] = useState(false);
const [reloads, setReloads] = useState(0); const [reloads, setReloads] = useState(0);
const auditor = canCorrect(useCurrentUser(true)); const currentUser = useCurrentUser(true);
const auditor = canCorrect(currentUser);
const [savedProfileStyle, setSavedProfileStyle] = useState(null);
const profileStyle = savedProfileStyle ?? currentUser?.profileStyle;
const [celebration, setCelebration] = useState(null);
const shownLevel = useRef(null);
// Every status change on this page (backlog card, hints) goes through setData.
useEffect(() => {
if (!data) return;
const size = celebrationFor(shownLevel.current, data);
if (size) setCelebration({size, id: Date.now()});
shownLevel.current = {appid: data.appid, level: celebrationLevel(data)};
}, [data]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -257,6 +273,9 @@ export default function GameDetail() {
<PanelTop/>{t('GAME_DETAIL.SET_BANNER')} <PanelTop/>{t('GAME_DETAIL.SET_BANNER')}
</Button> </Button>
)} )}
{profileStyle && (
<ProfileArtMenu appid={data.appid} style={profileStyle} onSaved={setSavedProfileStyle}/>
)}
{!manual && ( {!manual && (
<Button variant="outline" size="sm" onClick={toggleHidden} disabled={hiding}> <Button variant="outline" size="sm" onClick={toggleHidden} disabled={hiding}>
{data.hidden ? <Eye/> : <EyeOff/>} {data.hidden ? <Eye/> : <EyeOff/>}
@@ -357,6 +376,7 @@ export default function GameDetail() {
)} )}
</> </>
)} )}
{celebration && <Confetti key={celebration.id} size={celebration.size} onDone={() => setCelebration(null)}/>}
{/* Outside the content: the reload after a save must not close it. */} {/* Outside the content: the reload after a save must not close it. */}
{data && auditor && ( {data && auditor && (
<GameCorrectionDialog game={data} open={correcting} onOpenChange={setCorrecting} <GameCorrectionDialog game={data} open={correcting} onOpenChange={setCorrecting}
+96
View File
@@ -242,6 +242,70 @@ describe('GameDetail of a game added by hand', () => {
expect(screen.queryByText('MANUAL_GAME.TITLE')).toBeNull(); expect(screen.queryByText('MANUAL_GAME.TITLE')).toBeNull();
}); });
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', backgroundAppid}}}
: {...game, source: 'STEAM'})}));
renderDetail();
const menu = await screen.findByRole('button', {name: 'GAME_DETAIL.PROFILE_ART'});
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).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}}}
: {...game, source: 'STEAM'})}));
renderDetail();
const menu = await screen.findByRole('button', {name: 'GAME_DETAIL.PROFILE_ART'});
bannerAppid = 620; // another tab picked another game
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 [, init] = fetch.mock.calls.find(([u, i]) => i?.method === 'PUT' && u.endsWith('/profile-style'));
expect(JSON.parse(init.body).bannerAppid).toBe(220);
});
it('uses the art as profile banner and removes it on the second click', async () => {
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,
json: async () => (url.includes('/api/users/me')
? {role: 'USER', settings: {profileStyle: stored}}
: {...game, source: 'STEAM'})};
});
const styleBodies = () => fetch.mock.calls.filter(([url, init]) => init?.method === 'PUT' && url.endsWith('/profile-style'))
.map(([, init]) => JSON.parse(init.body));
const openMenu = async () => fireEvent.keyDown(await screen.findByRole('button', {name: 'GAME_DETAIL.PROFILE_ART'}), {key: 'Enter'});
renderDetail();
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}]));
await openMenu();
const banner = await screen.findByRole('menuitemcheckbox', {name: 'GAME_DETAIL.PROFILE_BANNER'});
expect(banner.getAttribute('aria-checked')).toBe('true');
fireEvent.click(banner);
await waitFor(() => expect(styleBodies()).toHaveLength(2));
expect(styleBodies()[1].bannerAppid).toBeNull();
});
describe('corrections', () => { describe('corrections', () => {
const loggedInAs = (me) => { const loggedInAs = (me) => {
globalThis.fetch = vi.fn(async (url) => ({ok: true, status: 200, globalThis.fetch = vi.fn(async (url) => ({ok: true, status: 200,
@@ -282,3 +346,35 @@ describe('GameDetail of a game added by hand', () => {
}); });
}); });
}); });
describe('GameDetail confetti', () => {
// jsdom has no canvas drawing.
beforeEach(() => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
});
afterEach(() => {
vi.restoreAllMocks();
});
const opened = async (entry) => {
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({...game, ...entry})}));
const {container} = renderDetail();
await screen.findByText('Half-Life 2');
return container.querySelector('canvas[data-size]');
};
it('plays a small one for a finished game', async () => {
expect((await opened({status: 'FINISHED'})).dataset.size).toBe('small');
});
it('plays a big one when every achievement is unlocked too', async () => {
const canvas = await opened({status: 'COMPLETED', hasAchievements: true, statsVisible: true,
achievementsTotal: 3, achievementsUnlocked: 3});
expect(canvas.dataset.size).toBe('big');
});
it('plays nothing for a game not finished', async () => {
expect(await opened({status: 'PLAYING'})).toBeNull();
});
});
+105 -55
View File
@@ -1,21 +1,30 @@
import {useEffect, useState} from 'react'; import {useEffect, useState} from 'react';
import {Link, useParams} from 'react-router-dom'; import {Link, useParams} from 'react-router-dom';
import {useTranslation} from 'react-i18next'; 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 {Card} from '@/components/ui/card';
import {Skeleton} from '@/components/ui/skeleton'; import {Skeleton} from '@/components/ui/skeleton';
import {cn} from '@/lib/utils';
import GameCover from '../components/GameCover.jsx'; import GameCover from '../components/GameCover.jsx';
import GameStatusBadge, {STATUS_META} from '../components/GameStatusBadge.jsx'; import GameStatusBadge, {STATUS_META} from '../components/GameStatusBadge.jsx';
import {GAME_STATUSES} from '../utils/backlog.js'; import {GAME_STATUSES} from '../utils/backlog.js';
import {formatPlaytime} from '../utils/games.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 PageContainer from '../components/PageContainer.jsx';
import PileStats from '../components/stats/PileStats.jsx'; import TileGrid from '../components/TileGrid.jsx';
import TrendStats from '../components/stats/TrendStats.jsx'; import statGroupTile from '../components/stats/statGroupTile.jsx';
import TopGamesStats from '../components/stats/TopGamesStats.jsx';
import TasteStats from '../components/stats/TasteStats.jsx'; // Games per tile size; Core sends at most 8 playing and 12 finished games.
import HabitsStats from '../components/stats/HabitsStats.jsx'; const PLAYING_GAMES = {S: 4, M: 8};
import FinishedStats from '../components/stats/FinishedStats.jsx'; 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. * Public, no login. Auth returns a short-lived ticket for a public profile; Core returns the data for it.
@@ -26,6 +35,10 @@ export default function PublicProfilePage() {
const [profile, setProfile] = useState(null); const [profile, setProfile] = useState(null);
const [library, setLibrary] = useState(null); const [library, setLibrary] = useState(null);
const [error, setError] = 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(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -67,7 +80,7 @@ export default function PublicProfilePage() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [name, t]); }, [name, t, reloads]);
if (error) { if (error) {
return ( return (
@@ -90,26 +103,44 @@ export default function PublicProfilePage() {
); );
} }
const finishedCount = (library.byStatus.FINISHED ?? 0) + (library.byStatus.COMPLETED ?? 0); const style = normalizeProfileStyle(profile.style);
const stats = [
[Library, library.totalGames.toLocaleString(i18n.language), t('PROFILE.GAMES')],
[Clock, formatPlaytime(library.totalPlaytimeMinutes), t('PROFILE.PLAYTIME')],
[CircleCheck, finishedCount.toLocaleString(i18n.language), t('PROFILE.FINISHED')],
];
if (library.averageCompletion != null) {
stats.push(
[Trophy, (library.perfectGames ?? 0).toLocaleString(i18n.language), t('PROFILE.PERFECT_GAMES')],
[Percent, `${library.averageCompletion.toLocaleString(i18n.language)} %`, t('PROFILE.AVERAGE_COMPLETION')],
);
}
const memberSince = profile.memberSince const memberSince = profile.memberSince
? new Date(`${profile.memberSince}T12:00:00`).toLocaleDateString(i18n.language, {year: 'numeric', month: 'long'}) ? new Date(`${profile.memberSince}T12:00:00`).toLocaleDateString(i18n.language, {year: 'numeric', month: 'long'})
: null; : null;
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.
return ( return (
<div data-accent={style.accent} data-corners={style.corners}>
<PageBackground appid={style.backgroundAppid}/>
<PageContainer className="p-4 md:p-8 space-y-6"> <PageContainer className="p-4 md:p-8 space-y-6">
<Card className="relative overflow-hidden p-6 md:p-8 gap-6"> <Card className="relative overflow-hidden p-6 md:p-8">
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-primary/15 via-transparent to-transparent"/> <HeroBanner appid={style.bannerAppid}/>
<div className="relative"> <div className="relative">
<p className="text-sm font-medium text-muted-foreground">{t('PROFILE.TITLE')}</p> <p className="text-sm font-medium text-muted-foreground">{t('PROFILE.TITLE')}</p>
<h1 className="mt-1 text-3xl md:text-4xl font-bold tracking-tight break-words">{profile.displayName}</h1> <h1 className="mt-1 text-3xl md:text-4xl font-bold tracking-tight break-words">{profile.displayName}</h1>
@@ -117,7 +148,34 @@ export default function PublicProfilePage() {
<p className="mt-2 text-sm text-muted-foreground">{t('PROFILE.MEMBER_SINCE', {date: memberSince})}</p> <p className="mt-2 text-sm text-muted-foreground">{t('PROFILE.MEMBER_SINCE', {date: memberSince})}</p>
)} )}
</div> </div>
<div className={`relative grid gap-3 ${stats.length > 3 ? 'grid-cols-2 sm:grid-cols-5' : 'grid-cols-3'}`}> {owner && !editing && (
<Button variant="outline" size="sm" className="relative mt-4" onClick={() => setEditing(true)}>
<LayoutGrid/>{t('TILES.CUSTOMIZE')}
</Button>
)}
</Card>
<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, compact}) {
const {t, i18n} = useTranslation();
const stats = [
[Library, library.totalGames.toLocaleString(i18n.language), t('PROFILE.GAMES')],
[Clock, formatPlaytime(library.totalPlaytimeMinutes), t('PROFILE.PLAYTIME')],
[CircleCheck, (library.finishedGames ?? 0).toLocaleString(i18n.language), t('PROFILE.FINISHED')],
];
if (library.averageCompletion != null) {
stats.push(
[Trophy, (library.perfectGames ?? 0).toLocaleString(i18n.language), t('PROFILE.PERFECT_GAMES')],
[Percent, `${library.averageCompletion.toLocaleString(i18n.language)} %`, t('PROFILE.AVERAGE_COMPLETION')],
);
}
return (
<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]) => ( {stats.map(([Icon, value, label]) => (
<div key={label} className="rounded-lg bg-muted/50 p-3 text-center"> <div key={label} className="rounded-lg bg-muted/50 p-3 text-center">
<Icon className="mx-auto mb-1 size-4 text-primary"/> <Icon className="mx-auto mb-1 size-4 text-primary"/>
@@ -125,13 +183,18 @@ export default function PublicProfilePage() {
<div className="text-xs text-muted-foreground">{label}</div> <div className="text-xs text-muted-foreground">{label}</div>
</div> </div>
))} ))}
</div> </Card>
{library.totalGames > 0 && ( );
<div className="relative space-y-2"> }
function StatusBar({byStatus}) {
const {t} = useTranslation();
const total = GAME_STATUSES.reduce((sum, s) => sum + (byStatus[s] ?? 0), 0);
return (
<Card className="gap-2 p-5">
<div className="flex h-2.5 overflow-hidden rounded-full bg-muted"> <div className="flex h-2.5 overflow-hidden rounded-full bg-muted">
{GAME_STATUSES.map((s) => ( {GAME_STATUSES.map((s) => (
<div key={s} className={STATUS_META[s].bar} <div key={s} className={STATUS_META[s].bar} style={{width: `${((byStatus[s] ?? 0) / total) * 100}%`}}/>
style={{width: `${((library.byStatus[s] ?? 0) / library.totalGames) * 100}%`}}/>
))} ))}
</div> </div>
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground"> <ul className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
@@ -139,21 +202,23 @@ export default function PublicProfilePage() {
<li key={s} className="flex items-center gap-1.5"> <li key={s} className="flex items-center gap-1.5">
<span className={`size-2 rounded-full ${STATUS_META[s].bar}`}/> <span className={`size-2 rounded-full ${STATUS_META[s].bar}`}/>
{t(`BACKLOG.STATUS.${s}`)} {t(`BACKLOG.STATUS.${s}`)}
<span className="tabular-nums text-foreground">{library.byStatus[s] ?? 0}</span> <span className="tabular-nums text-foreground">{byStatus[s] ?? 0}</span>
</li> </li>
))} ))}
</ul> </ul>
</div>
)}
</Card> </Card>
);
}
{library.playing.length > 0 && ( function Playing({games, compact}) {
const {t} = useTranslation();
return (
<Card className="gap-4 p-5"> <Card className="gap-4 p-5">
<h2 className="flex items-center gap-2 font-semibold"> <h2 className="flex items-center gap-2 font-semibold">
<Gamepad2 className="size-4 text-primary"/>{t('PROFILE.PLAYING')} <Gamepad2 className="size-4 text-primary"/>{t('PROFILE.PLAYING')}
</h2> </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')}>
{library.playing.map((g) => ( {games.map((g) => (
<li key={g.appid} className="space-y-1.5"> <li key={g.appid} className="space-y-1.5">
<GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-full"/> <GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-full"/>
<div className="truncate text-sm font-medium">{g.name ?? `App ${g.appid}`}</div> <div className="truncate text-sm font-medium">{g.name ?? `App ${g.appid}`}</div>
@@ -162,15 +227,18 @@ export default function PublicProfilePage() {
))} ))}
</ul> </ul>
</Card> </Card>
)} );
}
{library.finished.length > 0 && ( function Finished({games}) {
const {t} = useTranslation();
return (
<Card className="gap-0 py-0"> <Card className="gap-0 py-0">
<h2 className="flex items-center gap-2 border-b px-5 py-4 font-semibold"> <h2 className="flex items-center gap-2 border-b px-5 py-4 font-semibold">
<CircleCheck className="size-4 text-emerald-500"/>{t('PROFILE.FINISHED_GAMES')} <CircleCheck className="size-4 text-emerald-500"/>{t('PROFILE.FINISHED_GAMES')}
</h2> </h2>
<ul className="divide-y"> <ul className="divide-y">
{library.finished.map((g) => ( {games.map((g) => (
<li key={g.appid} className="flex items-center gap-3 sm:gap-4 px-4 sm:px-5 py-3"> <li key={g.appid} className="flex items-center gap-3 sm:gap-4 px-4 sm:px-5 py-3">
<GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-16 sm:w-24 shrink-0"/> <GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-16 sm:w-24 shrink-0"/>
<span className="min-w-0 flex-1 truncate font-medium">{g.name ?? `App ${g.appid}`}</span> <span className="min-w-0 flex-1 truncate font-medium">{g.name ?? `App ${g.appid}`}</span>
@@ -186,23 +254,5 @@ export default function PublicProfilePage() {
))} ))}
</ul> </ul>
</Card> </Card>
)}
{library.stats && <SharedStats stats={library.stats}/>}
</PageContainer>
);
}
// 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>
); );
} }
+125 -6
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest'; 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 {MemoryRouter, Route, Routes} from 'react-router-dom';
import PublicProfilePage from './PublicProfilePage.jsx'; import PublicProfilePage from './PublicProfilePage.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx'; import {PreferencesProvider} from '../context/PreferencesContext.jsx';
@@ -14,13 +14,15 @@ const library = {
finished: [{appid: 620, name: 'Portal 2', playtimeMinutes: 900, status: 'COMPLETED', rating: 5}], finished: [{appid: 620, name: 'Portal 2', playtimeMinutes: 900, status: 'COMPLETED', rating: 5}],
}; };
function mockBackends({profileStatus = 200, libraryStatus = 200} = {}) { function mockBackends({profileStatus = 200, libraryStatus = 200, style, answer = library, me} = {}) {
globalThis.fetch = vi.fn(async (url) => { 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')) { if (url.includes('/api/public/profiles/library')) {
return {ok: libraryStatus === 200, status: libraryStatus, json: async () => library}; return {ok: libraryStatus === 200, status: libraryStatus, json: async () => answer};
} }
return {ok: profileStatus === 200, status: profileStatus, return {ok: profileStatus === 200, status: profileStatus,
json: async () => ({displayName: 'Lobo', memberSince: '2026-03-02', ticket: 'the-ticket'})}; json: async () => ({displayName: 'Lobo', memberSince: '2026-03-02', ticket: 'the-ticket', style})};
}); });
} }
@@ -65,7 +67,7 @@ describe('PublicProfilePage', () => {
it('shows only the stat groups the user shares, without links', async () => { it('shows only the stat groups the user shares, without links', async () => {
library.stats = {pile: null, trend: null, topGames: null, taste: null, finished: null, library.stats = {pile: null, trend: null, topGames: null, taste: null, finished: null,
habits: {moonTrips: 2.5, longestDay: null, underOneHourGames: 0, underOneHourShare: 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(); renderPage();
expect(await screen.findByText('STATS.HABITS.TITLE')).toBeTruthy(); expect(await screen.findByText('STATS.HABITS.TITLE')).toBeTruthy();
@@ -74,6 +76,123 @@ describe('PublicProfilePage', () => {
delete library.stats; delete library.stats;
}); });
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, tiles: [{id: 'PLAYING', size: 'M'}]}});
const {container} = renderPage();
await screen.findByText('Half-Life 2');
const page = container.querySelector('[data-accent="red"]');
expect(page.dataset.corners).toBe('round');
expect(document.documentElement.dataset.accent).not.toBe('red');
expect([...container.querySelectorAll('img')].map((img) => img.getAttribute('src')))
.toEqual(expect.arrayContaining([expect.stringContaining('/620/'), expect.stringContaining('/220/')]));
});
it('shows the sections in the owner\'s order and leaves out hidden ones', async () => {
mockBackends({
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-tile]')].map((el) => el.dataset.tile))
.toEqual(['FINISHED', 'PLAYING', 'SUMMARY']);
expect(screen.getByText('60')).toBeTruthy();
expect(screen.queryByText('BACKLOG.STATUS.BACKLOG')).toBeNull();
});
it('leaves out the status bar of a profile without games', async () => {
mockBackends({answer: {...library, totalGames: 0, byStatus: {BACKLOG: 0, PLAYING: 0}, playing: [], finished: []}});
const {container} = renderPage();
await screen.findByText('PROFILE.GAMES');
expect([...container.querySelectorAll('[data-tile]')].map((el) => el.dataset.tile)).toEqual(['SUMMARY']);
});
it('shows every section for an Auth without a style', async () => {
mockBackends();
const {container} = renderPage();
await screen.findByText('Portal 2');
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 () => { it('says the profile is unknown or private on a 404', async () => {
mockBackends({profileStatus: 404}); mockBackends({profileStatus: 404});
renderPage(); renderPage();
+25 -58
View File
@@ -18,15 +18,15 @@ import BuildVersionCard from '../components/BuildVersionCard.jsx';
import DeleteAccountCard from '../components/DeleteAccountCard.jsx'; import DeleteAccountCard from '../components/DeleteAccountCard.jsx';
import FormField from '../components/FormField.jsx'; import FormField from '../components/FormField.jsx';
import SteamAccountCard from '../components/SteamAccountCard.jsx'; import SteamAccountCard from '../components/SteamAccountCard.jsx';
import ProfileStatsSettings from '../components/stats/ProfileStatsSettings.jsx';
import {settingsSchema} from '../utils/validation.js'; import {settingsSchema} from '../utils/validation.js';
import {usePreferences} from '../context/PreferencesContext.jsx'; import {usePreferences} from '../context/PreferencesContext.jsx';
import useSessionExpired from '../hooks/useSessionExpired.js'; import useSessionExpired from '../hooks/useSessionExpired.js';
import useSaveAppearance from '../hooks/useSaveAppearance.js'; import useSaveAppearance from '../hooks/useSaveAppearance.js';
import {ACCENTS, CORNERS} from '../utils/appearance.js'; import {AccentPicker, CornersPicker} from '../components/StylePickers.jsx';
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 {coverUrl} from '../utils/games.js';
import {saveProfileStats} from '../utils/stats.js';
import {cn} from '@/lib/utils';
import {apiFetch, refreshSession} from '../utils/api.js'; import {apiFetch, refreshSession} from '../utils/api.js';
import {hasSession, markSession} from '../utils/auth.js'; import {hasSession, markSession} from '../utils/auth.js';
@@ -59,11 +59,12 @@ export default function SettingsPage() {
const expireSession = useSessionExpired(); const expireSession = useSessionExpired();
const {isDarkMode, setDarkMode, setLanguage, appearance} = usePreferences(); const {isDarkMode, setDarkMode, setLanguage, appearance} = usePreferences();
const saveAppearance = useSaveAppearance(); const saveAppearance = useSaveAppearance();
const saveStyle = useSaveProfileStyle();
const [profile, setProfile] = useState(null); const [profile, setProfile] = useState(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState(null); const [loadError, setLoadError] = useState(null);
const [savingProfileStats, setSavingProfileStats] = useState(false); const [savingProfileStyle, setSavingProfileStyle] = useState(false);
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
// Read once: removing the parameter below must not load the profile again. // Read once: removing the parameter below must not load the profile again.
const [steamOutcome] = useState(() => searchParams.get('steam')); const [steamOutcome] = useState(() => searchParams.get('steam'));
@@ -202,29 +203,14 @@ export default function SettingsPage() {
} }
}; };
// Shown at once; the switches wait for the answer, so the saves reach Auth one after the other. // Shown at once; the pickers wait for the answer, so the saves reach Auth one after the other.
const handleProfileStatsChange = async (groups) => { const handleProfileStyleChange = async (changes) => {
const previous = profile?.settings?.profileStats ?? []; const previous = normalizeProfileStyle(profile?.settings?.profileStyle);
const showProfileStats = (next) => setProfile((p) => ({...p, settings: {...p.settings, profileStats: next}})); const showProfileStyle = (next) => setProfile((p) => ({...p, settings: {...p.settings, profileStyle: next}}));
showProfileStats(groups); showProfileStyle({...previous, ...changes});
setSavingProfileStats(true); setSavingProfileStyle(true);
try { showProfileStyle(await saveStyle(changes, previous) ?? previous);
const response = await saveProfileStats(groups); setSavingProfileStyle(false);
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);
}
}; };
const copyProfileLink = async (link) => { const copyProfileLink = async (link) => {
@@ -330,33 +316,8 @@ export default function SettingsPage() {
<CardTitle className="flex items-center gap-2"><Palette className="size-4 text-primary"/>{t('SETTINGS.APPEARANCE')}</CardTitle> <CardTitle className="flex items-center gap-2"><Palette className="size-4 text-primary"/>{t('SETTINGS.APPEARANCE')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <AccentPicker id="accent" value={appearance.accent} onChange={(accent) => saveAppearance({accent})}/>
<Label id="accent-label">{t('SETTINGS.ACCENT')}</Label> <CornersPicker id="corners" value={appearance.corners} onChange={(corners) => saveAppearance({corners})}/>
<div role="group" aria-labelledby="accent-label" className="flex flex-wrap gap-2">
{ACCENTS.map((accent) => (
<button key={accent} type="button" data-accent={accent}
aria-label={t(`SETTINGS.ACCENT_OPTIONS.${accent.toUpperCase()}`)}
title={t(`SETTINGS.ACCENT_OPTIONS.${accent.toUpperCase()}`)}
aria-pressed={appearance.accent === accent}
onClick={() => saveAppearance({accent})}
className={cn('size-8 rounded-full bg-primary ring-offset-2 ring-offset-background transition hover:scale-110',
appearance.accent === accent && 'ring-2 ring-foreground')}/>
))}
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Label id="corners-label">{t('SETTINGS.CORNERS')}</Label>
<div role="group" aria-labelledby="corners-label" className="flex gap-2">
{CORNERS.map((corners) => (
<Button key={corners} type="button" size="sm" data-corners={corners}
variant={appearance.corners === corners ? 'default' : 'outline'}
aria-pressed={appearance.corners === corners}
onClick={() => saveAppearance({corners})}>
{t(`SETTINGS.CORNER_OPTIONS.${corners.toUpperCase()}`)}
</Button>
))}
</div>
</div>
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className="space-y-1"> <div className="space-y-1">
<Label htmlFor="wideLayout">{t('SETTINGS.WIDE_LAYOUT')}</Label> <Label htmlFor="wideLayout">{t('SETTINGS.WIDE_LAYOUT')}</Label>
@@ -459,9 +420,15 @@ export default function SettingsPage() {
</Button> </Button>
</div> </div>
)} )}
<ProfileStatsSettings selected={profile?.settings?.profileStats ?? []} {publicProfile && profileLink && (
disabled={!publicProfile} saving={savingProfileStats} <p className="text-xs text-muted-foreground">{t('SETTINGS.PROFILE_TILES_HINT')}</p>
onChange={handleProfileStatsChange}/> )}
</div>
<div className="mt-6 border-t pt-6">
<ProfileStyleSettings style={normalizeProfileStyle(profile?.settings?.profileStyle)}
disabled={!publicProfile || savingProfileStyle}
onChange={handleProfileStyleChange}/>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
+33 -41
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; import {cleanup, fireEvent, render, screen, waitFor, within} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom'; import {MemoryRouter} from 'react-router-dom';
import SettingsPage from './SettingsPage.jsx'; import SettingsPage from './SettingsPage.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx'; import {PreferencesProvider} from '../context/PreferencesContext.jsx';
@@ -17,6 +17,8 @@ const profile = (overrides = {}) => ({
settings: {useDarkTheme: true, language: 'de', publicProfile: true}, ...overrides, settings: {useDarkTheme: true, language: 'de', publicProfile: true}, ...overrides,
}); });
const appearanceCard = () => within(screen.getByText('SETTINGS.APPEARANCE').closest('[data-slot="card"]'));
const puts = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT').map(([, init]) => JSON.parse(init.body)); const puts = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT').map(([, init]) => JSON.parse(init.body));
function renderPage(me) { function renderPage(me) {
@@ -55,55 +57,44 @@ describe('SettingsPage public profile', () => {
await waitFor(() => expect(screen.queryByText(/\/u\/Lobo$/)).toBeNull()); await waitFor(() => expect(screen.queryByText(/\/u\/Lobo$/)).toBeNull());
}); });
it('saves the stat groups the public profile shows, in a fixed order', async () => { const styled = (profileStyle) => profile({settings: {useDarkTheme: true, language: 'de', publicProfile: true, profileStyle}});
renderPage(profile({settings: {useDarkTheme: true, language: 'de', publicProfile: true, profileStats: ['TREND']}})); 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));
fireEvent.click(await screen.findByRole('switch', {name: 'STATS.PILE.TITLE'})); it('saves the profile accent apart from the app accent', async () => {
renderPage(styled({accent: 'blue', corners: 'sharp', bannerAppid: 620, backgroundAppid: null}));
await waitFor(() => expect(puts()).toHaveLength(1)); await screen.findByText('SETTINGS.PROFILE_STYLE');
expect(puts()[0]).toEqual({groups: ['PILE', 'TREND']}); fireEvent.click(styleGroup('SETTINGS.ACCENT').getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.RED'}));
const [url] = fetch.mock.calls.find(([, init]) => init?.method === 'PUT');
expect(url).toMatch(/\/api\/users\/settings\/profile-stats$/); await waitFor(() => expect(stylePuts()).toEqual([{accent: 'red', corners: 'sharp', bannerAppid: 620,
await waitFor(() => expect(screen.getByRole('switch', {name: 'STATS.PILE.TITLE'}).getAttribute('aria-checked')) backgroundAppid: null}]));
.toBe('true')); expect(styleGroup('SETTINGS.ACCENT').getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.RED'})
.getAttribute('aria-pressed')).toBe('true');
}); });
it('waits for a save before the next switch click', async () => { it('points to the profile page for the tiles', async () => {
const answers = []; renderPage(profile());
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'})); expect(await screen.findByText('SETTINGS.PROFILE_TILES_HINT')).toBeTruthy();
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 () => { it('removes the profile banner', async () => {
const me = profile({settings: {useDarkTheme: true, language: 'de', publicProfile: true, profileStats: []}}); renderPage(styled({bannerAppid: 620}));
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'})); fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.PROFILE_ART_REMOVE'}));
await waitFor(() => expect(screen.getByRole('switch', {name: 'STATS.PILE.TITLE'}).getAttribute('aria-checked')) await waitFor(() => expect(stylePuts()).toHaveLength(1));
.toBe('false')); expect(stylePuts()[0].bannerAppid).toBeNull();
}); });
it('offers the stat groups only while the profile is public', async () => { it('offers the profile look only while the profile is public', async () => {
renderPage(profile({settings: {useDarkTheme: true, language: 'de', publicProfile: false}})); renderPage(profile({settings: {useDarkTheme: true, language: 'de', publicProfile: false}}));
expect((await screen.findByRole('switch', {name: 'STATS.PILE.TITLE'})).disabled).toBe(true); await screen.findByText('SETTINGS.PROFILE_STYLE');
expect(screen.getByText('SETTINGS.PROFILE_STATS_NEEDS_PUBLIC')).toBeTruthy(); expect(screen.queryByText('SETTINGS.PROFILE_TILES_HINT')).toBeNull();
expect(styleGroup('SETTINGS.ACCENT').getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.RED'}).disabled).toBe(true);
}); });
it('needs a display name first', async () => { it('needs a display name first', async () => {
@@ -142,13 +133,14 @@ describe('SettingsPage appearance', () => {
it('saves a picked accent and corner style and marks them as picked', async () => { it('saves a picked accent and corner style and marks them as picked', async () => {
renderPage(profile()); renderPage(profile());
fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.GREEN'})); await screen.findByText('SETTINGS.APPEARANCE');
fireEvent.click(screen.getByRole('button', {name: 'SETTINGS.CORNER_OPTIONS.ROUND'})); fireEvent.click(appearanceCard().getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.GREEN'}));
fireEvent.click(appearanceCard().getByRole('button', {name: 'SETTINGS.CORNER_OPTIONS.ROUND'}));
await waitFor(() => expect(appearancePuts()).toHaveLength(2)); await waitFor(() => expect(appearancePuts()).toHaveLength(2));
expect(appearancePuts()[1]).toEqual({accent: 'green', corners: 'round', wideLayout: false, backgroundAppid: null, bannerAppid: null, showBanner: true}); expect(appearancePuts()[1]).toEqual({accent: 'green', corners: 'round', wideLayout: false, backgroundAppid: null, bannerAppid: null, showBanner: true});
expect(screen.getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.GREEN'}).getAttribute('aria-pressed')).toBe('true'); expect(appearanceCard().getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.GREEN'}).getAttribute('aria-pressed')).toBe('true');
expect(screen.getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.VIOLET'}).getAttribute('aria-pressed')).toBe('false'); expect(appearanceCard().getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.VIOLET'}).getAttribute('aria-pressed')).toBe('false');
}); });
it('removes the background', async () => { it('removes the background', async () => {
+1 -1
View File
@@ -9,7 +9,7 @@ export const DEFAULT_APPEARANCE = {
accent: 'violet', corners: 'default', wideLayout: false, backgroundAppid: null, bannerAppid: null, showBanner: true, accent: 'violet', corners: 'default', wideLayout: false, backgroundAppid: null, bannerAppid: null, showBanner: true,
}; };
const appid = (value) => (Number.isInteger(value) && value > 0 ? value : null); export const appid = (value) => (Number.isInteger(value) && value > 0 ? value : null);
const STORAGE_KEY = 'tbp_appearance'; const STORAGE_KEY = 'tbp_appearance';
const REQUEST_TIMEOUT_MS = 10000; const REQUEST_TIMEOUT_MS = 10000;
+29
View File
@@ -0,0 +1,29 @@
const FINISHED = ['FINISHED', 'COMPLETED'];
/**
* Every achievement unlocked. Only while the stats are visible: Core can still send the old unlock count after they turn private.
*/
export const allAchievementsUnlocked = (game) => game.hasAchievements && game.statsVisible
&& game.achievementsTotal > 0 && game.achievementsUnlocked >= game.achievementsTotal;
/**
* 0: not finished, 1: finished, 2: finished and every achievement unlocked.
*/
export const celebrationLevel = (game) => {
if (!FINISHED.includes(game.status)) return 0;
return allAchievementsUnlocked(game) ? 2 : 1;
};
/**
* The confetti to play when the page shows this game: on opening a finished game, and when the level rises on the
* same page (e.g. the user sets FINISHED). A reload of the same game plays nothing.
*
* @param before {appid, level} of the game shown before, null on opening the page
* @returns 'small', 'big' or null
*/
export const celebrationFor = (before, game) => {
const level = celebrationLevel(game);
const from = before?.appid === game.appid ? before.level : 0;
if (level <= from) return null;
return level === 2 ? 'big' : 'small';
};
+38
View File
@@ -0,0 +1,38 @@
import {describe, expect, it} from 'vitest';
import {celebrationFor, celebrationLevel} from './confetti.js';
const game = (status, unlocked = 0, total = 0, appid = 620) =>
({appid, status, hasAchievements: total > 0, statsVisible: true, achievementsUnlocked: unlocked, achievementsTotal: total});
describe('celebrationLevel', () => {
it('is 0 for a game not finished, 1 for finished, 2 for finished with every achievement', () => {
expect(celebrationLevel(game('PLAYING', 4, 4))).toBe(0);
expect(celebrationLevel(game('FINISHED'))).toBe(1);
expect(celebrationLevel(game('COMPLETED', 3, 4))).toBe(1);
expect(celebrationLevel(game('FINISHED', 4, 4))).toBe(2);
expect(celebrationLevel(game('COMPLETED', 4, 4))).toBe(2);
});
it('counts no achievements while the stats are hidden', () => {
expect(celebrationLevel({...game('FINISHED', 4, 4), statsVisible: false})).toBe(1);
});
});
describe('celebrationFor', () => {
it('plays on opening a finished game', () => {
expect(celebrationFor(null, game('FINISHED'))).toBe('small');
expect(celebrationFor(null, game('COMPLETED', 4, 4))).toBe('big');
expect(celebrationFor(null, game('BACKLOG'))).toBeNull();
});
it('plays when the status rises on the same page, not when the game loads again', () => {
expect(celebrationFor({appid: 620, level: 0}, game('FINISHED'))).toBe('small');
expect(celebrationFor({appid: 620, level: 1}, game('FINISHED', 4, 4))).toBe('big');
expect(celebrationFor({appid: 620, level: 1}, game('FINISHED'))).toBeNull();
expect(celebrationFor({appid: 620, level: 2}, game('PLAYING'))).toBeNull();
});
it('plays for another game opened from the page', () => {
expect(celebrationFor({appid: 620, level: 1}, game('FINISHED', 0, 0, 220))).toBe('small');
});
});
+30
View File
@@ -0,0 +1,30 @@
import {apiFetch} from './api.js';
import {ACCENTS, CORNERS, DEFAULT_APPEARANCE, appid} from './appearance.js';
export const DEFAULT_PROFILE_STYLE = {
accent: DEFAULT_APPEARANCE.accent, corners: DEFAULT_APPEARANCE.corners, bannerAppid: null, backgroundAppid: null,
};
export const normalizeProfileStyle = (value) => ({
accent: ACCENTS.includes(value?.accent) ? value.accent : DEFAULT_PROFILE_STYLE.accent,
corners: CORNERS.includes(value?.corners) ? value.corners : DEFAULT_PROFILE_STYLE.corners,
bannerAppid: appid(value?.bannerAppid),
backgroundAppid: appid(value?.backgroundAppid),
});
/**
* The stored style from GET /api/users/me; null when there is no answer to use.
*/
export const loadProfileStyle = async () => {
const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`);
if (!response.ok) return null;
const data = await response.json();
return normalizeProfileStyle(data.settings?.profileStyle);
};
export const saveProfileStyle = (style) =>
apiFetch(`${import.meta.env.VITE_API_URL}/api/users/settings/profile-style`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(style),
});
+13
View File
@@ -0,0 +1,13 @@
import {describe, expect, it} from 'vitest';
import {DEFAULT_PROFILE_STYLE, normalizeProfileStyle} from './profileStyle.js';
describe('normalizeProfileStyle', () => {
it('is the default without a style, e.g. from an older Auth', () => {
expect(normalizeProfileStyle(undefined)).toEqual(DEFAULT_PROFILE_STYLE);
});
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 * @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']);
});
});