From 78ba0700c8b9e153643184f0829d86a8f9bcfd18 Mon Sep 17 00:00:00 2001 From: lobo Date: Mon, 28 Sep 2026 11:25:45 +0200 Subject: [PATCH] feat(library): add the merge duplicate dialog --- README.md | 4 +- public/i18n/de.json | 16 ++++ public/i18n/en.json | 16 ++++ src/components/AddGamePanel.jsx | 87 ++----------------- src/components/GameMergeDialog.jsx | 88 +++++++++++++++++++ src/components/GameMergeDialog.test.jsx | 83 ++++++++++++++++++ src/components/GameSearch.jsx | 111 ++++++++++++++++++++++++ src/pages/GameDetail.jsx | 16 +++- src/pages/GameDetail.test.jsx | 10 +++ src/utils/corrections.js | 8 ++ 10 files changed, 355 insertions(+), 84 deletions(-) create mode 100644 src/components/GameMergeDialog.jsx create mode 100644 src/components/GameMergeDialog.test.jsx create mode 100644 src/components/GameSearch.jsx diff --git a/README.md b/README.md index 672b299..7d084c1 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ To Be Played is split into three repositories: - **Library**: status filters, "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest in library, rating or priority, paging. Each row shows the unlocked achievements. - **Add a game by name** for games outside Steam (PlayStation, Switch, Xbox, physical): search IGDB, pick a hit, - a platform and optional hours. Works without a Steam ID. + a platform and optional hours. Works without a Steam ID. Games already in ToBePlayed come first. - **Game detail**: status, priority, 1–5 star rating, notes, achievements, time to beat (story / extras / 100 %), a link to the game's speedrun.com page when it has verified runs, a hint when Steam shows recent playtime for a game still in the backlog, and one to set the status to 100 % when @@ -62,6 +62,8 @@ To Be Played is split into three repositories: "Accept" hint stays until "OK". Admins write, preview and end hints on `/admin/hints` (Auth stores them). - **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. + "Merge duplicate" joins two entries of the same game (e.g. the Steam game and the same game added by hand) for + every user. - **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 e49f562..626639f 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -547,6 +547,22 @@ "NO": "Nein" } }, + "MERGE": { + "OPEN": "Duplikat zusammenführen", + "TITLE": "Mit demselben Spiel zusammenführen", + "HINT": "Suche den anderen Eintrag von „{{name}}“, z. B. das Steam-Spiel oder ein von Hand hinzugefügtes Spiel. Das Zusammenführen gilt für alle Nutzer.", + "CONFIRM": "„{{name}}“ und „{{other}}“ für alle Nutzer zu einem Spiel zusammenführen? Bibliotheken mit beiden behalten einen Eintrag mit dem zuletzt gesetzten Status, der Bewertung und den Notizen. Das lässt sich nicht rückgängig machen.", + "CONFIRM_BUTTON": "Zusammenführen", + "BACK": "Zurück", + "DONE": "Spiele zusammengeführt", + "NO_ACCESS": "Nur Auditoren können Spiele zusammenführen.", + "ERROR": { + "SAME_GAME": "Ein Spiel kann nicht mit sich selbst zusammengeführt werden.", + "STEAM_GAMES": "Zwei Steam-Spiele können nicht zusammengeführt werden.", + "GAME_NOT_FOUND": "Dieses Spiel ist noch nicht in ToBePlayed und kann nicht zusammengeführt werden.", + "OTHER": "Die Spiele konnten nicht zusammengeführt werden." + } + }, "HINT": { "CLOSE": "Schließen", "DO_NOT_SHOW_AGAIN": "Nicht mehr anzeigen", diff --git a/public/i18n/en.json b/public/i18n/en.json index 5b3984f..de8d3a6 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -547,6 +547,22 @@ "NO": "No" } }, + "MERGE": { + "OPEN": "Merge duplicate", + "TITLE": "Merge with the same game", + "HINT": "Find the other entry of \"{{name}}\", e.g. the Steam game or a game added by hand. The merge applies to every user.", + "CONFIRM": "Merge \"{{name}}\" and \"{{other}}\" into one game for every user? Libraries with both keep one entry with the status, rating and notes set last. This cannot be undone.", + "CONFIRM_BUTTON": "Merge", + "BACK": "Back", + "DONE": "Games merged", + "NO_ACCESS": "Only auditors can merge games.", + "ERROR": { + "SAME_GAME": "A game cannot be merged with itself.", + "STEAM_GAMES": "Two Steam games cannot be merged.", + "GAME_NOT_FOUND": "This game is not in ToBePlayed yet, so it cannot be merged.", + "OTHER": "The games could not be merged." + } + }, "HINT": { "CLOSE": "Close", "DO_NOT_SHOW_AGAIN": "Do not show again", diff --git a/src/components/AddGamePanel.jsx b/src/components/AddGamePanel.jsx index 5b33a0f..eb46f63 100644 --- a/src/components/AddGamePanel.jsx +++ b/src/components/AddGamePanel.jsx @@ -1,22 +1,19 @@ -import {useEffect, useState} from 'react'; +import {useState} from 'react'; import {useNavigate} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; import {toast} from 'sonner'; -import {ArrowLeft, Loader2, Plus, Search, X} from 'lucide-react'; +import {ArrowLeft, Loader2, Plus, X} from 'lucide-react'; -import {Badge} from '@/components/ui/badge'; import {Button} from '@/components/ui/button'; import {Card} from '@/components/ui/card'; import {Input} from '@/components/ui/input'; import {Label} from '@/components/ui/label'; import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select'; import GameCover from './GameCover.jsx'; +import GameSearch from './GameSearch.jsx'; import useSessionExpired from '../hooks/useSessionExpired.js'; import {GAME_STATUSES} from '../utils/backlog.js'; -import {addGame, hoursToMinutes, searchGames} from '../utils/manualGames.js'; - -const SEARCH_DEBOUNCE_MS = 400; -const MIN_QUERY = 2; +import {addGame, hoursToMinutes} from '../utils/manualGames.js'; /** * Search IGDB and add a game the user owns outside Steam, e.g. on PlayStation or Switch. @@ -26,44 +23,8 @@ export default function AddGamePanel({onClose}) { const navigate = useNavigate(); const expireSession = useSessionExpired(); const [query, setQuery] = useState(''); - // idle | loading | done | unavailable | failed - const [searchState, setSearchState] = useState('idle'); - const [results, setResults] = useState([]); const [picked, setPicked] = useState(null); - useEffect(() => { - const trimmed = query.trim(); - if (trimmed.length < MIN_QUERY) { - setSearchState('idle'); - setResults([]); - return undefined; - } - const controller = new AbortController(); - const timer = setTimeout(async () => { - setSearchState('loading'); - try { - const response = await searchGames(trimmed, controller.signal); - if (response.status === 401) { - expireSession(); - return; - } - if (!response.ok) { - setSearchState('failed'); - return; - } - const data = await response.json(); - setResults(data.results ?? []); - setSearchState(data.available ? 'done' : 'unavailable'); - } catch (err) { - if (err.name !== 'AbortError') setSearchState('failed'); - } - }, SEARCH_DEBOUNCE_MS); - return () => { - clearTimeout(timer); - controller.abort(); - }; - }, [query, expireSession]); - return (
@@ -81,45 +42,7 @@ export default function AddGamePanel({onClose}) { ) : ( <>

{t('MANUAL_GAME.ADD_HINT')}

-
- - setQuery(e.target.value)}/> -
- - {searchState === 'loading' && ( -

- {t('MANUAL_GAME.SEARCHING')} -

- )} - {searchState === 'unavailable' &&

{t('MANUAL_GAME.UNAVAILABLE')}

} - {searchState === 'failed' &&

{t('MANUAL_GAME.SEARCH_FAILED')}

} - {searchState === 'done' && results.length === 0 && ( -

{t('MANUAL_GAME.NO_HITS')}

- )} - {searchState === 'done' && results.length > 0 && ( -
    - {results.map((hit) => ( -
  • - -
  • - ))} -
- )} + )} diff --git a/src/components/GameMergeDialog.jsx b/src/components/GameMergeDialog.jsx new file mode 100644 index 0000000..617f28f --- /dev/null +++ b/src/components/GameMergeDialog.jsx @@ -0,0 +1,88 @@ +import {useEffect, useState} from 'react'; +import {useTranslation} from 'react-i18next'; +import {ArrowLeft, Loader2, Merge} from 'lucide-react'; +import {toast} from 'sonner'; + +import {Button} from '@/components/ui/button'; +import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from '@/components/ui/dialog'; +import GameSearch from './GameSearch.jsx'; +import useSessionExpired from '../hooks/useSessionExpired.js'; +import {mergeGame} from '../utils/corrections.js'; +import {isSteamKey} from '../utils/manualGames.js'; + +const KNOWN_ERRORS = new Set(['SAME_GAME', 'STEAM_GAMES', 'GAME_NOT_FOUND']); + +/** + * AUDITOR or higher: merges this game with another key of the same game, for every user. onMerged gets the key + * the game lives under now. + */ +export default function GameMergeDialog({game, open, onOpenChange, onMerged}) { + const {t} = useTranslation(); + const expireSession = useSessionExpired(); + const [query, setQuery] = useState(''); + const [picked, setPicked] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + if (open) { + setQuery(''); + setPicked(null); + } + }, [open]); + + const merge = async () => { + setBusy(true); + try { + const response = await mergeGame(game.appid, picked.appid); + if (response.status === 401) { + expireSession(); + return; + } + const data = await response.json().catch(() => ({})); + if (response.ok) { + toast.success(t('MERGE.DONE')); + onOpenChange(false); + onMerged(data.appid); + return; + } + toast.error(KNOWN_ERRORS.has(data.code) ? t(`MERGE.ERROR.${data.code}`) + : response.status === 403 ? t('MERGE.NO_ACCESS') : t('MERGE.ERROR.OTHER')); + } catch (err) { + console.error('Merge error:', err); + toast.error(t('INFO.ERROR.NETWORK_FAILURE')); + } finally { + setBusy(false); + } + }; + + return ( + + + + {t('MERGE.TITLE')} + {t('MERGE.HINT', {name: game.name})} + + {picked ? ( +
+

{t('MERGE.CONFIRM', {name: game.name, other: picked.name})}

+
+ + +
+
+ ) : ( +
+ {/* Core merges only games it knows and refuses two Steam games, so those are not offered. */} + !hit.inCatalog || hit.appid === game.appid + || (isSteamKey(hit.appid) && isSteamKey(game.appid))}/> +
+ )} +
+
+ ); +} diff --git a/src/components/GameMergeDialog.test.jsx b/src/components/GameMergeDialog.test.jsx new file mode 100644 index 0000000..1a58d3a --- /dev/null +++ b/src/components/GameMergeDialog.test.jsx @@ -0,0 +1,83 @@ +// @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 GameMergeDialog from './GameMergeDialog.jsx'; + +vi.mock('react-i18next', () => import('../test/i18n-mock.js')); +vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}})); + +const hades = {appid: 1145360, name: 'Hades'}; +const hit = (appid, name) => ({appid, igdbId: Math.abs(appid), name, year: 2020, imageUrl: null, + platforms: ['Switch'], inLibrary: false, inCatalog: true}); + +const json = (status, body) => ({ok: status < 400, status, json: async () => body}); + +function renderDialog(game, post, results) { + globalThis.fetch = vi.fn(async (url, init) => init?.method === 'POST' + ? post + : json(200, {available: true, results})); + const onMerged = vi.fn(); + render(); + return onMerged; +} + +const search = (value) => fireEvent.change(screen.getByRole('searchbox'), {target: {value}}); + +beforeEach(() => { + localStorage.setItem('tbp_session', '1'); +}); + +afterEach(() => { + cleanup(); + localStorage.clear(); + vi.clearAllMocks(); +}); + +describe('GameMergeDialog', () => { + it('merges the picked game after the confirm step', async () => { + const onMerged = renderDialog(hades, json(200, {appid: 1145360}), + [hit(1145360, 'Hades'), hit(-113112, 'Hades (Switch)'), hit(1145350, 'Hades II'), + {...hit(-4711, 'Hades Fan Remake'), inCatalog: false}]); + search('hades'); + + const offered = await screen.findByRole('button', {name: /Hades \(Switch\)/}); + // The page's own game, other Steam games and games not in ToBePlayed are not offered. + expect(screen.queryByRole('button', {name: /^Hades \(2020\)/})).toBeNull(); + expect(screen.queryByRole('button', {name: /Hades II/})).toBeNull(); + expect(screen.queryByRole('button', {name: /Fan Remake/})).toBeNull(); + fireEvent.click(offered); + expect(fetch).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole('button', {name: 'MERGE.CONFIRM_BUTTON'})); + + await waitFor(() => expect(onMerged).toHaveBeenCalledWith(1145360)); + const [url, init] = fetch.mock.calls[1]; + expect(url).toContain('/api/library/games/1145360/merge'); + expect(JSON.parse(init.body)).toEqual({otherAppid: -113112}); + expect(toast.success).toHaveBeenCalledWith('MERGE.DONE'); + }); + + it('goes back from the confirm step without merging', async () => { + renderDialog(hades, json(200, {}), [hit(-113112, 'Hades (Switch)')]); + search('hades'); + + fireEvent.click(await screen.findByRole('button', {name: /Hades \(Switch\)/})); + fireEvent.click(screen.getByRole('button', {name: 'MERGE.BACK'})); + + expect(await screen.findByRole('searchbox')).toBeTruthy(); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('says why Core refused the merge', async () => { + const onMerged = renderDialog({appid: -113112, name: 'Hades (Switch)'}, + json(404, {code: 'GAME_NOT_FOUND'}), [hit(1145360, 'Hades')]); + search('hades'); + + fireEvent.click(await screen.findByRole('button', {name: /Hades \(2020\)/})); + fireEvent.click(screen.getByRole('button', {name: 'MERGE.CONFIRM_BUTTON'})); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('MERGE.ERROR.GAME_NOT_FOUND')); + expect(onMerged).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/GameSearch.jsx b/src/components/GameSearch.jsx new file mode 100644 index 0000000..b106ecf --- /dev/null +++ b/src/components/GameSearch.jsx @@ -0,0 +1,111 @@ +import {useEffect, useState} from 'react'; +import {useTranslation} from 'react-i18next'; +import {Loader2, Search} from 'lucide-react'; + +import {Badge} from '@/components/ui/badge'; +import {Input} from '@/components/ui/input'; +import GameCover from './GameCover.jsx'; +import useSessionExpired from '../hooks/useSessionExpired.js'; +import {searchGames} from '../utils/manualGames.js'; + +const SEARCH_DEBOUNCE_MS = 400; +const MIN_QUERY = 2; + +/** + * The search field and the hits of Core's game search. The parent keeps the query, so it stays when the user goes + * back. + * + * @param hide hits not offered at all + * @param blockInLibrary hits already in the own library are shown, but cannot be picked + */ +export default function GameSearch({query, onQueryChange, onPick, hide = () => false, blockInLibrary = false}) { + const {t} = useTranslation(); + const {searchState, results} = useGameSearch(query); + const hits = results.filter((hit) => !hide(hit)); + + return ( + <> +
+ + onQueryChange(e.target.value)}/> +
+ + {searchState === 'loading' && ( +

+ {t('MANUAL_GAME.SEARCHING')} +

+ )} + {searchState === 'unavailable' &&

{t('MANUAL_GAME.UNAVAILABLE')}

} + {searchState === 'failed' &&

{t('MANUAL_GAME.SEARCH_FAILED')}

} + {searchState === 'done' && hits.length === 0 && ( +

{t('MANUAL_GAME.NO_HITS')}

+ )} + {searchState === 'done' && hits.length > 0 && ( +
    + {hits.map((hit) => ( +
  • + +
  • + ))} +
+ )} + + ); +} + +// Asks Core 400 ms after the last key press; searchState is idle, loading, done, unavailable or failed. +function useGameSearch(query) { + const expireSession = useSessionExpired(); + const [searchState, setSearchState] = useState('idle'); + const [results, setResults] = useState([]); + + useEffect(() => { + const trimmed = query.trim(); + if (trimmed.length < MIN_QUERY) { + setSearchState('idle'); + setResults([]); + return undefined; + } + const controller = new AbortController(); + const timer = setTimeout(async () => { + setSearchState('loading'); + try { + const response = await searchGames(trimmed, controller.signal); + if (response.status === 401) { + expireSession(); + return; + } + if (!response.ok) { + setSearchState('failed'); + return; + } + const data = await response.json(); + setResults(data.results ?? []); + setSearchState(data.available ? 'done' : 'unavailable'); + } catch (err) { + if (err.name !== 'AbortError') setSearchState('failed'); + } + }, SEARCH_DEBOUNCE_MS); + return () => { + clearTimeout(timer); + controller.abort(); + }; + }, [query, expireSession]); + + return {searchState, results}; +} diff --git a/src/pages/GameDetail.jsx b/src/pages/GameDetail.jsx index e97c463..54a9c9f 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, PencilLine, RotateCcw, ShieldOff, Timer, Trophy} from 'lucide-react'; +import {ArrowLeft, Clock, ExternalLink, Eye, EyeOff, Gamepad2, Image as ImageIcon, ImageOff, Merge, PanelTop, PencilLine, RotateCcw, ShieldOff, Timer, Trophy} from 'lucide-react'; import {toast} from 'sonner'; import {Badge} from '@/components/ui/badge'; @@ -16,6 +16,7 @@ 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 GameMergeDialog from '../components/GameMergeDialog.jsx'; import PlayingHint from '../components/PlayingHint.jsx'; import TimeToBeat from '../components/TimeToBeat.jsx'; import {formatPlaytime} from '../utils/games.js'; @@ -67,6 +68,7 @@ export default function GameDetail() { const [hiding, setHiding] = useState(false); const [error, setError] = useState(null); const [correcting, setCorrecting] = useState(false); + const [merging, setMerging] = useState(false); const [reloads, setReloads] = useState(0); const auditor = canCorrect(useCurrentUser(true)); @@ -222,6 +224,11 @@ export default function GameDetail() { {t('CORRECTION.OPEN')} )} + {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. */} @@ -355,6 +362,13 @@ export default function GameDetail() { setReloads((n) => n + 1)}/> )} + {auditor && data && ( + { + // A new key loads through the address; the same key needs a reload. + if (merged === data.appid) setReloads((n) => n + 1); + else navigate(`/library/${merged}`, {replace: true}); + }}/> + )} ); } diff --git a/src/pages/GameDetail.test.jsx b/src/pages/GameDetail.test.jsx index a4194f9..79eba40 100644 --- a/src/pages/GameDetail.test.jsx +++ b/src/pages/GameDetail.test.jsx @@ -257,6 +257,15 @@ describe('GameDetail of a game added by hand', () => { expect(await screen.findByText('CORRECTION.TITLE')).toBeTruthy(); }); + it('offers "merge duplicate" to an active auditor', async () => { + loggedInAs({role: 'AUDITOR', activeRole: 'AUDITOR'}); + renderDetail(); + + fireEvent.click(await screen.findByRole('button', {name: 'MERGE.OPEN'})); + + expect(await screen.findByText('MERGE.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); @@ -267,6 +276,7 @@ describe('GameDetail of a game added by hand', () => { // Let the answer of /me reach the page before looking for the button. await act(async () => {}); expect(screen.queryByRole('button', {name: 'CORRECTION.OPEN'})).toBeNull(); + expect(screen.queryByRole('button', {name: 'MERGE.OPEN'})).toBeNull(); cleanup(); } }); diff --git a/src/utils/corrections.js b/src/utils/corrections.js index 7814d83..a2156be 100644 --- a/src/utils/corrections.js +++ b/src/utils/corrections.js @@ -33,3 +33,11 @@ export const saveCorrection = (appid, field, value) => * DELETE: back to the source's value, 204. */ export const resetCorrection = (appid, field) => apiFetch(url(appid, field), {method: 'DELETE'}); + +/** + * POST: 200 {appid} = the key the game lives under now; 400 {code: SAME_GAME | STEAM_GAMES}, 403 below AUDITOR, + * 404 {code: GAME_NOT_FOUND} for a game not in ToBePlayed. + */ +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})});