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.
- **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
+1 -1
View File
@@ -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"
>
<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="flex items-baseline justify-between gap-2">
<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) => (
<li key={g.appid}>
<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">
{g.name ?? `App ${g.appid}`}
</span>
+11 -4
View File
@@ -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 (
<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>
) : (
<img
src={imageUrl ?? coverUrl(appid)}
src={src}
alt=""
loading="lazy"
className="size-full object-cover"
onError={() => setFailed(true)}
onError={() => {
reportBrokenImage(appid, COVER);
setFailedSrc(src);
}}
/>
)}
</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 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 <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() {
const {t, i18n} = useTranslation();
const {appid} = useParams();
@@ -261,9 +274,7 @@ export default function GameDetail() {
a.unlocked ? 'border-primary/40' : 'opacity-60',
)}
>
{icon
? <img src={icon} alt="" className={cn('size-12 rounded-md', !a.unlocked && 'grayscale')}/>
: <div className="size-12 rounded-md bg-muted"/>}
<AchievementIcon key={icon} appid={data.appid} src={icon} unlocked={a.unlocked}/>
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{a.displayName ?? a.apiName}</div>
{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 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'})}));
+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();
});
});