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
+120 -48

No files matched your search

+1 -1
View File
@@ -116,7 +116,7 @@ describe('PileOfShameCard', () => {
render(<MemoryRouter><StatDrillProvider source="own"><PileOfShameCard summary={summary(2)} onShuffle={() => {}}/></StatDrillProvider></MemoryRouter>);
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();
+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();
});
});
+11 -18
View File
@@ -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() {
>
<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'}
@@ -346,10 +336,13 @@ export default function GameLibrary() {
>
<GameCover appid={g.appid} imageUrl={g.imageUrl} fx={coverEffect(equipped, g.status)}
className="w-16 sm:w-24 shrink-0"/>
<span className="@container min-w-0 flex-1 font-medium group-hover:text-primary transition-colors">
<FitText text={g.name ?? `App ${g.appid}`}/>
<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}`}/>
</span>
<PlatformIcons platforms={g.platforms}/>
</span>
{platformLabel(g) && (
{!g.platforms?.length && platformLabel(g) && (
<Badge variant="outline" className="hidden sm:inline-flex" title={platformLabel(g)}>
<Gamepad2 className="size-3"/>{platformLabel(g)}
</Badge>
+13 -6
View File
@@ -217,15 +217,22 @@ describe('GameLibrary short games', () => {
expect(screen.getAllByText(/^x\d/)).toHaveLength(1);
});
it('hideDuplicatesFilter_AddsHideDuplicatesToTheUrlAndTheRequest', async () => {
const lib = renderLibrary();
it('hasNoHideDuplicatesToggle_AndTheRequestNeverCarriesTheParam', async () => {
renderLibrary('/library?hideDuplicates=true');
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'));
await waitFor(() => expect(fetch.mock.calls.at(-1)[0]).toMatch(/[?&]hideDuplicates=true(&|$)/));
expect(screen.getByRole('button', {name: 'LIBRARY.FILTER_HIDE_DUPLICATES'}).getAttribute('aria-pressed')).toBe('true');
it('showsThePlatformsOfAnEntryInsteadOfTheSinglePlatformBadge', async () => {
answer([{appid: 220, name: 'Half-Life 2', playtimeMinutes: 0, status: 'BACKLOG', source: 'STEAM', platform: null,
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 () => {
+4 -4
View File
@@ -116,7 +116,7 @@ describe('StatsPage', () => {
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 () => {
@@ -178,7 +178,7 @@ describe('StatsPage', () => {
const link = await within(await screen.findByText('STATS.OVERVIEW.TITLE').then(overview))
.findByRole('link', {name: 'STATS.OVERVIEW.GAMES: 12'});
expect(link.getAttribute('href')).toBe('/library?hideDuplicates=true');
expect(link.getAttribute('href')).toBe('/library');
});
it('linksALegendRowToTheStatusLibrary', async () => {
@@ -188,7 +188,7 @@ describe('StatsPage', () => {
await screen.findByText('STATS.OVERVIEW.TITLE');
expect(within(overview()).getByRole('link', {name: /BACKLOG\.STATUS\.BACKLOG/}).getAttribute('href'))
.toBe('/library?hideDuplicates=true&status=BACKLOG');
.toBe('/library?status=BACKLOG');
});
it('leavesAStatusWithoutGamesWithoutSliceAndTarget', async () => {
@@ -211,7 +211,7 @@ describe('StatsPage', () => {
fireEvent.click(paths()[1]);
await waitFor(() => expect(router.state.location.pathname + router.state.location.search)
.toBe('/library?hideDuplicates=true&status=PLAYING'));
.toBe('/library?status=PLAYING'));
});
it('showsTheGamesTileInsteadOfTheDonutForAnEmptyLibrary', async () => {
+1 -2
View File
@@ -58,10 +58,9 @@ export const dismissFinishedHint = (appid, 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});
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));
-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', () => {
it('parseSources_KeepsKnownValuesInOrder', () => {
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}`;
+2 -2
View File
@@ -21,8 +21,8 @@ describe('statGamesUrl', () => {
describe('libraryLink', () => {
it('addsTheStatusOnlyWhenGiven', () => {
expect(libraryLink()).toBe('/library?hideDuplicates=true');
expect(libraryLink('BACKLOG')).toBe('/library?hideDuplicates=true&status=BACKLOG');
expect(libraryLink()).toBe('/library');
expect(libraryLink('BACKLOG')).toBe('/library?status=BACKLOG');
});
});