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
+621 -85

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
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 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.
Saved in Auth, so it follows you to every device.
- **Broken pictures**: a Steam cover or achievement icon that does not load shows a placeholder and is reported
+20 -1
View File
@@ -36,8 +36,24 @@
},
"SETTINGS": {
"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_STYLE": "Aussehen des Profils",
"PROFILE_STYLE_HINT": "So sehen Besucher dein öffentliches Profil. Das Aussehen deiner App bleibt, wie es ist.",
"PROFILE_BANNER": "Banner",
"PROFILE_BACKGROUND": "Hintergrund",
"PROFILE_ART_HINT": "Wähle das Bild auf der Seite eines Spiels: \"Profilbild\".",
"PROFILE_ART_REMOVE": "Entfernen",
"PROFILE_SECTIONS": "Abschnitte und Reihenfolge",
"PROFILE_SECTION": {
"SUMMARY": "Summen",
"STATUS": "Statusleiste",
"PLAYING": "Spielt gerade",
"FINISHED": "Beendete Spiele",
"STATS": "Statistiken"
},
"PROFILE_SECTION_UP": "{{name}} nach oben",
"PROFILE_SECTION_DOWN": "{{name}} nach unten",
"TITLE": "Einstellungen",
"LOADING": "Einstellungen werden geladen …",
"ACCOUNT": "Konto",
@@ -238,6 +254,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",
+20 -1
View File
@@ -36,8 +36,24 @@
},
"SETTINGS": {
"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_STYLE": "Profile look",
"PROFILE_STYLE_HINT": "How visitors see your public profile. Your own app look stays as it is.",
"PROFILE_BANNER": "Banner",
"PROFILE_BACKGROUND": "Background",
"PROFILE_ART_HINT": "Pick the art on a game's page: \"Profile art\".",
"PROFILE_ART_REMOVE": "Remove",
"PROFILE_SECTIONS": "Sections and their order",
"PROFILE_SECTION": {
"SUMMARY": "Totals",
"STATUS": "Status bar",
"PLAYING": "Playing",
"FINISHED": "Finished games",
"STATS": "Stats"
},
"PROFILE_SECTION_UP": "Move {{name}} up",
"PROFILE_SECTION_DOWN": "Move {{name}} down",
"TITLE": "Settings",
"LOADING": "Loading settings …",
"ACCOUNT": "Account",
@@ -238,6 +254,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 %",
+2 -1
View File
@@ -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">
+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 {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") 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,
profileStyle: normalizeProfileStyle(data.settings?.profileStyle)});
} catch {
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 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 {usePreferences} from '../context/PreferencesContext.jsx';
import {apiFetch} from '../utils/api.js';
import {hasSession} from '../utils/auth.js';
@@ -246,12 +245,8 @@ const Dashboard = () => {
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 && (
+8 -1
View File
@@ -26,6 +26,7 @@ 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';
@@ -72,7 +73,10 @@ 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);
@@ -269,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/>}
+64
View File
@@ -242,6 +242,70 @@ describe('GameDetail of a game added by hand', () => {
expect(screen.queryByText('MANUAL_GAME.TITLE')).toBeNull();
});
it('keeps a section hidden after the page loaded when it sets the profile art', async () => {
let sections = ['PLAYING'];
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', () => {
const loggedInAs = (me) => {
globalThis.fetch = vi.fn(async (url) => ({ok: true, status: 200,
+64 -32
View File
@@ -9,6 +9,9 @@ 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 {normalizeProfileStyle} from '../utils/profileStyle.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';
@@ -90,26 +93,26 @@ export default function PublicProfilePage() {
);
}
const finishedCount = (library.byStatus.FINISHED ?? 0) + (library.byStatus.COMPLETED ?? 0);
const stats = [
[Library, library.totalGames.toLocaleString(i18n.language), t('PROFILE.GAMES')],
[Clock, formatPlaytime(library.totalPlaytimeMinutes), t('PROFILE.PLAYTIME')],
[CircleCheck, finishedCount.toLocaleString(i18n.language), t('PROFILE.FINISHED')],
];
if (library.averageCompletion != null) {
stats.push(
[Trophy, (library.perfectGames ?? 0).toLocaleString(i18n.language), t('PROFILE.PERFECT_GAMES')],
[Percent, `${library.averageCompletion.toLocaleString(i18n.language)} %`, t('PROFILE.AVERAGE_COMPLETION')],
);
}
const 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 gap-6">
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-primary/15 via-transparent to-transparent"/>
<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>
@@ -117,7 +120,30 @@ export default function PublicProfilePage() {
<p className="mt-2 text-sm text-muted-foreground">{t('PROFILE.MEMBER_SINCE', {date: memberSince})}</p>
)}
</div>
<div className={`relative grid gap-3 ${stats.length > 3 ? 'grid-cols-2 sm:grid-cols-5' : 'grid-cols-3'}`}>
</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 = [
[Library, library.totalGames.toLocaleString(i18n.language), t('PROFILE.GAMES')],
[Clock, formatPlaytime(library.totalPlaytimeMinutes), t('PROFILE.PLAYTIME')],
[CircleCheck, (library.finishedGames ?? 0).toLocaleString(i18n.language), t('PROFILE.FINISHED')],
];
if (library.averageCompletion != null) {
stats.push(
[Trophy, (library.perfectGames ?? 0).toLocaleString(i18n.language), t('PROFILE.PERFECT_GAMES')],
[Percent, `${library.averageCompletion.toLocaleString(i18n.language)} %`, t('PROFILE.AVERAGE_COMPLETION')],
);
}
return (
<Card className={`grid gap-3 p-5 ${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"/>
@@ -125,13 +151,18 @@ export default function PublicProfilePage() {
<div className="text-xs text-muted-foreground">{label}</div>
</div>
))}
</div>
{library.totalGames > 0 && (
<div className="relative space-y-2">
</Card>
);
}
function StatusBar({byStatus}) {
const {t} = useTranslation();
const total = GAME_STATUSES.reduce((sum, s) => sum + (byStatus[s] ?? 0), 0);
return (
<Card className="gap-2 p-5">
<div className="flex h-2.5 overflow-hidden rounded-full bg-muted">
{GAME_STATUSES.map((s) => (
<div key={s} className={STATUS_META[s].bar}
style={{width: `${((library.byStatus[s] ?? 0) / library.totalGames) * 100}%`}}/>
<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">
@@ -139,21 +170,23 @@ export default function PublicProfilePage() {
<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>
<span className="tabular-nums text-foreground">{byStatus[s] ?? 0}</span>
</li>
))}
</ul>
</div>
)}
</Card>
);
}
{library.playing.length > 0 && (
function Playing({games}) {
const {t} = useTranslation();
return (
<Card className="gap-4 p-5">
<h2 className="flex items-center gap-2 font-semibold">
<Gamepad2 className="size-4 text-primary"/>{t('PROFILE.PLAYING')}
</h2>
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{library.playing.map((g) => (
{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>
@@ -162,15 +195,18 @@ export default function PublicProfilePage() {
))}
</ul>
</Card>
)}
);
}
{library.finished.length > 0 && (
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">
{library.finished.map((g) => (
{games.map((g) => (
<li key={g.appid} className="flex items-center gap-3 sm:gap-4 px-4 sm:px-5 py-3">
<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>
@@ -186,10 +222,6 @@ export default function PublicProfilePage() {
))}
</ul>
</Card>
)}
{library.stats && <SharedStats stats={library.stats}/>}
</PageContainer>
);
}
+47 -3
View File
@@ -14,13 +14,13 @@ const library = {
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) => {
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})};
});
}
@@ -74,6 +74,50 @@ 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, 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 () => {
mockBackends({profileStatus: 404});
renderPage();
+23 -29
View File
@@ -23,10 +23,12 @@ 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 +61,13 @@ 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'));
@@ -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) => {
try {
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>
</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>
@@ -463,6 +451,12 @@ export default function SettingsPage() {
disabled={!publicProfile} saving={savingProfileStats}
onChange={handleProfileStatsChange}/>
</div>
<div className="mt-6 border-t pt-6">
<ProfileStyleSettings style={normalizeProfileStyle(profile?.settings?.profileStyle)}
disabled={!publicProfile || savingProfileStyle}
onChange={handleProfileStyleChange}/>
</div>
</CardContent>
</Card>
+56 -5
View File
@@ -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) {
@@ -99,6 +101,54 @@ describe('SettingsPage public profile', () => {
.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 () => {
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 () => {
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 () => {
+1 -1
View File
@@ -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;
+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([]);
});
});