import {useCallback, useEffect, useState} from 'react'; import {Link, 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 {formatPlaytime} from '../utils/games.js'; import {libraryQuery, parseStatus} from '../utils/backlog.js'; import {GAME_STATUSES, MAX_LIBRARY_SEARCH_LENGTH} from '../utils/limits.js'; import {ArrowUpDown, ChevronLeft, ChevronRight, Clock, EyeOff, Gamepad2, Layers, Library, Plus, RefreshCw, Search, SearchX, Timer, Trophy, Upload} 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 AddGamePanel from '../components/AddGamePanel.jsx'; 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 {usePreferences} from '../context/PreferencesContext.jsx'; import {coverEffect} from '../utils/rewards.js'; import {apiFetch, request} from '../utils/api.js'; import useAbortPrevious from '../hooks/useAbortPrevious.js'; import {platformLabel} from '../utils/platforms.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 {equipped} = usePreferences(); 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 hiddenOnly = searchParams.get('hidden') === 'true'; const hideDuplicates = searchParams.get('hideDuplicates') === 'true'; // The hidden list stands alone, also for a hand-made URL: its status and short filters are ignored. const listStatus = hiddenOnly ? null : status; const listShortOnly = !hiddenOnly && shortOnly; const listHideDuplicates = !hiddenOnly && hideDuplicates; const filtered = Boolean(listStatus || q.trim() || listShortOnly || hiddenOnly || listHideDuplicates); const [search, setSearch] = useState(q); const [adding, setAdding] = useState(false); // Starts true until /games-count answers, so a Steam user does not see the sync controls vanish and come back. const [hasSteamId, setHasSteamId] = useState(true); const [data, setData] = useState({content: [], page: 0, size, totalElements: 0, totalPages: 0}); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const nextSignal = useAbortPrevious(); const load = useCallback(async () => { const signal = nextSignal(); setIsLoading(true); setError(null); const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games?${libraryQuery({page, size, sort, status: listStatus, q, maxMinutes: listShortOnly ? SHORT_GAME_MINUTES : null, hidden: hiddenOnly, hideDuplicates: listHideDuplicates})}`; const {ok, status, data: json} = await request(url, {signal}); if (signal.aborted) return; setIsLoading(false); if (status === 401) return; if (!ok || !json) { setError(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'LIBRARY.ERROR_LOAD'); return; } setData(json); }, [nextSignal, page, size, sort, listStatus, q, listShortOnly, hiddenOnly, listHideDuplicates]); useEffect(() => { load(); }, [load]); useEffect(() => { let cancelled = false; apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games-count`) .then((response) => (response.ok ? response.json() : null)) .then((json) => { if (!cancelled && json) setHasSteamId(json.hasSteamId); }) .catch(() => {}); return () => { cancelled = true; }; }, []); const expectInitialSync = !isLoading && !error && !filtered && data.totalElements === 0; const sync = useLibrarySync({ enabled: hasSteamId, expectInitialSync, onComplete: load, }); const updateParams = useCallback((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); }, [searchParams, setSearchParams]); // 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]); // updateParams changes with searchParams, 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, updateParams]); const onStatusChange = (value) => { updateParams({status: value, hidden: null, page: 0}); }; const onHiddenChange = () => { updateParams(hiddenOnly ? {hidden: null, page: 0} : {hidden: true, status: null, maxMinutes: null, hideDuplicates: null, 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(sync.watch); const syncing = syncBusy || sync.isRunning; const ready = !isLoading && !error; return (

{t('LIBRARY.TITLE')}

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

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

)} {ready && data.content.length > 0 && (
    {data.content.map((g) => (
  • {g.name ?? `App ${g.appid}`} {platformLabel(g) && ( {platformLabel(g)} )} {g.editions >= 2 && ( x{g.editions} )} {g.achievementsTotal > 0 && g.achievementsUnlocked != null && ( {g.achievementsUnlocked}/{g.achievementsTotal} )} {formatPlaytime(g.playtimeMinutes)}
  • ))}
)} {ready && data.totalPages > 1 && (
{t('LIBRARY.PAGE_OF', {current: data.page + 1, total: data.totalPages})} {' ยท '} {data.totalElements} {t('LIBRARY.GAMES')}
)}
); }