Public Access
feat(ui): make stat numbers clickable to open games
StatDrill context holds one dialog for a page. StatCount wraps a number: total and status counts link to the library filter, other numbers open StatGamesDialog. DrillRow and DrillButton support drill-down in tag bars (genres, platforms, moods) and other stat displays. Dialog shows 40 games per page with bottom-sheet layout on phones, handles pagination and errors (403 not shared, 401 expired, 400 bad argument).
This commit is contained in:
1 parent
ed5df8c40d
commit
777ba87fd6
31 files changed
+1026
-261
No files matched your search
@@ -0,0 +1,111 @@
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Dialog, DialogContent, DialogDescription, DialogTitle} from '@/components/ui/dialog';
|
||||
import {Skeleton} from '@/components/ui/skeleton';
|
||||
import GameRow from './GameRow.jsx';
|
||||
import GameStatusBadge from '../GameStatusBadge.jsx';
|
||||
import {formatDate} from '../../utils/format.js';
|
||||
import {formatPlaytime} from '../../utils/games.js';
|
||||
import {fetchStatGames} from '../../utils/statGames.js';
|
||||
|
||||
const ERRORS = {403: 'STATS.DRILL.NOT_SHARED', 401: 'STATS.DRILL.EXPIRED'};
|
||||
|
||||
/**
|
||||
* The games behind one number, 40 per page.
|
||||
*
|
||||
* @param request `{title, source, ticket, params}` or null (closed)
|
||||
*/
|
||||
export default function StatGamesDialog({request, onClose, onCloseAutoFocus}) {
|
||||
const {t, i18n} = useTranslation();
|
||||
const [state, setState] = useState({games: [], page: 0, totalPages: 0, total: 0, untracked: 0});
|
||||
const [status, setStatus] = useState('loading');
|
||||
const openRequest = useRef(null);
|
||||
|
||||
const load = useCallback(async (page, current) => {
|
||||
setStatus('loading');
|
||||
const {ok, status: code, data} = await fetchStatGames(request.source, {...request.params, page}, request.ticket);
|
||||
if (openRequest.current !== request) return;
|
||||
if (!ok) {
|
||||
setStatus(code);
|
||||
return;
|
||||
}
|
||||
setState({
|
||||
games: [...current, ...data.content], page, totalPages: data.totalPages, total: data.totalElements,
|
||||
untracked: data.untracked ?? 0,
|
||||
});
|
||||
setStatus('ok');
|
||||
}, [request]);
|
||||
|
||||
useEffect(() => {
|
||||
openRequest.current = request;
|
||||
if (!request) return;
|
||||
setState({games: [], page: 0, totalPages: 0, total: 0, untracked: 0});
|
||||
load(0, []);
|
||||
}, [request, load]);
|
||||
|
||||
const value = (game) => {
|
||||
if (request.source === 'community') {
|
||||
return `${t('STATS.DRILL.IN_LIBRARIES', {count: game.libraries})} · ${formatPlaytime(game.minutes)}`;
|
||||
}
|
||||
const moved = game.fromStatus || game.toStatus;
|
||||
return (
|
||||
<>
|
||||
{!moved && game.status && <GameStatusBadge status={game.status} compact/>}
|
||||
{moved && (
|
||||
<span>{game.fromStatus ? t(`BACKLOG.STATUS.${game.fromStatus}`) : t('STATS.DRILL.NEW_IN_LIBRARY')}
|
||||
{' → '}{game.toStatus ? t(`BACKLOG.STATUS.${game.toStatus}`) : '–'}</span>
|
||||
)}
|
||||
<span>{game.at
|
||||
? formatDate(game.at, i18n.language, {dateStyle: 'medium'})
|
||||
: formatPlaytime(game.minutes ?? game.playtimeMinutes ?? 0)}</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const failed = typeof status === 'number';
|
||||
return (
|
||||
<Dialog open={request != null} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent onCloseAutoFocus={onCloseAutoFocus}
|
||||
className="max-h-[85vh] overflow-y-auto overflow-x-hidden max-sm:top-auto max-sm:bottom-0 max-sm:max-w-full max-sm:translate-y-0 max-sm:rounded-b-none sm:max-w-xl">
|
||||
<DialogTitle className="pr-6">{request?.title}</DialogTitle>
|
||||
<DialogDescription>{status === 'ok' ? t('STATS.DRILL.GAMES', {count: state.total}) : ' '}</DialogDescription>
|
||||
{request && (
|
||||
<ul className="space-y-2">
|
||||
{state.games.map((game, index) => (
|
||||
<li key={`${game.appid}-${index}`}>
|
||||
<GameRow game={game} value={value(game)} linked={request.source === 'own'}/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{status === 'loading' && <Skeleton className="h-24 rounded-xl"/>}
|
||||
{status === 'ok' && state.games.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{t('STATS.DRILL.EMPTY')}</p>
|
||||
)}
|
||||
{status === 'ok' && state.untracked > 0 && (
|
||||
<p className="text-xs text-muted-foreground">{t('STATS.FINISHED.BEFORE', {count: state.untracked})}</p>
|
||||
)}
|
||||
{failed && (
|
||||
<div className="flex flex-col items-start gap-2 text-sm text-muted-foreground">
|
||||
<p>{t(ERRORS[status] ?? 'STATS.DRILL.ERROR')}</p>
|
||||
{status === 401
|
||||
? <Button variant="outline" size="sm" className="min-h-11" onClick={() => globalThis.location.reload()}>
|
||||
{t('STATS.DRILL.RELOAD')}</Button>
|
||||
: status !== 403 && (
|
||||
<Button variant="outline" size="sm" className="min-h-11"
|
||||
onClick={() => load(state.page + (state.games.length > 0 ? 1 : 0), state.games)}>
|
||||
{t('DASHBOARD.RETRY')}</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{status === 'ok' && state.page + 1 < state.totalPages && (
|
||||
<Button variant="outline" className="min-h-11" onClick={() => load(state.page + 1, state.games)}>
|
||||
{t('STATS.DRILL.LOAD_MORE')}
|
||||
</Button>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user