diff --git a/README.md b/README.md
index 65cfac1..42314a2 100644
--- a/README.md
+++ b/README.md
@@ -153,9 +153,10 @@ flowchart LR
```text
src/
-├── pages/ # One component per route (Dashboard, GameLibrary, GameDetail, ...)
-├── components/ # Shared components; ui/ holds the base components
-├── hooks/ # Data hooks (library sync, Steam profile, appearance, session expiry)
+├── pages/ # One component per route (Dashboard, GameLibrary, GameDetail, ...): loads data, places cards
+├── components/ # Cards and shared components; ui/ holds the base components, import/ and stats/ the parts
+│ # of one page that share page-only code
+├── hooks/ # Data hooks (game, dashboard, library sync, Steam profile, appearance, session)
├── context/ # PreferencesContext: theme, language, appearance
├── utils/ # Validation schemas and helpers
└── test/ # Test setup (i18n mock)
diff --git a/src/components/import/ImportHistory.jsx b/src/components/import/ImportHistory.jsx
new file mode 100644
index 0000000..232d6a4
--- /dev/null
+++ b/src/components/import/ImportHistory.jsx
@@ -0,0 +1,76 @@
+import {useState} from 'react';
+import {useTranslation} from 'react-i18next';
+import {toast} from 'sonner';
+import {RotateCcw} from 'lucide-react';
+
+import {Badge} from '@/components/ui/badge';
+import {Button} from '@/components/ui/button';
+import {Card} from '@/components/ui/card';
+import {checkUndo, undoImport} from '../../utils/imports.js';
+
+export default function ImportHistory({imports, onOpen, onChanged}) {
+ 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) return;
+ if (!response.ok) {
+ toast.error(t('IMPORT.UNDO_FAILED'));
+ return;
+ }
+ await onOk(response);
+ } catch (err) {
+ console.error('Import undo error:', err);
+ toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
+ } finally {
+ setBusy(false);
+ }
+ };
+ const askUndo = (id) => run(() => checkUndo(id), async (response) => setConfirm({id, ...await response.json()}));
+ const undo = () => run(() => undoImport(confirm.id), () => {
+ toast.success(t('IMPORT.UNDONE', {count: confirm.games}));
+ setConfirm(null);
+ onChanged();
+ });
+
+ return (
+
+ {t('IMPORT.HISTORY')}
+
+
+ );
+}
diff --git a/src/components/import/ImportInput.jsx b/src/components/import/ImportInput.jsx
new file mode 100644
index 0000000..ac46c52
--- /dev/null
+++ b/src/components/import/ImportInput.jsx
@@ -0,0 +1,71 @@
+import {useState} from 'react';
+import {useTranslation} from 'react-i18next';
+import {Loader2, Search} from 'lucide-react';
+
+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 useStartImport from '../../hooks/useStartImport.js';
+import {parseCsv, parseTextList} from '../../utils/imports.js';
+import {MAX_IMPORT_ROWS} from '../../utils/limits.js';
+
+export default function ImportInput({onCreated}) {
+ const {t} = useTranslation();
+ const [source, setSource] = useState('TEXT');
+ const [text, setText] = useState('');
+ const [csv, setCsv] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const rows = source === 'TEXT' ? parseTextList(text) : csv?.rows ?? [];
+ const tooMany = rows.length > MAX_IMPORT_ROWS;
+
+ const readFile = async (file) => setCsv(file ? parseCsv(await file.text()) : null);
+
+ const startImport = useStartImport();
+ const start = async () => {
+ setBusy(true);
+ const id = await startImport(source, rows);
+ setBusy(false);
+ if (id) onCreated(id);
+ };
+
+ return (
+
+ {t('IMPORT.HINT')}
+
+ {['TEXT', 'CSV'].map((value) => (
+
+ ))}
+
+ {source === 'TEXT' ? (
+
+ );
+}
diff --git a/src/components/import/ImportReview.jsx b/src/components/import/ImportReview.jsx
new file mode 100644
index 0000000..df42ea9
--- /dev/null
+++ b/src/components/import/ImportReview.jsx
@@ -0,0 +1,152 @@
+import {memo, useCallback, useState} from 'react';
+import {useTranslation} from 'react-i18next';
+import {toast} from 'sonner';
+import {Loader2, Search} 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 GameSearch from '../GameSearch.jsx';
+import useUnsavedChanges from '../../hooks/useUnsavedChanges.js';
+import {initialChoice, saveImport} from '../../utils/imports.js';
+import {GAME_STATUSES, MAX_PLATFORM_LENGTH} from '../../utils/limits.js';
+import {hoursToMinutes} from '../../utils/manualGames.js';
+import {preselectPlatform} from '../../utils/platforms.js';
+
+// 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';
+
+export default function ImportReview({data, favorite, onSaved, onDone}) {
+ const {t} = useTranslation();
+ const [initialChoices] = useState(() => data.rows.map((row) => initialChoice(row, favorite)));
+ const [choices, setChoices] = useState(initialChoices);
+ useUnsavedChanges(choices !== initialChoices);
+ 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]);
+ // A line without a match cannot be checked, one in the library only by hand; a hand check stays.
+ const checkAll = (checked) => setChoices((current) => current.map((choice) => {
+ const candidate = choice.candidates.find((known) => known.igdbId === choice.igdbId);
+ const next = checked && (choice.checked || (!!candidate && !candidate.inLibrary));
+ return choice.checked === next ? choice : {...choice, checked: next};
+ }));
+ 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) return;
+ if (!response.ok) {
+ toast.error(t('IMPORT.SAVE_FAILED'));
+ return;
+ }
+ onSaved();
+ } catch (err) {
+ console.error('Import save error:', err);
+ toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+
+
{t('IMPORT.REVIEW_HINT')}
+
+
+
+
+
+
+
+
+ {choices.map((choice, index) => (
+
+ ))}
+
+
+ );
+}
+
+const ReviewRow = memo(function ReviewRow({index, row, choice, onChange: changeLine, onPick: pickLine}) {
+ const {t} = useTranslation();
+ const onChange = (changes) => changeLine(index, changes);
+ const onPick = (candidate) => pickLine(index, candidate);
+ const [searching, setSearching] = useState(false);
+ const [query, setQuery] = useState(row.title ?? '');
+ const candidate = choice.candidates.find((known) => known.igdbId === choice.igdbId);
+ const label = `${t('IMPORT.LINE', {line: row.lineNo})}: ${row.title ?? ''}`;
+
+ return (
+
+
+ onChange({checked: e.target.checked})}/>
+ {label}
+ {candidate?.inLibrary && {t('MANUAL_GAME.IN_LIBRARY')}}
+ {row.searchFailed && {t('IMPORT.SEARCH_FAILED')}}
+
+
+ {searching && (
+ !hit.igdbId} onPick={(hit) => {
+ onPick(hit);
+ setSearching(false);
+ }}/>
+ )}
+ {choice.candidates.length === 0 ? (
+ {t('IMPORT.NO_MATCH')}
+ ) : (
+
+
+ {candidate?.platforms.length > 0 ? (
+
+ ) : (
+ onChange({platform: e.target.value})}/>
+ )}
+
+ onChange({hours: e.target.value})}/>
+
+ )}
+
+ );
+});
diff --git a/src/components/import/ImportView.jsx b/src/components/import/ImportView.jsx
new file mode 100644
index 0000000..7df5bd0
--- /dev/null
+++ b/src/components/import/ImportView.jsx
@@ -0,0 +1,170 @@
+import {useEffect, useRef, useState} from 'react';
+import {useTranslation} from 'react-i18next';
+import {toast} from 'sonner';
+import {CheckCircle2, Circle, Loader2, RotateCcw, XCircle} from 'lucide-react';
+
+import {Button} from '@/components/ui/button';
+import {Card} from '@/components/ui/card';
+import {cn} from '@/lib/utils';
+import ImportReview from './ImportReview.jsx';
+import {useCurrentUserContext} from '../../context/CurrentUserContext.jsx';
+import {getImport, retryImport, RUNNING, STEPS, stepOf} from '../../utils/imports.js';
+
+const POLL_MS = 2000;
+
+// Polls while a worker has the import.
+export default function ImportView({id, onDone, onChanged}) {
+ 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 {user} = useCurrentUserContext();
+ const favorite = user === undefined ? undefined : user?.settings?.favoritePlatform ?? null;
+
+ useEffect(() => {
+ let timer;
+ let cancelled = false;
+ const load = async () => {
+ try {
+ const response = await getImport(id);
+ if (cancelled) return;
+ if (response.status === 401) 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, version, onChanged]);
+
+ if (failed) return {t('IMPORT.LOAD_FAILED')}
;
+ if (!data || favorite === undefined) return ;
+ return (
+
+
+ {data.status === 'READY' ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+// The steps in a row: done ones checked, the current one marked (red when the import failed there).
+function ImportSteps({data}) {
+ const {t} = useTranslation();
+ const current = STEPS.indexOf(stepOf(data));
+ const failedHere = data.status === 'FAILED';
+ return (
+
+ {STEPS.map((step, index) => {
+ const done = index < current || (index === current && step === 'DONE');
+ const now = index === current && !done;
+ return (
+ -
+ {done ? : now && failedHere ?
+ : now && RUNNING.includes(data.status) ?
+ : }
+ {t(`IMPORT.STEP.${step}`)}
+
+ );
+ })}
+
+ );
+}
+
+// What the current step does, with its numbers; Retry for a FAILED import.
+function ImportProgress({data, onRetried, onDone}) {
+ 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) return;
+ if (response.status === 409) {
+ toast.error(t('IMPORT.RUNNING'));
+ } else if (!response.ok) {
+ toast.error(t('IMPORT.RETRY_FAILED'));
+ } else {
+ onRetried();
+ }
+ } catch (err) {
+ console.error('Import retry error:', err);
+ toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ let text;
+ let progress = null;
+ if (data.status === 'FAILED') {
+ text = t('IMPORT.FAILED', {count: data.attempts});
+ } else if (waiting) {
+ text = data.ahead > 0 ? t('IMPORT.WAITING', {count: data.ahead}) : t('IMPORT.WAITING_NEXT');
+ } else if (step === 'MATCHING') {
+ text = t('IMPORT.MATCHING', {matched: data.matched, total: data.total});
+ progress = [data.matched, data.total];
+ } else if (step === 'SAVING') {
+ text = t('IMPORT.SAVING', {saved: data.saved, chosen: data.chosen});
+ progress = [data.saved, data.chosen];
+ } else if (step === 'DETAILS') {
+ text = t('IMPORT.DETAILS');
+ } else {
+ text = t(data.status === 'UNDONE' ? 'IMPORT.STATUS.UNDONE' : 'IMPORT.DONE',
+ {added: data.added, skipped: data.saved - data.added});
+ }
+
+ return (
+
+ {text}
+ {progress &&
+ );
+}
diff --git a/src/pages/ImportPage.jsx b/src/pages/ImportPage.jsx
index 8cd1bc3..4bf9e54 100644
--- a/src/pages/ImportPage.jsx
+++ b/src/pages/ImportPage.jsx
@@ -1,48 +1,14 @@
-import {memo, useCallback, useEffect, useRef, useState} from 'react';
+import {useCallback, useEffect, useState} from 'react';
import {Link, 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 {ArrowLeft, Loader2, Upload} 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 ImportHistory from '../components/import/ImportHistory.jsx';
+import ImportInput from '../components/import/ImportInput.jsx';
+import ImportView from '../components/import/ImportView.jsx';
import PageContainer from '../components/PageContainer.jsx';
-import useStartImport from '../hooks/useStartImport.js';
-import {
- checkUndo, getImport, listImports, parseCsv, parseTextList, retryImport, saveImport, undoImport,
-} from '../utils/imports.js';
-import {GAME_STATUSES, MAX_IMPORT_ROWS, MAX_PLATFORM_LENGTH} from '../utils/limits.js';
-import {hoursToMinutes, toHours} from '../utils/manualGames.js';
-import {preselectPlatform} from '../utils/platforms.js';
-import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
-import useUnsavedChanges from '../hooks/useUnsavedChanges.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';
+import {listImports, RUNNING} from '../utils/imports.js';
/**
* Many games at once: a list or a CSV goes to Core, which matches it with IGDB in the background; the user checks
@@ -101,442 +67,7 @@ export default function ImportPage() {
loadImports();
}}/>
)}
- {imports?.length > 0 && }
+ {imports?.length > 0 && }
);
}
-
-function ImportInput({onCreated}) {
- const {t} = useTranslation();
- const [source, setSource] = useState('TEXT');
- const [text, setText] = useState('');
- const [csv, setCsv] = useState(null);
- const [busy, setBusy] = useState(false);
- const rows = source === 'TEXT' ? parseTextList(text) : csv?.rows ?? [];
- const tooMany = rows.length > MAX_IMPORT_ROWS;
-
- const readFile = async (file) => setCsv(file ? parseCsv(await file.text()) : null);
-
- const startImport = useStartImport();
- const start = async () => {
- setBusy(true);
- const id = await startImport(source, rows);
- setBusy(false);
- if (id) onCreated(id);
- };
-
- return (
-
- {t('IMPORT.HINT')}
-
- {['TEXT', 'CSV'].map((value) => (
-
- ))}
-
- {source === 'TEXT' ? (
-
- );
-}
-
-// Polls while a worker has the import.
-function ImportView({id, onDone, onChanged}) {
- 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 {user} = useCurrentUserContext();
- const favorite = user === undefined ? undefined : user?.settings?.favoritePlatform ?? null;
-
- useEffect(() => {
- let timer;
- let cancelled = false;
- const load = async () => {
- try {
- const response = await getImport(id);
- if (cancelled) return;
- if (response.status === 401) 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, version, onChanged]);
-
- if (failed) return {t('IMPORT.LOAD_FAILED')}
;
- if (!data || favorite === undefined) return ;
- return (
-
-
- {data.status === 'READY' ? (
-
- ) : (
-
- )}
-
- );
-}
-
-// The steps in a row: done ones checked, the current one marked (red when the import failed there).
-function ImportSteps({data}) {
- const {t} = useTranslation();
- const current = STEPS.indexOf(stepOf(data));
- const failedHere = data.status === 'FAILED';
- return (
-
- {STEPS.map((step, index) => {
- const done = index < current || (index === current && step === 'DONE');
- const now = index === current && !done;
- return (
- -
- {done ? : now && failedHere ?
- : now && RUNNING.includes(data.status) ?
- : }
- {t(`IMPORT.STEP.${step}`)}
-
- );
- })}
-
- );
-}
-
-// What the current step does, with its numbers; Retry for a FAILED import.
-function ImportProgress({data, onRetried, onDone}) {
- 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) return;
- if (response.status === 409) {
- toast.error(t('IMPORT.RUNNING'));
- } else if (!response.ok) {
- toast.error(t('IMPORT.RETRY_FAILED'));
- } else {
- onRetried();
- }
- } catch (err) {
- console.error('Import retry error:', err);
- toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
- } finally {
- setBusy(false);
- }
- };
-
- let text;
- let progress = null;
- if (data.status === 'FAILED') {
- text = t('IMPORT.FAILED', {count: data.attempts});
- } else if (waiting) {
- text = data.ahead > 0 ? t('IMPORT.WAITING', {count: data.ahead}) : t('IMPORT.WAITING_NEXT');
- } else if (step === 'MATCHING') {
- text = t('IMPORT.MATCHING', {matched: data.matched, total: data.total});
- progress = [data.matched, data.total];
- } else if (step === 'SAVING') {
- text = t('IMPORT.SAVING', {saved: data.saved, chosen: data.chosen});
- progress = [data.saved, data.chosen];
- } else if (step === 'DETAILS') {
- text = t('IMPORT.DETAILS');
- } else {
- text = t(data.status === 'UNDONE' ? 'IMPORT.STATUS.UNDONE' : 'IMPORT.DONE',
- {added: data.added, skipped: data.saved - data.added});
- }
-
- return (
-
- {text}
- {progress &&
- );
-}
-
-/**
- * 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}) {
- const {t} = useTranslation();
- const [initialChoices] = useState(() => data.rows.map((row) => initialChoice(row, favorite)));
- const [choices, setChoices] = useState(initialChoices);
- useUnsavedChanges(choices !== initialChoices);
- 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]);
- // A line without a match cannot be checked, one in the library only by hand; a hand check stays.
- const checkAll = (checked) => setChoices((current) => current.map((choice) => {
- const candidate = choice.candidates.find((known) => known.igdbId === choice.igdbId);
- const next = checked && (choice.checked || (!!candidate && !candidate.inLibrary));
- return choice.checked === next ? choice : {...choice, checked: next};
- }));
- 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) return;
- if (!response.ok) {
- toast.error(t('IMPORT.SAVE_FAILED'));
- return;
- }
- onSaved();
- } catch (err) {
- console.error('Import save error:', err);
- toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
- } finally {
- setBusy(false);
- }
- };
-
- return (
-
-
-
{t('IMPORT.REVIEW_HINT')}
-
-
-
-
-
-
-
-
- {choices.map((choice, index) => (
-
- ))}
-
-
- );
-}
-
-const ReviewRow = memo(function ReviewRow({index, row, choice, onChange: changeLine, onPick: pickLine}) {
- const {t} = useTranslation();
- const onChange = (changes) => changeLine(index, changes);
- const onPick = (candidate) => pickLine(index, candidate);
- const [searching, setSearching] = useState(false);
- const [query, setQuery] = useState(row.title ?? '');
- const candidate = choice.candidates.find((known) => known.igdbId === choice.igdbId);
- const label = `${t('IMPORT.LINE', {line: row.lineNo})}: ${row.title ?? ''}`;
-
- return (
-
-
- onChange({checked: e.target.checked})}/>
- {label}
- {candidate?.inLibrary && {t('MANUAL_GAME.IN_LIBRARY')}}
- {row.searchFailed && {t('IMPORT.SEARCH_FAILED')}}
-
-
- {searching && (
- !hit.igdbId} onPick={(hit) => {
- onPick(hit);
- setSearching(false);
- }}/>
- )}
- {choice.candidates.length === 0 ? (
- {t('IMPORT.NO_MATCH')}
- ) : (
-
-
- {candidate?.platforms.length > 0 ? (
-
- ) : (
- onChange({platform: e.target.value})}/>
- )}
-
- onChange({hours: e.target.value})}/>
-
- )}
-
- );
-});
-
-function History({imports, onOpen, onChanged}) {
- 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) return;
- if (!response.ok) {
- toast.error(t('IMPORT.UNDO_FAILED'));
- return;
- }
- await onOk(response);
- } catch (err) {
- console.error('Import undo error:', err);
- toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
- } finally {
- setBusy(false);
- }
- };
- const askUndo = (id) => run(() => checkUndo(id), async (response) => setConfirm({id, ...await response.json()}));
- const undo = () => run(() => undoImport(confirm.id), () => {
- toast.success(t('IMPORT.UNDONE', {count: confirm.games}));
- setConfirm(null);
- onChanged();
- });
-
- return (
-
- {t('IMPORT.HISTORY')}
-
-
- );
-}
diff --git a/src/pages/ImportPage.test.jsx b/src/pages/ImportPage.test.jsx
index 27bceac..5670a01 100644
--- a/src/pages/ImportPage.test.jsx
+++ b/src/pages/ImportPage.test.jsx
@@ -3,7 +3,8 @@ 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 ImportPage from './ImportPage.jsx';
+import {initialChoice, stepOf} from '../utils/imports.js';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
diff --git a/src/utils/imports.js b/src/utils/imports.js
index 2365e23..d9ba06f 100644
--- a/src/utils/imports.js
+++ b/src/utils/imports.js
@@ -1,7 +1,8 @@
import {apiFetch} from './api.js';
import {parseStatus} from './backlog.js';
import {MAX_IMPORT_TITLE_LENGTH, MAX_PLATFORM_LENGTH, MAX_PLAYTIME_MINUTES} from './limits.js';
-import {hoursToMinutes} from './manualGames.js';
+import {hoursToMinutes, toHours} from './manualGames.js';
+import {preselectPlatform} from './platforms.js';
const IMPORTS_URL = () => `${import.meta.env.VITE_CORE_API_URL}/api/library/imports`;
const JSON_HEADERS = {'Content-Type': 'application/json'};
@@ -108,3 +109,39 @@ function csvRecords(text, delimiter) {
}
return records;
}
+
+// Statuses a worker still has: the page polls while it shows one.
+export const RUNNING = ['QUEUED', 'MATCHING', 'SAVING'];
+// The steps an import goes through, as shown to the user.
+export 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 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 !== '',
+ };
+};