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 (

{t('CATALOG.NO_ACCESS')}

); } return ( {t('CATALOG.TITLE')}
setQuery(e.target.value)}/>
{status === 'loading' && } {status === 'error' &&

{t('CATALOG.LOAD_ERROR')}

} {status === 'ready' && result.content.length === 0 && (

{t('CATALOG.EMPTY')}

)} {status === 'ready' && result.content.length > 0 && (
    {result.content.map((game) => (
  • {game.platforms.join(', ')} ยท {t('CATALOG.KEY', {appid: game.appid})}

  • ))}
)} {status === 'ready' && result.totalPages > 1 && (
{t('AUDIT.PAGE_OF', {current: page + 1, total: result.totalPages})}
)}
{detail && ( !open && close()} onChanged={changed}/> )}
); }