import {useEffect, useMemo, useRef, 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 Confetti from '../components/Confetti.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 useSaveAppearance from '../hooks/useSaveAppearance.js'; import {usePreferences} from '../context/PreferencesContext.jsx'; import PageContainer from '../components/PageContainer.jsx'; import ProfileArtMenu from '../components/ProfileArtMenu.jsx'; import {request} from '../utils/api.js'; import useAbortPrevious from '../hooks/useAbortPrevious.js'; import {ACHIEVEMENT_ICONS, reportBrokenImage} from '../utils/brokenImages.js'; import {canCorrect} from '../utils/corrections.js'; import {celebrationFor, celebrationLevel} from '../utils/confetti.js'; import {setGameHidden} from '../utils/hiddenGames.js'; import {isSteamKey} from '../utils/manualGames.js'; import {platformLabel} from '../utils/platforms.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 [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 currentUser = useCurrentUser(true); const auditor = canCorrect(currentUser); const [savedProfileStyle, setSavedProfileStyle] = useState(null); const profileStyle = savedProfileStyle ?? currentUser?.profileStyle; const [celebration, setCelebration] = useState(null); const shownLevel = useRef(null); // Every status change on this page (backlog card, hints) goes through setData. useEffect(() => { if (!data) return; const size = celebrationFor(shownLevel.current, data); if (size) setCelebration({size, id: Date.now()}); shownLevel.current = {appid: data.appid, level: celebrationLevel(data)}; }, [data]); // Another game starts empty, already in this render. A reload of the same game keeps the old data, so the edit // cards keep what is typed. const [dataAppid, setDataAppid] = useState(appid); if (dataAppid !== appid) { setDataAppid(appid); setData(null); } const nextSignal = useAbortPrevious(); useEffect(() => { const signal = nextSignal(); setIsLoading(true); setError(null); (async () => { const {ok, status, data: json} = await request( `${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}`, {signal}); if (signal.aborted) return; setIsLoading(false); if (status === 401) return; if (status === 404) setError('GAME_DETAIL.NOT_FOUND'); else if (!ok || !json) setError(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'GAME_DETAIL.ERROR_LOAD'); else setData(json); })(); }, [nextSignal, appid, 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) 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 && !data && (
)} {error && !isLoading && (

{t(error)}

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

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

{t('GAME_DETAIL.PLAYTIME', {time: formatPlaytime(data.playtimeMinutes)})} {platformLabel(data) && ( {platformLabel(data)} )}
{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 ? ( ) : ( )} {profileStyle && ( )} {!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)}
    )}
  • ); })}
)} )} {celebration && setCelebration(null)}/>} {/* 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}); }}/> )} ); }