Public Access
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24a98062ee | ||
|
|
f605917874 | ||
|
|
14dd59f9e0 |
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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/>}/>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,23 +47,32 @@ 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 (
|
||||||
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">
|
<li key={hit.appid} className="flex items-center">
|
||||||
<GameCover appid={hit.appid} imageUrl={hit.imageUrl} className="w-20 shrink-0"/>
|
{onToggle && (
|
||||||
<span className="min-w-0 flex-1">
|
<input type="checkbox" className="ml-3 size-4 shrink-0 accent-primary"
|
||||||
<span className="block truncate font-medium">
|
aria-label={t('MANUAL_GAME.SELECT', {name: hit.name})}
|
||||||
{hit.name}{hit.year ? ` (${hit.year})` : ''}
|
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">
|
||||||
|
<GameCover appid={hit.appid} imageUrl={hit.imageUrl} className="w-20 shrink-0"/>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate font-medium">
|
||||||
|
{hit.name}{hit.year ? ` (${hit.year})` : ''}
|
||||||
|
</span>
|
||||||
|
<span className="block truncate text-xs text-muted-foreground">
|
||||||
|
{hit.platforms.join(' · ')}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="block truncate text-xs text-muted-foreground">
|
{blocked && <Badge variant="secondary">{t('MANUAL_GAME.IN_LIBRARY')}</Badge>}
|
||||||
{hit.platforms.join(' · ')}
|
</button>
|
||||||
</span>
|
</li>
|
||||||
</span>
|
);
|
||||||
{blockInLibrary && hit.inLibrary && <Badge variant="secondary">{t('MANUAL_GAME.IN_LIBRARY')}</Badge>}
|
})}
|
||||||
</button>
|
|
||||||
</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;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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}) {
|
||||||
|
|||||||
@@ -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,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
@@ -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/>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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')}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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
@@ -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')}
|
||||||
|
|||||||
@@ -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}]));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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'});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}),
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user