Public Access
feat(audit): game catalog search and time to beat correction
New page /audit/games: search every game and open the correction dialog without owning it. Time to beat as hours + minutes. Audit log game links open /audit/games?appid=. Menu entry next to the link queue.
This commit is contained in:
1 parent
6627a8db66
commit
0c300c455d
14 files changed
+439
-19
No files matched your search
@@ -0,0 +1,174 @@
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {useSearchParams} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {ChevronLeft, ChevronRight, Loader2, Search} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import GameCorrectionDialog from '../components/GameCorrectionDialog.jsx';
|
||||
import GameCover from '../components/GameCover.jsx';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import {fetchCatalogGame, searchCatalog} from '../utils/corrections.js';
|
||||
import {MAX_LIBRARY_SEARCH_LENGTH} from '../utils/limits.js';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
/**
|
||||
* AUDITOR or higher: finds any game some user has and opens the correction dialog for it. ?appid= opens a game
|
||||
* directly (the audit log links here). Core checks the role.
|
||||
*/
|
||||
export default function GameCatalogPage() {
|
||||
const {t} = useTranslation();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const openAppid = params.get('appid');
|
||||
const [query, setQuery] = useState('');
|
||||
const [term, setTerm] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const [result, setResult] = useState({content: [], totalPages: 0});
|
||||
const [status, setStatus] = useState('loading');
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [reloads, setReloads] = useState(0);
|
||||
// The answer for a game that is no longer the open one (Esc before it came) is dropped.
|
||||
const openRef = useRef(openAppid);
|
||||
useEffect(() => {
|
||||
openRef.current = openAppid;
|
||||
}, [openAppid]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (query.trim() !== term) {
|
||||
setTerm(query.trim());
|
||||
setPage(0);
|
||||
}
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [query, term]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
(async () => {
|
||||
try {
|
||||
const response = await searchCatalog({q: term, page, size: PAGE_SIZE}, controller.signal);
|
||||
if (response.status === 401) return;
|
||||
if (response.status === 403) {
|
||||
setStatus('forbidden');
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
setStatus('error');
|
||||
return;
|
||||
}
|
||||
setResult(await response.json());
|
||||
setStatus('ready');
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') return;
|
||||
console.error('Catalog error:', err);
|
||||
setStatus('error');
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, [term, page, reloads]);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setDetail(null);
|
||||
setParams({}, {replace: true});
|
||||
}, [setParams]);
|
||||
|
||||
const loadDetail = useCallback(async (appid) => {
|
||||
try {
|
||||
const response = await fetchCatalogGame(appid);
|
||||
if (response.status === 401 || String(appid) !== openRef.current) return;
|
||||
if (response.ok) {
|
||||
setDetail(await response.json());
|
||||
return;
|
||||
}
|
||||
// 404: the game was merged meanwhile.
|
||||
toast.error(t(response.status === 404 ? 'CATALOG.GONE' : 'CATALOG.LOAD_ERROR'));
|
||||
close();
|
||||
setReloads((n) => n + 1);
|
||||
} catch (err) {
|
||||
console.error('Catalog game error:', err);
|
||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||
close();
|
||||
}
|
||||
}, [close, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (openAppid) loadDetail(openAppid);
|
||||
else setDetail(null);
|
||||
}, [openAppid, loadDetail]);
|
||||
|
||||
const changed = () => {
|
||||
loadDetail(detail.appid);
|
||||
setReloads((n) => n + 1);
|
||||
};
|
||||
|
||||
if (status === 'forbidden') {
|
||||
return (
|
||||
<PageContainer className="px-4 py-6 md:px-8">
|
||||
<p className="text-sm text-muted-foreground">{t('CATALOG.NO_ACCESS')}</p>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer className="space-y-4 px-4 py-6 md:px-8">
|
||||
<Card>
|
||||
<CardHeader className="space-y-3">
|
||||
<CardTitle>{t('CATALOG.TITLE')}</CardTitle>
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"/>
|
||||
<Input type="search" value={query} maxLength={MAX_LIBRARY_SEARCH_LENGTH} className="pl-8"
|
||||
placeholder={t('CATALOG.SEARCH')} aria-label={t('CATALOG.SEARCH')}
|
||||
onChange={(e) => setQuery(e.target.value)}/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{status === 'loading' && <Loader2 className="size-5 animate-spin text-muted-foreground"/>}
|
||||
{status === 'error' && <p className="text-sm text-destructive">{t('CATALOG.LOAD_ERROR')}</p>}
|
||||
{status === 'ready' && result.content.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{t('CATALOG.EMPTY')}</p>
|
||||
)}
|
||||
{status === 'ready' && result.content.length > 0 && (
|
||||
<ul className="divide-y">
|
||||
{result.content.map((game) => (
|
||||
<li key={game.appid} className="flex items-center gap-3 py-2">
|
||||
<GameCover appid={game.appid} imageUrl={game.imageUrl} className="w-20 shrink-0"/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{game.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{game.platforms.join(', ')} · {t('CATALOG.KEY', {appid: game.appid})}
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" size="sm" variant="outline"
|
||||
onClick={() => setParams({appid: String(game.appid)})}>
|
||||
{t('CATALOG.CORRECT')}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{status === 'ready' && result.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
<Button type="button" variant="outline" size="sm" disabled={page === 0}
|
||||
onClick={() => setPage(page - 1)}>
|
||||
<ChevronLeft/>{t('ADMIN.PREVIOUS')}
|
||||
</Button>
|
||||
<span>{t('AUDIT.PAGE_OF', {current: page + 1, total: result.totalPages})}</span>
|
||||
<Button type="button" variant="outline" size="sm" disabled={page + 1 >= result.totalPages}
|
||||
onClick={() => setPage(page + 1)}>
|
||||
{t('ADMIN.NEXT')}<ChevronRight/>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{detail && (
|
||||
<GameCorrectionDialog game={detail} open onOpenChange={(open) => !open && close()} onChanged={changed}/>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user