Public Access
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:
1 parent
aa5124ea1e
commit
fd343da4be
7 files changed
+418
-18
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user