From 0c300c455d6d7c5f684f94d15c35aefa9f3dc57d Mon Sep 17 00:00:00 2001 From: lobo Date: Fri, 2 Oct 2026 11:21:15 +0200 Subject: [PATCH] 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. --- README.md | 7 +- public/i18n/de.json | 23 ++- public/i18n/en.json | 23 ++- src/App.jsx | 2 + src/components/AppHeader.jsx | 5 +- src/components/AppHeader.test.jsx | 6 +- src/components/GameCorrectionDialog.jsx | 55 +++++- src/components/GameCorrectionDialog.test.jsx | 48 ++++- src/pages/AuditLogPage.jsx | 5 +- src/pages/AuditLogPage.test.jsx | 2 +- src/pages/GameCatalogPage.jsx | 174 +++++++++++++++++++ src/pages/GameCatalogPage.test.jsx | 91 ++++++++++ src/utils/corrections.js | 15 ++ src/utils/limits.js | 2 + 14 files changed, 439 insertions(+), 19 deletions(-) create mode 100644 src/pages/GameCatalogPage.jsx create mode 100644 src/pages/GameCatalogPage.test.jsx diff --git a/README.md b/README.md index 4da3ea3..19b9f7e 100644 --- a/README.md +++ b/README.md @@ -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 links to it again. - **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 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 - 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. - **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. diff --git a/public/i18n/de.json b/public/i18n/de.json index 772479b..30ef593 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -349,7 +349,8 @@ "PENDING_REQUESTS_one": "{{count}} Registrierung wartet", "PENDING_REQUESTS_other": "{{count}} Registrierungen warten", "LINK_QUEUE": "Verknüpfungen", - "AUDIT_LOG": "Protokoll" + "AUDIT_LOG": "Protokoll", + "GAME_CATALOG": "Spielekatalog" }, "BACKLOG": { "TITLE": "Mein Backlog", @@ -626,6 +627,9 @@ "NO_ACCESS": "Nur Auditoren können Spieldaten korrigieren.", "ERROR": "Die Korrektur konnte nicht gespeichert werden.", "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)", "FIELD": { "NAME": "Name", @@ -633,7 +637,8 @@ "SPEEDRUN": "Speedrun-Link", "MOODS": "Stimmungen", "ENDLESS": "Endloses Spiel", - "GENRES": "Genres" + "GENRES": "Genres", + "TIME_TO_BEAT": "Spielzeit" }, "ENDLESS": { "UNKNOWN": "Unbekannt", @@ -689,6 +694,16 @@ "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": { "TITLE": "Protokoll", "EMPTY": "Keine Einträge.", @@ -702,7 +717,9 @@ "GAME_MERGE": "Zusammengeführt", "GAME_LINK": "Verknüpft", "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": { diff --git a/public/i18n/en.json b/public/i18n/en.json index 3df95fd..17d4c05 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -349,7 +349,8 @@ "PENDING_REQUESTS_one": "{{count}} registration waits", "PENDING_REQUESTS_other": "{{count}} registrations wait", "LINK_QUEUE": "Link queue", - "AUDIT_LOG": "Audit log" + "AUDIT_LOG": "Audit log", + "GAME_CATALOG": "Game catalog" }, "BACKLOG": { "TITLE": "My backlog", @@ -626,6 +627,9 @@ "NO_ACCESS": "Only auditors can correct game data.", "ERROR": "The correction could not be saved.", "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)", "FIELD": { "NAME": "Name", @@ -633,7 +637,8 @@ "SPEEDRUN": "Speedrun link", "MOODS": "Moods", "ENDLESS": "Endless game", - "GENRES": "Genres" + "GENRES": "Genres", + "TIME_TO_BEAT": "Time to beat" }, "ENDLESS": { "UNKNOWN": "Unknown", @@ -689,6 +694,16 @@ "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": { "TITLE": "Audit log", "EMPTY": "No entries.", @@ -702,7 +717,9 @@ "GAME_MERGE": "Merge", "GAME_LINK": "Link", "GAME_UNLINK": "Unlink", - "GAME_LINK_DISMISS": "Dismissed" + "GAME_LINK_DISMISS": "Dismissed", + "GAME_CORRECT": "Corrected", + "GAME_CORRECTION_RESET": "Back to source" } }, "HINT": { diff --git a/src/App.jsx b/src/App.jsx index c8b1ebf..ee37f84 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -18,6 +18,7 @@ import WelcomePage from './pages/WelcomePage.jsx'; import WelcomeGate from './components/WelcomeGate.jsx'; import AdminUsersPage from './pages/AdminUsersPage.jsx'; import AuditLogPage from './pages/AuditLogPage.jsx'; +import GameCatalogPage from './pages/GameCatalogPage.jsx'; import LinkQueuePage from './pages/LinkQueuePage.jsx'; import GameLibrary from './pages/GameLibrary.jsx'; import GameDetail from './pages/GameDetail.jsx'; @@ -89,6 +90,7 @@ function App() { }/> }/> }/> + }/> }/> }/> }/> diff --git a/src/components/AppHeader.jsx b/src/components/AppHeader.jsx index 4d3b45a..fb443fd 100644 --- a/src/components/AppHeader.jsx +++ b/src/components/AppHeader.jsx @@ -1,7 +1,7 @@ import {useEffect, useState} from 'react'; import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom'; 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 {Badge} from '@/components/ui/badge'; @@ -158,6 +158,9 @@ export default function AppHeader({isAuthenticated, onLogout}) { {canCorrect(currentUser) && ( <> + + {t('NAV.GAME_CATALOG')} + {t('NAV.LINK_QUEUE')} diff --git a/src/components/AppHeader.test.jsx b/src/components/AppHeader.test.jsx index 45380f5..bda98f3 100644 --- a/src/components/AppHeader.test.jsx +++ b/src/components/AppHeader.test.jsx @@ -88,12 +88,13 @@ describe('AppHeader', () => { expect(screen.queryByText('NAV.ADMIN_BROADCASTS')).toBeNull(); }); - it('offersTheLinkQueueAndAuditLogToAnAuditorOnly', async () => { + it('offersTheCatalogLinkQueueAndAuditLogToAnAuditorOnly', async () => { loggedInAs({role: 'AUDITOR', activeRole: 'AUDITOR'}); await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/users/me'), expect.anything())); 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.queryByText('NAV.ADMIN')).toBeNull(); }); @@ -108,6 +109,7 @@ describe('AppHeader', () => { expect(screen.getByText('NAV.WHATS_NEW')).toBeTruthy(); expect(screen.queryByText('NAV.ADMIN')).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_BROADCASTS')).toBeNull(); expect(screen.queryByText('NAV.VIEW_AS.MENU')).toBeNull(); diff --git a/src/components/GameCorrectionDialog.jsx b/src/components/GameCorrectionDialog.jsx index 0f4ac92..613ec0c 100644 --- a/src/components/GameCorrectionDialog.jsx +++ b/src/components/GameCorrectionDialog.jsx @@ -10,12 +10,19 @@ import {Input} from '@/components/ui/input'; import {Label} from '@/components/ui/label'; import {CORRECTABLE_MOODS, IGDB_GENRES, resetCorrection, saveCorrection} from '../utils/corrections.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 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) => ({ name: game.name ?? '', image: game.imageUrl ?? '', @@ -23,10 +30,19 @@ const initialValues = (game) => ({ moods: game.moods ?? [], endless: game.endless ?? null, 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. -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]); @@ -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) => (
@@ -90,7 +114,7 @@ export default function GameCorrectionDialog({game, open, onOpenChange, onChange {input}
{corrected.has(field) && ( @@ -116,6 +140,25 @@ export default function GameCorrectionDialog({game, open, onOpenChange, onChange
); + 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 ( +
+ {label} + setPart('h', e.target.value)}/> + {t('CORRECTION.HOURS')} + setPart('m', e.target.value)}/> + {t('CORRECTION.MINUTES')} +
+ ); + }; + // A genre IGDB added later still shows, so it can be removed. 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, (genre) => t(`STATS.GENRE.${genreKey(genre)}`, {defaultValue: genre}), MAX_CORRECTION_GENRES))} + {row('time_to_beat', ( +
+ {timeInputs('normally', t('GAME_DETAIL.TTB_NORMALLY'))} + {timeInputs('completely', t('GAME_DETAIL.TTB_COMPLETELY'))} +
+ ))}
diff --git a/src/components/GameCorrectionDialog.test.jsx b/src/components/GameCorrectionDialog.test.jsx index bcabc42..b2ff51b 100644 --- a/src/components/GameCorrectionDialog.test.jsx +++ b/src/components/GameCorrectionDialog.test.jsx @@ -13,12 +13,13 @@ const game = { appid: 620, name: 'Portal 2', imageUrl: null, speedrunUrl: 'https://www.speedrun.com/portal_2', moods: ['STORY'], endless: null, genres: ['Puzzle'], 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 corrections = (answer) => fetchRoutes({ [`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()) => { @@ -26,7 +27,7 @@ const renderDialog = (onChanged = vi.fn()) => { 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 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']}]); }); + 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(); + + 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 () => { const onChanged = renderDialog(); diff --git a/src/pages/AuditLogPage.jsx b/src/pages/AuditLogPage.jsx index c3e7b28..c99f062 100644 --- a/src/pages/AuditLogPage.jsx +++ b/src/pages/AuditLogPage.jsx @@ -11,12 +11,13 @@ import {fetchAuditLog} from '../utils/links.js'; import {formatDateTime} from '../utils/format.js'; 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'; function GameRef({appid, name, fallback}) { if (appid == null) return null; - return {name ?? fallback}; + return {name ?? fallback}; } /** diff --git a/src/pages/AuditLogPage.test.jsx b/src/pages/AuditLogPage.test.jsx index d4f6751..b84be2c 100644 --- a/src/pages/AuditLogPage.test.jsx +++ b/src/pages/AuditLogPage.test.jsx @@ -33,7 +33,7 @@ describe('AuditLogPage', () => { expect(await screen.findByText('lobo')).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 () => { diff --git a/src/pages/GameCatalogPage.jsx b/src/pages/GameCatalogPage.jsx new file mode 100644 index 0000000..da2ceea --- /dev/null +++ b/src/pages/GameCatalogPage.jsx @@ -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 ( + +

{t('CATALOG.NO_ACCESS')}

+
+ ); + } + + return ( + + + + {t('CATALOG.TITLE')} +
+ + setQuery(e.target.value)}/> +
+
+ + {status === 'loading' && } + {status === 'error' &&

{t('CATALOG.LOAD_ERROR')}

} + {status === 'ready' && result.content.length === 0 && ( +

{t('CATALOG.EMPTY')}

+ )} + {status === 'ready' && result.content.length > 0 && ( +
    + {result.content.map((game) => ( +
  • + +
    +

    {game.name}

    +

    + {game.platforms.join(', ')} · {t('CATALOG.KEY', {appid: game.appid})} +

    +
    + +
  • + ))} +
+ )} + {status === 'ready' && result.totalPages > 1 && ( +
+ + {t('AUDIT.PAGE_OF', {current: page + 1, total: result.totalPages})} + +
+ )} +
+
+ {detail && ( + !open && close()} onChanged={changed}/> + )} +
+ ); +} diff --git a/src/pages/GameCatalogPage.test.jsx b/src/pages/GameCatalogPage.test.jsx new file mode 100644 index 0000000..164027b --- /dev/null +++ b/src/pages/GameCatalogPage.test.jsx @@ -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(); +}; + +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')); + }); +}); diff --git a/src/utils/corrections.js b/src/utils/corrections.js index a2156be..476b04e 100644 --- a/src/utils/corrections.js +++ b/src/utils/corrections.js @@ -41,3 +41,18 @@ export const resetCorrection = (appid, field) => apiFetch(url(appid, field), {me export const mergeGame = (appid, otherAppid) => apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}/merge`, {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}`); diff --git a/src/utils/limits.js b/src/utils/limits.js index dfa4820..a8ab33b 100644 --- a/src/utils/limits.js +++ b/src/utils/limits.js @@ -23,6 +23,8 @@ export const MAX_GAME_SEARCH_LENGTH = 100; export const MAX_LIBRARY_SEARCH_LENGTH = 100; // Core GameCorrectionService.MAX_TEXT 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 export const MAX_CORRECTION_GENRES = 10;