Public Access
LinkGameDialog (user request or auditor link), EditionsCard on the details page, LinkQueuePage and AuditLogPage for auditors with paging, hideDuplicates filter and editions badge in the library, audit menu entries, de/en texts.
356 lines
18 KiB
React
356 lines
18 KiB
React
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 (
|
|
<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">
|
|
{hasSteamId && (
|
|
<>
|
|
<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 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={listHideDuplicates ? 'default' : 'outline'}
|
|
aria-pressed={listHideDuplicates}
|
|
className="h-7 rounded-full px-3 text-xs"
|
|
onClick={() => updateParams({hideDuplicates: listHideDuplicates ? null : true, hidden: null, page: 0})}
|
|
>
|
|
<Layers className="size-3"/>{t('LIBRARY.FILTER_HIDE_DUPLICATES')}
|
|
</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>
|
|
<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="min-w-0 flex-1 truncate font-medium group-hover:text-primary transition-colors">
|
|
{g.name ?? `App ${g.appid}`}
|
|
</span>
|
|
{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>
|
|
)}
|
|
<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="size-3"/>
|
|
{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>
|
|
);
|
|
}
|