feat(profile): public profile page and the switch in settings

/u/<name> shows a public profile without login. Settings get the switch and
the link; every settings save now sends the other saved values, and removing
the display name also makes the profile private.
This commit is contained in:
lobo committed 2026-09-24 14:18:32 +02:00
1 parent aa5124ea1e
commit fd343da4be
7 files changed
+418 -18

No files matched your search

+18 -1
View File
@@ -59,7 +59,12 @@
"DISPLAY_NAME_PLACEHOLDER": "z. B. Lobo",
"DISPLAY_NAME_HINT": "3–24 Zeichen: Buchstaben, Zahlen, Leerzeichen, _ . -",
"SAVE_PROFILE": "Profil speichern",
"ERROR_DISPLAY_NAME_IN_USE": "Dieser Anzeigename ist schon vergeben."
"ERROR_DISPLAY_NAME_IN_USE": "Dieser Anzeigename ist schon vergeben.",
"PUBLIC_PROFILE": "Öffentliches Profil",
"PUBLIC_PROFILE_HINT": "Jeder mit dem Link sieht deine Statistik, was du gerade spielst und deine durchgespielten Spiele mit Bewertung. Notizen, E-Mail und Steam-ID bleiben privat.",
"PUBLIC_PROFILE_NEEDS_NAME": "Lege zuerst einen Anzeigenamen fest; er wird Teil des Links.",
"PUBLIC_PROFILE_COPY": "Link kopieren",
"PUBLIC_PROFILE_COPIED": "Link kopiert"
},
"VALIDATION": {
"EMAIL_REQUIRED": "Email ist erforderlich.",
@@ -216,5 +221,17 @@
"NO_PRICE": "Noch kein Preis",
"ADDED": "Hinzugefügt am {{date}}",
"ERROR_LOAD": "Wunschliste konnte nicht geladen werden."
},
"PROFILE": {
"TITLE": "Spielerprofil",
"MEMBER_SINCE": "Dabei seit {{date}}",
"GAMES": "Spiele",
"PLAYTIME": "Spielzeit",
"FINISHED": "Durchgespielt",
"PLAYING": "Spielt gerade",
"FINISHED_GAMES": "Durchgespielte Spiele",
"NOT_FOUND": "Dieses Profil gibt es nicht oder es ist privat.",
"ERROR_LOAD": "Profil konnte nicht geladen werden.",
"TO_START": "Zur Startseite"
}
}
+18 -1
View File
@@ -59,7 +59,12 @@
"DISPLAY_NAME_PLACEHOLDER": "e.g. Lobo",
"DISPLAY_NAME_HINT": "3–24 characters: letters, digits, spaces, _ . -",
"SAVE_PROFILE": "Save profile",
"ERROR_DISPLAY_NAME_IN_USE": "This display name is already taken."
"ERROR_DISPLAY_NAME_IN_USE": "This display name is already taken.",
"PUBLIC_PROFILE": "Public profile",
"PUBLIC_PROFILE_HINT": "Anyone with the link sees your stats, what you play right now and your finished games with ratings. Notes, email and Steam ID stay private.",
"PUBLIC_PROFILE_NEEDS_NAME": "Set a display name first; it becomes part of the link.",
"PUBLIC_PROFILE_COPY": "Copy link",
"PUBLIC_PROFILE_COPIED": "Link copied"
},
"VALIDATION": {
"EMAIL_REQUIRED": "Email is required.",
@@ -216,5 +221,17 @@
"NO_PRICE": "No price yet",
"ADDED": "Added {{date}}",
"ERROR_LOAD": "Could not load the wishlist."
},
"PROFILE": {
"TITLE": "Player profile",
"MEMBER_SINCE": "Member since {{date}}",
"GAMES": "Games",
"PLAYTIME": "Playtime",
"FINISHED": "Finished",
"PLAYING": "Playing right now",
"FINISHED_GAMES": "Finished games",
"NOT_FOUND": "This profile does not exist or is private.",
"ERROR_LOAD": "Could not load the profile.",
"TO_START": "To the start page"
}
}
+2
View File
@@ -11,6 +11,7 @@ import SettingsPage from './pages/SettingsPage.jsx';
import GameLibrary from './pages/GameLibrary.jsx';
import GameDetail from './pages/GameDetail.jsx';
import WishlistPage from './pages/WishlistPage.jsx';
import PublicProfilePage from './pages/PublicProfilePage.jsx';
import AppHeader from './components/AppHeader.jsx';
import {Toaster} from '@/components/ui/sonner';
import {TooltipProvider} from '@/components/ui/tooltip';
@@ -49,6 +50,7 @@ function App() {
<Route path="/library" element={<GameLibrary/>}/>
<Route path="/library/:appid" element={<GameDetail/>}/>
<Route path="/wishlist" element={<WishlistPage/>}/>
<Route path="/u/:name" element={<PublicProfilePage/>}/>
<Route path="/settings" element={<SettingsPage/>}/>
<Route path="/impressum" element={<ImprintPage/>}/>
</Routes>
+179
View File
@@ -0,0 +1,179 @@
import {useEffect, useState} from 'react';
import {Link, useParams} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {CircleCheck, Clock, Gamepad2, Library, Star, UserRoundX} from 'lucide-react';
import {Card} from '@/components/ui/card';
import {Skeleton} from '@/components/ui/skeleton';
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';
/**
* Public, no login. Auth returns a short-lived ticket for a public profile; Core returns the data for it.
*/
export default function PublicProfilePage() {
const {t, i18n} = useTranslation();
const {name} = useParams();
const [profile, setProfile] = useState(null);
const [library, setLibrary] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
setProfile(null);
setLibrary(null);
setError(null);
(async () => {
try {
const profileResponse = await fetch(
`${import.meta.env.VITE_API_URL}/api/public/profiles/${encodeURIComponent(name)}`);
if (cancelled) return;
if (profileResponse.status === 404) {
setError(t('PROFILE.NOT_FOUND'));
return;
}
if (!profileResponse.ok) {
setError(t('PROFILE.ERROR_LOAD'));
return;
}
const found = await profileResponse.json();
const libraryResponse = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/public/profiles/library`, {
headers: {'X-Profile-Ticket': found.ticket},
});
if (cancelled) return;
if (!libraryResponse.ok) {
setError(t('PROFILE.ERROR_LOAD'));
return;
}
const data = await libraryResponse.json();
if (!cancelled) {
setProfile(found);
setLibrary(data);
}
} catch (err) {
console.error('Public profile load error:', err);
if (!cancelled) setError(t('INFO.ERROR.NETWORK_FAILURE'));
}
})();
return () => {
cancelled = true;
};
}, [name, t]);
if (error) {
return (
<div className="w-full max-w-5xl mx-auto p-4 md:p-8">
<Card className="items-center py-16 text-center text-muted-foreground">
<UserRoundX className="size-10"/>
<p className="max-w-sm text-sm">{error}</p>
<Link to="/" className="text-sm font-medium text-primary hover:underline">{t('PROFILE.TO_START')}</Link>
</Card>
</div>
);
}
if (!library) {
return (
<div className="w-full max-w-5xl mx-auto p-4 md:p-8 space-y-6">
<Skeleton className="h-36 rounded-xl"/>
<Skeleton className="h-64 rounded-xl"/>
</div>
);
}
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')],
];
const memberSince = profile.memberSince
? new Date(`${profile.memberSince}T12:00:00`).toLocaleDateString(i18n.language, {year: 'numeric', month: 'long'})
: null;
return (
<div className="w-full max-w-5xl mx-auto p-4 md:p-8 space-y-6">
<Card className="relative overflow-hidden p-6 md:p-8 gap-6">
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-primary/15 via-transparent to-transparent"/>
<div className="relative">
<p className="text-sm font-medium text-muted-foreground">{t('PROFILE.TITLE')}</p>
<h1 className="mt-1 text-3xl md:text-4xl font-bold tracking-tight break-words">{profile.displayName}</h1>
{memberSince && (
<p className="mt-2 text-sm text-muted-foreground">{t('PROFILE.MEMBER_SINCE', {date: memberSince})}</p>
)}
</div>
<div className="relative grid grid-cols-3 gap-3">
{stats.map(([Icon, value, label]) => (
<div key={label} className="rounded-lg bg-muted/50 p-3 text-center">
<Icon className="mx-auto mb-1 size-4 text-primary"/>
<div className="text-xl md:text-2xl font-bold tabular-nums">{value}</div>
<div className="text-xs text-muted-foreground">{label}</div>
</div>
))}
</div>
{library.totalGames > 0 && (
<div className="relative space-y-2">
<div className="flex h-2.5 overflow-hidden rounded-full bg-muted">
{GAME_STATUSES.map((s) => (
<div key={s} className={STATUS_META[s].bar}
style={{width: `${((library.byStatus[s] ?? 0) / library.totalGames) * 100}%`}}/>
))}
</div>
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
{GAME_STATUSES.map((s) => (
<li key={s} className="flex items-center gap-1.5">
<span className={`size-2 rounded-full ${STATUS_META[s].bar}`}/>
{t(`BACKLOG.STATUS.${s}`)}
<span className="tabular-nums text-foreground">{library.byStatus[s] ?? 0}</span>
</li>
))}
</ul>
</div>
)}
</Card>
{library.playing.length > 0 && (
<Card className="gap-4 p-5">
<h2 className="flex items-center gap-2 font-semibold">
<Gamepad2 className="size-4 text-primary"/>{t('PROFILE.PLAYING')}
</h2>
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{library.playing.map((g) => (
<li key={g.appid} className="space-y-1.5">
<GameCover appid={g.appid} className="w-full"/>
<div className="truncate text-sm font-medium">{g.name ?? `App ${g.appid}`}</div>
<div className="text-xs text-muted-foreground tabular-nums">{formatPlaytime(g.playtimeMinutes)}</div>
</li>
))}
</ul>
</Card>
)}
{library.finished.length > 0 && (
<Card className="gap-0 py-0">
<h2 className="flex items-center gap-2 border-b px-5 py-4 font-semibold">
<CircleCheck className="size-4 text-emerald-500"/>{t('PROFILE.FINISHED_GAMES')}
</h2>
<ul className="divide-y">
{library.finished.map((g) => (
<li key={g.appid} className="flex items-center gap-3 sm:gap-4 px-4 sm:px-5 py-3">
<GameCover appid={g.appid} className="w-16 sm:w-24 shrink-0"/>
<span className="min-w-0 flex-1 truncate font-medium">{g.name ?? `App ${g.appid}`}</span>
{g.rating && (
<span className="flex items-center gap-0.5" aria-label={t('BACKLOG.RATING_STARS', {count: g.rating})}>
{Array.from({length: g.rating}, (_, i) => (
<Star key={i} className="size-3.5 fill-amber-400 text-amber-400"/>
))}
</span>
)}
<GameStatusBadge status={g.status} compact/>
</li>
))}
</ul>
</Card>
)}
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter, Route, Routes} from 'react-router-dom';
import PublicProfilePage from './PublicProfilePage.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
const library = {
totalGames: 233, totalPlaytimeMinutes: 60000,
byStatus: {BACKLOG: 142, PLAYING: 6, FINISHED: 51, COMPLETED: 9, DROPPED: 25},
playing: [{appid: 220, name: 'Half-Life 2', playtimeMinutes: 300, status: 'PLAYING', rating: null}],
finished: [{appid: 620, name: 'Portal 2', playtimeMinutes: 900, status: 'COMPLETED', rating: 5}],
};
function mockBackends({profileStatus = 200, libraryStatus = 200} = {}) {
globalThis.fetch = vi.fn(async (url) => {
if (url.includes('/api/public/profiles/library')) {
return {ok: libraryStatus === 200, status: libraryStatus, json: async () => library};
}
return {ok: profileStatus === 200, status: profileStatus,
json: async () => ({displayName: 'Lobo', memberSince: '2026-03-02', ticket: 'the-ticket'})};
});
}
const renderPage = (name = 'Lobo') => render(
<MemoryRouter initialEntries={[`/u/${name}`]}>
<Routes><Route path="/u/:name" element={<PublicProfilePage/>}/></Routes>
</MemoryRouter>,
);
describe('PublicProfilePage', () => {
afterEach(cleanup);
it('asks Auth for the ticket, then Core for the library with it', async () => {
mockBackends();
renderPage('Lo bo');
expect(await screen.findByText('Portal 2')).toBeTruthy();
expect(fetch.mock.calls[0][0]).toMatch(/\/api\/public\/profiles\/Lo%20bo$/);
expect(fetch.mock.calls[1][1].headers['X-Profile-Ticket']).toBe('the-ticket');
expect(screen.getByText('Lobo')).toBeTruthy();
expect(screen.getByText('Half-Life 2')).toBeTruthy();
expect(screen.getByText('233')).toBeTruthy();
});
it('says the profile is unknown or private on a 404', async () => {
mockBackends({profileStatus: 404});
renderPage();
expect(await screen.findByText('PROFILE.NOT_FOUND')).toBeTruthy();
expect(fetch).toHaveBeenCalledTimes(1);
});
it('shows an error when Core fails', async () => {
mockBackends({libraryStatus: 500});
renderPage();
await waitFor(() => expect(screen.getByText('PROFILE.ERROR_LOAD')).toBeTruthy());
});
});
+64 -16
View File
@@ -4,7 +4,7 @@ import {useTranslation} from 'react-i18next';
import {useForm} from 'react-hook-form';
import {zodResolver} from '@hookform/resolvers/zod';
import {Gamepad2, Loader2, Save, SlidersHorizontal, UserRound} from 'lucide-react';
import {Copy, Gamepad2, Loader2, Save, SlidersHorizontal, UserRound} from 'lucide-react';
import {toast} from 'sonner';
import {Button} from '@/components/ui/button';
@@ -141,36 +141,56 @@ export default function SettingsPage() {
}
};
// The backend replaces all settings, so every save sends the saved values of the fields it does not change.
const savedSettings = () => ({
useDarkTheme: isDarkMode,
language: i18n.language,
steamId: profile?.steamId ?? null,
displayName: profile?.displayName ?? null,
publicProfile: !!profile?.settings?.publicProfile,
});
const handleDarkThemeToggle = async () => {
const next = !isDarkMode;
setDarkMode(next); // apply on the fly
await persistSettings({
useDarkTheme: next,
language: i18n.language,
steamId: profile?.steamId ?? null,
displayName: profile?.displayName ?? null,
});
await persistSettings({...savedSettings(), useDarkTheme: next});
};
const handleLanguageChange = async (next) => {
setLanguage(next); // apply on the fly
await persistSettings({
useDarkTheme: isDarkMode,
language: next,
steamId: profile?.steamId ?? null,
displayName: profile?.displayName ?? null,
});
await persistSettings({...savedSettings(), language: next});
};
const handlePublicProfileToggle = async () => {
const next = !profile?.settings?.publicProfile;
const result = await persistSettings({...savedSettings(), publicProfile: next});
if (result.ok) {
setProfile((p) => ({...p, settings: {...p.settings, publicProfile: next}}));
}
};
const copyProfileLink = async (link) => {
try {
await navigator.clipboard.writeText(link);
toast.success(t('SETTINGS.PUBLIC_PROFILE_COPIED'));
} catch {
toast.error(link);
}
};
const onSubmitProfile = async (values) => {
// Without a name the profile has no link, so removing the name also makes it private.
const publicProfile = savedSettings().publicProfile && !!values.displayName;
const result = await persistSettings({
useDarkTheme: isDarkMode,
language: i18n.language,
...savedSettings(),
steamId: values.steamId,
displayName: values.displayName,
publicProfile,
});
if (result.ok) {
setProfile((p) => (p ? {...p, steamId: values.steamId, displayName: values.displayName} : p));
setProfile((p) => (p ? {
...p, steamId: values.steamId, displayName: values.displayName, settings: {...p.settings, publicProfile},
} : p));
}
};
@@ -189,6 +209,11 @@ export default function SettingsPage() {
return <div className="py-24 text-center text-destructive">{loadError}</div>;
}
const publicProfile = !!profile?.settings?.publicProfile;
const profileLink = profile?.displayName
? `${window.location.origin}/u/${encodeURIComponent(profile.displayName)}`
: null;
const accountRows = [
[t('SETTINGS.EMAIL'), profile?.email],
[t('SETTINGS.ROLE'), profile?.role],
@@ -275,6 +300,29 @@ export default function SettingsPage() {
{t('SETTINGS.SAVE_PROFILE')}
</Button>
</form>
<div className="mt-6 space-y-3 border-t pt-6">
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<Label htmlFor="publicProfile">{t('SETTINGS.PUBLIC_PROFILE')}</Label>
<p className="text-xs text-muted-foreground">
{profileLink ? t('SETTINGS.PUBLIC_PROFILE_HINT') : t('SETTINGS.PUBLIC_PROFILE_NEEDS_NAME')}
</p>
</div>
<Switch id="publicProfile" checked={publicProfile} disabled={!profileLink}
onCheckedChange={handlePublicProfileToggle}/>
</div>
{publicProfile && profileLink && (
<div className="flex items-center gap-2 rounded-md bg-muted px-3 py-2 text-sm">
<a href={profileLink} target="_blank" rel="noopener noreferrer"
className="min-w-0 flex-1 truncate font-mono text-primary hover:underline">{profileLink}</a>
<Button type="button" variant="ghost" size="icon" className="size-7"
aria-label={t('SETTINGS.PUBLIC_PROFILE_COPY')} onClick={() => copyProfileLink(profileLink)}>
<Copy/>
</Button>
</div>
)}
</div>
</CardContent>
</Card>
</div>
+76
View File
@@ -0,0 +1,76 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import SettingsPage from './SettingsPage.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
vi.mock('react-i18next', async () => {
const mock = await import('../test/i18n-mock.js');
const i18n = {language: 'de', changeLanguage: vi.fn()};
return {...mock, useTranslation: () => ({t: mock.useTranslation().t, i18n})};
});
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const profile = (overrides = {}) => ({
id: 1, email: 'a@b.de', role: 'USER', displayName: 'Lobo', steamId: '76561198005679788',
settings: {useDarkTheme: true, language: 'de', publicProfile: true}, ...overrides,
});
const puts = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT').map(([, init]) => JSON.parse(init.body));
function renderPage(me) {
globalThis.fetch = vi.fn(async (url, init) => (init?.method === 'PUT'
? {ok: true, status: 200, json: async () => ({token: 'token'})}
: {ok: true, status: 200, json: async () => me}));
render(<MemoryRouter><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>);
}
describe('SettingsPage public profile', () => {
beforeEach(() => localStorage.setItem('tbp_jwt', 'token'));
afterEach(() => {
cleanup();
localStorage.clear();
});
it('keeps the public profile when another setting is saved', async () => {
renderPage(profile());
fireEvent.click(await screen.findByRole('switch', {name: 'SETTINGS.DARK_THEME'}));
await waitFor(() => expect(puts()).toHaveLength(1));
expect(puts()[0].publicProfile).toBe(true);
});
it('turns the public profile off and shows the link while it is on', async () => {
renderPage(profile());
expect(await screen.findByText(/\/u\/Lobo$/)).toBeTruthy();
fireEvent.click(screen.getByRole('switch', {name: 'SETTINGS.PUBLIC_PROFILE'}));
await waitFor(() => expect(puts()).toHaveLength(1));
expect(puts()[0]).toMatchObject({publicProfile: false, displayName: 'Lobo'});
await waitFor(() => expect(screen.queryByText(/\/u\/Lobo$/)).toBeNull());
});
it('needs a display name first', async () => {
renderPage(profile({displayName: null, settings: {useDarkTheme: true, language: 'de', publicProfile: false}}));
const toggle = await screen.findByRole('switch', {name: 'SETTINGS.PUBLIC_PROFILE'});
expect(toggle.disabled).toBe(true);
expect(screen.getByText('SETTINGS.PUBLIC_PROFILE_NEEDS_NAME')).toBeTruthy();
});
it('turns the public profile off when the display name is removed', async () => {
renderPage(profile());
const name = await screen.findByLabelText('SETTINGS.DISPLAY_NAME');
fireEvent.change(name, {target: {value: ''}});
fireEvent.click(screen.getByRole('button', {name: 'SETTINGS.SAVE_PROFILE'}));
await waitFor(() => expect(puts()).toHaveLength(1));
expect(puts()[0]).toMatchObject({displayName: null, publicProfile: false});
await waitFor(() => expect(screen.getByRole('switch', {name: 'SETTINGS.PUBLIC_PROFILE'}).getAttribute('aria-checked')).toBe('false'));
});
});