Public Access
refactor(import): move the import parts to components/import
This commit is contained in:
1 parent
28ff6eae52
commit
aa5832225d
8 files changed
+520
-481
No files matched your search
@@ -153,9 +153,10 @@ flowchart LR
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
src/
|
src/
|
||||||
├── pages/ # One component per route (Dashboard, GameLibrary, GameDetail, ...)
|
├── pages/ # One component per route (Dashboard, GameLibrary, GameDetail, ...): loads data, places cards
|
||||||
├── components/ # Shared components; ui/ holds the base components
|
├── components/ # Cards and shared components; ui/ holds the base components, import/ and stats/ the parts
|
||||||
├── hooks/ # Data hooks (library sync, Steam profile, appearance, session expiry)
|
│ # of one page that share page-only code
|
||||||
|
├── hooks/ # Data hooks (game, dashboard, library sync, Steam profile, appearance, session)
|
||||||
├── context/ # PreferencesContext: theme, language, appearance
|
├── context/ # PreferencesContext: theme, language, appearance
|
||||||
├── utils/ # Validation schemas and helpers
|
├── utils/ # Validation schemas and helpers
|
||||||
└── test/ # Test setup (i18n mock)
|
└── test/ # Test setup (i18n mock)
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Card className="gap-0 p-0">
|
||||||
|
<h2 className="border-b px-5 py-3 font-semibold">{t('IMPORT.HISTORY')}</h2>
|
||||||
|
<ul className="divide-y">
|
||||||
|
{imports.map((entry) => (
|
||||||
|
<li key={entry.id} className="flex flex-wrap items-center gap-3 px-5 py-3 text-sm">
|
||||||
|
<span className="tabular-nums text-muted-foreground">
|
||||||
|
{new Date(entry.createdAt).toLocaleDateString(i18n.language)}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1">
|
||||||
|
{t(`IMPORT.SOURCE_${entry.source}`)} · {t('IMPORT.LINES', {count: entry.total})}
|
||||||
|
</span>
|
||||||
|
<Badge variant={entry.status === 'FAILED' ? 'destructive' : 'outline'}>{t(`IMPORT.STATUS.${entry.status}`)}</Badge>
|
||||||
|
{entry.status !== 'SAVED' && entry.status !== 'UNDONE' && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => onOpen(entry.id)}>{t('IMPORT.OPEN_IMPORT')}</Button>
|
||||||
|
)}
|
||||||
|
{(entry.status === 'SAVED' || (entry.status === 'FAILED' && entry.added > 0))
|
||||||
|
&& confirm?.id !== entry.id && (
|
||||||
|
<Button variant="outline" size="sm" disabled={busy} onClick={() => askUndo(entry.id)}>
|
||||||
|
<RotateCcw/>{t('IMPORT.UNDO')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{confirm?.id === entry.id && (
|
||||||
|
<div className="flex w-full flex-wrap items-center gap-2">
|
||||||
|
<span>{t('IMPORT.UNDO_CONFIRM', {count: confirm.games, changed: confirm.changed})}</span>
|
||||||
|
<Button variant="destructive" size="sm" disabled={busy} onClick={undo}>{t('IMPORT.UNDO_YES')}</Button>
|
||||||
|
<Button variant="ghost" size="sm" disabled={busy} onClick={() => setConfirm(null)}>
|
||||||
|
{t('MANUAL_GAME.REMOVE_NO')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<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 flex-wrap gap-2">
|
||||||
|
<Button variant="outline" onClick={() => checkAll(true)} disabled={busy}>{t('IMPORT.SELECT_ALL')}</Button>
|
||||||
|
<Button variant="outline" onClick={() => checkAll(false)} disabled={busy}>{t('IMPORT.SELECT_NONE')}</Button>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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 <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' ? (
|
||||||
|
<ImportReview data={data} favorite={favorite} onSaved={reload} onDone={onDone}/>
|
||||||
|
) : (
|
||||||
|
<ImportProgress data={data} onRetried={reload} onDone={onDone}/>
|
||||||
|
)}
|
||||||
|
</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}) {
|
||||||
|
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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+7
-476
@@ -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 {Link, useSearchParams} from 'react-router-dom';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {toast} from 'sonner';
|
import {ArrowLeft, Loader2, Upload} from 'lucide-react';
|
||||||
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 {Button} from '@/components/ui/button';
|
||||||
import {Card} from '@/components/ui/card';
|
import ImportHistory from '../components/import/ImportHistory.jsx';
|
||||||
import {Input} from '@/components/ui/input';
|
import ImportInput from '../components/import/ImportInput.jsx';
|
||||||
import {Textarea} from '@/components/ui/textarea';
|
import ImportView from '../components/import/ImportView.jsx';
|
||||||
import {cn} from '@/lib/utils';
|
|
||||||
import GameSearch from '../components/GameSearch.jsx';
|
|
||||||
import PageContainer from '../components/PageContainer.jsx';
|
import PageContainer from '../components/PageContainer.jsx';
|
||||||
import useStartImport from '../hooks/useStartImport.js';
|
import {listImports, RUNNING} from '../utils/imports.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';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Many games at once: a list or a CSV goes to Core, which matches it with IGDB in the background; the user checks
|
* 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();
|
loadImports();
|
||||||
}}/>
|
}}/>
|
||||||
)}
|
)}
|
||||||
{imports?.length > 0 && <History imports={imports} onOpen={show} onChanged={loadImports}/>}
|
{imports?.length > 0 && <ImportHistory imports={imports} onOpen={show} onChanged={loadImports}/>}
|
||||||
</PageContainer>
|
</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}) {
|
|
||||||
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 <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}/>
|
|
||||||
) : (
|
|
||||||
<ImportProgress data={data} onRetried={reload} onDone={onDone}/>
|
|
||||||
)}
|
|
||||||
</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}) {
|
|
||||||
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 (
|
|
||||||
<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}) {
|
|
||||||
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 (
|
|
||||||
<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 flex-wrap gap-2">
|
|
||||||
<Button variant="outline" onClick={() => checkAll(true)} disabled={busy}>{t('IMPORT.SELECT_ALL')}</Button>
|
|
||||||
<Button variant="outline" onClick={() => checkAll(false)} disabled={busy}>{t('IMPORT.SELECT_NONE')}</Button>
|
|
||||||
<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}) {
|
|
||||||
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 (
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 {cleanup, fireEvent, render, screen, waitFor, within} from '@testing-library/react';
|
||||||
import {MemoryRouter, Route, Routes} from 'react-router-dom';
|
import {MemoryRouter, Route, Routes} from 'react-router-dom';
|
||||||
import {toast} from 'sonner';
|
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 {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||||
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
|
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
|
||||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
|||||||
+38
-1
@@ -1,7 +1,8 @@
|
|||||||
import {apiFetch} from './api.js';
|
import {apiFetch} from './api.js';
|
||||||
import {parseStatus} from './backlog.js';
|
import {parseStatus} from './backlog.js';
|
||||||
import {MAX_IMPORT_TITLE_LENGTH, MAX_PLATFORM_LENGTH, MAX_PLAYTIME_MINUTES} from './limits.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 IMPORTS_URL = () => `${import.meta.env.VITE_CORE_API_URL}/api/library/imports`;
|
||||||
const JSON_HEADERS = {'Content-Type': 'application/json'};
|
const JSON_HEADERS = {'Content-Type': 'application/json'};
|
||||||
@@ -108,3 +109,39 @@ function csvRecords(text, delimiter) {
|
|||||||
}
|
}
|
||||||
return records;
|
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 !== '',
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in new issue
Block a user