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

+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>
);
}