feat(profile): let users design their public profile

This commit is contained in:
lobo committed 2026-09-28 14:20:14 +02:00
1 parent a5458b3f86
commit b55fcfec92
20 files changed
+674 -138

No files matched your search

+3 -1
View File
@@ -50,7 +50,9 @@ To Be Played is split into three repositories:
the proved Steam ID, never a typed-in one. A Steam ID typed in before shows as "not verified" with a button to the proved Steam ID, never a typed-in one. A Steam ID typed in before shows as "not verified" with a button to
verify it; unlinking is possible. verify it; unlinking is possible.
- **Public profile** at `/u/<display name>`, opt-in in the settings, with perfect games and average completion. - **Public profile** at `/u/<display name>`, opt-in in the settings, with perfect games and average completion.
The user picks in the settings which stat groups of "Your stats" it shows (none by default). The user picks in the settings which stat groups of "Your stats" it shows (none by default), and designs its
look: accent, corners, banner and background from own game art ("Profile art" on a game's page), and which
sections it shows in which order. Visitors see the owner's look on that page only; light or dark mode stays theirs.
- **Appearance**: accent color, corner style, wide layout, page background and banner from your own game art. - **Appearance**: accent color, corner style, wide layout, page background and banner from your own game art.
Saved in Auth, so it follows you to every device. Saved in Auth, so it follows you to every device.
- **Broken pictures**: a Steam cover or achievement icon that does not load shows a placeholder and is reported - **Broken pictures**: a Steam cover or achievement icon that does not load shows a placeholder and is reported
+20 -1
View File
@@ -36,8 +36,24 @@
}, },
"SETTINGS": { "SETTINGS": {
"PROFILE_STATS": "Statistik im öffentlichen Profil", "PROFILE_STATS": "Statistik im öffentlichen Profil",
"PROFILE_STATS_HINT": "Wähle, was Besucher sehen. Deine Summen und Status sind immer da.", "PROFILE_STATS_HINT": "Wähle die Statistiken, die Besucher sehen. Sie erscheinen, wenn der Abschnitt Statistiken unten an ist.",
"PROFILE_STATS_NEEDS_PUBLIC": "Mach dein Profil zuerst öffentlich.", "PROFILE_STATS_NEEDS_PUBLIC": "Mach dein Profil zuerst öffentlich.",
"PROFILE_STYLE": "Aussehen des Profils",
"PROFILE_STYLE_HINT": "So sehen Besucher dein öffentliches Profil. Das Aussehen deiner App bleibt, wie es ist.",
"PROFILE_BANNER": "Banner",
"PROFILE_BACKGROUND": "Hintergrund",
"PROFILE_ART_HINT": "Wähle das Bild auf der Seite eines Spiels: \"Profilbild\".",
"PROFILE_ART_REMOVE": "Entfernen",
"PROFILE_SECTIONS": "Abschnitte und Reihenfolge",
"PROFILE_SECTION": {
"SUMMARY": "Summen",
"STATUS": "Statusleiste",
"PLAYING": "Spielt gerade",
"FINISHED": "Beendete Spiele",
"STATS": "Statistiken"
},
"PROFILE_SECTION_UP": "{{name}} nach oben",
"PROFILE_SECTION_DOWN": "{{name}} nach unten",
"TITLE": "Einstellungen", "TITLE": "Einstellungen",
"LOADING": "Einstellungen werden geladen …", "LOADING": "Einstellungen werden geladen …",
"ACCOUNT": "Konto", "ACCOUNT": "Konto",
@@ -238,6 +254,9 @@
"TTB_COMPLETELY": "100 %", "TTB_COMPLETELY": "100 %",
"SET_BACKGROUND": "Als Hintergrund", "SET_BACKGROUND": "Als Hintergrund",
"REMOVE_BACKGROUND": "Hintergrund entfernen", "REMOVE_BACKGROUND": "Hintergrund entfernen",
"PROFILE_ART": "Profilbild",
"PROFILE_BANNER": "Banner im Profil",
"PROFILE_BACKGROUND": "Hintergrund im Profil",
"SET_BANNER": "Als Banner", "SET_BANNER": "Als Banner",
"ALL_UNLOCKED": "Du hast alle Erfolge dieses Spiels freigeschaltet.", "ALL_UNLOCKED": "Du hast alle Erfolge dieses Spiels freigeschaltet.",
"SET_COMPLETED": "Auf 100 % setzen", "SET_COMPLETED": "Auf 100 % setzen",
+20 -1
View File
@@ -36,8 +36,24 @@
}, },
"SETTINGS": { "SETTINGS": {
"PROFILE_STATS": "Stats on the public profile", "PROFILE_STATS": "Stats on the public profile",
"PROFILE_STATS_HINT": "Pick what visitors see. Your totals and statuses are always there.", "PROFILE_STATS_HINT": "Pick the stat groups visitors see. They show when the Stats section below is on.",
"PROFILE_STATS_NEEDS_PUBLIC": "Make your profile public first.", "PROFILE_STATS_NEEDS_PUBLIC": "Make your profile public first.",
"PROFILE_STYLE": "Profile look",
"PROFILE_STYLE_HINT": "How visitors see your public profile. Your own app look stays as it is.",
"PROFILE_BANNER": "Banner",
"PROFILE_BACKGROUND": "Background",
"PROFILE_ART_HINT": "Pick the art on a game's page: \"Profile art\".",
"PROFILE_ART_REMOVE": "Remove",
"PROFILE_SECTIONS": "Sections and their order",
"PROFILE_SECTION": {
"SUMMARY": "Totals",
"STATUS": "Status bar",
"PLAYING": "Playing",
"FINISHED": "Finished games",
"STATS": "Stats"
},
"PROFILE_SECTION_UP": "Move {{name}} up",
"PROFILE_SECTION_DOWN": "Move {{name}} down",
"TITLE": "Settings", "TITLE": "Settings",
"LOADING": "Loading settings …", "LOADING": "Loading settings …",
"ACCOUNT": "Account", "ACCOUNT": "Account",
@@ -238,6 +254,9 @@
"TTB_COMPLETELY": "100%", "TTB_COMPLETELY": "100%",
"SET_BACKGROUND": "Use as background", "SET_BACKGROUND": "Use as background",
"REMOVE_BACKGROUND": "Remove background", "REMOVE_BACKGROUND": "Remove background",
"PROFILE_ART": "Profile art",
"PROFILE_BANNER": "Profile banner",
"PROFILE_BACKGROUND": "Profile background",
"SET_BANNER": "Use as banner", "SET_BANNER": "Use as banner",
"ALL_UNLOCKED": "You unlocked every achievement of this game.", "ALL_UNLOCKED": "You unlocked every achievement of this game.",
"SET_COMPLETED": "Set to 100 %", "SET_COMPLETED": "Set to 100 %",
+2 -1
View File
@@ -57,7 +57,8 @@ function App() {
<TooltipProvider> <TooltipProvider>
{/* isolate: the background layer sits behind the content but in front of this div's own background. */} {/* isolate: the background layer sits behind the content but in front of this div's own background. */}
<div className="isolate min-h-screen flex flex-col bg-background text-foreground font-sans"> <div className="isolate min-h-screen flex flex-col bg-background text-foreground font-sans">
{isAuthenticated && <PageBackground appid={appearance.backgroundAppid}/>} {/* A public profile shows the owner's background instead. */}
{isAuthenticated && !location.pathname.startsWith('/u/') && <PageBackground appid={appearance.backgroundAppid}/>}
<AppHeader isAuthenticated={isAuthenticated} onLogout={handleLogout}/> <AppHeader isAuthenticated={isAuthenticated} onLogout={handleLogout}/>
<main className="grow flex flex-col"> <main className="grow flex flex-col">
+17
View File
@@ -0,0 +1,17 @@
import {cn} from '@/lib/utils';
import HeroArt from './HeroArt.jsx';
/**
* The art of a game behind a card's content, or the accent gradient without one. Put it first in a card with
* `relative overflow-hidden`; the gradient keeps the text readable.
*/
export default function HeroBanner({appid}) {
return (
<>
<HeroArt key={appid} appid={appid} className="pointer-events-none absolute inset-0 size-full object-cover opacity-60"/>
<div className={cn('pointer-events-none absolute inset-0', appid
? 'bg-gradient-to-r from-card via-card/85 to-card/60'
: 'bg-gradient-to-br from-primary/15 via-transparent to-transparent')}/>
</>
);
}
+47
View File
@@ -0,0 +1,47 @@
import {useState} from 'react';
import {useTranslation} from 'react-i18next';
import {UserRound} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import useSaveProfileStyle from '../hooks/useSaveProfileStyle.js';
/**
* Uses the art of this Steam game as banner or background of the public profile; a checked item removes it again.
*
* @param onSaved gets the whole saved style
*/
export default function ProfileArtMenu({appid, style, onSaved}) {
const {t} = useTranslation();
const save = useSaveProfileStyle();
const [saving, setSaving] = useState(false);
const toggle = async (field) => {
setSaving(true);
const saved = await save((stored) => ({[field]: stored[field] === appid ? null : appid}), style);
if (saved) onSaved(saved);
setSaving(false);
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={saving}><UserRound/>{t('GAME_DETAIL.PROFILE_ART')}</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuCheckboxItem checked={style.bannerAppid === appid} onCheckedChange={() => toggle('bannerAppid')}>
{t('GAME_DETAIL.PROFILE_BANNER')}
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem checked={style.backgroundAppid === appid}
onCheckedChange={() => toggle('backgroundAppid')}>
{t('GAME_DETAIL.PROFILE_BACKGROUND')}
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
+97
View File
@@ -0,0 +1,97 @@
import {useTranslation} from 'react-i18next';
import {ArrowDown, ArrowUp, ImageOff} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Label} from '@/components/ui/label';
import {Switch} from '@/components/ui/switch';
import {AccentPicker, CornersPicker} from './StylePickers.jsx';
import {PROFILE_SECTIONS} from '../utils/profileStyle.js';
import {coverUrl} from '../utils/games.js';
function ArtRow({id, label, appid, onRemove, disabled}) {
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, background and the sections in their order.
*
* @param disabled the profile is private, or a save runs
* @param onChange gets the changed fields only
*/
export default function ProfileStyleSettings({style, disabled, onChange}) {
const {t} = useTranslation();
const shown = style.sections;
const rows = [...shown, ...PROFILE_SECTIONS.filter((s) => !shown.includes(s))];
const move = (index, by) => {
const next = [...shown];
[next[index], next[index + by]] = [next[index + by], next[index]];
onChange({sections: next});
};
const toggle = (section) => onChange({
sections: shown.includes(section) ? shown.filter((s) => s !== section) : [...shown, section],
});
return (
<div className="space-y-6">
<div className="space-y-1">
<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 className="space-y-3">
<span id="profile-sections-label" className="text-sm font-medium">{t('SETTINGS.PROFILE_SECTIONS')}</span>
<ul aria-labelledby="profile-sections-label" className="space-y-2">
{rows.map((section) => {
const index = shown.indexOf(section);
const name = t(`SETTINGS.PROFILE_SECTION.${section}`);
return (
<li key={section} className="flex items-center justify-between gap-4">
<Label htmlFor={`profileSection-${section}`}>{name}</Label>
<div className="flex items-center gap-1">
<Button type="button" variant="ghost" size="icon" className="size-7"
aria-label={t('SETTINGS.PROFILE_SECTION_UP', {name})}
disabled={disabled || index <= 0} onClick={() => move(index, -1)}>
<ArrowUp/>
</Button>
<Button type="button" variant="ghost" size="icon" className="size-7"
aria-label={t('SETTINGS.PROFILE_SECTION_DOWN', {name})}
disabled={disabled || index < 0 || index === shown.length - 1}
onClick={() => move(index, 1)}>
<ArrowDown/>
</Button>
<Switch id={`profileSection-${section}`} checked={index >= 0} disabled={disabled}
className="ml-2" onCheckedChange={() => toggle(section)}/>
</div>
</li>
);
})}
</ul>
</div>
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
import {useTranslation} from 'react-i18next';
import {Button} from '@/components/ui/button';
import {Label} from '@/components/ui/label';
import {cn} from '@/lib/utils';
import {ACCENTS, CORNERS} from '../utils/appearance.js';
/**
* One round button per accent preset; each button carries its own preset, so it shows its own color.
*
* @param id prefix for the label id, unique on the page
*/
export function AccentPicker({id, value, onChange, disabled}) {
const {t} = useTranslation();
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Label id={`${id}-label`}>{t('SETTINGS.ACCENT')}</Label>
<div role="group" aria-labelledby={`${id}-label`} className="flex flex-wrap gap-2">
{ACCENTS.map((accent) => (
<button key={accent} type="button" data-accent={accent} disabled={disabled}
aria-label={t(`SETTINGS.ACCENT_OPTIONS.${accent.toUpperCase()}`)}
title={t(`SETTINGS.ACCENT_OPTIONS.${accent.toUpperCase()}`)}
aria-pressed={value === accent}
onClick={() => onChange(accent)}
className={cn('size-8 rounded-full bg-primary ring-offset-2 ring-offset-background transition hover:scale-110 disabled:opacity-50 disabled:hover:scale-100',
value === accent && 'ring-2 ring-foreground')}/>
))}
</div>
</div>
);
}
/**
* @param id prefix for the label id, unique on the page
*/
export function CornersPicker({id, value, onChange, disabled}) {
const {t} = useTranslation();
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Label id={`${id}-label`}>{t('SETTINGS.CORNERS')}</Label>
<div role="group" aria-labelledby={`${id}-label`} className="flex gap-2">
{CORNERS.map((corners) => (
<Button key={corners} type="button" size="sm" data-corners={corners} disabled={disabled}
variant={value === corners ? 'default' : 'outline'}
aria-pressed={value === corners}
onClick={() => onChange(corners)}>
{t(`SETTINGS.CORNER_OPTIONS.${corners.toUpperCase()}`)}
</Button>
))}
</div>
</div>
);
}
+5 -2
View File
@@ -1,9 +1,11 @@
import {useEffect, useState} from 'react'; import {useEffect, useState} from 'react';
import {apiFetch} from '../utils/api.js'; import {apiFetch} from '../utils/api.js';
import {sessionStamp} from '../utils/auth.js'; import {sessionStamp} from '../utils/auth.js';
import {normalizeProfileStyle} from '../utils/profileStyle.js';
/** /**
* The stored role and the active role ("view as") of the logged-in user, or null while loading and on any error. * The stored role, the active role ("view as") and the public profile style of the logged-in user, or null while
* loading and on any error.
* Loads again after a new login or a role switch (see sessionStamp) on the next render. * Loads again after a new login or a role switch (see sessionStamp) on the next render.
*/ */
export default function useCurrentUser(enabled) { export default function useCurrentUser(enabled) {
@@ -20,7 +22,8 @@ export default function useCurrentUser(enabled) {
try { try {
const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`); const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`);
const data = response.ok ? await response.json() : null; const data = response.ok ? await response.json() : null;
if (!cancelled) setUser(data && {role: data.role, activeRole: data.activeRole ?? data.role}); if (!cancelled) setUser(data && {role: data.role, activeRole: data.activeRole ?? data.role,
profileStyle: normalizeProfileStyle(data.settings?.profileStyle)});
} catch { } catch {
if (!cancelled) setUser(null); if (!cancelled) setUser(null);
} }
+36
View File
@@ -0,0 +1,36 @@
import {useCallback} from 'react';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
import useSessionExpired from './useSessionExpired.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 {t} = useTranslation();
const expireSession = useSessionExpired();
return useCallback(async (changes, shown) => {
try {
const stored = await loadProfileStyle() ?? shown;
const style = {...stored, ...(typeof changes === 'function' ? changes(stored) : changes)};
const response = await saveProfileStyle(style);
if (response.status === 401) {
expireSession();
return null;
}
if (!response.ok) {
toast.error(t('SETTINGS.ERROR_SAVE'));
return null;
}
return style;
} catch (err) {
console.error('Profile style save error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
return null;
}
}, [expireSession, t]);
}
+3 -8
View File
@@ -21,11 +21,10 @@ import useForceSync from '../hooks/useForceSync.js';
import {formatPlaytime} from '../utils/games.js'; import {formatPlaytime} from '../utils/games.js';
import useSessionExpired from '../hooks/useSessionExpired.js'; import useSessionExpired from '../hooks/useSessionExpired.js';
import PageContainer from '../components/PageContainer.jsx'; import PageContainer from '../components/PageContainer.jsx';
import HeroArt from '../components/HeroArt.jsx'; import HeroBanner from '../components/HeroBanner.jsx';
import SteamAvatar from '../components/SteamAvatar.jsx'; import SteamAvatar from '../components/SteamAvatar.jsx';
import useSteamProfile from '../hooks/useSteamProfile.js'; import useSteamProfile from '../hooks/useSteamProfile.js';
import useUserStats from '../hooks/useUserStats.js'; import useUserStats from '../hooks/useUserStats.js';
import {cn} from '@/lib/utils';
import {usePreferences} from '../context/PreferencesContext.jsx'; import {usePreferences} from '../context/PreferencesContext.jsx';
import {apiFetch} from '../utils/api.js'; import {apiFetch} from '../utils/api.js';
import {hasSession} from '../utils/auth.js'; import {hasSession} from '../utils/auth.js';
@@ -246,12 +245,8 @@ const Dashboard = () => {
return ( return (
<PageContainer className="p-4 md:p-8 space-y-6"> <PageContainer className="p-4 md:p-8 space-y-6">
<Card className="relative overflow-hidden p-6 md:p-8 flex flex-col md:flex-row md:items-center md:justify-between gap-6"> <Card className="relative overflow-hidden p-6 md:p-8 flex flex-col md:flex-row md:items-center md:justify-between gap-6">
{/* A picked game, else the game played most in the last two weeks; the gradient keeps the text readable. */} {/* A picked game, else the game played most in the last two weeks. */}
<HeroArt key={bannerAppid} appid={bannerAppid} <HeroBanner appid={bannerAppid}/>
className="pointer-events-none absolute inset-0 size-full object-cover opacity-60"/>
<div className={cn('pointer-events-none absolute inset-0', bannerAppid
? 'bg-gradient-to-r from-card via-card/85 to-card/60'
: 'bg-gradient-to-br from-primary/15 via-transparent to-transparent')}/>
<div className="relative flex min-w-0 items-center gap-4"> <div className="relative flex min-w-0 items-center gap-4">
{/* The slot is there before the avatar loads, so the title does not jump. */} {/* The slot is there before the avatar loads, so the title does not jump. */}
{hasSteamId && ( {hasSteamId && (
+8 -1
View File
@@ -26,6 +26,7 @@ import useSessionExpired from '../hooks/useSessionExpired.js';
import useSaveAppearance from '../hooks/useSaveAppearance.js'; import useSaveAppearance from '../hooks/useSaveAppearance.js';
import {usePreferences} from '../context/PreferencesContext.jsx'; import {usePreferences} from '../context/PreferencesContext.jsx';
import PageContainer from '../components/PageContainer.jsx'; import PageContainer from '../components/PageContainer.jsx';
import ProfileArtMenu from '../components/ProfileArtMenu.jsx';
import {apiFetch} from '../utils/api.js'; import {apiFetch} from '../utils/api.js';
import {ACHIEVEMENT_ICONS, reportBrokenImage} from '../utils/brokenImages.js'; import {ACHIEVEMENT_ICONS, reportBrokenImage} from '../utils/brokenImages.js';
import {canCorrect} from '../utils/corrections.js'; import {canCorrect} from '../utils/corrections.js';
@@ -72,7 +73,10 @@ export default function GameDetail() {
const [correcting, setCorrecting] = useState(false); const [correcting, setCorrecting] = useState(false);
const [merging, setMerging] = useState(false); const [merging, setMerging] = useState(false);
const [reloads, setReloads] = useState(0); const [reloads, setReloads] = useState(0);
const auditor = canCorrect(useCurrentUser(true)); const currentUser = useCurrentUser(true);
const auditor = canCorrect(currentUser);
const [savedProfileStyle, setSavedProfileStyle] = useState(null);
const profileStyle = savedProfileStyle ?? currentUser?.profileStyle;
const [celebration, setCelebration] = useState(null); const [celebration, setCelebration] = useState(null);
const shownLevel = useRef(null); const shownLevel = useRef(null);
@@ -269,6 +273,9 @@ export default function GameDetail() {
<PanelTop/>{t('GAME_DETAIL.SET_BANNER')} <PanelTop/>{t('GAME_DETAIL.SET_BANNER')}
</Button> </Button>
)} )}
{profileStyle && (
<ProfileArtMenu appid={data.appid} style={profileStyle} onSaved={setSavedProfileStyle}/>
)}
{!manual && ( {!manual && (
<Button variant="outline" size="sm" onClick={toggleHidden} disabled={hiding}> <Button variant="outline" size="sm" onClick={toggleHidden} disabled={hiding}>
{data.hidden ? <Eye/> : <EyeOff/>} {data.hidden ? <Eye/> : <EyeOff/>}
+64
View File
@@ -242,6 +242,70 @@ describe('GameDetail of a game added by hand', () => {
expect(screen.queryByText('MANUAL_GAME.TITLE')).toBeNull(); expect(screen.queryByText('MANUAL_GAME.TITLE')).toBeNull();
}); });
it('keeps a section hidden after the page loaded when it sets the profile art', async () => {
let sections = ['PLAYING'];
globalThis.fetch = vi.fn(async (url, init) => ({ok: true, status: init?.method === 'PUT' ? 204 : 200,
json: async () => (url.includes('/api/users/me')
? {role: 'USER', settings: {profileStyle: {accent: 'red', corners: 'round', sections}}}
: {...game, source: 'STEAM'})}));
renderDetail();
const menu = await screen.findByRole('button', {name: 'GAME_DETAIL.PROFILE_ART'});
sections = []; // the owner hides PLAYING in Settings, e.g. in a second tab
fireEvent.keyDown(menu, {key: 'Enter'});
fireEvent.click(await screen.findByRole('menuitemcheckbox', {name: 'GAME_DETAIL.PROFILE_BANNER'}));
await waitFor(() => expect(fetch.mock.calls.some(([u, init]) => init?.method === 'PUT' && u.endsWith('/profile-style'))).toBe(true));
const sent = fetch.mock.calls.filter(([u, init]) => init?.method === 'PUT' && u.endsWith('/profile-style'))
.map(([, init]) => JSON.parse(init.body));
expect(sent.filter((body) => body.sections?.includes('PLAYING'))).toEqual([]);
});
it('keeps a banner picked in another tab when it sets this game as banner', async () => {
let bannerAppid = 220;
globalThis.fetch = vi.fn(async (url, init) => ({ok: true, status: init?.method === 'PUT' ? 204 : 200,
json: async () => (url.includes('/api/users/me')
? {role: 'USER', settings: {profileStyle: {bannerAppid, sections: ['PLAYING']}}}
: {...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', sections: ['PLAYING']};
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, sections: ['PLAYING']}]));
await openMenu();
const banner = await screen.findByRole('menuitemcheckbox', {name: 'GAME_DETAIL.PROFILE_BANNER'});
expect(banner.getAttribute('aria-checked')).toBe('true');
fireEvent.click(banner);
await waitFor(() => expect(styleBodies()).toHaveLength(2));
expect(styleBodies()[1].bannerAppid).toBeNull();
});
describe('corrections', () => { describe('corrections', () => {
const loggedInAs = (me) => { const loggedInAs = (me) => {
globalThis.fetch = vi.fn(async (url) => ({ok: true, status: 200, globalThis.fetch = vi.fn(async (url) => ({ok: true, status: 200,
+117 -85
View File
@@ -9,6 +9,9 @@ import GameCover from '../components/GameCover.jsx';
import GameStatusBadge, {STATUS_META} from '../components/GameStatusBadge.jsx'; import GameStatusBadge, {STATUS_META} from '../components/GameStatusBadge.jsx';
import {GAME_STATUSES} from '../utils/backlog.js'; import {GAME_STATUSES} from '../utils/backlog.js';
import {formatPlaytime} from '../utils/games.js'; import {formatPlaytime} from '../utils/games.js';
import {normalizeProfileStyle} from '../utils/profileStyle.js';
import HeroBanner from '../components/HeroBanner.jsx';
import PageBackground from '../components/PageBackground.jsx';
import PageContainer from '../components/PageContainer.jsx'; import PageContainer from '../components/PageContainer.jsx';
import PileStats from '../components/stats/PileStats.jsx'; import PileStats from '../components/stats/PileStats.jsx';
import TrendStats from '../components/stats/TrendStats.jsx'; import TrendStats from '../components/stats/TrendStats.jsx';
@@ -90,11 +93,48 @@ 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 sections = {
SUMMARY: library.totalGames != null && <Summary library={library}/>,
STATUS: library.byStatus && Object.values(library.byStatus).some((count) => count > 0)
&& <StatusBar byStatus={library.byStatus}/>,
PLAYING: library.playing?.length > 0 && <Playing games={library.playing}/>,
FINISHED: library.finished?.length > 0 && <Finished games={library.finished}/>,
STATS: library.stats && <SharedStats stats={library.stats}/>,
};
// 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>
</Card>
{style.sections.map((section) => sections[section] && (
<section key={section} data-section={section}>{sections[section]}</section>
))}
</PageContainer>
</div>
);
}
function Summary({library}) {
const {t, i18n} = useTranslation();
const stats = [ const stats = [
[Library, library.totalGames.toLocaleString(i18n.language), t('PROFILE.GAMES')], [Library, library.totalGames.toLocaleString(i18n.language), t('PROFILE.GAMES')],
[Clock, formatPlaytime(library.totalPlaytimeMinutes), t('PROFILE.PLAYTIME')], [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) { if (library.averageCompletion != null) {
stats.push( stats.push(
@@ -102,94 +142,86 @@ export default function PublicProfilePage() {
[Percent, `${library.averageCompletion.toLocaleString(i18n.language)} %`, t('PROFILE.AVERAGE_COMPLETION')], [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 ( return (
<PageContainer className="p-4 md:p-8 space-y-6"> <Card className={`grid gap-3 p-5 ${stats.length > 3 ? 'grid-cols-2 sm:grid-cols-5' : 'grid-cols-3'}`}>
<Card className="relative overflow-hidden p-6 md:p-8 gap-6"> {stats.map(([Icon, value, label]) => (
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-primary/15 via-transparent to-transparent"/> <div key={label} className="rounded-lg bg-muted/50 p-3 text-center">
<div className="relative"> <Icon className="mx-auto mb-1 size-4 text-primary"/>
<p className="text-sm font-medium text-muted-foreground">{t('PROFILE.TITLE')}</p> <div className="text-xl md:text-2xl font-bold tabular-nums">{value}</div>
<h1 className="mt-1 text-3xl md:text-4xl font-bold tracking-tight break-words">{profile.displayName}</h1> <div className="text-xs text-muted-foreground">{label}</div>
{memberSince && (
<p className="mt-2 text-sm text-muted-foreground">{t('PROFILE.MEMBER_SINCE', {date: memberSince})}</p>
)}
</div> </div>
<div className={`relative grid gap-3 ${stats.length > 3 ? 'grid-cols-2 sm:grid-cols-5' : 'grid-cols-3'}`}> ))}
{stats.map(([Icon, value, label]) => ( </Card>
<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 && ( function StatusBar({byStatus}) {
<Card className="gap-4 p-5"> const {t} = useTranslation();
<h2 className="flex items-center gap-2 font-semibold"> const total = GAME_STATUSES.reduce((sum, s) => sum + (byStatus[s] ?? 0), 0);
<Gamepad2 className="size-4 text-primary"/>{t('PROFILE.PLAYING')} return (
</h2> <Card className="gap-2 p-5">
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-4"> <div className="flex h-2.5 overflow-hidden rounded-full bg-muted">
{library.playing.map((g) => ( {GAME_STATUSES.map((s) => (
<li key={g.appid} className="space-y-1.5"> <div key={s} className={STATUS_META[s].bar} style={{width: `${((byStatus[s] ?? 0) / total) * 100}%`}}/>
<GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-full"/> ))}
<div className="truncate text-sm font-medium">{g.name ?? `App ${g.appid}`}</div> </div>
<div className="text-xs text-muted-foreground tabular-nums">{formatPlaytime(g.playtimeMinutes)}</div> <ul className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
</li> {GAME_STATUSES.map((s) => (
))} <li key={s} className="flex items-center gap-1.5">
</ul> <span className={`size-2 rounded-full ${STATUS_META[s].bar}`}/>
</Card> {t(`BACKLOG.STATUS.${s}`)}
)} <span className="tabular-nums text-foreground">{byStatus[s] ?? 0}</span>
</li>
))}
</ul>
</Card>
);
}
{library.finished.length > 0 && ( function Playing({games}) {
<Card className="gap-0 py-0"> const {t} = useTranslation();
<h2 className="flex items-center gap-2 border-b px-5 py-4 font-semibold"> return (
<CircleCheck className="size-4 text-emerald-500"/>{t('PROFILE.FINISHED_GAMES')} <Card className="gap-4 p-5">
</h2> <h2 className="flex items-center gap-2 font-semibold">
<ul className="divide-y"> <Gamepad2 className="size-4 text-primary"/>{t('PROFILE.PLAYING')}
{library.finished.map((g) => ( </h2>
<li key={g.appid} className="flex items-center gap-3 sm:gap-4 px-4 sm:px-5 py-3"> <ul className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-16 sm:w-24 shrink-0"/> {games.map((g) => (
<span className="min-w-0 flex-1 truncate font-medium">{g.name ?? `App ${g.appid}`}</span> <li key={g.appid} className="space-y-1.5">
{g.rating && ( <GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-full"/>
<span className="flex items-center gap-0.5" aria-label={t('BACKLOG.RATING_STARS', {count: g.rating})}> <div className="truncate text-sm font-medium">{g.name ?? `App ${g.appid}`}</div>
{Array.from({length: g.rating}, (_, i) => ( <div className="text-xs text-muted-foreground tabular-nums">{formatPlaytime(g.playtimeMinutes)}</div>
<Star key={i} className="size-3.5 fill-amber-400 text-amber-400"/> </li>
))} ))}
</span> </ul>
)} </Card>
<GameStatusBadge status={g.status} compact/> );
</li> }
))}
</ul>
</Card>
)}
{library.stats && <SharedStats stats={library.stats}/>} function Finished({games}) {
</PageContainer> 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>
); );
} }
+47 -3
View File
@@ -14,13 +14,13 @@ const library = {
finished: [{appid: 620, name: 'Portal 2', playtimeMinutes: 900, status: 'COMPLETED', rating: 5}], finished: [{appid: 620, name: 'Portal 2', playtimeMinutes: 900, status: 'COMPLETED', rating: 5}],
}; };
function mockBackends({profileStatus = 200, libraryStatus = 200} = {}) { function mockBackends({profileStatus = 200, libraryStatus = 200, style, answer = library} = {}) {
globalThis.fetch = vi.fn(async (url) => { globalThis.fetch = vi.fn(async (url) => {
if (url.includes('/api/public/profiles/library')) { if (url.includes('/api/public/profiles/library')) {
return {ok: libraryStatus === 200, status: libraryStatus, json: async () => library}; return {ok: libraryStatus === 200, status: libraryStatus, json: async () => answer};
} }
return {ok: profileStatus === 200, status: profileStatus, return {ok: profileStatus === 200, status: profileStatus,
json: async () => ({displayName: 'Lobo', memberSince: '2026-03-02', ticket: 'the-ticket'})}; json: async () => ({displayName: 'Lobo', memberSince: '2026-03-02', ticket: 'the-ticket', style})};
}); });
} }
@@ -74,6 +74,50 @@ describe('PublicProfilePage', () => {
delete library.stats; delete library.stats;
}); });
it('applies the owner\'s accent and corners to the page only, with banner and background', async () => {
mockBackends({style: {accent: 'red', corners: 'round', bannerAppid: 620, backgroundAppid: 220, sections: ['PLAYING']}});
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: {sections: ['FINISHED', 'PLAYING', 'SUMMARY']},
answer: {...library, byStatus: null, finishedGames: 60},
});
const {container} = renderPage();
await screen.findByText('Portal 2');
expect([...container.querySelectorAll('[data-section]')].map((el) => el.dataset.section))
.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-section]')].map((el) => el.dataset.section)).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-section]')].map((el) => el.dataset.section))
.toEqual(['SUMMARY', 'STATUS', 'PLAYING', 'FINISHED']);
expect(container.querySelector('[data-accent="violet"]')).toBeTruthy();
});
it('says the profile is unknown or private on a 404', async () => { it('says the profile is unknown or private on a 404', async () => {
mockBackends({profileStatus: 404}); mockBackends({profileStatus: 404});
renderPage(); renderPage();
+23 -29
View File
@@ -23,10 +23,12 @@ import {settingsSchema} from '../utils/validation.js';
import {usePreferences} from '../context/PreferencesContext.jsx'; import {usePreferences} from '../context/PreferencesContext.jsx';
import useSessionExpired from '../hooks/useSessionExpired.js'; import useSessionExpired from '../hooks/useSessionExpired.js';
import useSaveAppearance from '../hooks/useSaveAppearance.js'; import useSaveAppearance from '../hooks/useSaveAppearance.js';
import {ACCENTS, CORNERS} from '../utils/appearance.js'; import {AccentPicker, CornersPicker} from '../components/StylePickers.jsx';
import ProfileStyleSettings from '../components/ProfileStyleSettings.jsx';
import {normalizeProfileStyle} from '../utils/profileStyle.js';
import useSaveProfileStyle from '../hooks/useSaveProfileStyle.js';
import {coverUrl} from '../utils/games.js'; import {coverUrl} from '../utils/games.js';
import {saveProfileStats} from '../utils/stats.js'; import {saveProfileStats} from '../utils/stats.js';
import {cn} from '@/lib/utils';
import {apiFetch, refreshSession} from '../utils/api.js'; import {apiFetch, refreshSession} from '../utils/api.js';
import {hasSession, markSession} from '../utils/auth.js'; import {hasSession, markSession} from '../utils/auth.js';
@@ -59,11 +61,13 @@ export default function SettingsPage() {
const expireSession = useSessionExpired(); const expireSession = useSessionExpired();
const {isDarkMode, setDarkMode, setLanguage, appearance} = usePreferences(); const {isDarkMode, setDarkMode, setLanguage, appearance} = usePreferences();
const saveAppearance = useSaveAppearance(); const saveAppearance = useSaveAppearance();
const saveStyle = useSaveProfileStyle();
const [profile, setProfile] = useState(null); const [profile, setProfile] = useState(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState(null); const [loadError, setLoadError] = useState(null);
const [savingProfileStats, setSavingProfileStats] = useState(false); const [savingProfileStats, setSavingProfileStats] = useState(false);
const [savingProfileStyle, setSavingProfileStyle] = useState(false);
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
// Read once: removing the parameter below must not load the profile again. // Read once: removing the parameter below must not load the profile again.
const [steamOutcome] = useState(() => searchParams.get('steam')); const [steamOutcome] = useState(() => searchParams.get('steam'));
@@ -227,6 +231,15 @@ export default function SettingsPage() {
} }
}; };
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) => { const copyProfileLink = async (link) => {
try { try {
await navigator.clipboard.writeText(link); await navigator.clipboard.writeText(link);
@@ -330,33 +343,8 @@ export default function SettingsPage() {
<CardTitle className="flex items-center gap-2"><Palette className="size-4 text-primary"/>{t('SETTINGS.APPEARANCE')}</CardTitle> <CardTitle className="flex items-center gap-2"><Palette className="size-4 text-primary"/>{t('SETTINGS.APPEARANCE')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <AccentPicker id="accent" value={appearance.accent} onChange={(accent) => saveAppearance({accent})}/>
<Label id="accent-label">{t('SETTINGS.ACCENT')}</Label> <CornersPicker id="corners" value={appearance.corners} onChange={(corners) => saveAppearance({corners})}/>
<div role="group" aria-labelledby="accent-label" className="flex flex-wrap gap-2">
{ACCENTS.map((accent) => (
<button key={accent} type="button" data-accent={accent}
aria-label={t(`SETTINGS.ACCENT_OPTIONS.${accent.toUpperCase()}`)}
title={t(`SETTINGS.ACCENT_OPTIONS.${accent.toUpperCase()}`)}
aria-pressed={appearance.accent === accent}
onClick={() => saveAppearance({accent})}
className={cn('size-8 rounded-full bg-primary ring-offset-2 ring-offset-background transition hover:scale-110',
appearance.accent === accent && 'ring-2 ring-foreground')}/>
))}
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Label id="corners-label">{t('SETTINGS.CORNERS')}</Label>
<div role="group" aria-labelledby="corners-label" className="flex gap-2">
{CORNERS.map((corners) => (
<Button key={corners} type="button" size="sm" data-corners={corners}
variant={appearance.corners === corners ? 'default' : 'outline'}
aria-pressed={appearance.corners === corners}
onClick={() => saveAppearance({corners})}>
{t(`SETTINGS.CORNER_OPTIONS.${corners.toUpperCase()}`)}
</Button>
))}
</div>
</div>
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className="space-y-1"> <div className="space-y-1">
<Label htmlFor="wideLayout">{t('SETTINGS.WIDE_LAYOUT')}</Label> <Label htmlFor="wideLayout">{t('SETTINGS.WIDE_LAYOUT')}</Label>
@@ -463,6 +451,12 @@ export default function SettingsPage() {
disabled={!publicProfile} saving={savingProfileStats} disabled={!publicProfile} saving={savingProfileStats}
onChange={handleProfileStatsChange}/> onChange={handleProfileStatsChange}/>
</div> </div>
<div className="mt-6 border-t pt-6">
<ProfileStyleSettings style={normalizeProfileStyle(profile?.settings?.profileStyle)}
disabled={!publicProfile || savingProfileStyle}
onChange={handleProfileStyleChange}/>
</div>
</CardContent> </CardContent>
</Card> </Card>
+56 -5
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; import {cleanup, fireEvent, render, screen, waitFor, within} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom'; import {MemoryRouter} from 'react-router-dom';
import SettingsPage from './SettingsPage.jsx'; import SettingsPage from './SettingsPage.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx'; import {PreferencesProvider} from '../context/PreferencesContext.jsx';
@@ -17,6 +17,8 @@ const profile = (overrides = {}) => ({
settings: {useDarkTheme: true, language: 'de', publicProfile: true}, ...overrides, settings: {useDarkTheme: true, language: 'de', publicProfile: true}, ...overrides,
}); });
const appearanceCard = () => within(screen.getByText('SETTINGS.APPEARANCE').closest('[data-slot="card"]'));
const puts = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT').map(([, init]) => JSON.parse(init.body)); const puts = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT').map(([, init]) => JSON.parse(init.body));
function renderPage(me) { function renderPage(me) {
@@ -99,6 +101,54 @@ describe('SettingsPage public profile', () => {
.toBe('false')); .toBe('false'));
}); });
const styled = (profileStyle) => profile({settings: {useDarkTheme: true, language: 'de', publicProfile: true, profileStyle}});
const styleGroup = (label) => within(screen.getAllByRole('group', {name: label})[1]);
const stylePuts = () => fetch.mock.calls.filter(([url, init]) => init?.method === 'PUT' && url.endsWith('/profile-style'))
.map(([, init]) => JSON.parse(init.body));
it('saves the profile accent apart from the app accent', async () => {
renderPage(styled({accent: 'blue', corners: 'sharp', bannerAppid: 620, backgroundAppid: null,
sections: ['PLAYING', 'SUMMARY']}));
await screen.findByText('SETTINGS.PROFILE_STYLE');
fireEvent.click(styleGroup('SETTINGS.ACCENT').getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.RED'}));
await waitFor(() => expect(stylePuts()).toEqual([{accent: 'red', corners: 'sharp', bannerAppid: 620,
backgroundAppid: null, sections: ['PLAYING', 'SUMMARY']}]));
expect(styleGroup('SETTINGS.ACCENT').getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.RED'})
.getAttribute('aria-pressed')).toBe('true');
});
it('moves, hides and shows profile sections', async () => {
renderPage(styled({sections: ['PLAYING', 'SUMMARY']}));
await screen.findByText('SETTINGS.PROFILE_STYLE');
// The first row is the first shown section, PLAYING.
fireEvent.click(screen.getAllByRole('button', {name: 'SETTINGS.PROFILE_SECTION_DOWN'})[0]);
await waitFor(() => expect(stylePuts()).toHaveLength(1));
expect(stylePuts()[0].sections).toEqual(['SUMMARY', 'PLAYING']);
fireEvent.click(screen.getByRole('switch', {name: 'SETTINGS.PROFILE_SECTION.STATS'}));
await waitFor(() => expect(stylePuts()).toHaveLength(2));
expect(stylePuts()[1].sections).toEqual(['SUMMARY', 'PLAYING', 'STATS']);
});
it('removes the profile banner', async () => {
renderPage(styled({bannerAppid: 620}));
fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.PROFILE_ART_REMOVE'}));
await waitFor(() => expect(stylePuts()).toHaveLength(1));
expect(stylePuts()[0].bannerAppid).toBeNull();
});
it('offers the profile look only while the profile is public', async () => {
renderPage(profile({settings: {useDarkTheme: true, language: 'de', publicProfile: false}}));
expect((await screen.findByRole('switch', {name: 'SETTINGS.PROFILE_SECTION.STATS'})).disabled).toBe(true);
expect(styleGroup('SETTINGS.ACCENT').getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.RED'}).disabled).toBe(true);
});
it('offers the stat groups only while the profile is public', async () => { it('offers the stat groups only while the profile is public', async () => {
renderPage(profile({settings: {useDarkTheme: true, language: 'de', publicProfile: false}})); renderPage(profile({settings: {useDarkTheme: true, language: 'de', publicProfile: false}}));
@@ -142,13 +192,14 @@ describe('SettingsPage appearance', () => {
it('saves a picked accent and corner style and marks them as picked', async () => { it('saves a picked accent and corner style and marks them as picked', async () => {
renderPage(profile()); renderPage(profile());
fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.GREEN'})); await screen.findByText('SETTINGS.APPEARANCE');
fireEvent.click(screen.getByRole('button', {name: 'SETTINGS.CORNER_OPTIONS.ROUND'})); fireEvent.click(appearanceCard().getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.GREEN'}));
fireEvent.click(appearanceCard().getByRole('button', {name: 'SETTINGS.CORNER_OPTIONS.ROUND'}));
await waitFor(() => expect(appearancePuts()).toHaveLength(2)); await waitFor(() => expect(appearancePuts()).toHaveLength(2));
expect(appearancePuts()[1]).toEqual({accent: 'green', corners: 'round', wideLayout: false, backgroundAppid: null, bannerAppid: null, showBanner: true}); expect(appearancePuts()[1]).toEqual({accent: 'green', corners: 'round', wideLayout: false, backgroundAppid: null, bannerAppid: null, showBanner: true});
expect(screen.getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.GREEN'}).getAttribute('aria-pressed')).toBe('true'); expect(appearanceCard().getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.GREEN'}).getAttribute('aria-pressed')).toBe('true');
expect(screen.getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.VIOLET'}).getAttribute('aria-pressed')).toBe('false'); expect(appearanceCard().getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.VIOLET'}).getAttribute('aria-pressed')).toBe('false');
}); });
it('removes the background', async () => { it('removes the background', async () => {
+1 -1
View File
@@ -9,7 +9,7 @@ export const DEFAULT_APPEARANCE = {
accent: 'violet', corners: 'default', wideLayout: false, backgroundAppid: null, bannerAppid: null, showBanner: true, accent: 'violet', corners: 'default', wideLayout: false, backgroundAppid: null, bannerAppid: null, showBanner: true,
}; };
const appid = (value) => (Number.isInteger(value) && value > 0 ? value : null); export const appid = (value) => (Number.isInteger(value) && value > 0 ? value : null);
const STORAGE_KEY = 'tbp_appearance'; const STORAGE_KEY = 'tbp_appearance';
const REQUEST_TIMEOUT_MS = 10000; const REQUEST_TIMEOUT_MS = 10000;
+37
View File
@@ -0,0 +1,37 @@
import {apiFetch} from './api.js';
import {ACCENTS, CORNERS, DEFAULT_APPEARANCE, appid} from './appearance.js';
// Auth accepts only these names (ProfileStyleRequest); Core sends no data for a section that is not listed.
export const PROFILE_SECTIONS = ['SUMMARY', 'STATUS', 'PLAYING', 'FINISHED', 'STATS'];
export const DEFAULT_PROFILE_STYLE = {
accent: DEFAULT_APPEARANCE.accent, corners: DEFAULT_APPEARANCE.corners, bannerAppid: null, backgroundAppid: null,
sections: PROFILE_SECTIONS,
};
export const normalizeProfileStyle = (value) => ({
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),
sections: Array.isArray(value?.sections)
? value.sections.filter((s, i) => PROFILE_SECTIONS.includes(s) && value.sections.indexOf(s) === i)
: PROFILE_SECTIONS,
});
/**
* 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),
});
+18
View File
@@ -0,0 +1,18 @@
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('keeps the order, drops unknown and repeated sections and bad values', () => {
expect(normalizeProfileStyle({accent: 'hotpink', corners: 'round', bannerAppid: -5, backgroundAppid: 620,
sections: ['STATS', 'NOTES', 'PLAYING', 'STATS']}))
.toEqual({accent: 'violet', corners: 'round', bannerAppid: null, backgroundAppid: 620, sections: ['STATS', 'PLAYING']});
});
it('keeps an empty list: every section hidden', () => {
expect(normalizeProfileStyle({sections: []}).sections).toEqual([]);
});
});