From fd343da4be321aebea066fb46cce6ece02380a38 Mon Sep 17 00:00:00 2001 From: lobo Date: Thu, 24 Sep 2026 14:18:32 +0200 Subject: [PATCH] feat(profile): public profile page and the switch in settings /u/ 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. --- public/i18n/de.json | 19 ++- public/i18n/en.json | 19 ++- src/App.jsx | 2 + src/pages/PublicProfilePage.jsx | 179 +++++++++++++++++++++++++++ src/pages/PublicProfilePage.test.jsx | 61 +++++++++ src/pages/SettingsPage.jsx | 80 +++++++++--- src/pages/SettingsPage.test.jsx | 76 ++++++++++++ 7 files changed, 418 insertions(+), 18 deletions(-) create mode 100644 src/pages/PublicProfilePage.jsx create mode 100644 src/pages/PublicProfilePage.test.jsx create mode 100644 src/pages/SettingsPage.test.jsx diff --git a/public/i18n/de.json b/public/i18n/de.json index 5a5f964..3ff4c64 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -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" } } diff --git a/public/i18n/en.json b/public/i18n/en.json index 3646153..ec768d1 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -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" } } diff --git a/src/App.jsx b/src/App.jsx index ffad131..509dbe2 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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() { }/> }/> }/> + }/> }/> }/> diff --git a/src/pages/PublicProfilePage.jsx b/src/pages/PublicProfilePage.jsx new file mode 100644 index 0000000..c8a0920 --- /dev/null +++ b/src/pages/PublicProfilePage.jsx @@ -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 ( +
+ + +

{error}

+ {t('PROFILE.TO_START')} +
+
+ ); + } + + if (!library) { + return ( +
+ + +
+ ); + } + + 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 ( +
+ +
+
+

{t('PROFILE.TITLE')}

+

{profile.displayName}

+ {memberSince && ( +

{t('PROFILE.MEMBER_SINCE', {date: memberSince})}

+ )} +
+
+ {stats.map(([Icon, value, label]) => ( +
+ +
{value}
+
{label}
+
+ ))} +
+ {library.totalGames > 0 && ( +
+
+ {GAME_STATUSES.map((s) => ( +
+ ))} +
+
    + {GAME_STATUSES.map((s) => ( +
  • + + {t(`BACKLOG.STATUS.${s}`)} + {library.byStatus[s] ?? 0} +
  • + ))} +
+
+ )} + + + {library.playing.length > 0 && ( + +

+ {t('PROFILE.PLAYING')} +

+
    + {library.playing.map((g) => ( +
  • + +
    {g.name ?? `App ${g.appid}`}
    +
    {formatPlaytime(g.playtimeMinutes)}
    +
  • + ))} +
+
+ )} + + {library.finished.length > 0 && ( + +

+ {t('PROFILE.FINISHED_GAMES')} +

+
    + {library.finished.map((g) => ( +
  • + + {g.name ?? `App ${g.appid}`} + {g.rating && ( + + {Array.from({length: g.rating}, (_, i) => ( + + ))} + + )} + +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/src/pages/PublicProfilePage.test.jsx b/src/pages/PublicProfilePage.test.jsx new file mode 100644 index 0000000..377c9d4 --- /dev/null +++ b/src/pages/PublicProfilePage.test.jsx @@ -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( + + }/> + , +); + +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()); + }); +}); diff --git a/src/pages/SettingsPage.jsx b/src/pages/SettingsPage.jsx index 45bee6c..b89daf7 100644 --- a/src/pages/SettingsPage.jsx +++ b/src/pages/SettingsPage.jsx @@ -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
{loadError}
; } + 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')} + +
+
+
+ +

+ {profileLink ? t('SETTINGS.PUBLIC_PROFILE_HINT') : t('SETTINGS.PUBLIC_PROFILE_NEEDS_NAME')} +

+
+ +
+ {publicProfile && profileLink && ( +
+ {profileLink} + +
+ )} +
diff --git a/src/pages/SettingsPage.test.jsx b/src/pages/SettingsPage.test.jsx new file mode 100644 index 0000000..a7ca5a6 --- /dev/null +++ b/src/pages/SettingsPage.test.jsx @@ -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(); +} + +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')); + }); +});