Compare commits

..
2 Commits
Author SHA1 Message Date
lobo f71e8f7aff feat(library): add game links, auditor queue, audit log and filter
Build, Test & Push Frontend / docker (push) Successful in 23s
Build, Test & Push Frontend / quality-check (push) Successful in 25s
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.
2026-10-02 09:10:56 +02:00
lobo 80259cb0e9 feat(settings): remove the platform mails switch
Broadcasts go to every active user, so the settings switch and its API
call are gone. Admin broadcast texts no longer mention an opt-out.
2026-10-02 07:53:35 +02:00
25 changed files with 1011 additions and 71 deletions

No files matched your search

+7 -1
View File
@@ -40,6 +40,10 @@ To Be Played is split into three repositories:
finished games per year of all users. finished games per year of all users.
- **Library**: status filters, "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest - **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. 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, - **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 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. hits to add them at once through the import review.
@@ -115,7 +119,6 @@ To Be Played is split into three repositories:
(menu) lists all entries and marks them seen. Admins publish, preview, edit and delete entries on `/admin/news`. (menu) lists all entries and marks them seen. Admins publish, preview, edit and delete entries on `/admin/news`.
- **Broadcast mail**: admins write a mail in both languages on `/admin/broadcasts`, send it to all active users or to - **Broadcast mail**: admins write a mail in both languages on `/admin/broadcasts`, send it to all active users or to
picked ones, send a test to themselves, confirm with the real recipient count, and see the history with the counts. picked ones, send a test to themselves, confirm with the real recipient count, and see the history with the counts.
Users turn platform mails off in the settings; account mails always come.
- **Welcome page**: `/welcome` opens once after the first login (Auth `settings.welcomeSeenAt` is null; only the - **Welcome page**: `/welcome` opens once after the first login (Auth `settings.welcomeSeenAt` is null; only the
dashboard redirects, deep links stay). Feature cards with links, two next steps (Steam, import) with checks from dashboard redirects, deep links stay). Feature cards with links, two next steps (Steam, import) with checks from
`/me` and the library summary. "Let's go" and "Skip" stamp it in Auth and open the dashboard. The settings page `/me` and the library summary. "Let's go" and "Skip" stamp it in Auth and open the dashboard. The settings page
@@ -124,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. 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 "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.
- **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 - **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. 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 - **Build version** in the settings: commit and build time of Frontend, Auth and Core, each hash links to its
+57 -7
View File
@@ -143,9 +143,7 @@
"PROFILE_TILES_HINT": "Wähle, was dein Profil zeigt und wo: öffne dein Profil und klicke auf \"Anpassen\".", "PROFILE_TILES_HINT": "Wähle, was dein Profil zeigt und wo: öffne dein Profil und klicke auf \"Anpassen\".",
"FAVORITE_PLATFORM": "Lieblingsplattform", "FAVORITE_PLATFORM": "Lieblingsplattform",
"FAVORITE_PLATFORM_HINT": "Wird zuerst gewählt, wenn du ein Spiel für mehrere Plattformen hinzufügst.", "FAVORITE_PLATFORM_HINT": "Wird zuerst gewählt, wenn du ein Spiel für mehrere Plattformen hinzufügst.",
"FAVORITE_PLATFORM_NONE": "Keine", "FAVORITE_PLATFORM_NONE": "Keine"
"PLATFORM_MAILS": "Mails von der Plattform",
"PLATFORM_MAILS_HINT": "Neuigkeiten und Ankündigungen per Mail. Mails zu deinem Konto kommen immer."
}, },
"VALIDATION": { "VALIDATION": {
"EMAIL_REQUIRED": "Email ist erforderlich.", "EMAIL_REQUIRED": "Email ist erforderlich.",
@@ -231,7 +229,9 @@
"FILTER_SHORT": "Kurz (bis 10 h)", "FILTER_SHORT": "Kurz (bis 10 h)",
"FILTER_HIDDEN": "Ausgeblendet", "FILTER_HIDDEN": "Ausgeblendet",
"NO_HIDDEN": "Keine ausgeblendeten Spiele. Blende ein Spiel auf seiner Detailseite aus, z. B. einen Playtest oder ein Tool.", "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": { "GAME_DETAIL": {
"BACK": "Zurück", "BACK": "Zurück",
@@ -321,7 +321,9 @@
"ADMIN_NEWS": "Neuigkeiten verwalten", "ADMIN_NEWS": "Neuigkeiten verwalten",
"ADMIN_BROADCASTS": "Rundmail", "ADMIN_BROADCASTS": "Rundmail",
"PENDING_REQUESTS_one": "{{count}} Registrierung wartet", "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": { "BACKLOG": {
"TITLE": "Mein Backlog", "TITLE": "Mein Backlog",
@@ -624,6 +626,54 @@
"OTHER": "Die Spiele konnten nicht zusammengeführt werden." "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": { "HINT": {
"CLOSE": "Schließen", "CLOSE": "Schließen",
"DO_NOT_SHOW_AGAIN": "Nicht mehr anzeigen", "DO_NOT_SHOW_AGAIN": "Nicht mehr anzeigen",
@@ -718,7 +768,7 @@
"SUBJECT_DE": "Betreff (Deutsch)", "SUBJECT_DE": "Betreff (Deutsch)",
"BODY_EN": "Text (Englisch)", "BODY_EN": "Text (Englisch)",
"BODY_DE": "Text (Deutsch)", "BODY_DE": "Text (Deutsch)",
"TEXT_HELP": "Nur Text, in beiden Sprachen. Betreff höchstens 150, Text höchstens 5000 Zeichen. Eine Fußzeile mit dem Link zum Abschalten wird angehängt.", "TEXT_HELP": "Nur Text, in beiden Sprachen. Betreff höchstens 150, Text höchstens 5000 Zeichen. Eine Fußzeile wird angehängt.",
"RECIPIENTS": "Empfänger", "RECIPIENTS": "Empfänger",
"ALL": "Alle aktiven Nutzer", "ALL": "Alle aktiven Nutzer",
"PICKED": "Ausgewählte Nutzer", "PICKED": "Ausgewählte Nutzer",
@@ -732,7 +782,7 @@
"CONFIRM_TEXT": "An {{count}} Nutzer senden? Das kann nicht rückgängig gemacht werden.", "CONFIRM_TEXT": "An {{count}} Nutzer senden? Das kann nicht rückgängig gemacht werden.",
"CONFIRM": "Senden", "CONFIRM": "Senden",
"CANCEL": "Abbrechen", "CANCEL": "Abbrechen",
"NO_RECIPIENTS": "Kein Empfänger: niemand ist aktiv und hat Mails an.", "NO_RECIPIENTS": "Kein Empfänger: niemand davon ist aktiv.",
"SENT": "Mail für {{count}} Nutzer eingereiht.", "SENT": "Mail für {{count}} Nutzer eingereiht.",
"ERROR": "Die Aktion ist fehlgeschlagen.", "ERROR": "Die Aktion ist fehlgeschlagen.",
"LIST": "Gesendete Mails", "LIST": "Gesendete Mails",
+57 -7
View File
@@ -143,9 +143,7 @@
"PROFILE_TILES_HINT": "Choose what your profile shows, and where: open your profile and click \"Customize\".", "PROFILE_TILES_HINT": "Choose what your profile shows, and where: open your profile and click \"Customize\".",
"FAVORITE_PLATFORM": "Favorite platform", "FAVORITE_PLATFORM": "Favorite platform",
"FAVORITE_PLATFORM_HINT": "Selected first when you add a game on more than one platform.", "FAVORITE_PLATFORM_HINT": "Selected first when you add a game on more than one platform.",
"FAVORITE_PLATFORM_NONE": "None", "FAVORITE_PLATFORM_NONE": "None"
"PLATFORM_MAILS": "Mails from the platform",
"PLATFORM_MAILS_HINT": "News and announcements by mail. Mails about your account always come."
}, },
"VALIDATION": { "VALIDATION": {
"EMAIL_REQUIRED": "Email is required.", "EMAIL_REQUIRED": "Email is required.",
@@ -231,7 +229,9 @@
"FILTER_SHORT": "Short (up to 10 h)", "FILTER_SHORT": "Short (up to 10 h)",
"FILTER_HIDDEN": "Hidden", "FILTER_HIDDEN": "Hidden",
"NO_HIDDEN": "No hidden games. Hide a game on its detail page, e.g. a playtest or a tool.", "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": { "GAME_DETAIL": {
"BACK": "Back", "BACK": "Back",
@@ -321,7 +321,9 @@
"ADMIN_NEWS": "News", "ADMIN_NEWS": "News",
"ADMIN_BROADCASTS": "Mail", "ADMIN_BROADCASTS": "Mail",
"PENDING_REQUESTS_one": "{{count}} registration waits", "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": { "BACKLOG": {
"TITLE": "My backlog", "TITLE": "My backlog",
@@ -624,6 +626,54 @@
"OTHER": "The games could not be merged." "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": { "HINT": {
"CLOSE": "Close", "CLOSE": "Close",
"DO_NOT_SHOW_AGAIN": "Do not show again", "DO_NOT_SHOW_AGAIN": "Do not show again",
@@ -718,7 +768,7 @@
"SUBJECT_DE": "Subject (German)", "SUBJECT_DE": "Subject (German)",
"BODY_EN": "Text (English)", "BODY_EN": "Text (English)",
"BODY_DE": "Text (German)", "BODY_DE": "Text (German)",
"TEXT_HELP": "Plain text, both languages. Subject max 150, text max 5000 characters. A footer with the opt-out link is added.", "TEXT_HELP": "Plain text, both languages. Subject max 150, text max 5000 characters. A footer is added.",
"RECIPIENTS": "Recipients", "RECIPIENTS": "Recipients",
"ALL": "All active users", "ALL": "All active users",
"PICKED": "Picked users", "PICKED": "Picked users",
@@ -732,7 +782,7 @@
"CONFIRM_TEXT": "Send to {{count}} users? This cannot be undone.", "CONFIRM_TEXT": "Send to {{count}} users? This cannot be undone.",
"CONFIRM": "Send", "CONFIRM": "Send",
"CANCEL": "Cancel", "CANCEL": "Cancel",
"NO_RECIPIENTS": "No recipient: nobody active with mails on.", "NO_RECIPIENTS": "No recipient: nobody active among them.",
"SENT": "Mail queued for {{count}} users.", "SENT": "Mail queued for {{count}} users.",
"ERROR": "The action failed.", "ERROR": "The action failed.",
"LIST": "Sent mails", "LIST": "Sent mails",
+4
View File
@@ -16,6 +16,8 @@ import WhatsNewPage from './pages/WhatsNewPage.jsx';
import WelcomePage from './pages/WelcomePage.jsx'; import WelcomePage from './pages/WelcomePage.jsx';
import WelcomeGate from './components/WelcomeGate.jsx'; import WelcomeGate from './components/WelcomeGate.jsx';
import AdminUsersPage from './pages/AdminUsersPage.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 GameLibrary from './pages/GameLibrary.jsx';
import GameDetail from './pages/GameDetail.jsx'; import GameDetail from './pages/GameDetail.jsx';
import WishlistPage from './pages/WishlistPage.jsx'; import WishlistPage from './pages/WishlistPage.jsx';
@@ -85,6 +87,8 @@ function App() {
<Route path="/settings" element={<SettingsPage/>}/> <Route path="/settings" element={<SettingsPage/>}/>
<Route path="/whats-new" element={<WhatsNewPage/>}/> <Route path="/whats-new" element={<WhatsNewPage/>}/>
<Route path="/welcome" element={<WelcomePage/>}/> <Route path="/welcome" element={<WelcomePage/>}/>
<Route path="/audit/links" element={<LinkQueuePage/>}/>
<Route path="/audit/log" element={<AuditLogPage/>}/>
<Route path="/admin/users" element={<AdminUsersPage/>}/> <Route path="/admin/users" element={<AdminUsersPage/>}/>
<Route path="/admin/hints" element={<AdminHintsPage/>}/> <Route path="/admin/hints" element={<AdminHintsPage/>}/>
<Route path="/admin/news" element={<AdminNewsPage/>}/> <Route path="/admin/news" element={<AdminNewsPage/>}/>
+12 -1
View File
@@ -1,7 +1,7 @@
import {useEffect, useState} from 'react'; import {useEffect, useState} from 'react';
import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom'; import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next'; 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 {toast} from 'sonner';
import {Badge} from '@/components/ui/badge'; import {Badge} from '@/components/ui/badge';
@@ -24,6 +24,7 @@ import SteamAvatar from './SteamAvatar.jsx';
import useCurrentUser from '../hooks/useCurrentUser.js'; import useCurrentUser from '../hooks/useCurrentUser.js';
import useSteamProfile from '../hooks/useSteamProfile.js'; import useSteamProfile from '../hooks/useSteamProfile.js';
import {fetchPendingCount, ROLES, viewAs} from '../utils/admin.js'; import {fetchPendingCount, ROLES, viewAs} from '../utils/admin.js';
import {canCorrect} from '../utils/corrections.js';
import {expireSession, markSession} from '../utils/auth.js'; import {expireSession, markSession} from '../utils/auth.js';
const NAV_ITEMS = [ const NAV_ITEMS = [
@@ -155,6 +156,16 @@ export default function AppHeader({isAuthenticated, onLogout}) {
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link to="/whats-new"><Newspaper/>{t('NAV.WHATS_NEW')}</Link> <Link to="/whats-new"><Newspaper/>{t('NAV.WHATS_NEW')}</Link>
</DropdownMenuItem> </DropdownMenuItem>
{canCorrect(currentUser) && (
<>
<DropdownMenuItem asChild>
<Link to="/audit/links"><Link2/>{t('NAV.LINK_QUEUE')}</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/audit/log"><ScrollText/>{t('NAV.AUDIT_LOG')}</Link>
</DropdownMenuItem>
</>
)}
{seesAdminPage && ( {seesAdminPage && (
<> <>
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
+11
View File
@@ -88,6 +88,16 @@ describe('AppHeader', () => {
expect(screen.queryByText('NAV.ADMIN_BROADCASTS')).toBeNull(); 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 () => { it('offers nothing of that to a user', async () => {
loggedInAs({role: 'USER', activeRole: 'USER'}); loggedInAs({role: 'USER', activeRole: 'USER'});
await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/users/me'), expect.anything())); 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(await screen.findByText('NAV.SETTINGS')).toBeTruthy();
expect(screen.getByText('NAV.WHATS_NEW')).toBeTruthy(); expect(screen.getByText('NAV.WHATS_NEW')).toBeTruthy();
expect(screen.queryByText('NAV.ADMIN')).toBeNull(); 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_NEWS')).toBeNull();
expect(screen.queryByText('NAV.ADMIN_BROADCASTS')).toBeNull(); expect(screen.queryByText('NAV.ADMIN_BROADCASTS')).toBeNull();
expect(screen.queryByText('NAV.VIEW_AS.MENU')).toBeNull(); expect(screen.queryByText('NAV.VIEW_AS.MENU')).toBeNull();
+72
View File
@@ -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 (
<Card className="gap-3 p-4">
{game.editions?.length > 0 && (
<div className="space-y-2 text-sm">
<p className="font-medium">{t('LINKS.ALSO_IN_LIBRARY')}</p>
<ul className="space-y-1">
{game.editions.map((edition) => (
<li key={edition.appid} className="flex flex-wrap items-center gap-2">
<Link to={`/library/${edition.appid}`} className="hover:text-primary hover:underline">
{edition.name}
</Link>
{platformLabel(edition) && (
<span className="text-muted-foreground">({platformLabel(edition)})</span>
)}
<GameStatusBadge status={edition.status} compact/>
</li>
))}
</ul>
</div>
)}
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" size="sm" onClick={onRequest}>
<Layers/>{t(auditor ? 'LINKS.LINK_WITH' : 'LINKS.SAME_AS')}
</Button>
{auditor && game.linked && (
<Button type="button" variant="outline" size="sm" disabled={busy} onClick={unlink}>
<Link2Off/>{t('LINKS.UNLINK')}
</Button>
)}
</div>
</Card>
);
}
+129
View File
@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('LINKS.SAME_AS')}</DialogTitle>
<DialogDescription>{t('LINKS.DIALOG_HINT', {name: game.name})}</DialogDescription>
</DialogHeader>
{picked ? (
<div className="space-y-4">
<p className="text-sm">
{t(auditor ? 'LINKS.LINK_CONFIRM' : 'LINKS.REQUEST_CONFIRM', {name: game.name, other: picked.name})}
</p>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" disabled={busy} onClick={() => setPicked(null)}>
<ArrowLeft/>{t('MERGE.BACK')}
</Button>
<Button type="button" disabled={busy} onClick={send}>
{busy ? <Loader2 className="animate-spin"/> : auditor ? <Link2/> : <Send/>}
{t(auditor ? 'LINKS.LINK' : 'LINKS.REQUEST')}
</Button>
</div>
</div>
) : (
<div className="space-y-3">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"/>
<Input type="search" autoFocus value={query} maxLength={MAX_LIBRARY_SEARCH_LENGTH} className="pl-8"
placeholder={t('LINKS.SEARCH_PLACEHOLDER')} aria-label={t('LINKS.SEARCH_PLACEHOLDER')}
onChange={(e) => setQuery(e.target.value)}/>
</div>
{query.trim() && offered.length === 0 && (
<p className="text-sm text-muted-foreground">{t('LINKS.NO_HITS')}</p>
)}
{offered.length > 0 && (
<ul className="divide-y rounded-md border">
{offered.map((hit) => (
<li key={hit.appid}>
<button type="button" onClick={() => setPicked(hit)}
className="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-muted/50">
<GameCover appid={hit.appid} imageUrl={hit.imageUrl} className="w-16 shrink-0"/>
<span className="min-w-0 flex-1 truncate text-sm font-medium">{hit.name}</span>
{platformLabel(hit) && (
<span className="text-xs text-muted-foreground">{platformLabel(hit)}</span>
)}
</button>
</li>
))}
</ul>
)}
</div>
)}
</DialogContent>
</Dialog>
);
}
+79
View File
@@ -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(<MemoryRouter><LinkGameDialog game={hades} open onOpenChange={vi.fn()} auditor={auditor} onDone={onDone}/></MemoryRouter>);
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'));
});
});
+1 -16
View File
@@ -11,7 +11,7 @@ import {usePreferences} from '../context/PreferencesContext.jsx';
import usePersistSettings from '../hooks/usePersistSettings.js'; import usePersistSettings from '../hooks/usePersistSettings.js';
import useSaveResponse from '../hooks/useSaveResponse.js'; import useSaveResponse from '../hooks/useSaveResponse.js';
import {FAVORITE_PLATFORMS, saveFavoritePlatform} from '../utils/platforms.js'; import {FAVORITE_PLATFORMS, saveFavoritePlatform} from '../utils/platforms.js';
import {LANGUAGES, savePlatformMails, saveSettings} from '../utils/settings.js'; import {LANGUAGES, saveSettings} from '../utils/settings.js';
// Radix Select has no empty value. // Radix Select has no empty value.
const NO_FAVORITE = 'NONE'; const NO_FAVORITE = 'NONE';
@@ -41,13 +41,6 @@ export default function PreferencesCard() {
} }
}; };
const handlePlatformMailsChange = async (next) => {
if (await runSave(() => savePlatformMails(next))) {
updateProfile((p) => ({...p, settings: {...p.settings, platformMails: next}}));
toast.success(t('SETTINGS.SAVED'));
}
};
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@@ -90,14 +83,6 @@ export default function PreferencesCard() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<Label htmlFor="platformMails">{t('SETTINGS.PLATFORM_MAILS')}</Label>
<p className="text-xs text-muted-foreground">{t('SETTINGS.PLATFORM_MAILS_HINT')}</p>
</div>
<Switch id="platformMails" checked={profile?.settings?.platformMails ?? true}
onCheckedChange={handlePlatformMailsChange}/>
</div>
</CardContent> </CardContent>
</Card> </Card>
); );
+21
View File
@@ -20,3 +20,24 @@ describe('translation files', () => {
expect(loadPath).toBe(`/i18n/{{lng}}.json?v=${hash.digest('hex').slice(0, 12)}`); expect(loadPath).toBe(`/i18n/{{lng}}.json?v=${hash.digest('hex').slice(0, 12)}`);
}); });
}); });
describe('admin broadcast texts', () => {
it('adminBroadcastTexts_DoNotMentionAnOptOut', () => {
const dir = path.resolve(import.meta.dirname, '../public/i18n');
for (const file of ['en.json', 'de.json']) {
const texts = JSON.parse(readFileSync(path.join(dir, file), 'utf8')).ADMIN_BROADCAST;
expect(texts.TEXT_HELP, file).not.toMatch(/opt-out|Abschalten/i);
expect(texts.NO_RECIPIENTS, file).not.toMatch(/mails on|Mails an/i);
}
});
});
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'));
});
});
+123
View File
@@ -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 <Link to={`/library/${appid}`} className="font-medium hover:text-primary hover:underline">{name ?? fallback}</Link>;
}
/**
* 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 (
<PageContainer className="px-4 py-6 md:px-8">
<p className="text-sm text-muted-foreground">{t('AUDIT.NO_ACCESS')}</p>
</PageContainer>
);
}
return (
<PageContainer className="space-y-4 px-4 py-6 md:px-8">
<Card>
<CardHeader className="flex flex-wrap items-center justify-between gap-3">
<CardTitle>{t('AUDIT.TITLE')}</CardTitle>
<Select value={action} onValueChange={onActionChange}>
<SelectTrigger className="w-52" aria-label={t('AUDIT.ACTION.LABEL')}>
<SelectValue/>
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>{t('AUDIT.ACTION.ALL')}</SelectItem>
{ACTIONS.map((key) => <SelectItem key={key} value={key}>{t(`AUDIT.ACTION.${key}`)}</SelectItem>)}
</SelectContent>
</Select>
</CardHeader>
<CardContent className="space-y-3">
{status === 'loading' && <Loader2 className="size-5 animate-spin text-muted-foreground"/>}
{status === 'error' && <p className="text-sm text-destructive">{t('AUDIT.LOAD_ERROR')}</p>}
{status === 'ready' && result.content.length === 0 && (
<p className="text-sm text-muted-foreground">{t('AUDIT.EMPTY')}</p>
)}
{status === 'ready' && result.content.length > 0 && (
<ul className="divide-y">
{result.content.map((entry) => (
<li key={entry.id} className="space-y-1 py-3 text-sm">
<p className="flex flex-wrap items-center gap-x-2">
<span className="text-xs text-muted-foreground">{formatDateTime(entry.createdAt, i18n.language)}</span>
<span>{entry.actorName ?? t('AUDIT.USER_ID', {id: entry.actorUserId})}</span>
<span className="font-medium">{t(`AUDIT.ACTION.${entry.action}`)}</span>
<GameRef appid={entry.appid} name={entry.gameName} fallback={entry.detail}/>
<GameRef appid={entry.targetAppid} name={entry.targetGameName} fallback={entry.targetAppid}/>
</p>
{entry.detail && <p className="break-words text-xs text-muted-foreground">{entry.detail}</p>}
</li>
))}
</ul>
)}
{status === 'ready' && result.totalPages > 1 && (
<div className="flex items-center justify-between gap-2 text-sm">
<Button type="button" variant="outline" size="sm" disabled={page === 0}
onClick={() => setPage(page - 1)}>
<ChevronLeft/>{t('ADMIN.PREVIOUS')}
</Button>
<span>{t('AUDIT.PAGE_OF', {current: page + 1, total: result.totalPages})}</span>
<Button type="button" variant="outline" size="sm" disabled={page + 1 >= result.totalPages}
onClick={() => setPage(page + 1)}>
{t('ADMIN.NEXT')}<ChevronRight/>
</Button>
</div>
)}
</CardContent>
</Card>
</PageContainer>
);
}
+61
View File
@@ -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(<MemoryRouter><PreferencesProvider><AuditLogPage/></PreferencesProvider></MemoryRouter>);
};
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();
});
});
+6
View File
@@ -14,7 +14,9 @@ import ManualGameCard from '../components/ManualGameCard.jsx';
import CompletedHint from '../components/CompletedHint.jsx'; import CompletedHint from '../components/CompletedHint.jsx';
import FinishedHint from '../components/FinishedHint.jsx'; import FinishedHint from '../components/FinishedHint.jsx';
import GameCorrectionDialog from '../components/GameCorrectionDialog.jsx'; import GameCorrectionDialog from '../components/GameCorrectionDialog.jsx';
import EditionsCard from '../components/EditionsCard.jsx';
import GameMergeDialog from '../components/GameMergeDialog.jsx'; import GameMergeDialog from '../components/GameMergeDialog.jsx';
import LinkGameDialog from '../components/LinkGameDialog.jsx';
import PlayingHint from '../components/PlayingHint.jsx'; import PlayingHint from '../components/PlayingHint.jsx';
import useCurrentUser from '../hooks/useCurrentUser.js'; import useCurrentUser from '../hooks/useCurrentUser.js';
import useGame from '../hooks/useGame.js'; import useGame from '../hooks/useGame.js';
@@ -30,6 +32,7 @@ export default function GameDetail() {
const {data, setData, isLoading, error, reload} = useGame(appid); const {data, setData, isLoading, error, reload} = useGame(appid);
const [correcting, setCorrecting] = useState(false); const [correcting, setCorrecting] = useState(false);
const [merging, setMerging] = useState(false); const [merging, setMerging] = useState(false);
const [linking, setLinking] = useState(false);
const currentUser = useCurrentUser(true); const currentUser = useCurrentUser(true);
const auditor = canCorrect(currentUser); const auditor = canCorrect(currentUser);
const [savedProfileStyle, setSavedProfileStyle] = useState(null); const [savedProfileStyle, setSavedProfileStyle] = useState(null);
@@ -90,6 +93,8 @@ export default function GameDetail() {
<BacklogCard key={data.appid} game={data} <BacklogCard key={data.appid} game={data}
onSaved={(entry) => setData((d) => ({...d, ...entry}))}/> onSaved={(entry) => setData((d) => ({...d, ...entry}))}/>
<EditionsCard game={data} auditor={auditor} onRequest={() => setLinking(true)} onChanged={reload}/>
{manual && ( {manual && (
<ManualGameCard key={`manual-${data.appid}`} game={data} <ManualGameCard key={`manual-${data.appid}`} game={data}
onSaved={(changes) => setData((d) => ({...d, ...changes}))}/> onSaved={(changes) => setData((d) => ({...d, ...changes}))}/>
@@ -118,6 +123,7 @@ export default function GameDetail() {
<GameCorrectionDialog game={data} open={correcting} onOpenChange={setCorrecting} <GameCorrectionDialog game={data} open={correcting} onOpenChange={setCorrecting}
onChanged={reload}/> onChanged={reload}/>
)} )}
{data && <LinkGameDialog game={data} open={linking} onOpenChange={setLinking} auditor={auditor} onDone={reload}/>}
{auditor && data && ( {auditor && data && (
<GameMergeDialog game={data} open={merging} onOpenChange={setMerging} onMerged={(merged) => { <GameMergeDialog game={data} open={merging} onOpenChange={setMerging} onMerged={(merged) => {
// A new key loads through the address; the same key needs a reload. // A new key loads through the address; the same key needs a reload.
+32
View File
@@ -322,6 +322,26 @@ describe('GameDetail of a game added by hand', () => {
expect(await screen.findByText('MERGE.TITLE')).toBeTruthy(); 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 () => { 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'}]) { for (const me of [{role: 'USER', activeRole: 'USER'}, {role: 'PLATFORM_ADMIN', activeRole: 'USER'}]) {
loggedInAs(me); loggedInAs(me);
@@ -333,12 +353,24 @@ describe('GameDetail of a game added by hand', () => {
await act(async () => {}); await act(async () => {});
expect(screen.queryByRole('button', {name: 'CORRECTION.OPEN'})).toBeNull(); expect(screen.queryByRole('button', {name: 'CORRECTION.OPEN'})).toBeNull();
expect(screen.queryByRole('button', {name: 'MERGE.OPEN'})).toBeNull(); expect(screen.queryByRole('button', {name: 'MERGE.OPEN'})).toBeNull();
expect(screen.queryByRole('button', {name: 'LINKS.UNLINK'})).toBeNull();
cleanup(); 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', () => { describe('GameDetail confetti', () => {
// jsdom has no canvas drawing. // jsdom has no canvas drawing.
beforeEach(() => { beforeEach(() => {
+21 -5
View File
@@ -7,7 +7,7 @@ import useForceSync from '../hooks/useForceSync.js';
import {formatPlaytime} from '../utils/games.js'; import {formatPlaytime} from '../utils/games.js';
import {libraryQuery, parseStatus} from '../utils/backlog.js'; import {libraryQuery, parseStatus} from '../utils/backlog.js';
import {GAME_STATUSES, MAX_LIBRARY_SEARCH_LENGTH} from '../utils/limits.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 {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
@@ -55,10 +55,12 @@ export default function GameLibrary() {
const q = searchParams.get('q') ?? ''; const q = searchParams.get('q') ?? '';
const shortOnly = searchParams.get('maxMinutes') === String(SHORT_GAME_MINUTES); const shortOnly = searchParams.get('maxMinutes') === String(SHORT_GAME_MINUTES);
const hiddenOnly = searchParams.get('hidden') === 'true'; 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. // The hidden list stands alone, also for a hand-made URL: its status and short filters are ignored.
const listStatus = hiddenOnly ? null : status; const listStatus = hiddenOnly ? null : status;
const listShortOnly = !hiddenOnly && shortOnly; 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 [search, setSearch] = useState(q);
const [adding, setAdding] = useState(false); 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. // 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(); const signal = nextSignal();
setIsLoading(true); setIsLoading(true);
setError(null); 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}); const {ok, status, data: json} = await request(url, {signal});
if (signal.aborted) return; if (signal.aborted) return;
setIsLoading(false); setIsLoading(false);
@@ -83,7 +85,7 @@ export default function GameLibrary() {
return; return;
} }
setData(json); setData(json);
}, [nextSignal, page, size, sort, listStatus, q, listShortOnly, hiddenOnly]); }, [nextSignal, page, size, sort, listStatus, q, listShortOnly, hiddenOnly, listHideDuplicates]);
useEffect(() => { useEffect(() => {
load(); load();
@@ -132,7 +134,7 @@ export default function GameLibrary() {
}; };
const onHiddenChange = () => { 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) => { const onSizeChange = (value) => {
@@ -232,6 +234,15 @@ export default function GameLibrary() {
> >
<Timer className="size-3"/>{t('LIBRARY.FILTER_SHORT')} <Timer className="size-3"/>{t('LIBRARY.FILTER_SHORT')}
</Button> </Button>
<Button
size="sm"
variant={listHideDuplicates ? 'default' : 'outline'}
aria-pressed={listHideDuplicates}
className="h-7 rounded-full px-3 text-xs"
onClick={() => updateParams({hideDuplicates: listHideDuplicates ? null : true, hidden: null, page: 0})}
>
<Layers className="size-3"/>{t('LIBRARY.FILTER_HIDE_DUPLICATES')}
</Button>
<Button <Button
size="sm" size="sm"
variant={hiddenOnly ? 'default' : 'outline'} variant={hiddenOnly ? 'default' : 'outline'}
@@ -299,6 +310,11 @@ export default function GameLibrary() {
<Gamepad2 className="size-3"/>{platformLabel(g)} <Gamepad2 className="size-3"/>{platformLabel(g)}
</Badge> </Badge>
)} )}
{g.editions >= 2 && (
<Badge variant="outline" className="tabular-nums" title={t('LIBRARY.EDITIONS', {count: g.editions})}>
<Layers className="size-3"/>x{g.editions}
</Badge>
)}
<GameStatusBadge status={g.status} compact/> <GameStatusBadge status={g.status} compact/>
{g.achievementsTotal > 0 && g.achievementsUnlocked != null && ( {g.achievementsTotal > 0 && g.achievementsUnlocked != null && (
<Badge variant="secondary" className="hidden sm:inline-flex tabular-nums" <Badge variant="secondary" className="hidden sm:inline-flex tabular-nums"
+19
View File
@@ -182,6 +182,25 @@ describe('GameLibrary short games', () => {
expect(screen.getByRole('button', {name: 'LIBRARY.FILTER_ALL'}).getAttribute('aria-pressed')).toBe('false'); expect(screen.getByRole('button', {name: 'LIBRARY.FILTER_ALL'}).getAttribute('aria-pressed')).toBe('false');
}); });
it('showsTheEditionsBadgeOnlyFromTwo', async () => {
answer([{...page.content[0], editions: 2}, {appid: 620, name: 'Portal 2', playtimeMinutes: 0, status: 'BACKLOG', editions: null}]);
renderLibrary();
expect(await screen.findByText('x2')).toBeTruthy();
expect(screen.getAllByText(/^x\d/)).toHaveLength(1);
});
it('hideDuplicatesFilter_AddsHideDuplicatesToTheUrlAndTheRequest', async () => {
const lib = renderLibrary();
await waitFor(() => expect(fetch).toHaveBeenCalled());
fireEvent.click(screen.getByRole('button', {name: 'LIBRARY.FILTER_HIDE_DUPLICATES'}));
await waitFor(() => expect(lib.params().get('hideDuplicates')).toBe('true'));
await waitFor(() => expect(fetch.mock.calls.at(-1)[0]).toMatch(/[?&]hideDuplicates=true(&|$)/));
expect(screen.getByRole('button', {name: 'LIBRARY.FILTER_HIDE_DUPLICATES'}).getAttribute('aria-pressed')).toBe('true');
});
it('leaves the hidden games with a status click', async () => { it('leaves the hidden games with a status click', async () => {
const lib = renderLibrary('/library?hidden=true'); const lib = renderLibrary('/library?hidden=true');
await waitFor(() => expect(fetch).toHaveBeenCalled()); await waitFor(() => expect(fetch).toHaveBeenCalled());
+144
View File
@@ -0,0 +1,144 @@
import {useCallback, useEffect, useState} from 'react';
import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {ChevronLeft, ChevronRight, Link2, Loader2, X} from 'lucide-react';
import {toast} from 'sonner';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import GameCover from '../components/GameCover.jsx';
import PageContainer from '../components/PageContainer.jsx';
import {dismissPair, fetchLinkQueue, linkGames} from '../utils/links.js';
const PAGE_SIZE = 20;
function PairGame({game}) {
return (
<Link to={`/library/${game.appid}`} className="flex min-w-0 flex-1 items-center gap-2 hover:text-primary">
<GameCover appid={game.appid} imageUrl={game.imageUrl} className="w-16 shrink-0"/>
<span className="min-w-0 truncate text-sm font-medium">{game.name ?? `App ${game.appid}`}</span>
</Link>
);
}
/**
* AUDITOR or higher: the pairs IGDB suggests and users asked for. Core checks the role.
*/
export default function LinkQueuePage() {
const {t} = useTranslation();
const [page, setPage] = useState(0);
const [result, setResult] = useState({content: [], totalPages: 0});
const [status, setStatus] = useState('loading');
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
try {
const response = await fetchLinkQueue({page, size: PAGE_SIZE});
if (response.status === 401) return;
if (response.status === 403) {
setStatus('forbidden');
return;
}
if (!response.ok) {
setStatus('error');
return;
}
const data = await response.json();
// The last pair of the last page was decided: show the page before.
if (data.content.length === 0 && page > 0) {
setPage(page - 1);
return;
}
setResult(data);
setStatus('ready');
} catch (err) {
console.error('Link queue error:', err);
setStatus('error');
}
}, [page]);
useEffect(() => {
load();
}, [load]);
const run = async (request, doneKey) => {
setBusy(true);
try {
const response = await request();
if (response.status === 401) return;
if (response.ok) {
toast.success(t(doneKey));
} else {
const data = await response.json().catch(() => ({}));
toast.error(t(data.code === 'ALREADY_LINKED' ? 'LINKS.ERROR_CODES.ALREADY_LINKED' : 'LINKS.ERROR'));
}
await load();
} catch (err) {
console.error('Link queue action error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setBusy(false);
}
};
if (status === 'forbidden') {
return (
<PageContainer className="px-4 py-6 md:px-8">
<p className="text-sm text-muted-foreground">{t('LINKS.NO_ACCESS')}</p>
</PageContainer>
);
}
return (
<PageContainer className="space-y-4 px-4 py-6 md:px-8">
<Card>
<CardHeader>
<CardTitle>{t('LINKS.QUEUE_TITLE')}</CardTitle>
</CardHeader>
<CardContent>
{status === 'loading' && <Loader2 className="size-5 animate-spin text-muted-foreground"/>}
{status === 'error' && <p className="text-sm text-destructive">{t('LINKS.ERROR')}</p>}
{status === 'ready' && result.content.length === 0 && (
<p className="text-sm text-muted-foreground">{t('LINKS.QUEUE_EMPTY')}</p>
)}
{status === 'ready' && result.content.length > 0 && (
<ul className="divide-y">
{result.content.map(({a, b, igdb, requests}) => (
<li key={`${a.appid}-${b.appid}`} className="flex flex-wrap items-center gap-3 py-3">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-3">
<PairGame game={a}/>
<PairGame game={b}/>
</div>
{igdb && <Badge variant="secondary">{t('LINKS.FROM_IGDB')}</Badge>}
{requests > 0 && <Badge variant="outline">{t('LINKS.REQUESTED_BY', {count: requests})}</Badge>}
<Button type="button" size="sm" disabled={busy}
onClick={() => run(() => linkGames(a.appid, b.appid), 'LINKS.LINKED')}>
<Link2/>{t('LINKS.LINK')}
</Button>
<Button type="button" variant="outline" size="sm" disabled={busy}
onClick={() => run(() => dismissPair(a.appid, b.appid), 'LINKS.DISMISSED')}>
<X/>{t('LINKS.DISMISS')}
</Button>
</li>
))}
</ul>
)}
{status === 'ready' && result.totalPages > 1 && (
<div className="flex items-center justify-between gap-2 pt-3 text-sm">
<Button type="button" variant="outline" size="sm" disabled={page === 0}
onClick={() => setPage(page - 1)}>
<ChevronLeft/>{t('ADMIN.PREVIOUS')}
</Button>
<span>{t('LINKS.PAGE_OF', {current: page + 1, total: result.totalPages})}</span>
<Button type="button" variant="outline" size="sm" disabled={page + 1 >= result.totalPages}
onClick={() => setPage(page + 1)}>
{t('ADMIN.NEXT')}<ChevronRight/>
</Button>
</div>
)}
</CardContent>
</Card>
</PageContainer>
);
}
+95
View File
@@ -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(<MemoryRouter><PreferencesProvider><LinkQueuePage/></PreferencesProvider></MemoryRouter>);
};
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();
});
});
-24
View File
@@ -31,7 +31,6 @@ const authRoutes = (me) => ({
'PUT /api/users/settings/appearance': noBody, 'PUT /api/users/settings/appearance': noBody,
'PUT /api/users/settings/profile-style': noBody, 'PUT /api/users/settings/profile-style': noBody,
'PUT /api/users/settings/favorite-platform': noBody, 'PUT /api/users/settings/favorite-platform': noBody,
'PUT /api/users/settings/platform-mails': noBody,
'GET /api/version': json(404, {}), 'GET /api/version': json(404, {}),
}); });
@@ -257,29 +256,6 @@ describe('SettingsPage favorite platform', () => {
}); });
}); });
describe('SettingsPage platform mails', () => {
beforeEach(() => localStorage.setItem('tbp_session', '1'));
afterEach(() => {
cleanup();
localStorage.clear();
});
it('is on by default and saves the opt-out', async () => {
toast.success.mockClear();
renderPage(profile());
const mails = await screen.findByRole('switch', {name: 'SETTINGS.PLATFORM_MAILS'});
expect(mails.getAttribute('aria-checked')).toBe('true');
fireEvent.click(mails);
await waitFor(() => expect(puts()).toEqual([{enabled: false}]));
expect(fetch.mock.calls.find(([, init]) => init?.method === 'PUT')[0]).toContain('/api/users/settings/platform-mails');
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('SETTINGS.SAVED'));
expect(mails.getAttribute('aria-checked')).toBe('false');
});
});
describe('SettingsPage session', () => { describe('SettingsPage session', () => {
beforeEach(() => localStorage.setItem('tbp_session', '1')); beforeEach(() => localStorage.setItem('tbp_session', '1'));
+2 -1
View File
@@ -56,9 +56,10 @@ export const dismissFinishedHint = (appid, dismissed) =>
body: JSON.stringify({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}); const params = new URLSearchParams({page: String(page), size: String(size), sort});
if (hidden) params.set('hidden', 'true'); if (hidden) params.set('hidden', 'true');
if (hideDuplicates) params.set('hideDuplicates', 'true');
if (status) params.set('status', status); if (status) params.set('status', status);
if (q?.trim()) params.set('q', q.trim()); if (q?.trim()) params.set('q', q.trim());
if (maxMinutes) params.set('maxMinutes', String(maxMinutes)); if (maxMinutes) params.set('maxMinutes', String(maxMinutes));
+8
View File
@@ -48,3 +48,11 @@ describe('backlogShare', () => {
expect(backlogShare(null)).toBe(0); 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');
});
});
+1 -1
View File
@@ -10,7 +10,7 @@ const JSON_HEADERS = {'Content-Type': 'application/json'};
export const fetchBroadcasts = () => apiFetch(`${AUTH()}/api/admin/broadcasts`); export const fetchBroadcasts = () => apiFetch(`${AUTH()}/api/admin/broadcasts`);
/** /**
* {count}: the active users with platform mails on, among `userIds`; all of them when `userIds` is null. * {count}: the active users among `userIds`; all of them when `userIds` is null.
*/ */
export const fetchRecipientCount = (userIds) => export const fetchRecipientCount = (userIds) =>
apiFetch(`${AUTH()}/api/admin/broadcasts/recipient-count${userIds ? `?userIds=${userIds.join(',')}` : ''}`); apiFetch(`${AUTH()}/api/admin/broadcasts/recipient-count${userIds ? `?userIds=${userIds.join(',')}` : ''}`);
+49
View File
@@ -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}`);
};
-8
View File
@@ -58,11 +58,3 @@ export const markWelcomeSeen = () =>
method: 'PUT', method: 'PUT',
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
}); });
/** PUT on Auth, 204: turns the broadcast mails on or off. The caller handles the response status. */
export const savePlatformMails = (enabled) =>
apiFetch(`${import.meta.env.VITE_API_URL}/api/users/settings/platform-mails`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({enabled}),
});