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
+1436 -48

No files matched your search

+2
View File
@@ -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() {
<Route path="/wishlist" element={<WishlistPage/>}/>
<Route path="/play-next" element={<PlayNextPage/>}/>
<Route path="/stats" element={<StatsPage/>}/>
<Route path="/import" element={<ImportPage/>}/>
<Route path="/u/:name" element={<PublicProfilePage/>}/>
<Route path="/settings" element={<SettingsPage/>}/>
<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 {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 (
<Card className="p-5 gap-4">
@@ -38,20 +64,34 @@ export default function AddGamePanel({onClose}) {
</div>
{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>
<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>
);
}
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}) {
<Label htmlFor="add-platform">{t('MANUAL_GAME.PLATFORM')}</Label>
{hit.platforms.length > 0 ? (
<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>
{hit.platforms.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
</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});
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(
<MemoryRouter initialEntries={['/library']}>
<Routes>
<Route path="/library" element={<AddGamePanel onClose={() => {}}/>}/>
<Route path="/library/:appid" element={<p>detail page</p>}/>
<Route path="/import" element={<p>import page</p>}/>
</Routes>
</MemoryRouter>,
);
}
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();
+28 -17
View File
@@ -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 && (
<ul className="divide-y rounded-md border">
{hits.map((hit) => (
<li key={hit.appid}>
<button type="button" disabled={blockInLibrary && hit.inLibrary} onClick={() => onPick(hit)}
className="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-muted/50 disabled:cursor-not-allowed disabled:opacity-60">
<GameCover appid={hit.appid} imageUrl={hit.imageUrl} className="w-20 shrink-0"/>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">
{hit.name}{hit.year ? ` (${hit.year})` : ''}
{hits.map((hit) => {
const blocked = blockInLibrary && hit.inLibrary;
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">
<GameCover appid={hit.appid} imageUrl={hit.imageUrl} className="w-20 shrink-0"/>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">
{hit.name}{hit.year ? ` (${hit.year})` : ''}
</span>
<span className="block truncate text-xs text-muted-foreground">
{hit.platforms.join(' · ')}
</span>
</span>
<span className="block truncate text-xs text-muted-foreground">
{hit.platforms.join(' · ')}
</span>
</span>
{blockInLibrary && hit.inLibrary && <Badge variant="secondary">{t('MANUAL_GAME.IN_LIBRARY')}</Badge>}
</button>
</li>
))}
{blocked && <Badge variant="secondary">{t('MANUAL_GAME.IN_LIBRARY')}</Badge>}
</button>
</li>
);
})}
</ul>
)}
</>
+1 -4
View File
@@ -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.
+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 {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)})}
</Badge>
<GameStatusBadge status={data.status}/>
{data.platform && (
<Badge variant="outline"><Gamepad2 className="size-3"/>{data.platform}</Badge>
{platformLabel(data) && (
<Badge variant="outline"><Gamepad2 className="size-3"/>{platformLabel(data)}</Badge>
)}
</div>
<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 {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() {
<Button variant="outline" size="sm" onClick={() => setAdding(true)} disabled={adding}>
<Plus/>{t('MANUAL_GAME.ADD_BUTTON')}
</Button>
<Button variant="outline" size="sm" asChild>
<Link to="/import"><Upload/>{t('IMPORT.OPEN')}</Link>
</Button>
</div>
</div>
<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">
{g.name ?? `App ${g.appid}`}
</span>
{g.platform && (
<Badge variant="outline" className="hidden sm:inline-flex" title={g.platform}>
<Gamepad2 className="size-3"/>{g.platform}
{platformLabel(g) && (
<Badge variant="outline" className="hidden sm:inline-flex" title={platformLabel(g)}>
<Gamepad2 className="size-3"/>{platformLabel(g)}
</Badge>
)}
<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();
});
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();
+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 {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() {
</SelectContent>
</Select>
</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>
</Card>
+30
View File
@@ -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}]));
});
});
+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.
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;
+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');
});
});