Public Access
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:
1 parent
d3ad19e2fa
commit
e136a7abc1
8 files changed
+189
-20
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
+53
-13
@@ -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,6 +238,7 @@ 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 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
|
||||
@@ -257,6 +280,23 @@ export default function GameLibrary() {
|
||||
<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
|
||||
|
||||
@@ -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'};
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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,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'];
|
||||
|
||||
|
||||
Reference in new issue
Block a user