import {useEffect, useMemo, useState} from 'react'; import {Link, useNavigate, useParams} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; import {ArrowLeft, Clock, ExternalLink, Eye, EyeOff, Gamepad2, Image as ImageIcon, ImageOff, Merge, PanelTop, PencilLine, RotateCcw, ShieldOff, Timer, Trophy} from 'lucide-react'; import {toast} from 'sonner'; 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 ManualGameCard from '../components/ManualGameCard.jsx'; import CompletedHint from '../components/CompletedHint.jsx'; import FinishedHint from '../components/FinishedHint.jsx'; import GameCorrectionDialog from '../components/GameCorrectionDialog.jsx'; import GameMergeDialog from '../components/GameMergeDialog.jsx'; import PlayingHint from '../components/PlayingHint.jsx'; import TimeToBeat from '../components/TimeToBeat.jsx'; import {formatPlaytime} from '../utils/games.js'; import useCurrentUser from '../hooks/useCurrentUser.js'; import useSessionExpired from '../hooks/useSessionExpired.js'; import useSaveAppearance from '../hooks/useSaveAppearance.js'; import {usePreferences} from '../context/PreferencesContext.jsx'; import PageContainer from '../components/PageContainer.jsx'; import {apiFetch} from '../utils/api.js'; import {ACHIEVEMENT_ICONS, reportBrokenImage} from '../utils/brokenImages.js'; import {canCorrect} from '../utils/corrections.js'; import {setGameHidden} from '../utils/hiddenGames.js'; import {hasSession} from '../utils/auth.js'; import {isSteamKey} from '../utils/manualGames.js'; const SPEEDRUN_PREFIX = 'https://www.speedrun.com/'; const formatUnlockedAt = (instant, locale) => { if (!instant) return ''; try { return new Date(instant).toLocaleDateString(locale, {year: 'numeric', month: 'short', day: 'numeric'}); } catch { return ''; } }; function AchievementIcon({appid, src, unlocked}) { const [failed, setFailed] = useState(false); if (!src || failed) return
; return ( { reportBrokenImage(appid, ACHIEVEMENT_ICONS); setFailed(true); }}/> ); } export default function GameDetail() { const {t, i18n} = useTranslation(); const {appid} = useParams(); const {appearance} = usePreferences(); const saveAppearance = useSaveAppearance(); const navigate = useNavigate(); const expireSession = useSessionExpired(); const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(true); const [hiding, setHiding] = useState(false); const [error, setError] = useState(null); const [correcting, setCorrecting] = useState(false); const [merging, setMerging] = useState(false); const [reloads, setReloads] = useState(0); const auditor = canCorrect(useCurrentUser(true)); useEffect(() => { let cancelled = false; if (!hasSession()) { 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 apiFetch(url, { headers: { 'Content-Type': 'application/json', }, }); if (cancelled) return; if (response.status === 401) { expireSession(); 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, expireSession, t, reloads]); 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 manual = data?.source === 'MANUAL'; const onSteam = !!data && isSteamKey(data.appid); // Core checks the link too; a link from the API must never run script. const speedrunUrl = typeof data?.speedrunUrl === 'string' && data.speedrunUrl.startsWith(SPEEDRUN_PREFIX) ? data.speedrunUrl : null; const isBackground = !!data && appearance.backgroundAppid === data.appid; const isBanner = !!data && appearance.showBanner && appearance.bannerAppid === data.appid; const toggleHidden = async () => { const hidden = !data.hidden; setHiding(true); try { const response = await setGameHidden(data.appid, hidden); if (response.status === 401) { expireSession(); return; } if (!response.ok) { toast.error(t('GAME_DETAIL.HIDE_ERROR')); return; } setData((d) => ({...d, hidden})); toast.success(t(hidden ? 'GAME_DETAIL.HIDDEN_TOAST' : 'GAME_DETAIL.SHOWN_TOAST')); } catch (err) { console.error('Hide game error:', err); toast.error(t('INFO.ERROR.NETWORK_FAILURE')); } finally { setHiding(false); } }; const stats = data ? [ [t('GAME_DETAIL.UNLOCKED'), data.achievementsUnlocked], [t('GAME_DETAIL.REMAINING'), data.achievementsRemaining], [t('GAME_DETAIL.COMPLETION'), `${data.completionPercent}%`], ] : []; return ( {isLoading && (
)} {error && !isLoading && (

{error}

)} {!isLoading && !error && data && ( <> {data.hidden && (
{t('GAME_DETAIL.HIDDEN_NOTE')}
)}

{data.name ?? `App ${data.appid}`}

{t('GAME_DETAIL.PLAYTIME', {time: formatPlaytime(data.playtimeMinutes)})} {data.platform && ( {data.platform} )}
{speedrunUrl && ( )} {auditor && ( )} {auditor && ( )}
{/* Store page, background and banner need Steam art, so a game off Steam has none of them. Hide is here too: only a Steam game can be hidden. */} {onSteam &&
{isBackground ? ( ) : ( )} {isBanner ? ( ) : ( )} {!manual && ( )}
}
{/* The finished question also answers "are you playing it?". */} {!(data.finishedHint && ['BACKLOG', 'PLAYING'].includes(data.status)) && ( setData((d) => ({...d, ...entry}))}/> )} setData((d) => ({...d, ...entry, finishedHint: null}))} onDismissed={() => setData((d) => ({...d, finishedHint: null}))}/> setData((d) => ({...d, ...entry}))}/> setData((d) => ({...d, ...entry}))}/> {manual && ( setData((d) => ({...d, ...changes}))}/> )} {!data.hasAchievements && ( {t(manual ? 'MANUAL_GAME.NO_ACHIEVEMENTS' : 'GAME_DETAIL.NO_ACHIEVEMENTS')} )} {data.hasAchievements && !data.statsVisible && ( {t('GAME_DETAIL.STATS_HIDDEN')} )} {data.hasAchievements && data.statsVisible && ( <>

{t('GAME_DETAIL.ACHIEVEMENTS')}

{data.achievementsUnlocked} / {data.achievementsTotal}
{stats.map(([label, value]) => (
{value}
{label}
))}
    {sortedAchievements.map((a) => { const icon = a.unlocked ? a.iconUrl : a.iconGrayUrl; return (
  • {a.displayName ?? a.apiName}
    {a.description && (
    {a.description}
    )} {a.unlocked && a.unlockedAt && (
    {formatUnlockedAt(a.unlockedAt, i18n.language)}
    )}
  • ); })}
)} )} {/* Outside the content: the reload after a save must not close it. */} {data && auditor && ( setReloads((n) => n + 1)}/> )} {auditor && data && ( { // A new key loads through the address; the same key needs a reload. if (merged === data.appid) setReloads((n) => n + 1); else navigate(`/library/${merged}`, {replace: true}); }}/> )} ); }