Files
ToBePlayed-Frontend/src/pages/GameLibrary.jsx
T
lobo d9a39dda22 feat(auth): use the cookie session and refresh on 401
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.
2026-09-25 13:45:08 +02:00

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>
);
}