Files
ToBePlayed-Frontend/src/pages/GameLibrary.jsx
T
lobo 16651a1f56 feat(library): add platform badges, remove hide duplicates
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[]).
2026-10-09 11:31:11 +02:00

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