Public Access
feat(images): report broken covers and achievement icons
This commit is contained in:
1 parent
ca75e6a530
commit
42ed0afe51
9 files changed
+173
-9
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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'})}));
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user