diff --git a/src/components/GameAchievements.jsx b/src/components/GameAchievements.jsx
new file mode 100644
index 0000000..b98662c
--- /dev/null
+++ b/src/components/GameAchievements.jsx
@@ -0,0 +1,107 @@
+import {useMemo, useState} from 'react';
+import {useTranslation} from 'react-i18next';
+import {Trophy} from 'lucide-react';
+
+import {Card} from '@/components/ui/card';
+import {cn} from '@/lib/utils';
+import {ACHIEVEMENT_ICONS, reportBrokenImage} from '../utils/brokenImages.js';
+
+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 GameAchievements({game}) {
+ const {t, i18n} = useTranslation();
+
+ const sortedAchievements = useMemo(() => {
+ if (!game.achievements) return [];
+ return [...game.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);
+ });
+ }, [game.achievements]);
+
+ const stats = [
+ [t('GAME_DETAIL.UNLOCKED'), game.achievementsUnlocked],
+ [t('GAME_DETAIL.REMAINING'), game.achievementsRemaining],
+ [t('GAME_DETAIL.COMPLETION'), `${game.completionPercent}%`],
+ ];
+
+ return (
+ <>
+
+
+
+
+ {t('GAME_DETAIL.ACHIEVEMENTS')}
+
+
+ {game.achievementsUnlocked} / {game.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)}
+
+ )}
+
+
+ );
+ })}
+
+ >
+ );
+}
diff --git a/src/components/GameHeroCard.jsx b/src/components/GameHeroCard.jsx
new file mode 100644
index 0000000..24810be
--- /dev/null
+++ b/src/components/GameHeroCard.jsx
@@ -0,0 +1,134 @@
+import {useState} from 'react';
+import {useTranslation} from 'react-i18next';
+import {Clock, ExternalLink, Eye, EyeOff, Gamepad2, Image as ImageIcon, ImageOff, Merge, PanelTop, PencilLine, RotateCcw, Timer} 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 GameCover from './GameCover.jsx';
+import GameStatusBadge from './GameStatusBadge.jsx';
+import ProfileArtMenu from './ProfileArtMenu.jsx';
+import TimeToBeat from './TimeToBeat.jsx';
+import useSaveAppearance from '../hooks/useSaveAppearance.js';
+import {usePreferences} from '../context/PreferencesContext.jsx';
+import {formatPlaytime} from '../utils/games.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/';
+
+export default function GameHeroCard({game, manual, auditor, profileStyle, onCorrect, onMerge, onHiddenChanged, onProfileStyleSaved}) {
+ const {t} = useTranslation();
+ const {appearance} = usePreferences();
+ const saveAppearance = useSaveAppearance();
+ const [hiding, setHiding] = useState(false);
+
+ const onSteam = isSteamKey(game.appid);
+ // Core checks the link too; a link from the API must never run script.
+ const speedrunUrl = typeof game.speedrunUrl === 'string' && game.speedrunUrl.startsWith(SPEEDRUN_PREFIX)
+ ? game.speedrunUrl : null;
+ const isBackground = appearance.backgroundAppid === game.appid;
+ const isBanner = appearance.showBanner && appearance.bannerAppid === game.appid;
+
+ const toggleHidden = async () => {
+ const hidden = !game.hidden;
+ setHiding(true);
+ try {
+ const response = await setGameHidden(game.appid, hidden);
+ if (response.status === 401) return;
+ if (!response.ok) {
+ toast.error(t('GAME_DETAIL.HIDE_ERROR'));
+ return;
+ }
+ onHiddenChanged(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);
+ }
+ };
+
+ return (
+
+
+
+
+
+ {game.name ?? `App ${game.appid}`}
+
+
+
+
+ {t('GAME_DETAIL.PLAYTIME', {time: formatPlaytime(game.playtimeMinutes)})}
+
+
+ {platformLabel(game) && (
+ {platformLabel(game)}
+ )}
+
+
+
+
+ {speedrunUrl && (
+
+
+ {t('GAME_DETAIL.OPEN_IN_SPEEDRUN')}
+
+
+ )}
+ {auditor && (
+
+ {t('CORRECTION.OPEN')}
+
+ )}
+ {auditor && (
+
+ {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: game.appid})}>
+ {t('GAME_DETAIL.SET_BACKGROUND')}
+
+ )}
+ {isBanner ? (
+
saveAppearance({bannerAppid: null})}>
+ {t('SETTINGS.BANNER_AUTO')}
+
+ ) : (
+
saveAppearance({bannerAppid: game.appid, showBanner: true})}>
+ {t('GAME_DETAIL.SET_BANNER')}
+
+ )}
+ {profileStyle && (
+
+ )}
+ {!manual && (
+
+ {game.hidden ? : }
+ {t(game.hidden ? 'GAME_DETAIL.UNHIDE' : 'GAME_DETAIL.HIDE')}
+
+ )}
+ }
+
+
+ );
+}
diff --git a/src/hooks/useGame.js b/src/hooks/useGame.js
new file mode 100644
index 0000000..9c039fa
--- /dev/null
+++ b/src/hooks/useGame.js
@@ -0,0 +1,39 @@
+import {useCallback, useEffect, useState} from 'react';
+import {request} from '../utils/api.js';
+import useAbortPrevious from './useAbortPrevious.js';
+
+export default function useGame(appid) {
+ const [data, setData] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [reloads, setReloads] = useState(0);
+
+ // 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 reload = useCallback(() => setReloads((n) => n + 1), []);
+
+ return {data, setData, isLoading, error, reload};
+}
diff --git a/src/pages/GameDetail.jsx b/src/pages/GameDetail.jsx
index 57656b1..1e69857 100644
--- a/src/pages/GameDetail.jsx
+++ b/src/pages/GameDetail.jsx
@@ -1,81 +1,38 @@
-import {useEffect, useMemo, useRef, useState} from 'react';
+import {useEffect, 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 {ArrowLeft, EyeOff, 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 Confetti from '../components/Confetti.jsx';
-import GameCover from '../components/GameCover.jsx';
-import GameStatusBadge from '../components/GameStatusBadge.jsx';
+import GameAchievements from '../components/GameAchievements.jsx';
+import GameHeroCard from '../components/GameHeroCard.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 useGame from '../hooks/useGame.js';
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 {t} = 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 {data, setData, isLoading, error, reload} = useGame(appid);
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);
@@ -87,75 +44,7 @@ export default function GameDetail() {
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 (
@@ -184,83 +73,10 @@ export default function GameDetail() {
{t('GAME_DETAIL.HIDDEN_NOTE')}
)}
-
-
-
-
-
- {data.name ?? `App ${data.appid}`}
-
-
-
-
- {t('GAME_DETAIL.PLAYTIME', {time: formatPlaytime(data.playtimeMinutes)})}
-
-
- {platformLabel(data) && (
- {platformLabel(data)}
- )}
-
-
-
-
- {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')}
-
- )}
- {profileStyle && (
-
- )}
- {!manual && (
-
- {data.hidden ? : }
- {t(data.hidden ? 'GAME_DETAIL.UNHIDE' : 'GAME_DETAIL.HIDE')}
-
- )}
- }
-
-
+ setCorrecting(true)} onMerge={() => setMerging(true)}
+ onHiddenChanged={(hidden) => setData((d) => ({...d, hidden}))}
+ onProfileStyleSaved={setSavedProfileStyle}/>
{/* The finished question also answers "are you playing it?". */}
{!(data.finishedHint && ['BACKLOG', 'PLAYING'].includes(data.status)) && (
@@ -293,75 +109,19 @@ export default function GameDetail() {
)}
- {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)}
-
- )}
-
-
- );
- })}
-
- >
- )}
+ {data.hasAchievements && data.statsVisible && }
>
)}
{celebration && setCelebration(null)}/>}
{/* Outside the content: the reload after a save must not close it. */}
{data && auditor && (
setReloads((n) => n + 1)}/>
+ onChanged={reload}/>
)}
{auditor && data && (
{
// A new key loads through the address; the same key needs a reload.
- if (merged === data.appid) setReloads((n) => n + 1);
+ if (merged === data.appid) reload();
else navigate(`/library/${merged}`, {replace: true});
}}/>
)}