feat(ui): make stat numbers clickable to open games
Build, Test & Push Frontend / quality-check (push) Successful in 28s
Build, Test & Push Frontend / docker (push) Successful in 26s

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:
lobo committed 2026-10-03 18:51:59 +02:00
1 parent ed5df8c40d
commit 777ba87fd6
31 files changed
+1026 -261

No files matched your search

+111
View File
@@ -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>
);
}