diff --git a/README.md b/README.md
index 81eb4fb..f224c0c 100644
--- a/README.md
+++ b/README.md
@@ -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
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,
- 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 %),
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
diff --git a/public/i18n/de.json b/public/i18n/de.json
index 6d337be..b51326d 100644
--- a/public/i18n/de.json
+++ b/public/i18n/de.json
@@ -125,7 +125,10 @@
"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."
},
- "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": {
"EMAIL_REQUIRED": "Email ist erforderlich.",
@@ -403,7 +406,10 @@
"REMOVE_YES": "Ja, entfernen",
"REMOVE_NO": "Abbrechen",
"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": {
"TITLE": "Deine Statistik",
@@ -621,5 +627,81 @@
"SIZE_L": "Groß",
"SUMMARY": "Summen",
"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."
}
}
diff --git a/public/i18n/en.json b/public/i18n/en.json
index 9ecfa2a..7583d9a 100644
--- a/public/i18n/en.json
+++ b/public/i18n/en.json
@@ -125,7 +125,10 @@
"TOO_MANY": "Too many wrong passwords. Please try again in {{minutes}} minutes.",
"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": {
"EMAIL_REQUIRED": "Email is required.",
@@ -403,7 +406,10 @@
"REMOVE_YES": "Yes, remove",
"REMOVE_NO": "Cancel",
"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": {
"TITLE": "Your stats",
@@ -621,5 +627,81 @@
"SIZE_L": "Large",
"SUMMARY": "Totals",
"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."
}
}
diff --git a/src/App.jsx b/src/App.jsx
index 088f810..7fd0b1b 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -15,6 +15,7 @@ import GameDetail from './pages/GameDetail.jsx';
import WishlistPage from './pages/WishlistPage.jsx';
import PlayNextPage from './pages/PlayNextPage.jsx';
import StatsPage from './pages/StatsPage.jsx';
+import ImportPage from './pages/ImportPage.jsx';
import PublicProfilePage from './pages/PublicProfilePage.jsx';
import AppHeader from './components/AppHeader.jsx';
import {Toaster} from '@/components/ui/sonner';
@@ -72,6 +73,7 @@ function App() {
}/>
}/>
}/>
+ }/>
}/>
}/>
}/>
diff --git a/src/components/AddGamePanel.jsx b/src/components/AddGamePanel.jsx
index eb46f63..9755343 100644
--- a/src/components/AddGamePanel.jsx
+++ b/src/components/AddGamePanel.jsx
@@ -1,4 +1,4 @@
-import {useState} from 'react';
+import {useEffect, useState} from 'react';
import {useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
@@ -12,11 +12,14 @@ import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/c
import GameCover from './GameCover.jsx';
import GameSearch from './GameSearch.jsx';
import useSessionExpired from '../hooks/useSessionExpired.js';
+import useStartImport from '../hooks/useStartImport.js';
import {GAME_STATUSES} from '../utils/backlog.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}) {
const {t} = useTranslation();
@@ -24,6 +27,29 @@ export default function AddGamePanel({onClose}) {
const expireSession = useSessionExpired();
const [query, setQuery] = useState('');
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 (
@@ -38,20 +64,34 @@ export default function AddGamePanel({onClose}) {
{picked ? (
- setPicked(null)} navigate={navigate} expireSession={expireSession}/>
+ setPicked(null)} navigate={navigate}
+ expireSession={expireSession}/>
) : (
<>
{t('MANUAL_GAME.ADD_HINT')}
-
+
+ {selected.size > 0 && (
+
+
{t('MANUAL_GAME.SELECTED', {count: selected.size})}
+
+ {busy ? : }{t('MANUAL_GAME.ADD_SELECTED')}
+
+
+ )}
>
)}
);
}
-function AddForm({hit, onBack, navigate, expireSession}) {
+function AddForm({hit, favorite, onBack, navigate, expireSession}) {
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 [hours, setHours] = useState('');
const [busy, setBusy] = useState(false);
@@ -97,7 +137,9 @@ function AddForm({hit, onBack, navigate, expireSession}) {
{t('MANUAL_GAME.PLATFORM')}
{hit.platforms.length > 0 ? (
-
+
+
+
{hit.platforms.map((p) => {p} )}
diff --git a/src/components/AddGamePanel.test.jsx b/src/components/AddGamePanel.test.jsx
index 104216c..c70dcb5 100644
--- a/src/components/AddGamePanel.test.jsx
+++ b/src/components/AddGamePanel.test.jsx
@@ -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});
-function renderPanel(post = json(201, {appid: -1025})) {
- globalThis.fetch = vi.fn(async (url, init) => init?.method === 'POST'
- ? post
- : json(200, {available: true, results: [zelda, portal]}));
+function renderPanel(post = json(201, {appid: -1025}), favoritePlatform = 'Nintendo Switch') {
+ globalThis.fetch = vi.fn(async (url, init) => {
+ if (url.includes('/api/users/me')) return json(200, {role: 'USER', settings: {favoritePlatform}});
+ return init?.method === 'POST' ? post : json(200, {available: true, results: [zelda, portal]});
+ });
render(
{}}/>}/>
detail page
}/>
+ import page}/>
,
);
}
+const searchCalls = () => fetch.mock.calls.filter(([url]) => url.includes('game-search'));
+
const search = (value) => fireEvent.change(screen.getByRole('searchbox'), {target: {value}});
beforeEach(() => {
@@ -48,7 +52,7 @@ describe('AddGamePanel', () => {
search('z');
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 () => {
@@ -57,13 +61,13 @@ describe('AddGamePanel', () => {
search('zel');
const hit = await screen.findByRole('button', {name: /Zelda \(2017\)/});
- expect(fetch).toHaveBeenCalledTimes(1);
- expect(fetch.mock.calls[0][0]).toContain('/api/library/game-search?q=zel');
+ expect(searchCalls()).toHaveLength(1);
+ expect(searchCalls()[0][0]).toContain('/api/library/game-search?q=zel');
expect(hit.disabled).toBe(false);
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();
search('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 () => {
renderPanel(json(409, {code: 'ALREADY_IN_LIBRARY'}));
search('zelda');
@@ -92,7 +119,8 @@ describe('AddGamePanel', () => {
it('shows that adding is off when Core has no IGDB app', async () => {
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');
expect(await screen.findByText('MANUAL_GAME.UNAVAILABLE')).toBeTruthy();
diff --git a/src/components/GameSearch.jsx b/src/components/GameSearch.jsx
index b106ecf..9b644ca 100644
--- a/src/components/GameSearch.jsx
+++ b/src/components/GameSearch.jsx
@@ -17,8 +17,10 @@ const MIN_QUERY = 2;
*
* @param hide hits not offered at all
* @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 {searchState, results} = useGameSearch(query);
const hits = results.filter((hit) => !hide(hit));
@@ -45,23 +47,32 @@ export default function GameSearch({query, onQueryChange, onPick, hide = () => f
)}
{searchState === 'done' && hits.length > 0 && (
)}
>
diff --git a/src/components/ManualGameCard.jsx b/src/components/ManualGameCard.jsx
index b3e7651..37a124d 100644
--- a/src/components/ManualGameCard.jsx
+++ b/src/components/ManualGameCard.jsx
@@ -9,10 +9,7 @@ import {Card} from '@/components/ui/card';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import useSessionExpired from '../hooks/useSessionExpired.js';
-import {hoursToMinutes, removeGame, updateManualGame} from '../utils/manualGames.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) : '');
+import {hoursToMinutes, removeGame, toHours, updateManualGame} from '../utils/manualGames.js';
/**
* Platform and hours of a game added by hand, and removing it. Steam games get both from the sync.
diff --git a/src/hooks/useStartImport.js b/src/hooks/useStartImport.js
new file mode 100644
index 0000000..b4f8221
--- /dev/null
+++ b/src/hooks/useStartImport.js
@@ -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]);
+}
diff --git a/src/pages/GameDetail.jsx b/src/pages/GameDetail.jsx
index 0bea6c4..4931b27 100644
--- a/src/pages/GameDetail.jsx
+++ b/src/pages/GameDetail.jsx
@@ -34,6 +34,7 @@ import {celebrationFor, celebrationLevel} from '../utils/confetti.js';
import {setGameHidden} from '../utils/hiddenGames.js';
import {hasSession} from '../utils/auth.js';
import {isSteamKey} from '../utils/manualGames.js';
+import {platformLabel} from '../utils/platforms.js';
const SPEEDRUN_PREFIX = 'https://www.speedrun.com/';
@@ -221,8 +222,8 @@ export default function GameDetail() {
{t('GAME_DETAIL.PLAYTIME', {time: formatPlaytime(data.playtimeMinutes)})}
- {data.platform && (
- {data.platform}
+ {platformLabel(data) && (
+ {platformLabel(data)}
)}
diff --git a/src/pages/GameLibrary.jsx b/src/pages/GameLibrary.jsx
index 1731134..23563ee 100644
--- a/src/pages/GameLibrary.jsx
+++ b/src/pages/GameLibrary.jsx
@@ -7,7 +7,7 @@ import useForceSync from '../hooks/useForceSync.js';
import useSessionExpired from '../hooks/useSessionExpired.js';
import {formatPlaytime} from '../utils/games.js';
import {GAME_STATUSES, libraryQuery, parseStatus} from '../utils/backlog.js';
-import {ArrowUpDown, ChevronLeft, ChevronRight, Clock, EyeOff, Gamepad2, Library, Plus, RefreshCw, Search, SearchX, Timer, Trophy} from 'lucide-react';
+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 {Button} from '@/components/ui/button';
@@ -22,6 +22,7 @@ import GameStatusBadge from '../components/GameStatusBadge.jsx';
import PageContainer from '../components/PageContainer.jsx';
import {apiFetch} from '../utils/api.js';
import {hasSession} from '../utils/auth.js';
+import {platformLabel} from '../utils/platforms.js';
const PAGE_SIZES = [20, 40, 80];
const DEFAULT_SIZE = 20;
@@ -195,6 +196,9 @@ export default function GameLibrary() {
setAdding(true)} disabled={adding}>
{t('MANUAL_GAME.ADD_BUTTON')}
+
+ {t('IMPORT.OPEN')}
+
@@ -309,9 +313,9 @@ export default function GameLibrary() {
{g.name ?? `App ${g.appid}`}
- {g.platform && (
-
- {g.platform}
+ {platformLabel(g) && (
+
+ {platformLabel(g)}
)}
diff --git a/src/pages/GameLibrary.test.jsx b/src/pages/GameLibrary.test.jsx
index 5c01e64..98f2ac1 100644
--- a/src/pages/GameLibrary.test.jsx
+++ b/src/pages/GameLibrary.test.jsx
@@ -184,6 +184,15 @@ describe('GameLibrary with games added by hand', () => {
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 () => {
globalThis.fetch = answer([zelda], false);
renderLibrary();
diff --git a/src/pages/ImportPage.jsx b/src/pages/ImportPage.jsx
new file mode 100644
index 0000000..29f35c0
--- /dev/null
+++ b/src/pages/ImportPage.jsx
@@ -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 {GAME_STATUSES} from '../utils/backlog.js';
+import {
+ checkUndo, getImport, listImports, MAX_IMPORT_ROWS, parseCsv, parseTextList, retryImport, saveImport, undoImport,
+} from '../utils/imports.js';
+import {hoursToMinutes, MAX_PLATFORM_LENGTH, 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 (
+
+
+
{t('IMPORT.TITLE')}
+
+ {t('IMPORT.BACK')}
+
+
+ {imports === null ? (
+
+ ) : shownId ? (
+
+ ) : (
+ {
+ show(created);
+ loadImports();
+ }}/>
+ )}
+ {imports?.length > 0 && }
+
+ );
+}
+
+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 (
+
+ {t('IMPORT.HINT')}
+
+ {['TEXT', 'CSV'].map((value) => (
+ setSource(value)}>{t(`IMPORT.SOURCE_${value}`)}
+ ))}
+
+ {source === 'TEXT' ? (
+
+ );
+}
+
+// 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 {t('IMPORT.LOAD_FAILED')}
;
+ if (!data || favorite === undefined) return ;
+ return (
+
+
+ {data.status === 'READY' ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+// 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 (
+
+ {STEPS.map((step, index) => {
+ const done = index < current || (index === current && step === 'DONE');
+ const now = index === current && !done;
+ return (
+
+ {done ? : now && failedHere ?
+ : now && RUNNING.includes(data.status) ?
+ : }
+ {t(`IMPORT.STEP.${step}`)}
+
+ );
+ })}
+
+ );
+}
+
+// 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 (
+
+ {text}
+ {progress && }
+ {data.attempts > 0 && RUNNING.includes(data.status) && (
+ {t('IMPORT.ATTEMPT', {attempt: data.attempts + 1})}
+ )}
+ {RUNNING.includes(data.status) && {t('IMPORT.MATCHING_HINT')}
}
+
+ {data.status === 'FAILED' && (
+
+ {busy ? : }{t('IMPORT.RETRY')}
+
+ )}
+ {!RUNNING.includes(data.status) && (
+ {t('IMPORT.NEW')}
+ )}
+
+
+ );
+}
+
+/**
+ * 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 (
+
+
+
{t('IMPORT.REVIEW_HINT')}
+
+ {t('IMPORT.NEW')}
+
+ {busy && }{t('IMPORT.SAVE', {count: chosen.length})}
+
+
+
+
+ {choices.map((choice, index) => (
+
+ ))}
+
+
+ );
+}
+
+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 (
+
+
+ onChange({checked: e.target.checked})}/>
+ {label}
+ {candidate?.inLibrary && {t('MANUAL_GAME.IN_LIBRARY')} }
+ {row.searchFailed && {t('IMPORT.SEARCH_FAILED')} }
+ setSearching(!searching)}>
+ {t('IMPORT.SEARCH_AGAIN')}
+
+
+ {searching && (
+ !hit.igdbId} onPick={(hit) => {
+ onPick(hit);
+ setSearching(false);
+ }}/>
+ )}
+ {choice.candidates.length === 0 ? (
+ {t('IMPORT.NO_MATCH')}
+ ) : (
+
+ onPick(choice.candidates.find((known) => String(known.igdbId) === e.target.value))}>
+ {choice.candidates.map((known) => (
+
+ {known.name}{known.year ? ` (${known.year})` : ''}{known.inLibrary ? ` · ${t('MANUAL_GAME.IN_LIBRARY')}` : ''}
+
+ ))}
+
+ {candidate?.platforms.length > 0 ? (
+ onChange({platform: e.target.value})}>
+ {t('IMPORT.PICK_PLATFORM')}
+ {!candidate.platforms.includes(choice.platform) && choice.platform && (
+ {choice.platform}
+ )}
+ {candidate.platforms.map((platform) => {platform} )}
+
+ ) : (
+ onChange({platform: e.target.value})}/>
+ )}
+ onChange({status: e.target.value})}>
+ {GAME_STATUSES.map((status) => {t(`BACKLOG.STATUS.${status}`)} )}
+
+ onChange({hours: e.target.value})}/>
+
+ )}
+
+ );
+});
+
+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 (
+
+ {t('IMPORT.HISTORY')}
+
+
+ );
+}
diff --git a/src/pages/ImportPage.test.jsx b/src/pages/ImportPage.test.jsx
new file mode 100644
index 0000000..56d449e
--- /dev/null
+++ b/src/pages/ImportPage.test.jsx
@@ -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(
+
+
+ }/>
+ login}/>
+
+ ,
+ );
+}
+
+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);
+});
diff --git a/src/pages/SettingsPage.jsx b/src/pages/SettingsPage.jsx
index 36cf47f..faa1458 100644
--- a/src/pages/SettingsPage.jsx
+++ b/src/pages/SettingsPage.jsx
@@ -26,9 +26,11 @@ import {AccentPicker, CornersPicker} from '../components/StylePickers.jsx';
import ProfileStyleSettings from '../components/ProfileStyleSettings.jsx';
import {normalizeProfileStyle} from '../utils/profileStyle.js';
import useSaveProfileStyle from '../hooks/useSaveProfileStyle.js';
+import useSaveResponse from '../hooks/useSaveResponse.js';
import {coverUrl} from '../utils/games.js';
import {apiFetch, refreshSession} from '../utils/api.js';
import {hasSession, markSession} from '../utils/auth.js';
+import {FAVORITE_PLATFORMS, saveFavoritePlatform} from '../utils/platforms.js';
const BACKEND_ERROR_KEY = {
DISPLAY_NAME_ALREADY_IN_USE: 'SETTINGS.ERROR_DISPLAY_NAME_IN_USE',
@@ -43,6 +45,8 @@ const STEAM_LINK_TOASTS = {
};
const SUPPORTED_LANGUAGES = ['en', 'de'];
+// Radix Select has no empty value.
+const NO_FAVORITE = 'NONE';
const formatDate = (value, locale) => {
if (!value) return '—';
@@ -60,6 +64,7 @@ export default function SettingsPage() {
const {isDarkMode, setDarkMode, setLanguage, appearance} = usePreferences();
const saveAppearance = useSaveAppearance();
const saveStyle = useSaveProfileStyle();
+ const runSave = useSaveResponse();
const [profile, setProfile] = useState(null);
const [isLoading, setIsLoading] = useState(true);
@@ -195,6 +200,14 @@ export default function SettingsPage() {
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 next = !profile?.settings?.publicProfile;
const result = await persistSettings({...savedSettings(), publicProfile: next});
@@ -308,6 +321,23 @@ export default function SettingsPage() {
+
+
+
{t('SETTINGS.FAVORITE_PLATFORM')}
+
{t('SETTINGS.FAVORITE_PLATFORM_HINT')}
+
+
+
+
+
+
+ {t('SETTINGS.FAVORITE_PLATFORM_NONE')}
+ {FAVORITE_PLATFORMS.map((platform) => (
+ {platform.label}
+ ))}
+
+
+
diff --git a/src/pages/SettingsPage.test.jsx b/src/pages/SettingsPage.test.jsx
index 798d765..538f507 100644
--- a/src/pages/SettingsPage.test.jsx
+++ b/src/pages/SettingsPage.test.jsx
@@ -168,3 +168,33 @@ describe('SettingsPage appearance', () => {
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}]));
+ });
+});
diff --git a/src/utils/imports.js b/src/utils/imports.js
new file mode 100644
index 0000000..81c7c63
--- /dev/null
+++ b/src/utils/imports.js
@@ -0,0 +1,114 @@
+import {apiFetch} from './api.js';
+import {parseStatus} from './backlog.js';
+import {hoursToMinutes, MAX_PLATFORM_LENGTH} from './manualGames.js';
+
+const IMPORTS_URL = () => `${import.meta.env.VITE_CORE_API_URL}/api/library/imports`;
+const JSON_HEADERS = {'Content-Type': 'application/json'};
+
+// Core's limits: lines per import, title length of the game search, playtime.
+export const MAX_IMPORT_ROWS = 1000;
+const MAX_TITLE = 100;
+const MAX_MINUTES = 6_000_000;
+
+// 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_TITLE)}));
+
+// 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_TITLE),
+ platform: platform && platform.length <= MAX_PLATFORM_LENGTH ? platform : null,
+ playtimeMinutes: minutes > 0 ? Math.min(minutes, MAX_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;
+}
diff --git a/src/utils/imports.test.js b/src/utils/imports.test.js
new file mode 100644
index 0000000..199b974
--- /dev/null
+++ b/src/utils/imports.test.js
@@ -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'});
+ });
+});
diff --git a/src/utils/manualGames.js b/src/utils/manualGames.js
index 89e9bfd..887a0a2 100644
--- a/src/utils/manualGames.js
+++ b/src/utils/manualGames.js
@@ -7,6 +7,12 @@ const JSON_HEADERS = {'Content-Type': 'application/json'};
// positive keys; Auth also takes only those as background or banner.
export const isSteamKey = (appid) => Number(appid) > 0;
+// Core's limit of a game's platform (ManualGameUpdate.MAX_PLATFORM_LENGTH).
+export const MAX_PLATFORM_LENGTH = 32;
+
+// 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) => {
const value = Number(String(hours).replace(',', '.'));
return Number.isFinite(value) && value > 0 ? Math.round(value * 60) : 0;
diff --git a/src/utils/platforms.js b/src/utils/platforms.js
new file mode 100644
index 0000000..b24a319
--- /dev/null
+++ b/src/utils/platforms.js
@@ -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}),
+ });
diff --git a/src/utils/platforms.test.js b/src/utils/platforms.test.js
new file mode 100644
index 0000000..f915f7c
--- /dev/null
+++ b/src/utils/platforms.test.js
@@ -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');
+ });
+});