feat(library): add source chips to game library

Chips show only when the user has 2 or more sources.
A selected source the user lacks stays visible, so it can be cleared.
This commit is contained in:
lobo committed 2026-10-09 10:03:05 +02:00
1 parent d3ad19e2fa
commit e136a7abc1
8 files changed
+224 -55

No files matched your search

+88 -48
View File
@@ -1,12 +1,12 @@
import {useCallback, useEffect, useState} from '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, parseStatus} from '../utils/backlog.js';
import {GAME_STATUSES, MAX_LIBRARY_SEARCH_LENGTH} from '../utils/limits.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';
@@ -63,12 +63,16 @@ export default function GameLibrary() {
const listStatus = hiddenOnly ? null : status;
const listShortOnly = !hiddenOnly && shortOnly;
const listHideDuplicates = !hiddenOnly && hideDuplicates;
const filtered = Boolean(listStatus || q.trim() || listShortOnly || hiddenOnly || listHideDuplicates);
const sources = parseSources(searchParams.get('source'));
const listSources = hiddenOnly ? [] : sources;
const sourceKey = listSources.join(',');
const filtered = Boolean(listStatus || q.trim() || listShortOnly || hiddenOnly || listHideDuplicates || 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);
@@ -79,7 +83,7 @@ export default function GameLibrary() {
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 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, source: sourceKey})}`;
const {ok, status, data: json} = await request(url, {signal});
if (signal.aborted) return;
setIsLoading(false);
@@ -89,30 +93,41 @@ export default function GameLibrary() {
return;
}
setData(json);
}, [nextSignal, page, size, sort, listStatus, q, listShortOnly, hiddenOnly, listHideDuplicates]);
}, [nextSignal, page, size, sort, listStatus, q, listShortOnly, hiddenOnly, listHideDuplicates, sourceKey]);
useEffect(() => {
load();
}, [load]);
const mounted = useRef(true);
useEffect(() => {
let cancelled = false;
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 (!cancelled && json) setCanSync(json.canSync);
if (!mounted.current || !json) return;
setCanSync(json.canSync);
setOwnSources(json.sources ?? []);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
loadCounts();
}, [loadCounts]);
const expectInitialSync = !isLoading && !error && !filtered && data.totalElements === 0;
const sync = useLibrarySync({
enabled: canSync,
expectInitialSync,
onComplete: load,
onComplete: () => {
load();
loadCounts();
},
});
const updateParams = useCallback((next) => {
@@ -137,8 +152,13 @@ export default function GameLibrary() {
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, hideDuplicates: null, page: 0});
updateParams(hiddenOnly ? {hidden: null, page: 0} : {hidden: true, status: null, maxMinutes: null, hideDuplicates: null, source: null, page: 0});
};
const onSizeChange = (value) => {
@@ -158,6 +178,8 @@ export default function GameLibrary() {
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">
@@ -216,46 +238,64 @@ export default function GameLibrary() {
{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) => (
<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
key={s ?? 'ALL'}
size="sm"
variant={listStatus === s && !hiddenOnly ? 'default' : 'outline'}
aria-pressed={listStatus === s && !hiddenOnly}
variant={listShortOnly ? 'default' : 'outline'}
aria-pressed={listShortOnly}
className="h-7 rounded-full px-3 text-xs"
onClick={() => onStatusChange(s)}
onClick={() => updateParams({maxMinutes: listShortOnly ? null : SHORT_GAME_MINUTES, hidden: null, page: 0})}
>
{s ? t(`BACKLOG.STATUS.${s}`) : t('LIBRARY.FILTER_ALL')}
<Timer className="size-3"/>{t('LIBRARY.FILTER_SHORT')}
</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>
<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>
{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"/>