Public Access
feat(library): set status and rating in one step (#56)
Library rows: the status badge is a menu that saves at once. Game page: status and rating in the top card, saved on click. Status changes and hiding a game show an Undo toast. Other game actions moved into a "..." menu; split and unlink ask first. New sorts: name, last played, time to beat; "last synced" is gone. Page numbers and 44 px touch targets on phones.
This commit is contained in:
1 parent
f9f0d94e2c
commit
45f5087227
32 files changed
+1091
-352
No files matched your search
@@ -1,20 +1,31 @@
|
||||
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 {Clock, Ellipsis, 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 {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {cn} from '@/lib/utils';
|
||||
import GameCover from './GameCover.jsx';
|
||||
import FitText from './FitText.jsx';
|
||||
import ComingBadge from './ComingBadge.jsx';
|
||||
import GameStatusBadge from './GameStatusBadge.jsx';
|
||||
import ProfileArtMenu from './ProfileArtMenu.jsx';
|
||||
import ProfileArtItems from './ProfileArtItems.jsx';
|
||||
import RatingStars from './RatingStars.jsx';
|
||||
import StatusMenu from './StatusMenu.jsx';
|
||||
import TimeToBeat from './TimeToBeat.jsx';
|
||||
import useSaveAppearance from '../hooks/useSaveAppearance.js';
|
||||
import useSaveBacklogField from '../hooks/useSaveBacklogField.js';
|
||||
import useSetStatus from '../hooks/useSetStatus.js';
|
||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||
import {UNDO_MS} from '../utils/backlog.js';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
import {setGameHidden} from '../utils/hiddenGames.js';
|
||||
import {isSteamKey} from '../utils/manualGames.js';
|
||||
@@ -25,11 +36,19 @@ const SPEEDRUN_PREFIX = 'https://www.speedrun.com/';
|
||||
// The spans of each completed effect decoration; effects.css places them.
|
||||
const DETAIL_ELEMENTS = {'fx-detail-gold-shine': 8, 'fx-detail-neon-pulse': 1, 'fx-detail-holo': 1, 'fx-detail-ember': 6, 'fx-detail-platinum': 6};
|
||||
|
||||
export default function GameHeroCard({game, auditor, profileStyle, onCorrect, onMerge, onHiddenChanged, onProfileStyleSaved}) {
|
||||
/**
|
||||
* Status and rating are edited here and save at once. A status change needs no points check: GameDetail's
|
||||
* GamePointsLine sees the new status and does it.
|
||||
*
|
||||
* @param onSaved gets the saved backlog entry after a status or rating change
|
||||
*/
|
||||
export default function GameHeroCard({game, auditor, profileStyle, onCorrect, onMerge, onHiddenChanged, onProfileStyleSaved, onSaved}) {
|
||||
const {t} = useTranslation();
|
||||
const {appearance, equipped} = usePreferences();
|
||||
const saveAppearance = useSaveAppearance();
|
||||
const [hiding, setHiding] = useState(false);
|
||||
const {setStatus, busy: statusBusy} = useSetStatus(game.appid, {onChanged: onSaved});
|
||||
const {save, pending, busy: ratingBusy} = useSaveBacklogField(game.appid, {onSaved});
|
||||
|
||||
const onSteam = isSteamKey(game.appid);
|
||||
// Core checks the link too; a link from the API must never run script.
|
||||
@@ -39,8 +58,7 @@ export default function GameHeroCard({game, auditor, profileStyle, onCorrect, on
|
||||
const isBanner = appearance.showBanner && appearance.bannerAppid === game.appid;
|
||||
const detailFx = detailEffect(equipped, game.status);
|
||||
|
||||
const toggleHidden = async () => {
|
||||
const hidden = !game.hidden;
|
||||
const applyHidden = async (hidden, {undoable = false} = {}) => {
|
||||
setHiding(true);
|
||||
try {
|
||||
const response = await setGameHidden(game.appid, hidden);
|
||||
@@ -50,7 +68,12 @@ export default function GameHeroCard({game, auditor, profileStyle, onCorrect, on
|
||||
return;
|
||||
}
|
||||
onHiddenChanged(hidden);
|
||||
toast.success(t(hidden ? 'GAME_DETAIL.HIDDEN_TOAST' : 'GAME_DETAIL.SHOWN_TOAST'));
|
||||
const message = t(hidden ? 'GAME_DETAIL.HIDDEN_TOAST' : 'GAME_DETAIL.SHOWN_TOAST');
|
||||
if (undoable) {
|
||||
toast.success(message, {duration: UNDO_MS, action: {label: t('MANUAL_GAME.UNDO'), onClick: () => applyHidden(!hidden)}});
|
||||
} else {
|
||||
toast.success(message);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Hide game error:', err);
|
||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||
@@ -79,11 +102,17 @@ export default function GameHeroCard({game, auditor, profileStyle, onCorrect, on
|
||||
{t('GAME_DETAIL.PLAYTIME', {time: formatPlaytime(game.totalPlaytimeMinutes ?? game.playtimeMinutes)})}
|
||||
</Badge>
|
||||
<ComingBadge game={game}/>
|
||||
<GameStatusBadge status={game.status}/>
|
||||
<StatusMenu status={game.status} busy={statusBusy}
|
||||
onSelect={(status) => setStatus(status, {previous: game.status})}/>
|
||||
{platformLabel(game) && (
|
||||
<Badge variant="outline"><Gamepad2 className="size-3"/>{platformLabel(game)}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<span id="hero-rating" className="sr-only">{t('BACKLOG.RATING_LABEL')}</span>
|
||||
<RatingStars value={pending?.field === 'rating' ? pending.value : (game.rating ?? 0)}
|
||||
onChange={(value) => save('rating', value)} labelledBy="hero-rating" disabled={ratingBusy}/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<TimeToBeat time={game.timeToBeat}/>
|
||||
</div>
|
||||
@@ -94,52 +123,56 @@ export default function GameHeroCard({game, auditor, profileStyle, onCorrect, on
|
||||
</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. */}
|
||||
{/* Background, banner and profile art need Steam art, so a game off Steam has none of them. */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{onSteam && (<>
|
||||
{onSteam && (
|
||||
<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}/>
|
||||
)}
|
||||
</>)}
|
||||
<Button variant="outline" size="sm" onClick={toggleHidden} disabled={hiding}>
|
||||
{game.hidden ? <Eye/> : <EyeOff/>}
|
||||
{t(game.hidden ? 'GAME_DETAIL.UNHIDE' : 'GAME_DETAIL.HIDE')}
|
||||
</Button>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon-sm" aria-label={t('GAME_DETAIL.MORE')}><Ellipsis/></Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{onSteam && (<>
|
||||
{isBackground ? (
|
||||
<DropdownMenuItem onSelect={() => saveAppearance({backgroundAppid: null})}>
|
||||
<ImageOff/>{t('GAME_DETAIL.REMOVE_BACKGROUND')}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onSelect={() => saveAppearance({backgroundAppid: game.appid})}>
|
||||
<ImageIcon/>{t('GAME_DETAIL.SET_BACKGROUND')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isBanner ? (
|
||||
<DropdownMenuItem onSelect={() => saveAppearance({bannerAppid: null})}>
|
||||
<RotateCcw/>{t('SETTINGS.BANNER_AUTO')}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onSelect={() => saveAppearance({bannerAppid: game.appid, showBanner: true})}>
|
||||
<PanelTop/>{t('GAME_DETAIL.SET_BANNER')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{profileStyle && (
|
||||
<ProfileArtItems appid={game.appid} style={profileStyle} onSaved={onProfileStyleSaved}/>
|
||||
)}
|
||||
<DropdownMenuSeparator/>
|
||||
</>)}
|
||||
<DropdownMenuItem disabled={hiding} onSelect={() => applyHidden(!game.hidden, {undoable: true})}>
|
||||
{game.hidden ? <Eye/> : <EyeOff/>}
|
||||
{t(game.hidden ? 'GAME_DETAIL.UNHIDE' : 'GAME_DETAIL.HIDE')}
|
||||
</DropdownMenuItem>
|
||||
{auditor && (<>
|
||||
<DropdownMenuSeparator/>
|
||||
<DropdownMenuItem onSelect={onCorrect}><PencilLine/>{t('CORRECTION.OPEN')}</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={onMerge}><Merge/>{t('MERGE.OPEN')}</DropdownMenuItem>
|
||||
</>)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
Reference in new issue
Block a user