Public Access
Gitea #34 slice 2 of 3. Core lists each set once now. The hide duplicates toggle and its param are not needed. PlatformIcons shows a badge per platform (platforms[]).
394 lines
20 KiB
React
394 lines
20 KiB
React
import {useCallback, useEffect, useRef, 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, parseSources, parseStatus} from '../utils/backlog.js';
|
|
import {GAME_SOURCES, 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 {cn} from '@/lib/utils';
|
|
import {Skeleton} from '@/components/ui/skeleton';
|
|
import AddGamePanel from '../components/AddGamePanel.jsx';
|
|
import GameCover from '../components/GameCover.jsx';
|
|
import FitText from '../components/FitText.jsx';
|
|
import LibraryTabs from '../components/LibraryTabs.jsx';
|
|
import ComingBadge from '../components/ComingBadge.jsx';
|
|
import GameStatusBadge from '../components/GameStatusBadge.jsx';
|
|
import PageContainer from '../components/PageContainer.jsx';
|
|
import PlatformIcons from '../components/PlatformIcons.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';
|
|
// 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 sources = parseSources(searchParams.get('source'));
|
|
const listSources = hiddenOnly ? [] : sources;
|
|
const sourceKey = listSources.join(',');
|
|
const filtered = Boolean(listStatus || q.trim() || listShortOnly || hiddenOnly || sourceKey);
|
|
const [search, setSearch] = useState(q);
|
|
const [adding, setAdding] = useState(false);
|
|
// A Steam ID or a PlayStation link. Starts true until /games-count answers, so the sync controls do not vanish
|
|
// and come back.
|
|
const [canSync, setCanSync] = useState(true);
|
|
const [ownSources, setOwnSources] = useState([]);
|
|
|
|
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, source: sourceKey})}`;
|
|
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, sourceKey]);
|
|
|
|
useEffect(() => {
|
|
load();
|
|
}, [load]);
|
|
|
|
const mounted = useRef(true);
|
|
useEffect(() => {
|
|
mounted.current = true;
|
|
return () => {
|
|
mounted.current = false;
|
|
};
|
|
}, []);
|
|
const loadCounts = useCallback(() => {
|
|
apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games-count`)
|
|
.then((response) => (response.ok ? response.json() : null))
|
|
.then((json) => {
|
|
if (!mounted.current || !json) return;
|
|
setCanSync(json.canSync);
|
|
setOwnSources(json.sources ?? []);
|
|
})
|
|
.catch(() => {});
|
|
}, []);
|
|
useEffect(() => {
|
|
loadCounts();
|
|
}, [loadCounts]);
|
|
|
|
const expectInitialSync = !isLoading && !error && !filtered && data.totalElements === 0;
|
|
const sync = useLibrarySync({
|
|
enabled: canSync,
|
|
expectInitialSync,
|
|
onComplete: () => {
|
|
load();
|
|
loadCounts();
|
|
},
|
|
});
|
|
|
|
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 toggleSource = (source) => {
|
|
const next = GAME_SOURCES.filter((s) => (s === source ? !sources.includes(s) : sources.includes(s)));
|
|
updateParams({source: next.join(','), hidden: null, page: 0});
|
|
};
|
|
|
|
const onHiddenChange = () => {
|
|
updateParams(hiddenOnly ? {hidden: null, page: 0} : {hidden: true, status: null, maxMinutes: null, source: 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;
|
|
// A selected source the user lacks stays visible so a stale URL value can be cleared.
|
|
const sourceChips = GAME_SOURCES.filter((s) => ownSources.includes(s) || sources.includes(s));
|
|
|
|
return (
|
|
<PageContainer className="p-4 md:p-8 space-y-6">
|
|
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
|
<div className="space-y-2">
|
|
<LibraryTabs/>
|
|
<h1 className="text-3xl font-bold tracking-tight">{t('LIBRARY.TITLE')}</h1>
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
{canSync && (
|
|
<>
|
|
<SyncStatusBadge
|
|
status={sync.status}
|
|
lastSyncedAt={sync.lastSyncedAt}
|
|
isRunning={sync.isRunning}
|
|
stale={sync.stale}
|
|
/>
|
|
<Button variant="outline" size="sm" onClick={triggerSync} disabled={syncing}>
|
|
<RefreshCw className={syncing ? 'animate-spin' : ''}/>
|
|
{t('LIBRARY.RESYNC')}
|
|
</Button>
|
|
</>
|
|
)}
|
|
<Button variant="outline" size="sm" onClick={() => setAdding(true)} disabled={adding}>
|
|
<Plus/>{t('MANUAL_GAME.ADD_BUTTON')}
|
|
</Button>
|
|
<Button variant="outline" size="sm" asChild>
|
|
<Link to="/import"><Upload/>{t('IMPORT.OPEN')}</Link>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<Select value={sort} onValueChange={onSortChange}>
|
|
<SelectTrigger className="w-52" aria-label={t('LIBRARY.SORT_BY')}>
|
|
<ArrowUpDown className="text-muted-foreground"/>
|
|
<SelectValue/>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{SORT_OPTIONS.map((opt) => (
|
|
<SelectItem key={opt.value} value={opt.value}>{t(opt.labelKey)}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Select value={String(size)} onValueChange={onSizeChange}>
|
|
<SelectTrigger className="w-24" aria-label={t('LIBRARY.PER_PAGE')}>
|
|
<SelectValue/>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{PAGE_SIZES.map((n) => (
|
|
<SelectItem key={n} value={String(n)}>{n}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
{adding && <AddGamePanel onClose={() => setAdding(false)}/>}
|
|
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="flex flex-col gap-1.5">
|
|
<div role="group" aria-label={t('BACKLOG.STATUS_LABEL')} className="flex flex-wrap gap-1.5">
|
|
{[null, ...GAME_STATUSES].map((s) => (
|
|
<Button
|
|
key={s ?? 'ALL'}
|
|
size="sm"
|
|
variant={listStatus === s && !hiddenOnly ? 'default' : 'outline'}
|
|
aria-pressed={listStatus === s && !hiddenOnly}
|
|
className="h-7 rounded-full px-3 text-xs"
|
|
onClick={() => onStatusChange(s)}
|
|
>
|
|
{s ? t(`BACKLOG.STATUS.${s}`) : t('LIBRARY.FILTER_ALL')}
|
|
</Button>
|
|
))}
|
|
<Button
|
|
size="sm"
|
|
variant={listShortOnly ? 'default' : 'outline'}
|
|
aria-pressed={listShortOnly}
|
|
className="h-7 rounded-full px-3 text-xs"
|
|
onClick={() => updateParams({maxMinutes: listShortOnly ? null : SHORT_GAME_MINUTES, hidden: null, page: 0})}
|
|
>
|
|
<Timer className="size-3"/>{t('LIBRARY.FILTER_SHORT')}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant={hiddenOnly ? 'default' : 'outline'}
|
|
aria-pressed={hiddenOnly}
|
|
className="h-7 rounded-full px-3 text-xs"
|
|
onClick={onHiddenChange}
|
|
>
|
|
<EyeOff className="size-3"/>{t('LIBRARY.FILTER_HIDDEN')}
|
|
</Button>
|
|
</div>
|
|
{sourceChips.length >= 2 && (
|
|
<div role="group" aria-label={t('LIBRARY.SOURCE_LABEL')} className="flex flex-wrap gap-1.5">
|
|
{sourceChips.map((s) => (
|
|
<Button
|
|
key={s}
|
|
size="sm"
|
|
variant={listSources.includes(s) ? 'default' : 'outline'}
|
|
aria-pressed={listSources.includes(s)}
|
|
className="h-7 rounded-full px-3 text-xs"
|
|
onClick={() => toggleSource(s)}
|
|
>
|
|
{t(`LIBRARY.SOURCE.${s}`)}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="relative sm:w-64">
|
|
<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={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder={t('LIBRARY.SEARCH_PLACEHOLDER')}
|
|
aria-label={t('LIBRARY.SEARCH_PLACEHOLDER')}
|
|
maxLength={MAX_LIBRARY_SEARCH_LENGTH}
|
|
className="pl-8"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{isLoading && (
|
|
<Card className="gap-0 py-0 divide-y">
|
|
{Array.from({length: 8}, (_, i) => (
|
|
<div key={i} className="flex items-center gap-4 px-5 py-3">
|
|
<Skeleton className="h-11 w-24 rounded-md"/>
|
|
<Skeleton className="h-4 flex-1"/>
|
|
<Skeleton className="h-4 w-12"/>
|
|
</div>
|
|
))}
|
|
</Card>
|
|
)}
|
|
|
|
{error && <div className="py-12 text-center text-destructive">{t(error)}</div>}
|
|
|
|
{ready && data.content.length === 0 && (
|
|
<Card className="items-center py-16 text-center text-muted-foreground">
|
|
{filtered ? <SearchX className="size-10"/> : <Library className="size-10"/>}
|
|
<p className="max-w-sm text-sm">
|
|
{hiddenOnly ? t('LIBRARY.NO_HIDDEN') : filtered ? t('LIBRARY.NO_MATCH')
|
|
: sync.isRunning ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')}
|
|
</p>
|
|
</Card>
|
|
)}
|
|
|
|
{ready && data.content.length > 0 && (
|
|
<Card className="gap-0 py-0">
|
|
<ul className="divide-y">
|
|
{data.content.map((g) => (
|
|
<li key={g.appid}>
|
|
<Link
|
|
to={`/library/${g.appid}`}
|
|
className="group flex items-center gap-3 sm:gap-4 px-4 sm:px-5 py-3 transition-colors hover:bg-muted/50"
|
|
>
|
|
<GameCover appid={g.appid} imageUrl={g.imageUrl} fx={coverEffect(equipped, g.status)}
|
|
className="w-16 sm:w-24 shrink-0"/>
|
|
<span className="@container min-w-0 flex-1">
|
|
<span className="block font-medium group-hover:text-primary transition-colors">
|
|
<FitText text={g.name ?? `App ${g.appid}`}/>
|
|
</span>
|
|
<PlatformIcons platforms={g.platforms}/>
|
|
</span>
|
|
{!g.platforms?.length && platformLabel(g) && (
|
|
<Badge variant="outline" className="hidden sm:inline-flex" title={platformLabel(g)}>
|
|
<Gamepad2 className="size-3"/>{platformLabel(g)}
|
|
</Badge>
|
|
)}
|
|
{g.editions >= 2 && (
|
|
<Badge variant="outline" className="tabular-nums" title={t('LIBRARY.EDITIONS', {count: g.editions})}>
|
|
<Layers className="size-3"/>x{g.editions}
|
|
</Badge>
|
|
)}
|
|
<ComingBadge game={g} compact/>
|
|
<GameStatusBadge status={g.status} compact/>
|
|
{g.achievementsTotal > 0 && g.achievementsUnlocked != null && (
|
|
<Badge variant="secondary" className="hidden sm:inline-flex tabular-nums"
|
|
title={t('LIBRARY.ACHIEVEMENTS', {unlocked: g.achievementsUnlocked, total: g.achievementsTotal})}>
|
|
<Trophy className={cn('size-3', g.achievementsPerfect && 'text-amber-500')}/>
|
|
{g.achievementsUnlocked}/{g.achievementsTotal}
|
|
</Badge>
|
|
)}
|
|
<Badge variant="secondary" className="tabular-nums">
|
|
<Clock className="size-3"/>
|
|
{formatPlaytime(g.playtimeMinutes)}
|
|
</Badge>
|
|
<ChevronRight className="size-4 text-muted-foreground"/>
|
|
</Link>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</Card>
|
|
)}
|
|
|
|
{ready && data.totalPages > 1 && (
|
|
<div className="flex items-center justify-between gap-2 text-sm text-muted-foreground">
|
|
<Button variant="outline" size="sm" onClick={() => goToPage(page - 1)} disabled={page <= 0}>
|
|
<ChevronLeft/>{t('LIBRARY.PREV')}
|
|
</Button>
|
|
<span className="text-center">
|
|
{t('LIBRARY.PAGE_OF', {current: data.page + 1, total: data.totalPages})}
|
|
{' · '}
|
|
{data.totalElements} {t('LIBRARY.GAMES')}
|
|
</span>
|
|
<Button variant="outline" size="sm" onClick={() => goToPage(page + 1)} disabled={page >= data.totalPages - 1}>
|
|
{t('LIBRARY.NEXT')}<ChevronRight/>
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</PageContainer>
|
|
);
|
|
}
|