Public Access
feat(profile): let users design their public profile
This commit is contained in:
1 parent
a5458b3f86
commit
b55fcfec92
20 files changed
+621
-85
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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">
|
||||
|
||||
@@ -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')}/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import {useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {UserRound} from 'lucide-react';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import useSaveProfileStyle from '../hooks/useSaveProfileStyle.js';
|
||||
|
||||
/**
|
||||
* Uses the art of this Steam game as banner or background of the public profile; a checked item removes it again.
|
||||
*
|
||||
* @param onSaved gets the whole saved style
|
||||
*/
|
||||
export default function ProfileArtMenu({appid, style, onSaved}) {
|
||||
const {t} = useTranslation();
|
||||
const save = useSaveProfileStyle();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const toggle = async (field) => {
|
||||
setSaving(true);
|
||||
const saved = await save((stored) => ({[field]: stored[field] === appid ? null : appid}), style);
|
||||
if (saved) onSaved(saved);
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={saving}><UserRound/>{t('GAME_DETAIL.PROFILE_ART')}</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuCheckboxItem checked={style.bannerAppid === appid} onCheckedChange={() => toggle('bannerAppid')}>
|
||||
{t('GAME_DETAIL.PROFILE_BANNER')}
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem checked={style.backgroundAppid === appid}
|
||||
onCheckedChange={() => toggle('backgroundAppid')}>
|
||||
{t('GAME_DETAIL.PROFILE_BACKGROUND')}
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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/>}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -9,7 +9,7 @@ export const DEFAULT_APPEARANCE = {
|
||||
accent: 'violet', corners: 'default', wideLayout: false, backgroundAppid: null, bannerAppid: null, showBanner: true,
|
||||
};
|
||||
|
||||
const appid = (value) => (Number.isInteger(value) && value > 0 ? value : null);
|
||||
export const appid = (value) => (Number.isInteger(value) && value > 0 ? value : null);
|
||||
|
||||
const STORAGE_KEY = 'tbp_appearance';
|
||||
const REQUEST_TIMEOUT_MS = 10000;
|
||||
|
||||
@@ -0,0 +1,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),
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user