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]) => ( +
+
{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)} +
    + )} +
    +
  • + ); + })} +
+ + ); +} 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 && ( + + )} + {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 && ( + + )} +
} +
+
+ ); +} 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 && ( - - )} - {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 && ( - - )} -
} -
-
+ 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]) => ( -
-
{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)} -
    - )} -
    -
  • - ); - })} -
- - )} + {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}); }}/> )}