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 (
navigate(-1)}>
{t('GAME_DETAIL.BACK')}
{isLoading && (
)}
{error && !isLoading && (
{error}
{t('GAME_DETAIL.BACK_TO_LIBRARY')}
)}
{!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 && (
{t('GAME_DETAIL.OPEN_IN_SPEEDRUN')}
)}
{auditor && (
setCorrecting(true)}>
{t('CORRECTION.OPEN')}
)}
{auditor && (
setMerging(true)}>
{t('MERGE.OPEN')}
)}
{/* 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 &&
{t('GAME_DETAIL.OPEN_IN_STEAM')}
{isBackground ? (
saveAppearance({backgroundAppid: null})}>
{t('GAME_DETAIL.REMOVE_BACKGROUND')}
) : (
saveAppearance({backgroundAppid: data.appid})}>
{t('GAME_DETAIL.SET_BACKGROUND')}
)}
{isBanner ? (
saveAppearance({bannerAppid: null})}>
{t('SETTINGS.BANNER_AUTO')}
) : (
saveAppearance({bannerAppid: data.appid, showBanner: true})}>
{t('GAME_DETAIL.SET_BANNER')}
)}
{!manual && (
{data.hidden ? : }
{t(data.hidden ? 'GAME_DETAIL.UNHIDE' : 'GAME_DETAIL.HIDE')}
)}
}
{/* 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]) => (
))}
{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});
}}/>
)}
);
}