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
+18
-1
@@ -59,7 +59,12 @@
|
|||||||
"DISPLAY_NAME_PLACEHOLDER": "z. B. Lobo",
|
"DISPLAY_NAME_PLACEHOLDER": "z. B. Lobo",
|
||||||
"DISPLAY_NAME_HINT": "3–24 Zeichen: Buchstaben, Zahlen, Leerzeichen, _ . -",
|
"DISPLAY_NAME_HINT": "3–24 Zeichen: Buchstaben, Zahlen, Leerzeichen, _ . -",
|
||||||
"SAVE_PROFILE": "Profil speichern",
|
"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": {
|
"VALIDATION": {
|
||||||
"EMAIL_REQUIRED": "Email ist erforderlich.",
|
"EMAIL_REQUIRED": "Email ist erforderlich.",
|
||||||
@@ -216,5 +221,17 @@
|
|||||||
"NO_PRICE": "Noch kein Preis",
|
"NO_PRICE": "Noch kein Preis",
|
||||||
"ADDED": "Hinzugefügt am {{date}}",
|
"ADDED": "Hinzugefügt am {{date}}",
|
||||||
"ERROR_LOAD": "Wunschliste konnte nicht geladen werden."
|
"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
@@ -59,7 +59,12 @@
|
|||||||
"DISPLAY_NAME_PLACEHOLDER": "e.g. Lobo",
|
"DISPLAY_NAME_PLACEHOLDER": "e.g. Lobo",
|
||||||
"DISPLAY_NAME_HINT": "3–24 characters: letters, digits, spaces, _ . -",
|
"DISPLAY_NAME_HINT": "3–24 characters: letters, digits, spaces, _ . -",
|
||||||
"SAVE_PROFILE": "Save profile",
|
"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": {
|
"VALIDATION": {
|
||||||
"EMAIL_REQUIRED": "Email is required.",
|
"EMAIL_REQUIRED": "Email is required.",
|
||||||
@@ -216,5 +221,17 @@
|
|||||||
"NO_PRICE": "No price yet",
|
"NO_PRICE": "No price yet",
|
||||||
"ADDED": "Added {{date}}",
|
"ADDED": "Added {{date}}",
|
||||||
"ERROR_LOAD": "Could not load the wishlist."
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,6 +11,7 @@ import SettingsPage from './pages/SettingsPage.jsx';
|
|||||||
import GameLibrary from './pages/GameLibrary.jsx';
|
import GameLibrary from './pages/GameLibrary.jsx';
|
||||||
import GameDetail from './pages/GameDetail.jsx';
|
import GameDetail from './pages/GameDetail.jsx';
|
||||||
import WishlistPage from './pages/WishlistPage.jsx';
|
import WishlistPage from './pages/WishlistPage.jsx';
|
||||||
|
import PublicProfilePage from './pages/PublicProfilePage.jsx';
|
||||||
import AppHeader from './components/AppHeader.jsx';
|
import AppHeader from './components/AppHeader.jsx';
|
||||||
import {Toaster} from '@/components/ui/sonner';
|
import {Toaster} from '@/components/ui/sonner';
|
||||||
import {TooltipProvider} from '@/components/ui/tooltip';
|
import {TooltipProvider} from '@/components/ui/tooltip';
|
||||||
@@ -49,6 +50,7 @@ function App() {
|
|||||||
<Route path="/library" element={<GameLibrary/>}/>
|
<Route path="/library" element={<GameLibrary/>}/>
|
||||||
<Route path="/library/:appid" element={<GameDetail/>}/>
|
<Route path="/library/:appid" element={<GameDetail/>}/>
|
||||||
<Route path="/wishlist" element={<WishlistPage/>}/>
|
<Route path="/wishlist" element={<WishlistPage/>}/>
|
||||||
|
<Route path="/u/:name" element={<PublicProfilePage/>}/>
|
||||||
<Route path="/settings" element={<SettingsPage/>}/>
|
<Route path="/settings" element={<SettingsPage/>}/>
|
||||||
<Route path="/impressum" element={<ImprintPage/>}/>
|
<Route path="/impressum" element={<ImprintPage/>}/>
|
||||||
</Routes>
|
</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 {useForm} from 'react-hook-form';
|
||||||
import {zodResolver} from '@hookform/resolvers/zod';
|
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 {toast} from 'sonner';
|
||||||
|
|
||||||
import {Button} from '@/components/ui/button';
|
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 handleDarkThemeToggle = async () => {
|
||||||
const next = !isDarkMode;
|
const next = !isDarkMode;
|
||||||
setDarkMode(next); // apply on the fly
|
setDarkMode(next); // apply on the fly
|
||||||
await persistSettings({
|
await persistSettings({...savedSettings(), useDarkTheme: next});
|
||||||
useDarkTheme: next,
|
|
||||||
language: i18n.language,
|
|
||||||
steamId: profile?.steamId ?? null,
|
|
||||||
displayName: profile?.displayName ?? null,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLanguageChange = async (next) => {
|
const handleLanguageChange = async (next) => {
|
||||||
setLanguage(next); // apply on the fly
|
setLanguage(next); // apply on the fly
|
||||||
await persistSettings({
|
await persistSettings({...savedSettings(), language: next});
|
||||||
useDarkTheme: isDarkMode,
|
};
|
||||||
language: next,
|
|
||||||
steamId: profile?.steamId ?? null,
|
const handlePublicProfileToggle = async () => {
|
||||||
displayName: profile?.displayName ?? null,
|
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) => {
|
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({
|
const result = await persistSettings({
|
||||||
useDarkTheme: isDarkMode,
|
...savedSettings(),
|
||||||
language: i18n.language,
|
|
||||||
steamId: values.steamId,
|
steamId: values.steamId,
|
||||||
displayName: values.displayName,
|
displayName: values.displayName,
|
||||||
|
publicProfile,
|
||||||
});
|
});
|
||||||
if (result.ok) {
|
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>;
|
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 = [
|
const accountRows = [
|
||||||
[t('SETTINGS.EMAIL'), profile?.email],
|
[t('SETTINGS.EMAIL'), profile?.email],
|
||||||
[t('SETTINGS.ROLE'), profile?.role],
|
[t('SETTINGS.ROLE'), profile?.role],
|
||||||
@@ -275,6 +300,29 @@ export default function SettingsPage() {
|
|||||||
{t('SETTINGS.SAVE_PROFILE')}
|
{t('SETTINGS.SAVE_PROFILE')}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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