Public Access
371 lines
20 KiB
React
371 lines
20 KiB
React
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 <div className="size-12 rounded-md bg-muted"/>;
|
|
return (
|
|
<img src={src} alt="" className={cn('size-12 rounded-md', !unlocked && 'grayscale')}
|
|
onError={() => {
|
|
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 (
|
|
<PageContainer className="p-4 md:p-8 space-y-6">
|
|
<Button variant="ghost" size="sm" className="-ml-2" onClick={() => navigate(-1)}>
|
|
<ArrowLeft/>{t('GAME_DETAIL.BACK')}
|
|
</Button>
|
|
|
|
{isLoading && !data && (
|
|
<div className="space-y-6">
|
|
<Skeleton className="h-52 rounded-xl"/>
|
|
<Skeleton className="h-40 rounded-xl"/>
|
|
</div>
|
|
)}
|
|
|
|
{error && !isLoading && (
|
|
<div className="py-12 text-center text-destructive space-y-4">
|
|
<p>{t(error)}</p>
|
|
<Button asChild variant="outline"><Link to="/library">{t('GAME_DETAIL.BACK_TO_LIBRARY')}</Link></Button>
|
|
</div>
|
|
)}
|
|
|
|
{!error && data && (
|
|
<>
|
|
{data.hidden && (
|
|
<div role="status" className="flex items-center gap-2 rounded-lg border bg-muted/50 px-4 py-3 text-sm text-muted-foreground">
|
|
<EyeOff className="size-4 shrink-0"/>{t('GAME_DETAIL.HIDDEN_NOTE')}
|
|
</div>
|
|
)}
|
|
<Card className="relative overflow-hidden p-0 gap-0 md:flex-row">
|
|
<GameCover appid={data.appid} imageUrl={data.imageUrl} className="w-full rounded-none md:w-96 md:shrink-0"/>
|
|
<div className="flex flex-1 flex-col justify-between gap-4 p-6">
|
|
<div>
|
|
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">
|
|
{data.name ?? `App ${data.appid}`}
|
|
</h1>
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
<Badge variant="secondary" className="tabular-nums">
|
|
<Clock className="size-3"/>
|
|
{t('GAME_DETAIL.PLAYTIME', {time: formatPlaytime(data.playtimeMinutes)})}
|
|
</Badge>
|
|
<GameStatusBadge status={data.status}/>
|
|
{platformLabel(data) && (
|
|
<Badge variant="outline"><Gamepad2 className="size-3"/>{platformLabel(data)}</Badge>
|
|
)}
|
|
</div>
|
|
<div className="mt-3">
|
|
<TimeToBeat time={data.timeToBeat}/>
|
|
</div>
|
|
{speedrunUrl && (
|
|
<Button asChild variant="outline" size="sm" className="mt-3">
|
|
<a href={speedrunUrl} target="_blank" rel="noopener noreferrer">
|
|
<Timer/>{t('GAME_DETAIL.OPEN_IN_SPEEDRUN')}<ExternalLink/>
|
|
</a>
|
|
</Button>
|
|
)}
|
|
{auditor && (
|
|
<Button variant="outline" size="sm" className="mt-3 ml-2" onClick={() => setCorrecting(true)}>
|
|
<PencilLine/>{t('CORRECTION.OPEN')}
|
|
</Button>
|
|
)}
|
|
{auditor && (
|
|
<Button variant="outline" size="sm" className="mt-3 ml-2" onClick={() => setMerging(true)}>
|
|
<Merge/>{t('MERGE.OPEN')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
{/* 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 && <div className="flex flex-wrap gap-2">
|
|
<Button asChild variant="outline" size="sm">
|
|
<a href={`https://store.steampowered.com/app/${data.appid}`} target="_blank" rel="noopener noreferrer">
|
|
{t('GAME_DETAIL.OPEN_IN_STEAM')}<ExternalLink/>
|
|
</a>
|
|
</Button>
|
|
{isBackground ? (
|
|
<Button variant="outline" size="sm" onClick={() => saveAppearance({backgroundAppid: null})}>
|
|
<ImageOff/>{t('GAME_DETAIL.REMOVE_BACKGROUND')}
|
|
</Button>
|
|
) : (
|
|
<Button variant="outline" size="sm" onClick={() => saveAppearance({backgroundAppid: data.appid})}>
|
|
<ImageIcon/>{t('GAME_DETAIL.SET_BACKGROUND')}
|
|
</Button>
|
|
)}
|
|
{isBanner ? (
|
|
<Button variant="outline" size="sm" onClick={() => saveAppearance({bannerAppid: null})}>
|
|
<RotateCcw/>{t('SETTINGS.BANNER_AUTO')}
|
|
</Button>
|
|
) : (
|
|
<Button variant="outline" size="sm"
|
|
onClick={() => saveAppearance({bannerAppid: data.appid, showBanner: true})}>
|
|
<PanelTop/>{t('GAME_DETAIL.SET_BANNER')}
|
|
</Button>
|
|
)}
|
|
{profileStyle && (
|
|
<ProfileArtMenu appid={data.appid} style={profileStyle} onSaved={setSavedProfileStyle}/>
|
|
)}
|
|
{!manual && (
|
|
<Button variant="outline" size="sm" onClick={toggleHidden} disabled={hiding}>
|
|
{data.hidden ? <Eye/> : <EyeOff/>}
|
|
{t(data.hidden ? 'GAME_DETAIL.UNHIDE' : 'GAME_DETAIL.HIDE')}
|
|
</Button>
|
|
)}
|
|
</div>}
|
|
</div>
|
|
</Card>
|
|
|
|
{/* The finished question also answers "are you playing it?". */}
|
|
{!(data.finishedHint && ['BACKLOG', 'PLAYING'].includes(data.status)) && (
|
|
<PlayingHint game={data} onChanged={(entry) => setData((d) => ({...d, ...entry}))}/>
|
|
)}
|
|
<FinishedHint game={data}
|
|
onChanged={(entry) => setData((d) => ({...d, ...entry, finishedHint: null}))}
|
|
onDismissed={() => setData((d) => ({...d, finishedHint: null}))}/>
|
|
<CompletedHint game={data} onChanged={(entry) => setData((d) => ({...d, ...entry}))}/>
|
|
|
|
<BacklogCard key={data.appid} game={data}
|
|
onSaved={(entry) => setData((d) => ({...d, ...entry}))}/>
|
|
|
|
{manual && (
|
|
<ManualGameCard key={`manual-${data.appid}`} game={data}
|
|
onSaved={(changes) => setData((d) => ({...d, ...changes}))}/>
|
|
)}
|
|
|
|
{!data.hasAchievements && (
|
|
<Card className="items-center py-10 text-center text-sm text-muted-foreground">
|
|
<Trophy className="size-8"/>
|
|
{t(manual ? 'MANUAL_GAME.NO_ACHIEVEMENTS' : 'GAME_DETAIL.NO_ACHIEVEMENTS')}
|
|
</Card>
|
|
)}
|
|
|
|
{data.hasAchievements && !data.statsVisible && (
|
|
<Card className="items-center py-10 text-center text-sm text-muted-foreground">
|
|
<ShieldOff className="size-8"/>
|
|
{t('GAME_DETAIL.STATS_HIDDEN')}
|
|
</Card>
|
|
)}
|
|
|
|
{data.hasAchievements && data.statsVisible && (
|
|
<>
|
|
<Card className="p-6 gap-5">
|
|
<div className="flex items-baseline justify-between">
|
|
<h2 className="flex items-center gap-2 text-lg font-semibold">
|
|
<Trophy className="size-4 text-amber-500"/>
|
|
{t('GAME_DETAIL.ACHIEVEMENTS')}
|
|
</h2>
|
|
<span className="text-sm text-muted-foreground tabular-nums">
|
|
{data.achievementsUnlocked} / {data.achievementsTotal}
|
|
</span>
|
|
</div>
|
|
<div className="h-2.5 w-full overflow-hidden rounded-full bg-muted">
|
|
<div
|
|
className="h-full rounded-full bg-primary transition-all"
|
|
style={{width: `${Math.min(100, data.completionPercent)}%`}}
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-3 gap-3 text-center">
|
|
{stats.map(([label, value]) => (
|
|
<div key={label} className="rounded-lg bg-muted/50 p-3">
|
|
<div className="text-2xl font-bold tabular-nums">{value}</div>
|
|
<div className="text-xs text-muted-foreground">{label}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
|
|
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
{sortedAchievements.map((a) => {
|
|
const icon = a.unlocked ? a.iconUrl : a.iconGrayUrl;
|
|
return (
|
|
<li
|
|
key={a.apiName}
|
|
className={cn(
|
|
'flex items-start gap-3 rounded-xl border bg-card p-3',
|
|
a.unlocked ? 'border-primary/40' : 'opacity-60',
|
|
)}
|
|
>
|
|
<AchievementIcon key={icon} appid={data.appid} src={icon} unlocked={a.unlocked}/>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="truncate font-medium">{a.displayName ?? a.apiName}</div>
|
|
{a.description && (
|
|
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">{a.description}</div>
|
|
)}
|
|
{a.unlocked && a.unlockedAt && (
|
|
<div className="mt-1 text-xs text-primary tabular-nums">
|
|
{formatUnlockedAt(a.unlockedAt, i18n.language)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
{celebration && <Confetti key={celebration.id} size={celebration.size} onDone={() => setCelebration(null)}/>}
|
|
{/* Outside the content: the reload after a save must not close it. */}
|
|
{data && auditor && (
|
|
<GameCorrectionDialog game={data} open={correcting} onOpenChange={setCorrecting}
|
|
onChanged={() => setReloads((n) => n + 1)}/>
|
|
)}
|
|
{auditor && data && (
|
|
<GameMergeDialog game={data} open={merging} onOpenChange={setMerging} onMerged={(merged) => {
|
|
// 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});
|
|
}}/>
|
|
)}
|
|
</PageContainer>
|
|
);
|
|
}
|