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 &&