feat(import): add the import page, multi-select add and the favorite platform

This commit is contained in:
lobo committed 2026-09-29 10:20:54 +02:00
1 parent aa9e83e0cb
commit 14dd59f9e0
21 files changed
+1425 -37

No files matched your search

+11 -1
View File
@@ -34,7 +34,17 @@ To Be Played is split into three repositories:
- **Library**: status filters, "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest - **Library**: status filters, "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest
in library, rating or priority, paging. Each row shows the unlocked achievements. in library, rating or priority, paging. Each row shows the unlocked achievements.
- **Add a game by name** for games outside Steam (PlayStation, Switch, Xbox, physical): search IGDB, pick a hit, - **Add a game by name** for games outside Steam (PlayStation, Switch, Xbox, physical): search IGDB, pick a hit,
a platform and optional hours. Works without a Steam ID. Games already in ToBePlayed come first. a platform and optional hours. Works without a Steam ID. Games already in ToBePlayed come first. Check several
hits to add them at once through the import review.
- **Import** (`/import`): a list (one title per line) or a CSV (title, platform, hours or minutes, status). Core
matches the lines with IGDB and saves them in the background; the page shows the steps (queued with the imports
before yours, searching on IGDB, your review, adding to the library, time to beat and moods, done) with the
progress, and "Try again" for an import that stopped. The review shows the best match, other candidates and
"search again", with the platform and status per line. Only sure matches are checked. Earlier imports can be
undone.
- **Platform everywhere**: each game shows its platform ("Steam" for Steam games). The favorite platform from the
settings is preselected when a game is on it; otherwise a game on one platform gets that one, and a game on
several waits for the user's pick.
- **Game detail**: status, priority, 1–5 star rating, notes, achievements, time to beat (story / extras / 100 %), - **Game detail**: status, priority, 1–5 star rating, notes, achievements, time to beat (story / extras / 100 %),
a link to the game's speedrun.com page when it has verified runs, a hint when Steam shows recent playtime for a a link to the game's speedrun.com page when it has verified runs, a hint when Steam shows recent playtime for a
game still in the backlog, and one to set the status to 100 % when game still in the backlog, and one to set the status to 100 % when
+84 -2
View File
@@ -125,7 +125,10 @@
"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.",
@@ -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."
} }
} }
+84 -2
View File
@@ -125,7 +125,10 @@
"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.",
@@ -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."
} }
} }
+2
View File
@@ -15,6 +15,7 @@ import GameDetail from './pages/GameDetail.jsx';
import WishlistPage from './pages/WishlistPage.jsx'; import WishlistPage from './pages/WishlistPage.jsx';
import PlayNextPage from './pages/PlayNextPage.jsx'; import PlayNextPage from './pages/PlayNextPage.jsx';
import StatsPage from './pages/StatsPage.jsx'; import StatsPage from './pages/StatsPage.jsx';
import ImportPage from './pages/ImportPage.jsx';
import PublicProfilePage from './pages/PublicProfilePage.jsx'; import PublicProfilePage from './pages/PublicProfilePage.jsx';
import AppHeader from './components/AppHeader.jsx'; import AppHeader from './components/AppHeader.jsx';
import {Toaster} from '@/components/ui/sonner'; import {Toaster} from '@/components/ui/sonner';
@@ -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/>}/>
+49 -7
View File
@@ -1,4 +1,4 @@
import {useState} from 'react'; import {useEffect, useState} from 'react';
import {useNavigate} from 'react-router-dom'; import {useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {toast} from 'sonner'; import {toast} from 'sonner';
@@ -12,11 +12,14 @@ import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/c
import GameCover from './GameCover.jsx'; import GameCover from './GameCover.jsx';
import GameSearch from './GameSearch.jsx'; import GameSearch from './GameSearch.jsx';
import useSessionExpired from '../hooks/useSessionExpired.js'; import useSessionExpired from '../hooks/useSessionExpired.js';
import useStartImport from '../hooks/useStartImport.js';
import {GAME_STATUSES} from '../utils/backlog.js'; import {GAME_STATUSES} from '../utils/backlog.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,7 +137,9 @@ 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>
+37 -9
View File
@@ -16,20 +16,24 @@ const portal = {...zelda, appid: 400, igdbId: 71, name: 'Portal', inLibrary: tru
const json = (status, body) => ({ok: status < 400, status, json: async () => body}); const json = (status, body) => ({ok: status < 400, status, json: async () => body});
function renderPanel(post = json(201, {appid: -1025})) { function renderPanel(post = json(201, {appid: -1025}), favoritePlatform = 'Nintendo Switch') {
globalThis.fetch = vi.fn(async (url, init) => init?.method === 'POST' globalThis.fetch = vi.fn(async (url, init) => {
? post if (url.includes('/api/users/me')) return json(200, {role: 'USER', settings: {favoritePlatform}});
: json(200, {available: true, results: [zelda, portal]})); return init?.method === 'POST' ? post : json(200, {available: true, results: [zelda, portal]});
});
render( render(
<MemoryRouter initialEntries={['/library']}> <MemoryRouter initialEntries={['/library']}>
<Routes> <Routes>
<Route path="/library" element={<AddGamePanel onClose={() => {}}/>}/> <Route path="/library" element={<AddGamePanel onClose={() => {}}/>}/>
<Route path="/library/:appid" element={<p>detail page</p>}/> <Route path="/library/:appid" element={<p>detail page</p>}/>
<Route path="/import" element={<p>import page</p>}/>
</Routes> </Routes>
</MemoryRouter>, </MemoryRouter>,
); );
} }
const searchCalls = () => fetch.mock.calls.filter(([url]) => url.includes('game-search'));
const search = (value) => fireEvent.change(screen.getByRole('searchbox'), {target: {value}}); const search = (value) => fireEvent.change(screen.getByRole('searchbox'), {target: {value}});
beforeEach(() => { beforeEach(() => {
@@ -48,7 +52,7 @@ describe('AddGamePanel', () => {
search('z'); search('z');
await new Promise((resolve) => setTimeout(resolve, 600)); await new Promise((resolve) => setTimeout(resolve, 600));
expect(fetch).not.toHaveBeenCalled(); expect(searchCalls()).toHaveLength(0);
}); });
it('searches once after typing stops and blocks games already in the library', async () => { it('searches once after typing stops and blocks games already in the library', async () => {
@@ -57,13 +61,13 @@ describe('AddGamePanel', () => {
search('zel'); search('zel');
const hit = await screen.findByRole('button', {name: /Zelda \(2017\)/}); const hit = await screen.findByRole('button', {name: /Zelda \(2017\)/});
expect(fetch).toHaveBeenCalledTimes(1); expect(searchCalls()).toHaveLength(1);
expect(fetch.mock.calls[0][0]).toContain('/api/library/game-search?q=zel'); expect(searchCalls()[0][0]).toContain('/api/library/game-search?q=zel');
expect(hit.disabled).toBe(false); expect(hit.disabled).toBe(false);
expect(screen.getByRole('button', {name: /Portal/}).disabled).toBe(true); expect(screen.getByRole('button', {name: /Portal/}).disabled).toBe(true);
}); });
it('adds the picked game with the first platform and the hours, then opens its page', async () => { it('adds the picked game with the favorite platform and the hours, then opens its page', async () => {
renderPanel(); renderPanel();
search('zelda'); search('zelda');
fireEvent.click(await screen.findByRole('button', {name: /Zelda/})); fireEvent.click(await screen.findByRole('button', {name: /Zelda/}));
@@ -79,6 +83,29 @@ describe('AddGamePanel', () => {
}); });
}); });
it('preselects no platform without a favorite among several, so Add waits for a pick', async () => {
renderPanel(undefined, null);
search('zelda');
fireEvent.click(await screen.findByRole('button', {name: /Zelda/}));
expect(screen.getByRole('button', {name: 'MANUAL_GAME.ADD'}).disabled).toBe(true);
});
it('sends several checked games to a search import and opens its review', async () => {
renderPanel(json(202, {id: 7}));
search('zelda');
const [zeldaBox, portalBox] = await screen.findAllByRole('checkbox', {name: 'MANUAL_GAME.SELECT'});
expect(portalBox.disabled).toBe(true);
fireEvent.click(zeldaBox);
fireEvent.click(screen.getByRole('button', {name: 'MANUAL_GAME.ADD_SELECTED'}));
expect(await screen.findByText('import page')).toBeTruthy();
const [url, init] = fetch.mock.calls.find(([, call]) => call?.method === 'POST');
expect(url).toContain('/api/library/imports');
expect(JSON.parse(init.body)).toEqual({source: 'SEARCH', rows: [{igdbId: 1025, title: 'Zelda'}]});
});
it('says so when the game is already in the library', async () => { it('says so when the game is already in the library', async () => {
renderPanel(json(409, {code: 'ALREADY_IN_LIBRARY'})); renderPanel(json(409, {code: 'ALREADY_IN_LIBRARY'}));
search('zelda'); search('zelda');
@@ -92,7 +119,8 @@ describe('AddGamePanel', () => {
it('shows that adding is off when Core has no IGDB app', async () => { it('shows that adding is off when Core has no IGDB app', async () => {
renderPanel(); renderPanel();
fetch.mockImplementation(async () => json(200, {available: false, results: []})); fetch.mockImplementation(async (url) => (url.includes('/api/users/me')
? json(200, {settings: {}}) : json(200, {available: false, results: []})));
search('zelda'); search('zelda');
expect(await screen.findByText('MANUAL_GAME.UNAVAILABLE')).toBeTruthy(); expect(await screen.findByText('MANUAL_GAME.UNAVAILABLE')).toBeTruthy();
+17 -6
View File
@@ -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));
@@ -45,9 +47,17 @@ export default function GameSearch({query, onQueryChange, onPick, hide = () => f
)} )}
{searchState === 'done' && hits.length > 0 && ( {searchState === 'done' && hits.length > 0 && (
<ul className="divide-y rounded-md border"> <ul className="divide-y rounded-md border">
{hits.map((hit) => ( {hits.map((hit) => {
<li key={hit.appid}> const blocked = blockInLibrary && hit.inLibrary;
<button type="button" disabled={blockInLibrary && hit.inLibrary} onClick={() => onPick(hit)} return (
<li key={hit.appid} className="flex items-center">
{onToggle && (
<input type="checkbox" className="ml-3 size-4 shrink-0 accent-primary"
aria-label={t('MANUAL_GAME.SELECT', {name: hit.name})}
checked={selected.has(hit.igdbId)} disabled={!hit.igdbId || blocked}
onChange={() => onToggle(hit)}/>
)}
<button type="button" disabled={blocked} onClick={() => onPick(hit)}
className="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-muted/50 disabled:cursor-not-allowed disabled:opacity-60"> className="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-muted/50 disabled:cursor-not-allowed disabled:opacity-60">
<GameCover appid={hit.appid} imageUrl={hit.imageUrl} className="w-20 shrink-0"/> <GameCover appid={hit.appid} imageUrl={hit.imageUrl} className="w-20 shrink-0"/>
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
@@ -58,10 +68,11 @@ export default function GameSearch({query, onQueryChange, onPick, hide = () => f
{hit.platforms.join(' · ')} {hit.platforms.join(' · ')}
</span> </span>
</span> </span>
{blockInLibrary && hit.inLibrary && <Badge variant="secondary">{t('MANUAL_GAME.IN_LIBRARY')}</Badge>} {blocked && <Badge variant="secondary">{t('MANUAL_GAME.IN_LIBRARY')}</Badge>}
</button> </button>
</li> </li>
))} );
})}
</ul> </ul>
)} )}
</> </>
+1 -4
View File
@@ -9,10 +9,7 @@ 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';
// 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.
+35
View File
@@ -0,0 +1,35 @@
import {useCallback} from 'react';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
import useSessionExpired from './useSessionExpired.js';
import {createImport} from '../utils/imports.js';
/**
* Returns `start(source, rows)`: it creates an import in Core and resolves to its id, or shows why not (or ends the
* session on a 401) and resolves to null.
*/
export default function useStartImport() {
const {t} = useTranslation();
const expireSession = useSessionExpired();
return useCallback(async (source, rows) => {
try {
const response = await createImport(source, rows);
if (response.status === 401) {
expireSession();
} else if (response.status === 409) {
toast.error(t('IMPORT.RUNNING'));
} else if (response.status === 503) {
toast.error(t('MANUAL_GAME.UNAVAILABLE'));
} else if (!response.ok) {
toast.error(t('IMPORT.START_FAILED'));
} else {
return (await response.json()).id;
}
} catch (err) {
console.error('Import start error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
}
return null;
}, [expireSession, t]);
}
+3 -2
View File
@@ -34,6 +34,7 @@ import {celebrationFor, celebrationLevel} from '../utils/confetti.js';
import {setGameHidden} from '../utils/hiddenGames.js'; import {setGameHidden} from '../utils/hiddenGames.js';
import {hasSession} from '../utils/auth.js'; import {hasSession} from '../utils/auth.js';
import {isSteamKey} from '../utils/manualGames.js'; import {isSteamKey} from '../utils/manualGames.js';
import {platformLabel} from '../utils/platforms.js';
const SPEEDRUN_PREFIX = 'https://www.speedrun.com/'; const SPEEDRUN_PREFIX = 'https://www.speedrun.com/';
@@ -221,8 +222,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">
+8 -4
View File
@@ -7,7 +7,7 @@ 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 {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 {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
@@ -22,6 +22,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;
@@ -195,6 +196,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">
@@ -309,9 +313,9 @@ export default function GameLibrary() {
<span className="min-w-0 flex-1 truncate font-medium group-hover:text-primary transition-colors"> <span className="min-w-0 flex-1 truncate font-medium group-hover:text-primary transition-colors">
{g.name ?? `App ${g.appid}`} {g.name ?? `App ${g.appid}`}
</span> </span>
{g.platform && ( {platformLabel(g) && (
<Badge variant="outline" className="hidden sm:inline-flex" title={g.platform}> <Badge variant="outline" className="hidden sm:inline-flex" title={platformLabel(g)}>
<Gamepad2 className="size-3"/>{g.platform} <Gamepad2 className="size-3"/>{platformLabel(g)}
</Badge> </Badge>
)} )}
<GameStatusBadge status={g.status} compact/> <GameStatusBadge status={g.status} compact/>
+9
View File
@@ -184,6 +184,15 @@ describe('GameLibrary with games added by hand', () => {
expect(document.querySelector('img[src="https://images.igdb.com/z.jpg"]')).toBeTruthy(); expect(document.querySelector('img[src="https://images.igdb.com/z.jpg"]')).toBeTruthy();
}); });
it('shows Steam as the platform of a Steam game, and links the import', async () => {
globalThis.fetch = answer([{appid: 220, name: 'Half-Life 2', playtimeMinutes: 0, status: 'BACKLOG', source: 'STEAM',
platform: null}], true);
renderLibrary();
expect(await screen.findByText('Steam')).toBeTruthy();
expect(screen.getByRole('link', {name: 'IMPORT.OPEN'}).getAttribute('href')).toBe('/import');
});
it('hides the Steam sync for a user without a Steam ID but still offers adding a game', async () => { it('hides the Steam sync for a user without a Steam ID but still offers adding a game', async () => {
globalThis.fetch = answer([zelda], false); globalThis.fetch = answer([zelda], false);
renderLibrary(); renderLibrary();
+554
View File
@@ -0,0 +1,554 @@
import {memo, useCallback, useEffect, useRef, useState} from 'react';
import {Link, useNavigate, useSearchParams} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
import {ArrowLeft, CheckCircle2, Circle, Loader2, RotateCcw, Search, Upload, XCircle} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {Input} from '@/components/ui/input';
import {Textarea} from '@/components/ui/textarea';
import {cn} from '@/lib/utils';
import GameSearch from '../components/GameSearch.jsx';
import PageContainer from '../components/PageContainer.jsx';
import useSessionExpired from '../hooks/useSessionExpired.js';
import useStartImport from '../hooks/useStartImport.js';
import {hasSession} from '../utils/auth.js';
import {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 (
<PageContainer className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="flex items-center gap-2 text-2xl font-bold"><Upload className="size-5 text-primary"/>{t('IMPORT.TITLE')}</h1>
<Button variant="ghost" size="sm" asChild>
<Link to="/library"><ArrowLeft/>{t('IMPORT.BACK')}</Link>
</Button>
</div>
{imports === null ? (
<Loader2 className="animate-spin text-muted-foreground"/>
) : shownId ? (
<ImportView key={shownId} id={shownId} onDone={done} onChanged={loadImports} expireSession={expireSession}/>
) : (
<ImportInput onCreated={(created) => {
show(created);
loadImports();
}}/>
)}
{imports?.length > 0 && <History imports={imports} onOpen={show} onChanged={loadImports} expireSession={expireSession}/>}
</PageContainer>
);
}
function ImportInput({onCreated}) {
const {t} = useTranslation();
const [source, setSource] = useState('TEXT');
const [text, setText] = useState('');
const [csv, setCsv] = useState(null);
const [busy, setBusy] = useState(false);
const rows = source === 'TEXT' ? parseTextList(text) : csv?.rows ?? [];
const tooMany = rows.length > MAX_IMPORT_ROWS;
const readFile = async (file) => setCsv(file ? parseCsv(await file.text()) : null);
const startImport = useStartImport();
const start = async () => {
setBusy(true);
const id = await startImport(source, rows);
setBusy(false);
if (id) onCreated(id);
};
return (
<Card className="gap-4 p-5">
<p className="text-sm text-muted-foreground">{t('IMPORT.HINT')}</p>
<div role="group" aria-label={t('IMPORT.SOURCE')} className="flex gap-2">
{['TEXT', 'CSV'].map((value) => (
<Button key={value} size="sm" variant={source === value ? 'default' : 'outline'} aria-pressed={source === value}
onClick={() => setSource(value)}>{t(`IMPORT.SOURCE_${value}`)}</Button>
))}
</div>
{source === 'TEXT' ? (
<Textarea rows={10} value={text} aria-label={t('IMPORT.TEXT_LABEL')} placeholder={t('IMPORT.TEXT_PLACEHOLDER')}
onChange={(e) => setText(e.target.value)}/>
) : (
<div className="space-y-2">
<Input type="file" accept=".csv,text/csv" aria-label={t('IMPORT.CSV_LABEL')}
onChange={(e) => readFile(e.target.files?.[0])}/>
<p className="text-xs text-muted-foreground">{t('IMPORT.CSV_HINT')}</p>
{csv?.error && <p className="text-sm text-destructive">{t(csv.error)}</p>}
{csv?.rows?.length > 0 && (
<ul className="text-sm text-muted-foreground">
{csv.rows.slice(0, 5).map((row, index) => (
<li key={index} className="truncate">
{[row.title, row.platform, row.status].filter(Boolean).join(' · ')}
</li>
))}
</ul>
)}
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-3">
<span className={tooMany ? 'text-sm text-destructive' : 'text-sm text-muted-foreground'}>
{t(tooMany ? 'IMPORT.TOO_MANY' : 'IMPORT.LINES', {count: rows.length, max: MAX_IMPORT_ROWS})}
</span>
<Button onClick={start} disabled={busy || rows.length === 0 || tooMany}>
{busy ? <Loader2 className="animate-spin"/> : <Search/>}{t('IMPORT.START')}
</Button>
</div>
</Card>
);
}
// Polls while a worker has the import.
function ImportView({id, onDone, onChanged, expireSession}) {
const {t} = useTranslation();
const [data, setData] = useState(null);
const [failed, setFailed] = useState(false);
// Grows after a save or a retry: the import is back with a worker, so polling starts again.
const [version, setVersion] = useState(0);
const reload = () => setVersion((current) => current + 1);
const lastStatus = useRef(null);
// undefined while loading: the review preselects with it, so it waits.
const [favorite, setFavorite] = useState(undefined);
useEffect(() => {
loadFavoritePlatform().then(setFavorite);
}, []);
useEffect(() => {
let timer;
let cancelled = false;
const load = async () => {
try {
const response = await getImport(id);
if (cancelled) return;
if (response.status === 401) {
expireSession();
return;
}
// A 4xx ends the polling; a 5xx while Core restarts is asked again.
if (response.status >= 400 && response.status < 500) {
setFailed(true);
return;
}
if (!response.ok) {
timer = setTimeout(load, POLL_MS);
return;
}
const next = await response.json();
if (cancelled) return;
// A new status also shows in the history.
if (lastStatus.current && lastStatus.current !== next.status) onChanged();
lastStatus.current = next.status;
setData(next);
if (RUNNING.includes(next.status)) timer = setTimeout(load, POLL_MS);
} catch (err) {
console.error('Import load error:', err);
if (!cancelled) timer = setTimeout(load, POLL_MS);
}
};
load();
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [id, expireSession, version, onChanged]);
if (failed) return <p className="text-sm text-destructive">{t('IMPORT.LOAD_FAILED')}</p>;
if (!data || favorite === undefined) return <Loader2 className="animate-spin text-muted-foreground"/>;
return (
<div className="space-y-4">
<ImportSteps data={data}/>
{data.status === 'READY' ? (
<Review data={data} favorite={favorite} onSaved={reload} onDone={onDone} expireSession={expireSession}/>
) : (
<ImportProgress data={data} onRetried={reload} onDone={onDone} expireSession={expireSession}/>
)}
</div>
);
}
// The steps in a row: done ones checked, the current one marked (red when the import failed there).
function ImportSteps({data}) {
const {t} = useTranslation();
const current = STEPS.indexOf(stepOf(data));
const failedHere = data.status === 'FAILED';
return (
<ol className="flex flex-wrap gap-x-4 gap-y-2 text-sm" aria-label={t('IMPORT.STEPS')}>
{STEPS.map((step, index) => {
const done = index < current || (index === current && step === 'DONE');
const now = index === current && !done;
return (
<li key={step} aria-current={now ? 'step' : undefined}
className={cn('flex items-center gap-1.5', done && 'text-muted-foreground',
now && (failedHere ? 'font-medium text-destructive' : 'font-medium text-primary'),
!done && !now && 'text-muted-foreground/60')}>
{done ? <CheckCircle2 className="size-4"/> : now && failedHere ? <XCircle className="size-4"/>
: now && RUNNING.includes(data.status) ? <Loader2 className="size-4 animate-spin"/>
: <Circle className="size-4"/>}
{t(`IMPORT.STEP.${step}`)}
</li>
);
})}
</ol>
);
}
// What the current step does, with its numbers; Retry for a FAILED import.
function ImportProgress({data, onRetried, onDone, expireSession}) {
const {t} = useTranslation();
const [busy, setBusy] = useState(false);
const step = stepOf(data);
const waiting = data.ahead != null;
const retry = async () => {
setBusy(true);
try {
const response = await retryImport(data.id);
if (response.status === 401) {
expireSession();
} else if (response.status === 409) {
toast.error(t('IMPORT.RUNNING'));
} else if (!response.ok) {
toast.error(t('IMPORT.RETRY_FAILED'));
} else {
onRetried();
}
} catch (err) {
console.error('Import retry error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setBusy(false);
}
};
let text;
let progress = null;
if (data.status === 'FAILED') {
text = t('IMPORT.FAILED', {count: data.attempts});
} else if (waiting) {
text = data.ahead > 0 ? t('IMPORT.WAITING', {count: data.ahead}) : t('IMPORT.WAITING_NEXT');
} else if (step === 'MATCHING') {
text = t('IMPORT.MATCHING', {matched: data.matched, total: data.total});
progress = [data.matched, data.total];
} else if (step === 'SAVING') {
text = t('IMPORT.SAVING', {saved: data.saved, chosen: data.chosen});
progress = [data.saved, data.chosen];
} else if (step === 'DETAILS') {
text = t('IMPORT.DETAILS');
} else {
text = t(data.status === 'UNDONE' ? 'IMPORT.STATUS.UNDONE' : 'IMPORT.DONE',
{added: data.added, skipped: data.saved - data.added});
}
return (
<Card className="gap-3 p-5">
<p className={cn('font-medium', data.status === 'FAILED' && 'text-destructive')}>{text}</p>
{progress && <progress className="w-full accent-primary" value={progress[0]} max={progress[1]}/>}
{data.attempts > 0 && RUNNING.includes(data.status) && (
<p className="text-sm text-muted-foreground">{t('IMPORT.ATTEMPT', {attempt: data.attempts + 1})}</p>
)}
{RUNNING.includes(data.status) && <p className="text-sm text-muted-foreground">{t('IMPORT.MATCHING_HINT')}</p>}
<div className="flex flex-wrap gap-2">
{data.status === 'FAILED' && (
<Button onClick={retry} disabled={busy}>
{busy ? <Loader2 className="animate-spin"/> : <RotateCcw/>}{t('IMPORT.RETRY')}
</Button>
)}
{!RUNNING.includes(data.status) && (
<Button variant="outline" onClick={onDone}>{t('IMPORT.NEW')}</Button>
)}
</div>
</Card>
);
}
/**
* The first choice of a line: its best candidate with the preselected platform; checked only for a sure match (the
* same name, not in the library yet, a platform found).
*/
export const initialChoice = (row, favorite) => {
const candidate = row.candidates[0];
const platform = candidate ? preselectPlatform(row.platform, candidate.platforms, favorite) : '';
return {
candidates: row.candidates,
igdbId: candidate?.igdbId ?? null,
platform,
status: row.status ?? 'BACKLOG',
hours: toHours(row.playtimeMinutes),
checked: row.exact && !!candidate && !candidate.inLibrary && platform !== '',
};
};
function Review({data, favorite, onSaved, onDone, expireSession}) {
const {t} = useTranslation();
const [choices, setChoices] = useState(() => data.rows.map((row) => initialChoice(row, favorite)));
const [busy, setBusy] = useState(false);
// Stable, so a change in one line re-renders only that line (up to 1000 lines).
const change = useCallback((index, changes) => setChoices((current) =>
current.map((choice, i) => (i === index ? {...choice, ...changes} : choice))), []);
const pick = useCallback((index, candidate) => setChoices((current) => current.map((choice, i) => {
if (i !== index) return choice;
const candidates = choice.candidates.some((known) => known.igdbId === candidate.igdbId)
? choice.candidates : [...choice.candidates, candidate];
return {...choice, candidates, igdbId: candidate.igdbId,
platform: preselectPlatform(data.rows[index].platform, candidate.platforms, favorite)};
})), [data.rows, favorite]);
const chosen = choices.map((choice, index) => ({choice, row: data.rows[index]}))
.filter(({choice}) => choice.checked && choice.igdbId && choice.platform.trim());
const save = async () => {
setBusy(true);
try {
const response = await saveImport(data.id, chosen.map(({choice, row}) => ({
lineNo: row.lineNo, igdbId: choice.igdbId, platform: choice.platform.trim(), status: choice.status,
playtimeMinutes: hoursToMinutes(choice.hours),
})));
if (response.status === 401) {
expireSession();
return;
}
if (!response.ok) {
toast.error(t('IMPORT.SAVE_FAILED'));
return;
}
onSaved();
} catch (err) {
console.error('Import save error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setBusy(false);
}
};
return (
<Card className="gap-0 overflow-hidden p-0">
<div className="flex flex-wrap items-center justify-between gap-3 border-b px-5 py-4">
<p className="text-sm text-muted-foreground">{t('IMPORT.REVIEW_HINT')}</p>
<div className="flex gap-2">
<Button variant="ghost" onClick={onDone} disabled={busy}>{t('IMPORT.NEW')}</Button>
<Button onClick={save} disabled={busy || chosen.length === 0}>
{busy && <Loader2 className="animate-spin"/>}{t('IMPORT.SAVE', {count: chosen.length})}
</Button>
</div>
</div>
<ul className="divide-y">
{choices.map((choice, index) => (
<ReviewRow key={data.rows[index].lineNo} index={index} row={data.rows[index]} choice={choice}
onChange={change} onPick={pick}/>
))}
</ul>
</Card>
);
}
const ReviewRow = memo(function ReviewRow({index, row, choice, onChange: changeLine, onPick: pickLine}) {
const {t} = useTranslation();
const onChange = (changes) => changeLine(index, changes);
const onPick = (candidate) => pickLine(index, candidate);
const [searching, setSearching] = useState(false);
const [query, setQuery] = useState(row.title ?? '');
const candidate = choice.candidates.find((known) => known.igdbId === choice.igdbId);
const label = `${t('IMPORT.LINE', {line: row.lineNo})}: ${row.title ?? ''}`;
return (
<li className="space-y-2 px-5 py-3" data-line={row.lineNo}>
<div className="flex items-center gap-2">
<input type="checkbox" className="size-4 accent-primary" aria-label={label} checked={choice.checked}
disabled={!candidate} onChange={(e) => onChange({checked: e.target.checked})}/>
<span className="min-w-0 flex-1 truncate text-sm">{label}</span>
{candidate?.inLibrary && <Badge variant="secondary">{t('MANUAL_GAME.IN_LIBRARY')}</Badge>}
{row.searchFailed && <Badge variant="destructive">{t('IMPORT.SEARCH_FAILED')}</Badge>}
<Button variant="ghost" size="sm" onClick={() => setSearching(!searching)}>
<Search/>{t('IMPORT.SEARCH_AGAIN')}
</Button>
</div>
{searching && (
<GameSearch query={query} onQueryChange={setQuery} hide={(hit) => !hit.igdbId} onPick={(hit) => {
onPick(hit);
setSearching(false);
}}/>
)}
{choice.candidates.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('IMPORT.NO_MATCH')}</p>
) : (
<div className="grid gap-2 sm:grid-cols-[2fr_1fr_1fr_6rem]">
<select className={SELECT_CLASS} aria-label={t('IMPORT.MATCH', {line: row.lineNo})} value={choice.igdbId ?? ''}
onChange={(e) => onPick(choice.candidates.find((known) => String(known.igdbId) === e.target.value))}>
{choice.candidates.map((known) => (
<option key={known.igdbId} value={known.igdbId}>
{known.name}{known.year ? ` (${known.year})` : ''}{known.inLibrary ? ` · ${t('MANUAL_GAME.IN_LIBRARY')}` : ''}
</option>
))}
</select>
{candidate?.platforms.length > 0 ? (
<select className={SELECT_CLASS} aria-label={t('IMPORT.PLATFORM', {line: row.lineNo})} value={choice.platform}
onChange={(e) => onChange({platform: e.target.value})}>
<option value="">{t('IMPORT.PICK_PLATFORM')}</option>
{!candidate.platforms.includes(choice.platform) && choice.platform && (
<option value={choice.platform}>{choice.platform}</option>
)}
{candidate.platforms.map((platform) => <option key={platform} value={platform}>{platform}</option>)}
</select>
) : (
<Input value={choice.platform} maxLength={MAX_PLATFORM_LENGTH} aria-label={t('IMPORT.PLATFORM', {line: row.lineNo})}
onChange={(e) => onChange({platform: e.target.value})}/>
)}
<select className={SELECT_CLASS} aria-label={t('IMPORT.STATUS_LABEL', {line: row.lineNo})} value={choice.status}
onChange={(e) => onChange({status: e.target.value})}>
{GAME_STATUSES.map((status) => <option key={status} value={status}>{t(`BACKLOG.STATUS.${status}`)}</option>)}
</select>
<Input type="number" min="0" step="0.5" inputMode="decimal" value={choice.hours}
aria-label={t('IMPORT.HOURS', {line: row.lineNo})} onChange={(e) => onChange({hours: e.target.value})}/>
</div>
)}
</li>
);
});
function History({imports, onOpen, onChanged, expireSession}) {
const {t, i18n} = useTranslation();
const [confirm, setConfirm] = useState(null);
const [busy, setBusy] = useState(false);
const run = async (call, onOk) => {
setBusy(true);
try {
const response = await call();
if (response.status === 401) {
expireSession();
return;
}
if (!response.ok) {
toast.error(t('IMPORT.UNDO_FAILED'));
return;
}
await onOk(response);
} catch (err) {
console.error('Import undo error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setBusy(false);
}
};
const askUndo = (id) => run(() => checkUndo(id), async (response) => setConfirm({id, ...await response.json()}));
const undo = () => run(() => undoImport(confirm.id), () => {
toast.success(t('IMPORT.UNDONE', {count: confirm.games}));
setConfirm(null);
onChanged();
});
return (
<Card className="gap-0 p-0">
<h2 className="border-b px-5 py-3 font-semibold">{t('IMPORT.HISTORY')}</h2>
<ul className="divide-y">
{imports.map((entry) => (
<li key={entry.id} className="flex flex-wrap items-center gap-3 px-5 py-3 text-sm">
<span className="tabular-nums text-muted-foreground">
{new Date(entry.createdAt).toLocaleDateString(i18n.language)}
</span>
<span className="flex-1">
{t(`IMPORT.SOURCE_${entry.source}`)} · {t('IMPORT.LINES', {count: entry.total})}
</span>
<Badge variant={entry.status === 'FAILED' ? 'destructive' : 'outline'}>{t(`IMPORT.STATUS.${entry.status}`)}</Badge>
{entry.status !== 'SAVED' && entry.status !== 'UNDONE' && (
<Button variant="outline" size="sm" onClick={() => onOpen(entry.id)}>{t('IMPORT.OPEN_IMPORT')}</Button>
)}
{(entry.status === 'SAVED' || (entry.status === 'FAILED' && entry.added > 0))
&& confirm?.id !== entry.id && (
<Button variant="outline" size="sm" disabled={busy} onClick={() => askUndo(entry.id)}>
<RotateCcw/>{t('IMPORT.UNDO')}
</Button>
)}
{confirm?.id === entry.id && (
<div className="flex w-full flex-wrap items-center gap-2">
<span>{t('IMPORT.UNDO_CONFIRM', {count: confirm.games, changed: confirm.changed})}</span>
<Button variant="destructive" size="sm" disabled={busy} onClick={undo}>{t('IMPORT.UNDO_YES')}</Button>
<Button variant="ghost" size="sm" disabled={busy} onClick={() => setConfirm(null)}>
{t('MANUAL_GAME.REMOVE_NO')}
</Button>
</div>
)}
</li>
))}
</ul>
</Card>
);
}
+226
View File
@@ -0,0 +1,226 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor, within} from '@testing-library/react';
import {MemoryRouter, Route, Routes} from 'react-router-dom';
import {toast} from 'sonner';
import ImportPage, {initialChoice, stepOf} from './ImportPage.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const json = (status, body) => ({ok: status < 400, status, json: async () => body});
const hades = {igdbId: 1, name: 'Hades', year: 2020, imageUrl: null, platforms: ['PC', 'Switch'], inLibrary: false};
const hades2 = {igdbId: 2, name: 'Hades II', year: 2025, imageUrl: null, platforms: ['PC'], inLibrary: false};
const celeste = {igdbId: 3, name: 'Celeste', year: 2018, imageUrl: null, platforms: ['PC', 'PS4'], inLibrary: true};
const row = (lineNo, title, candidates, extra = {}) => ({
lineNo, title, platform: null, playtimeMinutes: null, status: null, searchFailed: false, exact: true, candidates, ...extra,
});
const ready = {
id: 5, source: 'TEXT', status: 'READY', total: 3, matched: 3, rows: [
row(1, 'Hades', [hades, hades2]),
row(2, 'Celeste', [celeste]),
row(3, 'Hade', [hades2, hades], {exact: false, playtimeMinutes: 90}),
],
};
// routes: 'METHOD path after /imports' -> response or (init) => response; any other URL (Auth) answers the favorite
function renderPage(routes, entry = '/import') {
globalThis.fetch = vi.fn(async (url, init) => {
if (!url.includes('/api/library/imports')) return json(200, {settings: {favoritePlatform: 'Switch'}});
const path = new URL(url, 'http://x').pathname.replace(/.*\/api\/library\/imports/, '');
const answer = routes[`${init?.method ?? 'GET'} ${path}`];
if (!answer) throw new Error(`no route ${init?.method ?? 'GET'} ${path}`);
return typeof answer === 'function' ? answer(init) : answer;
});
render(
<MemoryRouter initialEntries={[entry]}>
<Routes>
<Route path="/import" element={<PreferencesProvider><ImportPage/></PreferencesProvider>}/>
<Route path="/login" element={<p>login</p>}/>
</Routes>
</MemoryRouter>,
);
}
const posted = (method, path) => fetch.mock.calls.find(([url, init]) => init?.method === method && url.endsWith(path));
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
});
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
describe('initialChoice', () => {
it('checks only a sure match with a platform', () => {
expect(initialChoice(ready.rows[0], 'Switch')).toMatchObject({igdbId: 1, platform: 'Switch', checked: true});
expect(initialChoice(ready.rows[0], null)).toMatchObject({platform: '', checked: false});
expect(initialChoice(ready.rows[1], 'PC')).toMatchObject({platform: 'PC', checked: false});
expect(initialChoice(ready.rows[2], 'PC')).toMatchObject({platform: 'PC', checked: false, hours: '1.5'});
expect(initialChoice(row(4, 'x', []), 'PC')).toMatchObject({igdbId: null, checked: false});
});
});
describe('stepOf', () => {
it('maps the status to the step, and a failed import to the step it stopped in', () => {
expect(stepOf({status: 'QUEUED'})).toBe('QUEUED');
expect(stepOf({status: 'READY'})).toBe('REVIEW');
expect(stepOf({status: 'SAVING', step: null})).toBe('SAVING');
expect(stepOf({status: 'SAVING', step: 'DETAILS'})).toBe('DETAILS');
expect(stepOf({status: 'FAILED', chosen: 0})).toBe('MATCHING');
expect(stepOf({status: 'FAILED', chosen: 3})).toBe('SAVING');
expect(stepOf({status: 'UNDONE'})).toBe('DONE');
});
});
describe('ImportPage', () => {
it('shows a queued import with the imports before it', async () => {
renderPage({
'GET ': json(200, {imports: []}),
'GET /8': json(200, {id: 8, source: 'CSV', status: 'QUEUED', total: 40, matched: 0, chosen: 0, saved: 0,
added: 0, ahead: 2, attempts: 0}),
}, '/import?id=8');
expect(await screen.findByText('IMPORT.WAITING:2')).toBeTruthy();
expect(screen.getByText('IMPORT.STEP.QUEUED').closest('li').getAttribute('aria-current')).toBe('step');
});
it('tries a failed import again and polls it', async () => {
let retried = false;
renderPage({
'GET ': json(200, {imports: []}),
'GET /6': () => json(200, {id: 6, source: 'TEXT', total: 10, matched: retried ? 4 : 2, chosen: 0, saved: 0,
added: 0, ahead: null, attempts: retried ? 0 : 3, status: retried ? 'MATCHING' : 'FAILED'}),
'POST /6/retry': () => {
retried = true;
return json(202, null);
},
}, '/import?id=6');
expect(await screen.findByText('IMPORT.FAILED:3')).toBeTruthy();
fireEvent.click(screen.getByRole('button', {name: 'IMPORT.RETRY'}));
expect(await screen.findByText('IMPORT.MATCHING')).toBeTruthy();
expect(posted('POST', '/6/retry')).toBeTruthy();
});
it('starts an import from the list and shows the progress', async () => {
renderPage({
'GET ': json(200, {imports: []}),
'POST ': json(202, {id: 9}),
'GET /9': json(200, {id: 9, source: 'TEXT', status: 'MATCHING', total: 2, matched: 1}),
});
fireEvent.change(await screen.findByLabelText('IMPORT.TEXT_LABEL'), {target: {value: 'Hades\n\n Celeste \n'}});
expect(screen.getByText('IMPORT.LINES:2')).toBeTruthy();
fireEvent.click(screen.getByRole('button', {name: 'IMPORT.START'}));
expect(await screen.findByText('IMPORT.MATCHING')).toBeTruthy();
expect(JSON.parse(posted('POST', '/imports')[1].body)).toEqual({
source: 'TEXT', rows: [{title: 'Hades'}, {title: 'Celeste'}],
});
});
it('polls while matching and then shows the review', async () => {
let calls = 0;
renderPage({
'GET ': json(200, {imports: [{...ready, status: 'MATCHING', rows: null}]}),
'GET /5': () => (++calls === 1 ? json(200, {...ready, status: 'MATCHING', matched: 1, rows: null}) : json(200, ready)),
});
expect(await screen.findByText('IMPORT.MATCHING')).toBeTruthy();
expect(await screen.findByText('IMPORT.LINE: Hades', {}, {timeout: 3000})).toBeTruthy();
});
it('preselects the favorite, checks only sure matches, and saves the checked lines', async () => {
let saved = false;
renderPage({
'GET ': json(200, {imports: []}),
'GET /5': () => json(200, saved ? {...ready, status: 'SAVED', chosen: 2, saved: 2, added: 1, rows: null} : ready),
'POST /5/save': () => {
saved = true;
return json(202, null);
},
}, '/import?id=5');
const first = await screen.findByLabelText('IMPORT.LINE: Hades');
await waitFor(() => expect(within(first.closest('li')).getByLabelText('IMPORT.PLATFORM').value).toBe('Switch'));
expect(first.checked).toBe(true);
// In the library already, and not the same name.
expect(screen.getByLabelText('IMPORT.LINE: Celeste').checked).toBe(false);
const third = screen.getByLabelText('IMPORT.LINE: Hade');
expect(third.checked).toBe(false);
const line3 = third.closest('li');
fireEvent.change(within(line3).getByLabelText('IMPORT.MATCH'), {target: {value: '1'}});
expect(within(line3).getByLabelText('IMPORT.PLATFORM').value).toBe('Switch');
fireEvent.change(within(line3).getByLabelText('IMPORT.STATUS_LABEL'), {target: {value: 'PLAYING'}});
fireEvent.click(third);
fireEvent.click(screen.getByRole('button', {name: 'IMPORT.SAVE:2'}));
// The save runs in the background; the page polls until it is done.
expect(await screen.findByText('IMPORT.DONE')).toBeTruthy();
expect(JSON.parse(posted('POST', '/5/save')[1].body)).toEqual({rows: [
{lineNo: 1, igdbId: 1, platform: 'Switch', status: 'BACKLOG', playtimeMinutes: 0},
{lineNo: 3, igdbId: 1, platform: 'Switch', status: 'PLAYING', playtimeMinutes: 90},
]});
});
it('undoes a saved import after a confirm with the changed count', async () => {
renderPage({
'GET ': json(200, {imports: [{id: 4, source: 'CSV', status: 'SAVED', total: 3, matched: 3, createdAt: '2026-09-29T10:00:00Z'}]}),
'GET /4/undo-check': json(200, {games: 3, changed: 1}),
'DELETE /4/games': json(204, null),
});
fireEvent.click(await screen.findByRole('button', {name: 'IMPORT.UNDO'}));
expect(await screen.findByText('IMPORT.UNDO_CONFIRM:3')).toBeTruthy();
fireEvent.click(screen.getByRole('button', {name: 'IMPORT.UNDO_YES'}));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('IMPORT.UNDONE:3'));
expect(posted('DELETE', '/4/games')).toBeTruthy();
});
it('says so when another import is still matching', async () => {
renderPage({'GET ': json(200, {imports: []}), 'POST ': json(409, {code: 'IMPORT_RUNNING'})});
fireEvent.change(await screen.findByLabelText('IMPORT.TEXT_LABEL'), {target: {value: 'Hades'}});
fireEvent.click(screen.getByRole('button', {name: 'IMPORT.START'}));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('IMPORT.RUNNING'));
});
it('stops polling on a 4xx and says so', async () => {
renderPage({'GET ': json(200, {imports: []}), 'GET /abc': json(400, null)}, '/import?id=abc');
expect(await screen.findByText('IMPORT.LOAD_FAILED')).toBeTruthy();
});
it('keeps showing an import opened without an id when it leaves the worker', async () => {
let polls = 0;
const matching = {id: 5, source: 'TEXT', status: 'MATCHING', total: 3, matched: 1, chosen: 0, saved: 0, added: 0,
ahead: null, attempts: 0, rows: null};
renderPage({
'GET ': () => json(200, {imports: [polls > 1 ? {...ready, rows: null} : matching]}),
'GET /5': () => (++polls > 1 ? json(200, ready) : json(200, matching)),
});
expect(await screen.findByText('IMPORT.LINE: Hades', {}, {timeout: 4000})).toBeTruthy();
}, 10000);
it('keeps polling after a 502 while Core restarts', async () => {
let calls = 0;
renderPage({
'GET ': json(200, {imports: []}),
'GET /8': () => (++calls === 1 ? json(502, null) : json(200, {id: 8, source: 'TEXT', status: 'MATCHING',
total: 4, matched: 1, chosen: 0, saved: 0, added: 0, ahead: null, attempts: 0})),
}, '/import?id=8');
expect(await screen.findByText('IMPORT.MATCHING', {}, {timeout: 4000})).toBeTruthy();
}, 10000);
});
+30
View File
@@ -26,9 +26,11 @@ 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';
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',
@@ -43,6 +45,8 @@ const STEAM_LINK_TOASTS = {
}; };
const SUPPORTED_LANGUAGES = ['en', 'de']; 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 +64,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);
@@ -195,6 +200,14 @@ export default function SettingsPage() {
await persistSettings({...savedSettings(), language: 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({...savedSettings(), publicProfile: next});
@@ -308,6 +321,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>
+30
View File
@@ -168,3 +168,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}]));
});
});
+114
View File
@@ -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;
}
+42
View File
@@ -0,0 +1,42 @@
import {describe, expect, it} from 'vitest';
import {parseCsv, parseTextList} from './imports.js';
describe('parseTextList', () => {
it('takes one title per line and skips empty lines', () => {
expect(parseTextList(' Hades \r\n\nCeleste\n')).toEqual([{title: 'Hades'}, {title: 'Celeste'}]);
expect(parseTextList('x'.repeat(150))[0].title).toHaveLength(100);
});
});
describe('parseCsv', () => {
it('reads the known columns, quotes and hours', () => {
const {rows} = parseCsv('Name,Platform,Hours,Status\n'
+ '"Hades, the game",PS5,"1,5",playing\r\n'
+ '"The ""Best"" Game",,,\n'
+ ',PC,3,\n');
expect(rows).toEqual([
{title: 'Hades, the game', platform: 'PS5', playtimeMinutes: 90, status: 'PLAYING'},
{title: 'The "Best" Game', platform: null, playtimeMinutes: null, status: null},
]);
});
it('takes the delimiter the header has most of, and minutes', () => {
const {rows} = parseCsv('title;minutes;status\nCeleste;125;unknown\n');
expect(rows).toEqual([{title: 'Celeste', platform: null, playtimeMinutes: 125, status: null}]);
});
it('keeps a line break inside quotes', () => {
expect(parseCsv('game\n"Two\nLines"\n').rows[0].title).toBe('Two\nLines');
});
it('keeps a quote inside an unquoted field as a character', () => {
expect(parseCsv('title,platform\n12" Barbarian,PC\nHades,PS5\n').rows.map((row) => row.title))
.toEqual(['12" Barbarian', 'Hades']);
});
it('needs a title column', () => {
expect(parseCsv('platform,hours\nPC,1\n')).toEqual({error: 'IMPORT.CSV_NO_TITLE'});
});
});
+6
View File
@@ -7,6 +7,12 @@ 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;
// 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) => { export const hoursToMinutes = (hours) => {
const value = Number(String(hours).replace(',', '.')); const value = Number(String(hours).replace(',', '.'));
return Number.isFinite(value) && value > 0 ? Math.round(value * 60) : 0; return Number.isFinite(value) && value > 0 ? Math.round(value * 60) : 0;
+53
View File
@@ -0,0 +1,53 @@
import {apiFetch} from './api.js';
const AUTH_URL = () => import.meta.env.VITE_API_URL;
// IGDB platform abbreviations (/v4/platforms, checked 2026-09-29), the way Core stores a game's platform.
export const FAVORITE_PLATFORMS = [
{value: 'PC', label: 'PC (Windows)'},
{value: 'PS5', label: 'PlayStation 5'},
{value: 'PS4', label: 'PlayStation 4'},
{value: 'Series X|S', label: 'Xbox Series X|S'},
{value: 'XONE', label: 'Xbox One'},
{value: 'Switch', label: 'Nintendo Switch'},
{value: 'Switch 2', label: 'Nintendo Switch 2'},
{value: 'Mac', label: 'Mac'},
{value: 'Linux', label: 'Linux'},
{value: 'iOS', label: 'iOS'},
{value: 'Android', label: 'Android'},
];
/**
* The platform to preselect for a game: the line's own platform (in the game's spelling when it has it), else the
* favorite when the game has it, else the only platform of the game, else '' and the user picks.
*/
export const preselectPlatform = (own, platforms, favorite) => {
const find = (name) => name && platforms.find((platform) => platform.toLowerCase() === name.toLowerCase());
if (own) return find(own) ?? own;
return find(favorite) ?? (platforms.length === 1 ? platforms[0] : '');
};
// A Steam game has no stored platform.
export const platformLabel = (game) => (game.source === 'MANUAL' ? game.platform : 'Steam');
/**
* The user's favorite platform from Auth; null when none is set or the load failed, so a caller can go on.
*/
export const loadFavoritePlatform = async () => {
try {
const response = await apiFetch(`${AUTH_URL()}/api/users/me?v=1`);
return response.ok ? (await response.json()).settings?.favoritePlatform ?? null : null;
} catch {
return null;
}
};
/**
* PUT /api/users/settings/favorite-platform; null clears it. The caller handles the response status.
*/
export const saveFavoritePlatform = (platform) =>
apiFetch(`${AUTH_URL()}/api/users/settings/favorite-platform`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({platform}),
});
+30
View File
@@ -0,0 +1,30 @@
import {describe, expect, it} from 'vitest';
import {platformLabel, preselectPlatform} from './platforms.js';
describe('preselectPlatform', () => {
it('takes the own platform first, in the game spelling', () => {
expect(preselectPlatform('ps5', ['PC', 'PS5'], 'PC')).toBe('PS5');
expect(preselectPlatform('Amiga', ['PC'], 'PC')).toBe('Amiga');
});
it('then the favorite when the game has it, then the only platform', () => {
expect(preselectPlatform(null, ['PC', 'Switch'], 'switch')).toBe('Switch');
expect(preselectPlatform(null, ['PS4'], 'Switch')).toBe('PS4');
expect(preselectPlatform(null, ['PC', 'PS4'], 'Switch')).toBe('');
expect(preselectPlatform(null, ['PC', 'PS4'], null)).toBe('');
expect(preselectPlatform(null, [], null)).toBe('');
});
it('keeps Switch and Switch 2 apart', () => {
expect(preselectPlatform(null, ['Switch', 'Switch 2'], 'Switch')).toBe('Switch');
expect(preselectPlatform(null, ['Switch', 'Switch 2'], 'Switch 2')).toBe('Switch 2');
expect(preselectPlatform(null, ['Switch', 'PC'], 'Switch 2')).toBe('');
});
});
describe('platformLabel', () => {
it('shows Steam for a Steam game', () => {
expect(platformLabel({source: 'STEAM', platform: null})).toBe('Steam');
expect(platformLabel({source: 'MANUAL', platform: 'PS5'})).toBe('PS5');
});
});