From 42ed0afe516ade6d20302230ec122a501d5615b6 Mon Sep 17 00:00:00 2001 From: lobo Date: Sat, 26 Sep 2026 12:07:47 +0200 Subject: [PATCH] feat(images): report broken covers and achievement icons --- README.md | 5 +++ src/components/AchievementsCard.jsx | 2 +- src/components/ActivityCard.jsx | 2 +- src/components/GameCover.jsx | 15 ++++++--- src/components/GameCover.test.jsx | 41 +++++++++++++++++++++++ src/pages/GameDetail.jsx | 17 ++++++++-- src/pages/GameDetail.test.jsx | 22 +++++++++++++ src/utils/brokenImages.js | 28 ++++++++++++++++ src/utils/brokenImages.test.js | 50 +++++++++++++++++++++++++++++ 9 files changed, 173 insertions(+), 9 deletions(-) create mode 100644 src/components/GameCover.test.jsx create mode 100644 src/utils/brokenImages.js create mode 100644 src/utils/brokenImages.test.js diff --git a/README.md b/README.md index 0ec1153..2cb9cb7 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,9 @@ To Be Played is split into three repositories: - **Public profile** at `/u/`, opt-in in the settings, with perfect games and average completion. - **Appearance**: accent color, corner style, wide layout, page background and banner from your own game art. Saved in Auth, so it follows you to every device. +- **Broken pictures**: a Steam cover or achievement icon that does not load shows a placeholder and is reported + to Core once per page load (`POST /api/library/broken-images`, only with a session). Core repairs it at night; + a repaired cover comes back as `imageUrl`. - German and English, dark mode, mobile layout, session-expired message instead of a silent logout. - **Installable** as an app on phone and desktop (web app manifest, no service worker, so no offline mode). - **Session**: the tokens are HttpOnly cookies that no script can read. `apiFetch` renews an expired access token @@ -83,6 +86,8 @@ flowchart LR Parallel requests and other tabs share this refresh (`navigator.locks`). When the second answer is still a `401`, the page calls `useSessionExpired`, which opens the login page. Public pages (landing page, public profile) use plain `fetch`. +- `src/utils/brokenImages.js` reports pictures that fail to load (`GameCover`, achievement icons on the game + detail page) to Core. - `useLibrarySync` polls Core's `/api/library/sync-status` while a Steam sync runs, then loads the data again. ## Project structure diff --git a/src/components/AchievementsCard.jsx b/src/components/AchievementsCard.jsx index b05782d..a5233db 100644 --- a/src/components/AchievementsCard.jsx +++ b/src/components/AchievementsCard.jsx @@ -54,7 +54,7 @@ export default function AchievementsCard({summary}) { to={`/library/${game.appid}`} className="group flex items-center gap-3 rounded-lg p-1 transition-colors hover:bg-muted/50" > - +
diff --git a/src/components/ActivityCard.jsx b/src/components/ActivityCard.jsx index e4a4603..d5c3125 100644 --- a/src/components/ActivityCard.jsx +++ b/src/components/ActivityCard.jsx @@ -65,7 +65,7 @@ export default function ActivityCard({activity}) { {recentGames.map((g) => (
  • - + {g.name ?? `App ${g.appid}`} diff --git a/src/components/GameCover.jsx b/src/components/GameCover.jsx index eaf832b..70fd8f1 100644 --- a/src/components/GameCover.jsx +++ b/src/components/GameCover.jsx @@ -3,14 +3,18 @@ import {Gamepad2} from 'lucide-react'; import {cn} from '@/lib/utils'; import {coverUrl} from '@/utils/games.js'; +import {COVER, reportBrokenImage} from '@/utils/brokenImages.js'; /** * Steam store header (460x215). Falls back to a neutral tile when the app has no header image. * - * @param imageUrl picture for a game that has no Steam art (added by hand from IGDB); wins over the Steam header + * @param imageUrl a game added by hand from IGDB, or a Steam header Core repaired; wins over the fixed Steam header */ export default function GameCover({appid, imageUrl, className}) { - const [failed, setFailed] = useState(false); + const src = imageUrl ?? coverUrl(appid); + // Per URL: a reused cover (e.g. the next suggestion) loads and reports its own picture. + const [failedSrc, setFailedSrc] = useState(null); + const failed = failedSrc === src; return (
    @@ -20,11 +24,14 @@ export default function GameCover({appid, imageUrl, className}) {
    ) : ( setFailed(true)} + onError={() => { + reportBrokenImage(appid, COVER); + setFailedSrc(src); + }} /> )}
  • diff --git a/src/components/GameCover.test.jsx b/src/components/GameCover.test.jsx new file mode 100644 index 0000000..6082891 --- /dev/null +++ b/src/components/GameCover.test.jsx @@ -0,0 +1,41 @@ +// @vitest-environment jsdom +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {cleanup, fireEvent, render} from '@testing-library/react'; +import GameCover from './GameCover.jsx'; +import {reportBrokenImage} from '../utils/brokenImages.js'; + +vi.mock('../utils/brokenImages.js', () => ({COVER: 'COVER', reportBrokenImage: vi.fn()})); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe('GameCover', () => { + it('prefers the stored picture over the Steam header', () => { + const {container} = render(); + + expect(container.querySelector('img').getAttribute('src')).toBe('https://shared.akamai.steamstatic.com/x/header.jpg'); + }); + + it('reports a picture that does not load and shows the placeholder', () => { + const {container} = render(); + + fireEvent.error(container.querySelector('img')); + + expect(reportBrokenImage).toHaveBeenCalledWith(620, 'COVER'); + expect(container.querySelector('img')).toBeNull(); + expect(container.querySelector('svg')).toBeTruthy(); + }); + + it('loads the picture of the next game after a failed one', () => { + const {container, rerender} = render(); + fireEvent.error(container.querySelector('img')); + + rerender(); + + expect(container.querySelector('img').getAttribute('src')).toContain('/220/header.jpg'); + fireEvent.error(container.querySelector('img')); + expect(reportBrokenImage).toHaveBeenCalledWith(220, 'COVER'); + }); +}); diff --git a/src/pages/GameDetail.jsx b/src/pages/GameDetail.jsx index 5773ba8..d4b5492 100644 --- a/src/pages/GameDetail.jsx +++ b/src/pages/GameDetail.jsx @@ -21,6 +21,7 @@ import useSaveAppearance from '../hooks/useSaveAppearance.js'; 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 {hasSession} from '../utils/auth.js'; import {isSteamKey} from '../utils/manualGames.js'; @@ -35,6 +36,18 @@ const formatUnlockedAt = (instant, locale) => { } }; +function AchievementIcon({appid, src, unlocked}) { + const [failed, setFailed] = useState(false); + if (!src || failed) return
    ; + return ( + { + reportBrokenImage(appid, ACHIEVEMENT_ICONS); + setFailed(true); + }}/> + ); +} + export default function GameDetail() { const {t, i18n} = useTranslation(); const {appid} = useParams(); @@ -261,9 +274,7 @@ export default function GameDetail() { a.unlocked ? 'border-primary/40' : 'opacity-60', )} > - {icon - ? - :
    } +
    {a.displayName ?? a.apiName}
    {a.description && ( diff --git a/src/pages/GameDetail.test.jsx b/src/pages/GameDetail.test.jsx index 7a4603d..86275b4 100644 --- a/src/pages/GameDetail.test.jsx +++ b/src/pages/GameDetail.test.jsx @@ -4,6 +4,7 @@ import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/reac 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'; vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}})); @@ -32,9 +33,30 @@ beforeEach(() => { afterEach(() => { cleanup(); localStorage.clear(); + resetBrokenImageReports(); }); describe('GameDetail', () => { + 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`}); + globalThis.fetch = vi.fn(async (url, init) => init?.method === 'POST' + ? {ok: true, status: 204} + : {ok: true, status: 200, json: async () => ({...game, hasAchievements: true, statsVisible: true, + achievementsTotal: 2, achievementsUnlocked: 0, completionPercent: 0, + achievements: [achievement('A'), achievement('B')]})}); + const {container} = renderDetail(); + + await screen.findByText('A'); + container.querySelectorAll('img[src^="https://x/"]').forEach((img) => fireEvent.error(img)); + + const reports = fetch.mock.calls.filter(([, init]) => init?.method === 'POST'); + expect(reports).toHaveLength(1); + expect(reports[0][0]).toContain('/api/library/broken-images'); + expect(JSON.parse(reports[0][1].body)).toEqual({appid: 220, kind: 'ACHIEVEMENT_ICONS'}); + expect(container.querySelector('img[src^="https://x/"]')).toBeNull(); + }); + it('links the speedrun.com page when Core found one', async () => { globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({...game, speedrunUrl: 'https://www.speedrun.com/hl2'})})); diff --git a/src/utils/brokenImages.js b/src/utils/brokenImages.js new file mode 100644 index 0000000..22b1c00 --- /dev/null +++ b/src/utils/brokenImages.js @@ -0,0 +1,28 @@ +import {apiFetch} from './api.js'; +import {hasSession} from './auth.js'; + +export const COVER = 'COVER'; +export const ACHIEVEMENT_ICONS = 'ACHIEVEMENT_ICONS'; + +const reported = new Set(); + +/** + * Tells Core that a Steam picture did not load, for its nightly repair. Once per game and kind per page load, only + * with a session. Errors are ignored: the placeholder already shows. + */ +export function reportBrokenImage(appid, kind) { + if (!Number.isInteger(appid) || appid <= 0 || !hasSession()) return; + const key = `${kind}:${appid}`; + if (reported.has(key)) return; + reported.add(key); + apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/broken-images`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({appid, kind}), + }).catch(() => {}); +} + +// For tests only. +export function resetBrokenImageReports() { + reported.clear(); +} diff --git a/src/utils/brokenImages.test.js b/src/utils/brokenImages.test.js new file mode 100644 index 0000000..b839304 --- /dev/null +++ b/src/utils/brokenImages.test.js @@ -0,0 +1,50 @@ +// @vitest-environment jsdom +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {ACHIEVEMENT_ICONS, COVER, reportBrokenImage, resetBrokenImageReports} from './brokenImages.js'; + +beforeEach(() => { + localStorage.setItem('tbp_session', '1'); + globalThis.fetch = vi.fn(async () => ({ok: true, status: 204})); +}); + +afterEach(() => { + localStorage.clear(); + resetBrokenImageReports(); +}); + +describe('reportBrokenImage', () => { + it('posts the game and kind once per page load', () => { + reportBrokenImage(620, COVER); + reportBrokenImage(620, COVER); + reportBrokenImage(620, ACHIEVEMENT_ICONS); + + expect(fetch).toHaveBeenCalledTimes(2); + const [url, init] = fetch.mock.calls[0]; + expect(url).toContain('/api/library/broken-images'); + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body)).toEqual({appid: 620, kind: 'COVER'}); + }); + + it('sends nothing without a session', () => { + localStorage.clear(); + + reportBrokenImage(620, COVER); + + expect(fetch).not.toHaveBeenCalled(); + }); + + it.each([-1234, 0, undefined, '620'])('sends nothing for the key %s', (appid) => { + reportBrokenImage(appid, COVER); + + expect(fetch).not.toHaveBeenCalled(); + }); + + it('ignores a failed request', async () => { + globalThis.fetch = vi.fn(async () => { + throw new TypeError('network'); + }); + + expect(() => reportBrokenImage(620, COVER)).not.toThrow(); + await Promise.resolve(); + }); +});