feat(audit): game catalog search and time to beat correction

New page /audit/games: search every game and open the correction dialog
without owning it. Time to beat as hours + minutes. Audit log game links
open /audit/games?appid=. Menu entry next to the link queue.
This commit is contained in:
lobo committed 2026-10-02 11:21:15 +02:00
1 parent 6627a8db66
commit 0c300c455d
14 files changed
+439 -19

No files matched your search

+5 -2
View File
@@ -129,11 +129,14 @@ To Be Played is split into three repositories:
`/me` and the library summary. "Let's go" and "Skip" stamp it in Auth and open the dashboard. The settings page `/me` and the library summary. "Let's go" and "Skip" stamp it in Auth and open the dashboard. The settings page
links to it again. links to it again.
- **Correct game data** (AUDITOR or higher): "Correct data" on the game detail page changes name, header image, - **Correct game data** (AUDITOR or higher): "Correct data" on the game detail page changes name, header image,
speedrun link, moods, endless flag and genres for every user; each field goes back to the source on its own. speedrun link, moods, endless flag, genres and time to beat (hours and minutes) for every user; each field goes back to the source on its own.
"Merge duplicate" joins two entries of the same game (e.g. the Steam game and the same game added by hand) for "Merge duplicate" joins two entries of the same game (e.g. the Steam game and the same game added by hand) for
every user. every user.
- **Game catalog** (`/audit/games`, AUDITOR or higher): search every game any user has by name (cover, platforms,
key), paged; "Correct" opens the correction dialog, also for a game the auditor does not own. The audit log links
its games here.
- **Link queue** (`/audit/links`, AUDITOR or higher): the pairs IGDB suggests and users asked for, with "requested by N - **Link queue** (`/audit/links`, AUDITOR or higher): the pairs IGDB suggests and users asked for, with "requested by N
players"; Link or Dismiss; paged, 20 pairs a page. **Audit log** (`/audit/log`): the newest merges, links, unlinks and dismissals with who players"; Link or Dismiss; paged, 20 pairs a page. **Audit log** (`/audit/log`): the newest merges, links, unlinks, dismissals, corrections and resets with who
did it, filter by action, paged. did it, filter by action, paged.
- **Delete my account** in the settings: a dialog asks for the own email and the password. Auth blocks the - **Delete my account** in the settings: a dialog asks for the own email and the password. Auth blocks the
account at once and removes it after 30 days; the page ends the session and goes to the landing page. account at once and removes it after 30 days; the page ends the session and goes to the landing page.
+20 -3
View File
@@ -349,7 +349,8 @@
"PENDING_REQUESTS_one": "{{count}} Registrierung wartet", "PENDING_REQUESTS_one": "{{count}} Registrierung wartet",
"PENDING_REQUESTS_other": "{{count}} Registrierungen warten", "PENDING_REQUESTS_other": "{{count}} Registrierungen warten",
"LINK_QUEUE": "Verknüpfungen", "LINK_QUEUE": "Verknüpfungen",
"AUDIT_LOG": "Protokoll" "AUDIT_LOG": "Protokoll",
"GAME_CATALOG": "Spielekatalog"
}, },
"BACKLOG": { "BACKLOG": {
"TITLE": "Mein Backlog", "TITLE": "Mein Backlog",
@@ -626,6 +627,9 @@
"NO_ACCESS": "Nur Auditoren können Spieldaten korrigieren.", "NO_ACCESS": "Nur Auditoren können Spieldaten korrigieren.",
"ERROR": "Die Korrektur konnte nicht gespeichert werden.", "ERROR": "Die Korrektur konnte nicht gespeichert werden.",
"IMAGE_HINT": "https://… (leer: das Steam-Headerbild)", "IMAGE_HINT": "https://… (leer: das Steam-Headerbild)",
"HOURS": "Stunden",
"MINUTES": "Minuten",
"NEGATIVE": "Nicht gespeichert: Stunden und Minuten dürfen nicht negativ sein.",
"SPEEDRUN_HINT": "https://www.speedrun.com/… (leer: kein Speedrun)", "SPEEDRUN_HINT": "https://www.speedrun.com/… (leer: kein Speedrun)",
"FIELD": { "FIELD": {
"NAME": "Name", "NAME": "Name",
@@ -633,7 +637,8 @@
"SPEEDRUN": "Speedrun-Link", "SPEEDRUN": "Speedrun-Link",
"MOODS": "Stimmungen", "MOODS": "Stimmungen",
"ENDLESS": "Endloses Spiel", "ENDLESS": "Endloses Spiel",
"GENRES": "Genres" "GENRES": "Genres",
"TIME_TO_BEAT": "Spielzeit"
}, },
"ENDLESS": { "ENDLESS": {
"UNKNOWN": "Unbekannt", "UNKNOWN": "Unbekannt",
@@ -689,6 +694,16 @@
"GAME_NOT_FOUND": "Dieses Spiel ist nicht in deiner Bibliothek." "GAME_NOT_FOUND": "Dieses Spiel ist nicht in deiner Bibliothek."
} }
}, },
"CATALOG": {
"TITLE": "Spielekatalog",
"SEARCH": "Nach Name suchen",
"EMPTY": "Keine Spiele gefunden.",
"NO_ACCESS": "Nur Auditoren können diese Seite öffnen.",
"LOAD_ERROR": "Der Spielekatalog konnte nicht geladen werden.",
"CORRECT": "Korrigieren",
"KEY": "Schlüssel {{appid}}",
"GONE": "Dieses Spiel gibt es nicht mehr."
},
"AUDIT": { "AUDIT": {
"TITLE": "Protokoll", "TITLE": "Protokoll",
"EMPTY": "Keine Einträge.", "EMPTY": "Keine Einträge.",
@@ -702,7 +717,9 @@
"GAME_MERGE": "Zusammengeführt", "GAME_MERGE": "Zusammengeführt",
"GAME_LINK": "Verknüpft", "GAME_LINK": "Verknüpft",
"GAME_UNLINK": "Verknüpfung gelöst", "GAME_UNLINK": "Verknüpfung gelöst",
"GAME_LINK_DISMISS": "Verworfen" "GAME_LINK_DISMISS": "Verworfen",
"GAME_CORRECT": "Korrigiert",
"GAME_CORRECTION_RESET": "Zurück zur Quelle"
} }
}, },
"HINT": { "HINT": {
+20 -3
View File
@@ -349,7 +349,8 @@
"PENDING_REQUESTS_one": "{{count}} registration waits", "PENDING_REQUESTS_one": "{{count}} registration waits",
"PENDING_REQUESTS_other": "{{count}} registrations wait", "PENDING_REQUESTS_other": "{{count}} registrations wait",
"LINK_QUEUE": "Link queue", "LINK_QUEUE": "Link queue",
"AUDIT_LOG": "Audit log" "AUDIT_LOG": "Audit log",
"GAME_CATALOG": "Game catalog"
}, },
"BACKLOG": { "BACKLOG": {
"TITLE": "My backlog", "TITLE": "My backlog",
@@ -626,6 +627,9 @@
"NO_ACCESS": "Only auditors can correct game data.", "NO_ACCESS": "Only auditors can correct game data.",
"ERROR": "The correction could not be saved.", "ERROR": "The correction could not be saved.",
"IMAGE_HINT": "https://… (empty: the Steam header)", "IMAGE_HINT": "https://… (empty: the Steam header)",
"HOURS": "hours",
"MINUTES": "minutes",
"NEGATIVE": "Not saved: hours and minutes cannot be negative.",
"SPEEDRUN_HINT": "https://www.speedrun.com/… (empty: no speedrun)", "SPEEDRUN_HINT": "https://www.speedrun.com/… (empty: no speedrun)",
"FIELD": { "FIELD": {
"NAME": "Name", "NAME": "Name",
@@ -633,7 +637,8 @@
"SPEEDRUN": "Speedrun link", "SPEEDRUN": "Speedrun link",
"MOODS": "Moods", "MOODS": "Moods",
"ENDLESS": "Endless game", "ENDLESS": "Endless game",
"GENRES": "Genres" "GENRES": "Genres",
"TIME_TO_BEAT": "Time to beat"
}, },
"ENDLESS": { "ENDLESS": {
"UNKNOWN": "Unknown", "UNKNOWN": "Unknown",
@@ -689,6 +694,16 @@
"GAME_NOT_FOUND": "This game is not in your library." "GAME_NOT_FOUND": "This game is not in your library."
} }
}, },
"CATALOG": {
"TITLE": "Game catalog",
"SEARCH": "Search by name",
"EMPTY": "No games found.",
"NO_ACCESS": "Only auditors can open this page.",
"LOAD_ERROR": "The game catalog could not be loaded.",
"CORRECT": "Correct",
"KEY": "Key {{appid}}",
"GONE": "This game no longer exists."
},
"AUDIT": { "AUDIT": {
"TITLE": "Audit log", "TITLE": "Audit log",
"EMPTY": "No entries.", "EMPTY": "No entries.",
@@ -702,7 +717,9 @@
"GAME_MERGE": "Merge", "GAME_MERGE": "Merge",
"GAME_LINK": "Link", "GAME_LINK": "Link",
"GAME_UNLINK": "Unlink", "GAME_UNLINK": "Unlink",
"GAME_LINK_DISMISS": "Dismissed" "GAME_LINK_DISMISS": "Dismissed",
"GAME_CORRECT": "Corrected",
"GAME_CORRECTION_RESET": "Back to source"
} }
}, },
"HINT": { "HINT": {
+2
View File
@@ -18,6 +18,7 @@ import WelcomePage from './pages/WelcomePage.jsx';
import WelcomeGate from './components/WelcomeGate.jsx'; import WelcomeGate from './components/WelcomeGate.jsx';
import AdminUsersPage from './pages/AdminUsersPage.jsx'; import AdminUsersPage from './pages/AdminUsersPage.jsx';
import AuditLogPage from './pages/AuditLogPage.jsx'; import AuditLogPage from './pages/AuditLogPage.jsx';
import GameCatalogPage from './pages/GameCatalogPage.jsx';
import LinkQueuePage from './pages/LinkQueuePage.jsx'; import LinkQueuePage from './pages/LinkQueuePage.jsx';
import GameLibrary from './pages/GameLibrary.jsx'; import GameLibrary from './pages/GameLibrary.jsx';
import GameDetail from './pages/GameDetail.jsx'; import GameDetail from './pages/GameDetail.jsx';
@@ -89,6 +90,7 @@ function App() {
<Route path="/settings" element={<SettingsPage/>}/> <Route path="/settings" element={<SettingsPage/>}/>
<Route path="/whats-new" element={<WhatsNewPage/>}/> <Route path="/whats-new" element={<WhatsNewPage/>}/>
<Route path="/welcome" element={<WelcomePage/>}/> <Route path="/welcome" element={<WelcomePage/>}/>
<Route path="/audit/games" element={<GameCatalogPage/>}/>
<Route path="/audit/links" element={<LinkQueuePage/>}/> <Route path="/audit/links" element={<LinkQueuePage/>}/>
<Route path="/audit/log" element={<AuditLogPage/>}/> <Route path="/audit/log" element={<AuditLogPage/>}/>
<Route path="/admin/users" element={<AdminUsersPage/>}/> <Route path="/admin/users" element={<AdminUsersPage/>}/>
+4 -1
View File
@@ -1,7 +1,7 @@
import {useEffect, useState} from 'react'; import {useEffect, useState} from 'react';
import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom'; import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {ChartColumn, Coins, Dices, Eye, Languages, LayoutDashboard, Library, Link2, LogOut, Mail, Megaphone, Moon, Newspaper, ScrollText, Settings, Shield, Sun} from 'lucide-react'; import {ChartColumn, Coins, Dices, Eye, Languages, LayoutDashboard, Library, Link2, LogOut, Mail, Megaphone, Moon, Newspaper, ScrollText, Search, Settings, Shield, Sun} from 'lucide-react';
import {toast} from 'sonner'; import {toast} from 'sonner';
import {Badge} from '@/components/ui/badge'; import {Badge} from '@/components/ui/badge';
@@ -158,6 +158,9 @@ export default function AppHeader({isAuthenticated, onLogout}) {
</DropdownMenuItem> </DropdownMenuItem>
{canCorrect(currentUser) && ( {canCorrect(currentUser) && (
<> <>
<DropdownMenuItem asChild>
<Link to="/audit/games"><Search/>{t('NAV.GAME_CATALOG')}</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link to="/audit/links"><Link2/>{t('NAV.LINK_QUEUE')}</Link> <Link to="/audit/links"><Link2/>{t('NAV.LINK_QUEUE')}</Link>
</DropdownMenuItem> </DropdownMenuItem>
+4 -2
View File
@@ -88,12 +88,13 @@ describe('AppHeader', () => {
expect(screen.queryByText('NAV.ADMIN_BROADCASTS')).toBeNull(); expect(screen.queryByText('NAV.ADMIN_BROADCASTS')).toBeNull();
}); });
it('offersTheLinkQueueAndAuditLogToAnAuditorOnly', async () => { it('offersTheCatalogLinkQueueAndAuditLogToAnAuditorOnly', async () => {
loggedInAs({role: 'AUDITOR', activeRole: 'AUDITOR'}); loggedInAs({role: 'AUDITOR', activeRole: 'AUDITOR'});
await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/users/me'), expect.anything())); await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/users/me'), expect.anything()));
openMenu(); openMenu();
expect(await screen.findByText('NAV.LINK_QUEUE')).toBeTruthy(); expect(await screen.findByText('NAV.GAME_CATALOG')).toBeTruthy();
expect(screen.getByText('NAV.LINK_QUEUE')).toBeTruthy();
expect(screen.getByText('NAV.AUDIT_LOG')).toBeTruthy(); expect(screen.getByText('NAV.AUDIT_LOG')).toBeTruthy();
expect(screen.queryByText('NAV.ADMIN')).toBeNull(); expect(screen.queryByText('NAV.ADMIN')).toBeNull();
}); });
@@ -108,6 +109,7 @@ describe('AppHeader', () => {
expect(screen.getByText('NAV.WHATS_NEW')).toBeTruthy(); expect(screen.getByText('NAV.WHATS_NEW')).toBeTruthy();
expect(screen.queryByText('NAV.ADMIN')).toBeNull(); expect(screen.queryByText('NAV.ADMIN')).toBeNull();
expect(screen.queryByText('NAV.LINK_QUEUE')).toBeNull(); expect(screen.queryByText('NAV.LINK_QUEUE')).toBeNull();
expect(screen.queryByText('NAV.GAME_CATALOG')).toBeNull();
expect(screen.queryByText('NAV.ADMIN_NEWS')).toBeNull(); expect(screen.queryByText('NAV.ADMIN_NEWS')).toBeNull();
expect(screen.queryByText('NAV.ADMIN_BROADCASTS')).toBeNull(); expect(screen.queryByText('NAV.ADMIN_BROADCASTS')).toBeNull();
expect(screen.queryByText('NAV.VIEW_AS.MENU')).toBeNull(); expect(screen.queryByText('NAV.VIEW_AS.MENU')).toBeNull();
+52 -3
View File
@@ -10,12 +10,19 @@ import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label'; import {Label} from '@/components/ui/label';
import {CORRECTABLE_MOODS, IGDB_GENRES, resetCorrection, saveCorrection} from '../utils/corrections.js'; import {CORRECTABLE_MOODS, IGDB_GENRES, resetCorrection, saveCorrection} from '../utils/corrections.js';
import {genreKey} from '../utils/stats.js'; import {genreKey} from '../utils/stats.js';
import {MAX_CORRECTION_GENRES, MAX_CORRECTION_LENGTH} from '../utils/limits.js'; import {MAX_CORRECTION_GENRES, MAX_CORRECTION_LENGTH, MAX_TIME_TO_BEAT_MINUTES} from '../utils/limits.js';
const REFUSAL_KEY = {INVALID_CORRECTION: 'CORRECTION.INVALID'}; const REFUSAL_KEY = {INVALID_CORRECTION: 'CORRECTION.INVALID'};
const ENDLESS_CHOICES = [[null, 'UNKNOWN'], [true, 'YES'], [false, 'NO']]; const ENDLESS_CHOICES = [[null, 'UNKNOWN'], [true, 'YES'], [false, 'NO']];
const toHoursAndMinutes = (minutes) => (minutes ? {h: String(Math.floor(minutes / 60)), m: String(minutes % 60)} : {h: '', m: ''});
const toMinutes = ({h, m}) => (h.trim() === '' && m.trim() === '' ? null
: Math.round(Number(h || 0) * 60 + Number(m || 0)));
const hasNegativePart = ({normally, completely}) => [normally.h, normally.m, completely.h, completely.m].some((part) => Number(part) < 0);
const initialValues = (game) => ({ const initialValues = (game) => ({
name: game.name ?? '', name: game.name ?? '',
image: game.imageUrl ?? '', image: game.imageUrl ?? '',
@@ -23,10 +30,19 @@ const initialValues = (game) => ({
moods: game.moods ?? [], moods: game.moods ?? [],
endless: game.endless ?? null, endless: game.endless ?? null,
genres: game.genres ?? [], genres: game.genres ?? [],
time_to_beat: {
normally: toHoursAndMinutes(game.timeToBeat?.normallyMinutes),
completely: toHoursAndMinutes(game.timeToBeat?.completelyMinutes),
},
}); });
// An empty text field means "none": the Steam header, or no speedrun. // An empty text field means "none": the Steam header, or no speedrun.
const toValue = (value) => (typeof value === 'string' ? value.trim() || null : value); const toValue = (field, value) => {
if (field === 'time_to_beat') {
return {normallyMinutes: toMinutes(value.normally), completelyMinutes: toMinutes(value.completely)};
}
return typeof value === 'string' ? value.trim() || null : value;
};
const toggled = (list, item) => (list.includes(item) ? list.filter((each) => each !== item) : [...list, item]); const toggled = (list, item) => (list.includes(item) ? list.filter((each) => each !== item) : [...list, item]);
@@ -81,6 +97,14 @@ export default function GameCorrectionDialog({game, open, onOpenChange, onChange
} }
}; };
const save = (field) => {
if (field === 'time_to_beat' && hasNegativePart(values.time_to_beat)) {
toast.error(t('CORRECTION.NEGATIVE'));
return;
}
send(field, () => saveCorrection(game.appid, field, toValue(field, values[field])));
};
const row = (field, input) => ( const row = (field, input) => (
<div key={field} className="space-y-2 border-b pb-4 last:border-b-0"> <div key={field} className="space-y-2 border-b pb-4 last:border-b-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -90,7 +114,7 @@ export default function GameCorrectionDialog({game, open, onOpenChange, onChange
{input} {input}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button type="button" size="sm" disabled={busy !== null} <Button type="button" size="sm" disabled={busy !== null}
onClick={() => send(field, () => saveCorrection(game.appid, field, toValue(values[field])))}> onClick={() => save(field)}>
{busy === field ? <Loader2 className="animate-spin"/> : <Save/>}{t('CORRECTION.SAVE')} {busy === field ? <Loader2 className="animate-spin"/> : <Save/>}{t('CORRECTION.SAVE')}
</Button> </Button>
{corrected.has(field) && ( {corrected.has(field) && (
@@ -116,6 +140,25 @@ export default function GameCorrectionDialog({game, open, onOpenChange, onChange
</div> </div>
); );
const timeInputs = (kind, label) => {
const setPart = (part, text) => set('time_to_beat', {
...values.time_to_beat, [kind]: {...values.time_to_beat[kind], [part]: text},
});
return (
<div className="flex flex-wrap items-center gap-2">
<span className="w-20 text-sm">{label}</span>
<Input type="number" min="0" max={MAX_TIME_TO_BEAT_MINUTES / 60} step="1" className="w-24"
aria-label={`${label} ${t('CORRECTION.HOURS')}`} value={values.time_to_beat[kind].h}
onChange={(e) => setPart('h', e.target.value)}/>
<span className="text-sm text-muted-foreground">{t('CORRECTION.HOURS')}</span>
<Input type="number" min="0" max="59" step="1" className="w-20"
aria-label={`${label} ${t('CORRECTION.MINUTES')}`} value={values.time_to_beat[kind].m}
onChange={(e) => setPart('m', e.target.value)}/>
<span className="text-sm text-muted-foreground">{t('CORRECTION.MINUTES')}</span>
</div>
);
};
// A genre IGDB added later still shows, so it can be removed. // A genre IGDB added later still shows, so it can be removed.
const genreOptions = [...IGDB_GENRES, ...values.genres.filter((g) => !IGDB_GENRES.includes(g))]; const genreOptions = [...IGDB_GENRES, ...values.genres.filter((g) => !IGDB_GENRES.includes(g))];
@@ -149,6 +192,12 @@ export default function GameCorrectionDialog({game, open, onOpenChange, onChange
))} ))}
{row('genres', choices('genres', genreOptions, {row('genres', choices('genres', genreOptions,
(genre) => t(`STATS.GENRE.${genreKey(genre)}`, {defaultValue: genre}), MAX_CORRECTION_GENRES))} (genre) => t(`STATS.GENRE.${genreKey(genre)}`, {defaultValue: genre}), MAX_CORRECTION_GENRES))}
{row('time_to_beat', (
<div id="correction-time_to_beat" className="space-y-2">
{timeInputs('normally', t('GAME_DETAIL.TTB_NORMALLY'))}
{timeInputs('completely', t('GAME_DETAIL.TTB_COMPLETELY'))}
</div>
))}
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
+46 -2
View File
@@ -13,12 +13,13 @@ const game = {
appid: 620, name: 'Portal 2', imageUrl: null, speedrunUrl: 'https://www.speedrun.com/portal_2', appid: 620, name: 'Portal 2', imageUrl: null, speedrunUrl: 'https://www.speedrun.com/portal_2',
moods: ['STORY'], endless: null, genres: ['Puzzle'], moods: ['STORY'], endless: null, genres: ['Puzzle'],
corrections: [{field: 'NAME', correctedAt: '2026-09-28T10:00:00Z'}], corrections: [{field: 'NAME', correctedAt: '2026-09-28T10:00:00Z'}],
timeToBeat: {hastilyMinutes: 300, normallyMinutes: 750, completelyMinutes: null},
}; };
const GAME_URL = '/api/library/games/620/corrections'; const GAME_URL = '/api/library/games/620/corrections';
const corrections = (answer) => fetchRoutes({ const corrections = (answer) => fetchRoutes({
[`PUT ${GAME_URL}/name`]: answer, [`PUT ${GAME_URL}/image`]: answer, [`PUT ${GAME_URL}/speedrun`]: answer, [`PUT ${GAME_URL}/name`]: answer, [`PUT ${GAME_URL}/image`]: answer, [`PUT ${GAME_URL}/speedrun`]: answer,
[`PUT ${GAME_URL}/moods`]: answer, [`DELETE ${GAME_URL}/name`]: answer, [`PUT ${GAME_URL}/moods`]: answer, [`PUT ${GAME_URL}/time_to_beat`]: answer, [`DELETE ${GAME_URL}/name`]: answer,
}); });
const renderDialog = (onChanged = vi.fn()) => { const renderDialog = (onChanged = vi.fn()) => {
@@ -26,7 +27,7 @@ const renderDialog = (onChanged = vi.fn()) => {
return onChanged; return onChanged;
}; };
// The rows in order: name, image, speedrun, moods, endless, genres. // The rows in order: name, image, speedrun, moods, endless, genres, time_to_beat.
const saveButton = (row) => screen.getAllByRole('button', {name: 'CORRECTION.SAVE'})[row]; const saveButton = (row) => screen.getAllByRole('button', {name: 'CORRECTION.SAVE'})[row];
const sent = () => fetch.mock.calls.map(([url, init]) => ({url, method: init?.method, body: init?.body && JSON.parse(init.body)})); const sent = () => fetch.mock.calls.map(([url, init]) => ({url, method: init?.method, body: init?.body && JSON.parse(init.body)}));
@@ -69,6 +70,49 @@ describe('GameCorrectionDialog', () => {
expect(sent().map((call) => call.body)).toEqual([{value: null}, {value: ['COOP']}]); expect(sent().map((call) => call.body)).toEqual([{value: null}, {value: ['COOP']}]);
}); });
it('shows the time to beat as hours and minutes and sends minutes, empty as null', async () => {
renderDialog();
expect(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.HOURS').value).toBe('12');
expect(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.MINUTES').value).toBe('30');
expect(screen.getByLabelText('GAME_DETAIL.TTB_COMPLETELY CORRECTION.HOURS').value).toBe('');
fireEvent.change(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.MINUTES'), {target: {value: ''}});
fireEvent.click(saveButton(6));
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
expect(sent()[0].body).toEqual({value: {normallyMinutes: 720, completelyMinutes: null}});
});
it('sendsFractionalHoursAsExactMinutes', async () => {
renderDialog();
fireEvent.change(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.HOURS'), {target: {value: '1.5'}});
fireEvent.change(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.MINUTES'), {target: {value: ''}});
fireEvent.click(saveButton(6));
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
expect(sent()[0].body.value.normallyMinutes).toBe(90);
});
it('refusesANegativeTimePartBeforeSaving', () => {
renderDialog();
fireEvent.change(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.HOURS'), {target: {value: '1'}});
fireEvent.change(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.MINUTES'), {target: {value: '-5'}});
fireEvent.click(saveButton(6));
expect(toast.error).toHaveBeenCalledWith('CORRECTION.NEGATIVE');
expect(fetch).not.toHaveBeenCalled();
});
it('offers back to source for a corrected time to beat', () => {
render(<MemoryRouter><GameCorrectionDialog open onOpenChange={vi.fn()} onChanged={vi.fn()}
game={{...game, corrections: [{field: 'TIME_TO_BEAT', correctedAt: '2026-09-28T10:00:00Z'}]}}/></MemoryRouter>);
expect(screen.getAllByText('CORRECTION.CORRECTED')).toHaveLength(1);
expect(screen.getByRole('button', {name: 'CORRECTION.BACK_TO_SOURCE'})).toBeTruthy();
});
it('marks a corrected field and goes back to the source', async () => { it('marks a corrected field and goes back to the source', async () => {
const onChanged = renderDialog(); const onChanged = renderDialog();
+3 -2
View File
@@ -11,12 +11,13 @@ import {fetchAuditLog} from '../utils/links.js';
import {formatDateTime} from '../utils/format.js'; import {formatDateTime} from '../utils/format.js';
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
const ACTIONS = ['GAME_MERGE', 'GAME_LINK', 'GAME_UNLINK', 'GAME_LINK_DISMISS']; const ACTIONS = ['GAME_MERGE', 'GAME_LINK', 'GAME_UNLINK', 'GAME_LINK_DISMISS', 'GAME_CORRECT',
'GAME_CORRECTION_RESET'];
const ALL = 'ALL'; const ALL = 'ALL';
function GameRef({appid, name, fallback}) { function GameRef({appid, name, fallback}) {
if (appid == null) return null; if (appid == null) return null;
return <Link to={`/library/${appid}`} className="font-medium hover:text-primary hover:underline">{name ?? fallback}</Link>; return <Link to={`/audit/games?appid=${appid}`} className="font-medium hover:text-primary hover:underline">{name ?? fallback}</Link>;
} }
/** /**
+1 -1
View File
@@ -33,7 +33,7 @@ describe('AuditLogPage', () => {
expect(await screen.findByText('lobo')).toBeTruthy(); expect(await screen.findByText('lobo')).toBeTruthy();
expect(screen.getByText('AUDIT.ACTION.GAME_LINK')).toBeTruthy(); expect(screen.getByText('AUDIT.ACTION.GAME_LINK')).toBeTruthy();
expect(screen.getByRole('link', {name: 'Dark Souls'}).getAttribute('href')).toBe('/library/10'); expect(screen.getByRole('link', {name: 'Dark Souls'}).getAttribute('href')).toBe('/audit/games?appid=10');
}); });
it('fallsBackToTheUserIdAndTheDetailForAMergedKey', async () => { it('fallsBackToTheUserIdAndTheDetailForAMergedKey', async () => {
+174
View File
@@ -0,0 +1,174 @@
import {useCallback, useEffect, useRef, useState} from 'react';
import {useSearchParams} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {ChevronLeft, ChevronRight, Loader2, Search} from 'lucide-react';
import {toast} from 'sonner';
import {Button} from '@/components/ui/button';
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import {Input} from '@/components/ui/input';
import GameCorrectionDialog from '../components/GameCorrectionDialog.jsx';
import GameCover from '../components/GameCover.jsx';
import PageContainer from '../components/PageContainer.jsx';
import {fetchCatalogGame, searchCatalog} from '../utils/corrections.js';
import {MAX_LIBRARY_SEARCH_LENGTH} from '../utils/limits.js';
const PAGE_SIZE = 20;
const SEARCH_DEBOUNCE_MS = 300;
/**
* AUDITOR or higher: finds any game some user has and opens the correction dialog for it. ?appid= opens a game
* directly (the audit log links here). Core checks the role.
*/
export default function GameCatalogPage() {
const {t} = useTranslation();
const [params, setParams] = useSearchParams();
const openAppid = params.get('appid');
const [query, setQuery] = useState('');
const [term, setTerm] = useState('');
const [page, setPage] = useState(0);
const [result, setResult] = useState({content: [], totalPages: 0});
const [status, setStatus] = useState('loading');
const [detail, setDetail] = useState(null);
const [reloads, setReloads] = useState(0);
// The answer for a game that is no longer the open one (Esc before it came) is dropped.
const openRef = useRef(openAppid);
useEffect(() => {
openRef.current = openAppid;
}, [openAppid]);
useEffect(() => {
const timer = setTimeout(() => {
if (query.trim() !== term) {
setTerm(query.trim());
setPage(0);
}
}, SEARCH_DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [query, term]);
useEffect(() => {
const controller = new AbortController();
(async () => {
try {
const response = await searchCatalog({q: term, page, size: PAGE_SIZE}, controller.signal);
if (response.status === 401) return;
if (response.status === 403) {
setStatus('forbidden');
return;
}
if (!response.ok) {
setStatus('error');
return;
}
setResult(await response.json());
setStatus('ready');
} catch (err) {
if (err.name === 'AbortError') return;
console.error('Catalog error:', err);
setStatus('error');
}
})();
return () => controller.abort();
}, [term, page, reloads]);
const close = useCallback(() => {
setDetail(null);
setParams({}, {replace: true});
}, [setParams]);
const loadDetail = useCallback(async (appid) => {
try {
const response = await fetchCatalogGame(appid);
if (response.status === 401 || String(appid) !== openRef.current) return;
if (response.ok) {
setDetail(await response.json());
return;
}
// 404: the game was merged meanwhile.
toast.error(t(response.status === 404 ? 'CATALOG.GONE' : 'CATALOG.LOAD_ERROR'));
close();
setReloads((n) => n + 1);
} catch (err) {
console.error('Catalog game error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
close();
}
}, [close, t]);
useEffect(() => {
if (openAppid) loadDetail(openAppid);
else setDetail(null);
}, [openAppid, loadDetail]);
const changed = () => {
loadDetail(detail.appid);
setReloads((n) => n + 1);
};
if (status === 'forbidden') {
return (
<PageContainer className="px-4 py-6 md:px-8">
<p className="text-sm text-muted-foreground">{t('CATALOG.NO_ACCESS')}</p>
</PageContainer>
);
}
return (
<PageContainer className="space-y-4 px-4 py-6 md:px-8">
<Card>
<CardHeader className="space-y-3">
<CardTitle>{t('CATALOG.TITLE')}</CardTitle>
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"/>
<Input type="search" value={query} maxLength={MAX_LIBRARY_SEARCH_LENGTH} className="pl-8"
placeholder={t('CATALOG.SEARCH')} aria-label={t('CATALOG.SEARCH')}
onChange={(e) => setQuery(e.target.value)}/>
</div>
</CardHeader>
<CardContent className="space-y-3">
{status === 'loading' && <Loader2 className="size-5 animate-spin text-muted-foreground"/>}
{status === 'error' && <p className="text-sm text-destructive">{t('CATALOG.LOAD_ERROR')}</p>}
{status === 'ready' && result.content.length === 0 && (
<p className="text-sm text-muted-foreground">{t('CATALOG.EMPTY')}</p>
)}
{status === 'ready' && result.content.length > 0 && (
<ul className="divide-y">
{result.content.map((game) => (
<li key={game.appid} className="flex items-center gap-3 py-2">
<GameCover appid={game.appid} imageUrl={game.imageUrl} className="w-20 shrink-0"/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{game.name}</p>
<p className="text-xs text-muted-foreground">
{game.platforms.join(', ')} · {t('CATALOG.KEY', {appid: game.appid})}
</p>
</div>
<Button type="button" size="sm" variant="outline"
onClick={() => setParams({appid: String(game.appid)})}>
{t('CATALOG.CORRECT')}
</Button>
</li>
))}
</ul>
)}
{status === 'ready' && result.totalPages > 1 && (
<div className="flex items-center justify-between gap-2 text-sm">
<Button type="button" variant="outline" size="sm" disabled={page === 0}
onClick={() => setPage(page - 1)}>
<ChevronLeft/>{t('ADMIN.PREVIOUS')}
</Button>
<span>{t('AUDIT.PAGE_OF', {current: page + 1, total: result.totalPages})}</span>
<Button type="button" variant="outline" size="sm" disabled={page + 1 >= result.totalPages}
onClick={() => setPage(page + 1)}>
{t('ADMIN.NEXT')}<ChevronRight/>
</Button>
</div>
)}
</CardContent>
</Card>
{detail && (
<GameCorrectionDialog game={detail} open onOpenChange={(open) => !open && close()} onChanged={changed}/>
)}
</PageContainer>
);
}
+91
View File
@@ -0,0 +1,91 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import GameCatalogPage from './GameCatalogPage.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const hit = {appid: 10, name: 'Hades', imageUrl: null, platforms: ['PS5', 'Steam']};
const detail = {appid: 10, name: 'Hades', imageUrl: null, speedrunUrl: null, moods: [], endless: null, genres: [],
timeToBeat: null, corrections: []};
const catalog = (content, totalPages = 1) => json(200, {content, page: 0, size: 20, totalElements: content.length, totalPages});
const renderPage = (routes, url = '/audit/games') => {
fetchRoutes(routes);
render(<MemoryRouter initialEntries={[url]}><PreferencesProvider><GameCatalogPage/></PreferencesProvider></MemoryRouter>);
};
const urls = () => fetch.mock.calls.map(([url]) => url);
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
});
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
describe('GameCatalogPage', () => {
it('listsHitsWithPlatformsAndKey', async () => {
renderPage({'GET /api/library/catalog': catalog([hit])});
expect(await screen.findByText('Hades')).toBeTruthy();
expect(screen.getByText(/PS5, Steam/)).toBeTruthy();
expect(screen.getByText(/CATALOG.KEY/)).toBeTruthy();
});
it('searchesWithTheTypedName', async () => {
renderPage({'GET /api/library/catalog': catalog([hit])});
await screen.findByText('Hades');
fireEvent.change(screen.getByLabelText('CATALOG.SEARCH'), {target: {value: ' had '}});
await waitFor(() => expect(urls().some((u) => u.includes('q=had'))).toBe(true));
});
it('opensTheCorrectionDialogForTheChosenGame', async () => {
renderPage({'GET /api/library/catalog': catalog([hit]), 'GET /api/library/catalog/10': json(200, detail)});
fireEvent.click(await screen.findByRole('button', {name: 'CATALOG.CORRECT'}));
expect(await screen.findByText('CORRECTION.TITLE')).toBeTruthy();
expect(urls().some((u) => u.endsWith('/api/library/catalog/10'))).toBe(true);
});
it('opensTheGameOfTheAppidInTheUrl', async () => {
renderPage({'GET /api/library/catalog': catalog([]), 'GET /api/library/catalog/10': json(200, detail)},
'/audit/games?appid=10');
expect(await screen.findByText('CORRECTION.TITLE')).toBeTruthy();
});
it('saysSoWhenTheGameIsGone', async () => {
const {toast} = await import('sonner');
renderPage({'GET /api/library/catalog': catalog([]), 'GET /api/library/catalog/10': json(404, {})},
'/audit/games?appid=10');
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('CATALOG.GONE'));
expect(screen.queryByText('CORRECTION.TITLE')).toBeNull();
});
it('showsNoAccessOn403', async () => {
renderPage({'GET /api/library/catalog': json(403, {})});
expect(await screen.findByText('CATALOG.NO_ACCESS')).toBeTruthy();
});
it('pagesThroughTheCatalog', async () => {
renderPage({'GET /api/library/catalog': () => catalog([hit], 2)});
await screen.findByText('Hades');
fireEvent.click(screen.getByRole('button', {name: /ADMIN.NEXT/}));
await waitFor(() => expect(urls().at(-1)).toContain('page=1'));
});
});
+15
View File
@@ -41,3 +41,18 @@ export const resetCorrection = (appid, field) => apiFetch(url(appid, field), {me
export const mergeGame = (appid, otherAppid) => export const mergeGame = (appid, otherAppid) =>
apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}/merge`, apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}/merge`,
{method: 'POST', headers: JSON_HEADERS, body: JSON.stringify({otherAppid})}); {method: 'POST', headers: JSON_HEADERS, body: JSON.stringify({otherAppid})});
/**
* GET: 200 `{content: [{appid, name, imageUrl, platforms}], totalPages}` of every game some user has, 403 below
* AUDITOR. A blank q means all.
*/
export const searchCatalog = ({q, page, size}, signal) => {
const params = new URLSearchParams({page: String(page), size: String(size)});
if (q?.trim()) params.set('q', q.trim());
return apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/catalog?${params}`, {signal});
};
/**
* GET: 200 what GameCorrectionDialog reads (like the game details), 403 below AUDITOR, 404 GAME_NOT_FOUND.
*/
export const fetchCatalogGame = (appid) => apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/catalog/${appid}`);
+2
View File
@@ -23,6 +23,8 @@ export const MAX_GAME_SEARCH_LENGTH = 100;
export const MAX_LIBRARY_SEARCH_LENGTH = 100; export const MAX_LIBRARY_SEARCH_LENGTH = 100;
// Core GameCorrectionService.MAX_TEXT // Core GameCorrectionService.MAX_TEXT
export const MAX_CORRECTION_LENGTH = 512; export const MAX_CORRECTION_LENGTH = 512;
// Core GameCorrectionService.MAX_TIME_TO_BEAT_MINUTES
export const MAX_TIME_TO_BEAT_MINUTES = 60_000;
// Core GameCorrectionService.MAX_GENRES // Core GameCorrectionService.MAX_GENRES
export const MAX_CORRECTION_GENRES = 10; export const MAX_CORRECTION_GENRES = 10;