diff --git a/README.md b/README.md index 19b9f7e..f037427 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ To Be Played is split into three repositories: - **Finished?**: Core marks a game as finished by itself when the ending achievement is unlocked and the status was never set by hand. Otherwise the game page asks: "You unlocked the ending, finished?" or "You played longer than most players need, finished?", with "Mark as finished" and "Not yet". -- **Hide games**: a Steam game (e.g. a playtest or a tool) can be hidden on its detail page. It leaves the lists, +- **Hide games**: a game, Steam or added by hand (e.g. a playtest or a tool), can be hidden on its detail page. It leaves the lists, the dashboard, the stats and the public profile; the library's "Hidden" filter shows it again for unhiding. - **Wishlist** with store price and discount. - **Steam account** linked in the settings with "Sign in through Steam": the user signs in on Steam and Auth gets @@ -131,13 +131,13 @@ To Be Played is split into three repositories: - **Correct game data** (AUDITOR or higher): "Correct data" on the game detail page changes name, header image, 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. + every user; an auditor can undo a merge for 30 days from the audit log ("Undo"). An old link of a merged game opens the kept game. - **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, dismissals, corrections and resets with who - did it, filter by action, paged. + players"; Link or Dismiss; paged, 20 pairs a page. **Audit log** (`/audit/log`): the newest merges, merge undos, links, unlinks, dismissals, corrections and resets with who + did it, filter by action, paged; "Undo" on a merge made in the last 30 days. - **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 30ef593..7939d7a 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -269,7 +269,7 @@ "OPEN_IN_STEAM": "Auf Steam ansehen", "HIDE": "Spiel ausblenden", "UNHIDE": "Spiel wieder anzeigen", - "HIDDEN_NOTE": "Ausgeblendet: Das Spiel fehlt in deinen Listen, Statistiken und im öffentlichen Profil. Nur seine Spielzeit wird weiter aktualisiert.", + "HIDDEN_NOTE": "Ausgeblendet: Das Spiel fehlt in deinen Listen, Statistiken und im öffentlichen Profil.", "HIDDEN_TOAST": "Spiel ausgeblendet", "SHOWN_TOAST": "Spiel wird wieder angezeigt", "HIDE_ERROR": "Das Spiel konnte nicht geändert werden.", @@ -650,7 +650,7 @@ "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": "„{{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 30 Tage lang im Audit-Log rückgängig machen.", "CONFIRM_BUTTON": "Zusammenführen", "BACK": "Zurück", "DONE": "Spiele zusammengeführt", @@ -710,11 +710,24 @@ "NO_ACCESS": "Nur Auditoren können diese Seite öffnen.", "LOAD_ERROR": "Das Protokoll konnte nicht geladen werden.", "USER_ID": "Nutzer {{id}}", + "UNDO": { + "BUTTON": "Rückgängig", + "CONFIRM": "Die Zusammenführung von „{{name}}“ und „{{other}}“ rückgängig machen? Beide Spiele kommen für alle Nutzer zurück. Status, Bewertung und Notizen, die seitdem gesetzt wurden, bleiben erhalten.", + "DONE": "Zusammenführung rückgängig gemacht.", + "ERROR": "Die Zusammenführung konnte nicht rückgängig gemacht werden.", + "ERROR_CODES": { + "MERGE_NOT_FOUND": "Diese Zusammenführung gibt es nicht.", + "UNDO_NOT_AVAILABLE": "Diese Zusammenführung wurde schon rückgängig gemacht oder ist zu alt.", + "NEWER_MERGE": "Mache zuerst die neuere Zusammenführung dieses Spiels rückgängig.", + "UNDO_EXPIRED": "Die Frist von 30 Tagen für diese Zusammenführung ist abgelaufen." + } + }, "PAGE_OF": "Seite {{current}} von {{total}}", "ACTION": { "LABEL": "Aktion", "ALL": "Alle Aktionen", "GAME_MERGE": "Zusammengeführt", + "GAME_MERGE_UNDO": "Zusammenführung rückgängig", "GAME_LINK": "Verknüpft", "GAME_UNLINK": "Verknüpfung gelöst", "GAME_LINK_DISMISS": "Verworfen", diff --git a/public/i18n/en.json b/public/i18n/en.json index 17d4c05..d2efbd0 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -269,7 +269,7 @@ "OPEN_IN_STEAM": "View on Steam", "HIDE": "Hide game", "UNHIDE": "Show game again", - "HIDDEN_NOTE": "Hidden: this game is not in your library lists, stats or public profile. Only its playtime is still updated.", + "HIDDEN_NOTE": "Hidden: this game is not in your library lists, stats or public profile.", "HIDDEN_TOAST": "Game hidden", "SHOWN_TOAST": "Game shown again", "HIDE_ERROR": "Could not change the game.", @@ -650,7 +650,7 @@ "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": "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 can be undone for 30 days in the audit log.", "CONFIRM_BUTTON": "Merge", "BACK": "Back", "DONE": "Games merged", @@ -710,11 +710,24 @@ "NO_ACCESS": "Only auditors can open this page.", "LOAD_ERROR": "The audit log could not be loaded.", "USER_ID": "user {{id}}", + "UNDO": { + "BUTTON": "Undo", + "CONFIRM": "Undo the merge of \"{{name}}\" and \"{{other}}\"? Both games come back for every user. Status, rating and notes set since the merge are kept.", + "DONE": "Merge undone.", + "ERROR": "The merge could not be undone.", + "ERROR_CODES": { + "MERGE_NOT_FOUND": "This merge does not exist.", + "UNDO_NOT_AVAILABLE": "This merge was already undone or is too old to undo.", + "NEWER_MERGE": "Undo the newer merge of this game first.", + "UNDO_EXPIRED": "The undo of this merge expired after 30 days." + } + }, "PAGE_OF": "Page {{current}} of {{total}}", "ACTION": { "LABEL": "Action", "ALL": "All actions", "GAME_MERGE": "Merge", + "GAME_MERGE_UNDO": "Merge undone", "GAME_LINK": "Link", "GAME_UNLINK": "Unlink", "GAME_LINK_DISMISS": "Dismissed", diff --git a/src/components/GameHeroCard.jsx b/src/components/GameHeroCard.jsx index 7e5aaa8..9214672 100644 --- a/src/components/GameHeroCard.jsx +++ b/src/components/GameHeroCard.jsx @@ -23,7 +23,7 @@ const SPEEDRUN_PREFIX = 'https://www.speedrun.com/'; // The spans of each completed effect decoration; effects.css places them. const DETAIL_ELEMENTS = {'fx-detail-gold-shine': 8, 'fx-detail-neon-pulse': 1, 'fx-detail-holo': 1, 'fx-detail-ember': 6}; -export default function GameHeroCard({game, manual, auditor, profileStyle, onCorrect, onMerge, onHiddenChanged, onProfileStyleSaved}) { +export default function GameHeroCard({game, auditor, profileStyle, onCorrect, onMerge, onHiddenChanged, onProfileStyleSaved}) { const {t} = useTranslation(); const {appearance, equipped} = usePreferences(); const saveAppearance = useSaveAppearance(); @@ -102,43 +102,42 @@ export default function GameHeroCard({game, manual, auditor, profileStyle, onCor )} - {/* 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. */} - {onSteam &&
- + {isBackground ? ( + + ) : ( + + )} + {isBanner ? ( + + ) : ( + + )} + {profileStyle && ( + + )} + )} + - {isBackground ? ( - - ) : ( - - )} - {isBanner ? ( - - ) : ( - - )} - {profileStyle && ( - - )} - {!manual && ( - - )} -
} + ); diff --git a/src/pages/AuditLogPage.jsx b/src/pages/AuditLogPage.jsx index c99f062..dfca2bc 100644 --- a/src/pages/AuditLogPage.jsx +++ b/src/pages/AuditLogPage.jsx @@ -1,17 +1,18 @@ import {useCallback, useEffect, useState} from 'react'; import {Link} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; -import {ChevronLeft, ChevronRight, Loader2} from 'lucide-react'; +import {ChevronLeft, ChevronRight, Loader2, Undo2} from 'lucide-react'; import {Button} from '@/components/ui/button'; import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'; import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select'; import PageContainer from '../components/PageContainer.jsx'; -import {fetchAuditLog} from '../utils/links.js'; +import {toast} from 'sonner'; +import {fetchAuditLog, undoMerge} 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', 'GAME_CORRECT', +const ACTIONS = ['GAME_MERGE', 'GAME_MERGE_UNDO', 'GAME_LINK', 'GAME_UNLINK', 'GAME_LINK_DISMISS', 'GAME_CORRECT', 'GAME_CORRECTION_RESET']; const ALL = 'ALL'; @@ -29,6 +30,7 @@ export default function AuditLogPage() { const [page, setPage] = useState(0); const [result, setResult] = useState({content: [], totalPages: 0}); const [status, setStatus] = useState('loading'); + const [undoing, setUndoing] = useState(false); const load = useCallback(async () => { try { @@ -54,6 +56,30 @@ export default function AuditLogPage() { load(); }, [load]); + const onUndo = async (entry) => { + // The merged key has no catalog row any more; its name is the first one in the detail. + const name = entry.gameName ?? /^"(.*?)" into "/.exec(entry.detail ?? '')?.[1] ?? entry.appid; + const other = entry.targetGameName ?? entry.targetAppid; + if (!window.confirm(t('AUDIT.UNDO.CONFIRM', {name, other}))) return; + setUndoing(true); + try { + const response = await undoMerge(entry.id); + if (response.status === 401) return; + if (response.ok) { + toast.success(t('AUDIT.UNDO.DONE')); + } else { + const data = await response.json().catch(() => ({})); + toast.error(t(`AUDIT.UNDO.ERROR_CODES.${data.code}`, {defaultValue: t('AUDIT.UNDO.ERROR')})); + } + await load(); + } catch (err) { + console.error('Undo merge error:', err); + toast.error(t('INFO.ERROR.NETWORK_FAILURE')); + } finally { + setUndoing(false); + } + }; + const onActionChange = (value) => { setAction(value); setPage(0); @@ -100,6 +126,12 @@ export default function AuditLogPage() {

