Public Access
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa9e83e0cb | ||
|
|
b55fcfec92 | ||
|
|
a5458b3f86 | ||
|
|
09e1f767d8 |
No files matched your search
@@ -19,7 +19,9 @@ To Be Played is split into three repositories:
|
||||
|
||||
## Features
|
||||
|
||||
- **Dashboard**: most played games, games in progress, "Pile of Shame" with backlog hours, its trend of the last 90
|
||||
- **Dashboard** with an edit mode ("Customize"): the user orders, hides and resizes the tiles (small = half width
|
||||
and less content, large = double width and more), and can add the stat groups of "Your stats" as tiles. Stored
|
||||
in Auth, so it follows the user to the phone; "Reset" goes back to the default. Tiles: most played games, games in progress, "Pile of Shame" with backlog hours, its trend of the last 90
|
||||
days ("this month: +N added, -N off the pile") and a random suggestion,
|
||||
Steam avatar and a banner from the most played game of the last two weeks, and an achievement card: unlocked
|
||||
total, perfect games, average completion and the 5 games closest to 100 %.
|
||||
@@ -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
|
||||
game still in the backlog, and one to set the status to 100 % when
|
||||
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
|
||||
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".
|
||||
@@ -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
|
||||
verify it; unlinking is possible.
|
||||
- **Public profile** at `/u/<display name>`, opt-in in the settings, with perfect games and average completion.
|
||||
The user picks in the settings which stat groups of "Your stats" it shows (none by default).
|
||||
The owner arranges it with the same edit mode on the profile page itself (the tiles, incl. the stat groups of
|
||||
"Your stats"), and sets its look in the settings: accent, corners, banner and background from own game art
|
||||
("Profile art" on a game's page). Visitors see the owner's look on that page only; light or dark mode stays theirs.
|
||||
- **Appearance**: accent color, corner style, wide layout, page background and banner from your own game art.
|
||||
Saved in Auth, so it follows you to every device.
|
||||
- **Broken pictures**: a Steam cover or achievement icon that does not load shows a placeholder and is reported
|
||||
@@ -130,6 +137,26 @@ Tests sit next to the code they test (`Component.test.jsx`).
|
||||
|
||||
## 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.
|
||||
|
||||
```bash
|
||||
|
||||
+29
-4
@@ -35,9 +35,12 @@
|
||||
"ERROR_TOO_MANY_ATTEMPTS": "Zu viele fehlgeschlagene Versuche. Bitte in {{minutes}} Minuten erneut versuchen."
|
||||
},
|
||||
"SETTINGS": {
|
||||
"PROFILE_STATS": "Statistik im öffentlichen Profil",
|
||||
"PROFILE_STATS_HINT": "Wähle, was Besucher sehen. Deine Summen und Status sind immer da.",
|
||||
"PROFILE_STATS_NEEDS_PUBLIC": "Mach dein Profil zuerst öffentlich.",
|
||||
"PROFILE_STYLE": "Aussehen des Profils",
|
||||
"PROFILE_STYLE_HINT": "So sehen Besucher dein öffentliches Profil. Das Aussehen deiner App bleibt, wie es ist.",
|
||||
"PROFILE_BANNER": "Banner",
|
||||
"PROFILE_BACKGROUND": "Hintergrund",
|
||||
"PROFILE_ART_HINT": "Wähle das Bild auf der Seite eines Spiels: \"Profilbild\".",
|
||||
"PROFILE_ART_REMOVE": "Entfernen",
|
||||
"TITLE": "Einstellungen",
|
||||
"LOADING": "Einstellungen werden geladen …",
|
||||
"ACCOUNT": "Konto",
|
||||
@@ -121,7 +124,8 @@
|
||||
"WRONG_PASSWORD": "Das Passwort ist falsch.",
|
||||
"TOO_MANY": "Zu viele falsche Passwörter. Bitte in {{minutes}} Minuten erneut versuchen.",
|
||||
"ERROR": "Das Konto konnte nicht gelöscht werden. Bitte später erneut versuchen."
|
||||
}
|
||||
},
|
||||
"PROFILE_TILES_HINT": "Wähle, was dein Profil zeigt und wo: öffne dein Profil und klicke auf \"Anpassen\"."
|
||||
},
|
||||
"VALIDATION": {
|
||||
"EMAIL_REQUIRED": "Email ist erforderlich.",
|
||||
@@ -238,6 +242,9 @@
|
||||
"TTB_COMPLETELY": "100 %",
|
||||
"SET_BACKGROUND": "Als Hintergrund",
|
||||
"REMOVE_BACKGROUND": "Hintergrund entfernen",
|
||||
"PROFILE_ART": "Profilbild",
|
||||
"PROFILE_BANNER": "Banner im Profil",
|
||||
"PROFILE_BACKGROUND": "Hintergrund im Profil",
|
||||
"SET_BANNER": "Als Banner",
|
||||
"ALL_UNLOCKED": "Du hast alle Erfolge dieses Spiels freigeschaltet.",
|
||||
"SET_COMPLETED": "Auf 100 % setzen",
|
||||
@@ -596,5 +603,23 @@
|
||||
"RANGE": "{{start}} bis {{end}}",
|
||||
"DONE_COUNT_one": "Von {{count}} Nutzer für immer geschlossen",
|
||||
"DONE_COUNT_other": "Von {{count}} Nutzern für immer geschlossen"
|
||||
},
|
||||
"TILES": {
|
||||
"CUSTOMIZE": "Anpassen",
|
||||
"EDIT_HINT": "Verschiebe, vergrößere oder verstecke die Kacheln. Gespeichert wird erst mit \"Fertig\".",
|
||||
"DONE": "Fertig",
|
||||
"CANCEL": "Abbrechen",
|
||||
"RESET": "Zurücksetzen",
|
||||
"HIDDEN": "Versteckte Kacheln",
|
||||
"NO_DATA": "Noch nichts zu zeigen.",
|
||||
"MOVE_UP": "{{name}} nach oben",
|
||||
"MOVE_DOWN": "{{name}} nach unten",
|
||||
"SIZE": "Größe von {{name}}",
|
||||
"HIDE": "{{name}} verstecken",
|
||||
"SIZE_S": "Klein",
|
||||
"SIZE_M": "Normal",
|
||||
"SIZE_L": "Groß",
|
||||
"SUMMARY": "Summen",
|
||||
"STATUS": "Statusleiste"
|
||||
}
|
||||
}
|
||||
+29
-4
@@ -35,9 +35,12 @@
|
||||
"ERROR_TOO_MANY_ATTEMPTS": "Too many failed attempts. Please try again in {{minutes}} minutes."
|
||||
},
|
||||
"SETTINGS": {
|
||||
"PROFILE_STATS": "Stats on the public profile",
|
||||
"PROFILE_STATS_HINT": "Pick what visitors see. Your totals and statuses are always there.",
|
||||
"PROFILE_STATS_NEEDS_PUBLIC": "Make your profile public first.",
|
||||
"PROFILE_STYLE": "Profile look",
|
||||
"PROFILE_STYLE_HINT": "How visitors see your public profile. Your own app look stays as it is.",
|
||||
"PROFILE_BANNER": "Banner",
|
||||
"PROFILE_BACKGROUND": "Background",
|
||||
"PROFILE_ART_HINT": "Pick the art on a game's page: \"Profile art\".",
|
||||
"PROFILE_ART_REMOVE": "Remove",
|
||||
"TITLE": "Settings",
|
||||
"LOADING": "Loading settings …",
|
||||
"ACCOUNT": "Account",
|
||||
@@ -121,7 +124,8 @@
|
||||
"WRONG_PASSWORD": "The password is wrong.",
|
||||
"TOO_MANY": "Too many wrong passwords. Please try again in {{minutes}} minutes.",
|
||||
"ERROR": "The account could not be deleted. Please try again later."
|
||||
}
|
||||
},
|
||||
"PROFILE_TILES_HINT": "Choose what your profile shows, and where: open your profile and click \"Customize\"."
|
||||
},
|
||||
"VALIDATION": {
|
||||
"EMAIL_REQUIRED": "Email is required.",
|
||||
@@ -238,6 +242,9 @@
|
||||
"TTB_COMPLETELY": "100%",
|
||||
"SET_BACKGROUND": "Use as background",
|
||||
"REMOVE_BACKGROUND": "Remove background",
|
||||
"PROFILE_ART": "Profile art",
|
||||
"PROFILE_BANNER": "Profile banner",
|
||||
"PROFILE_BACKGROUND": "Profile background",
|
||||
"SET_BANNER": "Use as banner",
|
||||
"ALL_UNLOCKED": "You unlocked every achievement of this game.",
|
||||
"SET_COMPLETED": "Set to 100 %",
|
||||
@@ -596,5 +603,23 @@
|
||||
"RANGE": "{{start}} to {{end}}",
|
||||
"DONE_COUNT_one": "Closed for good by {{count}} user",
|
||||
"DONE_COUNT_other": "Closed for good by {{count}} users"
|
||||
},
|
||||
"TILES": {
|
||||
"CUSTOMIZE": "Customize",
|
||||
"EDIT_HINT": "Move, resize or hide the tiles. Nothing is saved before \"Done\".",
|
||||
"DONE": "Done",
|
||||
"CANCEL": "Cancel",
|
||||
"RESET": "Reset",
|
||||
"HIDDEN": "Hidden tiles",
|
||||
"NO_DATA": "Nothing to show yet.",
|
||||
"MOVE_UP": "Move {{name}} up",
|
||||
"MOVE_DOWN": "Move {{name}} down",
|
||||
"SIZE": "Size of {{name}}",
|
||||
"HIDE": "Hide {{name}}",
|
||||
"SIZE_S": "Small",
|
||||
"SIZE_M": "Normal",
|
||||
"SIZE_L": "Large",
|
||||
"SUMMARY": "Totals",
|
||||
"STATUS": "Status bar"
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -57,7 +57,8 @@ function App() {
|
||||
<TooltipProvider>
|
||||
{/* 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">
|
||||
{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}/>
|
||||
|
||||
<main className="grow flex flex-col">
|
||||
|
||||
@@ -7,6 +7,7 @@ import {Button} from '@/components/ui/button';
|
||||
import {Card} from '@/components/ui/card';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.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.
|
||||
@@ -18,9 +19,7 @@ export default function CompletedHint({game, onChanged}) {
|
||||
const expireSession = useSessionExpired();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const allUnlocked = game.hasAchievements && game.statsVisible && game.achievementsTotal > 0
|
||||
&& game.achievementsUnlocked >= game.achievementsTotal;
|
||||
if (!allUnlocked || game.status === 'COMPLETED') return null;
|
||||
if (!allAchievementsUnlocked(game) || game.status === 'COMPLETED') return null;
|
||||
|
||||
const setCompleted = async () => {
|
||||
setBusy(true);
|
||||
|
||||
@@ -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"/>;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -4,10 +4,14 @@ import {ArrowRight, Gamepad2} from 'lucide-react';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card} from '@/components/ui/card';
|
||||
import {cn} from '@/lib/utils';
|
||||
import GameCover from './GameCover.jsx';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
|
||||
export default function CurrentlyPlayingCard({games, syncing}) {
|
||||
/**
|
||||
* @param wide four games per row instead of two, for the large tile
|
||||
*/
|
||||
export default function CurrentlyPlayingCard({games, syncing, wide}) {
|
||||
const {t} = useTranslation();
|
||||
return (
|
||||
<Card className="gap-0 py-0">
|
||||
@@ -26,7 +30,7 @@ export default function CurrentlyPlayingCard({games, syncing}) {
|
||||
{syncing ? t('LIBRARY.SYNCING_FIRST_TIME') : t('DASHBOARD.NOTHING_PLAYING')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="grid grid-cols-2 gap-3 p-4">
|
||||
<ul className={cn('grid grid-cols-2 gap-3 p-4', wide && 'sm:grid-cols-4')}>
|
||||
{games.map((g) => (
|
||||
<li key={g.appid}>
|
||||
<Link to={`/library/${g.appid}`} className="group block space-y-1.5">
|
||||
|
||||
@@ -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')}/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,11 +2,14 @@ import {useId} from 'react';
|
||||
|
||||
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.
|
||||
const violet = ['var(--logo-violet, #7467f9)', '#7467f9'];
|
||||
const blue = ['var(--logo-blue, #0192ef)', '#0192ef'];
|
||||
const teal = ['var(--logo-teal, #17c7a4)', '#17c7a4'];
|
||||
/**
|
||||
* The three logo colors as [CSS variable, hex as designed]; style.css sets the variables per accent.
|
||||
*/
|
||||
export const LOGO_COLORS = [['--logo-violet', '#7467f9'], ['--logo-blue', '#0192ef'], ['--logo-teal', '#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];
|
||||
|
||||
function Gradient({id, from, to, stops}) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import {useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {ArrowDown, ArrowUp, Check, EyeOff, Loader2, Plus, RotateCcw, X} from 'lucide-react';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card} from '@/components/ui/card';
|
||||
import {cn} from '@/lib/utils';
|
||||
import {SIZES, spanClass} from '../utils/tiles.js';
|
||||
|
||||
/**
|
||||
* The tiles of a page in their order, in a 12 column grid (one column on a phone). In edit mode each tile gets
|
||||
* move, size and hide buttons, and the hidden tiles can be added again; nothing is saved before "Done".
|
||||
*
|
||||
* @param tiles the tile table (utils/tiles.js)
|
||||
* @param titles tile id -> i18n key of its name in the edit mode
|
||||
* @param renderTile (id, size) => the tile, or null when it has nothing to show (then it is left out, and in edit
|
||||
* mode it shows a placeholder so it can still be moved)
|
||||
* @param defaultLayout offered as "Reset"; none: no reset
|
||||
* @param onSave (layout) => promise; the page ends the edit mode when the save worked
|
||||
*/
|
||||
export default function TileGrid({tiles, titles, layout, renderTile, editing, defaultLayout, onSave, onCancel}) {
|
||||
if (!editing) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-12">
|
||||
{layout.map((tile) => {
|
||||
const content = renderTile(tile.id, tile.size);
|
||||
return content && (
|
||||
// *:h-full: the card fills the grid row, as it did as a direct grid item.
|
||||
<div key={tile.id} data-tile={tile.id} className={cn('min-w-0 *:h-full', spanClass(tiles, tile))}>{content}</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <TileEditor tiles={tiles} titles={titles} layout={layout} renderTile={renderTile} defaultLayout={defaultLayout}
|
||||
onSave={onSave} onCancel={onCancel}/>;
|
||||
}
|
||||
|
||||
// Mounted per edit session, so every session starts from the saved layout.
|
||||
function TileEditor({tiles, titles, layout, renderTile, defaultLayout, onSave, onCancel}) {
|
||||
const {t} = useTranslation();
|
||||
const [shown, setShown] = useState(layout);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const hidden = Object.keys(tiles).filter((id) => !shown.some((tile) => tile.id === id));
|
||||
const move = (index, by) => {
|
||||
const next = [...shown];
|
||||
[next[index], next[index + by]] = [next[index + by], next[index]];
|
||||
setShown(next);
|
||||
};
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
await onSave(shown);
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-dashed border-primary/50 px-4 py-3">
|
||||
<p className="text-sm text-muted-foreground">{t('TILES.EDIT_HINT')}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{defaultLayout && (
|
||||
<Button variant="ghost" size="sm" disabled={saving} onClick={() => setShown(defaultLayout)}>
|
||||
<RotateCcw/>{t('TILES.RESET')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" disabled={saving} onClick={onCancel}><X/>{t('TILES.CANCEL')}</Button>
|
||||
<Button size="sm" disabled={saving} onClick={save}>
|
||||
{saving ? <Loader2 className="animate-spin"/> : <Check/>}{t('TILES.DONE')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-12">
|
||||
{shown.map((tile, index) => {
|
||||
const name = t(titles[tile.id]);
|
||||
const sizes = SIZES.filter((size) => tiles[tile.id][size]);
|
||||
return (
|
||||
<div key={tile.id} data-tile={tile.id} className={cn('min-w-0 space-y-2', spanClass(tiles, tile))}>
|
||||
<div className="flex flex-wrap items-center gap-1 rounded-md bg-muted px-2 py-1">
|
||||
<span className="mr-auto truncate text-sm font-medium">{name}</span>
|
||||
<Button variant="ghost" size="icon" className="size-7" disabled={index === 0}
|
||||
aria-label={t('TILES.MOVE_UP', {name})} onClick={() => move(index, -1)}><ArrowUp/></Button>
|
||||
<Button variant="ghost" size="icon" className="size-7" disabled={index === shown.length - 1}
|
||||
aria-label={t('TILES.MOVE_DOWN', {name})} onClick={() => move(index, 1)}><ArrowDown/></Button>
|
||||
{sizes.length > 1 && (
|
||||
<div role="group" aria-label={t('TILES.SIZE', {name})} className="flex">
|
||||
{sizes.map((size) => (
|
||||
<Button key={size} variant={tile.size === size ? 'default' : 'ghost'} size="sm"
|
||||
className="h-7 px-2" aria-pressed={tile.size === size}
|
||||
aria-label={t(`TILES.SIZE_${size}`)}
|
||||
onClick={() => setShown(shown.map((other) => other.id === tile.id ? {...other, size} : other))}>
|
||||
{size}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" className="size-7" aria-label={t('TILES.HIDE', {name})}
|
||||
onClick={() => setShown(shown.filter((other) => other.id !== tile.id))}><EyeOff/></Button>
|
||||
</div>
|
||||
{/* inert: a click on a game in the tile must not leave the edit mode. */}
|
||||
<div inert className="opacity-80">
|
||||
{renderTile(tile.id, tile.size) || (
|
||||
<Card className="items-center p-6 text-sm text-muted-foreground">{t('TILES.NO_DATA')}</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{hidden.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">{t('TILES.HIDDEN')}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{hidden.map((id) => (
|
||||
<Button key={id} variant="outline" size="sm" onClick={() => setShown([...shown, {id, size: 'M'}])}>
|
||||
<Plus/>{t(titles[id])}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||
import TileGrid from './TileGrid.jsx';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
|
||||
const TILES = {A: {S: 3, M: 6, L: 12}, B: {M: 6}, C: {M: 12}};
|
||||
const TITLES = {A: 'TILES.A', B: 'TILES.B', C: 'TILES.C'};
|
||||
const LAYOUT = [{id: 'A', size: 'M'}, {id: 'B', size: 'M'}];
|
||||
const renderTile = (id, size) => (id === 'C' ? null : <p>{`tile ${id} ${size}`}</p>);
|
||||
|
||||
const renderGrid = (props) => render(
|
||||
<TileGrid tiles={TILES} titles={TITLES} layout={LAYOUT} renderTile={renderTile} editing={false} onSave={vi.fn()}
|
||||
onCancel={vi.fn()} {...props}/>,
|
||||
);
|
||||
const order = (container) => [...container.querySelectorAll('[data-tile]')].map((el) => el.dataset.tile);
|
||||
|
||||
describe('TileGrid', () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it('shows the tiles in order with their spans and leaves out a tile without content', () => {
|
||||
const {container} = renderGrid({layout: [...LAYOUT, {id: 'C', size: 'M'}]});
|
||||
|
||||
expect(order(container)).toEqual(['A', 'B']);
|
||||
expect(container.querySelector('[data-tile="A"]').className).toContain('md:col-span-6');
|
||||
});
|
||||
|
||||
it('moves, resizes, hides and adds tiles, and saves only on "Done"', async () => {
|
||||
const onSave = vi.fn(async () => true);
|
||||
const {container} = renderGrid({editing: true, onSave});
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', {name: 'TILES.MOVE_DOWN'})[0]);
|
||||
expect(order(container)).toEqual(['B', 'A']);
|
||||
fireEvent.click(screen.getByRole('button', {name: 'TILES.SIZE_L'}));
|
||||
fireEvent.click(screen.getAllByRole('button', {name: 'TILES.HIDE'})[0]);
|
||||
fireEvent.click(screen.getByRole('button', {name: 'TILES.C'}));
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {name: 'TILES.DONE'}));
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith([{id: 'A', size: 'L'}, {id: 'C', size: 'M'}]));
|
||||
});
|
||||
|
||||
it('offers sizes only for a tile with more than one, and shows a placeholder for a tile without content', () => {
|
||||
renderGrid({editing: true, layout: [{id: 'B', size: 'M'}, {id: 'C', size: 'M'}]});
|
||||
|
||||
expect(screen.queryByRole('button', {name: 'TILES.SIZE_S'})).toBeNull();
|
||||
expect(screen.getByText('TILES.NO_DATA')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('resets to the default layout and cancels without a save', () => {
|
||||
const onSave = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
const {container} = renderGrid({editing: true, defaultLayout: [{id: 'C', size: 'M'}], onSave, onCancel});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {name: 'TILES.RESET'}));
|
||||
expect(order(container)).toEqual(['C']);
|
||||
fireEvent.click(screen.getByRole('button', {name: 'TILES.CANCEL'}));
|
||||
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('offers no reset without a default layout', () => {
|
||||
renderGrid({editing: true});
|
||||
|
||||
expect(screen.queryByRole('button', {name: 'TILES.RESET'})).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
import {useTranslation} from 'react-i18next';
|
||||
|
||||
import {Label} from '@/components/ui/label';
|
||||
import {Switch} from '@/components/ui/switch';
|
||||
import {PROFILE_STAT_GROUPS} from '../../utils/stats.js';
|
||||
|
||||
/**
|
||||
* One switch per stat group the public profile can show.
|
||||
*
|
||||
* @param disabled the profile is private
|
||||
* @param saving a save runs; the switches wait for it
|
||||
* @param onChange gets the whole new list of groups
|
||||
*/
|
||||
export default function ProfileStatsSettings({selected, disabled, saving, onChange}) {
|
||||
const {t} = useTranslation();
|
||||
const toggle = (group) => onChange(selected.includes(group)
|
||||
? selected.filter((g) => g !== group)
|
||||
: PROFILE_STAT_GROUPS.filter((g) => g === group || selected.includes(g)));
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm font-medium">{t('SETTINGS.PROFILE_STATS')}</span>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{disabled ? t('SETTINGS.PROFILE_STATS_NEEDS_PUBLIC') : t('SETTINGS.PROFILE_STATS_HINT')}
|
||||
</p>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{PROFILE_STAT_GROUPS.map((group) => (
|
||||
<li key={group} className="flex items-center justify-between gap-4">
|
||||
<Label htmlFor={`profileStats-${group}`}>{t(`STATS.${group}.TITLE`)}</Label>
|
||||
<Switch id={`profileStats-${group}`} checked={selected.includes(group)}
|
||||
disabled={disabled || saving}
|
||||
onCheckedChange={() => toggle(group)}/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import OverviewStats from './OverviewStats.jsx';
|
||||
import PileStats from './PileStats.jsx';
|
||||
import TrendStats from './TrendStats.jsx';
|
||||
import TopGamesStats from './TopGamesStats.jsx';
|
||||
import TasteStats from './TasteStats.jsx';
|
||||
import HabitsStats from './HabitsStats.jsx';
|
||||
import FinishedStats from './FinishedStats.jsx';
|
||||
|
||||
/**
|
||||
* One stat group as a tile; null while its data is not there. A function, not a component: a tile grid leaves out a
|
||||
* null tile, and an element is never null.
|
||||
*
|
||||
* @param stats the answer of GET /api/library/stats, or the stats of a public profile
|
||||
* @param linked the game rows link to the game pages; false on the public profile
|
||||
*/
|
||||
export default function statGroupTile(id, stats, linked = true) {
|
||||
switch (id) {
|
||||
case 'STATS_OVERVIEW': return stats?.overview ? <OverviewStats overview={stats.overview}/> : null;
|
||||
case 'STATS_PILE': return stats?.pile ? <PileStats pile={stats.pile} linked={linked}/> : null;
|
||||
case 'STATS_TREND': return stats?.trend ? <TrendStats trend={stats.trend}/> : null;
|
||||
case 'STATS_TOP_GAMES': return stats?.topGames ? <TopGamesStats topGames={stats.topGames} linked={linked}/> : null;
|
||||
case 'STATS_TASTE': return stats?.taste ? <TasteStats taste={stats.taste}/> : null;
|
||||
case 'STATS_HABITS': return stats?.habits ? <HabitsStats habits={stats.habits}/> : null;
|
||||
case 'STATS_FINISHED': return stats?.finished ? <FinishedStats finished={stats.finished}/> : null;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {apiFetch} from '../utils/api.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.
|
||||
*/
|
||||
export default function useCurrentUser(enabled) {
|
||||
@@ -20,7 +22,8 @@ export default function useCurrentUser(enabled) {
|
||||
try {
|
||||
const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`);
|
||||
const data = response.ok ? await response.json() : null;
|
||||
if (!cancelled) setUser(data && {role: data.role, activeRole: data.activeRole ?? data.role});
|
||||
if (!cancelled) setUser(data && {role: data.role, activeRole: data.activeRole ?? data.role, displayName: data.displayName,
|
||||
profileStyle: normalizeProfileStyle(data.settings?.profileStyle)});
|
||||
} catch {
|
||||
if (!cancelled) setUser(null);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
+138
-106
@@ -2,7 +2,7 @@ import React, {useCallback, useEffect, useState} from 'react';
|
||||
import {Link, useNavigate} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {toast} from 'sonner';
|
||||
import {ArrowRight, Hash, Link2, RefreshCw, Trophy} from 'lucide-react';
|
||||
import {ArrowRight, Hash, LayoutGrid, Link2, RefreshCw, Trophy} from 'lucide-react';
|
||||
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Button} from '@/components/ui/button';
|
||||
@@ -21,23 +21,26 @@ import useForceSync from '../hooks/useForceSync.js';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
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 useSteamProfile from '../hooks/useSteamProfile.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 {apiFetch} from '../utils/api.js';
|
||||
import {hasSession} from '../utils/auth.js';
|
||||
|
||||
const TOP_GAMES = 5;
|
||||
// Games per tile size; the page loads the most for the large tile.
|
||||
const TOP_GAMES = {S: 3, M: 5, L: 10};
|
||||
const PLAYING_GAMES = {S: 2, M: 4, L: 8};
|
||||
// The games endpoint accepts only the page sizes 20, 40 and 80.
|
||||
const TOP_GAMES_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc';
|
||||
const PLAYING_GAMES = 4;
|
||||
const PLAYING_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc&status=PLAYING';
|
||||
|
||||
const JSON_HEADERS = {'Content-Type': 'application/json'};
|
||||
const TREND_GROUPS = ['TREND'];
|
||||
|
||||
const Dashboard = () => {
|
||||
const {t} = useTranslation();
|
||||
@@ -54,8 +57,18 @@ const Dashboard = () => {
|
||||
const [shuffling, setShuffling] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const pileTrend = useUserStats(TREND_GROUPS, expireSession, hasSession());
|
||||
const reloadTrend = pileTrend.reload;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const runSave = useSaveResponse();
|
||||
const layout = normalizeLayout(userData?.settings?.dashboardTiles, DASHBOARD_TILES, DEFAULT_DASHBOARD);
|
||||
// The stat groups of the shown stat tiles, and TREND for the pile card. Waits for the saved layout in /me.
|
||||
const statGroups = [...new Set([
|
||||
...layout.map((tile) => statGroup(tile.id)).filter(Boolean),
|
||||
...(layout.some((tile) => tile.id === 'PILE_OF_SHAME') ? ['TREND'] : []),
|
||||
])].sort();
|
||||
// No groups would mean "all groups" to the stats endpoint.
|
||||
const hasStatGroups = statGroups.length > 0;
|
||||
const stats = useUserStats(statGroups, expireSession, hasSession() && userData != null && hasStatGroups);
|
||||
const reloadStats = stats.reload;
|
||||
|
||||
const fetchSteamCount = useCallback(async () => {
|
||||
try {
|
||||
@@ -81,7 +94,7 @@ const Dashboard = () => {
|
||||
}
|
||||
if (r.ok) {
|
||||
const page = await r.json();
|
||||
setTopGames((page.content ?? []).slice(0, TOP_GAMES));
|
||||
setTopGames((page.content ?? []).slice(0, TOP_GAMES.L));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching top games:', err);
|
||||
@@ -97,7 +110,7 @@ const Dashboard = () => {
|
||||
}
|
||||
if (r.ok) {
|
||||
const page = await r.json();
|
||||
setPlaying((page.content ?? []).slice(0, PLAYING_GAMES));
|
||||
setPlaying((page.content ?? []).slice(0, PLAYING_GAMES.L));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching playing games:', err);
|
||||
@@ -173,8 +186,8 @@ const Dashboard = () => {
|
||||
fetchSummary();
|
||||
fetchActivity();
|
||||
fetchAchievements();
|
||||
reloadTrend();
|
||||
}, [fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements, reloadTrend]);
|
||||
if (hasStatGroups) reloadStats();
|
||||
}, [fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements, hasStatGroups, reloadStats]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUserData = async () => {
|
||||
@@ -241,17 +254,49 @@ const Dashboard = () => {
|
||||
}
|
||||
|
||||
const syncBusy = forceSync.busy || sync.isRunning;
|
||||
const maxMinutes = topGames[0]?.playtimeMinutes || 1;
|
||||
|
||||
const saveLayout = async (next) => {
|
||||
// The default is saved as "never changed", so a later change of the default reaches this user too.
|
||||
const tiles = JSON.stringify(next) === JSON.stringify(DEFAULT_DASHBOARD) ? null : next;
|
||||
if (!await runSave(() => saveTiles('dashboard-tiles', tiles))) return;
|
||||
setUserData((d) => ({...d, settings: {...d.settings, dashboardTiles: tiles}}));
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const renderTile = (id, size) => {
|
||||
switch (id) {
|
||||
case 'GAME_COUNT': return showLibrary && <SteamGameCount targetCount={steamData.count}/>;
|
||||
case 'MOST_PLAYED':
|
||||
return showLibrary && <MostPlayedCard games={topGames.slice(0, TOP_GAMES[size])} syncing={sync.isRunning}/>;
|
||||
case 'PLAYING':
|
||||
return showLibrary && (
|
||||
<CurrentlyPlayingCard games={playing.slice(0, PLAYING_GAMES[size])} syncing={sync.isRunning} wide={size === 'L'}/>
|
||||
);
|
||||
case 'PILE_OF_SHAME':
|
||||
if (!showLibrary) return null;
|
||||
if (summary) {
|
||||
return <PileOfShameCard summary={summary} trend={stats.stats?.trend} syncing={sync.isRunning}
|
||||
onShuffle={shuffleSuggestion} shuffling={shuffling}/>;
|
||||
}
|
||||
return summaryFailed && (
|
||||
<Card className="items-center justify-center gap-3 p-6 text-center text-sm text-muted-foreground">
|
||||
{t('DASHBOARD.SUMMARY_FAILED')}
|
||||
<Button variant="outline" size="sm" onClick={() => fetchSummary()}>
|
||||
<RefreshCw/>{t('DASHBOARD.RETRY')}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
case 'ACTIVITY': return hasSteamId && activity && <ActivityCard activity={activity}/>;
|
||||
case 'ACHIEVEMENTS': return hasSteamId && achievements && <AchievementsCard summary={achievements}/>;
|
||||
default: return statGroupTile(id, stats.stats);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||
<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. */}
|
||||
<HeroArt key={bannerAppid} 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')}/>
|
||||
{/* A picked game, else the game played most in the last two weeks. */}
|
||||
<HeroBanner appid={bannerAppid}/>
|
||||
<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. */}
|
||||
{hasSteamId && (
|
||||
@@ -277,101 +322,88 @@ const Dashboard = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasSteamId && (
|
||||
<div className="relative flex flex-col items-start md:items-end gap-3 shrink-0">
|
||||
<SyncStatusBadge
|
||||
status={sync.status}
|
||||
lastSyncedAt={sync.lastSyncedAt}
|
||||
isRunning={sync.isRunning}
|
||||
/>
|
||||
<Button onClick={forceSync.trigger} disabled={syncBusy}>
|
||||
<RefreshCw className={syncBusy ? 'animate-spin' : ''}/>
|
||||
{t('LIBRARY.RESYNC')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative flex flex-col items-start md:items-end gap-3 shrink-0">
|
||||
{hasSteamId && (
|
||||
<>
|
||||
<SyncStatusBadge
|
||||
status={sync.status}
|
||||
lastSyncedAt={sync.lastSyncedAt}
|
||||
isRunning={sync.isRunning}
|
||||
/>
|
||||
<Button onClick={forceSync.trigger} disabled={syncBusy}>
|
||||
<RefreshCw className={syncBusy ? 'animate-spin' : ''}/>
|
||||
{t('LIBRARY.RESYNC')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" disabled={editing} onClick={() => setEditing(true)}>
|
||||
<LayoutGrid/>{t('TILES.CUSTOMIZE')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{showLibrary && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<SteamGameCount targetCount={steamData.count}/>
|
||||
|
||||
<Card className="md:col-span-2 gap-0 py-0">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b">
|
||||
<h2 className="flex items-center gap-2 font-semibold">
|
||||
<Trophy className="size-4 text-amber-500"/>
|
||||
{t('DASHBOARD.MOST_PLAYED')}
|
||||
</h2>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to="/library">{t('DASHBOARD.SHOW_ALL')}<ArrowRight/></Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{topGames.length === 0 ? (
|
||||
<p className="px-5 py-10 text-center text-sm text-muted-foreground">
|
||||
{sync.isRunning ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')}
|
||||
</p>
|
||||
) : (
|
||||
<ol className="divide-y">
|
||||
{topGames.map((g, index) => (
|
||||
<li key={g.appid}>
|
||||
<Link
|
||||
to={`/library/${g.appid}`}
|
||||
className="group flex items-center gap-3 sm:gap-4 px-4 sm:px-5 py-3 transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<span className="w-4 text-right text-sm font-semibold text-muted-foreground tabular-nums">
|
||||
{index + 1}
|
||||
</span>
|
||||
<GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-16 sm:w-24 shrink-0"/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium group-hover:text-primary transition-colors">
|
||||
{g.name ?? `App ${g.appid}`}
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary"
|
||||
style={{width: `${Math.max(2, (g.playtimeMinutes / maxMinutes) * 100)}%`}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="w-12 sm:w-14 text-right text-sm font-medium text-muted-foreground tabular-nums">
|
||||
{formatPlaytime(g.playtimeMinutes)}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showLibrary && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<CurrentlyPlayingCard games={playing} syncing={sync.isRunning}/>
|
||||
{summary && (
|
||||
<PileOfShameCard summary={summary} trend={pileTrend.stats?.trend} syncing={sync.isRunning}
|
||||
onShuffle={shuffleSuggestion} shuffling={shuffling}/>
|
||||
)}
|
||||
{!summary && summaryFailed && (
|
||||
<Card className="items-center justify-center gap-3 p-6 text-center text-sm text-muted-foreground">
|
||||
{t('DASHBOARD.SUMMARY_FAILED')}
|
||||
<Button variant="outline" size="sm" onClick={() => fetchSummary()}>
|
||||
<RefreshCw/>{t('DASHBOARD.RETRY')}
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasSteamId && activity && <ActivityCard activity={activity}/>}
|
||||
{hasSteamId && achievements && <AchievementsCard summary={achievements}/>}
|
||||
<TileGrid tiles={DASHBOARD_TILES} titles={DASHBOARD_TITLES} layout={layout} renderTile={renderTile} editing={editing}
|
||||
defaultLayout={DEFAULT_DASHBOARD} onSave={saveLayout} onCancel={() => setEditing(false)}/>
|
||||
{/* Last child: space-y gives every other child a bottom margin, which would move the fixed card. */}
|
||||
<DashboardHint/>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
function MostPlayedCard({games, syncing}) {
|
||||
const {t} = useTranslation();
|
||||
const maxMinutes = games[0]?.playtimeMinutes || 1;
|
||||
return (
|
||||
<Card className="gap-0 py-0">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b">
|
||||
<h2 className="flex items-center gap-2 font-semibold">
|
||||
<Trophy className="size-4 text-amber-500"/>
|
||||
{t('DASHBOARD.MOST_PLAYED')}
|
||||
</h2>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to="/library">{t('DASHBOARD.SHOW_ALL')}<ArrowRight/></Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{games.length === 0 ? (
|
||||
<p className="px-5 py-10 text-center text-sm text-muted-foreground">
|
||||
{syncing ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')}
|
||||
</p>
|
||||
) : (
|
||||
<ol className="divide-y">
|
||||
{games.map((g, index) => (
|
||||
<li key={g.appid}>
|
||||
<Link
|
||||
to={`/library/${g.appid}`}
|
||||
className="group flex items-center gap-3 sm:gap-4 px-4 sm:px-5 py-3 transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<span className="w-4 text-right text-sm font-semibold text-muted-foreground tabular-nums">
|
||||
{index + 1}
|
||||
</span>
|
||||
<GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-16 sm:w-24 shrink-0"/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium group-hover:text-primary transition-colors">
|
||||
{g.name ?? `App ${g.appid}`}
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary"
|
||||
style={{width: `${Math.max(2, (g.playtimeMinutes / maxMinutes) * 100)}%`}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="w-12 sm:w-14 text-right text-sm font-medium text-muted-foreground tabular-nums">
|
||||
{formatPlaytime(g.playtimeMinutes)}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardSkeleton() {
|
||||
return (
|
||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, render, screen, waitFor} from '@testing-library/react';
|
||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||
import {MemoryRouter, Route, Routes, useLocation} from 'react-router-dom';
|
||||
import Dashboard from './Dashboard.jsx';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||
@@ -49,9 +49,13 @@ describe('Dashboard', () => {
|
||||
});
|
||||
|
||||
const avatar = 'https://avatars.steamstatic.com/abc_full.jpg';
|
||||
const mockDashboard = () => {
|
||||
globalThis.fetch = vi.fn(async (url) => {
|
||||
if (url.includes('/api/users/me')) return ok({email: 'a@b.de', displayName: 'Lobo', steamId: '76561198000000000'});
|
||||
const games = Array.from({length: 12}, (_, i) => ({appid: 100 + i, name: `Game ${i}`, playtimeMinutes: 1000 - i}));
|
||||
const mockDashboard = (settings) => {
|
||||
globalThis.fetch = vi.fn(async (url, init) => {
|
||||
if (init?.method === 'PUT') return {ok: true, status: 204, json: async () => ({})};
|
||||
if (url.includes('/api/users/me')) return ok({email: 'a@b.de', displayName: 'Lobo', steamId: '76561198000000000', settings});
|
||||
if (url.includes('sort=playtimeMinutes') && !url.includes('status=')) return ok({content: games, total: 12});
|
||||
if (url.includes('/api/library/stats')) return ok({trend: null, habits: {moonTrips: 1, longestDay: null, underOneHourGames: 0, underOneHourShare: null}});
|
||||
if (url.includes('/api/library/steam-profile')) return ok({personaName: 'Lobo', avatarUrl: avatar});
|
||||
if (url.includes('/api/library/activity')) return ok({totalMinutes: 90, days: [{day: '2026-09-23', minutes: 0}, {day: '2026-09-24', minutes: 90}], recentGames: [
|
||||
{appid: 1086940, name: "Baldur's Gate 3", playtimeMinutes: 900, playtime2Weeks: 90, status: 'PLAYING'}]});
|
||||
@@ -74,6 +78,55 @@ describe('Dashboard', () => {
|
||||
expect(container.querySelector('img[src$="/1086940/library_hero.jpg"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
const order = (container) => [...container.querySelectorAll('[data-tile]')].map((el) => el.dataset.tile);
|
||||
|
||||
it('shows today\'s tiles without a saved layout', async () => {
|
||||
const container = mockDashboard();
|
||||
|
||||
await screen.findByText('DASHBOARD.ACHIEVEMENTS.TITLE');
|
||||
expect(order(container)).toEqual(['GAME_COUNT', 'MOST_PLAYED', 'PLAYING', 'PILE_OF_SHAME', 'ACTIVITY', 'ACHIEVEMENTS']);
|
||||
expect(screen.getByText('Game 4')).toBeTruthy();
|
||||
expect(screen.queryByText('Game 5')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows a saved layout with its sizes and loads the stats of its stat tiles', async () => {
|
||||
const container = mockDashboard({dashboardTiles: [{id: 'STATS_HABITS', size: 'L'}, {id: 'MOST_PLAYED', size: 'S'}]});
|
||||
|
||||
await screen.findByText('STATS.HABITS.TITLE');
|
||||
expect(order(container)).toEqual(['STATS_HABITS', 'MOST_PLAYED']);
|
||||
expect(screen.getByText('Game 2')).toBeTruthy();
|
||||
expect(screen.queryByText('Game 3')).toBeNull();
|
||||
// No pile tile, so no TREND.
|
||||
expect(fetch).toHaveBeenCalledWith(expect.stringMatching(/\/api\/library\/stats\?groups=HABITS$/), expect.anything());
|
||||
});
|
||||
|
||||
it('leaves out a stat tile whose data did not come', async () => {
|
||||
const container = mockDashboard({dashboardTiles: [{id: 'STATS_PILE', size: 'M'}, {id: 'MOST_PLAYED', size: 'M'}]});
|
||||
|
||||
await screen.findByText('Game 0');
|
||||
expect(order(container)).toEqual(['MOST_PLAYED']);
|
||||
});
|
||||
|
||||
it('saves the default layout as "never changed"', async () => {
|
||||
mockDashboard({dashboardTiles: [{id: 'MOST_PLAYED', size: 'S'}]});
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', {name: 'TILES.CUSTOMIZE'}));
|
||||
fireEvent.click(screen.getByRole('button', {name: 'TILES.RESET'}));
|
||||
fireEvent.click(screen.getByRole('button', {name: 'TILES.DONE'}));
|
||||
|
||||
await waitFor(() => expect(fetch.mock.calls.some(([, init]) => init?.method === 'PUT')).toBe(true));
|
||||
const [url, init] = fetch.mock.calls.find(([, i]) => i?.method === 'PUT');
|
||||
expect(url).toMatch(/\/api\/users\/settings\/dashboard-tiles$/);
|
||||
expect(JSON.parse(init.body)).toEqual({tiles: null});
|
||||
expect(await screen.findByRole('button', {name: 'TILES.CUSTOMIZE'})).toBeTruthy();
|
||||
});
|
||||
it('puts "Customize" into the welcome card', async () => {
|
||||
mockDashboard();
|
||||
|
||||
const button = await screen.findByRole('button', {name: 'TILES.CUSTOMIZE'});
|
||||
expect(button.closest('[data-slot="card"]')?.textContent ?? 'not in a card').toContain('DASHBOARD.WELCOME_BACK');
|
||||
});
|
||||
|
||||
it('shows the achievement summary', async () => {
|
||||
mockDashboard();
|
||||
|
||||
|
||||
@@ -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 {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';
|
||||
@@ -10,6 +10,7 @@ import {Card} from '@/components/ui/card';
|
||||
import {Skeleton} from '@/components/ui/skeleton';
|
||||
import {cn} from '@/lib/utils';
|
||||
import BacklogCard from '../components/BacklogCard.jsx';
|
||||
import Confetti from '../components/Confetti.jsx';
|
||||
import GameCover from '../components/GameCover.jsx';
|
||||
import GameStatusBadge from '../components/GameStatusBadge.jsx';
|
||||
import ManualGameCard from '../components/ManualGameCard.jsx';
|
||||
@@ -25,9 +26,11 @@ import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import useSaveAppearance from '../hooks/useSaveAppearance.js';
|
||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import ProfileArtMenu from '../components/ProfileArtMenu.jsx';
|
||||
import {apiFetch} from '../utils/api.js';
|
||||
import {ACHIEVEMENT_ICONS, reportBrokenImage} from '../utils/brokenImages.js';
|
||||
import {canCorrect} from '../utils/corrections.js';
|
||||
import {celebrationFor, celebrationLevel} from '../utils/confetti.js';
|
||||
import {setGameHidden} from '../utils/hiddenGames.js';
|
||||
import {hasSession} from '../utils/auth.js';
|
||||
import {isSteamKey} from '../utils/manualGames.js';
|
||||
@@ -70,7 +73,20 @@ export default function GameDetail() {
|
||||
const [correcting, setCorrecting] = useState(false);
|
||||
const [merging, setMerging] = useState(false);
|
||||
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(() => {
|
||||
let cancelled = false;
|
||||
@@ -257,6 +273,9 @@ export default function GameDetail() {
|
||||
<PanelTop/>{t('GAME_DETAIL.SET_BANNER')}
|
||||
</Button>
|
||||
)}
|
||||
{profileStyle && (
|
||||
<ProfileArtMenu appid={data.appid} style={profileStyle} onSaved={setSavedProfileStyle}/>
|
||||
)}
|
||||
{!manual && (
|
||||
<Button variant="outline" size="sm" onClick={toggleHidden} disabled={hiding}>
|
||||
{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. */}
|
||||
{data && auditor && (
|
||||
<GameCorrectionDialog game={data} open={correcting} onOpenChange={setCorrecting}
|
||||
|
||||
@@ -242,6 +242,70 @@ describe('GameDetail of a game added by hand', () => {
|
||||
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', () => {
|
||||
const loggedInAs = (me) => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
+156
-106
@@ -1,21 +1,30 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {Link, useParams} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {CircleCheck, Clock, Gamepad2, Library, Percent, Star, Trophy, UserRoundX} from 'lucide-react';
|
||||
import {CircleCheck, Clock, Gamepad2, LayoutGrid, Library, Percent, Star, Trophy, UserRoundX} from 'lucide-react';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card} from '@/components/ui/card';
|
||||
import {Skeleton} from '@/components/ui/skeleton';
|
||||
import {cn} from '@/lib/utils';
|
||||
import GameCover from '../components/GameCover.jsx';
|
||||
import GameStatusBadge, {STATUS_META} from '../components/GameStatusBadge.jsx';
|
||||
import {GAME_STATUSES} from '../utils/backlog.js';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
import {hasSession} from '../utils/auth.js';
|
||||
import {normalizeProfileStyle} from '../utils/profileStyle.js';
|
||||
import {DEFAULT_PROFILE, PROFILE_TILES, PROFILE_TITLES, normalizeLayout, saveTiles} from '../utils/tiles.js';
|
||||
import useCurrentUser from '../hooks/useCurrentUser.js';
|
||||
import useSaveResponse from '../hooks/useSaveResponse.js';
|
||||
import HeroBanner from '../components/HeroBanner.jsx';
|
||||
import PageBackground from '../components/PageBackground.jsx';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import PileStats from '../components/stats/PileStats.jsx';
|
||||
import TrendStats from '../components/stats/TrendStats.jsx';
|
||||
import TopGamesStats from '../components/stats/TopGamesStats.jsx';
|
||||
import TasteStats from '../components/stats/TasteStats.jsx';
|
||||
import HabitsStats from '../components/stats/HabitsStats.jsx';
|
||||
import FinishedStats from '../components/stats/FinishedStats.jsx';
|
||||
import TileGrid from '../components/TileGrid.jsx';
|
||||
import statGroupTile from '../components/stats/statGroupTile.jsx';
|
||||
|
||||
// Games per tile size; Core sends at most 8 playing and 12 finished games.
|
||||
const PLAYING_GAMES = {S: 4, M: 8};
|
||||
const FINISHED_GAMES = {S: 6, M: 12};
|
||||
|
||||
/**
|
||||
* Public, no login. Auth returns a short-lived ticket for a public profile; Core returns the data for it.
|
||||
@@ -26,6 +35,10 @@ export default function PublicProfilePage() {
|
||||
const [profile, setProfile] = useState(null);
|
||||
const [library, setLibrary] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [reloads, setReloads] = useState(0);
|
||||
const viewer = useCurrentUser(hasSession());
|
||||
const runSave = useSaveResponse();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -67,7 +80,7 @@ export default function PublicProfilePage() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [name, t]);
|
||||
}, [name, t, reloads]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
@@ -90,11 +103,70 @@ export default function PublicProfilePage() {
|
||||
);
|
||||
}
|
||||
|
||||
const finishedCount = (library.byStatus.FINISHED ?? 0) + (library.byStatus.COMPLETED ?? 0);
|
||||
const style = normalizeProfileStyle(profile.style);
|
||||
const memberSince = profile.memberSince
|
||||
? new Date(`${profile.memberSince}T12:00:00`).toLocaleDateString(i18n.language, {year: 'numeric', month: 'long'})
|
||||
: 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 (
|
||||
<div data-accent={style.accent} data-corners={style.corners}>
|
||||
<PageBackground appid={style.backgroundAppid}/>
|
||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||
<Card className="relative overflow-hidden p-6 md:p-8">
|
||||
<HeroBanner appid={style.bannerAppid}/>
|
||||
<div className="relative">
|
||||
<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>
|
||||
{memberSince && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">{t('PROFILE.MEMBER_SINCE', {date: memberSince})}</p>
|
||||
)}
|
||||
</div>
|
||||
{owner && !editing && (
|
||||
<Button variant="outline" size="sm" className="relative mt-4" onClick={() => setEditing(true)}>
|
||||
<LayoutGrid/>{t('TILES.CUSTOMIZE')}
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
<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, finishedCount.toLocaleString(i18n.language), t('PROFILE.FINISHED')],
|
||||
[CircleCheck, (library.finishedGames ?? 0).toLocaleString(i18n.language), t('PROFILE.FINISHED')],
|
||||
];
|
||||
if (library.averageCompletion != null) {
|
||||
stats.push(
|
||||
@@ -102,107 +174,85 @@ export default function PublicProfilePage() {
|
||||
[Percent, `${library.averageCompletion.toLocaleString(i18n.language)} %`, t('PROFILE.AVERAGE_COMPLETION')],
|
||||
);
|
||||
}
|
||||
const memberSince = profile.memberSince
|
||||
? new Date(`${profile.memberSince}T12:00:00`).toLocaleDateString(i18n.language, {year: 'numeric', month: 'long'})
|
||||
: null;
|
||||
|
||||
return (
|
||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||
<Card className="relative overflow-hidden p-6 md:p-8 gap-6">
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-primary/15 via-transparent to-transparent"/>
|
||||
<div className="relative">
|
||||
<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>
|
||||
{memberSince && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">{t('PROFILE.MEMBER_SINCE', {date: memberSince})}</p>
|
||||
)}
|
||||
<Card className={cn('grid gap-3 p-5', stats.length > 3 && !compact ? 'grid-cols-2 sm:grid-cols-5' : 'grid-cols-3')}>
|
||||
{stats.map(([Icon, value, label]) => (
|
||||
<div key={label} className="rounded-lg bg-muted/50 p-3 text-center">
|
||||
<Icon className="mx-auto mb-1 size-4 text-primary"/>
|
||||
<div className="text-xl md:text-2xl font-bold tabular-nums">{value}</div>
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
</div>
|
||||
<div className={`relative grid gap-3 ${stats.length > 3 ? 'grid-cols-2 sm:grid-cols-5' : 'grid-cols-3'}`}>
|
||||
{stats.map(([Icon, value, label]) => (
|
||||
<div key={label} className="rounded-lg bg-muted/50 p-3 text-center">
|
||||
<Icon className="mx-auto mb-1 size-4 text-primary"/>
|
||||
<div className="text-xl md:text-2xl font-bold tabular-nums">{value}</div>
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{library.totalGames > 0 && (
|
||||
<div className="relative space-y-2">
|
||||
<div className="flex h-2.5 overflow-hidden rounded-full bg-muted">
|
||||
{GAME_STATUSES.map((s) => (
|
||||
<div key={s} className={STATUS_META[s].bar}
|
||||
style={{width: `${((library.byStatus[s] ?? 0) / library.totalGames) * 100}%`}}/>
|
||||
))}
|
||||
</div>
|
||||
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
{GAME_STATUSES.map((s) => (
|
||||
<li key={s} className="flex items-center gap-1.5">
|
||||
<span className={`size-2 rounded-full ${STATUS_META[s].bar}`}/>
|
||||
{t(`BACKLOG.STATUS.${s}`)}
|
||||
<span className="tabular-nums text-foreground">{library.byStatus[s] ?? 0}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{library.playing.length > 0 && (
|
||||
<Card className="gap-4 p-5">
|
||||
<h2 className="flex items-center gap-2 font-semibold">
|
||||
<Gamepad2 className="size-4 text-primary"/>{t('PROFILE.PLAYING')}
|
||||
</h2>
|
||||
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{library.playing.map((g) => (
|
||||
<li key={g.appid} className="space-y-1.5">
|
||||
<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="text-xs text-muted-foreground tabular-nums">{formatPlaytime(g.playtimeMinutes)}</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{library.finished.length > 0 && (
|
||||
<Card className="gap-0 py-0">
|
||||
<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')}
|
||||
</h2>
|
||||
<ul className="divide-y">
|
||||
{library.finished.map((g) => (
|
||||
<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"/>
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{g.name ?? `App ${g.appid}`}</span>
|
||||
{g.rating && (
|
||||
<span className="flex items-center gap-0.5" aria-label={t('BACKLOG.RATING_STARS', {count: g.rating})}>
|
||||
{Array.from({length: g.rating}, (_, i) => (
|
||||
<Star key={i} className="size-3.5 fill-amber-400 text-amber-400"/>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
<GameStatusBadge status={g.status} compact/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{library.stats && <SharedStats stats={library.stats}/>}
|
||||
</PageContainer>
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Only the groups the user shares come back; the game rows do not link, the library pages are private.
|
||||
function SharedStats({stats}) {
|
||||
function StatusBar({byStatus}) {
|
||||
const {t} = useTranslation();
|
||||
const total = GAME_STATUSES.reduce((sum, s) => sum + (byStatus[s] ?? 0), 0);
|
||||
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>
|
||||
<Card className="gap-2 p-5">
|
||||
<div className="flex h-2.5 overflow-hidden rounded-full bg-muted">
|
||||
{GAME_STATUSES.map((s) => (
|
||||
<div key={s} className={STATUS_META[s].bar} style={{width: `${((byStatus[s] ?? 0) / total) * 100}%`}}/>
|
||||
))}
|
||||
</div>
|
||||
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
{GAME_STATUSES.map((s) => (
|
||||
<li key={s} className="flex items-center gap-1.5">
|
||||
<span className={`size-2 rounded-full ${STATUS_META[s].bar}`}/>
|
||||
{t(`BACKLOG.STATUS.${s}`)}
|
||||
<span className="tabular-nums text-foreground">{byStatus[s] ?? 0}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Playing({games, compact}) {
|
||||
const {t} = useTranslation();
|
||||
return (
|
||||
<Card className="gap-4 p-5">
|
||||
<h2 className="flex items-center gap-2 font-semibold">
|
||||
<Gamepad2 className="size-4 text-primary"/>{t('PROFILE.PLAYING')}
|
||||
</h2>
|
||||
<ul className={cn('grid grid-cols-2 gap-3', !compact && 'sm:grid-cols-4')}>
|
||||
{games.map((g) => (
|
||||
<li key={g.appid} className="space-y-1.5">
|
||||
<GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-full"/>
|
||||
<div className="truncate text-sm font-medium">{g.name ?? `App ${g.appid}`}</div>
|
||||
<div className="text-xs text-muted-foreground tabular-nums">{formatPlaytime(g.playtimeMinutes)}</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Finished({games}) {
|
||||
const {t} = useTranslation();
|
||||
return (
|
||||
<Card className="gap-0 py-0">
|
||||
<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')}
|
||||
</h2>
|
||||
<ul className="divide-y">
|
||||
{games.map((g) => (
|
||||
<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"/>
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{g.name ?? `App ${g.appid}`}</span>
|
||||
{g.rating && (
|
||||
<span className="flex items-center gap-0.5" aria-label={t('BACKLOG.RATING_STARS', {count: g.rating})}>
|
||||
{Array.from({length: g.rating}, (_, i) => (
|
||||
<Star key={i} className="size-3.5 fill-amber-400 text-amber-400"/>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
<GameStatusBadge status={g.status} compact/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, render, screen, waitFor} from '@testing-library/react';
|
||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||
import {MemoryRouter, Route, Routes} from 'react-router-dom';
|
||||
import PublicProfilePage from './PublicProfilePage.jsx';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||
@@ -14,13 +14,15 @@ const library = {
|
||||
finished: [{appid: 620, name: 'Portal 2', playtimeMinutes: 900, status: 'COMPLETED', rating: 5}],
|
||||
};
|
||||
|
||||
function mockBackends({profileStatus = 200, libraryStatus = 200} = {}) {
|
||||
globalThis.fetch = vi.fn(async (url) => {
|
||||
function mockBackends({profileStatus = 200, libraryStatus = 200, style, answer = library, me} = {}) {
|
||||
globalThis.fetch = vi.fn(async (url, init) => {
|
||||
if (init?.method === 'PUT') return {ok: true, status: 204, json: async () => ({})};
|
||||
if (url.includes('/api/users/me')) return {ok: !!me, status: me ? 200 : 401, json: async () => me};
|
||||
if (url.includes('/api/public/profiles/library')) {
|
||||
return {ok: libraryStatus === 200, status: libraryStatus, json: async () => library};
|
||||
return {ok: libraryStatus === 200, status: libraryStatus, json: async () => answer};
|
||||
}
|
||||
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 () => {
|
||||
library.stats = {pile: null, trend: null, topGames: null, taste: null, finished: null,
|
||||
habits: {moonTrips: 2.5, longestDay: null, underOneHourGames: 0, underOneHourShare: null}};
|
||||
mockBackends();
|
||||
mockBackends({style: {tiles: [{id: 'STATS_HABITS', size: 'M'}, {id: 'STATS_PILE', size: 'M'}]}});
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText('STATS.HABITS.TITLE')).toBeTruthy();
|
||||
@@ -74,6 +76,123 @@ describe('PublicProfilePage', () => {
|
||||
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 () => {
|
||||
mockBackends({profileStatus: 404});
|
||||
renderPage();
|
||||
|
||||
+25
-58
@@ -18,15 +18,15 @@ import BuildVersionCard from '../components/BuildVersionCard.jsx';
|
||||
import DeleteAccountCard from '../components/DeleteAccountCard.jsx';
|
||||
import FormField from '../components/FormField.jsx';
|
||||
import SteamAccountCard from '../components/SteamAccountCard.jsx';
|
||||
import ProfileStatsSettings from '../components/stats/ProfileStatsSettings.jsx';
|
||||
import {settingsSchema} from '../utils/validation.js';
|
||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
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 {saveProfileStats} from '../utils/stats.js';
|
||||
import {cn} from '@/lib/utils';
|
||||
import {apiFetch, refreshSession} from '../utils/api.js';
|
||||
import {hasSession, markSession} from '../utils/auth.js';
|
||||
|
||||
@@ -59,11 +59,12 @@ export default function SettingsPage() {
|
||||
const expireSession = useSessionExpired();
|
||||
const {isDarkMode, setDarkMode, setLanguage, appearance} = usePreferences();
|
||||
const saveAppearance = useSaveAppearance();
|
||||
const saveStyle = useSaveProfileStyle();
|
||||
|
||||
const [profile, setProfile] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState(null);
|
||||
const [savingProfileStats, setSavingProfileStats] = useState(false);
|
||||
const [savingProfileStyle, setSavingProfileStyle] = useState(false);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
// Read once: removing the parameter below must not load the profile again.
|
||||
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.
|
||||
const handleProfileStatsChange = async (groups) => {
|
||||
const previous = profile?.settings?.profileStats ?? [];
|
||||
const showProfileStats = (next) => setProfile((p) => ({...p, settings: {...p.settings, profileStats: next}}));
|
||||
showProfileStats(groups);
|
||||
setSavingProfileStats(true);
|
||||
try {
|
||||
const response = await saveProfileStats(groups);
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
showProfileStats(previous);
|
||||
toast.error(t('SETTINGS.ERROR_SAVE'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Profile stats save error:', err);
|
||||
showProfileStats(previous);
|
||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||
} finally {
|
||||
setSavingProfileStats(false);
|
||||
}
|
||||
// Shown at once; the pickers wait for the answer, so the saves reach Auth one after the other.
|
||||
const handleProfileStyleChange = async (changes) => {
|
||||
const previous = normalizeProfileStyle(profile?.settings?.profileStyle);
|
||||
const showProfileStyle = (next) => setProfile((p) => ({...p, settings: {...p.settings, profileStyle: next}}));
|
||||
showProfileStyle({...previous, ...changes});
|
||||
setSavingProfileStyle(true);
|
||||
showProfileStyle(await saveStyle(changes, previous) ?? previous);
|
||||
setSavingProfileStyle(false);
|
||||
};
|
||||
|
||||
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>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Label id="accent-label">{t('SETTINGS.ACCENT')}</Label>
|
||||
<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>
|
||||
<AccentPicker id="accent" value={appearance.accent} onChange={(accent) => saveAppearance({accent})}/>
|
||||
<CornersPicker id="corners" value={appearance.corners} onChange={(corners) => saveAppearance({corners})}/>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="wideLayout">{t('SETTINGS.WIDE_LAYOUT')}</Label>
|
||||
@@ -459,9 +420,15 @@ export default function SettingsPage() {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<ProfileStatsSettings selected={profile?.settings?.profileStats ?? []}
|
||||
disabled={!publicProfile} saving={savingProfileStats}
|
||||
onChange={handleProfileStatsChange}/>
|
||||
{publicProfile && profileLink && (
|
||||
<p className="text-xs text-muted-foreground">{t('SETTINGS.PROFILE_TILES_HINT')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border-t pt-6">
|
||||
<ProfileStyleSettings style={normalizeProfileStyle(profile?.settings?.profileStyle)}
|
||||
disabled={!publicProfile || savingProfileStyle}
|
||||
onChange={handleProfileStyleChange}/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
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 SettingsPage from './SettingsPage.jsx';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||
@@ -17,6 +17,8 @@ const profile = (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));
|
||||
|
||||
function renderPage(me) {
|
||||
@@ -55,55 +57,44 @@ describe('SettingsPage public profile', () => {
|
||||
await waitFor(() => expect(screen.queryByText(/\/u\/Lobo$/)).toBeNull());
|
||||
});
|
||||
|
||||
it('saves the stat groups the public profile shows, in a fixed order', async () => {
|
||||
renderPage(profile({settings: {useDarkTheme: true, language: 'de', publicProfile: true, profileStats: ['TREND']}}));
|
||||
const styled = (profileStyle) => profile({settings: {useDarkTheme: true, language: 'de', publicProfile: true, profileStyle}});
|
||||
const styleGroup = (label) => within(screen.getAllByRole('group', {name: label})[1]);
|
||||
const stylePuts = () => fetch.mock.calls.filter(([url, init]) => init?.method === 'PUT' && url.endsWith('/profile-style'))
|
||||
.map(([, init]) => JSON.parse(init.body));
|
||||
|
||||
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));
|
||||
expect(puts()[0]).toEqual({groups: ['PILE', 'TREND']});
|
||||
const [url] = fetch.mock.calls.find(([, init]) => init?.method === 'PUT');
|
||||
expect(url).toMatch(/\/api\/users\/settings\/profile-stats$/);
|
||||
await waitFor(() => expect(screen.getByRole('switch', {name: 'STATS.PILE.TITLE'}).getAttribute('aria-checked'))
|
||||
.toBe('true'));
|
||||
await screen.findByText('SETTINGS.PROFILE_STYLE');
|
||||
fireEvent.click(styleGroup('SETTINGS.ACCENT').getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.RED'}));
|
||||
|
||||
await waitFor(() => expect(stylePuts()).toEqual([{accent: 'red', corners: 'sharp', bannerAppid: 620,
|
||||
backgroundAppid: null}]));
|
||||
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 () => {
|
||||
const answers = [];
|
||||
const me = profile({settings: {useDarkTheme: true, language: 'de', publicProfile: true, profileStats: []}});
|
||||
globalThis.fetch = vi.fn((url, init) => (init?.method === 'PUT'
|
||||
? new Promise((resolve) => answers.push(() => resolve({ok: true, status: 204, json: async () => ({})})))
|
||||
: Promise.resolve({ok: true, status: 200, json: async () => me})));
|
||||
render(<MemoryRouter><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>);
|
||||
it('points to the profile page for the tiles', async () => {
|
||||
renderPage(profile());
|
||||
|
||||
fireEvent.click(await screen.findByRole('switch', {name: 'STATS.PILE.TITLE'}));
|
||||
await waitFor(() => expect(screen.getByRole('switch', {name: 'STATS.TASTE.TITLE'}).disabled).toBe(true));
|
||||
answers.forEach((answer) => answer());
|
||||
await waitFor(() => expect(screen.getByRole('switch', {name: 'STATS.TASTE.TITLE'}).disabled).toBe(false));
|
||||
fireEvent.click(screen.getByRole('switch', {name: 'STATS.TASTE.TITLE'}));
|
||||
|
||||
await waitFor(() => expect(puts()).toHaveLength(2));
|
||||
expect(puts()[1]).toEqual({groups: ['PILE', 'TASTE']});
|
||||
expect(await screen.findByText('SETTINGS.PROFILE_TILES_HINT')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('switches the group back when the save fails', async () => {
|
||||
const me = profile({settings: {useDarkTheme: true, language: 'de', publicProfile: true, profileStats: []}});
|
||||
globalThis.fetch = vi.fn(async (url, init) => (init?.method === 'PUT'
|
||||
? {ok: false, status: 500, json: async () => ({})}
|
||||
: {ok: true, status: 200, json: async () => me}));
|
||||
render(<MemoryRouter><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>);
|
||||
it('removes the profile banner', async () => {
|
||||
renderPage(styled({bannerAppid: 620}));
|
||||
|
||||
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'))
|
||||
.toBe('false'));
|
||||
await waitFor(() => expect(stylePuts()).toHaveLength(1));
|
||||
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}}));
|
||||
|
||||
expect((await screen.findByRole('switch', {name: 'STATS.PILE.TITLE'})).disabled).toBe(true);
|
||||
expect(screen.getByText('SETTINGS.PROFILE_STATS_NEEDS_PUBLIC')).toBeTruthy();
|
||||
await screen.findByText('SETTINGS.PROFILE_STYLE');
|
||||
expect(screen.queryByText('SETTINGS.PROFILE_TILES_HINT')).toBeNull();
|
||||
expect(styleGroup('SETTINGS.ACCENT').getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.RED'}).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('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 () => {
|
||||
renderPage(profile());
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.GREEN'}));
|
||||
fireEvent.click(screen.getByRole('button', {name: 'SETTINGS.CORNER_OPTIONS.ROUND'}));
|
||||
await screen.findByText('SETTINGS.APPEARANCE');
|
||||
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));
|
||||
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(screen.getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.VIOLET'}).getAttribute('aria-pressed')).toBe('false');
|
||||
expect(appearanceCard().getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.GREEN'}).getAttribute('aria-pressed')).toBe('true');
|
||||
expect(appearanceCard().getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.VIOLET'}).getAttribute('aria-pressed')).toBe('false');
|
||||
});
|
||||
|
||||
it('removes the background', async () => {
|
||||
|
||||
@@ -9,7 +9,7 @@ export const DEFAULT_APPEARANCE = {
|
||||
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 REQUEST_TIMEOUT_MS = 10000;
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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});
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,3 @@
|
||||
import {apiFetch} from './api.js';
|
||||
|
||||
// The groups a user can show on the public profile; the overview numbers are always there.
|
||||
export const PROFILE_STAT_GROUPS = ['PILE', 'TREND', 'TOP_GAMES', 'TASTE', 'HABITS', 'FINISHED'];
|
||||
|
||||
/**
|
||||
* PUT /api/users/settings/profile-stats on Auth. The caller handles the response status.
|
||||
*/
|
||||
export const saveProfileStats = (groups) =>
|
||||
apiFetch(`${import.meta.env.VITE_API_URL}/api/users/settings/profile-stats`, {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({groups}),
|
||||
});
|
||||
|
||||
/**
|
||||
* @param groups comma separated stat groups of Core (StatGroup); empty for all
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import {apiFetch} from './api.js';
|
||||
|
||||
/**
|
||||
* The stat group of Core a STATS_ tile shows, e.g. STATS_TOP_GAMES -> TOP_GAMES; null for any other tile.
|
||||
*/
|
||||
export function statGroup(id) {
|
||||
return id.startsWith('STATS_') ? id.slice('STATS_'.length) : null;
|
||||
}
|
||||
|
||||
// Tile id -> its column span per size, in a 12 column grid. A size not listed is not offered. M is the look from
|
||||
// before the layouts. Auth accepts only these ids (DashboardTilesRequest, ProfileTilesRequest).
|
||||
const STAT_TILES = {
|
||||
STATS_PILE: {M: 6, L: 12},
|
||||
STATS_TREND: {M: 6, L: 12},
|
||||
STATS_TOP_GAMES: {M: 6, L: 12},
|
||||
STATS_TASTE: {M: 6, L: 12},
|
||||
STATS_HABITS: {M: 6, L: 12},
|
||||
STATS_FINISHED: {M: 6, L: 12},
|
||||
};
|
||||
|
||||
export const DASHBOARD_TILES = {
|
||||
GAME_COUNT: {S: 2, M: 4, L: 8},
|
||||
MOST_PLAYED: {S: 4, M: 8, L: 12},
|
||||
PLAYING: {S: 3, M: 6, L: 12},
|
||||
PILE_OF_SHAME: {M: 6, L: 12},
|
||||
ACTIVITY: {S: 6, M: 12},
|
||||
ACHIEVEMENTS: {S: 6, M: 12},
|
||||
STATS_OVERVIEW: {M: 12},
|
||||
...STAT_TILES,
|
||||
};
|
||||
|
||||
export const PROFILE_TILES = {
|
||||
SUMMARY: {S: 6, M: 12},
|
||||
STATUS: {S: 6, M: 12},
|
||||
PLAYING: {S: 6, M: 12},
|
||||
FINISHED: {S: 6, M: 12},
|
||||
...STAT_TILES,
|
||||
};
|
||||
|
||||
// The i18n key of each tile's name in the edit mode: the heading of its card where it has one.
|
||||
const STAT_TITLES = Object.fromEntries(Object.keys(STAT_TILES).map((id) => [id, `STATS.${statGroup(id)}.TITLE`]));
|
||||
|
||||
export const DASHBOARD_TITLES = {
|
||||
GAME_COUNT: 'DASHBOARD.STEAM_GAMES', MOST_PLAYED: 'DASHBOARD.MOST_PLAYED', PLAYING: 'DASHBOARD.CURRENTLY_PLAYING',
|
||||
PILE_OF_SHAME: 'DASHBOARD.PILE_OF_SHAME', ACTIVITY: 'DASHBOARD.ACTIVITY', ACHIEVEMENTS: 'DASHBOARD.ACHIEVEMENTS.TITLE',
|
||||
STATS_OVERVIEW: 'STATS.OVERVIEW.TITLE', ...STAT_TITLES,
|
||||
};
|
||||
|
||||
export const PROFILE_TITLES = {
|
||||
SUMMARY: 'TILES.SUMMARY', STATUS: 'TILES.STATUS', PLAYING: 'PROFILE.PLAYING', FINISHED: 'PROFILE.FINISHED_GAMES',
|
||||
...STAT_TITLES,
|
||||
};
|
||||
|
||||
const all = (ids) => ids.map((id) => ({id, size: 'M'}));
|
||||
|
||||
export const DEFAULT_DASHBOARD = all(['GAME_COUNT', 'MOST_PLAYED', 'PLAYING', 'PILE_OF_SHAME', 'ACTIVITY', 'ACHIEVEMENTS']);
|
||||
export const DEFAULT_PROFILE = all(['SUMMARY', 'STATUS', 'PLAYING', 'FINISHED']);
|
||||
|
||||
export const SIZES = ['S', 'M', 'L'];
|
||||
|
||||
// Written out, so Tailwind finds every class.
|
||||
const SPANS = {
|
||||
2: 'md:col-span-2', 3: 'md:col-span-3', 4: 'md:col-span-4', 6: 'md:col-span-6', 8: 'md:col-span-8',
|
||||
12: 'md:col-span-12',
|
||||
};
|
||||
|
||||
export const spanClass = (tiles, {id, size}) => SPANS[tiles[id][size]];
|
||||
|
||||
/**
|
||||
* A saved layout as the page can use it: unknown and repeated ids are left out, a size the tile does not offer
|
||||
* becomes M. A saved layout of a tile removed later so breaks nothing.
|
||||
*
|
||||
* @param saved list of {id, size}; null or not a list gives the fallback
|
||||
*/
|
||||
export const normalizeLayout = (saved, tiles, fallback) => {
|
||||
if (!Array.isArray(saved)) return fallback;
|
||||
const seen = new Set();
|
||||
return saved.filter((tile) => tiles[tile?.id] && !seen.has(tile.id) && seen.add(tile.id))
|
||||
.map(({id, size}) => ({id, size: tiles[id][size] ? size : 'M'}));
|
||||
};
|
||||
|
||||
/**
|
||||
* @param path dashboard-tiles or profile-tiles
|
||||
* @param layout null resets the dashboard to its default
|
||||
*/
|
||||
export const saveTiles = (path, layout) =>
|
||||
apiFetch(`${import.meta.env.VITE_API_URL}/api/users/settings/${path}`, {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({tiles: layout}),
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {DASHBOARD_TILES, DEFAULT_DASHBOARD, normalizeLayout, spanClass} from './tiles.js';
|
||||
|
||||
describe('normalizeLayout', () => {
|
||||
it('is the fallback without a saved list', () => {
|
||||
expect(normalizeLayout(null, DASHBOARD_TILES, DEFAULT_DASHBOARD)).toBe(DEFAULT_DASHBOARD);
|
||||
expect(normalizeLayout(undefined, DASHBOARD_TILES, DEFAULT_DASHBOARD)).toBe(DEFAULT_DASHBOARD);
|
||||
});
|
||||
|
||||
it('drops unknown and repeated tiles and makes a size the tile does not offer M', () => {
|
||||
expect(normalizeLayout([
|
||||
{id: 'STATS_TREND', size: 'L'}, {id: 'WISHLIST', size: 'M'}, {id: 'PILE_OF_SHAME', size: 'S'},
|
||||
{id: 'STATS_TREND', size: 'M'}, null,
|
||||
], DASHBOARD_TILES, DEFAULT_DASHBOARD)).toEqual([{id: 'STATS_TREND', size: 'L'}, {id: 'PILE_OF_SHAME', size: 'M'}]);
|
||||
});
|
||||
|
||||
it('keeps an empty list: every tile hidden', () => {
|
||||
expect(normalizeLayout([], DASHBOARD_TILES, DEFAULT_DASHBOARD)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('spanClass', () => {
|
||||
it('halves and doubles the normal width', () => {
|
||||
expect(['S', 'M', 'L'].map((size) => spanClass(DASHBOARD_TILES, {id: 'MOST_PLAYED', size})))
|
||||
.toEqual(['md:col-span-4', 'md:col-span-8', 'md:col-span-12']);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user