From 660127796191981b335c39d438e1a90ebc51b2e4 Mon Sep 17 00:00:00 2001 From: lobo Date: Mon, 28 Sep 2026 09:31:39 +0200 Subject: [PATCH] feat(library): add the game data correction dialog --- README.md | 2 + public/i18n/de.json | 27 ++++ public/i18n/en.json | 27 ++++ src/components/GameCorrectionDialog.jsx | 158 +++++++++++++++++++ src/components/GameCorrectionDialog.test.jsx | 114 +++++++++++++ src/pages/GameDetail.jsx | 20 ++- src/pages/GameDetail.test.jsx | 32 +++- src/utils/corrections.js | 35 ++++ src/utils/corrections.test.js | 22 +++ 9 files changed, 434 insertions(+), 3 deletions(-) create mode 100644 src/components/GameCorrectionDialog.jsx create mode 100644 src/components/GameCorrectionDialog.test.jsx create mode 100644 src/utils/corrections.js create mode 100644 src/utils/corrections.test.js diff --git a/README.md b/README.md index 98a4b03..49dc36c 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,8 @@ To Be Played is split into three repositories: - **Admin** (PLATFORM_ADMIN only, `/admin/users`): search the users, block, unblock and delete them (the email is typed to confirm a delete). "View as" in the menu switches the own session to User or Auditor and back; a badge shows the lower role. Auth checks every right on the server. +- **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. - **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. - **Build version** in the settings: commit and build time of Frontend, Auth and Core, each hash links to its diff --git a/public/i18n/de.json b/public/i18n/de.json index dd9eebe..ca3a1f4 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -518,5 +518,32 @@ "PAGE_OF": "Seite {{current}} von {{total}}", "PREVIOUS": "Zurück", "NEXT": "Weiter" + }, + "CORRECTION": { + "OPEN": "Daten korrigieren", + "TITLE": "Spieldaten korrigieren", + "TEXT": "Änderungen gelten für alle Nutzer und bleiben auch nach dem nächsten Abgleich mit Steam, IGDB oder speedrun.com.", + "SAVE": "Speichern", + "BACK_TO_SOURCE": "Zurück zur Quelle", + "CORRECTED": "Korrigiert", + "SAVED": "Korrektur gespeichert", + "INVALID": "Nicht gespeichert: {{message}}", + "NO_ACCESS": "Nur Auditoren können Spieldaten korrigieren.", + "ERROR": "Die Korrektur konnte nicht gespeichert werden.", + "IMAGE_HINT": "https://… (leer: das Steam-Headerbild)", + "SPEEDRUN_HINT": "https://www.speedrun.com/… (leer: kein Speedrun)", + "FIELD": { + "NAME": "Name", + "IMAGE": "URL des Headerbilds", + "SPEEDRUN": "Speedrun-Link", + "MOODS": "Stimmungen", + "ENDLESS": "Endloses Spiel", + "GENRES": "Genres" + }, + "ENDLESS": { + "UNKNOWN": "Unbekannt", + "YES": "Ja", + "NO": "Nein" + } } } diff --git a/public/i18n/en.json b/public/i18n/en.json index 3d327e8..3f79c69 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -518,5 +518,32 @@ "PAGE_OF": "Page {{current}} of {{total}}", "PREVIOUS": "Previous", "NEXT": "Next" + }, + "CORRECTION": { + "OPEN": "Correct data", + "TITLE": "Correct game data", + "TEXT": "Changes apply to every user and stay after the next refresh from Steam, IGDB or speedrun.com.", + "SAVE": "Save", + "BACK_TO_SOURCE": "Back to source", + "CORRECTED": "Corrected", + "SAVED": "Correction saved", + "INVALID": "Not saved: {{message}}", + "NO_ACCESS": "Only auditors can correct game data.", + "ERROR": "The correction could not be saved.", + "IMAGE_HINT": "https://… (empty: the Steam header)", + "SPEEDRUN_HINT": "https://www.speedrun.com/… (empty: no speedrun)", + "FIELD": { + "NAME": "Name", + "IMAGE": "Header image URL", + "SPEEDRUN": "Speedrun link", + "MOODS": "Moods", + "ENDLESS": "Endless game", + "GENRES": "Genres" + }, + "ENDLESS": { + "UNKNOWN": "Unknown", + "YES": "Yes", + "NO": "No" + } } } diff --git a/src/components/GameCorrectionDialog.jsx b/src/components/GameCorrectionDialog.jsx new file mode 100644 index 0000000..1257399 --- /dev/null +++ b/src/components/GameCorrectionDialog.jsx @@ -0,0 +1,158 @@ +import {useEffect, useRef, useState} from 'react'; +import {useTranslation} from 'react-i18next'; +import {Loader2, RotateCcw, Save} from 'lucide-react'; +import {toast} from 'sonner'; + +import {Badge} from '@/components/ui/badge'; +import {Button} from '@/components/ui/button'; +import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from '@/components/ui/dialog'; +import {Input} from '@/components/ui/input'; +import {Label} from '@/components/ui/label'; +import useSessionExpired from '../hooks/useSessionExpired.js'; +import {CORRECTABLE_MOODS, IGDB_GENRES, resetCorrection, saveCorrection} from '../utils/corrections.js'; +import {genreKey} from '../utils/stats.js'; + +const ENDLESS_CHOICES = [[null, 'UNKNOWN'], [true, 'YES'], [false, 'NO']]; +// Core refuses more. +const MAX_GENRES = 10; + +const initialValues = (game) => ({ + name: game.name ?? '', + image: game.imageUrl ?? '', + speedrun: game.speedrunUrl ?? '', + moods: game.moods ?? [], + endless: game.endless ?? null, + genres: game.genres ?? [], +}); + +// An empty text field means "none": the Steam header, or no speedrun. +const toValue = (value) => (typeof value === 'string' ? value.trim() || null : value); + +const toggled = (list, item) => (list.includes(item) ? list.filter((each) => each !== item) : [...list, item]); + +/** + * AUDITOR or higher: corrects the shared data of one game for every user. Each field saves on its own; + * onChanged loads the game again. + */ +export default function GameCorrectionDialog({game, open, onOpenChange, onChanged}) { + const {t} = useTranslation(); + const expireSession = useSessionExpired(); + const [values, setValues] = useState(() => initialValues(game)); + const [busy, setBusy] = useState(null); + // The field whose save or reset the next game from GameDetail answers; the other fields keep their edits. + const sentField = useRef(null); + + useEffect(() => { + if (open) setValues(initialValues(game)); + // Only when the dialog opens; a reload after a save is handled below. + }, [open]); + + useEffect(() => { + const field = sentField.current; + if (field) { + sentField.current = null; + setValues((v) => ({...v, [field]: initialValues(game)[field]})); + } + }, [game]); + + const corrected = new Set((game.corrections ?? []).map((c) => c.field.toLowerCase())); + const set = (field, value) => setValues((v) => ({...v, [field]: value})); + + const send = async (field, request) => { + setBusy(field); + try { + const response = await request(); + if (response.status === 401) { + expireSession(); + return; + } + if (response.ok) { + toast.success(t('CORRECTION.SAVED')); + sentField.current = field; + onChanged?.(); + return; + } + const data = await response.json().catch(() => ({})); + toast.error(data.code === 'INVALID_CORRECTION' ? t('CORRECTION.INVALID', {message: data.message}) + : response.status === 403 ? t('CORRECTION.NO_ACCESS') : t('CORRECTION.ERROR')); + } catch (err) { + console.error('Correction error:', err); + toast.error(t('INFO.ERROR.NETWORK_FAILURE')); + } finally { + setBusy(null); + } + }; + + const row = (field, input) => ( +
+
+ + {corrected.has(field) && {t('CORRECTION.CORRECTED')}} +
+ {input} +
+ + {corrected.has(field) && ( + + )} +
+
+ ); + + const choices = (field, options, label, max = Infinity) => ( +
+ {options.map((option) => ( + + ))} +
+ ); + + // A genre IGDB added later still shows, so it can be removed. + const genreOptions = [...IGDB_GENRES, ...values.genres.filter((g) => !IGDB_GENRES.includes(g))]; + + return ( + + + + {t('CORRECTION.TITLE')} + {t('CORRECTION.TEXT')} + +
+ {row('name', set('name', e.target.value)}/>)} + {row('image', set('image', e.target.value)}/>)} + {row('speedrun', set('speedrun', e.target.value)}/>)} + {row('moods', choices('moods', CORRECTABLE_MOODS, (mood) => t(`PLAY_NEXT.MOOD.${mood}`)))} + {row('endless', ( +
+ {ENDLESS_CHOICES.map(([choice, key]) => ( + + ))} +
+ ))} + {row('genres', choices('genres', genreOptions, + (genre) => t(`STATS.GENRE.${genreKey(genre)}`, {defaultValue: genre}), MAX_GENRES))} +
+
+
+ ); +} diff --git a/src/components/GameCorrectionDialog.test.jsx b/src/components/GameCorrectionDialog.test.jsx new file mode 100644 index 0000000..ec045b8 --- /dev/null +++ b/src/components/GameCorrectionDialog.test.jsx @@ -0,0 +1,114 @@ +// @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 {toast} from 'sonner'; +import GameCorrectionDialog from './GameCorrectionDialog.jsx'; + +vi.mock('react-i18next', () => import('../test/i18n-mock.js')); +vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}})); + +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'}], +}; + +const answer = (status, body = {}) => ({ok: status < 300, status, json: async () => body}); + +const renderDialog = (onChanged = vi.fn()) => { + render(); + return onChanged; +}; + +// The rows in order: name, image, speedrun, moods, endless, genres. +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)})); + +beforeEach(() => { + localStorage.setItem('tbp_session', '1'); + globalThis.fetch = vi.fn(async () => answer(204)); +}); + +afterEach(() => { + cleanup(); + localStorage.clear(); + vi.clearAllMocks(); +}); + +describe('GameCorrectionDialog', () => { + it('saves one field and loads the game again', async () => { + const onChanged = renderDialog(); + + fireEvent.change(screen.getByLabelText('CORRECTION.FIELD.NAME'), {target: {value: ' Portal Two '}}); + fireEvent.click(saveButton(0)); + + await waitFor(() => expect(onChanged).toHaveBeenCalled()); + expect(sent()).toEqual([{url: expect.stringContaining('/api/library/games/620/corrections/name'), method: 'PUT', + body: {value: 'Portal Two'}}]); + expect(toast.success).toHaveBeenCalledWith('CORRECTION.SAVED'); + }); + + it('sends an empty link as "no speedrun" and the chosen moods as a list', async () => { + renderDialog(); + + fireEvent.change(screen.getByLabelText('CORRECTION.FIELD.SPEEDRUN'), {target: {value: ' '}}); + fireEvent.click(saveButton(2)); + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); + fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.MOOD.COOP'})); + fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.MOOD.STORY'})); + fireEvent.click(saveButton(3)); + + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); + expect(sent().map((call) => call.body)).toEqual([{value: null}, {value: ['COOP']}]); + }); + + it('marks a corrected field and goes back to the source', async () => { + const onChanged = renderDialog(); + + expect(screen.getAllByText('CORRECTION.CORRECTED')).toHaveLength(1); + fireEvent.click(screen.getByRole('button', {name: 'CORRECTION.BACK_TO_SOURCE'})); + + await waitFor(() => expect(onChanged).toHaveBeenCalled()); + expect(sent()).toEqual([{url: expect.stringContaining('/corrections/name'), method: 'DELETE', body: undefined}]); + }); + + it('shows why Core refused a value', async () => { + globalThis.fetch = vi.fn(async () => answer(400, {code: 'INVALID_CORRECTION', message: 'image must be https'})); + const onChanged = renderDialog(); + + fireEvent.change(screen.getByLabelText('CORRECTION.FIELD.IMAGE'), {target: {value: 'http://x'}}); + fireEvent.click(saveButton(1)); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('CORRECTION.INVALID')); + expect(onChanged).not.toHaveBeenCalled(); + }); + + it('keeps an unsaved edit of another field after the reload', async () => { + const onChanged = vi.fn(); + const {rerender} = render(); + fireEvent.change(screen.getByLabelText('CORRECTION.FIELD.SPEEDRUN'), {target: {value: 'https://www.speedrun.com/portal'}}); + fireEvent.change(screen.getByLabelText('CORRECTION.FIELD.NAME'), {target: {value: 'Portal Two'}}); + fireEvent.click(saveButton(0)); + await waitFor(() => expect(onChanged).toHaveBeenCalled()); + + // GameDetail loads the game again and passes the new object. + rerender(); + + expect(screen.getByLabelText('CORRECTION.FIELD.SPEEDRUN').value).toBe('https://www.speedrun.com/portal'); + expect(screen.getByLabelText('CORRECTION.FIELD.NAME').value).toBe('Portal Two'); + }); + + it('offers no eleventh genre', () => { + renderDialog(); + + const genres = ['Adventure', 'Arcade', 'Fighting', 'Indie', 'MOBA', 'Music', 'Pinball', 'Platform', 'Racing']; + genres.forEach((genre) => fireEvent.click(screen.getByRole('button', {name: `STATS.GENRE.${genre.toUpperCase()}`}))); + + expect(screen.getByRole('button', {name: 'STATS.GENRE.SHOOTER'}).disabled).toBe(true); + expect(screen.getByRole('button', {name: 'STATS.GENRE.PUZZLE'}).disabled).toBe(false); + }); +}); diff --git a/src/pages/GameDetail.jsx b/src/pages/GameDetail.jsx index 51a745e..e97c463 100644 --- a/src/pages/GameDetail.jsx +++ b/src/pages/GameDetail.jsx @@ -1,7 +1,7 @@ import {useEffect, useMemo, useState} from 'react'; import {Link, useNavigate, useParams} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; -import {ArrowLeft, Clock, ExternalLink, Eye, EyeOff, Gamepad2, Image as ImageIcon, ImageOff, PanelTop, RotateCcw, ShieldOff, Timer, Trophy} from 'lucide-react'; +import {ArrowLeft, Clock, ExternalLink, Eye, EyeOff, Gamepad2, Image as ImageIcon, ImageOff, PanelTop, PencilLine, RotateCcw, ShieldOff, Timer, Trophy} from 'lucide-react'; import {toast} from 'sonner'; import {Badge} from '@/components/ui/badge'; @@ -15,15 +15,18 @@ import GameStatusBadge from '../components/GameStatusBadge.jsx'; import ManualGameCard from '../components/ManualGameCard.jsx'; import CompletedHint from '../components/CompletedHint.jsx'; import FinishedHint from '../components/FinishedHint.jsx'; +import GameCorrectionDialog from '../components/GameCorrectionDialog.jsx'; import PlayingHint from '../components/PlayingHint.jsx'; import TimeToBeat from '../components/TimeToBeat.jsx'; import {formatPlaytime} from '../utils/games.js'; +import useCurrentUser from '../hooks/useCurrentUser.js'; import useSessionExpired from '../hooks/useSessionExpired.js'; 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 {canCorrect} from '../utils/corrections.js'; import {setGameHidden} from '../utils/hiddenGames.js'; import {hasSession} from '../utils/auth.js'; import {isSteamKey} from '../utils/manualGames.js'; @@ -63,6 +66,9 @@ export default function GameDetail() { const [isLoading, setIsLoading] = useState(true); const [hiding, setHiding] = useState(false); const [error, setError] = useState(null); + const [correcting, setCorrecting] = useState(false); + const [reloads, setReloads] = useState(0); + const auditor = canCorrect(useCurrentUser(true)); useEffect(() => { let cancelled = false; @@ -107,7 +113,7 @@ export default function GameDetail() { return () => { cancelled = true; }; - }, [appid, navigate, expireSession, t]); + }, [appid, navigate, expireSession, t, reloads]); const sortedAchievements = useMemo(() => { if (!data?.achievements) return []; @@ -211,6 +217,11 @@ export default function GameDetail() { )} + {auditor && ( + + )} {/* Store page, background and banner need Steam art, so a game off Steam has none of them. Hide is here too: only a Steam game can be hidden. */} @@ -339,6 +350,11 @@ export default function GameDetail() { )} )} + {/* Outside the content: the reload after a save must not close it. */} + {data && auditor && ( + setReloads((n) => n + 1)}/> + )} ); } diff --git a/src/pages/GameDetail.test.jsx b/src/pages/GameDetail.test.jsx index fcffb70..a4194f9 100644 --- a/src/pages/GameDetail.test.jsx +++ b/src/pages/GameDetail.test.jsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; -import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; +import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; import {MemoryRouter, Route, Routes} from 'react-router-dom'; import GameDetail from './GameDetail.jsx'; import {PreferencesProvider} from '../context/PreferencesContext.jsx'; @@ -241,4 +241,34 @@ describe('GameDetail of a game added by hand', () => { expect(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BACKGROUND'})).toBeTruthy(); expect(screen.queryByText('MANUAL_GAME.TITLE')).toBeNull(); }); + + describe('corrections', () => { + const loggedInAs = (me) => { + globalThis.fetch = vi.fn(async (url) => ({ok: true, status: 200, + json: async () => (url.includes('/api/users/me') ? me : game)})); + }; + + it('offers "correct data" to an active auditor', async () => { + loggedInAs({role: 'AUDITOR', activeRole: 'AUDITOR'}); + renderDetail(); + + fireEvent.click(await screen.findByRole('button', {name: 'CORRECTION.OPEN'})); + + expect(await screen.findByText('CORRECTION.TITLE')).toBeTruthy(); + }); + + it('offers nothing to a user or to an admin viewing as user', async () => { + for (const me of [{role: 'USER', activeRole: 'USER'}, {role: 'PLATFORM_ADMIN', activeRole: 'USER'}]) { + loggedInAs(me); + renderDetail(); + + await screen.findByText('Half-Life 2'); + await waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/users/me'), expect.anything())); + // Let the answer of /me reach the page before looking for the button. + await act(async () => {}); + expect(screen.queryByRole('button', {name: 'CORRECTION.OPEN'})).toBeNull(); + cleanup(); + } + }); + }); }); diff --git a/src/utils/corrections.js b/src/utils/corrections.js new file mode 100644 index 0000000..7814d83 --- /dev/null +++ b/src/utils/corrections.js @@ -0,0 +1,35 @@ +import {apiFetch} from './api.js'; +import {ROLES} from './admin.js'; + +const JSON_HEADERS = {'Content-Type': 'application/json'}; + +const url = (appid, field) => + `${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}/corrections/${field}`; + +/** + * IGDB's genre names, the values Core stores. Each has a STATS.GENRE key (see genreKey). + */ +export const IGDB_GENRES = ['Adventure', 'Arcade', 'Card & Board Game', 'Fighting', "Hack and slash/Beat 'em up", + 'Indie', 'MOBA', 'Music', 'Pinball', 'Platform', 'Point-and-click', 'Puzzle', 'Quiz/Trivia', 'Racing', + 'Real Time Strategy (RTS)', 'Role-playing (RPG)', 'Shooter', 'Simulator', 'Sport', 'Strategy', 'Tactical', + 'Turn-based strategy (TBS)', 'Visual Novel']; + +// SPEEDRUN is the speedrun link, not a stored mood. +export const CORRECTABLE_MOODS = ['STORY', 'COOP', 'CHILL', 'ACTION', 'SCARY']; + +/** + * An AUDITOR or higher, by the active role: an admin viewing as USER cannot correct. + */ +export const canCorrect = (currentUser) => + !!currentUser && ROLES.indexOf(currentUser.activeRole) >= ROLES.indexOf('AUDITOR'); + +/** + * PUT: 204, 400 {code: INVALID_CORRECTION, message}, 403 below AUDITOR, 404 unknown game. + */ +export const saveCorrection = (appid, field, value) => + apiFetch(url(appid, field), {method: 'PUT', headers: JSON_HEADERS, body: JSON.stringify({value})}); + +/** + * DELETE: back to the source's value, 204. + */ +export const resetCorrection = (appid, field) => apiFetch(url(appid, field), {method: 'DELETE'}); diff --git a/src/utils/corrections.test.js b/src/utils/corrections.test.js new file mode 100644 index 0000000..9abc470 --- /dev/null +++ b/src/utils/corrections.test.js @@ -0,0 +1,22 @@ +import {describe, expect, it} from 'vitest'; +import en from '../../public/i18n/en.json'; +import de from '../../public/i18n/de.json'; +import {canCorrect, IGDB_GENRES} from './corrections.js'; +import {genreKey} from './stats.js'; + +describe('corrections', () => { + it('knows a text for every IGDB genre it offers', () => { + for (const genre of IGDB_GENRES) { + expect(en.STATS.GENRE[genreKey(genre)], genre).toBeTruthy(); + expect(de.STATS.GENRE[genreKey(genre)], genre).toBeTruthy(); + } + }); + + it('lets only an active AUDITOR or higher correct', () => { + expect(canCorrect({role: 'AUDITOR', activeRole: 'AUDITOR'})).toBe(true); + expect(canCorrect({role: 'PLATFORM_ADMIN', activeRole: 'PLATFORM_ADMIN'})).toBe(true); + expect(canCorrect({role: 'PLATFORM_ADMIN', activeRole: 'USER'})).toBe(false); + expect(canCorrect({role: 'USER', activeRole: 'USER'})).toBe(false); + expect(canCorrect(null)).toBe(false); + }); +});