{entry.detail &&

{entry.detail}

} + {entry.action === 'GAME_MERGE' && entry.undoable && ( + + )} ))} diff --git a/src/pages/AuditLogPage.test.jsx b/src/pages/AuditLogPage.test.jsx index b84be2c..a81d7a5 100644 --- a/src/pages/AuditLogPage.test.jsx +++ b/src/pages/AuditLogPage.test.jsx @@ -5,16 +5,21 @@ import {MemoryRouter} from 'react-router-dom'; import AuditLogPage from './AuditLogPage.jsx'; import {PreferencesProvider} from '../context/PreferencesContext.jsx'; import {fetchRoutes, json} from '../test/fetchRoutes.js'; +import {toast} from 'sonner'; vi.mock('react-i18next', () => import('../test/i18n-mock.js')); +vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}})); const entry = (over) => ({id: 1, actorUserId: 5, actorName: 'lobo', action: 'GAME_LINK', appid: 10, gameName: 'Dark Souls', targetAppid: -7, targetGameName: 'Dark Souls Remastered', detail: '"Dark Souls" with "Dark Souls Remastered"', createdAt: '2026-10-01T10:00:00Z', ...over}); const answer = (content, totalPages = 1) => json(200, {content, page: 0, size: 20, totalElements: content.length, totalPages}); -const renderPage = (route) => { - fetchRoutes({'GET /api/library/audit-log': route}); +const merge = (over) => entry({id: 7, action: 'GAME_MERGE', gameName: null, targetGameName: 'Hades', undoable: true, + detail: '"Hades (Switch)" into "Hades": 1 moved, 0 combined', ...over}); + +const renderPage = (route, more = {}) => { + fetchRoutes({'GET /api/library/audit-log': route, ...more}); render(); }; @@ -24,6 +29,8 @@ beforeEach(() => { afterEach(() => { cleanup(); + vi.restoreAllMocks(); + vi.clearAllMocks(); localStorage.clear(); }); @@ -58,4 +65,42 @@ describe('AuditLogPage', () => { expect(await screen.findByText('AUDIT.NO_ACCESS')).toBeTruthy(); }); + + it('showsUndoOnlyOnAnUndoableMerge', async () => { + renderPage(answer([merge({}), merge({id: 8, undoable: false}), entry({id: 9, undoable: true})])); + await screen.findAllByText('AUDIT.ACTION.GAME_MERGE'); + + expect(screen.getAllByRole('button', {name: /AUDIT.UNDO.BUTTON/})).toHaveLength(1); + }); + + it('undoesAfterConfirmAndReloads', async () => { + const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true); + renderPage(answer([merge({})]), {'POST /api/library/merges/7/undo': json(204, null)}); + + fireEvent.click(await screen.findByRole('button', {name: /AUDIT.UNDO.BUTTON/})); + + await waitFor(() => expect(toast.success).toHaveBeenCalledWith('AUDIT.UNDO.DONE')); + expect(confirm).toHaveBeenCalledWith('AUDIT.UNDO.CONFIRM'); + expect(fetch.mock.calls.filter(([, init]) => init?.method === 'POST')).toHaveLength(1); + expect(fetch.mock.calls.filter(([url]) => url.includes('/audit-log'))).toHaveLength(2); + }); + + it('doesNothingWhenTheConfirmIsCancelled', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(false); + renderPage(answer([merge({})])); + + fireEvent.click(await screen.findByRole('button', {name: /AUDIT.UNDO.BUTTON/})); + + expect(fetch.mock.calls.filter(([, init]) => init?.method === 'POST')).toHaveLength(0); + expect(toast.success).not.toHaveBeenCalled(); + }); + + it.each(['NEWER_MERGE', 'UNDO_EXPIRED'])('showsTheMessageOf%s', async (code) => { + vi.spyOn(window, 'confirm').mockReturnValue(true); + renderPage(answer([merge({})]), {'POST /api/library/merges/7/undo': json(409, {code})}); + + fireEvent.click(await screen.findByRole('button', {name: /AUDIT.UNDO.BUTTON/})); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith(`AUDIT.UNDO.ERROR_CODES.${code}`)); + }); }); diff --git a/src/pages/GameDetail.jsx b/src/pages/GameDetail.jsx index 5855118..d365a71 100644 --- a/src/pages/GameDetail.jsx +++ b/src/pages/GameDetail.jsx @@ -47,6 +47,11 @@ export default function GameDetail() { shownLevel.current = {appid: data.appid, level: celebrationLevel(data)}; }, [data]); + // An old key of a merged game: Core answers with the kept game. + useEffect(() => { + if (data && String(data.appid) !== appid) navigate(`/library/${data.appid}`, {replace: true}); + }, [data, appid, navigate]); + const manual = data?.source === 'MANUAL'; return ( @@ -76,7 +81,7 @@ export default function GameDetail() { {t('GAME_DETAIL.HIDDEN_NOTE')} )} - setCorrecting(true)} onMerge={() => setMerging(true)} onHiddenChanged={(hidden) => setData((d) => ({...d, hidden}))} onProfileStyleSaved={setSavedProfileStyle}/> diff --git a/src/pages/GameDetail.test.jsx b/src/pages/GameDetail.test.jsx index 3c9fd7f..cbc50ca 100644 --- a/src/pages/GameDetail.test.jsx +++ b/src/pages/GameDetail.test.jsx @@ -64,7 +64,7 @@ describe('GameDetail', () => { }); it('keeps the game shown when Core refuses', async () => { - answer({...game, source: 'STEAM', hidden: false}, {'PUT /api/library/games/220/hidden': json(409, {code: 'MANUAL_GAME'})}); + answer({...game, source: 'STEAM', hidden: false}, {'PUT /api/library/games/220/hidden': json(500, {})}); renderDetail(); fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.HIDE'})); @@ -73,12 +73,19 @@ describe('GameDetail', () => { expect(screen.queryByText('GAME_DETAIL.HIDDEN_NOTE')).toBeNull(); }); - it('offers no hide for a game added by hand', async () => { - answer({...game, source: 'MANUAL', platform: 'PS5', hidden: false}); - renderDetail(); + it('hides a game added by hand and shows it again', async () => { + answer({...game, appid: -1942, source: 'MANUAL', platform: 'PS5', hidden: false}, + {'PUT /api/library/games/-1942/hidden': echo}); + renderDetail(-1942); - await screen.findByText('GAME_DETAIL.OPEN_IN_STEAM'); - expect(screen.queryByRole('button', {name: 'GAME_DETAIL.HIDE'})).toBeNull(); + fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.HIDE'})); + + expect(await screen.findByText('GAME_DETAIL.HIDDEN_NOTE')).toBeTruthy(); + expect(screen.queryByText('GAME_DETAIL.OPEN_IN_STEAM')).toBeNull(); + + fireEvent.click(screen.getByRole('button', {name: 'GAME_DETAIL.UNHIDE'})); + + await waitFor(() => expect(screen.queryByText('GAME_DETAIL.HIDDEN_NOTE')).toBeNull()); }); it('reports a broken achievement icon once and shows the empty tile', async () => { @@ -419,4 +426,16 @@ describe('GameDetail session', () => { expect(toast.error).not.toHaveBeenCalled(); expect(screen.queryByText('GAME_DETAIL.ERROR_LOAD')).toBeNull(); }); + + it('replacesTheUrlOfAMergedKeyWithTheKeptGame', async () => { + fetchRoutes({ + 'GET /api/users/me': json(200, {settings: {}}), + 'GET /api/library/games/-7': json(200, {...game, appid: 220, source: 'STEAM'}), + 'GET /api/library/games/220': json(200, {...game, appid: 220, source: 'STEAM'}), + }); + const {router} = renderDetail(-7); + + await waitFor(() => expect(router.state.location.pathname).toBe('/library/220')); + expect(router.state.historyAction).toBe('REPLACE'); + }); }); diff --git a/src/utils/links.js b/src/utils/links.js index 8c75d1a..6f69eb7 100644 --- a/src/utils/links.js +++ b/src/utils/links.js @@ -47,3 +47,9 @@ export const fetchAuditLog = ({action, page, size}) => { if (action) params.set('action', action); return apiFetch(`${base()}/audit-log?${params}`); }; + +/** + * POST: 204 = undone; 404 MERGE_NOT_FOUND, 409 UNDO_NOT_AVAILABLE / NEWER_MERGE, 410 UNDO_EXPIRED (body `{code}`), + * 403 below AUDITOR. + */ +export const undoMerge = (mergeId) => apiFetch(`${base()}/merges/${mergeId}/undo`, {method: 'POST'});