Public Access
Display and game names shrink to 0.875rem, then end with an ellipsis (FitText + fit-text utility, CSS container units, full text in title). Rows and buttons wrap at large fonts, header points chip can shrink. Guard: scripts/check-overflow.js.
176 lines
7.7 KiB
React
176 lines
7.7 KiB
React
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 FitText from '../components/FitText.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="@container min-w-0 flex-1">
|
|
<p className="text-sm font-medium"><FitText text={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>
|
|
);
|
|
}
|