feat(images): report broken covers and achievement icons
Build, Test & Push Frontend / quality-check (push) Successful in 15s
Build, Test & Push Frontend / docker (push) Successful in 22s

This commit is contained in:
lobo committed 2026-09-26 12:07:47 +02:00
1 parent ca75e6a530
commit 42ed0afe51
9 files changed
+173 -9

No files matched your search

+5
View File
@@ -40,6 +40,9 @@ To Be Played is split into three repositories:
- **Public profile** at `/u/<display name>`, opt-in in the settings, with perfect games and average completion. - **Public profile** at `/u/<display name>`, 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. - **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. 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. - 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). - **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 - **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 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 `401`, the page calls `useSessionExpired`, which opens the login page. Public pages (landing page, public
profile) use plain `fetch`. 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. - `useLibrarySync` polls Core's `/api/library/sync-status` while a Steam sync runs, then loads the data again.
## Project structure ## Project structure
+1 -1
View File
@@ -54,7 +54,7 @@ export default function AchievementsCard({summary}) {
to={`/library/${game.appid}`} to={`/library/${game.appid}`}
className="group flex items-center gap-3 rounded-lg p-1 transition-colors hover:bg-muted/50" className="group flex items-center gap-3 rounded-lg p-1 transition-colors hover:bg-muted/50"
> >
<GameCover appid={game.appid} className="w-16 shrink-0"/> <GameCover appid={game.appid} imageUrl={game.imageUrl} className="w-16 shrink-0"/>
<div className="min-w-0 flex-1 space-y-1"> <div className="min-w-0 flex-1 space-y-1">
<div className="flex items-baseline justify-between gap-2"> <div className="flex items-baseline justify-between gap-2">
<span className="truncate text-sm font-medium group-hover:text-primary transition-colors"> <span className="truncate text-sm font-medium group-hover:text-primary transition-colors">
+1 -1
View File
@@ -65,7 +65,7 @@ export default function ActivityCard({activity}) {
{recentGames.map((g) => ( {recentGames.map((g) => (
<li key={g.appid}> <li key={g.appid}>
<Link to={`/library/${g.appid}`} className="group flex items-center gap-3"> <Link to={`/library/${g.appid}`} className="group flex items-center gap-3">
<GameCover appid={g.appid} className="w-16 shrink-0"/> <GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-16 shrink-0"/>
<span className="min-w-0 flex-1 truncate text-sm font-medium group-hover:text-primary transition-colors"> <span className="min-w-0 flex-1 truncate text-sm font-medium group-hover:text-primary transition-colors">
{g.name ?? `App ${g.appid}`} {g.name ?? `App ${g.appid}`}
</span> </span>
+11 -4
View File
@@ -3,14 +3,18 @@ import {Gamepad2} from 'lucide-react';
import {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
import {coverUrl} from '@/utils/games.js'; 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. * 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}) { 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 ( return (
<div className={cn('aspect-[460/215] overflow-hidden rounded-md bg-muted', className)}> <div className={cn('aspect-[460/215] overflow-hidden rounded-md bg-muted', className)}>
@@ -20,11 +24,14 @@ export default function GameCover({appid, imageUrl, className}) {
</div> </div>
) : ( ) : (
<img <img
src={imageUrl ?? coverUrl(appid)} src={src}
alt="" alt=""
loading="lazy" loading="lazy"
className="size-full object-cover" className="size-full object-cover"
onError={() => setFailed(true)} onError={() => {
reportBrokenImage(appid, COVER);
setFailedSrc(src);
}}
/> />
)} )}
</div> </div>
+41
View File
@@ -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(<GameCover appid={620} imageUrl="https://shared.akamai.steamstatic.com/x/header.jpg"/>);
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(<GameCover appid={620}/>);
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(<GameCover appid={620}/>);
fireEvent.error(container.querySelector('img'));
rerender(<GameCover appid={220}/>);
expect(container.querySelector('img').getAttribute('src')).toContain('/220/header.jpg');
fireEvent.error(container.querySelector('img'));
expect(reportBrokenImage).toHaveBeenCalledWith(220, 'COVER');
});
});
+14 -3
View File
@@ -21,6 +21,7 @@ import useSaveAppearance from '../hooks/useSaveAppearance.js';
import {usePreferences} from '../context/PreferencesContext.jsx'; import {usePreferences} from '../context/PreferencesContext.jsx';
import PageContainer from '../components/PageContainer.jsx'; import PageContainer from '../components/PageContainer.jsx';
import {apiFetch} from '../utils/api.js'; import {apiFetch} from '../utils/api.js';
import {ACHIEVEMENT_ICONS, reportBrokenImage} from '../utils/brokenImages.js';
import {hasSession} from '../utils/auth.js'; import {hasSession} from '../utils/auth.js';
import {isSteamKey} from '../utils/manualGames.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 <div className="size-12 rounded-md bg-muted"/>;
return (
<img src={src} alt="" className={cn('size-12 rounded-md', !unlocked && 'grayscale')}
onError={() => {
reportBrokenImage(appid, ACHIEVEMENT_ICONS);
setFailed(true);
}}/>
);
}
export default function GameDetail() { export default function GameDetail() {
const {t, i18n} = useTranslation(); const {t, i18n} = useTranslation();
const {appid} = useParams(); const {appid} = useParams();
@@ -261,9 +274,7 @@ export default function GameDetail() {
a.unlocked ? 'border-primary/40' : 'opacity-60', a.unlocked ? 'border-primary/40' : 'opacity-60',
)} )}
> >
{icon <AchievementIcon key={icon} appid={data.appid} src={icon} unlocked={a.unlocked}/>
? <img src={icon} alt="" className={cn('size-12 rounded-md', !a.unlocked && 'grayscale')}/>
: <div className="size-12 rounded-md bg-muted"/>}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate font-medium">{a.displayName ?? a.apiName}</div> <div className="truncate font-medium">{a.displayName ?? a.apiName}</div>
{a.description && ( {a.description && (
+22
View File
@@ -4,6 +4,7 @@ import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/reac
import {MemoryRouter, Route, Routes} from 'react-router-dom'; import {MemoryRouter, Route, Routes} from 'react-router-dom';
import GameDetail from './GameDetail.jsx'; import GameDetail from './GameDetail.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx'; import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import {resetBrokenImageReports} from '../utils/brokenImages.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}})); vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
@@ -32,9 +33,30 @@ beforeEach(() => {
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
localStorage.clear(); localStorage.clear();
resetBrokenImageReports();
}); });
describe('GameDetail', () => { 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 () => { it('links the speedrun.com page when Core found one', async () => {
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, globalThis.fetch = vi.fn(async () => ({ok: true, status: 200,
json: async () => ({...game, speedrunUrl: 'https://www.speedrun.com/hl2'})})); json: async () => ({...game, speedrunUrl: 'https://www.speedrun.com/hl2'})}));
+28
View File
@@ -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();
}
+50
View File
@@ -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();
});
});