diff --git a/README.md b/README.md index 3a4165c..3f9ccd0 100644 --- a/README.md +++ b/README.md @@ -55,12 +55,14 @@ 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, 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. +- **Library**: status filters, source filter (Steam, PlayStation, manual; a linked set matches 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. A game you own on several + platforms is one row (Core's counted edition) with a small badge per platform next to the title (`PlatformIcons`: + ST, PS or the platform name, from `platforms[]`; lucide has no brand icons). - **Editions**: the editions of one game (Steam and PS5 copy, remaster, port) are linked automatically from IGDB's "same game" key; an auditor can also link or unlink them 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 and the filter "Hide - duplicates"; the numbers count the set once. The editions of a set share one status, rating and notes (the page + set sees "Also in your library" on the details page and an "x2" badge in the library; the library lists the set + once (no "Hide duplicates" filter any more), and the numbers count it once. The editions of a set share one status, rating and notes (the page reloads after a save to show it). "Not the same game" splits the game off its set for this user only, and it stays split. "Same game as..." on the details page asks the auditors for a link. - **Add a game by name** for games outside Steam (PlayStation, Switch, Xbox, physical): search IGDB, pick a hit, @@ -259,6 +261,13 @@ flowchart LR which question is open; `src/utils/questions.js` only loads the list and sends the answer. A yes to Finished? gets the Rate it? card back at once. `409` / `404` reload the list. `RatingStars` is shared with `BacklogCard`. +```mermaid +flowchart LR + Games["Core GET /api/library/games: one row per set + platforms[]"] --> Row["GameLibrary row"] + Row --> Icons["PlatformIcons: badge per platform"] + Row -.->|"no platforms (hidden game)"| Badge["single platform badge"] +``` + ```mermaid flowchart LR Detail["GameDetail: EditionsCard"] -->|"Not the same game"| Split["Core DELETE /api/library/games/{appid}/set"] diff --git a/public/i18n/de.json b/public/i18n/de.json index e6f5804..a54e575 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -291,7 +291,6 @@ "FILTER_HIDDEN": "Ausgeblendet", "NO_HIDDEN": "Keine ausgeblendeten Spiele. Blende ein Spiel auf seiner Detailseite aus, z. B. einen Playtest oder ein Tool.", "ACHIEVEMENTS": "{{unlocked}} von {{total}} Erfolgen", - "FILTER_HIDE_DUPLICATES": "Duplikate ausblenden", "EDITIONS": "{{count}} Ausgaben in deiner Bibliothek" }, "GAME_DETAIL": { diff --git a/public/i18n/en.json b/public/i18n/en.json index a78e0d0..177de84 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -291,7 +291,6 @@ "FILTER_HIDDEN": "Hidden", "NO_HIDDEN": "No hidden games. Hide a game on its detail page, e.g. a playtest or a tool.", "ACHIEVEMENTS": "{{unlocked}} of {{total}} achievements", - "FILTER_HIDE_DUPLICATES": "Hide duplicates", "EDITIONS": "{{count}} editions in your library" }, "GAME_DETAIL": { diff --git a/src/components/PileOfShameCard.test.jsx b/src/components/PileOfShameCard.test.jsx index 9b37952..e2be322 100644 --- a/src/components/PileOfShameCard.test.jsx +++ b/src/components/PileOfShameCard.test.jsx @@ -116,7 +116,7 @@ describe('PileOfShameCard', () => { render( {}}/>); expect(screen.getAllByRole('link', {name: 'BACKLOG.STATUS.BACKLOG: 2'})[0].getAttribute('href')) - .toBe('/library?hideDuplicates=true&status=BACKLOG'); + .toBe('/library?status=BACKLOG'); fireEvent.click(screen.getByRole('button', {name: 'DASHBOARD.NEVER_PLAYED:1'})); expect(await screen.findByText('Untouched')).toBeTruthy(); diff --git a/src/components/PlatformIcons.jsx b/src/components/PlatformIcons.jsx new file mode 100644 index 0000000..9652ce2 --- /dev/null +++ b/src/components/PlatformIcons.jsx @@ -0,0 +1,30 @@ +import {useTranslation} from 'react-i18next'; + +import {platformLabel} from '../utils/platforms.js'; + +// lucide has no brand icons: a short text badge per platform. +const shortName = ({source, platform}) => { + if (source === 'STEAM') return 'ST'; + return platform ?? (source === 'PSN' ? 'PS' : 'M'); +}; + +/** + * @param platforms the {source, platform} of every edition of a library entry, see Core's `platforms` + */ +export default function PlatformIcons({platforms}) { + const {t} = useTranslation(); + if (!platforms?.length) return null; + return ( + + ); +} diff --git a/src/components/PlatformIcons.test.jsx b/src/components/PlatformIcons.test.jsx new file mode 100644 index 0000000..a0812c9 --- /dev/null +++ b/src/components/PlatformIcons.test.jsx @@ -0,0 +1,44 @@ +// @vitest-environment jsdom +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {cleanup, render, screen} from '@testing-library/react'; +import PlatformIcons from './PlatformIcons.jsx'; + +vi.mock('react-i18next', () => import('../test/i18n-mock.js')); + +afterEach(cleanup); + +describe('PlatformIcons', () => { + it('showsOneBadgeWithALabelPerPlatform', () => { + render(); + + expect(screen.getAllByRole('listitem')).toHaveLength(2); + expect(screen.getByLabelText('Steam').textContent).toBe('ST'); + expect(screen.getByLabelText('PS5').textContent).toBe('PS5'); + }); + + it('namesAPlayStationTitleWithoutPlatformAfterItsSource', () => { + render(); + + expect(screen.getByLabelText('LIBRARY.SOURCE.PSN').textContent).toBe('PS'); + }); + + it('showsAManualPlatformByName', () => { + render(); + + expect(screen.getByLabelText('Switch').textContent).toBe('Switch'); + }); + + it('aManualGameWithoutPlatformIsNotShownAsPlayStation', () => { + render(); + + expect(screen.getByLabelText('LIBRARY.SOURCE.MANUAL').textContent).not.toBe('PS'); + }); + + it('rendersNothingWithoutPlatforms', () => { + const {container} = render(); + expect(container.firstChild).toBeNull(); + cleanup(); + const missing = render(); + expect(missing.container.firstChild).toBeNull(); + }); +}); diff --git a/src/pages/GameLibrary.jsx b/src/pages/GameLibrary.jsx index 56e66f4..59cfb45 100644 --- a/src/pages/GameLibrary.jsx +++ b/src/pages/GameLibrary.jsx @@ -23,6 +23,7 @@ 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'; @@ -58,15 +59,13 @@ export default function GameLibrary() { const q = searchParams.get('q') ?? ''; const shortOnly = searchParams.get('maxMinutes') === String(SHORT_GAME_MINUTES); const hiddenOnly = searchParams.get('hidden') === 'true'; - const hideDuplicates = searchParams.get('hideDuplicates') === '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 listHideDuplicates = !hiddenOnly && hideDuplicates; 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 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 @@ -83,7 +82,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, source: sourceKey})}`; + 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); @@ -93,7 +92,7 @@ export default function GameLibrary() { return; } setData(json); - }, [nextSignal, page, size, sort, listStatus, q, listShortOnly, hiddenOnly, listHideDuplicates, sourceKey]); + }, [nextSignal, page, size, sort, listStatus, q, listShortOnly, hiddenOnly, sourceKey]); useEffect(() => { load(); @@ -158,7 +157,7 @@ export default function GameLibrary() { }; const onHiddenChange = () => { - updateParams(hiddenOnly ? {hidden: null, page: 0} : {hidden: true, status: null, maxMinutes: null, hideDuplicates: null, source: null, page: 0}); + updateParams(hiddenOnly ? {hidden: null, page: 0} : {hidden: true, status: null, maxMinutes: null, source: null, page: 0}); }; const onSizeChange = (value) => { @@ -261,15 +260,6 @@ export default function GameLibrary() { > {t('LIBRARY.FILTER_SHORT')} -