From f71e8f7aff9ca5fbb9280829699caea787e7abfb Mon Sep 17 00:00:00 2001 From: lobo Date: Fri, 2 Oct 2026 09:10:56 +0200 Subject: [PATCH] feat(library): add game links, auditor queue, audit log and filter LinkGameDialog (user request or auditor link), EditionsCard on the details page, LinkQueuePage and AuditLogPage for auditors with paging, hideDuplicates filter and editions badge in the library, audit menu entries, de/en texts. --- README.md | 7 ++ public/i18n/de.json | 56 +++++++++- public/i18n/en.json | 56 +++++++++- src/App.jsx | 4 + src/components/AppHeader.jsx | 13 ++- src/components/AppHeader.test.jsx | 11 ++ src/components/EditionsCard.jsx | 72 +++++++++++++ src/components/LinkGameDialog.jsx | 129 ++++++++++++++++++++++ src/components/LinkGameDialog.test.jsx | 79 ++++++++++++++ src/i18n.test.js | 10 ++ src/pages/AuditLogPage.jsx | 123 +++++++++++++++++++++ src/pages/AuditLogPage.test.jsx | 61 +++++++++++ src/pages/GameDetail.jsx | 6 ++ src/pages/GameDetail.test.jsx | 32 ++++++ src/pages/GameLibrary.jsx | 26 ++++- src/pages/GameLibrary.test.jsx | 19 ++++ src/pages/LinkQueuePage.jsx | 144 +++++++++++++++++++++++++ src/pages/LinkQueuePage.test.jsx | 95 ++++++++++++++++ src/utils/backlog.js | 3 +- src/utils/backlog.test.js | 8 ++ src/utils/links.js | 49 +++++++++ 21 files changed, 992 insertions(+), 11 deletions(-) create mode 100644 src/components/EditionsCard.jsx create mode 100644 src/components/LinkGameDialog.jsx create mode 100644 src/components/LinkGameDialog.test.jsx create mode 100644 src/pages/AuditLogPage.jsx create mode 100644 src/pages/AuditLogPage.test.jsx create mode 100644 src/pages/LinkQueuePage.jsx create mode 100644 src/pages/LinkQueuePage.test.jsx create mode 100644 src/utils/links.js diff --git a/README.md b/README.md index 1ae1acb..d407686 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,10 @@ To Be Played is split into three repositories: finished games per year of all users. - **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. +- **Editions**: an auditor links the editions of one game (Steam and PS5 copy, remaster, port) for every user. A user + who owns two or more of a linked set sees "Also in your library" on the details page, an "x2" badge in the library + and the filter "Hide duplicates"; the numbers count the set once. "Same game as..." on the details page asks the + auditors for a link. - **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. Games already in ToBePlayed come first. Check several hits to add them at once through the import review. @@ -123,6 +127,9 @@ To Be Played is split into three repositories: 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. +- **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 + 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. - **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 e22cdfa..9753e00 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -229,7 +229,9 @@ "FILTER_SHORT": "Kurz (bis 10 h)", "FILTER_HIDDEN": "Ausgeblendet", "NO_HIDDEN": "Keine ausgeblendeten Spiele. Blende ein Spiel auf seiner Detailseite aus, z. B. einen Playtest oder ein Tool.", - "ACHIEVEMENTS": "{{unlocked}} von {{total}} Erfolgen" + "ACHIEVEMENTS": "{{unlocked}} von {{total}} Erfolgen", + "FILTER_HIDE_DUPLICATES": "Duplikate ausblenden", + "EDITIONS": "{{count}} Ausgaben in deiner Bibliothek" }, "GAME_DETAIL": { "BACK": "Zurück", @@ -319,7 +321,9 @@ "ADMIN_NEWS": "Neuigkeiten verwalten", "ADMIN_BROADCASTS": "Rundmail", "PENDING_REQUESTS_one": "{{count}} Registrierung wartet", - "PENDING_REQUESTS_other": "{{count}} Registrierungen warten" + "PENDING_REQUESTS_other": "{{count}} Registrierungen warten", + "LINK_QUEUE": "Verknüpfungen", + "AUDIT_LOG": "Protokoll" }, "BACKLOG": { "TITLE": "Mein Backlog", @@ -622,6 +626,54 @@ "OTHER": "Die Spiele konnten nicht zusammengeführt werden." } }, + "LINKS": { + "ALSO_IN_LIBRARY": "Auch in deiner Bibliothek:", + "SAME_AS": "Dasselbe Spiel wie...", + "LINK_WITH": "Verknüpfen mit...", + "DIALOG_HINT": "Wähle das Spiel deiner Bibliothek, das dasselbe Spiel wie \"{{name}}\" ist.", + "SEARCH_PLACEHOLDER": "Deine Bibliothek durchsuchen ...", + "NO_HITS": "Kein Spiel in deiner Bibliothek gefunden.", + "REQUEST_CONFIRM": "Einen Auditor bitten, \"{{name}}\" und \"{{other}}\" für alle als ein Spiel zu verknüpfen?", + "LINK_CONFIRM": "\"{{name}}\" und \"{{other}}\" für alle Nutzer als ein Spiel verknüpfen?", + "REQUEST": "Anfrage senden", + "LINK": "Verknüpfen", + "UNLINK": "Verknüpfung lösen", + "DISMISS": "Nicht dasselbe", + "REQUESTED": "Anfrage gesendet", + "LINKED": "Spiele verknüpft", + "UNLINKED": "Verknüpfung gelöst", + "DISMISSED": "Paar verworfen", + "QUEUE_TITLE": "Verknüpfungen prüfen", + "QUEUE_EMPTY": "Nichts zu prüfen.", + "PAGE_OF": "Seite {{current}} von {{total}}", + "FROM_IGDB": "IGDB", + "REQUESTED_BY": "von {{count}} Spielern angefragt", + "NO_ACCESS": "Nur Auditoren können diese Seite öffnen.", + "ERROR": "Die Aktion ist fehlgeschlagen.", + "ERROR_CODES": { + "SAME_GAME": "Ein Spiel kann nicht mit sich selbst verknüpft werden.", + "ALREADY_LINKED": "Diese Spiele sind schon verknüpft.", + "PAIR_DISMISSED": "Ein Auditor hat bereits entschieden, dass das verschiedene Spiele sind.", + "TOO_MANY_REQUESTS": "Du hast 20 offene Anfragen. Warte, bis ein Auditor sie entschieden hat.", + "GAME_NOT_FOUND": "Dieses Spiel ist nicht in deiner Bibliothek." + } + }, + "AUDIT": { + "TITLE": "Protokoll", + "EMPTY": "Keine Einträge.", + "NO_ACCESS": "Nur Auditoren können diese Seite öffnen.", + "LOAD_ERROR": "Das Protokoll konnte nicht geladen werden.", + "USER_ID": "Nutzer {{id}}", + "PAGE_OF": "Seite {{current}} von {{total}}", + "ACTION": { + "LABEL": "Aktion", + "ALL": "Alle Aktionen", + "GAME_MERGE": "Zusammengeführt", + "GAME_LINK": "Verknüpft", + "GAME_UNLINK": "Verknüpfung gelöst", + "GAME_LINK_DISMISS": "Verworfen" + } + }, "HINT": { "CLOSE": "Schließen", "DO_NOT_SHOW_AGAIN": "Nicht mehr anzeigen", diff --git a/public/i18n/en.json b/public/i18n/en.json index 4414511..9fafd16 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -229,7 +229,9 @@ "FILTER_SHORT": "Short (up to 10 h)", "FILTER_HIDDEN": "Hidden", "NO_HIDDEN": "No hidden games. Hide a game on its detail page, e.g. a playtest or a tool.", - "ACHIEVEMENTS": "{{unlocked}} of {{total}} achievements" + "ACHIEVEMENTS": "{{unlocked}} of {{total}} achievements", + "FILTER_HIDE_DUPLICATES": "Hide duplicates", + "EDITIONS": "{{count}} editions in your library" }, "GAME_DETAIL": { "BACK": "Back", @@ -319,7 +321,9 @@ "ADMIN_NEWS": "News", "ADMIN_BROADCASTS": "Mail", "PENDING_REQUESTS_one": "{{count}} registration waits", - "PENDING_REQUESTS_other": "{{count}} registrations wait" + "PENDING_REQUESTS_other": "{{count}} registrations wait", + "LINK_QUEUE": "Link queue", + "AUDIT_LOG": "Audit log" }, "BACKLOG": { "TITLE": "My backlog", @@ -622,6 +626,54 @@ "OTHER": "The games could not be merged." } }, + "LINKS": { + "ALSO_IN_LIBRARY": "Also in your library:", + "SAME_AS": "Same game as...", + "LINK_WITH": "Link with...", + "DIALOG_HINT": "Pick the game of your library that is the same game as \"{{name}}\".", + "SEARCH_PLACEHOLDER": "Search your library ...", + "NO_HITS": "No game found in your library.", + "REQUEST_CONFIRM": "Ask an auditor to link \"{{name}}\" and \"{{other}}\" as one game for everyone?", + "LINK_CONFIRM": "Link \"{{name}}\" and \"{{other}}\" as one game for every user?", + "REQUEST": "Send request", + "LINK": "Link", + "UNLINK": "Unlink", + "DISMISS": "Not the same", + "REQUESTED": "Request sent", + "LINKED": "Games linked", + "UNLINKED": "Link removed", + "DISMISSED": "Pair dismissed", + "QUEUE_TITLE": "Link queue", + "QUEUE_EMPTY": "Nothing to check.", + "PAGE_OF": "Page {{current}} of {{total}}", + "FROM_IGDB": "IGDB", + "REQUESTED_BY": "requested by {{count}} players", + "NO_ACCESS": "Only auditors can open this page.", + "ERROR": "The action failed.", + "ERROR_CODES": { + "SAME_GAME": "A game cannot be linked with itself.", + "ALREADY_LINKED": "These games are already linked.", + "PAIR_DISMISSED": "An auditor already decided that these are different games.", + "TOO_MANY_REQUESTS": "You have 20 open requests. Wait until an auditor decided them.", + "GAME_NOT_FOUND": "This game is not in your library." + } + }, + "AUDIT": { + "TITLE": "Audit log", + "EMPTY": "No entries.", + "NO_ACCESS": "Only auditors can open this page.", + "LOAD_ERROR": "The audit log could not be loaded.", + "USER_ID": "user {{id}}", + "PAGE_OF": "Page {{current}} of {{total}}", + "ACTION": { + "LABEL": "Action", + "ALL": "All actions", + "GAME_MERGE": "Merge", + "GAME_LINK": "Link", + "GAME_UNLINK": "Unlink", + "GAME_LINK_DISMISS": "Dismissed" + } + }, "HINT": { "CLOSE": "Close", "DO_NOT_SHOW_AGAIN": "Do not show again", diff --git a/src/App.jsx b/src/App.jsx index 076f1e6..fb79590 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -16,6 +16,8 @@ import WhatsNewPage from './pages/WhatsNewPage.jsx'; 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 LinkQueuePage from './pages/LinkQueuePage.jsx'; import GameLibrary from './pages/GameLibrary.jsx'; import GameDetail from './pages/GameDetail.jsx'; import WishlistPage from './pages/WishlistPage.jsx'; @@ -85,6 +87,8 @@ function App() { }/> }/> }/> + }/> + }/> }/> }/> }/> diff --git a/src/components/AppHeader.jsx b/src/components/AppHeader.jsx index f63f5c6..4d3b45a 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, LogOut, Mail, Megaphone, Moon, Newspaper, Settings, Shield, Sun} from 'lucide-react'; +import {ChartColumn, Coins, Dices, Eye, Languages, LayoutDashboard, Library, Link2, LogOut, Mail, Megaphone, Moon, Newspaper, ScrollText, Settings, Shield, Sun} from 'lucide-react'; import {toast} from 'sonner'; import {Badge} from '@/components/ui/badge'; @@ -24,6 +24,7 @@ import SteamAvatar from './SteamAvatar.jsx'; import useCurrentUser from '../hooks/useCurrentUser.js'; import useSteamProfile from '../hooks/useSteamProfile.js'; import {fetchPendingCount, ROLES, viewAs} from '../utils/admin.js'; +import {canCorrect} from '../utils/corrections.js'; import {expireSession, markSession} from '../utils/auth.js'; const NAV_ITEMS = [ @@ -155,6 +156,16 @@ export default function AppHeader({isAuthenticated, onLogout}) { {t('NAV.WHATS_NEW')} + {canCorrect(currentUser) && ( + <> + + {t('NAV.LINK_QUEUE')} + + + {t('NAV.AUDIT_LOG')} + + + )} {seesAdminPage && ( <> diff --git a/src/components/AppHeader.test.jsx b/src/components/AppHeader.test.jsx index f90bfcd..45380f5 100644 --- a/src/components/AppHeader.test.jsx +++ b/src/components/AppHeader.test.jsx @@ -88,6 +88,16 @@ describe('AppHeader', () => { expect(screen.queryByText('NAV.ADMIN_BROADCASTS')).toBeNull(); }); + it('offersTheLinkQueueAndAuditLogToAnAuditorOnly', 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(screen.getByText('NAV.AUDIT_LOG')).toBeTruthy(); + expect(screen.queryByText('NAV.ADMIN')).toBeNull(); + }); + it('offers nothing of that to a user', async () => { loggedInAs({role: 'USER', activeRole: 'USER'}); await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/users/me'), expect.anything())); @@ -97,6 +107,7 @@ describe('AppHeader', () => { expect(await screen.findByText('NAV.SETTINGS')).toBeTruthy(); expect(screen.getByText('NAV.WHATS_NEW')).toBeTruthy(); expect(screen.queryByText('NAV.ADMIN')).toBeNull(); + expect(screen.queryByText('NAV.LINK_QUEUE')).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/EditionsCard.jsx b/src/components/EditionsCard.jsx new file mode 100644 index 0000000..10ebc29 --- /dev/null +++ b/src/components/EditionsCard.jsx @@ -0,0 +1,72 @@ +import {useState} from 'react'; +import {Link} from 'react-router-dom'; +import {useTranslation} from 'react-i18next'; +import {Layers, Link2Off} from 'lucide-react'; +import {toast} from 'sonner'; + +import {Button} from '@/components/ui/button'; +import {Card} from '@/components/ui/card'; +import GameStatusBadge from './GameStatusBadge.jsx'; +import {unlinkGame} from '../utils/links.js'; +import {platformLabel} from '../utils/platforms.js'; + +/** + * The other editions of this game in the own library, and the buttons to ask for a link (everyone) or to unlink + * (auditor). Shown on every game: a request must be possible from any game. + */ +export default function EditionsCard({game, auditor, onRequest, onChanged}) { + const {t} = useTranslation(); + const [busy, setBusy] = useState(false); + + const unlink = async () => { + setBusy(true); + try { + const response = await unlinkGame(game.appid); + if (response.status === 401) return; + if (response.ok) { + toast.success(t('LINKS.UNLINKED')); + onChanged(); + } else { + toast.error(t('LINKS.ERROR')); + } + } catch (err) { + console.error('Unlink error:', err); + toast.error(t('INFO.ERROR.NETWORK_FAILURE')); + } finally { + setBusy(false); + } + }; + + return ( + + {game.editions?.length > 0 && ( +
+

{t('LINKS.ALSO_IN_LIBRARY')}

+
    + {game.editions.map((edition) => ( +
  • + + {edition.name} + + {platformLabel(edition) && ( + ({platformLabel(edition)}) + )} + +
  • + ))} +
+
+ )} +
+ + {auditor && game.linked && ( + + )} +
+
+ ); +} diff --git a/src/components/LinkGameDialog.jsx b/src/components/LinkGameDialog.jsx new file mode 100644 index 0000000..9441702 --- /dev/null +++ b/src/components/LinkGameDialog.jsx @@ -0,0 +1,129 @@ +import {useEffect, useState} from 'react'; +import {useTranslation} from 'react-i18next'; +import {ArrowLeft, Link2, Loader2, Search, Send} from 'lucide-react'; +import {toast} from 'sonner'; + +import {Button} from '@/components/ui/button'; +import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from '@/components/ui/dialog'; +import {Input} from '@/components/ui/input'; +import GameCover from './GameCover.jsx'; +import {linkGames, requestLink, searchOwnGames} from '../utils/links.js'; +import {MAX_LIBRARY_SEARCH_LENGTH} from '../utils/limits.js'; +import {platformLabel} from '../utils/platforms.js'; + +const SEARCH_DEBOUNCE_MS = 300; +const KNOWN_ERRORS = new Set(['SAME_GAME', 'ALREADY_LINKED', 'PAIR_DISMISSED', 'TOO_MANY_REQUESTS', 'GAME_NOT_FOUND']); + +/** + * Picks a game of the own library that is the same game as this one. A user sends a request to the auditors; an + * auditor links at once. + */ +export default function LinkGameDialog({game, open, onOpenChange, auditor, onDone}) { + const {t} = useTranslation(); + const [query, setQuery] = useState(''); + const [hits, setHits] = useState([]); + const [picked, setPicked] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + if (open) { + setQuery(''); + setHits([]); + setPicked(null); + } + }, [open]); + + useEffect(() => { + if (!open || !query.trim()) { + setHits([]); + return undefined; + } + const controller = new AbortController(); + const timer = setTimeout(async () => { + const {ok, data} = await searchOwnGames(query.trim(), controller.signal); + if (ok && data) setHits(data.content); + }, SEARCH_DEBOUNCE_MS); + return () => { + clearTimeout(timer); + controller.abort(); + }; + }, [open, query]); + + const send = async () => { + setBusy(true); + try { + const response = await (auditor ? linkGames : requestLink)(game.appid, picked.appid); + if (response.status === 401) return; + if (response.ok) { + toast.success(t(auditor ? 'LINKS.LINKED' : 'LINKS.REQUESTED')); + onOpenChange(false); + if (auditor) onDone(); + return; + } + const data = await response.json().catch(() => ({})); + toast.error(t(KNOWN_ERRORS.has(data.code) ? `LINKS.ERROR_CODES.${data.code}` : 'LINKS.ERROR')); + } catch (err) { + console.error('Link error:', err); + toast.error(t('INFO.ERROR.NETWORK_FAILURE')); + } finally { + setBusy(false); + } + }; + + const offered = hits.filter((hit) => hit.appid !== game.appid); + + return ( + + + + {t('LINKS.SAME_AS')} + {t('LINKS.DIALOG_HINT', {name: game.name})} + + {picked ? ( +
+

+ {t(auditor ? 'LINKS.LINK_CONFIRM' : 'LINKS.REQUEST_CONFIRM', {name: game.name, other: picked.name})} +

+
+ + +
+
+ ) : ( +
+
+ + setQuery(e.target.value)}/> +
+ {query.trim() && offered.length === 0 && ( +

{t('LINKS.NO_HITS')}

+ )} + {offered.length > 0 && ( +
    + {offered.map((hit) => ( +
  • + +
  • + ))} +
+ )} +
+ )} +
+
+ ); +} diff --git a/src/components/LinkGameDialog.test.jsx b/src/components/LinkGameDialog.test.jsx new file mode 100644 index 0000000..7d3f6b0 --- /dev/null +++ b/src/components/LinkGameDialog.test.jsx @@ -0,0 +1,79 @@ +// @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 LinkGameDialog from './LinkGameDialog.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 hades = {appid: 1145360, name: 'Hades'}; +const owned = [ + {appid: 1145360, name: 'Hades', source: 'STEAM'}, + {appid: -113112, name: 'Hades (Switch)', source: 'MANUAL', platform: 'Switch'}, +]; + +function renderDialog({auditor = false, post}) { + fetchRoutes({ + 'GET /api/library/games': json(200, {content: owned}), + 'POST /api/library/games/1145360/link-requests': post, + 'POST /api/library/games/1145360/link': post, + }); + const onDone = vi.fn(); + render(); + return onDone; +} + +const pickSwitch = async () => { + fireEvent.change(screen.getByRole('searchbox'), {target: {value: 'hades'}}); + // The own game is not offered. + fireEvent.click(await screen.findByRole('button', {name: /Hades \(Switch\)/})); + expect(screen.queryByRole('button', {name: /^Hades$/})).toBeNull(); +}; + +beforeEach(() => { + localStorage.setItem('tbp_session', '1'); +}); + +afterEach(() => { + cleanup(); + localStorage.clear(); + vi.clearAllMocks(); +}); + +describe('LinkGameDialog', () => { + it('sendsARequestAfterTheConfirmStep', async () => { + renderDialog({post: json(204)}); + await pickSwitch(); + + expect(screen.getByText('LINKS.REQUEST_CONFIRM')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', {name: 'LINKS.REQUEST'})); + + await waitFor(() => expect(toast.success).toHaveBeenCalledWith('LINKS.REQUESTED')); + const [url, init] = fetch.mock.calls.at(-1); + expect(url).toContain('/api/library/games/1145360/link-requests'); + expect(JSON.parse(init.body)).toEqual({otherAppid: -113112}); + }); + + it('linksAtOnceForAnAuditorAndCallsOnDone', async () => { + const onDone = renderDialog({auditor: true, post: json(204)}); + await pickSwitch(); + + fireEvent.click(screen.getByRole('button', {name: 'LINKS.LINK'})); + + await waitFor(() => expect(onDone).toHaveBeenCalled()); + expect(fetch.mock.calls.at(-1)[0]).toMatch(/\/games\/1145360\/link$/); + expect(toast.success).toHaveBeenCalledWith('LINKS.LINKED'); + }); + + it('showsTheTooManyRequestsText', async () => { + renderDialog({post: json(429, {code: 'TOO_MANY_REQUESTS'})}); + await pickSwitch(); + + fireEvent.click(screen.getByRole('button', {name: 'LINKS.REQUEST'})); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('LINKS.ERROR_CODES.TOO_MANY_REQUESTS')); + }); +}); diff --git a/src/i18n.test.js b/src/i18n.test.js index 439279c..8d9deab 100644 --- a/src/i18n.test.js +++ b/src/i18n.test.js @@ -31,3 +31,13 @@ describe('admin broadcast texts', () => { } }); }); + +describe('translation keys', () => { + it('de_And_En_HaveTheSameKeys', () => { + const dir = path.resolve(import.meta.dirname, '../public/i18n'); + const keys = (value, prefix = '') => Object.entries(value).flatMap(([key, child]) => + (typeof child === 'object' ? keys(child, `${prefix}${key}.`) : [`${prefix}${key}`])); + const read = (file) => keys(JSON.parse(readFileSync(path.join(dir, file), 'utf8'))).sort(); + expect(read('de.json')).toEqual(read('en.json')); + }); +}); diff --git a/src/pages/AuditLogPage.jsx b/src/pages/AuditLogPage.jsx new file mode 100644 index 0000000..c3e7b28 --- /dev/null +++ b/src/pages/AuditLogPage.jsx @@ -0,0 +1,123 @@ +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 {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 {formatDateTime} from '../utils/format.js'; + +const PAGE_SIZE = 20; +const ACTIONS = ['GAME_MERGE', 'GAME_LINK', 'GAME_UNLINK', 'GAME_LINK_DISMISS']; +const ALL = 'ALL'; + +function GameRef({appid, name, fallback}) { + if (appid == null) return null; + return {name ?? fallback}; +} + +/** + * AUDITOR or higher: the newest rows of Core's audit log (merge, link, unlink, dismiss). Core checks the role. + */ +export default function AuditLogPage() { + const {t, i18n} = useTranslation(); + const [action, setAction] = useState(ALL); + const [page, setPage] = useState(0); + const [result, setResult] = useState({content: [], totalPages: 0}); + const [status, setStatus] = useState('loading'); + + const load = useCallback(async () => { + try { + const response = await fetchAuditLog({action: action === ALL ? '' : action, page, size: PAGE_SIZE}); + 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) { + console.error('Audit log error:', err); + setStatus('error'); + } + }, [action, page]); + + useEffect(() => { + load(); + }, [load]); + + const onActionChange = (value) => { + setAction(value); + setPage(0); + }; + + if (status === 'forbidden') { + return ( + +

