From 011711fe4f7a349768304814bea44e6cfff5db8c Mon Sep 17 00:00:00 2001 From: lobo Date: Sat, 26 Sep 2026 12:27:58 +0200 Subject: [PATCH] feat(library): hide and unhide games --- README.md | 2 ++ public/i18n/de.json | 8 +++++++ public/i18n/en.json | 8 +++++++ src/pages/GameDetail.jsx | 42 ++++++++++++++++++++++++++++++++-- src/pages/GameDetail.test.jsx | 41 +++++++++++++++++++++++++++++++++ src/pages/GameLibrary.jsx | 39 ++++++++++++++++++++++--------- src/pages/GameLibrary.test.jsx | 40 ++++++++++++++++++++++++++++++++ src/utils/backlog.js | 3 ++- src/utils/hiddenGames.js | 11 +++++++++ 9 files changed, 180 insertions(+), 14 deletions(-) create mode 100644 src/utils/hiddenGames.js diff --git a/README.md b/README.md index 2cb9cb7..bf79571 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/public/i18n/de.json b/public/i18n/de.json index 9ce340d..4c127c2 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -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", diff --git a/public/i18n/en.json b/public/i18n/en.json index 787d8d8..778d43b 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -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", diff --git a/src/pages/GameDetail.jsx b/src/pages/GameDetail.jsx index d4b5492..0547d2b 100644 --- a/src/pages/GameDetail.jsx +++ b/src/pages/GameDetail.jsx @@ -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 && ( +
+ {t('GAME_DETAIL.HIDDEN_NOTE')} +
+ )}
@@ -180,7 +211,8 @@ export default function GameDetail() { )}
- {/* 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 &&
)} + {!manual && ( + + )}
}
diff --git a/src/pages/GameDetail.test.jsx b/src/pages/GameDetail.test.jsx index 86275b4..d665aa0 100644 --- a/src/pages/GameDetail.test.jsx +++ b/src/pages/GameDetail.test.jsx @@ -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`}); diff --git a/src/pages/GameLibrary.jsx b/src/pages/GameLibrary.jsx index aec95b4..1731134 100644 --- a/src/pages/GameLibrary.jsx +++ b/src/pages/GameLibrary.jsx @@ -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() { +
@@ -272,7 +289,7 @@ export default function GameLibrary() { {filtered ? : }

- {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')}

diff --git a/src/pages/GameLibrary.test.jsx b/src/pages/GameLibrary.test.jsx index 624e5fb..5c01e64 100644 --- a/src/pages/GameLibrary.test.jsx +++ b/src/pages/GameLibrary.test.jsx @@ -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', () => { diff --git a/src/utils/backlog.js b/src/utils/backlog.js index 5c1a956..23ee2f6 100644 --- a/src/utils/backlog.js +++ b/src/utils/backlog.js @@ -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)); diff --git a/src/utils/hiddenGames.js b/src/utils/hiddenGames.js new file mode 100644 index 0000000..a2b6e72 --- /dev/null +++ b/src/utils/hiddenGames.js @@ -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}), + });