Files
ToBePlayed-Frontend/src/pages/PublicProfilePage.jsx
T
lobo 9d1c7bb40d
Build, Test & Push Frontend / quality-check (push) Successful in 15s
Build, Test & Push Frontend / docker (push) Successful in 22s
feat(stats): add stats page and pile trend
Show the stat groups on /stats, the pile trend on the dashboard card,
one switch per group in the settings, and the shared groups on the
public profile.
2026-09-27 07:16:10 +02:00

209 lines
10 KiB
React

import {useEffect, useState} from 'react';
import {Link, useParams} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {CircleCheck, Clock, Gamepad2, Library, Percent, Star, Trophy, 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';
import PageContainer from '../components/PageContainer.jsx';
import PileStats from '../components/stats/PileStats.jsx';
import TrendStats from '../components/stats/TrendStats.jsx';
import TopGamesStats from '../components/stats/TopGamesStats.jsx';
import TasteStats from '../components/stats/TasteStats.jsx';
import HabitsStats from '../components/stats/HabitsStats.jsx';
import FinishedStats from '../components/stats/FinishedStats.jsx';
/**
* 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 (
<PageContainer className="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>
</PageContainer>
);
}
if (!library) {
return (
<PageContainer className="p-4 md:p-8 space-y-6">
<Skeleton className="h-36 rounded-xl"/>
<Skeleton className="h-64 rounded-xl"/>
</PageContainer>
);
}
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')],
];
if (library.averageCompletion != null) {
stats.push(
[Trophy, (library.perfectGames ?? 0).toLocaleString(i18n.language), t('PROFILE.PERFECT_GAMES')],
[Percent, `${library.averageCompletion.toLocaleString(i18n.language)} %`, t('PROFILE.AVERAGE_COMPLETION')],
);
}
const memberSince = profile.memberSince
? new Date(`${profile.memberSince}T12:00:00`).toLocaleDateString(i18n.language, {year: 'numeric', month: 'long'})
: null;
return (
<PageContainer className="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 gap-3 ${stats.length > 3 ? 'grid-cols-2 sm:grid-cols-5' : 'grid-cols-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} imageUrl={g.imageUrl} 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} imageUrl={g.imageUrl} 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>
)}
{library.stats && <SharedStats stats={library.stats}/>}
</PageContainer>
);
}
// Only the groups the user shares come back; the game rows do not link, the library pages are private.
function SharedStats({stats}) {
return (
<div className="grid gap-6 md:grid-cols-2">
{stats.pile && <PileStats pile={stats.pile} linked={false}/>}
{stats.trend && <TrendStats trend={stats.trend}/>}
{stats.topGames && <TopGamesStats topGames={stats.topGames} linked={false}/>}
{stats.taste && <TasteStats taste={stats.taste}/>}
{stats.habits && <HabitsStats habits={stats.habits}/>}
{stats.finished && <FinishedStats finished={stats.finished}/>}
</div>
);
}