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

+2
View File
@@ -33,6 +33,8 @@ To Be Played is split into three repositories:
a link to the game's speedrun.com page when it has verified runs, a hint when Steam shows recent playtime for a
game still in the backlog, and one to set the status to 100 % when
every achievement is unlocked.
- **Hide games**: a Steam game (e.g. a playtest or a tool) can be hidden on its detail page. It leaves the lists,
the dashboard, the stats and the public profile; the library's "Hidden" filter shows it again for unhiding.
- **Wishlist** with store price and discount.
- **Steam account** linked in the settings with "Sign in through Steam": the user signs in on Steam and Auth gets
the proved Steam ID, never a typed-in one. A Steam ID typed in before shows as "not verified" with a button to
+8
View File
@@ -185,6 +185,8 @@
"SORT_RATING": "Beste Bewertung",
"SORT_PRIORITY": "Höchste Priorität",
"FILTER_SHORT": "Kurz (bis 10 h)",
"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"
},
"GAME_DETAIL": {
@@ -195,6 +197,12 @@
"NOT_FOUND": "Spiel nicht in deiner Bibliothek.",
"PLAYTIME": "Spielzeit: {{time}}",
"OPEN_IN_STEAM": "Auf Steam ansehen",
"HIDE": "Spiel ausblenden",
"UNHIDE": "Spiel wieder anzeigen",
"HIDDEN_NOTE": "Ausgeblendet: Das Spiel fehlt in deinen Listen, Statistiken und im öffentlichen Profil. Nur seine Spielzeit wird weiter aktualisiert.",
"HIDDEN_TOAST": "Spiel ausgeblendet",
"SHOWN_TOAST": "Spiel wird wieder angezeigt",
"HIDE_ERROR": "Das Spiel konnte nicht geändert werden.",
"OPEN_IN_SPEEDRUN": "Speedruns auf speedrun.com",
"ACHIEVEMENTS": "Erfolge",
"UNLOCKED": "Erreicht",
+8
View File
@@ -185,6 +185,8 @@
"SORT_RATING": "Best rated",
"SORT_PRIORITY": "Highest priority",
"FILTER_SHORT": "Short (up to 10 h)",
"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"
},
"GAME_DETAIL": {
@@ -195,6 +197,12 @@
"NOT_FOUND": "Game not in your library.",
"PLAYTIME": "Playtime: {{time}}",
"OPEN_IN_STEAM": "View on Steam",
"HIDE": "Hide game",
"UNHIDE": "Show game again",
"HIDDEN_NOTE": "Hidden: this game is not in your library lists, stats or public profile. Only its playtime is still updated.",
"HIDDEN_TOAST": "Game hidden",
"SHOWN_TOAST": "Game shown again",
"HIDE_ERROR": "Could not change the game.",
"OPEN_IN_SPEEDRUN": "Speedruns on speedrun.com",
"ACHIEVEMENTS": "Achievements",
"UNLOCKED": "Unlocked",
+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}),
});