diff --git a/README.md b/README.md index 85cd6d9..5f0b51f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/public/i18n/de.json b/public/i18n/de.json index 0299ebc..d8202cf 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -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.", diff --git a/public/i18n/en.json b/public/i18n/en.json index c68bbd1..5037ad1 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -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.", diff --git a/src/pages/GameLibrary.jsx b/src/pages/GameLibrary.jsx index 12440e4..56e66f4 100644 --- a/src/pages/GameLibrary.jsx +++ b/src/pages/GameLibrary.jsx @@ -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 ( @@ -216,46 +238,64 @@ export default function GameLibrary() { {adding && setAdding(false)}/>}
-
- {[null, ...GAME_STATUSES].map((s) => ( +
+
+ {[null, ...GAME_STATUSES].map((s) => ( + + ))} - ))} - - - + + +
+ {sourceChips.length >= 2 && ( +
+ {sourceChips.map((s) => ( + + ))} +
+ )}
diff --git a/src/pages/GameLibrary.test.jsx b/src/pages/GameLibrary.test.jsx index c38682a..6659804 100644 --- a/src/pages/GameLibrary.test.jsx +++ b/src/pages/GameLibrary.test.jsx @@ -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: , + }], {initialEntries: ['/library']}); + render(); + + 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'}; diff --git a/src/utils/backlog.js b/src/utils/backlog.js index 0c7aa41..e71a83c 100644 --- a/src/utils/backlog.js +++ b/src/utils/backlog.js @@ -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(); }; diff --git a/src/utils/backlog.test.js b/src/utils/backlog.test.js index 3990e0d..42d368a 100644 --- a/src/utils/backlog.test.js +++ b/src/utils/backlog.test.js @@ -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'); + }); +}); diff --git a/src/utils/limits.js b/src/utils/limits.js index a8ab33b..3bc75d6 100644 --- a/src/utils/limits.js +++ b/src/utils/limits.js @@ -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'];