Public Access
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.
209 lines
10 KiB
React
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>
|
|
);
|
|
}
|