import {useCallback, useEffect, useState} from 'react'; import {Link, useNavigate, useSearchParams} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; import useLibrarySync from '../hooks/useLibrarySync.js'; import SyncStatusBadge from '../components/SyncStatusBadge.jsx'; import useForceSync from '../hooks/useForceSync.js'; import useSessionExpired from '../hooks/useSessionExpired.js'; import {formatPlaytime} from '../utils/games.js'; import {GAME_STATUSES, libraryQuery, parseStatus} from '../utils/backlog.js'; import {ArrowUpDown, ChevronLeft, ChevronRight, Clock, Library, RefreshCw, Search, SearchX, Timer, Trophy} from 'lucide-react'; import {Badge} from '@/components/ui/badge'; import {Button} from '@/components/ui/button'; import {Card} from '@/components/ui/card'; import {Input} from '@/components/ui/input'; import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select'; import {Skeleton} from '@/components/ui/skeleton'; import GameCover from '../components/GameCover.jsx'; import LibraryTabs from '../components/LibraryTabs.jsx'; import GameStatusBadge from '../components/GameStatusBadge.jsx'; import PageContainer from '../components/PageContainer.jsx'; import {apiFetch} from '../utils/api.js'; import {hasSession} from '../utils/auth.js'; const PAGE_SIZES = [20, 40, 80]; const DEFAULT_SIZE = 20; const DEFAULT_SORT = 'playtimeMinutes,desc'; const SEARCH_DEBOUNCE_MS = 300; // "Short" games: IGDB's normal time to beat of at most 10 hours. const SHORT_GAME_MINUTES = 600; const SORT_OPTIONS = [ {value: 'playtimeMinutes,desc', labelKey: 'LIBRARY.SORT_PLAYTIME_DESC'}, {value: 'playtimeMinutes,asc', labelKey: 'LIBRARY.SORT_PLAYTIME_ASC'}, {value: 'lastSeenAt,desc', labelKey: 'LIBRARY.SORT_LAST_SEEN'}, {value: 'firstSeenAt,desc', labelKey: 'LIBRARY.SORT_FIRST_SEEN'}, {value: 'rating,desc', labelKey: 'LIBRARY.SORT_RATING'}, {value: 'priority,desc', labelKey: 'LIBRARY.SORT_PRIORITY'}, ]; export default function GameLibrary() { const {t} = useTranslation(); const navigate = useNavigate(); const expireSession = useSessionExpired(); const [searchParams, setSearchParams] = useSearchParams(); const page = Math.max(0, parseInt(searchParams.get('page') ?? '0', 10) || 0); const sizeParam = parseInt(searchParams.get('size') ?? String(DEFAULT_SIZE), 10); const size = PAGE_SIZES.includes(sizeParam) ? sizeParam : DEFAULT_SIZE; const sortParam = searchParams.get('sort') ?? DEFAULT_SORT; const sort = SORT_OPTIONS.some((o) => o.value === sortParam) ? sortParam : DEFAULT_SORT; const status = parseStatus(searchParams.get('status')); const q = searchParams.get('q') ?? ''; const shortOnly = searchParams.get('maxMinutes') === String(SHORT_GAME_MINUTES); const filtered = Boolean(status || q.trim() || shortOnly); const librarySearch = searchParams.toString(); const [search, setSearch] = useState(q); const [data, setData] = useState({content: [], page: 0, size, totalElements: 0, totalPages: 0}); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const load = useCallback(async () => { if (!hasSession()) { navigate('/login'); return; } setIsLoading(true); setError(null); try { const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games?${libraryQuery({page, size, sort, status, q, maxMinutes: shortOnly ? SHORT_GAME_MINUTES : null})}`; const response = await apiFetch(url, { headers: { 'Content-Type': 'application/json', }, }); if (response.status === 401) { expireSession(); return; } if (!response.ok) { setError(t('LIBRARY.ERROR_LOAD')); return; } const json = await response.json(); setData(json); } catch (err) { console.error('Library load error:', err); setError(t('INFO.ERROR.NETWORK_FAILURE')); } finally { setIsLoading(false); } }, [navigate, expireSession, page, size, sort, status, q, shortOnly, t]); useEffect(() => { load(); }, [load]); const expectInitialSync = !isLoading && !error && !filtered && data.totalElements === 0; const sync = useLibrarySync({ enabled: true, expectInitialSync, onComplete: load, onUnauthorized: expireSession, }); const updateParams = (next) => { const params = new URLSearchParams(searchParams); Object.entries(next).forEach(([k, v]) => (v === null || v === '' ? params.delete(k) : params.set(k, String(v)))); setSearchParams(params); }; // Back/forward navigation changes q without typing. Keeps a trailing space the user is still typing. useEffect(() => { setSearch((current) => (current.trim() === q ? current : q)); }, [q]); // searchParams is a dependency, so a status click during the delay restarts the timer with fresh params. useEffect(() => { if (search.trim() === q) return undefined; const timer = setTimeout(() => updateParams({q: search.trim(), page: 0}), SEARCH_DEBOUNCE_MS); return () => clearTimeout(timer); }, [search, q, searchParams]); const onStatusChange = (value) => { updateParams({status: value, page: 0}); }; const onSizeChange = (value) => { updateParams({size: value, page: 0}); }; const onSortChange = (value) => { updateParams({sort: value, page: 0}); }; const goToPage = (target) => { if (target < 0 || target >= data.totalPages) return; updateParams({page: target}); }; const {trigger: triggerSync, busy: syncBusy} = useForceSync(load); const syncing = syncBusy || sync.isRunning; const ready = !isLoading && !error; return (

{t('LIBRARY.TITLE')}

{[null, ...GAME_STATUSES].map((s) => ( ))}
setSearch(e.target.value)} placeholder={t('LIBRARY.SEARCH_PLACEHOLDER')} aria-label={t('LIBRARY.SEARCH_PLACEHOLDER')} maxLength={100} className="pl-8" />
{isLoading && ( {Array.from({length: 8}, (_, i) => (
))}
)} {error &&
{error}
} {ready && data.content.length === 0 && ( {filtered ? : }

{filtered ? t('LIBRARY.NO_MATCH') : sync.isRunning ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')}

)} {ready && data.content.length > 0 && ( )} {ready && data.totalPages > 1 && (
{t('LIBRARY.PAGE_OF', {current: data.page + 1, total: data.totalPages})} {' ยท '} {data.totalElements} {t('LIBRARY.GAMES')}
)}
); }