feat(library): add platform badges, remove hide duplicates

Gitea #34 slice 2 of 3. Core lists each set once now.
The hide duplicates toggle and its param are not needed.
PlatformIcons shows a badge per platform (platforms[]).
This commit is contained in:
lobo committed 2026-10-09 11:31:11 +02:00
1 parent 25cc5ff2d8
commit 16651a1f56
13 files changed
+119 -47

No files matched your search

+13 -4
View File
@@ -55,12 +55,14 @@ To Be Played is split into three repositories:
system setting "reduce motion" or the "Reduce animations" switch in the settings. 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 - **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. 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 - **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. 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 - **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 "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 set sees "Also in your library" on the details page and an "x2" badge in the library; the library lists the set
duplicates"; the numbers count the set once. The editions of a set share one status, rating and notes (the page 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 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. 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, - **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 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`. 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 ```mermaid
flowchart LR flowchart LR
Detail["GameDetail: EditionsCard"] -->|"Not the same game"| Split["Core DELETE /api/library/games/{appid}/set"] Detail["GameDetail: EditionsCard"] -->|"Not the same game"| Split["Core DELETE /api/library/games/{appid}/set"]
-1
View File
@@ -291,7 +291,6 @@
"FILTER_HIDDEN": "Ausgeblendet", "FILTER_HIDDEN": "Ausgeblendet",
"NO_HIDDEN": "Keine ausgeblendeten Spiele. Blende ein Spiel auf seiner Detailseite aus, z. B. einen Playtest oder ein Tool.", "NO_HIDDEN": "Keine ausgeblendeten Spiele. Blende ein Spiel auf seiner Detailseite aus, z. B. einen Playtest oder ein Tool.",
"ACHIEVEMENTS": "{{unlocked}} von {{total}} Erfolgen", "ACHIEVEMENTS": "{{unlocked}} von {{total}} Erfolgen",
"FILTER_HIDE_DUPLICATES": "Duplikate ausblenden",
"EDITIONS": "{{count}} Ausgaben in deiner Bibliothek" "EDITIONS": "{{count}} Ausgaben in deiner Bibliothek"
}, },
"GAME_DETAIL": { "GAME_DETAIL": {
-1
View File
@@ -291,7 +291,6 @@
"FILTER_HIDDEN": "Hidden", "FILTER_HIDDEN": "Hidden",
"NO_HIDDEN": "No hidden games. Hide a game on its detail page, e.g. a playtest or a tool.", "NO_HIDDEN": "No hidden games. Hide a game on its detail page, e.g. a playtest or a tool.",
"ACHIEVEMENTS": "{{unlocked}} of {{total}} achievements", "ACHIEVEMENTS": "{{unlocked}} of {{total}} achievements",
"FILTER_HIDE_DUPLICATES": "Hide duplicates",
"EDITIONS": "{{count}} editions in your library" "EDITIONS": "{{count}} editions in your library"
}, },
"GAME_DETAIL": { "GAME_DETAIL": {
+1 -1
View File
@@ -116,7 +116,7 @@ describe('PileOfShameCard', () => {
render(<MemoryRouter><StatDrillProvider source="own"><PileOfShameCard summary={summary(2)} onShuffle={() => {}}/></StatDrillProvider></MemoryRouter>); render(<MemoryRouter><StatDrillProvider source="own"><PileOfShameCard summary={summary(2)} onShuffle={() => {}}/></StatDrillProvider></MemoryRouter>);
expect(screen.getAllByRole('link', {name: 'BACKLOG.STATUS.BACKLOG: 2'})[0].getAttribute('href')) 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'})); fireEvent.click(screen.getByRole('button', {name: 'DASHBOARD.NEVER_PLAYED:1'}));
expect(await screen.findByText('Untouched')).toBeTruthy(); expect(await screen.findByText('Untouched')).toBeTruthy();
+30
View File
@@ -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 (
<ul className="flex flex-wrap gap-1">
{platforms.map((p) => {
const label = platformLabel(p) ?? t(`LIBRARY.SOURCE.${p.source}`);
return (
<li key={`${p.source}:${p.platform ?? ''}`} title={label} aria-label={label}
className="rounded border px-1 text-[10px] leading-4 font-semibold text-muted-foreground">
{shortName(p)}
</li>
);
})}
</ul>
);
}
+44
View File
@@ -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(<PlatformIcons platforms={[{source: 'STEAM', platform: null}, {source: 'PSN', platform: 'PS5'}]}/>);
expect(screen.getAllByRole('listitem')).toHaveLength(2);
expect(screen.getByLabelText('Steam').textContent).toBe('ST');
expect(screen.getByLabelText('PS5').textContent).toBe('PS5');
});
it('namesAPlayStationTitleWithoutPlatformAfterItsSource', () => {
render(<PlatformIcons platforms={[{source: 'PSN', platform: null}]}/>);
expect(screen.getByLabelText('LIBRARY.SOURCE.PSN').textContent).toBe('PS');
});
it('showsAManualPlatformByName', () => {
render(<PlatformIcons platforms={[{source: 'MANUAL', platform: 'Switch'}]}/>);
expect(screen.getByLabelText('Switch').textContent).toBe('Switch');
});
it('aManualGameWithoutPlatformIsNotShownAsPlayStation', () => {
render(<PlatformIcons platforms={[{source: 'MANUAL', platform: null}]}/>);
expect(screen.getByLabelText('LIBRARY.SOURCE.MANUAL').textContent).not.toBe('PS');
});
it('rendersNothingWithoutPlatforms', () => {
const {container} = render(<PlatformIcons platforms={[]}/>);
expect(container.firstChild).toBeNull();
cleanup();
const missing = render(<PlatformIcons/>);
expect(missing.container.firstChild).toBeNull();
});
});
+10 -17
View File
@@ -23,6 +23,7 @@ import LibraryTabs from '../components/LibraryTabs.jsx';
import ComingBadge from '../components/ComingBadge.jsx'; import ComingBadge from '../components/ComingBadge.jsx';
import GameStatusBadge from '../components/GameStatusBadge.jsx'; import GameStatusBadge from '../components/GameStatusBadge.jsx';
import PageContainer from '../components/PageContainer.jsx'; import PageContainer from '../components/PageContainer.jsx';
import PlatformIcons from '../components/PlatformIcons.jsx';
import {usePreferences} from '../context/PreferencesContext.jsx'; import {usePreferences} from '../context/PreferencesContext.jsx';
import {coverEffect} from '../utils/rewards.js'; import {coverEffect} from '../utils/rewards.js';
import {apiFetch, request} from '../utils/api.js'; import {apiFetch, request} from '../utils/api.js';
@@ -58,15 +59,13 @@ export default function GameLibrary() {
const q = searchParams.get('q') ?? ''; const q = searchParams.get('q') ?? '';
const shortOnly = searchParams.get('maxMinutes') === String(SHORT_GAME_MINUTES); const shortOnly = searchParams.get('maxMinutes') === String(SHORT_GAME_MINUTES);
const hiddenOnly = searchParams.get('hidden') === 'true'; 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. // The hidden list stands alone, also for a hand-made URL: its status and short filters are ignored.
const listStatus = hiddenOnly ? null : status; const listStatus = hiddenOnly ? null : status;
const listShortOnly = !hiddenOnly && shortOnly; const listShortOnly = !hiddenOnly && shortOnly;
const listHideDuplicates = !hiddenOnly && hideDuplicates;
const sources = parseSources(searchParams.get('source')); const sources = parseSources(searchParams.get('source'));
const listSources = hiddenOnly ? [] : sources; const listSources = hiddenOnly ? [] : sources;
const sourceKey = listSources.join(','); 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 [search, setSearch] = useState(q);
const [adding, setAdding] = useState(false); 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 // 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(); const signal = nextSignal();
setIsLoading(true); setIsLoading(true);
setError(null); 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}); const {ok, status, data: json} = await request(url, {signal});
if (signal.aborted) return; if (signal.aborted) return;
setIsLoading(false); setIsLoading(false);
@@ -93,7 +92,7 @@ export default function GameLibrary() {
return; return;
} }
setData(json); setData(json);
}, [nextSignal, page, size, sort, listStatus, q, listShortOnly, hiddenOnly, listHideDuplicates, sourceKey]); }, [nextSignal, page, size, sort, listStatus, q, listShortOnly, hiddenOnly, sourceKey]);
useEffect(() => { useEffect(() => {
load(); load();
@@ -158,7 +157,7 @@ export default function GameLibrary() {
}; };
const onHiddenChange = () => { 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) => { const onSizeChange = (value) => {
@@ -261,15 +260,6 @@ export default function GameLibrary() {
> >
<Timer className="size-3"/>{t('LIBRARY.FILTER_SHORT')} <Timer className="size-3"/>{t('LIBRARY.FILTER_SHORT')}
</Button> </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 <Button
size="sm" size="sm"
variant={hiddenOnly ? 'default' : 'outline'} variant={hiddenOnly ? 'default' : 'outline'}
@@ -346,10 +336,13 @@ export default function GameLibrary() {
> >
<GameCover appid={g.appid} imageUrl={g.imageUrl} fx={coverEffect(equipped, g.status)} <GameCover appid={g.appid} imageUrl={g.imageUrl} fx={coverEffect(equipped, g.status)}
className="w-16 sm:w-24 shrink-0"/> className="w-16 sm:w-24 shrink-0"/>
<span className="@container min-w-0 flex-1 font-medium group-hover:text-primary transition-colors"> <span className="@container min-w-0 flex-1">
<span className="block font-medium group-hover:text-primary transition-colors">
<FitText text={g.name ?? `App ${g.appid}`}/> <FitText text={g.name ?? `App ${g.appid}`}/>
</span> </span>
{platformLabel(g) && ( <PlatformIcons platforms={g.platforms}/>
</span>
{!g.platforms?.length && platformLabel(g) && (
<Badge variant="outline" className="hidden sm:inline-flex" title={platformLabel(g)}> <Badge variant="outline" className="hidden sm:inline-flex" title={platformLabel(g)}>
<Gamepad2 className="size-3"/>{platformLabel(g)} <Gamepad2 className="size-3"/>{platformLabel(g)}
</Badge> </Badge>
+13 -6
View File
@@ -217,15 +217,22 @@ describe('GameLibrary short games', () => {
expect(screen.getAllByText(/^x\d/)).toHaveLength(1); expect(screen.getAllByText(/^x\d/)).toHaveLength(1);
}); });
it('hideDuplicatesFilter_AddsHideDuplicatesToTheUrlAndTheRequest', async () => { it('hasNoHideDuplicatesToggle_AndTheRequestNeverCarriesTheParam', async () => {
const lib = renderLibrary(); renderLibrary('/library?hideDuplicates=true');
await waitFor(() => expect(fetch).toHaveBeenCalled()); await waitFor(() => expect(fetch).toHaveBeenCalled());
fireEvent.click(screen.getByRole('button', {name: 'LIBRARY.FILTER_HIDE_DUPLICATES'})); expect(screen.queryByRole('button', {name: 'LIBRARY.FILTER_HIDE_DUPLICATES'})).toBeNull();
expect(fetch.mock.calls.every(([url]) => !String(url).includes('hideDuplicates'))).toBe(true);
});
await waitFor(() => expect(lib.params().get('hideDuplicates')).toBe('true')); it('showsThePlatformsOfAnEntryInsteadOfTheSinglePlatformBadge', async () => {
await waitFor(() => expect(fetch.mock.calls.at(-1)[0]).toMatch(/[?&]hideDuplicates=true(&|$)/)); answer([{appid: 220, name: 'Half-Life 2', playtimeMinutes: 0, status: 'BACKLOG', source: 'STEAM', platform: null,
expect(screen.getByRole('button', {name: 'LIBRARY.FILTER_HIDE_DUPLICATES'}).getAttribute('aria-pressed')).toBe('true'); platforms: [{source: 'STEAM', platform: null}, {source: 'PSN', platform: 'PS5'}]}], true);
renderLibrary();
expect(await screen.findByLabelText('PS5')).toBeTruthy();
expect(screen.getByLabelText('Steam')).toBeTruthy();
expect(screen.queryByText('Steam')).toBeNull();
}); });
it('leaves the hidden games with a status click', async () => { it('leaves the hidden games with a status click', async () => {
+4 -4
View File
@@ -116,7 +116,7 @@ describe('StatsPage', () => {
const link = await screen.findByRole('link', {name: /BACKLOG\.STATUS\.BACKLOG/}); const link = await screen.findByRole('link', {name: /BACKLOG\.STATUS\.BACKLOG/});
expect(link.getAttribute('href')).toBe('/library?hideDuplicates=true&status=BACKLOG'); expect(link.getAttribute('href')).toBe('/library?status=BACKLOG');
}); });
it('aZeroTileIsNotClickable', async () => { it('aZeroTileIsNotClickable', async () => {
@@ -178,7 +178,7 @@ describe('StatsPage', () => {
const link = await within(await screen.findByText('STATS.OVERVIEW.TITLE').then(overview)) const link = await within(await screen.findByText('STATS.OVERVIEW.TITLE').then(overview))
.findByRole('link', {name: 'STATS.OVERVIEW.GAMES: 12'}); .findByRole('link', {name: 'STATS.OVERVIEW.GAMES: 12'});
expect(link.getAttribute('href')).toBe('/library?hideDuplicates=true'); expect(link.getAttribute('href')).toBe('/library');
}); });
it('linksALegendRowToTheStatusLibrary', async () => { it('linksALegendRowToTheStatusLibrary', async () => {
@@ -188,7 +188,7 @@ describe('StatsPage', () => {
await screen.findByText('STATS.OVERVIEW.TITLE'); await screen.findByText('STATS.OVERVIEW.TITLE');
expect(within(overview()).getByRole('link', {name: /BACKLOG\.STATUS\.BACKLOG/}).getAttribute('href')) expect(within(overview()).getByRole('link', {name: /BACKLOG\.STATUS\.BACKLOG/}).getAttribute('href'))
.toBe('/library?hideDuplicates=true&status=BACKLOG'); .toBe('/library?status=BACKLOG');
}); });
it('leavesAStatusWithoutGamesWithoutSliceAndTarget', async () => { it('leavesAStatusWithoutGamesWithoutSliceAndTarget', async () => {
@@ -211,7 +211,7 @@ describe('StatsPage', () => {
fireEvent.click(paths()[1]); fireEvent.click(paths()[1]);
await waitFor(() => expect(router.state.location.pathname + router.state.location.search) await waitFor(() => expect(router.state.location.pathname + router.state.location.search)
.toBe('/library?hideDuplicates=true&status=PLAYING')); .toBe('/library?status=PLAYING'));
}); });
it('showsTheGamesTileInsteadOfTheDonutForAnEmptyLibrary', async () => { it('showsTheGamesTileInsteadOfTheDonutForAnEmptyLibrary', async () => {
+1 -2
View File
@@ -58,10 +58,9 @@ export const dismissFinishedHint = (appid, dismissed) =>
body: JSON.stringify({dismissed}), body: JSON.stringify({dismissed}),
}); });
export const libraryQuery = ({page, size, sort, status, q, maxMinutes, hidden, hideDuplicates, source}) => { export const libraryQuery = ({page, size, sort, status, q, maxMinutes, hidden, source}) => {
const params = new URLSearchParams({page: String(page), size: String(size), sort}); const params = new URLSearchParams({page: String(page), size: String(size), sort});
if (hidden) params.set('hidden', 'true'); if (hidden) params.set('hidden', 'true');
if (hideDuplicates) params.set('hideDuplicates', 'true');
if (status) params.set('status', status); if (status) params.set('status', status);
if (q?.trim()) params.set('q', q.trim()); if (q?.trim()) params.set('q', q.trim());
if (maxMinutes) params.set('maxMinutes', String(maxMinutes)); if (maxMinutes) params.set('maxMinutes', String(maxMinutes));
-8
View File
@@ -53,14 +53,6 @@ describe('backlogShare', () => {
}); });
}); });
describe('libraryQuery hideDuplicates', () => {
it('libraryQuery_SetsHideDuplicatesOnlyWhenAsked', () => {
const base = {page: 0, size: 20, sort: 'appid,asc', status: null, q: ''};
expect(libraryQuery({...base, hideDuplicates: true})).toBe('page=0&size=20&sort=appid%2Casc&hideDuplicates=true');
expect(libraryQuery({...base, hideDuplicates: false})).not.toContain('hideDuplicates');
});
});
describe('sources', () => { describe('sources', () => {
it('parseSources_KeepsKnownValuesInOrder', () => { it('parseSources_KeepsKnownValuesInOrder', () => {
expect(parseSources('PSN,FOO,STEAM,PSN')).toEqual(['STEAM', 'PSN']); expect(parseSources('PSN,FOO,STEAM,PSN')).toEqual(['STEAM', 'PSN']);
+1 -1
View File
@@ -45,6 +45,6 @@ export async function fetchStatGames(source, params, ticket) {
} }
} }
export const libraryLink = (status) => `/library?hideDuplicates=true${status ? `&status=${status}` : ''}`; export const libraryLink = (status) => `/library${status ? `?status=${status}` : ''}`;
export const gameHref = (appid) => `/library/${appid}`; export const gameHref = (appid) => `/library/${appid}`;
+2 -2
View File
@@ -21,8 +21,8 @@ describe('statGamesUrl', () => {
describe('libraryLink', () => { describe('libraryLink', () => {
it('addsTheStatusOnlyWhenGiven', () => { it('addsTheStatusOnlyWhenGiven', () => {
expect(libraryLink()).toBe('/library?hideDuplicates=true'); expect(libraryLink()).toBe('/library');
expect(libraryLink('BACKLOG')).toBe('/library?hideDuplicates=true&status=BACKLOG'); expect(libraryLink('BACKLOG')).toBe('/library?status=BACKLOG');
}); });
}); });