Files
ToBePlayed-Frontend/src/pages/GameCatalogPage.jsx
T
lobo d7810fbf94
Build, Test & Push Frontend / quality-check (push) Successful in 31s
Build, Test & Push Frontend / docker (push) Successful in 24s
fix(layout): fit names on phones without sideways scroll
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.
2026-10-03 12:18:31 +02:00

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>
);
}