feat(library): hide and unhide games
Build, Test & Push Frontend / quality-check (push) Successful in 16s
Build, Test & Push Frontend / docker (push) Successful in 22s

This commit is contained in:
lobo committed 2026-09-26 12:27:58 +02:00
1 parent 42ed0afe51
commit 011711fe4f
9 files changed
+180 -14

No files matched your search

+40 -2
View File
@@ -1,7 +1,8 @@
import {useEffect, useMemo, useState} from 'react';
import {Link, useNavigate, useParams} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {ArrowLeft, Clock, ExternalLink, Gamepad2, Image as ImageIcon, ImageOff, PanelTop, RotateCcw, ShieldOff, Timer, Trophy} from 'lucide-react';
import {ArrowLeft, Clock, ExternalLink, Eye, EyeOff, Gamepad2, Image as ImageIcon, ImageOff, PanelTop, RotateCcw, ShieldOff, Timer, Trophy} from 'lucide-react';
import {toast} from 'sonner';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
@@ -22,6 +23,7 @@ import {usePreferences} from '../context/PreferencesContext.jsx';
import PageContainer from '../components/PageContainer.jsx';
import {apiFetch} from '../utils/api.js';
import {ACHIEVEMENT_ICONS, reportBrokenImage} from '../utils/brokenImages.js';
import {setGameHidden} from '../utils/hiddenGames.js';
import {hasSession} from '../utils/auth.js';
import {isSteamKey} from '../utils/manualGames.js';
@@ -58,6 +60,7 @@ export default function GameDetail() {
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [hiding, setHiding] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
@@ -124,6 +127,29 @@ export default function GameDetail() {
const isBackground = !!data && appearance.backgroundAppid === data.appid;
const isBanner = !!data && appearance.showBanner && appearance.bannerAppid === data.appid;
const toggleHidden = async () => {
const hidden = !data.hidden;
setHiding(true);
try {
const response = await setGameHidden(data.appid, hidden);
if (response.status === 401) {
expireSession();
return;
}
if (!response.ok) {
toast.error(t('GAME_DETAIL.HIDE_ERROR'));
return;
}
setData((d) => ({...d, hidden}));
toast.success(t(hidden ? 'GAME_DETAIL.HIDDEN_TOAST' : 'GAME_DETAIL.SHOWN_TOAST'));
} catch (err) {
console.error('Hide game error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setHiding(false);
}
};
const stats = data ? [
[t('GAME_DETAIL.UNLOCKED'), data.achievementsUnlocked],
[t('GAME_DETAIL.REMAINING'), data.achievementsRemaining],
@@ -152,6 +178,11 @@ export default function GameDetail() {
{!isLoading && !error && data && (
<>
{data.hidden && (
<div role="status" className="flex items-center gap-2 rounded-lg border bg-muted/50 px-4 py-3 text-sm text-muted-foreground">
<EyeOff className="size-4 shrink-0"/>{t('GAME_DETAIL.HIDDEN_NOTE')}
</div>
)}
<Card className="relative overflow-hidden p-0 gap-0 md:flex-row">
<GameCover appid={data.appid} imageUrl={data.imageUrl} className="w-full rounded-none md:w-96 md:shrink-0"/>
<div className="flex flex-1 flex-col justify-between gap-4 p-6">
@@ -180,7 +211,8 @@ export default function GameDetail() {
</Button>
)}
</div>
{/* Store page, background and banner need Steam art, so a game off Steam has none of them. */}
{/* Store page, background and banner need Steam art, so a game off Steam has none of them.
Hide is here too: only a Steam game can be hidden. */}
{onSteam && <div className="flex flex-wrap gap-2">
<Button asChild variant="outline" size="sm">
<a href={`https://store.steampowered.com/app/${data.appid}`} target="_blank" rel="noopener noreferrer">
@@ -206,6 +238,12 @@ export default function GameDetail() {
<PanelTop/>{t('GAME_DETAIL.SET_BANNER')}
</Button>
)}
{!manual && (
<Button variant="outline" size="sm" onClick={toggleHidden} disabled={hiding}>
{data.hidden ? <Eye/> : <EyeOff/>}
{t(data.hidden ? 'GAME_DETAIL.UNHIDE' : 'GAME_DETAIL.HIDE')}
</Button>
)}
</div>}
</div>
</Card>
+41
View File
@@ -5,6 +5,7 @@ import {MemoryRouter, Route, Routes} from 'react-router-dom';
import GameDetail from './GameDetail.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import {resetBrokenImageReports} from '../utils/brokenImages.js';
import {toast} from 'sonner';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
@@ -37,6 +38,46 @@ afterEach(() => {
});
describe('GameDetail', () => {
it('hides a Steam game and shows it again', async () => {
globalThis.fetch = vi.fn(async (url, init) => init?.method === 'PUT'
? {ok: true, status: 200, json: async () => JSON.parse(init.body)}
: {ok: true, status: 200, json: async () => ({...game, source: 'STEAM', hidden: false})});
renderDetail();
fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.HIDE'}));
expect(await screen.findByText('GAME_DETAIL.HIDDEN_NOTE')).toBeTruthy();
const [url, init] = fetch.mock.calls.find(([, call]) => call?.method === 'PUT');
expect(url).toContain('/api/library/games/220/hidden');
expect(JSON.parse(init.body)).toEqual({hidden: true});
fireEvent.click(screen.getByRole('button', {name: 'GAME_DETAIL.UNHIDE'}));
await waitFor(() => expect(screen.queryByText('GAME_DETAIL.HIDDEN_NOTE')).toBeNull());
expect(screen.getByRole('button', {name: 'GAME_DETAIL.HIDE'})).toBeTruthy();
});
it('keeps the game shown when Core refuses', async () => {
globalThis.fetch = vi.fn(async (url, init) => init?.method === 'PUT'
? {ok: false, status: 409, json: async () => ({code: 'MANUAL_GAME'})}
: {ok: true, status: 200, json: async () => ({...game, source: 'STEAM', hidden: false})});
renderDetail();
fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.HIDE'}));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('GAME_DETAIL.HIDE_ERROR'));
expect(screen.queryByText('GAME_DETAIL.HIDDEN_NOTE')).toBeNull();
});
it('offers no hide for a game added by hand', async () => {
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200,
json: async () => ({...game, source: 'MANUAL', platform: 'PS5', hidden: false})}));
renderDetail();
await screen.findByText('GAME_DETAIL.OPEN_IN_STEAM');
expect(screen.queryByRole('button', {name: 'GAME_DETAIL.HIDE'})).toBeNull();
});
it('reports a broken achievement icon once and shows the empty tile', async () => {
const achievement = (apiName) => ({apiName, displayName: apiName, unlocked: false, iconUrl: `https://x/${apiName}.jpg`,
iconGrayUrl: `https://x/${apiName}_gray.jpg`});
+28 -11
View File
@@ -7,7 +7,7 @@ import useForceSync from '../hooks/useForceSync.js';
import useSessionExpired from '../hooks/useSessionExpired.js';
import {formatPlaytime} from '../utils/games.js';
import {GAME_STATUSES, libraryQuery, parseStatus} from '../utils/backlog.js';
import {ArrowUpDown, ChevronLeft, ChevronRight, Clock, Gamepad2, Library, Plus, RefreshCw, Search, SearchX, Timer, Trophy} from 'lucide-react';
import {ArrowUpDown, ChevronLeft, ChevronRight, Clock, EyeOff, Gamepad2, Library, Plus, RefreshCw, Search, SearchX, Timer, Trophy} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
@@ -52,7 +52,11 @@ export default function GameLibrary() {
const status = parseStatus(searchParams.get('status'));
const q = searchParams.get('q') ?? '';
const shortOnly = searchParams.get('maxMinutes') === String(SHORT_GAME_MINUTES);
const filtered = Boolean(status || q.trim() || shortOnly);
const hiddenOnly = searchParams.get('hidden') === '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 filtered = Boolean(listStatus || q.trim() || listShortOnly || hiddenOnly);
const librarySearch = searchParams.toString();
const [search, setSearch] = useState(q);
const [adding, setAdding] = useState(false);
@@ -71,7 +75,7 @@ export default function GameLibrary() {
setIsLoading(true);
setError(null);
try {
const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games?${libraryQuery({page, size, sort, status, q, maxMinutes: shortOnly ? SHORT_GAME_MINUTES : 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})}`;
const response = await apiFetch(url, {
headers: {
'Content-Type': 'application/json',
@@ -96,7 +100,7 @@ export default function GameLibrary() {
} finally {
setIsLoading(false);
}
}, [navigate, expireSession, page, size, sort, status, q, shortOnly, t]);
}, [navigate, expireSession, page, size, sort, listStatus, q, listShortOnly, hiddenOnly, t]);
useEffect(() => {
load();
@@ -143,7 +147,11 @@ export default function GameLibrary() {
}, [search, q, searchParams]);
const onStatusChange = (value) => {
updateParams({status: value, page: 0});
updateParams({status: value, hidden: null, page: 0});
};
const onHiddenChange = () => {
updateParams(hiddenOnly ? {hidden: null, page: 0} : {hidden: true, status: null, maxMinutes: null, page: 0});
};
const onSizeChange = (value) => {
@@ -222,8 +230,8 @@ export default function GameLibrary() {
<Button
key={s ?? 'ALL'}
size="sm"
variant={status === s ? 'default' : 'outline'}
aria-pressed={status === s}
variant={listStatus === s && !hiddenOnly ? 'default' : 'outline'}
aria-pressed={listStatus === s && !hiddenOnly}
className="h-7 rounded-full px-3 text-xs"
onClick={() => onStatusChange(s)}
>
@@ -232,13 +240,22 @@ export default function GameLibrary() {
))}
<Button
size="sm"
variant={shortOnly ? 'default' : 'outline'}
aria-pressed={shortOnly}
variant={listShortOnly ? 'default' : 'outline'}
aria-pressed={listShortOnly}
className="h-7 rounded-full px-3 text-xs"
onClick={() => updateParams({maxMinutes: shortOnly ? null : SHORT_GAME_MINUTES, page: 0})}
onClick={() => updateParams({maxMinutes: listShortOnly ? null : SHORT_GAME_MINUTES, hidden: null, page: 0})}
>
<Timer className="size-3"/>{t('LIBRARY.FILTER_SHORT')}
</Button>
<Button
size="sm"
variant={hiddenOnly ? 'default' : 'outline'}
aria-pressed={hiddenOnly}
className="h-7 rounded-full px-3 text-xs"
onClick={onHiddenChange}
>
<EyeOff className="size-3"/>{t('LIBRARY.FILTER_HIDDEN')}
</Button>
</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"/>
@@ -272,7 +289,7 @@ export default function GameLibrary() {
<Card className="items-center py-16 text-center text-muted-foreground">
{filtered ? <SearchX className="size-10"/> : <Library className="size-10"/>}
<p className="max-w-sm text-sm">
{filtered ? t('LIBRARY.NO_MATCH')
{hiddenOnly ? t('LIBRARY.NO_HIDDEN') : filtered ? t('LIBRARY.NO_MATCH')
: sync.isRunning ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')}
</p>
</Card>
+40
View File
@@ -126,6 +126,46 @@ describe('GameLibrary short games', () => {
await waitFor(() => expect(fetch.mock.calls.at(-1)[0]).toMatch(/[?&]maxMinutes=600(&|$)/));
expect(screen.getByRole('button', {name: 'LIBRARY.FILTER_SHORT'}).getAttribute('aria-pressed')).toBe('true');
});
it('shows only the hidden games and clears the other filters', async () => {
const lib = renderLibrary('/library?status=BACKLOG&maxMinutes=600');
await waitFor(() => expect(fetch).toHaveBeenCalled());
fireEvent.click(screen.getByRole('button', {name: 'LIBRARY.FILTER_HIDDEN'}));
await waitFor(() => expect(lib.params().get('hidden')).toBe('true'));
expect(lib.params().get('status')).toBeNull();
expect(lib.params().get('maxMinutes')).toBeNull();
await waitFor(() => expect(fetch.mock.calls.at(-1)[0]).toMatch(/[?&]hidden=true(&|$)/));
expect(fetch.mock.calls.at(-1)[0]).not.toMatch(/status=|maxMinutes=/);
expect(screen.getByRole('button', {name: 'LIBRARY.FILTER_ALL'}).getAttribute('aria-pressed')).toBe('false');
});
it('leaves the hidden games with a status click', async () => {
const lib = renderLibrary('/library?hidden=true');
await waitFor(() => expect(fetch).toHaveBeenCalled());
fireEvent.click(screen.getByRole('button', {name: 'BACKLOG.STATUS.PLAYING'}));
await waitFor(() => expect(lib.params().get('status')).toBe('PLAYING'));
expect(lib.params().get('hidden')).toBeNull();
await waitFor(() => expect(fetch.mock.calls.at(-1)[0]).not.toMatch(/hidden=/));
});
it('ignores a status in a hidden link', async () => {
renderLibrary('/library?hidden=true&status=BACKLOG');
const gamesUrl = () => fetch.mock.calls.map(([url]) => String(url)).find((url) => url.includes('/api/library/games?'));
await waitFor(() => expect(gamesUrl()).toMatch(/[?&]hidden=true(&|$)/));
expect(gamesUrl()).not.toMatch(/status=/);
});
it('says so when no game is hidden', async () => {
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({...page, content: [], totalElements: 0, totalPages: 0})}));
renderLibrary('/library?hidden=true');
expect(await screen.findByText('LIBRARY.NO_HIDDEN')).toBeTruthy();
});
});
describe('GameLibrary with games added by hand', () => {
+2 -1
View File
@@ -37,8 +37,9 @@ export const saveGameStatus = (appid, status) =>
body: JSON.stringify({status}),
});
export const libraryQuery = ({page, size, sort, status, q, maxMinutes}) => {
export const libraryQuery = ({page, size, sort, status, q, maxMinutes, hidden}) => {
const params = new URLSearchParams({page: String(page), size: String(size), sort});
if (hidden) params.set('hidden', 'true');
if (status) params.set('status', status);
if (q?.trim()) params.set('q', q.trim());
if (maxMinutes) params.set('maxMinutes', String(maxMinutes));
+11
View File
@@ -0,0 +1,11 @@
import {apiFetch} from './api.js';
/**
* PUT /api/library/games/{appid}/hidden. The caller handles a response that is not ok.
*/
export const setGameHidden = (appid, hidden) =>
apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}/hidden`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({hidden}),
});