Public Access
Detail page gets an edit card. The library gets status badges, status filter chips and a debounced name search, both kept in the URL.
224 lines
11 KiB
React
224 lines
11 KiB
React
import {useEffect, useMemo, useState} from 'react';
|
|
import {Link, useNavigate, useParams} from 'react-router-dom';
|
|
import {useTranslation} from 'react-i18next';
|
|
import {ArrowLeft, Clock, ExternalLink, ShieldOff, Trophy} from 'lucide-react';
|
|
|
|
import {Badge} from '@/components/ui/badge';
|
|
import {Button} from '@/components/ui/button';
|
|
import {Card} from '@/components/ui/card';
|
|
import {Skeleton} from '@/components/ui/skeleton';
|
|
import {cn} from '@/lib/utils';
|
|
import BacklogCard from '../components/BacklogCard.jsx';
|
|
import GameCover from '../components/GameCover.jsx';
|
|
import GameStatusBadge from '../components/GameStatusBadge.jsx';
|
|
import {formatPlaytime} from '../utils/games.js';
|
|
|
|
const formatUnlockedAt = (instant, locale) => {
|
|
if (!instant) return '';
|
|
try {
|
|
return new Date(instant).toLocaleDateString(locale, {year: 'numeric', month: 'short', day: 'numeric'});
|
|
} catch {
|
|
return '';
|
|
}
|
|
};
|
|
|
|
export default function GameDetail() {
|
|
const {t, i18n} = useTranslation();
|
|
const {appid} = useParams();
|
|
const navigate = useNavigate();
|
|
|
|
const [data, setData] = useState(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState(null);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
const token = localStorage.getItem('tbp_jwt');
|
|
if (!token) {
|
|
navigate('/login');
|
|
return;
|
|
}
|
|
setIsLoading(true);
|
|
setError(null);
|
|
(async () => {
|
|
try {
|
|
const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}`;
|
|
const response = await fetch(url, {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
});
|
|
if (cancelled) return;
|
|
if (response.status === 401) {
|
|
localStorage.removeItem('tbp_jwt');
|
|
navigate('/login');
|
|
return;
|
|
}
|
|
if (response.status === 404) {
|
|
setError(t('GAME_DETAIL.NOT_FOUND'));
|
|
return;
|
|
}
|
|
if (!response.ok) {
|
|
setError(t('GAME_DETAIL.ERROR_LOAD'));
|
|
return;
|
|
}
|
|
const json = await response.json();
|
|
setData(json);
|
|
} catch (err) {
|
|
if (!cancelled) {
|
|
console.error('GameDetail load error:', err);
|
|
setError(t('INFO.ERROR.NETWORK_FAILURE'));
|
|
}
|
|
} finally {
|
|
if (!cancelled) setIsLoading(false);
|
|
}
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [appid, navigate, t]);
|
|
|
|
const sortedAchievements = useMemo(() => {
|
|
if (!data?.achievements) return [];
|
|
return [...data.achievements].sort((a, b) => {
|
|
if (a.unlocked !== b.unlocked) return a.unlocked ? -1 : 1;
|
|
if (a.unlocked && b.unlocked) {
|
|
return (b.unlockedAt ?? '').localeCompare(a.unlockedAt ?? '');
|
|
}
|
|
return (a.displayName ?? a.apiName).localeCompare(b.displayName ?? b.apiName);
|
|
});
|
|
}, [data]);
|
|
|
|
const stats = data ? [
|
|
[t('GAME_DETAIL.UNLOCKED'), data.achievementsUnlocked],
|
|
[t('GAME_DETAIL.REMAINING'), data.achievementsRemaining],
|
|
[t('GAME_DETAIL.COMPLETION'), `${data.completionPercent}%`],
|
|
] : [];
|
|
|
|
return (
|
|
<div className="w-full max-w-5xl mx-auto p-4 md:p-8 space-y-6">
|
|
<Button variant="ghost" size="sm" className="-ml-2" onClick={() => navigate(-1)}>
|
|
<ArrowLeft/>{t('GAME_DETAIL.BACK')}
|
|
</Button>
|
|
|
|
{isLoading && (
|
|
<div className="space-y-6">
|
|
<Skeleton className="h-52 rounded-xl"/>
|
|
<Skeleton className="h-40 rounded-xl"/>
|
|
</div>
|
|
)}
|
|
|
|
{error && !isLoading && (
|
|
<div className="py-12 text-center text-destructive space-y-4">
|
|
<p>{error}</p>
|
|
<Button asChild variant="outline"><Link to="/library">{t('GAME_DETAIL.BACK_TO_LIBRARY')}</Link></Button>
|
|
</div>
|
|
)}
|
|
|
|
{!isLoading && !error && data && (
|
|
<>
|
|
<Card className="relative overflow-hidden p-0 gap-0 md:flex-row">
|
|
<GameCover appid={data.appid} className="w-full rounded-none md:w-96 md:shrink-0"/>
|
|
<div className="flex flex-1 flex-col justify-between gap-4 p-6">
|
|
<div>
|
|
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">
|
|
{data.name ?? `App ${data.appid}`}
|
|
</h1>
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
<Badge variant="secondary" className="tabular-nums">
|
|
<Clock className="size-3"/>
|
|
{t('GAME_DETAIL.PLAYTIME', {time: formatPlaytime(data.playtimeMinutes)})}
|
|
</Badge>
|
|
<GameStatusBadge status={data.status}/>
|
|
</div>
|
|
</div>
|
|
<Button asChild variant="outline" size="sm" className="self-start">
|
|
<a href={`https://store.steampowered.com/app/${data.appid}`} target="_blank" rel="noopener noreferrer">
|
|
{t('GAME_DETAIL.OPEN_IN_STEAM')}<ExternalLink/>
|
|
</a>
|
|
</Button>
|
|
</div>
|
|
</Card>
|
|
|
|
<BacklogCard key={data.appid} game={data} onSaved={(entry) => setData((d) => ({...d, ...entry}))}/>
|
|
|
|
{!data.hasAchievements && (
|
|
<Card className="items-center py-10 text-center text-sm text-muted-foreground">
|
|
<Trophy className="size-8"/>
|
|
{t('GAME_DETAIL.NO_ACHIEVEMENTS')}
|
|
</Card>
|
|
)}
|
|
|
|
{data.hasAchievements && !data.statsVisible && (
|
|
<Card className="items-center py-10 text-center text-sm text-muted-foreground">
|
|
<ShieldOff className="size-8"/>
|
|
{t('GAME_DETAIL.STATS_HIDDEN')}
|
|
</Card>
|
|
)}
|
|
|
|
{data.hasAchievements && data.statsVisible && (
|
|
<>
|
|
<Card className="p-6 gap-5">
|
|
<div className="flex items-baseline justify-between">
|
|
<h2 className="flex items-center gap-2 text-lg font-semibold">
|
|
<Trophy className="size-4 text-amber-500"/>
|
|
{t('GAME_DETAIL.ACHIEVEMENTS')}
|
|
</h2>
|
|
<span className="text-sm text-muted-foreground tabular-nums">
|
|
{data.achievementsUnlocked} / {data.achievementsTotal}
|
|
</span>
|
|
</div>
|
|
<div className="h-2.5 w-full overflow-hidden rounded-full bg-muted">
|
|
<div
|
|
className="h-full rounded-full bg-primary transition-all"
|
|
style={{width: `${Math.min(100, data.completionPercent)}%`}}
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-3 gap-3 text-center">
|
|
{stats.map(([label, value]) => (
|
|
<div key={label} className="rounded-lg bg-muted/50 p-3">
|
|
<div className="text-2xl font-bold tabular-nums">{value}</div>
|
|
<div className="text-xs text-muted-foreground">{label}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
|
|
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
{sortedAchievements.map((a) => {
|
|
const icon = a.unlocked ? a.iconUrl : a.iconGrayUrl;
|
|
return (
|
|
<li
|
|
key={a.apiName}
|
|
className={cn(
|
|
'flex items-start gap-3 rounded-xl border bg-card p-3',
|
|
a.unlocked ? 'border-primary/40' : 'opacity-60',
|
|
)}
|
|
>
|
|
{icon
|
|
? <img src={icon} alt="" className={cn('size-12 rounded-md', !a.unlocked && 'grayscale')}/>
|
|
: <div className="size-12 rounded-md bg-muted"/>}
|
|
<div className="min-w-0 flex-1">
|
|
<div className="truncate font-medium">{a.displayName ?? a.apiName}</div>
|
|
{a.description && (
|
|
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">{a.description}</div>
|
|
)}
|
|
{a.unlocked && a.unlockedAt && (
|
|
<div className="mt-1 text-xs text-primary tabular-nums">
|
|
{formatUnlockedAt(a.unlockedAt, i18n.language)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|