Public Access
refactor(game-detail): load in useGame, hero and achievements cards
This commit is contained in:
1 parent
628920a006
commit
05f4ca42e5
4 files changed
+294
-254
No files matched your search
@@ -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 <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 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 (
|
||||
<>
|
||||
<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">
|
||||
{game.achievementsUnlocked} / {game.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, game.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={game.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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Card className="relative overflow-hidden p-0 gap-0 md:flex-row">
|
||||
<GameCover appid={game.appid} imageUrl={game.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">
|
||||
{game.name ?? `App ${game.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(game.playtimeMinutes)})}
|
||||
</Badge>
|
||||
<GameStatusBadge status={game.status}/>
|
||||
{platformLabel(game) && (
|
||||
<Badge variant="outline"><Gamepad2 className="size-3"/>{platformLabel(game)}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<TimeToBeat time={game.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={onCorrect}>
|
||||
<PencilLine/>{t('CORRECTION.OPEN')}
|
||||
</Button>
|
||||
)}
|
||||
{auditor && (
|
||||
<Button variant="outline" size="sm" className="mt-3 ml-2" onClick={onMerge}>
|
||||
<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/${game.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: game.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: game.appid, showBanner: true})}>
|
||||
<PanelTop/>{t('GAME_DETAIL.SET_BANNER')}
|
||||
</Button>
|
||||
)}
|
||||
{profileStyle && (
|
||||
<ProfileArtMenu appid={game.appid} style={profileStyle} onSaved={onProfileStyleSaved}/>
|
||||
)}
|
||||
{!manual && (
|
||||
<Button variant="outline" size="sm" onClick={toggleHidden} disabled={hiding}>
|
||||
{game.hidden ? <Eye/> : <EyeOff/>}
|
||||
{t(game.hidden ? 'GAME_DETAIL.UNHIDE' : 'GAME_DETAIL.HIDE')}
|
||||
</Button>
|
||||
)}
|
||||
</div>}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user