{t('AUDIT.NO_ACCESS')}

+
+ ); + } + + return ( + + + + {t('AUDIT.TITLE')} + + + + {status === 'loading' && } + {status === 'error' &&

{t('AUDIT.LOAD_ERROR')}

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

{t('AUDIT.EMPTY')}

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

    + {formatDateTime(entry.createdAt, i18n.language)} + {entry.actorName ?? t('AUDIT.USER_ID', {id: entry.actorUserId})} + {t(`AUDIT.ACTION.${entry.action}`)} + + +

    + {entry.detail &&

    {entry.detail}

    } +
  • + ))} +
+ )} + {status === 'ready' && result.totalPages > 1 && ( +
+ + {t('AUDIT.PAGE_OF', {current: page + 1, total: result.totalPages})} + +
+ )} +
+
+
+ ); +} diff --git a/src/pages/AuditLogPage.test.jsx b/src/pages/AuditLogPage.test.jsx new file mode 100644 index 0000000..d4f6751 --- /dev/null +++ b/src/pages/AuditLogPage.test.jsx @@ -0,0 +1,61 @@ +// @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 AuditLogPage from './AuditLogPage.jsx'; +import {PreferencesProvider} from '../context/PreferencesContext.jsx'; +import {fetchRoutes, json} from '../test/fetchRoutes.js'; + +vi.mock('react-i18next', () => import('../test/i18n-mock.js')); + +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}); + render(); +}; + +beforeEach(() => { + localStorage.setItem('tbp_session', '1'); +}); + +afterEach(() => { + cleanup(); + localStorage.clear(); +}); + +describe('AuditLogPage', () => { + it('showsActorActionAndGameLink', async () => { + renderPage(answer([entry({})])); + + 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'); + }); + + it('fallsBackToTheUserIdAndTheDetailForAMergedKey', async () => { + renderPage(answer([entry({actorName: null, actorUserId: 9, action: 'GAME_MERGE', gameName: null, detail: 'Old key', + targetAppid: null, targetGameName: null})])); + + expect(await screen.findByText('AUDIT.USER_ID')).toBeTruthy(); + expect(screen.getAllByText('Old key').length).toBeGreaterThan(0); + }); + + it('pagesThroughTheLog', async () => { + renderPage(() => answer([entry({})], 2)); + await screen.findByText('lobo'); + + fireEvent.click(screen.getByRole('button', {name: /ADMIN.NEXT/})); + await waitFor(() => expect(fetch.mock.calls.at(-1)[0]).toContain('page=1')); + expect(screen.getByText('AUDIT.PAGE_OF')).toBeTruthy(); + }); + + it('showsNoAccessOn403', async () => { + renderPage(json(403, {})); + + expect(await screen.findByText('AUDIT.NO_ACCESS')).toBeTruthy(); + }); +}); diff --git a/src/pages/GameDetail.jsx b/src/pages/GameDetail.jsx index 1e69857..5855118 100644 --- a/src/pages/GameDetail.jsx +++ b/src/pages/GameDetail.jsx @@ -14,7 +14,9 @@ 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 EditionsCard from '../components/EditionsCard.jsx'; import GameMergeDialog from '../components/GameMergeDialog.jsx'; +import LinkGameDialog from '../components/LinkGameDialog.jsx'; import PlayingHint from '../components/PlayingHint.jsx'; import useCurrentUser from '../hooks/useCurrentUser.js'; import useGame from '../hooks/useGame.js'; @@ -30,6 +32,7 @@ export default function GameDetail() { const {data, setData, isLoading, error, reload} = useGame(appid); const [correcting, setCorrecting] = useState(false); const [merging, setMerging] = useState(false); + const [linking, setLinking] = useState(false); const currentUser = useCurrentUser(true); const auditor = canCorrect(currentUser); const [savedProfileStyle, setSavedProfileStyle] = useState(null); @@ -90,6 +93,8 @@ export default function GameDetail() { setData((d) => ({...d, ...entry}))}/> + setLinking(true)} onChanged={reload}/> + {manual && ( setData((d) => ({...d, ...changes}))}/> @@ -118,6 +123,7 @@ export default function GameDetail() { )} + {data && } {auditor && data && ( { // A new key loads through the address; the same key needs a reload. diff --git a/src/pages/GameDetail.test.jsx b/src/pages/GameDetail.test.jsx index b0be9ab..3c9fd7f 100644 --- a/src/pages/GameDetail.test.jsx +++ b/src/pages/GameDetail.test.jsx @@ -322,6 +322,26 @@ describe('GameDetail of a game added by hand', () => { expect(await screen.findByText('MERGE.TITLE')).toBeTruthy(); }); + it('showsTheOtherEditionsAndOffersUnlinkOnlyToAnAuditorOnALinkedGame', async () => { + const linked = {...game, linked: true, editions: [{appid: -7, name: 'Half-Life 2 PS5', platform: 'PS5', source: 'MANUAL', status: 'FINISHED'}]}; + answer(linked, {'DELETE /api/library/games/220/link': json(204)}, {role: 'AUDITOR', activeRole: 'AUDITOR'}); + renderDetail(); + + expect(await screen.findByRole('link', {name: 'Half-Life 2 PS5'})).toBeTruthy(); + fireEvent.click(await screen.findByRole('button', {name: 'LINKS.UNLINK'})); + + await waitFor(() => expect(fetch.mock.calls.some(([, init]) => init?.method === 'DELETE')).toBe(true)); + expect(toast.success).toHaveBeenCalledWith('LINKS.UNLINKED'); + }); + + it('offersNoUnlinkWithoutALink', async () => { + loggedInAs({role: 'AUDITOR', activeRole: 'AUDITOR'}); + renderDetail(); + + await screen.findByRole('button', {name: 'LINKS.LINK_WITH'}); + expect(screen.queryByRole('button', {name: 'LINKS.UNLINK'})).toBeNull(); + }); + 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); @@ -333,12 +353,24 @@ describe('GameDetail of a game added by hand', () => { await act(async () => {}); expect(screen.queryByRole('button', {name: 'CORRECTION.OPEN'})).toBeNull(); expect(screen.queryByRole('button', {name: 'MERGE.OPEN'})).toBeNull(); + expect(screen.queryByRole('button', {name: 'LINKS.UNLINK'})).toBeNull(); cleanup(); } }); }); }); +describe('GameDetail edition requests', () => { + it('opensTheSameGameDialogForAUser', async () => { + answer(game, {}, {role: 'USER', activeRole: 'USER'}); + renderDetail(); + + fireEvent.click(await screen.findByRole('button', {name: 'LINKS.SAME_AS'})); + + expect(await screen.findByText('LINKS.DIALOG_HINT')).toBeTruthy(); + }); +}); + describe('GameDetail confetti', () => { // jsdom has no canvas drawing. beforeEach(() => { diff --git a/src/pages/GameLibrary.jsx b/src/pages/GameLibrary.jsx index 3714d11..1cbc305 100644 --- a/src/pages/GameLibrary.jsx +++ b/src/pages/GameLibrary.jsx @@ -7,7 +7,7 @@ import useForceSync from '../hooks/useForceSync.js'; import {formatPlaytime} from '../utils/games.js'; import {libraryQuery, parseStatus} from '../utils/backlog.js'; import {GAME_STATUSES, MAX_LIBRARY_SEARCH_LENGTH} from '../utils/limits.js'; -import {ArrowUpDown, ChevronLeft, ChevronRight, Clock, EyeOff, Gamepad2, Library, Plus, RefreshCw, Search, SearchX, Timer, Trophy, Upload} from 'lucide-react'; +import {ArrowUpDown, ChevronLeft, ChevronRight, Clock, EyeOff, Gamepad2, Layers, Library, Plus, RefreshCw, Search, SearchX, Timer, Trophy, Upload} from 'lucide-react'; import {Badge} from '@/components/ui/badge'; import {Button} from '@/components/ui/button'; @@ -55,10 +55,12 @@ export default function GameLibrary() { const q = searchParams.get('q') ?? ''; const shortOnly = searchParams.get('maxMinutes') === String(SHORT_GAME_MINUTES); const hiddenOnly = searchParams.get('hidden') === 'true'; + const hideDuplicates = searchParams.get('hideDuplicates') === 'true'; // The hidden list stands alone, also for a hand-made URL: its status and short filters are ignored. const listStatus = hiddenOnly ? null : status; const listShortOnly = !hiddenOnly && shortOnly; - const filtered = Boolean(listStatus || q.trim() || listShortOnly || hiddenOnly); + const listHideDuplicates = !hiddenOnly && hideDuplicates; + const filtered = Boolean(listStatus || q.trim() || listShortOnly || hiddenOnly || listHideDuplicates); const [search, setSearch] = useState(q); const [adding, setAdding] = useState(false); // Starts true until /games-count answers, so a Steam user does not see the sync controls vanish and come back. @@ -73,7 +75,7 @@ export default function GameLibrary() { const signal = nextSignal(); setIsLoading(true); setError(null); - const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games?${libraryQuery({page, size, sort, status: listStatus, q, maxMinutes: listShortOnly ? SHORT_GAME_MINUTES : null, hidden: hiddenOnly})}`; + const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games?${libraryQuery({page, size, sort, status: listStatus, q, maxMinutes: listShortOnly ? SHORT_GAME_MINUTES : null, hidden: hiddenOnly, hideDuplicates: listHideDuplicates})}`; const {ok, status, data: json} = await request(url, {signal}); if (signal.aborted) return; setIsLoading(false); @@ -83,7 +85,7 @@ export default function GameLibrary() { return; } setData(json); - }, [nextSignal, page, size, sort, listStatus, q, listShortOnly, hiddenOnly]); + }, [nextSignal, page, size, sort, listStatus, q, listShortOnly, hiddenOnly, listHideDuplicates]); useEffect(() => { load(); @@ -132,7 +134,7 @@ export default function GameLibrary() { }; const onHiddenChange = () => { - updateParams(hiddenOnly ? {hidden: null, page: 0} : {hidden: true, status: null, maxMinutes: null, page: 0}); + updateParams(hiddenOnly ? {hidden: null, page: 0} : {hidden: true, status: null, maxMinutes: null, hideDuplicates: null, page: 0}); }; const onSizeChange = (value) => { @@ -232,6 +234,15 @@ export default function GameLibrary() { > {t('LIBRARY.FILTER_SHORT')} + + + + ))} + + )} + {status === 'ready' && result.totalPages > 1 && ( +
+ + {t('LINKS.PAGE_OF', {current: page + 1, total: result.totalPages})} + +
+ )} + + + + ); +} diff --git a/src/pages/LinkQueuePage.test.jsx b/src/pages/LinkQueuePage.test.jsx new file mode 100644 index 0000000..3eb510f --- /dev/null +++ b/src/pages/LinkQueuePage.test.jsx @@ -0,0 +1,95 @@ +// @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 LinkQueuePage from './LinkQueuePage.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 pair = {a: {appid: 10, name: 'Dark Souls', imageUrl: null}, b: {appid: -7, name: 'Dark Souls Remastered', imageUrl: null}, + igdb: true, requests: 2, createdAt: '2026-10-01T10:00:00Z'}; + +const queue = (content, totalPages = 1) => json(200, {content, page: 0, size: 20, totalElements: content.length, totalPages}); + +const renderPage = (routes) => { + fetchRoutes(routes); + render(); +}; + +beforeEach(() => { + localStorage.setItem('tbp_session', '1'); +}); + +afterEach(() => { + cleanup(); + localStorage.clear(); + vi.clearAllMocks(); +}); + +describe('LinkQueuePage', () => { + it('listsThePairWithItsBadges', async () => { + renderPage({'GET /api/library/link-queue': queue([pair])}); + + expect(await screen.findByText('Dark Souls')).toBeTruthy(); + expect(screen.getByText('Dark Souls Remastered')).toBeTruthy(); + expect(screen.getByText('LINKS.FROM_IGDB')).toBeTruthy(); + expect(screen.getByText('LINKS.REQUESTED_BY:2')).toBeTruthy(); + }); + + it('linksThePairAndLoadsTheQueueAgain', async () => { + let pending = [pair]; + renderPage({ + 'GET /api/library/link-queue': () => queue(pending), + 'POST /api/library/games/10/link': () => { + pending = []; + return json(204); + }, + }); + + fireEvent.click(await screen.findByRole('button', {name: 'LINKS.LINK'})); + + expect(await screen.findByText('LINKS.QUEUE_EMPTY')).toBeTruthy(); + const post = fetch.mock.calls.find(([, init]) => init?.method === 'POST'); + expect(JSON.parse(post[1].body)).toEqual({otherAppid: -7}); + }); + + it('dismissesThePair', async () => { + renderPage({ + 'GET /api/library/link-queue': queue([pair]), + 'DELETE /api/library/link-queue/10/-7': json(204), + }); + + fireEvent.click(await screen.findByRole('button', {name: 'LINKS.DISMISS'})); + + await waitFor(() => expect(fetch.mock.calls.some(([url, init]) => init?.method === 'DELETE' && url.endsWith('/link-queue/10/-7'))).toBe(true)); + }); + + it('pagesThroughTheQueue', async () => { + renderPage({'GET /api/library/link-queue': () => queue([pair], 2)}); + await screen.findByText('Dark Souls'); + + fireEvent.click(screen.getByRole('button', {name: /ADMIN.NEXT/})); + + await waitFor(() => expect(fetch.mock.calls.at(-1)[0]).toContain('page=1')); + expect(screen.getByText('LINKS.PAGE_OF')).toBeTruthy(); + }); + + it('goesBackAPageWhenThePageIsEmpty', async () => { + renderPage({'GET /api/library/link-queue': (url) => (url.includes('page=1') ? queue([], 2) : queue([pair], 2))}); + await screen.findByText('Dark Souls'); + fireEvent.click(screen.getByRole('button', {name: /ADMIN.NEXT/})); + + await waitFor(() => expect(fetch.mock.calls.at(-1)[0]).toContain('page=0')); + expect(fetch.mock.calls.some(([url]) => url.includes('page=1'))).toBe(true); + expect(await screen.findByText('Dark Souls')).toBeTruthy(); + }); + + it('showsNoAccessOn403', async () => { + renderPage({'GET /api/library/link-queue': json(403, {})}); + + expect(await screen.findByText('LINKS.NO_ACCESS')).toBeTruthy(); + }); +}); diff --git a/src/utils/backlog.js b/src/utils/backlog.js index 4e16b90..08cd5ca 100644 --- a/src/utils/backlog.js +++ b/src/utils/backlog.js @@ -56,9 +56,10 @@ export const dismissFinishedHint = (appid, dismissed) => body: JSON.stringify({dismissed}), }); -export const libraryQuery = ({page, size, sort, status, q, maxMinutes, hidden}) => { +export const libraryQuery = ({page, size, sort, status, q, maxMinutes, hidden, hideDuplicates}) => { const params = new URLSearchParams({page: String(page), size: String(size), sort}); if (hidden) params.set('hidden', 'true'); + if (hideDuplicates) params.set('hideDuplicates', 'true'); if (status) params.set('status', status); if (q?.trim()) params.set('q', q.trim()); if (maxMinutes) params.set('maxMinutes', String(maxMinutes)); diff --git a/src/utils/backlog.test.js b/src/utils/backlog.test.js index 1ba86aa..1a1fae7 100644 --- a/src/utils/backlog.test.js +++ b/src/utils/backlog.test.js @@ -48,3 +48,11 @@ describe('backlogShare', () => { expect(backlogShare(null)).toBe(0); }); }); + +describe('libraryQuery hideDuplicates', () => { + it('libraryQuery_SetsHideDuplicatesOnlyWhenAsked', () => { + const base = {page: 0, size: 20, sort: 'appid,asc', status: null, q: ''}; + expect(libraryQuery({...base, hideDuplicates: true})).toBe('page=0&size=20&sort=appid%2Casc&hideDuplicates=true'); + expect(libraryQuery({...base, hideDuplicates: false})).not.toContain('hideDuplicates'); + }); +}); diff --git a/src/utils/links.js b/src/utils/links.js new file mode 100644 index 0000000..8c75d1a --- /dev/null +++ b/src/utils/links.js @@ -0,0 +1,49 @@ +import {apiFetch, request} from './api.js'; +import {libraryQuery} from './backlog.js'; + +const JSON_HEADERS = {'Content-Type': 'application/json'}; +const base = () => `${import.meta.env.VITE_CORE_API_URL}/api/library`; + +/** + * GET: 200 `{content: [{a, b, igdb, requests, createdAt}], totalPages}` (a and b are `{appid, name, imageUrl}`), 403 + * below AUDITOR. + */ +export const fetchLinkQueue = ({page, size}) => apiFetch(`${base()}/link-queue?page=${page}&size=${size}`); + +/** + * POST: 204, 400 SAME_GAME, 403 below AUDITOR, 404 GAME_NOT_FOUND, 409 ALREADY_LINKED. + */ +export const linkGames = (appid, otherAppid) => + apiFetch(`${base()}/games/${appid}/link`, {method: 'POST', headers: JSON_HEADERS, body: JSON.stringify({otherAppid})}); + +/** + * DELETE: 204, 404 NOT_LINKED. + */ +export const unlinkGame = (appid) => apiFetch(`${base()}/games/${appid}/link`, {method: 'DELETE'}); + +/** + * DELETE: 204, 404 PAIR_NOT_OPEN. + */ +export const dismissPair = (appid, otherAppid) => apiFetch(`${base()}/link-queue/${appid}/${otherAppid}`, {method: 'DELETE'}); + +/** + * POST: 204, 400 SAME_GAME, 404 GAME_NOT_FOUND, 409 ALREADY_LINKED or PAIR_DISMISSED, 429 TOO_MANY_REQUESTS. + */ +export const requestLink = (appid, otherAppid) => + apiFetch(`${base()}/games/${appid}/link-requests`, + {method: 'POST', headers: JSON_HEADERS, body: JSON.stringify({otherAppid})}); + +/** + * The user's own games that match the query, as a library page `{content: [...]}`. + */ +export const searchOwnGames = (q, signal) => + request(`${base()}/games?${libraryQuery({page: 0, size: 20, sort: 'playtimeMinutes,desc', q})}`, {signal}); + +/** + * GET: 200 `{content: [AuditEntry], totalPages}`, 403 below AUDITOR. An empty action means all. + */ +export const fetchAuditLog = ({action, page, size}) => { + const params = new URLSearchParams({page: String(page), size: String(size)}); + if (action) params.set('action', action); + return apiFetch(`${base()}/audit-log?${params}`); +};