Public Access
feat(library): hide and unhide games
This commit is contained in:
1 parent
42ed0afe51
commit
011711fe4f
9 files changed
+180
-14
No files matched your search
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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}),
|
||||
});
|
||||
Reference in new issue
Block a user