Compare commits

...
3 Commits
Author SHA1 Message Date
lobo 24a98062ee refactor(limits): server limits in one file, own texts for unknown errors
Build, Test & Push Frontend / docker (push) Successful in 27s
Build, Test & Push Frontend / quality-check (push) Successful in 19s
2026-09-29 14:48:28 +02:00
lobo f605917874 fix(settings): keep the language and save settings one after the other 2026-09-29 13:27:31 +02:00
lobo 14dd59f9e0 feat(import): add the import page, multi-select add and the favorite platform 2026-09-29 10:20:54 +02:00
47 changed files with 1917 additions and 169 deletions

No files matched your search

+17 -1
View File
@@ -34,7 +34,17 @@ To Be Played is split into three repositories:
- **Library**: status filters, "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest - **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.
- **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. 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.
- **Import** (`/import`): a list (one title per line) or a CSV (title, platform, hours or minutes, status). Core
matches the lines with IGDB and saves them in the background; the page shows the steps (queued with the imports
before yours, searching on IGDB, your review, adding to the library, time to beat and moods, done) with the
progress, and "Try again" for an import that stopped. The review shows the best match, other candidates and
"search again", with the platform and status per line. Only sure matches are checked. Earlier imports can be
undone.
- **Platform everywhere**: each game shows its platform ("Steam" for Steam games). The favorite platform from the
settings is preselected when a game is on it; otherwise a game on one platform gets that one, and a game on
several waits for the user's pick.
- **Game detail**: status, priority, 1–5 star rating, notes, achievements, time to beat (story / extras / 100 %), - **Game detail**: status, priority, 1–5 star rating, notes, achievements, time to beat (story / extras / 100 %),
a link to the game's speedrun.com page when it has verified runs, a hint when Steam shows recent playtime for a a link to the game's speedrun.com page when it has verified runs, a hint when Steam shows recent playtime for a
game still in the backlog, and one to set the status to 100 % when game still in the backlog, and one to set the status to 100 % when
@@ -57,6 +67,8 @@ To Be Played is split into three repositories:
("Profile art" on a game's page). Visitors see the owner's look on that page only; light or dark mode stays theirs. ("Profile art" on a game's page). Visitors see the owner's look on that page only; light or dark mode stays theirs.
- **Appearance**: accent color, corner style, wide layout, page background and banner from your own game art. - **Appearance**: accent color, corner style, wide layout, page background and banner from your own game art.
Saved in Auth, so it follows you to every device. Saved in Auth, so it follows you to every device.
- **Language**: kept in the browser and saved in Auth; after the login Auth's language wins when one is saved. Dark
mode stays per device.
- **Broken pictures**: a Steam cover or achievement icon that does not load shows a placeholder and is reported - **Broken pictures**: a Steam cover or achievement icon that does not load shows a placeholder and is reported
to Core once per page load (`POST /api/library/broken-images`, only with a session). Core repairs it at night; to Core once per page load (`POST /api/library/broken-images`, only with a session). Core repairs it at night;
a repaired cover comes back as `imageUrl`. a repaired cover comes back as `imageUrl`.
@@ -120,6 +132,10 @@ flowchart LR
- `src/utils/brokenImages.js` reports pictures that fail to load (`GameCover`, achievement icons on the game - `src/utils/brokenImages.js` reports pictures that fail to load (`GameCover`, achievement icons on the game
detail page) to Core. detail page) to Core.
- `useLibrarySync` polls Core's `/api/library/sync-status` while a Steam sync runs, then loads the data again. - `useLibrarySync` polls Core's `/api/library/sync-status` while a Steam sync runs, then loads the data again.
- `PUT /api/users/settings` replaces dark mode, language, display name and public profile at once, so every save
goes through `saveSettings` in `src/utils/settings.js`: one after the other, each on a fresh `GET /api/users/me`.
- Pages keep a load error as its i18n key and translate it when rendering, so a language switch loads nothing
again.
## Project structure ## Project structure
+85 -3
View File
@@ -125,16 +125,19 @@
"TOO_MANY": "Zu viele falsche Passwörter. Bitte in {{minutes}} Minuten erneut versuchen.", "TOO_MANY": "Zu viele falsche Passwörter. Bitte in {{minutes}} Minuten erneut versuchen.",
"ERROR": "Das Konto konnte nicht gelöscht werden. Bitte später erneut versuchen." "ERROR": "Das Konto konnte nicht gelöscht werden. Bitte später erneut versuchen."
}, },
"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_HINT": "Wird zuerst gewählt, wenn du ein Spiel für mehrere Plattformen hinzufügst.",
"FAVORITE_PLATFORM_NONE": "Keine"
}, },
"VALIDATION": { "VALIDATION": {
"EMAIL_REQUIRED": "Email ist erforderlich.", "EMAIL_REQUIRED": "Email ist erforderlich.",
"EMAIL_INVALID": "Gib eine gültige Email-Adresse ein.", "EMAIL_INVALID": "Gib eine gültige Email-Adresse ein.",
"PASSWORD_TOO_SHORT": "Passwort muss mindestens 8 Zeichen haben.", "PASSWORD_TOO_SHORT": "Passwort muss mindestens 8 Zeichen haben.",
"PASSWORD_TOO_LONG": "Passwort ist zu lang: höchstens 72 Zeichen, mit Umlauten oder Emoji weniger.",
"PASSWORD_CONFIRMATION_REQUIRED": "Bitte Passwort bestätigen.", "PASSWORD_CONFIRMATION_REQUIRED": "Bitte Passwort bestätigen.",
"PASSWORD_MISMATCH": "Die Passwörter stimmen nicht überein.", "PASSWORD_MISMATCH": "Die Passwörter stimmen nicht überein.",
"INVITE_CODE_REQUIRED": "Einladungscode ist erforderlich.", "INVITE_CODE_REQUIRED": "Einladungscode ist erforderlich.",
"LANGUAGE_INVALID": "Nicht unterstützte Sprache.",
"DISPLAY_NAME_INVALID": "3–24 Zeichen: Buchstaben, Zahlen, Leerzeichen, _ . - (kein Leerzeichen am Anfang oder Ende)." "DISPLAY_NAME_INVALID": "3–24 Zeichen: Buchstaben, Zahlen, Leerzeichen, _ . - (kein Leerzeichen am Anfang oder Ende)."
}, },
"INFO": { "INFO": {
@@ -403,7 +406,10 @@
"REMOVE_YES": "Ja, entfernen", "REMOVE_YES": "Ja, entfernen",
"REMOVE_NO": "Abbrechen", "REMOVE_NO": "Abbrechen",
"REMOVED": "Spiel entfernt.", "REMOVED": "Spiel entfernt.",
"NO_ACHIEVEMENTS": "Erfolge gibt es nur für Steam-Spiele." "NO_ACHIEVEMENTS": "Erfolge gibt es nur für Steam-Spiele.",
"SELECT": "{{name}} auswählen",
"SELECTED": "{{count}} ausgewählt",
"ADD_SELECTED": "Prüfen und hinzufügen"
}, },
"STATS": { "STATS": {
"TITLE": "Deine Statistik", "TITLE": "Deine Statistik",
@@ -621,5 +627,81 @@
"SIZE_L": "Groß", "SIZE_L": "Groß",
"SUMMARY": "Summen", "SUMMARY": "Summen",
"STATUS": "Statusleiste" "STATUS": "Statusleiste"
},
"IMPORT": {
"OPEN": "Importieren",
"TITLE": "Spiele importieren",
"BACK": "Bibliothek",
"HINT": "Füge viele Spiele auf einmal hinzu, z. B. aus einer Liste oder Tabelle. Wir suchen jede Zeile bei IGDB; du prüfst die Treffer, bevor etwas gespeichert wird.",
"SOURCE": "Eingabe",
"SOURCE_TEXT": "Liste",
"SOURCE_CSV": "CSV-Datei",
"SOURCE_SEARCH": "Suche",
"TEXT_LABEL": "Spieltitel",
"TEXT_PLACEHOLDER": "Ein Spiel pro Zeile",
"CSV_LABEL": "CSV-Datei",
"CSV_HINT": "Erste Zeile mit Spaltennamen: title (oder name), platform, hours (oder minutes), status.",
"CSV_NO_TITLE": "Die Datei hat keine Spalte title oder name.",
"LINES": "{{count}} Zeilen",
"TOO_MANY": "{{count}} Zeilen: höchstens {{max}} pro Import.",
"START": "Spiele suchen",
"START_FAILED": "Der Import konnte nicht starten.",
"RUNNING": "Ein anderer Import wird noch gesucht. Warte, bis er fertig ist.",
"LOAD_FAILED": "Der Import konnte nicht geladen werden.",
"MATCHING": "Spiele werden gesucht: {{matched}} / {{total}}",
"MATCHING_HINT": "Du kannst die Seite verlassen und später wiederkommen.",
"REVIEW_HINT": "Hake die Spiele an, die du hinzufügen willst. Nur sichere Treffer sind schon angehakt.",
"SAVE": "{{count}} Spiele hinzufügen",
"SAVE_FAILED": "Der Import konnte nicht gespeichert werden.",
"NEW": "Neuer Import",
"LINE": "Zeile {{line}}",
"SEARCH_FAILED": "Suche fehlgeschlagen",
"SEARCH_AGAIN": "Neu suchen",
"NO_MATCH": "Kein Spiel gefunden. Suche neu, um eins zu wählen.",
"MATCH": "Spiel für Zeile {{line}}",
"PLATFORM": "Plattform für Zeile {{line}}",
"PICK_PLATFORM": "Plattform wählen",
"STATUS_LABEL": "Status für Zeile {{line}}",
"HOURS": "Stunden für Zeile {{line}}",
"HISTORY": "Frühere Importe",
"OPEN_IMPORT": "Öffnen",
"UNDO": "Rückgängig",
"UNDO_CONFIRM": "Die {{count}} Spiele dieses Imports entfernen? {{changed}} davon hast du seitdem geändert.",
"UNDO_YES": "Ja, entfernen",
"UNDONE": "{{count}} Spiele entfernt.",
"UNDO_FAILED": "Der Import konnte nicht rückgängig gemacht werden.",
"STATUS": {
"MATCHING": "Wird gesucht",
"READY": "Zu prüfen",
"SAVED": "Gespeichert",
"UNDONE": "Rückgängig gemacht",
"QUEUED": "Wartet",
"SAVING": "Wird gespeichert",
"FAILED": "Fehlgeschlagen"
},
"LINES_one": "{{count}} Zeile",
"SAVE_one": "{{count}} Spiel hinzufügen",
"UNDONE_one": "{{count}} Spiel entfernt.",
"UNDO_CONFIRM_one": "Das {{count}} Spiel dieses Imports entfernen? {{changed}} seitdem geändert.",
"STEPS": "Schritte des Imports",
"STEP": {
"QUEUED": "In der Warteschlange",
"MATCHING": "Suche bei IGDB",
"REVIEW": "Deine Prüfung",
"SAVING": "In die Bibliothek",
"DETAILS": "Spielzeit und Stimmungen",
"DONE": "Fertig"
},
"WAITING_one": "Wartet: {{count}} Import vor deinem.",
"WAITING": "Wartet: {{count}} Importe vor deinem.",
"WAITING_NEXT": "Startet gleich.",
"SAVING": "Wird in deine Bibliothek übernommen: {{saved}} / {{chosen}}",
"DETAILS": "Spielzeit und Stimmungen werden geladen.",
"DONE": "Fertig: {{added}} Spiele hinzugefügt, {{skipped}} übersprungen.",
"FAILED_one": "Der Import ist nach {{count}} Versuch stehen geblieben.",
"FAILED": "Der Import ist nach {{count}} Versuchen stehen geblieben.",
"RETRY": "Nochmal versuchen",
"RETRY_FAILED": "Der Import konnte nicht neu gestartet werden.",
"ATTEMPT": "Versuch {{attempt}}: der letzte Versuch kam nicht weiter."
} }
} }
+85 -3
View File
@@ -125,16 +125,19 @@
"TOO_MANY": "Too many wrong passwords. Please try again in {{minutes}} minutes.", "TOO_MANY": "Too many wrong passwords. Please try again in {{minutes}} minutes.",
"ERROR": "The account could not be deleted. Please try again later." "ERROR": "The account could not be deleted. Please try again later."
}, },
"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_HINT": "Selected first when you add a game on more than one platform.",
"FAVORITE_PLATFORM_NONE": "None"
}, },
"VALIDATION": { "VALIDATION": {
"EMAIL_REQUIRED": "Email is required.", "EMAIL_REQUIRED": "Email is required.",
"EMAIL_INVALID": "Enter a valid email address.", "EMAIL_INVALID": "Enter a valid email address.",
"PASSWORD_TOO_SHORT": "Password must be at least 8 characters.", "PASSWORD_TOO_SHORT": "Password must be at least 8 characters.",
"PASSWORD_TOO_LONG": "Password is too long: at most 72 characters, fewer with umlauts or emoji.",
"PASSWORD_CONFIRMATION_REQUIRED": "Please confirm your password.", "PASSWORD_CONFIRMATION_REQUIRED": "Please confirm your password.",
"PASSWORD_MISMATCH": "Passwords do not match.", "PASSWORD_MISMATCH": "Passwords do not match.",
"INVITE_CODE_REQUIRED": "Invite code is required.", "INVITE_CODE_REQUIRED": "Invite code is required.",
"LANGUAGE_INVALID": "Unsupported language.",
"DISPLAY_NAME_INVALID": "3–24 characters: letters, digits, spaces, _ . - (no space at the start or end)." "DISPLAY_NAME_INVALID": "3–24 characters: letters, digits, spaces, _ . - (no space at the start or end)."
}, },
"INFO": { "INFO": {
@@ -403,7 +406,10 @@
"REMOVE_YES": "Yes, remove", "REMOVE_YES": "Yes, remove",
"REMOVE_NO": "Cancel", "REMOVE_NO": "Cancel",
"REMOVED": "Game removed.", "REMOVED": "Game removed.",
"NO_ACHIEVEMENTS": "Achievements exist only for Steam games." "NO_ACHIEVEMENTS": "Achievements exist only for Steam games.",
"SELECT": "Select {{name}}",
"SELECTED": "{{count}} selected",
"ADD_SELECTED": "Check and add"
}, },
"STATS": { "STATS": {
"TITLE": "Your stats", "TITLE": "Your stats",
@@ -621,5 +627,81 @@
"SIZE_L": "Large", "SIZE_L": "Large",
"SUMMARY": "Totals", "SUMMARY": "Totals",
"STATUS": "Status bar" "STATUS": "Status bar"
},
"IMPORT": {
"OPEN": "Import",
"TITLE": "Import games",
"BACK": "Library",
"HINT": "Add many games at once, e.g. from a list or a spreadsheet. We search each line on IGDB; you check the matches before anything is saved.",
"SOURCE": "Input",
"SOURCE_TEXT": "List",
"SOURCE_CSV": "CSV file",
"SOURCE_SEARCH": "Search",
"TEXT_LABEL": "Game titles",
"TEXT_PLACEHOLDER": "One game per line",
"CSV_LABEL": "CSV file",
"CSV_HINT": "First row with column names: title (or name), platform, hours (or minutes), status.",
"CSV_NO_TITLE": "The file has no title or name column.",
"LINES": "{{count}} lines",
"TOO_MANY": "{{count}} lines: at most {{max}} per import.",
"START": "Search games",
"START_FAILED": "Could not start the import.",
"RUNNING": "Another import is still being matched. Wait until it is done.",
"LOAD_FAILED": "Could not load the import.",
"MATCHING": "Searching games: {{matched}} / {{total}}",
"MATCHING_HINT": "You can leave this page and come back later.",
"REVIEW_HINT": "Check the games to add. Only sure matches are checked already.",
"SAVE": "Add {{count}} games",
"SAVE_FAILED": "Could not save the import.",
"NEW": "New import",
"LINE": "Line {{line}}",
"SEARCH_FAILED": "Search failed",
"SEARCH_AGAIN": "Search again",
"NO_MATCH": "No game found. Search again to pick one.",
"MATCH": "Game for line {{line}}",
"PLATFORM": "Platform for line {{line}}",
"PICK_PLATFORM": "Pick a platform",
"STATUS_LABEL": "Status for line {{line}}",
"HOURS": "Hours for line {{line}}",
"HISTORY": "Earlier imports",
"OPEN_IMPORT": "Open",
"UNDO": "Undo",
"UNDO_CONFIRM": "Remove the {{count}} games of this import? {{changed}} of them you changed since.",
"UNDO_YES": "Yes, remove",
"UNDONE": "{{count}} games removed.",
"UNDO_FAILED": "Could not undo the import.",
"STATUS": {
"MATCHING": "Searching",
"READY": "To check",
"SAVED": "Saved",
"UNDONE": "Undone",
"QUEUED": "Queued",
"SAVING": "Saving",
"FAILED": "Failed"
},
"LINES_one": "{{count}} line",
"SAVE_one": "Add {{count}} game",
"UNDONE_one": "{{count}} game removed.",
"UNDO_CONFIRM_one": "Remove the {{count}} game of this import? {{changed}} changed since.",
"STEPS": "Import steps",
"STEP": {
"QUEUED": "Queued",
"MATCHING": "Searching on IGDB",
"REVIEW": "Your review",
"SAVING": "Adding to library",
"DETAILS": "Time to beat and moods",
"DONE": "Done"
},
"WAITING_one": "Waiting: {{count}} import before yours.",
"WAITING": "Waiting: {{count}} imports before yours.",
"WAITING_NEXT": "Starts in a moment.",
"SAVING": "Adding to your library: {{saved}} / {{chosen}}",
"DETAILS": "Loading time to beat and moods.",
"DONE": "Done: {{added}} games added, {{skipped}} skipped.",
"FAILED_one": "The import stopped after {{count}} try.",
"FAILED": "The import stopped after {{count}} tries.",
"RETRY": "Try again",
"RETRY_FAILED": "Could not start the import again.",
"ATTEMPT": "Try {{attempt}}: the last try made no progress."
} }
} }
+5 -3
View File
@@ -15,6 +15,7 @@ import GameDetail from './pages/GameDetail.jsx';
import WishlistPage from './pages/WishlistPage.jsx'; import WishlistPage from './pages/WishlistPage.jsx';
import PlayNextPage from './pages/PlayNextPage.jsx'; import PlayNextPage from './pages/PlayNextPage.jsx';
import StatsPage from './pages/StatsPage.jsx'; import StatsPage from './pages/StatsPage.jsx';
import ImportPage from './pages/ImportPage.jsx';
import PublicProfilePage from './pages/PublicProfilePage.jsx'; import PublicProfilePage from './pages/PublicProfilePage.jsx';
import AppHeader from './components/AppHeader.jsx'; import AppHeader from './components/AppHeader.jsx';
import {Toaster} from '@/components/ui/sonner'; import {Toaster} from '@/components/ui/sonner';
@@ -27,7 +28,7 @@ import {logout} from './utils/api.js';
function App() { function App() {
const {t} = useTranslation(); const {t} = useTranslation();
const [isAuthenticated, setIsAuthenticated] = useState(false); const [isAuthenticated, setIsAuthenticated] = useState(false);
const {appearance, syncAppearance, resetAppearance} = usePreferences(); const {appearance, syncPreferences, resetAppearance} = usePreferences();
// Hooks for routing // Hooks for routing
const navigate = useNavigate(); const navigate = useNavigate();
@@ -41,8 +42,8 @@ function App() {
// On login and on page load; the cached look shows until Auth answers. // On login and on page load; the cached look shows until Auth answers.
useEffect(() => { useEffect(() => {
if (isAuthenticated) syncAppearance(); if (isAuthenticated) syncPreferences();
}, [isAuthenticated, syncAppearance]); }, [isAuthenticated, syncPreferences]);
// Waits for Auth: a late logout answer would clear the cookies of a login that came after it. // Waits for Auth: a late logout answer would clear the cookies of a login that came after it.
const handleLogout = async () => { const handleLogout = async () => {
@@ -72,6 +73,7 @@ function App() {
<Route path="/wishlist" element={<WishlistPage/>}/> <Route path="/wishlist" element={<WishlistPage/>}/>
<Route path="/play-next" element={<PlayNextPage/>}/> <Route path="/play-next" element={<PlayNextPage/>}/>
<Route path="/stats" element={<StatsPage/>}/> <Route path="/stats" element={<StatsPage/>}/>
<Route path="/import" element={<ImportPage/>}/>
<Route path="/u/:name" element={<PublicProfilePage/>}/> <Route path="/u/:name" element={<PublicProfilePage/>}/>
<Route path="/settings" element={<SettingsPage/>}/> <Route path="/settings" element={<SettingsPage/>}/>
<Route path="/admin/users" element={<AdminUsersPage/>}/> <Route path="/admin/users" element={<AdminUsersPage/>}/>
+51 -9
View File
@@ -1,4 +1,4 @@
import {useState} from 'react'; import {useEffect, useState} from 'react';
import {useNavigate} from 'react-router-dom'; import {useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {toast} from 'sonner'; import {toast} from 'sonner';
@@ -12,11 +12,14 @@ import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/c
import GameCover from './GameCover.jsx'; import GameCover from './GameCover.jsx';
import GameSearch from './GameSearch.jsx'; import GameSearch from './GameSearch.jsx';
import useSessionExpired from '../hooks/useSessionExpired.js'; import useSessionExpired from '../hooks/useSessionExpired.js';
import {GAME_STATUSES} from '../utils/backlog.js'; import useStartImport from '../hooks/useStartImport.js';
import {GAME_STATUSES, MAX_PLATFORM_LENGTH} from '../utils/limits.js';
import {addGame, hoursToMinutes} from '../utils/manualGames.js'; import {addGame, hoursToMinutes} from '../utils/manualGames.js';
import {loadFavoritePlatform, preselectPlatform} from '../utils/platforms.js';
/** /**
* Search IGDB and add a game the user owns outside Steam, e.g. on PlayStation or Switch. * Search IGDB and add a game the user owns outside Steam, e.g. on PlayStation or Switch; or check several and review
* them on the import page.
*/ */
export default function AddGamePanel({onClose}) { export default function AddGamePanel({onClose}) {
const {t} = useTranslation(); const {t} = useTranslation();
@@ -24,6 +27,29 @@ export default function AddGamePanel({onClose}) {
const expireSession = useSessionExpired(); const expireSession = useSessionExpired();
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [picked, setPicked] = useState(null); const [picked, setPicked] = useState(null);
// igdbId -> hit, over several searches.
const [selected, setSelected] = useState(() => new Map());
const [busy, setBusy] = useState(false);
const [favorite, setFavorite] = useState(null);
useEffect(() => {
loadFavoritePlatform().then(setFavorite);
}, []);
const startImport = useStartImport();
const toggle = (hit) => {
const next = new Map(selected);
if (!next.delete(hit.igdbId)) next.set(hit.igdbId, hit);
setSelected(next);
};
// Several games go through a SEARCH import: its review picks the platforms, and it can be undone.
const reviewSelected = async () => {
setBusy(true);
const id = await startImport('SEARCH', [...selected.values()]
.map((hit) => ({igdbId: hit.igdbId, title: hit.name.slice(0, 100)})));
setBusy(false);
if (id) navigate(`/import?id=${id}`);
};
return ( return (
<Card className="p-5 gap-4"> <Card className="p-5 gap-4">
@@ -38,20 +64,34 @@ export default function AddGamePanel({onClose}) {
</div> </div>
{picked ? ( {picked ? (
<AddForm hit={picked} onBack={() => setPicked(null)} navigate={navigate} expireSession={expireSession}/> <AddForm hit={picked} favorite={favorite} onBack={() => setPicked(null)} navigate={navigate}
expireSession={expireSession}/>
) : ( ) : (
<> <>
<p className="text-sm text-muted-foreground">{t('MANUAL_GAME.ADD_HINT')}</p> <p className="text-sm text-muted-foreground">{t('MANUAL_GAME.ADD_HINT')}</p>
<GameSearch query={query} onQueryChange={setQuery} onPick={setPicked} blockInLibrary/> <GameSearch query={query} onQueryChange={setQuery} onPick={setPicked} blockInLibrary
selected={selected} onToggle={toggle}/>
{selected.size > 0 && (
<div className="flex flex-wrap items-center justify-between gap-3">
<span className="text-sm text-muted-foreground">{t('MANUAL_GAME.SELECTED', {count: selected.size})}</span>
<Button onClick={reviewSelected} disabled={busy}>
{busy ? <Loader2 className="animate-spin"/> : <Plus/>}{t('MANUAL_GAME.ADD_SELECTED')}
</Button>
</div>
)}
</> </>
)} )}
</Card> </Card>
); );
} }
function AddForm({hit, onBack, navigate, expireSession}) { function AddForm({hit, favorite, onBack, navigate, expireSession}) {
const {t} = useTranslation(); const {t} = useTranslation();
const [platform, setPlatform] = useState(hit.platforms[0] ?? ''); const [platform, setPlatform] = useState(() => preselectPlatform(null, hit.platforms, favorite));
// The favorite can arrive after the pick; it fills only a platform the user has not picked yet.
useEffect(() => {
setPlatform((current) => current || preselectPlatform(null, hit.platforms, favorite));
}, [favorite, hit.platforms]);
const [status, setStatus] = useState('BACKLOG'); const [status, setStatus] = useState('BACKLOG');
const [hours, setHours] = useState(''); const [hours, setHours] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -97,13 +137,15 @@ function AddForm({hit, onBack, navigate, expireSession}) {
<Label htmlFor="add-platform">{t('MANUAL_GAME.PLATFORM')}</Label> <Label htmlFor="add-platform">{t('MANUAL_GAME.PLATFORM')}</Label>
{hit.platforms.length > 0 ? ( {hit.platforms.length > 0 ? (
<Select value={platform} onValueChange={setPlatform}> <Select value={platform} onValueChange={setPlatform}>
<SelectTrigger id="add-platform" className="w-full"><SelectValue/></SelectTrigger> <SelectTrigger id="add-platform" className="w-full">
<SelectValue placeholder={t('IMPORT.PICK_PLATFORM')}/>
</SelectTrigger>
<SelectContent> <SelectContent>
{hit.platforms.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)} {hit.platforms.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
</SelectContent> </SelectContent>
</Select> </Select>
) : ( ) : (
<Input id="add-platform" value={platform} maxLength={32} onChange={(e) => setPlatform(e.target.value)}/> <Input id="add-platform" value={platform} maxLength={MAX_PLATFORM_LENGTH} onChange={(e) => setPlatform(e.target.value)}/>
)} )}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
+37 -9
View File
@@ -16,20 +16,24 @@ const portal = {...zelda, appid: 400, igdbId: 71, name: 'Portal', inLibrary: tru
const json = (status, body) => ({ok: status < 400, status, json: async () => body}); const json = (status, body) => ({ok: status < 400, status, json: async () => body});
function renderPanel(post = json(201, {appid: -1025})) { function renderPanel(post = json(201, {appid: -1025}), favoritePlatform = 'Nintendo Switch') {
globalThis.fetch = vi.fn(async (url, init) => init?.method === 'POST' globalThis.fetch = vi.fn(async (url, init) => {
? post if (url.includes('/api/users/me')) return json(200, {role: 'USER', settings: {favoritePlatform}});
: json(200, {available: true, results: [zelda, portal]})); return init?.method === 'POST' ? post : json(200, {available: true, results: [zelda, portal]});
});
render( render(
<MemoryRouter initialEntries={['/library']}> <MemoryRouter initialEntries={['/library']}>
<Routes> <Routes>
<Route path="/library" element={<AddGamePanel onClose={() => {}}/>}/> <Route path="/library" element={<AddGamePanel onClose={() => {}}/>}/>
<Route path="/library/:appid" element={<p>detail page</p>}/> <Route path="/library/:appid" element={<p>detail page</p>}/>
<Route path="/import" element={<p>import page</p>}/>
</Routes> </Routes>
</MemoryRouter>, </MemoryRouter>,
); );
} }
const searchCalls = () => fetch.mock.calls.filter(([url]) => url.includes('game-search'));
const search = (value) => fireEvent.change(screen.getByRole('searchbox'), {target: {value}}); const search = (value) => fireEvent.change(screen.getByRole('searchbox'), {target: {value}});
beforeEach(() => { beforeEach(() => {
@@ -48,7 +52,7 @@ describe('AddGamePanel', () => {
search('z'); search('z');
await new Promise((resolve) => setTimeout(resolve, 600)); await new Promise((resolve) => setTimeout(resolve, 600));
expect(fetch).not.toHaveBeenCalled(); expect(searchCalls()).toHaveLength(0);
}); });
it('searches once after typing stops and blocks games already in the library', async () => { it('searches once after typing stops and blocks games already in the library', async () => {
@@ -57,13 +61,13 @@ describe('AddGamePanel', () => {
search('zel'); search('zel');
const hit = await screen.findByRole('button', {name: /Zelda \(2017\)/}); const hit = await screen.findByRole('button', {name: /Zelda \(2017\)/});
expect(fetch).toHaveBeenCalledTimes(1); expect(searchCalls()).toHaveLength(1);
expect(fetch.mock.calls[0][0]).toContain('/api/library/game-search?q=zel'); expect(searchCalls()[0][0]).toContain('/api/library/game-search?q=zel');
expect(hit.disabled).toBe(false); expect(hit.disabled).toBe(false);
expect(screen.getByRole('button', {name: /Portal/}).disabled).toBe(true); expect(screen.getByRole('button', {name: /Portal/}).disabled).toBe(true);
}); });
it('adds the picked game with the first platform and the hours, then opens its page', async () => { it('adds the picked game with the favorite platform and the hours, then opens its page', async () => {
renderPanel(); renderPanel();
search('zelda'); search('zelda');
fireEvent.click(await screen.findByRole('button', {name: /Zelda/})); fireEvent.click(await screen.findByRole('button', {name: /Zelda/}));
@@ -79,6 +83,29 @@ describe('AddGamePanel', () => {
}); });
}); });
it('preselects no platform without a favorite among several, so Add waits for a pick', async () => {
renderPanel(undefined, null);
search('zelda');
fireEvent.click(await screen.findByRole('button', {name: /Zelda/}));
expect(screen.getByRole('button', {name: 'MANUAL_GAME.ADD'}).disabled).toBe(true);
});
it('sends several checked games to a search import and opens its review', async () => {
renderPanel(json(202, {id: 7}));
search('zelda');
const [zeldaBox, portalBox] = await screen.findAllByRole('checkbox', {name: 'MANUAL_GAME.SELECT'});
expect(portalBox.disabled).toBe(true);
fireEvent.click(zeldaBox);
fireEvent.click(screen.getByRole('button', {name: 'MANUAL_GAME.ADD_SELECTED'}));
expect(await screen.findByText('import page')).toBeTruthy();
const [url, init] = fetch.mock.calls.find(([, call]) => call?.method === 'POST');
expect(url).toContain('/api/library/imports');
expect(JSON.parse(init.body)).toEqual({source: 'SEARCH', rows: [{igdbId: 1025, title: 'Zelda'}]});
});
it('says so when the game is already in the library', async () => { it('says so when the game is already in the library', async () => {
renderPanel(json(409, {code: 'ALREADY_IN_LIBRARY'})); renderPanel(json(409, {code: 'ALREADY_IN_LIBRARY'}));
search('zelda'); search('zelda');
@@ -92,7 +119,8 @@ describe('AddGamePanel', () => {
it('shows that adding is off when Core has no IGDB app', async () => { it('shows that adding is off when Core has no IGDB app', async () => {
renderPanel(); renderPanel();
fetch.mockImplementation(async () => json(200, {available: false, results: []})); fetch.mockImplementation(async (url) => (url.includes('/api/users/me')
? json(200, {settings: {}}) : json(200, {available: false, results: []})));
search('zelda'); search('zelda');
expect(await screen.findByText('MANUAL_GAME.UNAVAILABLE')).toBeTruthy(); expect(await screen.findByText('MANUAL_GAME.UNAVAILABLE')).toBeTruthy();
+10 -1
View File
@@ -71,7 +71,16 @@ export default function AppHeader({isAuthenticated, onLogout}) {
toast.error(t('NAV.VIEW_AS.ERROR')); toast.error(t('NAV.VIEW_AS.ERROR'));
}; };
const toggleLanguage = () => setLanguage(i18n.language === 'de' ? 'en' : 'de'); // Unsaved, the next login would switch back to Auth's language.
const toggleLanguage = async () => {
try {
const response = await setLanguage(i18n.language === 'de' ? 'en' : 'de');
if (response && !response.ok) toast.error(t('SETTINGS.ERROR_SAVE'));
} catch (err) {
console.error('Language save error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
}
};
const toggleTheme = () => setDarkMode(!isDarkMode); const toggleTheme = () => setDarkMode(!isDarkMode);
const ThemeIcon = isDarkMode ? Sun : Moon; const ThemeIcon = isDarkMode ? Sun : Moon;
+17
View File
@@ -2,14 +2,18 @@
import {afterEach, describe, expect, it, vi} from 'vitest'; import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen} from '@testing-library/react'; import {cleanup, fireEvent, render, screen} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom'; import {MemoryRouter} from 'react-router-dom';
import {toast} from 'sonner';
import {useTranslation} from 'react-i18next';
import AppHeader from './AppHeader.jsx'; import AppHeader from './AppHeader.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx'; import {PreferencesProvider} from '../context/PreferencesContext.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
localStorage.clear(); localStorage.clear();
useTranslation().i18n.language = 'de';
}); });
const loggedInAs = (me) => { const loggedInAs = (me) => {
@@ -27,6 +31,19 @@ const loggedInAs = (me) => {
const openMenu = () => fireEvent.keyDown(screen.getByRole('button', {name: 'NAV.MENU'}), {key: 'Enter'}); const openMenu = () => fireEvent.keyDown(screen.getByRole('button', {name: 'NAV.MENU'}), {key: 'Enter'});
describe('AppHeader', () => { describe('AppHeader', () => {
it('says so when Auth refuses the language switch', async () => {
loggedInAs({role: 'USER', activeRole: 'USER', settings: {language: 'de'}});
fetch.mockImplementation(async (url, init) => (init?.method === 'PUT'
? {ok: false, status: 500, json: async () => ({})}
: {ok: true, status: 200, json: async () => ({role: 'USER', activeRole: 'USER', settings: {language: 'de'}})}));
await screen.findByRole('button', {name: 'NAV.MENU'});
openMenu();
fireEvent.click(await screen.findByRole('menuitem', {name: /NAV.LANGUAGE/}));
await vi.waitFor(() => expect(toast.error).toHaveBeenCalledWith('SETTINGS.ERROR_SAVE'));
});
// jsdom applies no media queries, so the test hides the name the way the 380px breakpoint does. // jsdom applies no media queries, so the test hides the name the way the 380px breakpoint does.
it('keeps a name on the home link when a narrow phone hides the app name', () => { it('keeps a name on the home link when a narrow phone hides the app name', () => {
render( render(
+4 -5
View File
@@ -11,12 +11,11 @@ import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/c
import {Textarea} from '@/components/ui/textarea'; import {Textarea} from '@/components/ui/textarea';
import {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
import {STATUS_META} from './GameStatusBadge.jsx'; import {STATUS_META} from './GameStatusBadge.jsx';
import {GAME_PRIORITIES, GAME_STATUSES, NO_PRIORITY, saveBacklogEntry, toBacklogRequest} from '../utils/backlog.js'; import {NO_PRIORITY, saveBacklogEntry, toBacklogRequest} from '../utils/backlog.js';
import {GAME_PRIORITIES, GAME_STATUSES, MAX_NOTES_LENGTH} from '../utils/limits.js';
import useSessionExpired from '../hooks/useSessionExpired.js'; import useSessionExpired from '../hooks/useSessionExpired.js';
import {hasSession} from '../utils/auth.js'; import {hasSession} from '../utils/auth.js';
const NOTES_MAX = 2000;
const toForm = (entry) => ({ const toForm = (entry) => ({
status: entry.status ?? 'BACKLOG', status: entry.status ?? 'BACKLOG',
rating: entry.rating ?? 0, rating: entry.rating ?? 0,
@@ -133,12 +132,12 @@ export default function BacklogCard({game, onSaved}) {
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-baseline justify-between"> <div className="flex items-baseline justify-between">
<Label htmlFor="backlog-notes">{t('BACKLOG.NOTES_LABEL')}</Label> <Label htmlFor="backlog-notes">{t('BACKLOG.NOTES_LABEL')}</Label>
<span className="text-xs text-muted-foreground tabular-nums">{form.notes.length} / {NOTES_MAX}</span> <span className="text-xs text-muted-foreground tabular-nums">{form.notes.length} / {MAX_NOTES_LENGTH}</span>
</div> </div>
<Textarea <Textarea
id="backlog-notes" id="backlog-notes"
rows={3} rows={3}
maxLength={NOTES_MAX} maxLength={MAX_NOTES_LENGTH}
placeholder={t('BACKLOG.NOTES_PLACEHOLDER')} placeholder={t('BACKLOG.NOTES_PLACEHOLDER')}
value={form.notes} value={form.notes}
onChange={(e) => set('notes')(e.target.value)} onChange={(e) => set('notes')(e.target.value)}
+5 -6
View File
@@ -11,10 +11,9 @@ import {Label} from '@/components/ui/label';
import useSessionExpired from '../hooks/useSessionExpired.js'; import useSessionExpired from '../hooks/useSessionExpired.js';
import {CORRECTABLE_MOODS, IGDB_GENRES, resetCorrection, saveCorrection} from '../utils/corrections.js'; import {CORRECTABLE_MOODS, IGDB_GENRES, resetCorrection, saveCorrection} from '../utils/corrections.js';
import {genreKey} from '../utils/stats.js'; import {genreKey} from '../utils/stats.js';
import {MAX_CORRECTION_GENRES, MAX_CORRECTION_LENGTH} from '../utils/limits.js';
const ENDLESS_CHOICES = [[null, 'UNKNOWN'], [true, 'YES'], [false, 'NO']]; const ENDLESS_CHOICES = [[null, 'UNKNOWN'], [true, 'YES'], [false, 'NO']];
// Core refuses more.
const MAX_GENRES = 10;
const initialValues = (game) => ({ const initialValues = (game) => ({
name: game.name ?? '', name: game.name ?? '',
@@ -129,12 +128,12 @@ export default function GameCorrectionDialog({game, open, onOpenChange, onChange
<DialogDescription>{t('CORRECTION.TEXT')}</DialogDescription> <DialogDescription>{t('CORRECTION.TEXT')}</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
{row('name', <Input id="correction-name" value={values.name} maxLength={512} {row('name', <Input id="correction-name" value={values.name} maxLength={MAX_CORRECTION_LENGTH}
onChange={(e) => set('name', e.target.value)}/>)} onChange={(e) => set('name', e.target.value)}/>)}
{row('image', <Input id="correction-image" value={values.image} maxLength={512} {row('image', <Input id="correction-image" value={values.image} maxLength={MAX_CORRECTION_LENGTH}
placeholder={t('CORRECTION.IMAGE_HINT')} placeholder={t('CORRECTION.IMAGE_HINT')}
onChange={(e) => set('image', e.target.value)}/>)} onChange={(e) => set('image', e.target.value)}/>)}
{row('speedrun', <Input id="correction-speedrun" value={values.speedrun} maxLength={512} {row('speedrun', <Input id="correction-speedrun" value={values.speedrun} maxLength={MAX_CORRECTION_LENGTH}
placeholder={t('CORRECTION.SPEEDRUN_HINT')} placeholder={t('CORRECTION.SPEEDRUN_HINT')}
onChange={(e) => set('speedrun', e.target.value)}/>)} onChange={(e) => set('speedrun', e.target.value)}/>)}
{row('moods', choices('moods', CORRECTABLE_MOODS, (mood) => t(`PLAY_NEXT.MOOD.${mood}`)))} {row('moods', choices('moods', CORRECTABLE_MOODS, (mood) => t(`PLAY_NEXT.MOOD.${mood}`)))}
@@ -150,7 +149,7 @@ export default function GameCorrectionDialog({game, open, onOpenChange, onChange
</div> </div>
))} ))}
{row('genres', choices('genres', genreOptions, {row('genres', choices('genres', genreOptions,
(genre) => t(`STATS.GENRE.${genreKey(genre)}`, {defaultValue: genre}), MAX_GENRES))} (genre) => t(`STATS.GENRE.${genreKey(genre)}`, {defaultValue: genre}), MAX_CORRECTION_GENRES))}
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
+20 -9
View File
@@ -7,9 +7,9 @@ import {Input} from '@/components/ui/input';
import GameCover from './GameCover.jsx'; import GameCover from './GameCover.jsx';
import useSessionExpired from '../hooks/useSessionExpired.js'; import useSessionExpired from '../hooks/useSessionExpired.js';
import {searchGames} from '../utils/manualGames.js'; import {searchGames} from '../utils/manualGames.js';
import {MAX_GAME_SEARCH_LENGTH, MIN_GAME_SEARCH_LENGTH} from '../utils/limits.js';
const SEARCH_DEBOUNCE_MS = 400; const SEARCH_DEBOUNCE_MS = 400;
const MIN_QUERY = 2;
/** /**
* The search field and the hits of Core's game search. The parent keeps the query, so it stays when the user goes * The search field and the hits of Core's game search. The parent keeps the query, so it stays when the user goes
@@ -17,8 +17,10 @@ const MIN_QUERY = 2;
* *
* @param hide hits not offered at all * @param hide hits not offered at all
* @param blockInLibrary hits already in the own library are shown, but cannot be picked * @param blockInLibrary hits already in the own library are shown, but cannot be picked
* @param selected with onToggle: igdb ids checked for a pick of several games; each hit gets a checkbox
*/ */
export default function GameSearch({query, onQueryChange, onPick, hide = () => false, blockInLibrary = false}) { export default function GameSearch({query, onQueryChange, onPick, hide = () => false, blockInLibrary = false,
selected, onToggle}) {
const {t} = useTranslation(); const {t} = useTranslation();
const {searchState, results} = useGameSearch(query); const {searchState, results} = useGameSearch(query);
const hits = results.filter((hit) => !hide(hit)); const hits = results.filter((hit) => !hide(hit));
@@ -27,7 +29,7 @@ export default function GameSearch({query, onQueryChange, onPick, hide = () => f
<> <>
<div className="relative"> <div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"/> <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={100} className="pl-8" <Input type="search" autoFocus value={query} maxLength={MAX_GAME_SEARCH_LENGTH} className="pl-8"
placeholder={t('MANUAL_GAME.SEARCH_PLACEHOLDER')} placeholder={t('MANUAL_GAME.SEARCH_PLACEHOLDER')}
aria-label={t('MANUAL_GAME.SEARCH_PLACEHOLDER')} aria-label={t('MANUAL_GAME.SEARCH_PLACEHOLDER')}
onChange={(e) => onQueryChange(e.target.value)}/> onChange={(e) => onQueryChange(e.target.value)}/>
@@ -45,9 +47,17 @@ export default function GameSearch({query, onQueryChange, onPick, hide = () => f
)} )}
{searchState === 'done' && hits.length > 0 && ( {searchState === 'done' && hits.length > 0 && (
<ul className="divide-y rounded-md border"> <ul className="divide-y rounded-md border">
{hits.map((hit) => ( {hits.map((hit) => {
<li key={hit.appid}> const blocked = blockInLibrary && hit.inLibrary;
<button type="button" disabled={blockInLibrary && hit.inLibrary} onClick={() => onPick(hit)} return (
<li key={hit.appid} className="flex items-center">
{onToggle && (
<input type="checkbox" className="ml-3 size-4 shrink-0 accent-primary"
aria-label={t('MANUAL_GAME.SELECT', {name: hit.name})}
checked={selected.has(hit.igdbId)} disabled={!hit.igdbId || blocked}
onChange={() => onToggle(hit)}/>
)}
<button type="button" disabled={blocked} onClick={() => onPick(hit)}
className="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-muted/50 disabled:cursor-not-allowed disabled:opacity-60"> className="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-muted/50 disabled:cursor-not-allowed disabled:opacity-60">
<GameCover appid={hit.appid} imageUrl={hit.imageUrl} className="w-20 shrink-0"/> <GameCover appid={hit.appid} imageUrl={hit.imageUrl} className="w-20 shrink-0"/>
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
@@ -58,10 +68,11 @@ export default function GameSearch({query, onQueryChange, onPick, hide = () => f
{hit.platforms.join(' · ')} {hit.platforms.join(' · ')}
</span> </span>
</span> </span>
{blockInLibrary && hit.inLibrary && <Badge variant="secondary">{t('MANUAL_GAME.IN_LIBRARY')}</Badge>} {blocked && <Badge variant="secondary">{t('MANUAL_GAME.IN_LIBRARY')}</Badge>}
</button> </button>
</li> </li>
))} );
})}
</ul> </ul>
)} )}
</> </>
@@ -76,7 +87,7 @@ function useGameSearch(query) {
useEffect(() => { useEffect(() => {
const trimmed = query.trim(); const trimmed = query.trim();
if (trimmed.length < MIN_QUERY) { if (trimmed.length < MIN_GAME_SEARCH_LENGTH) {
setSearchState('idle'); setSearchState('idle');
setResults([]); setResults([]);
return undefined; return undefined;
+3 -5
View File
@@ -9,10 +9,8 @@ import {Card} from '@/components/ui/card';
import {Input} from '@/components/ui/input'; import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label'; import {Label} from '@/components/ui/label';
import useSessionExpired from '../hooks/useSessionExpired.js'; import useSessionExpired from '../hooks/useSessionExpired.js';
import {hoursToMinutes, removeGame, updateManualGame} from '../utils/manualGames.js'; import {hoursToMinutes, removeGame, toHours, updateManualGame} from '../utils/manualGames.js';
import {MAX_PLATFORM_LENGTH} from '../utils/limits.js';
// Two decimals: then hoursToMinutes gives back the same minute, so an unchanged form is not dirty.
const toHours = (minutes) => (minutes > 0 ? String(Math.round((minutes / 60) * 100) / 100) : '');
/** /**
* Platform and hours of a game added by hand, and removing it. Steam games get both from the sync. * Platform and hours of a game added by hand, and removing it. Steam games get both from the sync.
@@ -73,7 +71,7 @@ export default function ManualGameCard({game, onSaved}) {
<div className="grid gap-5 sm:grid-cols-2"> <div className="grid gap-5 sm:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="manual-platform">{t('MANUAL_GAME.PLATFORM')}</Label> <Label htmlFor="manual-platform">{t('MANUAL_GAME.PLATFORM')}</Label>
<Input id="manual-platform" value={platform} maxLength={32} <Input id="manual-platform" value={platform} maxLength={MAX_PLATFORM_LENGTH}
onChange={(e) => setPlatform(e.target.value)}/> onChange={(e) => setPlatform(e.target.value)}/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
+2 -1
View File
@@ -7,7 +7,8 @@ import {Card} from '@/components/ui/card';
import GameCover from './GameCover.jsx'; import GameCover from './GameCover.jsx';
import PileTrend from './stats/PileTrend.jsx'; import PileTrend from './stats/PileTrend.jsx';
import {STATUS_META} from './GameStatusBadge.jsx'; import {STATUS_META} from './GameStatusBadge.jsx';
import {backlogShare, GAME_STATUSES} from '../utils/backlog.js'; import {backlogShare} from '../utils/backlog.js';
import {GAME_STATUSES} from '../utils/limits.js';
import {formatPlaytime} from '../utils/games.js'; import {formatPlaytime} from '../utils/games.js';
/** /**
+1 -1
View File
@@ -3,7 +3,7 @@ import {Gamepad2} from 'lucide-react';
import StatsSection from './StatsSection.jsx'; import StatsSection from './StatsSection.jsx';
import StatTile from './StatTile.jsx'; import StatTile from './StatTile.jsx';
import {GAME_STATUSES} from '../../utils/backlog.js'; import {GAME_STATUSES} from '../../utils/limits.js';
import {formatPlaytime} from '../../utils/games.js'; import {formatPlaytime} from '../../utils/games.js';
export default function OverviewStats({overview}) { export default function OverviewStats({overview}) {
+30 -10
View File
@@ -4,11 +4,13 @@ import {
applyAppearance, applyAppearance,
cacheAppearance, cacheAppearance,
DEFAULT_APPEARANCE, DEFAULT_APPEARANCE,
loadAppearance, loadPreferences,
normalizeAppearance, normalizeAppearance,
readCachedAppearance, readCachedAppearance,
saveAppearance, saveAppearance,
} from '../utils/appearance.js'; } from '../utils/appearance.js';
import {hasSession} from '../utils/auth.js';
import {LANGUAGES, saveSettings} from '../utils/settings.js';
const DARK_MODE_STORAGE_KEY = 'tbp_dark_mode'; const DARK_MODE_STORAGE_KEY = 'tbp_dark_mode';
// The --background of style.css; the installed app paints its title bar in this color. // The --background of style.css; the installed app paints its title bar in this color.
@@ -51,21 +53,29 @@ export function PreferencesProvider({children}) {
cacheAppearance(appearance); cacheAppearance(appearance);
}, [appearance]); }, [appearance]);
const syncAppearance = useCallback(() => { // Grows on every language pick, so a load that started before does not undo it.
const languagePicks = useRef(0);
const syncPreferences = useCallback(() => {
const started = generation.current; const started = generation.current;
const picksBefore = languagePicks.current;
syncing.current = (async () => { syncing.current = (async () => {
try { try {
const loaded = await loadAppearance(); const loaded = await loadPreferences();
if (loaded && generation.current === started) { if (loaded?.appearance && generation.current === started) {
savedAppearance.current = loaded; savedAppearance.current = loaded.appearance;
setAppearanceState(normalizeAppearance({...loaded, ...pendingChanges.current})); setAppearanceState(normalizeAppearance({...loaded.appearance, ...pendingChanges.current}));
}
if (LANGUAGES.includes(loaded?.language) && languagePicks.current === picksBefore
&& loaded.language !== i18n.language) {
i18n.changeLanguage(loaded.language);
} }
} catch (err) { } catch (err) {
console.error('Appearance load error:', err); console.error('Preferences load error:', err);
} }
})(); })();
return syncing.current; return syncing.current;
}, []); }, [i18n]);
/** /**
* Applies the changed fields at once, then saves them to Auth after a running load and after the saves before * Applies the changed fields at once, then saves them to Auth after a running load and after the saves before
@@ -114,7 +124,17 @@ export function PreferencesProvider({children}) {
setAppearanceState(DEFAULT_APPEARANCE); setAppearanceState(DEFAULT_APPEARANCE);
}, []); }, []);
const setLanguage = (lang) => i18n.changeLanguage(lang); /**
* Applies the language at once and saves it to Auth while logged in.
*
* @return the fetch response, or null when logged out; rejects on a network error
*/
const setLanguage = (lang) => {
languagePicks.current += 1;
i18n.changeLanguage(lang);
if (!hasSession()) return Promise.resolve(null);
return saveSettings({language: lang});
};
return ( return (
<PreferencesContext.Provider <PreferencesContext.Provider
@@ -125,7 +145,7 @@ export function PreferencesProvider({children}) {
setLanguage, setLanguage,
appearance, appearance,
changeAppearance, changeAppearance,
syncAppearance, syncPreferences,
resetAppearance, resetAppearance,
}} }}
> >
+61 -1
View File
@@ -3,7 +3,11 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, render} from '@testing-library/react'; import {act, cleanup, render} from '@testing-library/react';
import {PreferencesProvider, usePreferences} from './PreferencesContext.jsx'; import {PreferencesProvider, usePreferences} from './PreferencesContext.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js')); const i18n = vi.hoisted(() => ({language: 'de'}));
vi.mock('react-i18next', async () => {
const mock = await import('../test/i18n-mock.js');
return {...mock, useTranslation: () => ({t: mock.useTranslation().t, i18n})};
});
let preferences; let preferences;
function Spy() { function Spy() {
@@ -17,11 +21,67 @@ beforeEach(() => {
document.head.innerHTML = '<meta name="theme-color" content="#09090b">'; document.head.innerHTML = '<meta name="theme-color" content="#09090b">';
}); });
beforeEach(() => {
i18n.language = 'de';
i18n.changeLanguage = vi.fn((lng) => { i18n.language = lng; });
});
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
localStorage.clear(); localStorage.clear();
}); });
const answerMe = (settings) => {
globalThis.fetch = vi.fn(async (url, init) => (init?.method === 'PUT'
? {ok: true, status: 204}
: {ok: true, status: 200, json: async () => ({displayName: 'Lobo', settings})}));
};
describe('PreferencesProvider language', () => {
it('takes the language saved in Auth', async () => {
answerMe({language: 'en', useDarkTheme: true, publicProfile: false});
render(<PreferencesProvider><Spy/></PreferencesProvider>);
await act(() => preferences.syncPreferences());
expect(i18n.changeLanguage).toHaveBeenCalledWith('en');
});
it('keeps its own language while Auth has none', async () => {
answerMe({language: '', useDarkTheme: true, publicProfile: false});
render(<PreferencesProvider><Spy/></PreferencesProvider>);
await act(() => preferences.syncPreferences());
expect(i18n.changeLanguage).not.toHaveBeenCalled();
});
it('keeps a language picked while Auth was still answering', async () => {
answerMe({language: 'de', useDarkTheme: true, publicProfile: false});
render(<PreferencesProvider><Spy/></PreferencesProvider>);
await act(async () => {
const sync = preferences.syncPreferences();
preferences.setLanguage('en');
await sync;
});
expect(i18n.changeLanguage.mock.calls).toEqual([['en']]);
});
it('saves a picked language to Auth with the other settings unchanged', async () => {
localStorage.setItem('tbp_session', '1');
answerMe({language: 'de', useDarkTheme: true, publicProfile: true});
render(<PreferencesProvider><Spy/></PreferencesProvider>);
await act(() => preferences.setLanguage('en'));
const put = fetch.mock.calls.find(([, init]) => init?.method === 'PUT');
expect(put[0]).toMatch(/\/api\/users\/settings$/);
expect(JSON.parse(put[1].body)).toEqual({useDarkTheme: true, language: 'en', displayName: 'Lobo', publicProfile: true});
});
});
describe('PreferencesProvider theme color', () => { describe('PreferencesProvider theme color', () => {
it('gives the installed app a light title bar in light mode and a dark one again in dark mode', () => { it('gives the installed app a light title bar in light mode and a dark one again in dark mode', () => {
localStorage.setItem('tbp_dark_mode', 'false'); localStorage.setItem('tbp_dark_mode', 'false');
+3 -3
View File
@@ -121,7 +121,7 @@ describe('useSaveAppearance', () => {
let sync; let sync;
let save; let save;
act(() => { act(() => {
sync = result.current.preferences.syncAppearance(); sync = result.current.preferences.syncPreferences();
}); });
act(() => { act(() => {
save = result.current.save({corners: 'round'}); save = result.current.save({corners: 'round'});
@@ -149,7 +149,7 @@ describe('useSaveAppearance', () => {
let sync; let sync;
act(() => { act(() => {
sync = result.current.preferences.syncAppearance(); sync = result.current.preferences.syncPreferences();
}); });
act(() => result.current.preferences.resetAppearance()); act(() => result.current.preferences.resetAppearance());
await act(async () => { await act(async () => {
@@ -209,7 +209,7 @@ describe('useSaveAppearance', () => {
const {result} = renderSave(); const {result} = renderSave();
act(() => { act(() => {
result.current.preferences.syncAppearance(); result.current.preferences.syncPreferences();
}); });
let save; let save;
act(() => { act(() => {
+35
View File
@@ -0,0 +1,35 @@
import {useCallback} from 'react';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
import useSessionExpired from './useSessionExpired.js';
import {createImport} from '../utils/imports.js';
/**
* Returns `start(source, rows)`: it creates an import in Core and resolves to its id, or shows why not (or ends the
* session on a 401) and resolves to null.
*/
export default function useStartImport() {
const {t} = useTranslation();
const expireSession = useSessionExpired();
return useCallback(async (source, rows) => {
try {
const response = await createImport(source, rows);
if (response.status === 401) {
expireSession();
} else if (response.status === 409) {
toast.error(t('IMPORT.RUNNING'));
} else if (response.status === 503) {
toast.error(t('MANUAL_GAME.UNAVAILABLE'));
} else if (!response.ok) {
toast.error(t('IMPORT.START_FAILED'));
} else {
return (await response.json()).id;
}
} catch (err) {
console.error('Import start error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
}
return null;
}, [expireSession, t]);
}
+4 -1
View File
@@ -1,12 +1,13 @@
import i18n from 'i18next'; import i18n from 'i18next';
import {initReactI18next} from 'react-i18next'; import {initReactI18next} from 'react-i18next';
import HttpBackend from 'i18next-http-backend'; import HttpBackend from 'i18next-http-backend';
import {cacheLanguage, readCachedLanguage} from './utils/settings.js';
i18n.use(HttpBackend) i18n.use(HttpBackend)
.use(initReactI18next) .use(initReactI18next)
.init({ .init({
lng: 'de', lng: readCachedLanguage() ?? 'de',
fallbackLng: 'en', fallbackLng: 'en',
backend: { backend: {
// A browser kept an old file without its Cache-Control and showed raw keys; a new URL per version avoids that. // A browser kept an old file without its Cache-Control and showed raw keys; a new URL per version avoids that.
@@ -17,4 +18,6 @@ i18n.use(HttpBackend)
} }
}); });
i18n.on('languageChanged', cacheLanguage);
export default i18n; export default i18n;
+3 -3
View File
@@ -15,8 +15,8 @@ import PageContainer from '../components/PageContainer.jsx';
import useSessionExpired from '../hooks/useSessionExpired.js'; import useSessionExpired from '../hooks/useSessionExpired.js';
import {hasSession} from '../utils/auth.js'; import {hasSession} from '../utils/auth.js';
import {createHint, endHint, fetchAdminHints} from '../utils/hints.js'; import {createHint, endHint, fetchAdminHints} from '../utils/hints.js';
import {MAX_HINT_LENGTH} from '../utils/limits.js';
const MAX_TEXT = 500;
const WEEK_MS = 7 * 24 * 60 * 60 * 1000; const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
// The value of a datetime-local input: local time, no seconds. // The value of a datetime-local input: local time, no seconds.
@@ -129,11 +129,11 @@ export default function AdminHintsPage() {
<form onSubmit={submit} className="space-y-4"> <form onSubmit={submit} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="hintTextEn">{t('ADMIN_HINTS.TEXT_EN')}</Label> <Label htmlFor="hintTextEn">{t('ADMIN_HINTS.TEXT_EN')}</Label>
<Textarea id="hintTextEn" required maxLength={MAX_TEXT} value={form.textEn} onChange={change('textEn')}/> <Textarea id="hintTextEn" required maxLength={MAX_HINT_LENGTH} value={form.textEn} onChange={change('textEn')}/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="hintTextDe">{t('ADMIN_HINTS.TEXT_DE')}</Label> <Label htmlFor="hintTextDe">{t('ADMIN_HINTS.TEXT_DE')}</Label>
<Textarea id="hintTextDe" maxLength={MAX_TEXT} value={form.textDe} onChange={change('textDe')}/> <Textarea id="hintTextDe" maxLength={MAX_HINT_LENGTH} value={form.textDe} onChange={change('textDe')}/>
<p className="text-xs text-muted-foreground">{t('ADMIN_HINTS.TEXT_HELP')}</p> <p className="text-xs text-muted-foreground">{t('ADMIN_HINTS.TEXT_HELP')}</p>
</div> </div>
<fieldset className="space-y-2"> <fieldset className="space-y-2">
+4 -4
View File
@@ -213,18 +213,18 @@ const Dashboard = () => {
} else if (profileResponse.status === 401) { } else if (profileResponse.status === 401) {
expireSession(); expireSession();
} else { } else {
setError(t('INFO.ERROR.LOAD_PROFILE')); setError('INFO.ERROR.LOAD_PROFILE');
} }
} catch (err) { } catch (err) {
console.error('Dashboard fetch error:', err); console.error('Dashboard fetch error:', err);
setError(t('INFO.ERROR.NETWORK_FAILURE')); setError('INFO.ERROR.NETWORK_FAILURE');
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
fetchUserData(); fetchUserData();
}, [navigate, expireSession, t, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements]); }, [navigate, expireSession, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements]);
const hasSteamId = !!userData?.steamId; const hasSteamId = !!userData?.steamId;
const showLibrary = hasSteamId || steamData.count > 0; const showLibrary = hasSteamId || steamData.count > 0;
@@ -248,7 +248,7 @@ const Dashboard = () => {
if (error) { if (error) {
return ( return (
<div className="py-24 text-center text-destructive"> <div className="py-24 text-center text-destructive">
{error} {t(error)}
</div> </div>
); );
} }
+18 -9
View File
@@ -34,6 +34,7 @@ import {celebrationFor, celebrationLevel} from '../utils/confetti.js';
import {setGameHidden} from '../utils/hiddenGames.js'; import {setGameHidden} from '../utils/hiddenGames.js';
import {hasSession} from '../utils/auth.js'; import {hasSession} from '../utils/auth.js';
import {isSteamKey} from '../utils/manualGames.js'; import {isSteamKey} from '../utils/manualGames.js';
import {platformLabel} from '../utils/platforms.js';
const SPEEDRUN_PREFIX = 'https://www.speedrun.com/'; const SPEEDRUN_PREFIX = 'https://www.speedrun.com/';
@@ -88,6 +89,14 @@ export default function GameDetail() {
shownLevel.current = {appid: data.appid, level: celebrationLevel(data)}; shownLevel.current = {appid: data.appid, level: celebrationLevel(data)};
}, [data]); }, [data]);
// Another game starts empty, already in this render. A reload of the same game keeps the old data, so the edit
// cards keep what is typed.
const [dataAppid, setDataAppid] = useState(appid);
if (dataAppid !== appid) {
setDataAppid(appid);
setData(null);
}
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
if (!hasSession()) { if (!hasSession()) {
@@ -110,11 +119,11 @@ export default function GameDetail() {
return; return;
} }
if (response.status === 404) { if (response.status === 404) {
setError(t('GAME_DETAIL.NOT_FOUND')); setError('GAME_DETAIL.NOT_FOUND');
return; return;
} }
if (!response.ok) { if (!response.ok) {
setError(t('GAME_DETAIL.ERROR_LOAD')); setError('GAME_DETAIL.ERROR_LOAD');
return; return;
} }
const json = await response.json(); const json = await response.json();
@@ -122,7 +131,7 @@ export default function GameDetail() {
} catch (err) { } catch (err) {
if (!cancelled) { if (!cancelled) {
console.error('GameDetail load error:', err); console.error('GameDetail load error:', err);
setError(t('INFO.ERROR.NETWORK_FAILURE')); setError('INFO.ERROR.NETWORK_FAILURE');
} }
} finally { } finally {
if (!cancelled) setIsLoading(false); if (!cancelled) setIsLoading(false);
@@ -131,7 +140,7 @@ export default function GameDetail() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [appid, navigate, expireSession, t, reloads]); }, [appid, navigate, expireSession, reloads]);
const sortedAchievements = useMemo(() => { const sortedAchievements = useMemo(() => {
if (!data?.achievements) return []; if (!data?.achievements) return [];
@@ -187,7 +196,7 @@ export default function GameDetail() {
<ArrowLeft/>{t('GAME_DETAIL.BACK')} <ArrowLeft/>{t('GAME_DETAIL.BACK')}
</Button> </Button>
{isLoading && ( {isLoading && !data && (
<div className="space-y-6"> <div className="space-y-6">
<Skeleton className="h-52 rounded-xl"/> <Skeleton className="h-52 rounded-xl"/>
<Skeleton className="h-40 rounded-xl"/> <Skeleton className="h-40 rounded-xl"/>
@@ -196,12 +205,12 @@ export default function GameDetail() {
{error && !isLoading && ( {error && !isLoading && (
<div className="py-12 text-center text-destructive space-y-4"> <div className="py-12 text-center text-destructive space-y-4">
<p>{error}</p> <p>{t(error)}</p>
<Button asChild variant="outline"><Link to="/library">{t('GAME_DETAIL.BACK_TO_LIBRARY')}</Link></Button> <Button asChild variant="outline"><Link to="/library">{t('GAME_DETAIL.BACK_TO_LIBRARY')}</Link></Button>
</div> </div>
)} )}
{!isLoading && !error && data && ( {!error && data && (
<> <>
{data.hidden && ( {data.hidden && (
<div role="status" className="flex items-center gap-2 rounded-lg border bg-muted/50 px-4 py-3 text-sm text-muted-foreground"> <div role="status" className="flex items-center gap-2 rounded-lg border bg-muted/50 px-4 py-3 text-sm text-muted-foreground">
@@ -221,8 +230,8 @@ export default function GameDetail() {
{t('GAME_DETAIL.PLAYTIME', {time: formatPlaytime(data.playtimeMinutes)})} {t('GAME_DETAIL.PLAYTIME', {time: formatPlaytime(data.playtimeMinutes)})}
</Badge> </Badge>
<GameStatusBadge status={data.status}/> <GameStatusBadge status={data.status}/>
{data.platform && ( {platformLabel(data) && (
<Badge variant="outline"><Gamepad2 className="size-3"/>{data.platform}</Badge> <Badge variant="outline"><Gamepad2 className="size-3"/>{platformLabel(data)}</Badge>
)} )}
</div> </div>
<div className="mt-3"> <div className="mt-3">
+44
View File
@@ -0,0 +1,44 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter, Route, Routes} from 'react-router-dom';
import GameDetail from './GameDetail.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
// Stands in for a saved correction, which loads the game again.
vi.mock('../components/GameCorrectionDialog.jsx', () => ({
default: ({onChanged}) => <button type="button" onClick={onChanged}>correction saved</button>,
}));
const game = {
appid: 220, name: 'Half-Life 2', playtimeMinutes: 100, playtime2Weeks: 90, status: 'BACKLOG',
rating: null, notes: null, priority: null, hasAchievements: false, statsVisible: false, achievements: [],
};
const gameLoads = () => fetch.mock.calls.filter(([url]) => url.endsWith('/api/library/games/220')).length;
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
globalThis.fetch = vi.fn(async (url) => ({ok: true, status: 200,
json: async () => (url.includes('/api/users/me') ? {role: 'AUDITOR', activeRole: 'AUDITOR'} : game)}));
});
afterEach(() => {
cleanup();
localStorage.clear();
});
it('keeps unsaved notes while the game loads again after a correction', async () => {
render(
<MemoryRouter initialEntries={['/library/220']}><PreferencesProvider>
<Routes><Route path="/library/:appid" element={<GameDetail/>}/></Routes>
</PreferencesProvider></MemoryRouter>,
);
fireEvent.change(await screen.findByLabelText('BACKLOG.NOTES_LABEL'), {target: {value: 'my draft'}});
fireEvent.click(await screen.findByRole('button', {name: 'correction saved'}));
await waitFor(() => expect(gameLoads()).toBe(2));
await waitFor(() => expect(screen.getByLabelText('BACKLOG.NOTES_LABEL').value).toBe('my draft'));
});
+15 -10
View File
@@ -6,8 +6,9 @@ import SyncStatusBadge from '../components/SyncStatusBadge.jsx';
import useForceSync from '../hooks/useForceSync.js'; import useForceSync from '../hooks/useForceSync.js';
import useSessionExpired from '../hooks/useSessionExpired.js'; import useSessionExpired from '../hooks/useSessionExpired.js';
import {formatPlaytime} from '../utils/games.js'; import {formatPlaytime} from '../utils/games.js';
import {GAME_STATUSES, libraryQuery, parseStatus} from '../utils/backlog.js'; import {libraryQuery, parseStatus} from '../utils/backlog.js';
import {ArrowUpDown, ChevronLeft, ChevronRight, Clock, EyeOff, Gamepad2, Library, Plus, RefreshCw, Search, SearchX, Timer, Trophy} from 'lucide-react'; 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 {Badge} from '@/components/ui/badge'; import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
@@ -22,6 +23,7 @@ import GameStatusBadge from '../components/GameStatusBadge.jsx';
import PageContainer from '../components/PageContainer.jsx'; import PageContainer from '../components/PageContainer.jsx';
import {apiFetch} from '../utils/api.js'; import {apiFetch} from '../utils/api.js';
import {hasSession} from '../utils/auth.js'; import {hasSession} from '../utils/auth.js';
import {platformLabel} from '../utils/platforms.js';
const PAGE_SIZES = [20, 40, 80]; const PAGE_SIZES = [20, 40, 80];
const DEFAULT_SIZE = 20; const DEFAULT_SIZE = 20;
@@ -88,7 +90,7 @@ export default function GameLibrary() {
} }
if (!response.ok) { if (!response.ok) {
setError(t('LIBRARY.ERROR_LOAD')); setError('LIBRARY.ERROR_LOAD');
return; return;
} }
@@ -96,11 +98,11 @@ export default function GameLibrary() {
setData(json); setData(json);
} catch (err) { } catch (err) {
console.error('Library load error:', err); console.error('Library load error:', err);
setError(t('INFO.ERROR.NETWORK_FAILURE')); setError('INFO.ERROR.NETWORK_FAILURE');
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [navigate, expireSession, page, size, sort, listStatus, q, listShortOnly, hiddenOnly, t]); }, [navigate, expireSession, page, size, sort, listStatus, q, listShortOnly, hiddenOnly]);
useEffect(() => { useEffect(() => {
load(); load();
@@ -195,6 +197,9 @@ export default function GameLibrary() {
<Button variant="outline" size="sm" onClick={() => setAdding(true)} disabled={adding}> <Button variant="outline" size="sm" onClick={() => setAdding(true)} disabled={adding}>
<Plus/>{t('MANUAL_GAME.ADD_BUTTON')} <Plus/>{t('MANUAL_GAME.ADD_BUTTON')}
</Button> </Button>
<Button variant="outline" size="sm" asChild>
<Link to="/import"><Upload/>{t('IMPORT.OPEN')}</Link>
</Button>
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
@@ -265,7 +270,7 @@ export default function GameLibrary() {
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
placeholder={t('LIBRARY.SEARCH_PLACEHOLDER')} placeholder={t('LIBRARY.SEARCH_PLACEHOLDER')}
aria-label={t('LIBRARY.SEARCH_PLACEHOLDER')} aria-label={t('LIBRARY.SEARCH_PLACEHOLDER')}
maxLength={100} maxLength={MAX_LIBRARY_SEARCH_LENGTH}
className="pl-8" className="pl-8"
/> />
</div> </div>
@@ -283,7 +288,7 @@ export default function GameLibrary() {
</Card> </Card>
)} )}
{error && <div className="py-12 text-center text-destructive">{error}</div>} {error && <div className="py-12 text-center text-destructive">{t(error)}</div>}
{ready && data.content.length === 0 && ( {ready && data.content.length === 0 && (
<Card className="items-center py-16 text-center text-muted-foreground"> <Card className="items-center py-16 text-center text-muted-foreground">
@@ -309,9 +314,9 @@ export default function GameLibrary() {
<span className="min-w-0 flex-1 truncate font-medium group-hover:text-primary transition-colors"> <span className="min-w-0 flex-1 truncate font-medium group-hover:text-primary transition-colors">
{g.name ?? `App ${g.appid}`} {g.name ?? `App ${g.appid}`}
</span> </span>
{g.platform && ( {platformLabel(g) && (
<Badge variant="outline" className="hidden sm:inline-flex" title={g.platform}> <Badge variant="outline" className="hidden sm:inline-flex" title={platformLabel(g)}>
<Gamepad2 className="size-3"/>{g.platform} <Gamepad2 className="size-3"/>{platformLabel(g)}
</Badge> </Badge>
)} )}
<GameStatusBadge status={g.status} compact/> <GameStatusBadge status={g.status} compact/>
+9
View File
@@ -184,6 +184,15 @@ describe('GameLibrary with games added by hand', () => {
expect(document.querySelector('img[src="https://images.igdb.com/z.jpg"]')).toBeTruthy(); expect(document.querySelector('img[src="https://images.igdb.com/z.jpg"]')).toBeTruthy();
}); });
it('shows Steam as the platform of a Steam game, and links the import', async () => {
globalThis.fetch = answer([{appid: 220, name: 'Half-Life 2', playtimeMinutes: 0, status: 'BACKLOG', source: 'STEAM',
platform: null}], true);
renderLibrary();
expect(await screen.findByText('Steam')).toBeTruthy();
expect(screen.getByRole('link', {name: 'IMPORT.OPEN'}).getAttribute('href')).toBe('/import');
});
it('hides the Steam sync for a user without a Steam ID but still offers adding a game', async () => { it('hides the Steam sync for a user without a Steam ID but still offers adding a game', async () => {
globalThis.fetch = answer([zelda], false); globalThis.fetch = answer([zelda], false);
renderLibrary(); renderLibrary();
+554
View File
@@ -0,0 +1,554 @@
import {memo, useCallback, useEffect, useRef, useState} from 'react';
import {Link, useNavigate, useSearchParams} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
import {ArrowLeft, CheckCircle2, Circle, Loader2, RotateCcw, Search, Upload, XCircle} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {Input} from '@/components/ui/input';
import {Textarea} from '@/components/ui/textarea';
import {cn} from '@/lib/utils';
import GameSearch from '../components/GameSearch.jsx';
import PageContainer from '../components/PageContainer.jsx';
import useSessionExpired from '../hooks/useSessionExpired.js';
import useStartImport from '../hooks/useStartImport.js';
import {hasSession} from '../utils/auth.js';
import {
checkUndo, getImport, listImports, parseCsv, parseTextList, retryImport, saveImport, undoImport,
} from '../utils/imports.js';
import {GAME_STATUSES, MAX_IMPORT_ROWS, MAX_PLATFORM_LENGTH} from '../utils/limits.js';
import {hoursToMinutes, toHours} from '../utils/manualGames.js';
import {loadFavoritePlatform, preselectPlatform} from '../utils/platforms.js';
const POLL_MS = 2000;
// Statuses a worker still has: the page polls while it shows one.
const RUNNING = ['QUEUED', 'MATCHING', 'SAVING'];
// The steps an import goes through, as shown to the user.
const STEPS = ['QUEUED', 'MATCHING', 'REVIEW', 'SAVING', 'DETAILS', 'DONE'];
/**
* The step an import is in; a FAILED one is in the step it stopped in.
*/
export const stepOf = (data) => {
switch (data.status) {
case 'QUEUED': return 'QUEUED';
case 'MATCHING': return 'MATCHING';
case 'READY': return 'REVIEW';
case 'SAVING': return data.step === 'DETAILS' ? 'DETAILS' : 'SAVING';
case 'FAILED': return data.chosen > 0 ? 'SAVING' : 'MATCHING';
default: return 'DONE';
}
};
// The native select: a review of 1000 lines would mount 3000 Radix selects.
const SELECT_CLASS = 'h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-2 text-sm dark:bg-input/30';
/**
* Many games at once: a list or a CSV goes to Core, which matches it with IGDB in the background; the user checks
* the matches and saves them. ?id= opens one import; the history below can undo a saved import.
*/
export default function ImportPage() {
const {t} = useTranslation();
const navigate = useNavigate();
const expireSession = useSessionExpired();
const [searchParams, setSearchParams] = useSearchParams();
const [imports, setImports] = useState(null);
const id = searchParams.get('id');
const loadImports = useCallback(async () => {
try {
const response = await listImports();
if (response.status === 401) {
expireSession();
return;
}
setImports(response.ok ? (await response.json()).imports : []);
} catch (err) {
console.error('Import list error:', err);
setImports([]);
}
}, [expireSession]);
useEffect(() => {
if (!hasSession()) {
navigate('/login');
return;
}
loadImports();
}, [navigate, loadImports]);
// Without ?id=, an import a worker still has is shown first; one to review or failed is in the history.
const open = imports?.find((entry) => RUNNING.includes(entry.status));
const show = useCallback((next) => setSearchParams(next ? {id: String(next)} : {}, {replace: !id}),
[setSearchParams, id]);
// Into the URL, so the import stays shown when it leaves the worker (review, done).
useEffect(() => {
if (!id && open) show(open.id);
}, [id, open, show]);
const shownId = id ?? (open ? String(open.id) : null);
const done = () => {
show(null);
loadImports();
};
return (
<PageContainer className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="flex items-center gap-2 text-2xl font-bold"><Upload className="size-5 text-primary"/>{t('IMPORT.TITLE')}</h1>
<Button variant="ghost" size="sm" asChild>
<Link to="/library"><ArrowLeft/>{t('IMPORT.BACK')}</Link>
</Button>
</div>
{imports === null ? (
<Loader2 className="animate-spin text-muted-foreground"/>
) : shownId ? (
<ImportView key={shownId} id={shownId} onDone={done} onChanged={loadImports} expireSession={expireSession}/>
) : (
<ImportInput onCreated={(created) => {
show(created);
loadImports();
}}/>
)}
{imports?.length > 0 && <History imports={imports} onOpen={show} onChanged={loadImports} expireSession={expireSession}/>}
</PageContainer>
);
}
function ImportInput({onCreated}) {
const {t} = useTranslation();
const [source, setSource] = useState('TEXT');
const [text, setText] = useState('');
const [csv, setCsv] = useState(null);
const [busy, setBusy] = useState(false);
const rows = source === 'TEXT' ? parseTextList(text) : csv?.rows ?? [];
const tooMany = rows.length > MAX_IMPORT_ROWS;
const readFile = async (file) => setCsv(file ? parseCsv(await file.text()) : null);
const startImport = useStartImport();
const start = async () => {
setBusy(true);
const id = await startImport(source, rows);
setBusy(false);
if (id) onCreated(id);
};
return (
<Card className="gap-4 p-5">
<p className="text-sm text-muted-foreground">{t('IMPORT.HINT')}</p>
<div role="group" aria-label={t('IMPORT.SOURCE')} className="flex gap-2">
{['TEXT', 'CSV'].map((value) => (
<Button key={value} size="sm" variant={source === value ? 'default' : 'outline'} aria-pressed={source === value}
onClick={() => setSource(value)}>{t(`IMPORT.SOURCE_${value}`)}</Button>
))}
</div>
{source === 'TEXT' ? (
<Textarea rows={10} value={text} aria-label={t('IMPORT.TEXT_LABEL')} placeholder={t('IMPORT.TEXT_PLACEHOLDER')}
onChange={(e) => setText(e.target.value)}/>
) : (
<div className="space-y-2">
<Input type="file" accept=".csv,text/csv" aria-label={t('IMPORT.CSV_LABEL')}
onChange={(e) => readFile(e.target.files?.[0])}/>
<p className="text-xs text-muted-foreground">{t('IMPORT.CSV_HINT')}</p>
{csv?.error && <p className="text-sm text-destructive">{t(csv.error)}</p>}
{csv?.rows?.length > 0 && (
<ul className="text-sm text-muted-foreground">
{csv.rows.slice(0, 5).map((row, index) => (
<li key={index} className="truncate">
{[row.title, row.platform, row.status].filter(Boolean).join(' · ')}
</li>
))}
</ul>
)}
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-3">
<span className={tooMany ? 'text-sm text-destructive' : 'text-sm text-muted-foreground'}>
{t(tooMany ? 'IMPORT.TOO_MANY' : 'IMPORT.LINES', {count: rows.length, max: MAX_IMPORT_ROWS})}
</span>
<Button onClick={start} disabled={busy || rows.length === 0 || tooMany}>
{busy ? <Loader2 className="animate-spin"/> : <Search/>}{t('IMPORT.START')}
</Button>
</div>
</Card>
);
}
// Polls while a worker has the import.
function ImportView({id, onDone, onChanged, expireSession}) {
const {t} = useTranslation();
const [data, setData] = useState(null);
const [failed, setFailed] = useState(false);
// Grows after a save or a retry: the import is back with a worker, so polling starts again.
const [version, setVersion] = useState(0);
const reload = () => setVersion((current) => current + 1);
const lastStatus = useRef(null);
// undefined while loading: the review preselects with it, so it waits.
const [favorite, setFavorite] = useState(undefined);
useEffect(() => {
loadFavoritePlatform().then(setFavorite);
}, []);
useEffect(() => {
let timer;
let cancelled = false;
const load = async () => {
try {
const response = await getImport(id);
if (cancelled) return;
if (response.status === 401) {
expireSession();
return;
}
// A 4xx ends the polling; a 5xx while Core restarts is asked again.
if (response.status >= 400 && response.status < 500) {
setFailed(true);
return;
}
if (!response.ok) {
timer = setTimeout(load, POLL_MS);
return;
}
const next = await response.json();
if (cancelled) return;
// A new status also shows in the history.
if (lastStatus.current && lastStatus.current !== next.status) onChanged();
lastStatus.current = next.status;
setData(next);
if (RUNNING.includes(next.status)) timer = setTimeout(load, POLL_MS);
} catch (err) {
console.error('Import load error:', err);
if (!cancelled) timer = setTimeout(load, POLL_MS);
}
};
load();
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [id, expireSession, version, onChanged]);
if (failed) return <p className="text-sm text-destructive">{t('IMPORT.LOAD_FAILED')}</p>;
if (!data || favorite === undefined) return <Loader2 className="animate-spin text-muted-foreground"/>;
return (
<div className="space-y-4">
<ImportSteps data={data}/>
{data.status === 'READY' ? (
<Review data={data} favorite={favorite} onSaved={reload} onDone={onDone} expireSession={expireSession}/>
) : (
<ImportProgress data={data} onRetried={reload} onDone={onDone} expireSession={expireSession}/>
)}
</div>
);
}
// The steps in a row: done ones checked, the current one marked (red when the import failed there).
function ImportSteps({data}) {
const {t} = useTranslation();
const current = STEPS.indexOf(stepOf(data));
const failedHere = data.status === 'FAILED';
return (
<ol className="flex flex-wrap gap-x-4 gap-y-2 text-sm" aria-label={t('IMPORT.STEPS')}>
{STEPS.map((step, index) => {
const done = index < current || (index === current && step === 'DONE');
const now = index === current && !done;
return (
<li key={step} aria-current={now ? 'step' : undefined}
className={cn('flex items-center gap-1.5', done && 'text-muted-foreground',
now && (failedHere ? 'font-medium text-destructive' : 'font-medium text-primary'),
!done && !now && 'text-muted-foreground/60')}>
{done ? <CheckCircle2 className="size-4"/> : now && failedHere ? <XCircle className="size-4"/>
: now && RUNNING.includes(data.status) ? <Loader2 className="size-4 animate-spin"/>
: <Circle className="size-4"/>}
{t(`IMPORT.STEP.${step}`)}
</li>
);
})}
</ol>
);
}
// What the current step does, with its numbers; Retry for a FAILED import.
function ImportProgress({data, onRetried, onDone, expireSession}) {
const {t} = useTranslation();
const [busy, setBusy] = useState(false);
const step = stepOf(data);
const waiting = data.ahead != null;
const retry = async () => {
setBusy(true);
try {
const response = await retryImport(data.id);
if (response.status === 401) {
expireSession();
} else if (response.status === 409) {
toast.error(t('IMPORT.RUNNING'));
} else if (!response.ok) {
toast.error(t('IMPORT.RETRY_FAILED'));
} else {
onRetried();
}
} catch (err) {
console.error('Import retry error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setBusy(false);
}
};
let text;
let progress = null;
if (data.status === 'FAILED') {
text = t('IMPORT.FAILED', {count: data.attempts});
} else if (waiting) {
text = data.ahead > 0 ? t('IMPORT.WAITING', {count: data.ahead}) : t('IMPORT.WAITING_NEXT');
} else if (step === 'MATCHING') {
text = t('IMPORT.MATCHING', {matched: data.matched, total: data.total});
progress = [data.matched, data.total];
} else if (step === 'SAVING') {
text = t('IMPORT.SAVING', {saved: data.saved, chosen: data.chosen});
progress = [data.saved, data.chosen];
} else if (step === 'DETAILS') {
text = t('IMPORT.DETAILS');
} else {
text = t(data.status === 'UNDONE' ? 'IMPORT.STATUS.UNDONE' : 'IMPORT.DONE',
{added: data.added, skipped: data.saved - data.added});
}
return (
<Card className="gap-3 p-5">
<p className={cn('font-medium', data.status === 'FAILED' && 'text-destructive')}>{text}</p>
{progress && <progress className="w-full accent-primary" value={progress[0]} max={progress[1]}/>}
{data.attempts > 0 && RUNNING.includes(data.status) && (
<p className="text-sm text-muted-foreground">{t('IMPORT.ATTEMPT', {attempt: data.attempts + 1})}</p>
)}
{RUNNING.includes(data.status) && <p className="text-sm text-muted-foreground">{t('IMPORT.MATCHING_HINT')}</p>}
<div className="flex flex-wrap gap-2">
{data.status === 'FAILED' && (
<Button onClick={retry} disabled={busy}>
{busy ? <Loader2 className="animate-spin"/> : <RotateCcw/>}{t('IMPORT.RETRY')}
</Button>
)}
{!RUNNING.includes(data.status) && (
<Button variant="outline" onClick={onDone}>{t('IMPORT.NEW')}</Button>
)}
</div>
</Card>
);
}
/**
* The first choice of a line: its best candidate with the preselected platform; checked only for a sure match (the
* same name, not in the library yet, a platform found).
*/
export const initialChoice = (row, favorite) => {
const candidate = row.candidates[0];
const platform = candidate ? preselectPlatform(row.platform, candidate.platforms, favorite) : '';
return {
candidates: row.candidates,
igdbId: candidate?.igdbId ?? null,
platform,
status: row.status ?? 'BACKLOG',
hours: toHours(row.playtimeMinutes),
checked: row.exact && !!candidate && !candidate.inLibrary && platform !== '',
};
};
function Review({data, favorite, onSaved, onDone, expireSession}) {
const {t} = useTranslation();
const [choices, setChoices] = useState(() => data.rows.map((row) => initialChoice(row, favorite)));
const [busy, setBusy] = useState(false);
// Stable, so a change in one line re-renders only that line (up to 1000 lines).
const change = useCallback((index, changes) => setChoices((current) =>
current.map((choice, i) => (i === index ? {...choice, ...changes} : choice))), []);
const pick = useCallback((index, candidate) => setChoices((current) => current.map((choice, i) => {
if (i !== index) return choice;
const candidates = choice.candidates.some((known) => known.igdbId === candidate.igdbId)
? choice.candidates : [...choice.candidates, candidate];
return {...choice, candidates, igdbId: candidate.igdbId,
platform: preselectPlatform(data.rows[index].platform, candidate.platforms, favorite)};
})), [data.rows, favorite]);
const chosen = choices.map((choice, index) => ({choice, row: data.rows[index]}))
.filter(({choice}) => choice.checked && choice.igdbId && choice.platform.trim());
const save = async () => {
setBusy(true);
try {
const response = await saveImport(data.id, chosen.map(({choice, row}) => ({
lineNo: row.lineNo, igdbId: choice.igdbId, platform: choice.platform.trim(), status: choice.status,
playtimeMinutes: hoursToMinutes(choice.hours),
})));
if (response.status === 401) {
expireSession();
return;
}
if (!response.ok) {
toast.error(t('IMPORT.SAVE_FAILED'));
return;
}
onSaved();
} catch (err) {
console.error('Import save error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setBusy(false);
}
};
return (
<Card className="gap-0 overflow-hidden p-0">
<div className="flex flex-wrap items-center justify-between gap-3 border-b px-5 py-4">
<p className="text-sm text-muted-foreground">{t('IMPORT.REVIEW_HINT')}</p>
<div className="flex gap-2">
<Button variant="ghost" onClick={onDone} disabled={busy}>{t('IMPORT.NEW')}</Button>
<Button onClick={save} disabled={busy || chosen.length === 0}>
{busy && <Loader2 className="animate-spin"/>}{t('IMPORT.SAVE', {count: chosen.length})}
</Button>
</div>
</div>
<ul className="divide-y">
{choices.map((choice, index) => (
<ReviewRow key={data.rows[index].lineNo} index={index} row={data.rows[index]} choice={choice}
onChange={change} onPick={pick}/>
))}
</ul>
</Card>
);
}
const ReviewRow = memo(function ReviewRow({index, row, choice, onChange: changeLine, onPick: pickLine}) {
const {t} = useTranslation();
const onChange = (changes) => changeLine(index, changes);
const onPick = (candidate) => pickLine(index, candidate);
const [searching, setSearching] = useState(false);
const [query, setQuery] = useState(row.title ?? '');
const candidate = choice.candidates.find((known) => known.igdbId === choice.igdbId);
const label = `${t('IMPORT.LINE', {line: row.lineNo})}: ${row.title ?? ''}`;
return (
<li className="space-y-2 px-5 py-3" data-line={row.lineNo}>
<div className="flex items-center gap-2">
<input type="checkbox" className="size-4 accent-primary" aria-label={label} checked={choice.checked}
disabled={!candidate} onChange={(e) => onChange({checked: e.target.checked})}/>
<span className="min-w-0 flex-1 truncate text-sm">{label}</span>
{candidate?.inLibrary && <Badge variant="secondary">{t('MANUAL_GAME.IN_LIBRARY')}</Badge>}
{row.searchFailed && <Badge variant="destructive">{t('IMPORT.SEARCH_FAILED')}</Badge>}
<Button variant="ghost" size="sm" onClick={() => setSearching(!searching)}>
<Search/>{t('IMPORT.SEARCH_AGAIN')}
</Button>
</div>
{searching && (
<GameSearch query={query} onQueryChange={setQuery} hide={(hit) => !hit.igdbId} onPick={(hit) => {
onPick(hit);
setSearching(false);
}}/>
)}
{choice.candidates.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('IMPORT.NO_MATCH')}</p>
) : (
<div className="grid gap-2 sm:grid-cols-[2fr_1fr_1fr_6rem]">
<select className={SELECT_CLASS} aria-label={t('IMPORT.MATCH', {line: row.lineNo})} value={choice.igdbId ?? ''}
onChange={(e) => onPick(choice.candidates.find((known) => String(known.igdbId) === e.target.value))}>
{choice.candidates.map((known) => (
<option key={known.igdbId} value={known.igdbId}>
{known.name}{known.year ? ` (${known.year})` : ''}{known.inLibrary ? ` · ${t('MANUAL_GAME.IN_LIBRARY')}` : ''}
</option>
))}
</select>
{candidate?.platforms.length > 0 ? (
<select className={SELECT_CLASS} aria-label={t('IMPORT.PLATFORM', {line: row.lineNo})} value={choice.platform}
onChange={(e) => onChange({platform: e.target.value})}>
<option value="">{t('IMPORT.PICK_PLATFORM')}</option>
{!candidate.platforms.includes(choice.platform) && choice.platform && (
<option value={choice.platform}>{choice.platform}</option>
)}
{candidate.platforms.map((platform) => <option key={platform} value={platform}>{platform}</option>)}
</select>
) : (
<Input value={choice.platform} maxLength={MAX_PLATFORM_LENGTH} aria-label={t('IMPORT.PLATFORM', {line: row.lineNo})}
onChange={(e) => onChange({platform: e.target.value})}/>
)}
<select className={SELECT_CLASS} aria-label={t('IMPORT.STATUS_LABEL', {line: row.lineNo})} value={choice.status}
onChange={(e) => onChange({status: e.target.value})}>
{GAME_STATUSES.map((status) => <option key={status} value={status}>{t(`BACKLOG.STATUS.${status}`)}</option>)}
</select>
<Input type="number" min="0" step="0.5" inputMode="decimal" value={choice.hours}
aria-label={t('IMPORT.HOURS', {line: row.lineNo})} onChange={(e) => onChange({hours: e.target.value})}/>
</div>
)}
</li>
);
});
function History({imports, onOpen, onChanged, expireSession}) {
const {t, i18n} = useTranslation();
const [confirm, setConfirm] = useState(null);
const [busy, setBusy] = useState(false);
const run = async (call, onOk) => {
setBusy(true);
try {
const response = await call();
if (response.status === 401) {
expireSession();
return;
}
if (!response.ok) {
toast.error(t('IMPORT.UNDO_FAILED'));
return;
}
await onOk(response);
} catch (err) {
console.error('Import undo error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setBusy(false);
}
};
const askUndo = (id) => run(() => checkUndo(id), async (response) => setConfirm({id, ...await response.json()}));
const undo = () => run(() => undoImport(confirm.id), () => {
toast.success(t('IMPORT.UNDONE', {count: confirm.games}));
setConfirm(null);
onChanged();
});
return (
<Card className="gap-0 p-0">
<h2 className="border-b px-5 py-3 font-semibold">{t('IMPORT.HISTORY')}</h2>
<ul className="divide-y">
{imports.map((entry) => (
<li key={entry.id} className="flex flex-wrap items-center gap-3 px-5 py-3 text-sm">
<span className="tabular-nums text-muted-foreground">
{new Date(entry.createdAt).toLocaleDateString(i18n.language)}
</span>
<span className="flex-1">
{t(`IMPORT.SOURCE_${entry.source}`)} · {t('IMPORT.LINES', {count: entry.total})}
</span>
<Badge variant={entry.status === 'FAILED' ? 'destructive' : 'outline'}>{t(`IMPORT.STATUS.${entry.status}`)}</Badge>
{entry.status !== 'SAVED' && entry.status !== 'UNDONE' && (
<Button variant="outline" size="sm" onClick={() => onOpen(entry.id)}>{t('IMPORT.OPEN_IMPORT')}</Button>
)}
{(entry.status === 'SAVED' || (entry.status === 'FAILED' && entry.added > 0))
&& confirm?.id !== entry.id && (
<Button variant="outline" size="sm" disabled={busy} onClick={() => askUndo(entry.id)}>
<RotateCcw/>{t('IMPORT.UNDO')}
</Button>
)}
{confirm?.id === entry.id && (
<div className="flex w-full flex-wrap items-center gap-2">
<span>{t('IMPORT.UNDO_CONFIRM', {count: confirm.games, changed: confirm.changed})}</span>
<Button variant="destructive" size="sm" disabled={busy} onClick={undo}>{t('IMPORT.UNDO_YES')}</Button>
<Button variant="ghost" size="sm" disabled={busy} onClick={() => setConfirm(null)}>
{t('MANUAL_GAME.REMOVE_NO')}
</Button>
</div>
)}
</li>
))}
</ul>
</Card>
);
}
+226
View File
@@ -0,0 +1,226 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor, within} from '@testing-library/react';
import {MemoryRouter, Route, Routes} from 'react-router-dom';
import {toast} from 'sonner';
import ImportPage, {initialChoice, stepOf} from './ImportPage.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const json = (status, body) => ({ok: status < 400, status, json: async () => body});
const hades = {igdbId: 1, name: 'Hades', year: 2020, imageUrl: null, platforms: ['PC', 'Switch'], inLibrary: false};
const hades2 = {igdbId: 2, name: 'Hades II', year: 2025, imageUrl: null, platforms: ['PC'], inLibrary: false};
const celeste = {igdbId: 3, name: 'Celeste', year: 2018, imageUrl: null, platforms: ['PC', 'PS4'], inLibrary: true};
const row = (lineNo, title, candidates, extra = {}) => ({
lineNo, title, platform: null, playtimeMinutes: null, status: null, searchFailed: false, exact: true, candidates, ...extra,
});
const ready = {
id: 5, source: 'TEXT', status: 'READY', total: 3, matched: 3, rows: [
row(1, 'Hades', [hades, hades2]),
row(2, 'Celeste', [celeste]),
row(3, 'Hade', [hades2, hades], {exact: false, playtimeMinutes: 90}),
],
};
// routes: 'METHOD path after /imports' -> response or (init) => response; any other URL (Auth) answers the favorite
function renderPage(routes, entry = '/import') {
globalThis.fetch = vi.fn(async (url, init) => {
if (!url.includes('/api/library/imports')) return json(200, {settings: {favoritePlatform: 'Switch'}});
const path = new URL(url, 'http://x').pathname.replace(/.*\/api\/library\/imports/, '');
const answer = routes[`${init?.method ?? 'GET'} ${path}`];
if (!answer) throw new Error(`no route ${init?.method ?? 'GET'} ${path}`);
return typeof answer === 'function' ? answer(init) : answer;
});
render(
<MemoryRouter initialEntries={[entry]}>
<Routes>
<Route path="/import" element={<PreferencesProvider><ImportPage/></PreferencesProvider>}/>
<Route path="/login" element={<p>login</p>}/>
</Routes>
</MemoryRouter>,
);
}
const posted = (method, path) => fetch.mock.calls.find(([url, init]) => init?.method === method && url.endsWith(path));
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
});
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
describe('initialChoice', () => {
it('checks only a sure match with a platform', () => {
expect(initialChoice(ready.rows[0], 'Switch')).toMatchObject({igdbId: 1, platform: 'Switch', checked: true});
expect(initialChoice(ready.rows[0], null)).toMatchObject({platform: '', checked: false});
expect(initialChoice(ready.rows[1], 'PC')).toMatchObject({platform: 'PC', checked: false});
expect(initialChoice(ready.rows[2], 'PC')).toMatchObject({platform: 'PC', checked: false, hours: '1.5'});
expect(initialChoice(row(4, 'x', []), 'PC')).toMatchObject({igdbId: null, checked: false});
});
});
describe('stepOf', () => {
it('maps the status to the step, and a failed import to the step it stopped in', () => {
expect(stepOf({status: 'QUEUED'})).toBe('QUEUED');
expect(stepOf({status: 'READY'})).toBe('REVIEW');
expect(stepOf({status: 'SAVING', step: null})).toBe('SAVING');
expect(stepOf({status: 'SAVING', step: 'DETAILS'})).toBe('DETAILS');
expect(stepOf({status: 'FAILED', chosen: 0})).toBe('MATCHING');
expect(stepOf({status: 'FAILED', chosen: 3})).toBe('SAVING');
expect(stepOf({status: 'UNDONE'})).toBe('DONE');
});
});
describe('ImportPage', () => {
it('shows a queued import with the imports before it', async () => {
renderPage({
'GET ': json(200, {imports: []}),
'GET /8': json(200, {id: 8, source: 'CSV', status: 'QUEUED', total: 40, matched: 0, chosen: 0, saved: 0,
added: 0, ahead: 2, attempts: 0}),
}, '/import?id=8');
expect(await screen.findByText('IMPORT.WAITING:2')).toBeTruthy();
expect(screen.getByText('IMPORT.STEP.QUEUED').closest('li').getAttribute('aria-current')).toBe('step');
});
it('tries a failed import again and polls it', async () => {
let retried = false;
renderPage({
'GET ': json(200, {imports: []}),
'GET /6': () => json(200, {id: 6, source: 'TEXT', total: 10, matched: retried ? 4 : 2, chosen: 0, saved: 0,
added: 0, ahead: null, attempts: retried ? 0 : 3, status: retried ? 'MATCHING' : 'FAILED'}),
'POST /6/retry': () => {
retried = true;
return json(202, null);
},
}, '/import?id=6');
expect(await screen.findByText('IMPORT.FAILED:3')).toBeTruthy();
fireEvent.click(screen.getByRole('button', {name: 'IMPORT.RETRY'}));
expect(await screen.findByText('IMPORT.MATCHING')).toBeTruthy();
expect(posted('POST', '/6/retry')).toBeTruthy();
});
it('starts an import from the list and shows the progress', async () => {
renderPage({
'GET ': json(200, {imports: []}),
'POST ': json(202, {id: 9}),
'GET /9': json(200, {id: 9, source: 'TEXT', status: 'MATCHING', total: 2, matched: 1}),
});
fireEvent.change(await screen.findByLabelText('IMPORT.TEXT_LABEL'), {target: {value: 'Hades\n\n Celeste \n'}});
expect(screen.getByText('IMPORT.LINES:2')).toBeTruthy();
fireEvent.click(screen.getByRole('button', {name: 'IMPORT.START'}));
expect(await screen.findByText('IMPORT.MATCHING')).toBeTruthy();
expect(JSON.parse(posted('POST', '/imports')[1].body)).toEqual({
source: 'TEXT', rows: [{title: 'Hades'}, {title: 'Celeste'}],
});
});
it('polls while matching and then shows the review', async () => {
let calls = 0;
renderPage({
'GET ': json(200, {imports: [{...ready, status: 'MATCHING', rows: null}]}),
'GET /5': () => (++calls === 1 ? json(200, {...ready, status: 'MATCHING', matched: 1, rows: null}) : json(200, ready)),
});
expect(await screen.findByText('IMPORT.MATCHING')).toBeTruthy();
expect(await screen.findByText('IMPORT.LINE: Hades', {}, {timeout: 3000})).toBeTruthy();
});
it('preselects the favorite, checks only sure matches, and saves the checked lines', async () => {
let saved = false;
renderPage({
'GET ': json(200, {imports: []}),
'GET /5': () => json(200, saved ? {...ready, status: 'SAVED', chosen: 2, saved: 2, added: 1, rows: null} : ready),
'POST /5/save': () => {
saved = true;
return json(202, null);
},
}, '/import?id=5');
const first = await screen.findByLabelText('IMPORT.LINE: Hades');
await waitFor(() => expect(within(first.closest('li')).getByLabelText('IMPORT.PLATFORM').value).toBe('Switch'));
expect(first.checked).toBe(true);
// In the library already, and not the same name.
expect(screen.getByLabelText('IMPORT.LINE: Celeste').checked).toBe(false);
const third = screen.getByLabelText('IMPORT.LINE: Hade');
expect(third.checked).toBe(false);
const line3 = third.closest('li');
fireEvent.change(within(line3).getByLabelText('IMPORT.MATCH'), {target: {value: '1'}});
expect(within(line3).getByLabelText('IMPORT.PLATFORM').value).toBe('Switch');
fireEvent.change(within(line3).getByLabelText('IMPORT.STATUS_LABEL'), {target: {value: 'PLAYING'}});
fireEvent.click(third);
fireEvent.click(screen.getByRole('button', {name: 'IMPORT.SAVE:2'}));
// The save runs in the background; the page polls until it is done.
expect(await screen.findByText('IMPORT.DONE')).toBeTruthy();
expect(JSON.parse(posted('POST', '/5/save')[1].body)).toEqual({rows: [
{lineNo: 1, igdbId: 1, platform: 'Switch', status: 'BACKLOG', playtimeMinutes: 0},
{lineNo: 3, igdbId: 1, platform: 'Switch', status: 'PLAYING', playtimeMinutes: 90},
]});
});
it('undoes a saved import after a confirm with the changed count', async () => {
renderPage({
'GET ': json(200, {imports: [{id: 4, source: 'CSV', status: 'SAVED', total: 3, matched: 3, createdAt: '2026-09-29T10:00:00Z'}]}),
'GET /4/undo-check': json(200, {games: 3, changed: 1}),
'DELETE /4/games': json(204, null),
});
fireEvent.click(await screen.findByRole('button', {name: 'IMPORT.UNDO'}));
expect(await screen.findByText('IMPORT.UNDO_CONFIRM:3')).toBeTruthy();
fireEvent.click(screen.getByRole('button', {name: 'IMPORT.UNDO_YES'}));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('IMPORT.UNDONE:3'));
expect(posted('DELETE', '/4/games')).toBeTruthy();
});
it('says so when another import is still matching', async () => {
renderPage({'GET ': json(200, {imports: []}), 'POST ': json(409, {code: 'IMPORT_RUNNING'})});
fireEvent.change(await screen.findByLabelText('IMPORT.TEXT_LABEL'), {target: {value: 'Hades'}});
fireEvent.click(screen.getByRole('button', {name: 'IMPORT.START'}));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('IMPORT.RUNNING'));
});
it('stops polling on a 4xx and says so', async () => {
renderPage({'GET ': json(200, {imports: []}), 'GET /abc': json(400, null)}, '/import?id=abc');
expect(await screen.findByText('IMPORT.LOAD_FAILED')).toBeTruthy();
});
it('keeps showing an import opened without an id when it leaves the worker', async () => {
let polls = 0;
const matching = {id: 5, source: 'TEXT', status: 'MATCHING', total: 3, matched: 1, chosen: 0, saved: 0, added: 0,
ahead: null, attempts: 0, rows: null};
renderPage({
'GET ': () => json(200, {imports: [polls > 1 ? {...ready, rows: null} : matching]}),
'GET /5': () => (++polls > 1 ? json(200, ready) : json(200, matching)),
});
expect(await screen.findByText('IMPORT.LINE: Hades', {}, {timeout: 4000})).toBeTruthy();
}, 10000);
it('keeps polling after a 502 while Core restarts', async () => {
let calls = 0;
renderPage({
'GET ': json(200, {imports: []}),
'GET /8': () => (++calls === 1 ? json(502, null) : json(200, {id: 8, source: 'TEXT', status: 'MATCHING',
total: 4, matched: 1, chosen: 0, saved: 0, added: 0, ahead: null, attempts: 0})),
}, '/import?id=8');
expect(await screen.findByText('IMPORT.MATCHING', {}, {timeout: 4000})).toBeTruthy();
}, 10000);
});
+7 -7
View File
@@ -9,7 +9,7 @@ import {Skeleton} from '@/components/ui/skeleton';
import {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
import GameCover from '../components/GameCover.jsx'; import GameCover from '../components/GameCover.jsx';
import GameStatusBadge, {STATUS_META} from '../components/GameStatusBadge.jsx'; import GameStatusBadge, {STATUS_META} from '../components/GameStatusBadge.jsx';
import {GAME_STATUSES} from '../utils/backlog.js'; import {GAME_STATUSES} from '../utils/limits.js';
import {formatPlaytime} from '../utils/games.js'; import {formatPlaytime} from '../utils/games.js';
import {hasSession} from '../utils/auth.js'; import {hasSession} from '../utils/auth.js';
import {normalizeProfileStyle} from '../utils/profileStyle.js'; import {normalizeProfileStyle} from '../utils/profileStyle.js';
@@ -51,11 +51,11 @@ export default function PublicProfilePage() {
`${import.meta.env.VITE_API_URL}/api/public/profiles/${encodeURIComponent(name)}`); `${import.meta.env.VITE_API_URL}/api/public/profiles/${encodeURIComponent(name)}`);
if (cancelled) return; if (cancelled) return;
if (profileResponse.status === 404) { if (profileResponse.status === 404) {
setError(t('PROFILE.NOT_FOUND')); setError('PROFILE.NOT_FOUND');
return; return;
} }
if (!profileResponse.ok) { if (!profileResponse.ok) {
setError(t('PROFILE.ERROR_LOAD')); setError('PROFILE.ERROR_LOAD');
return; return;
} }
const found = await profileResponse.json(); const found = await profileResponse.json();
@@ -64,7 +64,7 @@ export default function PublicProfilePage() {
}); });
if (cancelled) return; if (cancelled) return;
if (!libraryResponse.ok) { if (!libraryResponse.ok) {
setError(t('PROFILE.ERROR_LOAD')); setError('PROFILE.ERROR_LOAD');
return; return;
} }
const data = await libraryResponse.json(); const data = await libraryResponse.json();
@@ -74,20 +74,20 @@ export default function PublicProfilePage() {
} }
} catch (err) { } catch (err) {
console.error('Public profile load error:', err); console.error('Public profile load error:', err);
if (!cancelled) setError(t('INFO.ERROR.NETWORK_FAILURE')); if (!cancelled) setError('INFO.ERROR.NETWORK_FAILURE');
} }
})(); })();
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [name, t, reloads]); }, [name, reloads]);
if (error) { if (error) {
return ( return (
<PageContainer className="p-4 md:p-8"> <PageContainer className="p-4 md:p-8">
<Card className="items-center py-16 text-center text-muted-foreground"> <Card className="items-center py-16 text-center text-muted-foreground">
<UserRoundX className="size-10"/> <UserRoundX className="size-10"/>
<p className="max-w-sm text-sm">{error}</p> <p className="max-w-sm text-sm">{t(error)}</p>
<Link to="/" className="text-sm font-medium text-primary hover:underline">{t('PROFILE.TO_START')}</Link> <Link to="/" className="text-sm font-medium text-primary hover:underline">{t('PROFILE.TO_START')}</Link>
</Card> </Card>
</PageContainer> </PageContainer>
+3 -2
View File
@@ -13,6 +13,7 @@ import FeedbackAlert from '../components/FeedbackAlert.jsx';
import FormField from '../components/FormField.jsx'; import FormField from '../components/FormField.jsx';
import {registrationSchema} from '../utils/validation.js'; import {registrationSchema} from '../utils/validation.js';
import {clientFetch} from '../utils/api.js'; import {clientFetch} from '../utils/api.js';
import {MAX_DISPLAY_NAME_LENGTH} from '../utils/limits.js';
const BACKEND_ERROR_KEY = { const BACKEND_ERROR_KEY = {
EMAIL_ALREADY_IN_USE: 'REGISTRATION.ERROR_EMAIL_IN_USE', EMAIL_ALREADY_IN_USE: 'REGISTRATION.ERROR_EMAIL_IN_USE',
@@ -68,7 +69,7 @@ export default function RegistrationPage() {
? {minutes: Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60)} ? {minutes: Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60)}
: undefined; : undefined;
setFeedback({ setFeedback({
message: mappedKey ? t(mappedKey, options) : data.message || t('REGISTRATION.ERROR_GENERIC'), message: t(mappedKey ?? 'REGISTRATION.ERROR_GENERIC', options),
isError: true, isError: true,
}); });
} catch (err) { } catch (err) {
@@ -131,7 +132,7 @@ export default function RegistrationPage() {
<Input <Input
id="displayName" id="displayName"
type="text" type="text"
maxLength={24} maxLength={MAX_DISPLAY_NAME_LENGTH}
aria-invalid={!!errors.displayName} aria-invalid={!!errors.displayName}
{...register('displayName')} {...register('displayName')}
/> />
+11 -2
View File
@@ -12,12 +12,12 @@ vi.mock('react-i18next', () => ({
}), }),
})); }));
function errorResponse(status, code, headers = {}) { function errorResponse(status, code, headers = {}, message = undefined) {
return { return {
ok: false, ok: false,
status, status,
headers: new Headers(headers), headers: new Headers(headers),
json: async () => ({code}), json: async () => ({code, message}),
}; };
} }
@@ -60,4 +60,13 @@ describe('RegistrationPage', () => {
expect(await screen.findByText('REGISTRATION.ERROR_INVALID_INVITE_CODE')).toBeTruthy(); expect(await screen.findByText('REGISTRATION.ERROR_INVALID_INVITE_CODE')).toBeTruthy();
}); });
it('shows its own text, not the English one of the server, for a refused input', async () => {
globalThis.fetch = vi.fn(async () => errorResponse(400, 'VALIDATION_FAILED', {}, 'The request is not valid'));
submitValidForm();
expect(await screen.findByText('REGISTRATION.ERROR_GENERIC')).toBeTruthy();
expect(screen.queryByText('The request is not valid')).toBeNull();
});
}); });
+48 -40
View File
@@ -26,9 +26,13 @@ import {AccentPicker, CornersPicker} from '../components/StylePickers.jsx';
import ProfileStyleSettings from '../components/ProfileStyleSettings.jsx'; import ProfileStyleSettings from '../components/ProfileStyleSettings.jsx';
import {normalizeProfileStyle} from '../utils/profileStyle.js'; import {normalizeProfileStyle} from '../utils/profileStyle.js';
import useSaveProfileStyle from '../hooks/useSaveProfileStyle.js'; import useSaveProfileStyle from '../hooks/useSaveProfileStyle.js';
import useSaveResponse from '../hooks/useSaveResponse.js';
import {coverUrl} from '../utils/games.js'; import {coverUrl} from '../utils/games.js';
import {apiFetch, refreshSession} from '../utils/api.js'; import {apiFetch, refreshSession} from '../utils/api.js';
import {hasSession, markSession} from '../utils/auth.js'; import {hasSession, markSession} from '../utils/auth.js';
import {FAVORITE_PLATFORMS, saveFavoritePlatform} from '../utils/platforms.js';
import {LANGUAGES, saveSettings} from '../utils/settings.js';
import {MAX_DISPLAY_NAME_LENGTH} from '../utils/limits.js';
const BACKEND_ERROR_KEY = { const BACKEND_ERROR_KEY = {
DISPLAY_NAME_ALREADY_IN_USE: 'SETTINGS.ERROR_DISPLAY_NAME_IN_USE', DISPLAY_NAME_ALREADY_IN_USE: 'SETTINGS.ERROR_DISPLAY_NAME_IN_USE',
@@ -42,7 +46,8 @@ const STEAM_LINK_TOASTS = {
failed: {show: toast.error, key: 'SETTINGS.STEAM_LINK.FAILED'}, failed: {show: toast.error, key: 'SETTINGS.STEAM_LINK.FAILED'},
}; };
const SUPPORTED_LANGUAGES = ['en', 'de']; // Radix Select has no empty value.
const NO_FAVORITE = 'NONE';
const formatDate = (value, locale) => { const formatDate = (value, locale) => {
if (!value) return '—'; if (!value) return '—';
@@ -60,6 +65,7 @@ export default function SettingsPage() {
const {isDarkMode, setDarkMode, setLanguage, appearance} = usePreferences(); const {isDarkMode, setDarkMode, setLanguage, appearance} = usePreferences();
const saveAppearance = useSaveAppearance(); const saveAppearance = useSaveAppearance();
const saveStyle = useSaveProfileStyle(); const saveStyle = useSaveProfileStyle();
const runSave = useSaveResponse();
const [profile, setProfile] = useState(null); const [profile, setProfile] = useState(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -76,7 +82,7 @@ export default function SettingsPage() {
formState: {errors, isSubmitting}, formState: {errors, isSubmitting},
} = useForm({ } = useForm({
resolver: zodResolver(settingsSchema), resolver: zodResolver(settingsSchema),
defaultValues: {useDarkTheme: true, language: 'en', displayName: ''}, defaultValues: {displayName: ''},
}); });
// A language change gives a new `t` and runs the effect again; the toast shows once. // A language change gives a new `t` and runs the effect again; the toast shows once.
@@ -118,42 +124,32 @@ export default function SettingsPage() {
} }
if (!response.ok) { if (!response.ok) {
setLoadError(t('INFO.ERROR.LOAD_PROFILE')); setLoadError('INFO.ERROR.LOAD_PROFILE');
return; return;
} }
const data = await response.json(); const data = await response.json();
setProfile(data); setProfile(data);
reset({ reset({displayName: data.displayName ?? ''});
useDarkTheme: !!data.settings?.useDarkTheme,
language: data.settings?.language || 'en',
displayName: data.displayName ?? '',
});
} catch (err) { } catch (err) {
console.error('Settings load error:', err); console.error('Settings load error:', err);
setLoadError(t('INFO.ERROR.NETWORK_FAILURE')); setLoadError('INFO.ERROR.NETWORK_FAILURE');
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
load(); load();
}, [navigate, expireSession, reset, t, steamOutcome]); }, [navigate, expireSession, reset, steamOutcome]);
const persistSettings = async (payload) => { const persistSettings = async (send) => {
if (!hasSession()) { if (!hasSession()) {
navigate('/login'); navigate('/login');
return {ok: false}; return {ok: false};
} }
try { try {
const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/settings`, { const response = await send();
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (response.status === 401) { if (response.status === 401) {
expireSession(); expireSession();
@@ -163,7 +159,7 @@ export default function SettingsPage() {
if (!response.ok) { if (!response.ok) {
const data = await response.json().catch(() => ({})); const data = await response.json().catch(() => ({}));
const mappedKey = BACKEND_ERROR_KEY[data.code]; const mappedKey = BACKEND_ERROR_KEY[data.code];
toast.error(mappedKey ? t(mappedKey) : data.message || t('SETTINGS.ERROR_SAVE')); toast.error(t(mappedKey ?? 'SETTINGS.ERROR_SAVE'));
return {ok: false}; return {ok: false};
} }
@@ -176,28 +172,27 @@ export default function SettingsPage() {
} }
}; };
// The backend replaces all settings, so every save sends the saved values of the fields it does not change.
const savedSettings = () => ({
useDarkTheme: isDarkMode,
language: i18n.language,
displayName: profile?.displayName ?? null,
publicProfile: !!profile?.settings?.publicProfile,
});
const handleDarkThemeToggle = async () => { const handleDarkThemeToggle = async () => {
const next = !isDarkMode; const next = !isDarkMode;
setDarkMode(next); // apply on the fly setDarkMode(next); // apply on the fly
await persistSettings({...savedSettings(), useDarkTheme: next}); await persistSettings(() => saveSettings({useDarkTheme: next}));
}; };
const handleLanguageChange = async (next) => { const handleLanguageChange = async (next) => {
setLanguage(next); // apply on the fly await persistSettings(() => setLanguage(next));
await persistSettings({...savedSettings(), language: next}); };
const handleFavoritePlatformChange = async (value) => {
const platform = value === NO_FAVORITE ? null : value;
if (await runSave(() => saveFavoritePlatform(platform))) {
setProfile((p) => ({...p, settings: {...p.settings, favoritePlatform: platform}}));
toast.success(t('SETTINGS.SAVED'));
}
}; };
const handlePublicProfileToggle = async () => { const handlePublicProfileToggle = async () => {
const next = !profile?.settings?.publicProfile; const next = !profile?.settings?.publicProfile;
const result = await persistSettings({...savedSettings(), publicProfile: next}); const result = await persistSettings(() => saveSettings({publicProfile: next}));
if (result.ok) { if (result.ok) {
setProfile((p) => ({...p, settings: {...p.settings, publicProfile: next}})); setProfile((p) => ({...p, settings: {...p.settings, publicProfile: next}}));
} }
@@ -224,12 +219,8 @@ export default function SettingsPage() {
const onSubmitProfile = async (values) => { const onSubmitProfile = async (values) => {
// Without a name the profile has no link, so removing the name also makes it private. // Without a name the profile has no link, so removing the name also makes it private.
const publicProfile = savedSettings().publicProfile && !!values.displayName; const publicProfile = !!profile?.settings?.publicProfile && !!values.displayName;
const result = await persistSettings({ const result = await persistSettings(() => saveSettings({displayName: values.displayName, publicProfile}));
...savedSettings(),
displayName: values.displayName,
publicProfile,
});
if (result.ok) { if (result.ok) {
setProfile((p) => (p ? { setProfile((p) => (p ? {
...p, displayName: values.displayName, settings: {...p.settings, publicProfile}, ...p, displayName: values.displayName, settings: {...p.settings, publicProfile},
@@ -249,7 +240,7 @@ export default function SettingsPage() {
} }
if (loadError) { if (loadError) {
return <div className="py-24 text-center text-destructive">{loadError}</div>; return <div className="py-24 text-center text-destructive">{t(loadError)}</div>;
} }
const publicProfile = !!profile?.settings?.publicProfile; const publicProfile = !!profile?.settings?.publicProfile;
@@ -300,7 +291,7 @@ export default function SettingsPage() {
<SelectValue/> <SelectValue/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{SUPPORTED_LANGUAGES.map((lang) => ( {LANGUAGES.map((lang) => (
<SelectItem key={lang} value={lang}> <SelectItem key={lang} value={lang}>
{t(`SETTINGS.LANGUAGE_OPTIONS.${lang.toUpperCase()}`)} {t(`SETTINGS.LANGUAGE_OPTIONS.${lang.toUpperCase()}`)}
</SelectItem> </SelectItem>
@@ -308,6 +299,23 @@ export default function SettingsPage() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<Label htmlFor="favoritePlatform">{t('SETTINGS.FAVORITE_PLATFORM')}</Label>
<p className="text-xs text-muted-foreground">{t('SETTINGS.FAVORITE_PLATFORM_HINT')}</p>
</div>
<Select value={profile?.settings?.favoritePlatform ?? NO_FAVORITE} onValueChange={handleFavoritePlatformChange}>
<SelectTrigger id="favoritePlatform" className="w-48">
<SelectValue/>
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_FAVORITE}>{t('SETTINGS.FAVORITE_PLATFORM_NONE')}</SelectItem>
{FAVORITE_PLATFORMS.map((platform) => (
<SelectItem key={platform.value} value={platform.value}>{platform.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent> </CardContent>
</Card> </Card>
@@ -386,7 +394,7 @@ export default function SettingsPage() {
<Input <Input
id="displayName" id="displayName"
type="text" type="text"
maxLength={24} maxLength={MAX_DISPLAY_NAME_LENGTH}
placeholder={t('SETTINGS.DISPLAY_NAME_PLACEHOLDER')} placeholder={t('SETTINGS.DISPLAY_NAME_PLACEHOLDER')}
aria-invalid={!!errors.displayName} aria-invalid={!!errors.displayName}
{...register('displayName')} {...register('displayName')}
+63 -1
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor, within} from '@testing-library/react'; import {act, cleanup, fireEvent, render, screen, waitFor, within} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom'; import {MemoryRouter} from 'react-router-dom';
import SettingsPage from './SettingsPage.jsx'; import SettingsPage from './SettingsPage.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx'; import {PreferencesProvider} from '../context/PreferencesContext.jsx';
@@ -29,6 +29,24 @@ function renderPage(me) {
render(<MemoryRouter><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>); render(<MemoryRouter><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>);
} }
/**
* Auth with PUT /api/users/settings answered one by one through answer(); GET /api/users/me shows what was saved.
*/
function fakeAuth(me) {
const saved = structuredClone(me);
const open = [];
globalThis.fetch = vi.fn((url, init) => {
if (init?.method !== 'PUT') return Promise.resolve({ok: true, status: 200, json: async () => structuredClone(saved)});
return new Promise((resolve) => open.push(() => {
const body = JSON.parse(init.body);
saved.displayName = body.displayName;
Object.assign(saved.settings, {useDarkTheme: body.useDarkTheme, language: body.language, publicProfile: body.publicProfile});
resolve({ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }});
}));
});
return {answer: () => open.shift()()};
}
describe('SettingsPage public profile', () => { describe('SettingsPage public profile', () => {
beforeEach(() => localStorage.setItem('tbp_session', '1')); beforeEach(() => localStorage.setItem('tbp_session', '1'));
@@ -46,6 +64,20 @@ describe('SettingsPage public profile', () => {
expect(puts()[0].publicProfile).toBe(true); expect(puts()[0].publicProfile).toBe(true);
}); });
it('builds a save on the one before it, also while that one is still open', async () => {
const auth = fakeAuth(profile());
render(<MemoryRouter><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>);
fireEvent.click(await screen.findByRole('switch', {name: 'SETTINGS.PUBLIC_PROFILE'}));
await waitFor(() => expect(puts()).toHaveLength(1));
fireEvent.click(screen.getByRole('switch', {name: 'SETTINGS.DARK_THEME'}));
await act(async () => auth.answer());
await waitFor(() => expect(puts()).toHaveLength(2));
expect(puts()[1]).toMatchObject({publicProfile: false, useDarkTheme: false, displayName: 'Lobo'});
await act(async () => auth.answer());
});
it('turns the public profile off and shows the link while it is on', async () => { it('turns the public profile off and shows the link while it is on', async () => {
renderPage(profile()); renderPage(profile());
@@ -168,3 +200,33 @@ describe('SettingsPage appearance', () => {
expect(appearancePuts()[1].showBanner).toBe(false); expect(appearancePuts()[1].showBanner).toBe(false);
}); });
}); });
describe('SettingsPage favorite platform', () => {
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
// Radix Select needs these in jsdom.
Element.prototype.hasPointerCapture ??= () => false;
Element.prototype.scrollIntoView ??= () => {};
});
afterEach(() => {
cleanup();
localStorage.clear();
});
it('shows the saved favorite and saves a new one, or none', async () => {
renderPage(profile({settings: {useDarkTheme: true, language: 'de', publicProfile: true, favoritePlatform: 'PS5'}}));
const trigger = await screen.findByRole('combobox', {name: 'SETTINGS.FAVORITE_PLATFORM'});
expect(trigger.textContent).toContain('PlayStation 5');
fireEvent.pointerDown(trigger, {button: 0, ctrlKey: false, pointerType: 'mouse'});
fireEvent.click(await screen.findByRole('option', {name: 'Nintendo Switch 2'}));
await waitFor(() => expect(puts()).toEqual([{platform: 'Switch 2'}]));
expect(fetch.mock.calls.find(([, init]) => init?.method === 'PUT')[0]).toContain('/api/users/settings/favorite-platform');
fireEvent.pointerDown(trigger, {button: 0, ctrlKey: false, pointerType: 'mouse'});
fireEvent.click(await screen.findByRole('option', {name: 'SETTINGS.FAVORITE_PLATFORM_NONE'}));
await waitFor(() => expect(puts()).toEqual([{platform: 'Switch 2'}, {platform: null}]));
});
});
+4 -4
View File
@@ -40,20 +40,20 @@ export default function WishlistPage() {
return; return;
} }
if (!response.ok) { if (!response.ok) {
setError(t('WISHLIST.ERROR_LOAD')); setError('WISHLIST.ERROR_LOAD');
return; return;
} }
const json = await response.json(); const json = await response.json();
if (!cancelled) setGames(json.games ?? []); if (!cancelled) setGames(json.games ?? []);
} catch (err) { } catch (err) {
console.error('Wishlist load error:', err); console.error('Wishlist load error:', err);
if (!cancelled) setError(t('INFO.ERROR.NETWORK_FAILURE')); if (!cancelled) setError('INFO.ERROR.NETWORK_FAILURE');
} }
})(); })();
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [navigate, expireSession, t]); }, [navigate, expireSession]);
const shown = (games ?? []).filter((g) => !onlyOnSale || g.discountPct > 0); const shown = (games ?? []).filter((g) => !onlyOnSale || g.discountPct > 0);
const onSaleCount = (games ?? []).filter((g) => g.discountPct > 0).length; const onSaleCount = (games ?? []).filter((g) => g.discountPct > 0).length;
@@ -91,7 +91,7 @@ export default function WishlistPage() {
</Card> </Card>
)} )}
{error && <div className="py-12 text-center text-destructive">{error}</div>} {error && <div className="py-12 text-center text-destructive">{t(error)}</div>}
{games !== null && shown.length === 0 && ( {games !== null && shown.length === 0 && (
<Card className="items-center py-16 text-center text-muted-foreground"> <Card className="items-center py-16 text-center text-muted-foreground">
+54
View File
@@ -0,0 +1,54 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render, screen} from '@testing-library/react';
import {MemoryRouter, Route, Routes} from 'react-router-dom';
import {switchLanguage} from '../test/i18n-mock.js';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import Dashboard from './Dashboard.jsx';
import GameDetail from './GameDetail.jsx';
import GameLibrary from './GameLibrary.jsx';
import PublicProfilePage from './PublicProfilePage.jsx';
import SettingsPage from './SettingsPage.jsx';
import WishlistPage from './WishlistPage.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
const pages = [
['Dashboard', '/', '/', <Dashboard/>, 'INFO.ERROR.LOAD_PROFILE'],
['GameDetail', '/games/:appid', '/games/220', <GameDetail/>, 'GAME_DETAIL.ERROR_LOAD'],
['GameLibrary', '/library', '/library', <GameLibrary/>, 'LIBRARY.ERROR_LOAD'],
['PublicProfilePage', '/u/:name', '/u/Lobo', <PublicProfilePage/>, 'PROFILE.ERROR_LOAD'],
['SettingsPage', '/settings', '/settings', <SettingsPage/>, 'INFO.ERROR.LOAD_PROFILE'],
['WishlistPage', '/wishlist', '/wishlist', <WishlistPage/>, 'WISHLIST.ERROR_LOAD'],
];
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
switchLanguage('de');
globalThis.fetch = vi.fn(async () => ({ok: false, status: 500, json: async () => ({})}));
});
afterEach(() => {
cleanup();
localStorage.clear();
});
describe('a language change', () => {
it.each(pages)('%s: loads nothing again and shows the error in the new language', async (name, path, url, page, errorKey) => {
const tree = () => (
<MemoryRouter initialEntries={[url]}>
<PreferencesProvider><Routes><Route path={path} element={page}/></Routes></PreferencesProvider>
</MemoryRouter>
);
const {rerender} = render(tree());
await screen.findByText(`de:${errorKey}`);
const requests = fetch.mock.calls.length;
switchLanguage('en');
rerender(tree());
expect(await screen.findByText(`en:${errorKey}`)).toBeTruthy();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(fetch.mock.calls.length).toBe(requests);
});
});
+11 -2
View File
@@ -1,6 +1,15 @@
// Module mock for react-i18next: t() returns the key (plus ":<count>" for plurals), so tests do not depend on the texts. // Module mock for react-i18next: t() returns the key (plus ":<count>" for plurals), so tests do not depend on the texts.
// Use in a test file: vi.mock('react-i18next', () => import('../test/i18n-mock.js')); // Use in a test file: vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
const t = (key, options) => (options?.count === undefined ? key : `${key}:${options.count}`); const translate = (key, options) => (options?.count === undefined ? key : `${key}:${options.count}`);
const i18n = {language: 'de'}; let t = translate;
const i18n = {language: 'de', changeLanguage: (language) => { i18n.language = language; }};
export const useTranslation = () => ({t, i18n}); export const useTranslation = () => ({t, i18n});
/**
* A new `t` that puts `language:` before each key, like react-i18next gives a new `t` on a language change. Rerender
* to see it.
*/
export const switchLanguage = (language) => {
t = (key, options) => `${language}:${translate(key, options)}`;
};
+10 -8
View File
@@ -1,4 +1,5 @@
import {apiFetch} from './api.js'; import {apiFetch} from './api.js';
import {fetchMe, REQUEST_TIMEOUT_MS} from './settings.js';
// Auth accepts only these names (AppearanceRequest); style.css has a preset for each. // Auth accepts only these names (AppearanceRequest); style.css has a preset for each.
export const ACCENTS = ['violet', 'blue', 'green', 'orange', 'pink', 'red']; export const ACCENTS = ['violet', 'blue', 'green', 'orange', 'pink', 'red'];
@@ -12,7 +13,6 @@ export const DEFAULT_APPEARANCE = {
export const appid = (value) => (Number.isInteger(value) && value > 0 ? value : null); export const appid = (value) => (Number.isInteger(value) && value > 0 ? value : null);
const STORAGE_KEY = 'tbp_appearance'; const STORAGE_KEY = 'tbp_appearance';
const REQUEST_TIMEOUT_MS = 10000;
export const normalizeAppearance = (value) => ({ export const normalizeAppearance = (value) => ({
accent: ACCENTS.includes(value?.accent) ? value.accent : DEFAULT_APPEARANCE.accent, accent: ACCENTS.includes(value?.accent) ? value.accent : DEFAULT_APPEARANCE.accent,
@@ -60,14 +60,16 @@ export const saveAppearance = (appearance) =>
}); });
/** /**
* GET /api/users/me on Auth, only for its appearance. Null when there is no answer to use. * GET /api/users/me on Auth, for its appearance (null when none is saved) and its language ('' when none is chosen).
* Null when there is no answer to use.
*/ */
export const loadAppearance = async () => { export const loadPreferences = async () => {
const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, { // Saves wait for this load, so it has a timeout.
// Saves wait for this load. const response = await fetchMe();
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) return null; if (!response.ok) return null;
const data = await response.json(); const data = await response.json();
return data.settings?.appearance ? normalizeAppearance(data.settings.appearance) : null; return {
appearance: data.settings?.appearance ? normalizeAppearance(data.settings.appearance) : null,
language: data.settings?.language ?? '',
};
}; };
+1 -3
View File
@@ -1,7 +1,5 @@
import {apiFetch} from './api.js'; import {apiFetch} from './api.js';
import {GAME_STATUSES} from './limits.js';
export const GAME_STATUSES = ['BACKLOG', 'PLAYING', 'FINISHED', 'COMPLETED', 'DROPPED'];
export const GAME_PRIORITIES = ['HIGH', 'MEDIUM', 'LOW'];
// Radix Select has no empty value, so "no priority" needs its own value in the form. // Radix Select has no empty value, so "no priority" needs its own value in the form.
export const NO_PRIORITY = 'NONE'; export const NO_PRIORITY = 'NONE';
+110
View File
@@ -0,0 +1,110 @@
import {apiFetch} from './api.js';
import {parseStatus} from './backlog.js';
import {MAX_IMPORT_TITLE_LENGTH, MAX_PLATFORM_LENGTH, MAX_PLAYTIME_MINUTES} from './limits.js';
import {hoursToMinutes} from './manualGames.js';
const IMPORTS_URL = () => `${import.meta.env.VITE_CORE_API_URL}/api/library/imports`;
const JSON_HEADERS = {'Content-Type': 'application/json'};
// The calls return the raw response; the caller handles the status.
export const createImport = (source, rows) =>
apiFetch(IMPORTS_URL(), {method: 'POST', headers: JSON_HEADERS, body: JSON.stringify({source, rows})});
export const listImports = () => apiFetch(IMPORTS_URL());
export const getImport = (id) => apiFetch(`${IMPORTS_URL()}/${id}`);
export const saveImport = (id, rows) =>
apiFetch(`${IMPORTS_URL()}/${id}/save`, {method: 'POST', headers: JSON_HEADERS, body: JSON.stringify({rows})});
export const retryImport = (id) => apiFetch(`${IMPORTS_URL()}/${id}/retry`, {method: 'POST'});
export const checkUndo = (id) => apiFetch(`${IMPORTS_URL()}/${id}/undo-check`);
export const undoImport = (id) => apiFetch(`${IMPORTS_URL()}/${id}/games`, {method: 'DELETE'});
/**
* One title per line; empty lines are left out.
*/
export const parseTextList = (text) => text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
.map((line) => ({title: line.slice(0, MAX_IMPORT_TITLE_LENGTH)}));
// Header names, lower case, per field.
const COLUMNS = {
title: ['title', 'name', 'game'],
platform: ['platform'],
hours: ['hours', 'playtime', 'playtime (hours)'],
minutes: ['minutes', 'playtime (minutes)'],
status: ['status'],
};
/**
* A CSV with a header row; the delimiter (comma, semicolon or tab) is the one the header has most of. A field that
* starts with a double quote may hold the delimiter, line breaks and "" for a quote.
*
* @return {rows} with title, platform, playtimeMinutes and status where the file has them, or {error} as an i18n key
*/
export const parseCsv = (text) => {
const clean = text.replace(/^/, '');
const firstLine = clean.split(/\r?\n/, 1)[0];
const delimiter = [',', ';', '\t'].reduce((best, next) =>
firstLine.split(next).length > firstLine.split(best).length ? next : best);
const [header, ...records] = csvRecords(clean, delimiter);
const names = (header ?? []).map((name) => name.trim().toLowerCase());
const at = Object.fromEntries(Object.entries(COLUMNS)
.map(([field, headers]) => [field, names.findIndex((name) => headers.includes(name))]));
if (at.title < 0) return {error: 'IMPORT.CSV_NO_TITLE'};
const value = (record, field) => (at[field] >= 0 ? (record[at[field]] ?? '').trim() : '');
const rows = records.filter((record) => value(record, 'title')).map((record) => {
const platform = value(record, 'platform');
const minutes = at.minutes >= 0 ? Math.round(Number(value(record, 'minutes'))) || 0
: hoursToMinutes(value(record, 'hours'));
return {
title: value(record, 'title').slice(0, MAX_IMPORT_TITLE_LENGTH),
platform: platform && platform.length <= MAX_PLATFORM_LENGTH ? platform : null,
playtimeMinutes: minutes > 0 ? Math.min(minutes, MAX_PLAYTIME_MINUTES) : null,
status: parseStatus(value(record, 'status').toUpperCase()),
};
});
return {rows};
};
function csvRecords(text, delimiter) {
const records = [];
let record = [];
let field = '';
let quoted = false;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (quoted) {
if (c === '"' && text[i + 1] === '"') {
field += '"';
i++;
} else if (c === '"') {
quoted = false;
} else {
field += c;
}
} else if (c === '"' && field === '') {
// Only at the start of a field; a quote inside one (12" Barbarian) is a character.
quoted = true;
} else if (c === delimiter) {
record.push(field);
field = '';
} else if (c === '\n' || c === '\r') {
if (c === '\r' && text[i + 1] === '\n') i++;
record.push(field);
records.push(record);
record = [];
field = '';
} else {
field += c;
}
}
if (field || record.length) {
record.push(field);
records.push(record);
}
return records;
}
+42
View File
@@ -0,0 +1,42 @@
import {describe, expect, it} from 'vitest';
import {parseCsv, parseTextList} from './imports.js';
describe('parseTextList', () => {
it('takes one title per line and skips empty lines', () => {
expect(parseTextList(' Hades \r\n\nCeleste\n')).toEqual([{title: 'Hades'}, {title: 'Celeste'}]);
expect(parseTextList('x'.repeat(150))[0].title).toHaveLength(100);
});
});
describe('parseCsv', () => {
it('reads the known columns, quotes and hours', () => {
const {rows} = parseCsv('Name,Platform,Hours,Status\n'
+ '"Hades, the game",PS5,"1,5",playing\r\n'
+ '"The ""Best"" Game",,,\n'
+ ',PC,3,\n');
expect(rows).toEqual([
{title: 'Hades, the game', platform: 'PS5', playtimeMinutes: 90, status: 'PLAYING'},
{title: 'The "Best" Game', platform: null, playtimeMinutes: null, status: null},
]);
});
it('takes the delimiter the header has most of, and minutes', () => {
const {rows} = parseCsv('title;minutes;status\nCeleste;125;unknown\n');
expect(rows).toEqual([{title: 'Celeste', platform: null, playtimeMinutes: 125, status: null}]);
});
it('keeps a line break inside quotes', () => {
expect(parseCsv('game\n"Two\nLines"\n').rows[0].title).toBe('Two\nLines');
});
it('keeps a quote inside an unquoted field as a character', () => {
expect(parseCsv('title,platform\n12" Barbarian,PC\nHades,PS5\n').rows.map((row) => row.title))
.toEqual(['12" Barbarian', 'Hades']);
});
it('needs a title column', () => {
expect(parseCsv('platform,hours\nPC,1\n')).toEqual({error: 'IMPORT.CSV_NO_TITLE'});
});
});
+32
View File
@@ -0,0 +1,32 @@
// Limits and enums that Core or Auth check. Each copy names its server source: change both together.
// Core GameStatus
export const GAME_STATUSES = ['BACKLOG', 'PLAYING', 'FINISHED', 'COMPLETED', 'DROPPED'];
// Core GamePriority, in the order the UI shows it
export const GAME_PRIORITIES = ['HIGH', 'MEDIUM', 'LOW'];
// Core ManualGameUpdate.MAX_PLATFORM_LENGTH, Auth FavoritePlatformRequest @Size(max = 32)
export const MAX_PLATFORM_LENGTH = 32;
// Core ManualGameUpdate.MAX_MINUTES
export const MAX_PLAYTIME_MINUTES = 6_000_000;
// Core BacklogEntryDto.notes @Size(max = 2000)
export const MAX_NOTES_LENGTH = 2000;
// Core ImportRequest.MAX_ROWS
export const MAX_IMPORT_ROWS = 1000;
// Core ImportRequest.Row.title @Size(max = 100)
export const MAX_IMPORT_TITLE_LENGTH = 100;
// Core ManualGameController.search @Size(min = 2, max = 100)
export const MIN_GAME_SEARCH_LENGTH = 2;
export const MAX_GAME_SEARCH_LENGTH = 100;
// Frontend only: Core's library search takes any length.
export const MAX_LIBRARY_SEARCH_LENGTH = 100;
// Core GameCorrectionService.MAX_TEXT
export const MAX_CORRECTION_LENGTH = 512;
// Core GameCorrectionService.MAX_GENRES
export const MAX_CORRECTION_GENRES = 10;
// Auth DisplayNameFormat.PATTERN (3 to 24 characters)
export const MAX_DISPLAY_NAME_LENGTH = 24;
// Auth CreateHintRequest @Size(max = 500)
export const MAX_HINT_LENGTH = 500;
+3
View File
@@ -7,6 +7,9 @@ const JSON_HEADERS = {'Content-Type': 'application/json'};
// positive keys; Auth also takes only those as background or banner. // positive keys; Auth also takes only those as background or banner.
export const isSteamKey = (appid) => Number(appid) > 0; export const isSteamKey = (appid) => Number(appid) > 0;
// Two decimals: then hoursToMinutes gives back the same minute.
export const toHours = (minutes) => (minutes > 0 ? String(Math.round((minutes / 60) * 100) / 100) : '');
export const hoursToMinutes = (hours) => { export const hoursToMinutes = (hours) => {
const value = Number(String(hours).replace(',', '.')); const value = Number(String(hours).replace(',', '.'));
return Number.isFinite(value) && value > 0 ? Math.round(value * 60) : 0; return Number.isFinite(value) && value > 0 ? Math.round(value * 60) : 0;
+53
View File
@@ -0,0 +1,53 @@
import {apiFetch} from './api.js';
const AUTH_URL = () => import.meta.env.VITE_API_URL;
// IGDB platform abbreviations (/v4/platforms, checked 2026-09-29), the way Core stores a game's platform.
export const FAVORITE_PLATFORMS = [
{value: 'PC', label: 'PC (Windows)'},
{value: 'PS5', label: 'PlayStation 5'},
{value: 'PS4', label: 'PlayStation 4'},
{value: 'Series X|S', label: 'Xbox Series X|S'},
{value: 'XONE', label: 'Xbox One'},
{value: 'Switch', label: 'Nintendo Switch'},
{value: 'Switch 2', label: 'Nintendo Switch 2'},
{value: 'Mac', label: 'Mac'},
{value: 'Linux', label: 'Linux'},
{value: 'iOS', label: 'iOS'},
{value: 'Android', label: 'Android'},
];
/**
* The platform to preselect for a game: the line's own platform (in the game's spelling when it has it), else the
* favorite when the game has it, else the only platform of the game, else '' and the user picks.
*/
export const preselectPlatform = (own, platforms, favorite) => {
const find = (name) => name && platforms.find((platform) => platform.toLowerCase() === name.toLowerCase());
if (own) return find(own) ?? own;
return find(favorite) ?? (platforms.length === 1 ? platforms[0] : '');
};
// A Steam game has no stored platform.
export const platformLabel = (game) => (game.source === 'MANUAL' ? game.platform : 'Steam');
/**
* The user's favorite platform from Auth; null when none is set or the load failed, so a caller can go on.
*/
export const loadFavoritePlatform = async () => {
try {
const response = await apiFetch(`${AUTH_URL()}/api/users/me?v=1`);
return response.ok ? (await response.json()).settings?.favoritePlatform ?? null : null;
} catch {
return null;
}
};
/**
* PUT /api/users/settings/favorite-platform; null clears it. The caller handles the response status.
*/
export const saveFavoritePlatform = (platform) =>
apiFetch(`${AUTH_URL()}/api/users/settings/favorite-platform`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({platform}),
});
+30
View File
@@ -0,0 +1,30 @@
import {describe, expect, it} from 'vitest';
import {platformLabel, preselectPlatform} from './platforms.js';
describe('preselectPlatform', () => {
it('takes the own platform first, in the game spelling', () => {
expect(preselectPlatform('ps5', ['PC', 'PS5'], 'PC')).toBe('PS5');
expect(preselectPlatform('Amiga', ['PC'], 'PC')).toBe('Amiga');
});
it('then the favorite when the game has it, then the only platform', () => {
expect(preselectPlatform(null, ['PC', 'Switch'], 'switch')).toBe('Switch');
expect(preselectPlatform(null, ['PS4'], 'Switch')).toBe('PS4');
expect(preselectPlatform(null, ['PC', 'PS4'], 'Switch')).toBe('');
expect(preselectPlatform(null, ['PC', 'PS4'], null)).toBe('');
expect(preselectPlatform(null, [], null)).toBe('');
});
it('keeps Switch and Switch 2 apart', () => {
expect(preselectPlatform(null, ['Switch', 'Switch 2'], 'Switch')).toBe('Switch');
expect(preselectPlatform(null, ['Switch', 'Switch 2'], 'Switch 2')).toBe('Switch 2');
expect(preselectPlatform(null, ['Switch', 'PC'], 'Switch 2')).toBe('');
});
});
describe('platformLabel', () => {
it('shows Steam for a Steam game', () => {
expect(platformLabel({source: 'STEAM', platform: null})).toBe('Steam');
expect(platformLabel({source: 'MANUAL', platform: 'PS5'})).toBe('PS5');
});
});
+60
View File
@@ -0,0 +1,60 @@
import {apiFetch} from './api.js';
export const LANGUAGES = ['en', 'de'];
const LANGUAGE_STORAGE_KEY = 'tbp_language';
export const REQUEST_TIMEOUT_MS = 10000;
export const readCachedLanguage = () => {
try {
const stored = localStorage.getItem(LANGUAGE_STORAGE_KEY);
return LANGUAGES.includes(stored) ? stored : null;
} catch {
return null;
}
};
export const cacheLanguage = (language) => {
try {
localStorage.setItem(LANGUAGE_STORAGE_KEY, language);
} catch {
// Private mode or a full storage: the language is only not cached.
}
};
/**
* GET /api/users/me on Auth. The caller handles the response status.
*/
export const fetchMe = () => apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, {
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
let queue = Promise.resolve();
/**
* PUT /api/users/settings on Auth with `changes`. That endpoint replaces all four fields, so each save first loads
* the saved ones; saves run one after another, from every page. The caller handles the response status.
*/
export const saveSettings = (changes) => {
const save = async () => {
const me = await fetchMe();
if (!me.ok) return me;
const data = await me.json();
return apiFetch(`${import.meta.env.VITE_API_URL}/api/users/settings`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
useDarkTheme: !!data.settings?.useDarkTheme,
language: data.settings?.language ?? '',
displayName: data.displayName ?? null,
publicProfile: !!data.settings?.publicProfile,
...changes,
}),
// A hung request must not hold back the next saves.
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
};
const result = queue.then(save, save);
queue = result.catch(() => {});
return result;
};
+15
View File
@@ -0,0 +1,15 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it} from 'vitest';
import {cacheLanguage, readCachedLanguage} from './settings.js';
afterEach(() => localStorage.clear());
describe('language cache', () => {
it('gives back the cached language, and nothing for an unknown one', () => {
expect(readCachedLanguage()).toBeNull();
cacheLanguage('en');
expect(readCachedLanguage()).toBe('en');
localStorage.setItem('tbp_language', 'fr');
expect(readCachedLanguage()).toBeNull();
});
});
+3 -3
View File
@@ -7,9 +7,11 @@ const emailField = z
.min(1, 'VALIDATION.EMAIL_REQUIRED') .min(1, 'VALIDATION.EMAIL_REQUIRED')
.email('VALIDATION.EMAIL_INVALID'); .email('VALIDATION.EMAIL_INVALID');
// Auth hashes with BCrypt, which takes at most 72 bytes.
const passwordField = z const passwordField = z
.string() .string()
.min(8, 'VALIDATION.PASSWORD_TOO_SHORT'); .min(8, 'VALIDATION.PASSWORD_TOO_SHORT')
.refine((value) => new TextEncoder().encode(value).length <= 72, 'VALIDATION.PASSWORD_TOO_LONG');
const inviteCodeField = z const inviteCodeField = z
.string() .string()
@@ -40,7 +42,5 @@ export const registrationSchema = z
}); });
export const settingsSchema = z.object({ export const settingsSchema = z.object({
useDarkTheme: z.boolean(),
language: z.enum(['en', 'de'], {message: 'VALIDATION.LANGUAGE_INVALID'}),
displayName: displayNameField, displayName: displayNameField,
}); });
+9
View File
@@ -29,3 +29,12 @@ describe('displayName validation', () => {
}); });
}); });
describe('registration password', () => {
it('rejects a password over 72 UTF-8 bytes, as Auth does', () => {
const password = 'é'.repeat(37);
expect(registrationSchema.safeParse({
email: 'a@b.de', password, passwordConfirmation: password, inviteCode: 'code',
}).success).toBe(false);
});
});