Public Access
Remove the placeholder tiles and the settings tile, fix the mobile header width. iconUrl/formatPlaytime move to utils/games.js (599 min now shows 10h, not 10.0h), and the sync button logic to the useForceSync hook shared with the library.
249 lines
13 KiB
React
249 lines
13 KiB
React
import {useEffect, useMemo, useState} from 'react';
|
|
import {Link, useNavigate, useParams} from 'react-router-dom';
|
|
import {useTranslation} from 'react-i18next';
|
|
import {formatPlaytime, iconUrl} 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]);
|
|
|
|
return (
|
|
<div className="min-h-screen bg-slate-50 dark:bg-slate-900 p-8">
|
|
<div className="max-w-4xl mx-auto space-y-6">
|
|
<button
|
|
type="button"
|
|
onClick={() => navigate(-1)}
|
|
className="inline-flex items-center gap-2 text-sm font-bold text-slate-600 dark:text-slate-300 hover:text-blue-500 transition-colors"
|
|
>
|
|
<i className="mdi mdi-arrow-left"></i> {t('GAME_DETAIL.BACK')}
|
|
</button>
|
|
|
|
{isLoading && (
|
|
<div className="text-center text-slate-600 dark:text-slate-300 py-12">
|
|
{t('GAME_DETAIL.LOADING')}
|
|
</div>
|
|
)}
|
|
|
|
{error && !isLoading && (
|
|
<div className="text-center text-red-500 py-12">
|
|
{error}
|
|
<div className="mt-4">
|
|
<Link to="/library" className="text-blue-500 hover:underline">
|
|
{t('GAME_DETAIL.BACK_TO_LIBRARY')}
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{!isLoading && !error && data && (
|
|
<>
|
|
<header className="flex items-center gap-4 bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 p-6">
|
|
{iconUrl(data.appid, data.imgIconUrl) ? (
|
|
<img
|
|
src={iconUrl(data.appid, data.imgIconUrl)}
|
|
alt=""
|
|
className="w-16 h-16 rounded"
|
|
loading="lazy"
|
|
/>
|
|
) : (
|
|
<div className="w-16 h-16 rounded bg-slate-200 dark:bg-slate-700"/>
|
|
)}
|
|
<div className="flex-1">
|
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-white truncate">
|
|
{data.name ?? `App ${data.appid}`}
|
|
</h1>
|
|
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
|
{t('GAME_DETAIL.PLAYTIME', {time: formatPlaytime(data.playtimeMinutes)})}
|
|
</p>
|
|
</div>
|
|
<a
|
|
href={`https://store.steampowered.com/app/${data.appid}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-sm font-bold text-blue-600 dark:text-blue-400 hover:underline whitespace-nowrap"
|
|
>
|
|
{t('GAME_DETAIL.OPEN_IN_STEAM')} <i className="mdi mdi-open-in-new"></i>
|
|
</a>
|
|
</header>
|
|
|
|
{!data.hasAchievements && (
|
|
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 p-6 text-center text-slate-500 dark:text-slate-400">
|
|
{t('GAME_DETAIL.NO_ACHIEVEMENTS')}
|
|
</div>
|
|
)}
|
|
|
|
{data.hasAchievements && !data.statsVisible && (
|
|
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 p-6 text-center text-slate-500 dark:text-slate-400">
|
|
<i className="mdi mdi-shield-lock text-3xl block mb-2"></i>
|
|
{t('GAME_DETAIL.STATS_HIDDEN')}
|
|
</div>
|
|
)}
|
|
|
|
{data.hasAchievements && data.statsVisible && (
|
|
<>
|
|
<section className="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 p-6 space-y-4">
|
|
<div className="flex items-baseline justify-between">
|
|
<h2 className="text-lg font-bold text-slate-900 dark:text-white">
|
|
{t('GAME_DETAIL.ACHIEVEMENTS')}
|
|
</h2>
|
|
<span className="text-sm text-slate-500 dark:text-slate-400 tabular-nums">
|
|
{data.achievementsUnlocked} / {data.achievementsTotal}
|
|
{' · '}
|
|
{data.completionPercent}%
|
|
</span>
|
|
</div>
|
|
<div className="w-full h-3 rounded-full bg-slate-200 dark:bg-slate-700 overflow-hidden">
|
|
<div
|
|
className="h-full bg-blue-500 transition-all"
|
|
style={{width: `${Math.min(100, data.completionPercent)}%`}}
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-3 gap-3 text-center text-sm">
|
|
<div className="p-3 rounded-lg bg-slate-50 dark:bg-slate-900">
|
|
<div className="text-xl font-bold text-slate-900 dark:text-white tabular-nums">
|
|
{data.achievementsUnlocked}
|
|
</div>
|
|
<div className="text-xs uppercase tracking-wider text-slate-500 dark:text-slate-400">
|
|
{t('GAME_DETAIL.UNLOCKED')}
|
|
</div>
|
|
</div>
|
|
<div className="p-3 rounded-lg bg-slate-50 dark:bg-slate-900">
|
|
<div className="text-xl font-bold text-slate-900 dark:text-white tabular-nums">
|
|
{data.achievementsRemaining}
|
|
</div>
|
|
<div className="text-xs uppercase tracking-wider text-slate-500 dark:text-slate-400">
|
|
{t('GAME_DETAIL.REMAINING')}
|
|
</div>
|
|
</div>
|
|
<div className="p-3 rounded-lg bg-slate-50 dark:bg-slate-900">
|
|
<div className="text-xl font-bold text-slate-900 dark:text-white tabular-nums">
|
|
{data.completionPercent}%
|
|
</div>
|
|
<div className="text-xs uppercase tracking-wider text-slate-500 dark:text-slate-400">
|
|
{t('GAME_DETAIL.COMPLETION')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 p-6">
|
|
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
{sortedAchievements.map((a) => (
|
|
<li
|
|
key={a.apiName}
|
|
className={`flex items-start gap-3 p-3 rounded-lg border ${
|
|
a.unlocked
|
|
? 'border-blue-200 dark:border-blue-700 bg-blue-50/50 dark:bg-blue-900/20'
|
|
: 'border-slate-200 dark:border-slate-700 opacity-70'
|
|
}`}
|
|
>
|
|
{a.unlocked ? (
|
|
a.iconUrl
|
|
? <img src={a.iconUrl} alt="" className="w-12 h-12 rounded"/>
|
|
: <div className="w-12 h-12 rounded bg-slate-200 dark:bg-slate-700"/>
|
|
) : (
|
|
a.iconGrayUrl
|
|
? <img src={a.iconGrayUrl} alt="" className="w-12 h-12 rounded grayscale"/>
|
|
: <div className="w-12 h-12 rounded bg-slate-200 dark:bg-slate-700"/>
|
|
)}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="font-semibold text-slate-900 dark:text-white truncate">
|
|
{a.displayName ?? a.apiName}
|
|
</div>
|
|
{a.description && (
|
|
<div className="text-xs text-slate-500 dark:text-slate-400 mt-1 line-clamp-2">
|
|
{a.description}
|
|
</div>
|
|
)}
|
|
{a.unlocked && a.unlockedAt && (
|
|
<div className="text-xs text-blue-600 dark:text-blue-400 mt-1 tabular-nums">
|
|
{formatUnlockedAt(a.unlockedAt, i18n.language)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|