Public Access
Compare commits
2
Commits
6dda186bdf
...
f71e8f7aff
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f71e8f7aff | ||
|
|
80259cb0e9 |
No files matched your search
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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/>}/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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'));
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(',')}` : ''}`);
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
};
|
||||||
@@ -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}),
|
|
||||||
});
|
|
||||||
Reference in new issue
Block a user