Public Access
The token is no longer stored; a tbp_session flag remembers the login. apiFetch renews the session once on a 401 and sends the request again. Parallel requests and other tabs share one refresh, because Auth revokes the session when a refresh token is used twice. Only a 401 from the refresh ends the session; a 502 or no network shows the normal error.
306 lines
14 KiB
React
306 lines
14 KiB
React
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 (
|
|
<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">
|
|
<SyncStatusBadge
|
|
status={sync.status}
|
|
lastSyncedAt={sync.lastSyncedAt}
|
|
isRunning={sync.isRunning}
|
|
/>
|
|
<Button variant="outline" size="sm" onClick={triggerSync} disabled={syncing}>
|
|
<RefreshCw className={syncing ? 'animate-spin' : ''}/>
|
|
{t('LIBRARY.RESYNC')}
|
|
</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>
|
|
|
|
<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={status === s ? 'default' : 'outline'}
|
|
aria-pressed={status === s}
|
|
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={shortOnly ? 'default' : 'outline'}
|
|
aria-pressed={shortOnly}
|
|
className="h-7 rounded-full px-3 text-xs"
|
|
onClick={() => updateParams({maxMinutes: shortOnly ? null : SHORT_GAME_MINUTES, page: 0})}
|
|
>
|
|
<Timer className="size-3"/>{t('LIBRARY.FILTER_SHORT')}
|
|
</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={100}
|
|
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">{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">
|
|
{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}`}
|
|
state={{librarySearch}}
|
|
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} 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>
|
|
<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>
|
|
);
|
|
}
|