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

+1 -1
View File
@@ -55,7 +55,7 @@ To Be Played is split into three repositories:
system setting "reduce motion" or the "Reduce animations" switch in the settings.
- **Landing page**: next to the community numbers and the top games, three blocks: games per platform, top genres and
finished games per year of all users.
- **Library**: status filters, "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest
- **Library**: status filters, source filter (Steam, PlayStation, manual; a linked set counts on each of its sources), "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest
in library, rating or priority, paging. Each row shows the unlocked achievements.
- **Editions**: an auditor links the editions of one game (Steam and PS5 copy, remaster, port) for every user. A user
who owns two or more of a linked set sees "Also in your library" on the details page, an "x2" badge in the library
+2
View File
@@ -256,6 +256,8 @@
}
},
"LIBRARY": {
"SOURCE_LABEL": "Quelle",
"SOURCE": { "STEAM": "Steam", "PSN": "PlayStation", "MANUAL": "Manuell" },
"TITLE": "Spielebibliothek",
"LOADING": "Lade Spiele …",
"EMPTY": "Noch keine Spiele. Synchronisiere deine Steam-Bibliothek oder füge ein Spiel hinzu.",
+2
View File
@@ -256,6 +256,8 @@
}
},
"LIBRARY": {
"SOURCE_LABEL": "Source",
"SOURCE": { "STEAM": "Steam", "PSN": "PlayStation", "MANUAL": "Manual" },
"TITLE": "Game Library",
"LOADING": "Loading games …",
"EMPTY": "No games yet. Sync your Steam library or add a game.",
+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"/>
+108 -3
View File
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
import {StrictMode} from 'react';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {createMemoryRouter, RouterProvider, useLocation, useNavigate} from 'react-router-dom';
@@ -11,16 +12,20 @@ import {toast} from 'sonner';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
vi.mock('../hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false})}));
const syncOptions = vi.hoisted(() => ({current: null}));
vi.mock('../hooks/useLibrarySync.js', () => ({default: (opts) => {
syncOptions.current = opts;
return {status: 'OK', lastSyncedAt: null, isRunning: false};
}}));
vi.mock('../hooks/useForceSync.js', () => ({default: () => ({trigger: vi.fn(), busy: false})}));
const DEBOUNCE_WAIT_MS = 700;
const page = {content: [{appid: 220, name: 'Half-Life 2', playtimeMinutes: 0, status: 'BACKLOG'}], page: 0, size: 20, totalElements: 1, totalPages: 1};
const answer = (games = page.content, canSync = true, hasSteamId = canSync) => fetchRoutes({
const answer = (games = page.content, canSync = true, hasSteamId = canSync, sources = []) => fetchRoutes({
'GET /api/users/me': json(200, {settings: {}}),
'GET /api/library/games': json(200, {...page, content: games, totalElements: games.length, totalPages: games.length ? 1 : 0}),
'GET /api/library/games-count': json(200, {count: games.length, hasSteamId, canSync}),
'GET /api/library/games-count': json(200, {count: games.length, hasSteamId, canSync, sources}),
});
const wait = (ms) => act(() => new Promise((resolve) => setTimeout(resolve, ms)));
@@ -250,6 +255,106 @@ describe('GameLibrary short games', () => {
});
});
describe('GameLibrary source filter', () => {
const lastGamesUrl = () => String(fetch.mock.calls.map(([url]) => url).filter((url) => String(url).includes('/api/library/games?')).at(-1));
const chip = (source) => screen.getByRole('button', {name: `LIBRARY.SOURCE.${source}`});
it('sourceFilter_AddsSourceToTheUrlAndTheRequest', async () => {
answer(page.content, true, true, ['STEAM', 'PSN']);
const lib = renderLibrary('/library?page=2');
await screen.findByRole('group', {name: 'LIBRARY.SOURCE_LABEL'});
fireEvent.click(chip('STEAM'));
await waitFor(() => expect(lib.params().get('source')).toBe('STEAM'));
fireEvent.click(chip('PSN'));
await waitFor(() => expect(lib.params().get('source')).toBe('STEAM,PSN'));
expect(lib.params().get('page')).toBe('0');
await waitFor(() => expect(lastGamesUrl()).toMatch(/[?&]source=STEAM%2CPSN(&|$)/));
expect(chip('STEAM').getAttribute('aria-pressed')).toBe('true');
fireEvent.click(chip('STEAM'));
await waitFor(() => expect(lib.params().get('source')).toBe('PSN'));
fireEvent.click(chip('PSN'));
await waitFor(() => expect(lib.params().get('source')).toBeNull());
});
it('sourceFilter_HiddenWithOneOwnSource', async () => {
answer(page.content, true, true, ['STEAM']);
renderLibrary();
await waitFor(() => expect(fetch).toHaveBeenCalled());
await wait(50);
expect(screen.queryByRole('group', {name: 'LIBRARY.SOURCE_LABEL'})).toBeNull();
});
it('sourceFilter_ShowsASelectedSourceTheUserLacks', async () => {
answer(page.content, true, true, ['STEAM']);
const lib = renderLibrary('/library?source=PSN');
expect((await screen.findByRole('button', {name: 'LIBRARY.SOURCE.PSN'})).getAttribute('aria-pressed')).toBe('true');
fireEvent.click(chip('PSN'));
await waitFor(() => expect(lib.params().get('source')).toBeNull());
});
it('sourceFilter_IgnoresUnknownValuesInTheUrl', async () => {
answer(page.content, true, true, ['STEAM', 'PSN']);
renderLibrary('/library?source=XBOX');
await waitFor(() => expect(lastGamesUrl()).toContain('/api/library/games?'));
expect(lastGamesUrl()).not.toMatch(/source=/);
});
it('hiddenFilter_ClearsTheSource', async () => {
answer(page.content, true, true, ['STEAM', 'PSN']);
const lib = renderLibrary('/library?source=PSN');
await screen.findByRole('group', {name: 'LIBRARY.SOURCE_LABEL'});
fireEvent.click(screen.getByRole('button', {name: 'LIBRARY.FILTER_HIDDEN'}));
await waitFor(() => expect(lib.params().get('hidden')).toBe('true'));
expect(lib.params().get('source')).toBeNull();
await waitFor(() => expect(lastGamesUrl()).not.toMatch(/source=/));
});
it('hiddenLink_IgnoresTheSource', async () => {
renderLibrary('/library?hidden=true&source=PSN');
await waitFor(() => expect(lastGamesUrl()).toMatch(/[?&]hidden=true(&|$)/));
expect(lastGamesUrl()).not.toMatch(/source=/);
});
it('sourceFilter_ChipsFollowAFinishedSync', async () => {
answer(page.content, true, true, ['STEAM']);
renderLibrary();
await waitFor(() => expect(fetch).toHaveBeenCalled());
await wait(50);
expect(screen.queryByRole('group', {name: 'LIBRARY.SOURCE_LABEL'})).toBeNull();
answer(page.content, true, true, ['STEAM', 'PSN']);
await act(async () => syncOptions.current.onComplete());
expect(await screen.findByRole('group', {name: 'LIBRARY.SOURCE_LABEL'})).toBeTruthy();
});
it('sourceFilter_ChipsShowUnderStrictMode', async () => {
answer(page.content, true, true, ['STEAM', 'PSN']);
const router = createMemoryRouter([{
path: '/library',
element: <PreferencesProvider><CurrentUserProvider><GameLibrary/></CurrentUserProvider></PreferencesProvider>,
}], {initialEntries: ['/library']});
render(<StrictMode><RouterProvider router={router}/></StrictMode>);
expect(await screen.findByRole('group', {name: 'LIBRARY.SOURCE_LABEL'})).toBeTruthy();
});
it('sourceFilter_EmptyResultShowsNoMatch', async () => {
answer([], true, true, ['STEAM', 'PSN']);
renderLibrary('/library?source=PSN');
expect(await screen.findByText('LIBRARY.NO_MATCH')).toBeTruthy();
});
});
describe('GameLibrary with games added by hand', () => {
const zelda = {appid: -1025, name: 'Zelda', playtimeMinutes: 90, status: 'BACKLOG', source: 'MANUAL',
platform: 'Nintendo Switch', imageUrl: 'https://images.igdb.com/z.jpg'};
+5 -2
View File
@@ -1,11 +1,13 @@
import {apiFetch} from './api.js';
import {GAME_STATUSES} from './limits.js';
import {GAME_SOURCES, GAME_STATUSES} from './limits.js';
// Radix Select has no empty value, so "no priority" needs its own value in the form.
export const NO_PRIORITY = 'NONE';
export const parseStatus = (value) => (GAME_STATUSES.includes(value) ? value : null);
export const parseSources = (value) => GAME_SOURCES.filter((s) => (value ?? '').split(',').includes(s));
export const toBacklogRequest = ({status, rating, notes, priority}) => ({
status,
rating: rating || null,
@@ -56,13 +58,14 @@ export const dismissFinishedHint = (appid, dismissed) =>
body: JSON.stringify({dismissed}),
});
export const libraryQuery = ({page, size, sort, status, q, maxMinutes, hidden, hideDuplicates}) => {
export const libraryQuery = ({page, size, sort, status, q, maxMinutes, hidden, hideDuplicates, source}) => {
const params = new URLSearchParams({page: String(page), size: String(size), sort});
if (hidden) params.set('hidden', 'true');
if (hideDuplicates) params.set('hideDuplicates', 'true');
if (status) params.set('status', status);
if (q?.trim()) params.set('q', q.trim());
if (maxMinutes) params.set('maxMinutes', String(maxMinutes));
if (source) params.set('source', source);
return params.toString();
};
+16 -1
View File
@@ -1,5 +1,5 @@
import {describe, expect, it} from 'vitest';
import {backlogShare, libraryQuery, NO_PRIORITY, parseStatus, toBacklogRequest} from './backlog.js';
import {backlogShare, libraryQuery, NO_PRIORITY, parseSources, parseStatus, toBacklogRequest} from './backlog.js';
describe('toBacklogRequest', () => {
it('sends every field', () => {
@@ -60,3 +60,18 @@ describe('libraryQuery hideDuplicates', () => {
expect(libraryQuery({...base, hideDuplicates: false})).not.toContain('hideDuplicates');
});
});
describe('sources', () => {
it('parseSources_KeepsKnownValuesInOrder', () => {
expect(parseSources('PSN,FOO,STEAM,PSN')).toEqual(['STEAM', 'PSN']);
expect(parseSources(null)).toEqual([]);
expect(parseSources('')).toEqual([]);
});
it('libraryQuery_SetsSourceOnlyWhenSelected', () => {
const base = {page: 0, size: 20, sort: 'appid,asc', status: null, q: ''};
expect(libraryQuery({...base, source: 'STEAM,PSN'})).toBe('page=0&size=20&sort=appid%2Casc&source=STEAM%2CPSN');
expect(libraryQuery({...base, source: ''})).not.toContain('source');
expect(libraryQuery(base)).not.toContain('source');
});
});
+2
View File
@@ -2,6 +2,8 @@
// Core GameStatus
export const GAME_STATUSES = ['BACKLOG', 'PLAYING', 'FINISHED', 'COMPLETED', 'DROPPED'];
// Core GameSource, in the order the UI shows it
export const GAME_SOURCES = ['STEAM', 'PSN', 'MANUAL'];
// Core GamePriority, in the order the UI shows it
export const GAME_PRIORITIES = ['HIGH', 'MEDIUM', 'LOW'];