diff --git a/README.md b/README.md index 81eb4fb..f224c0c 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,17 @@ To Be Played is split into three repositories: - **Library**: status filters, "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest in library, rating or priority, paging. Each row shows the unlocked achievements. - **Add a game by name** for games outside Steam (PlayStation, Switch, Xbox, physical): search IGDB, pick a hit, - a platform and optional hours. Works without a Steam ID. Games already in ToBePlayed come first. + a platform and optional hours. Works without a Steam ID. Games already in ToBePlayed come first. Check several + hits to add them at once through the import review. +- **Import** (`/import`): a list (one title per line) or a CSV (title, platform, hours or minutes, status). Core + matches the lines with IGDB and saves them in the background; the page shows the steps (queued with the imports + before yours, searching on IGDB, your review, adding to the library, time to beat and moods, done) with the + progress, and "Try again" for an import that stopped. The review shows the best match, other candidates and + "search again", with the platform and status per line. Only sure matches are checked. Earlier imports can be + undone. +- **Platform everywhere**: each game shows its platform ("Steam" for Steam games). The favorite platform from the + settings is preselected when a game is on it; otherwise a game on one platform gets that one, and a game on + several waits for the user's pick. - **Game detail**: status, priority, 1–5 star rating, notes, achievements, time to beat (story / extras / 100 %), a link to the game's speedrun.com page when it has verified runs, a hint when Steam shows recent playtime for a game still in the backlog, and one to set the status to 100 % when diff --git a/public/i18n/de.json b/public/i18n/de.json index 6d337be..b51326d 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -125,7 +125,10 @@ "TOO_MANY": "Zu viele falsche Passwörter. Bitte in {{minutes}} Minuten erneut versuchen.", "ERROR": "Das Konto konnte nicht gelöscht werden. Bitte später erneut versuchen." }, - "PROFILE_TILES_HINT": "Wähle, was dein Profil zeigt und wo: öffne dein Profil und klicke auf \"Anpassen\"." + "PROFILE_TILES_HINT": "Wähle, was dein Profil zeigt und wo: öffne dein Profil und klicke auf \"Anpassen\".", + "FAVORITE_PLATFORM": "Lieblingsplattform", + "FAVORITE_PLATFORM_HINT": "Wird zuerst gewählt, wenn du ein Spiel für mehrere Plattformen hinzufügst.", + "FAVORITE_PLATFORM_NONE": "Keine" }, "VALIDATION": { "EMAIL_REQUIRED": "Email ist erforderlich.", @@ -403,7 +406,10 @@ "REMOVE_YES": "Ja, entfernen", "REMOVE_NO": "Abbrechen", "REMOVED": "Spiel entfernt.", - "NO_ACHIEVEMENTS": "Erfolge gibt es nur für Steam-Spiele." + "NO_ACHIEVEMENTS": "Erfolge gibt es nur für Steam-Spiele.", + "SELECT": "{{name}} auswählen", + "SELECTED": "{{count}} ausgewählt", + "ADD_SELECTED": "Prüfen und hinzufügen" }, "STATS": { "TITLE": "Deine Statistik", @@ -621,5 +627,81 @@ "SIZE_L": "Groß", "SUMMARY": "Summen", "STATUS": "Statusleiste" + }, + "IMPORT": { + "OPEN": "Importieren", + "TITLE": "Spiele importieren", + "BACK": "Bibliothek", + "HINT": "Füge viele Spiele auf einmal hinzu, z. B. aus einer Liste oder Tabelle. Wir suchen jede Zeile bei IGDB; du prüfst die Treffer, bevor etwas gespeichert wird.", + "SOURCE": "Eingabe", + "SOURCE_TEXT": "Liste", + "SOURCE_CSV": "CSV-Datei", + "SOURCE_SEARCH": "Suche", + "TEXT_LABEL": "Spieltitel", + "TEXT_PLACEHOLDER": "Ein Spiel pro Zeile", + "CSV_LABEL": "CSV-Datei", + "CSV_HINT": "Erste Zeile mit Spaltennamen: title (oder name), platform, hours (oder minutes), status.", + "CSV_NO_TITLE": "Die Datei hat keine Spalte title oder name.", + "LINES": "{{count}} Zeilen", + "TOO_MANY": "{{count}} Zeilen: höchstens {{max}} pro Import.", + "START": "Spiele suchen", + "START_FAILED": "Der Import konnte nicht starten.", + "RUNNING": "Ein anderer Import wird noch gesucht. Warte, bis er fertig ist.", + "LOAD_FAILED": "Der Import konnte nicht geladen werden.", + "MATCHING": "Spiele werden gesucht: {{matched}} / {{total}}", + "MATCHING_HINT": "Du kannst die Seite verlassen und später wiederkommen.", + "REVIEW_HINT": "Hake die Spiele an, die du hinzufügen willst. Nur sichere Treffer sind schon angehakt.", + "SAVE": "{{count}} Spiele hinzufügen", + "SAVE_FAILED": "Der Import konnte nicht gespeichert werden.", + "NEW": "Neuer Import", + "LINE": "Zeile {{line}}", + "SEARCH_FAILED": "Suche fehlgeschlagen", + "SEARCH_AGAIN": "Neu suchen", + "NO_MATCH": "Kein Spiel gefunden. Suche neu, um eins zu wählen.", + "MATCH": "Spiel für Zeile {{line}}", + "PLATFORM": "Plattform für Zeile {{line}}", + "PICK_PLATFORM": "Plattform wählen", + "STATUS_LABEL": "Status für Zeile {{line}}", + "HOURS": "Stunden für Zeile {{line}}", + "HISTORY": "Frühere Importe", + "OPEN_IMPORT": "Öffnen", + "UNDO": "Rückgängig", + "UNDO_CONFIRM": "Die {{count}} Spiele dieses Imports entfernen? {{changed}} davon hast du seitdem geändert.", + "UNDO_YES": "Ja, entfernen", + "UNDONE": "{{count}} Spiele entfernt.", + "UNDO_FAILED": "Der Import konnte nicht rückgängig gemacht werden.", + "STATUS": { + "MATCHING": "Wird gesucht", + "READY": "Zu prüfen", + "SAVED": "Gespeichert", + "UNDONE": "Rückgängig gemacht", + "QUEUED": "Wartet", + "SAVING": "Wird gespeichert", + "FAILED": "Fehlgeschlagen" + }, + "LINES_one": "{{count}} Zeile", + "SAVE_one": "{{count}} Spiel hinzufügen", + "UNDONE_one": "{{count}} Spiel entfernt.", + "UNDO_CONFIRM_one": "Das {{count}} Spiel dieses Imports entfernen? {{changed}} seitdem geändert.", + "STEPS": "Schritte des Imports", + "STEP": { + "QUEUED": "In der Warteschlange", + "MATCHING": "Suche bei IGDB", + "REVIEW": "Deine Prüfung", + "SAVING": "In die Bibliothek", + "DETAILS": "Spielzeit und Stimmungen", + "DONE": "Fertig" + }, + "WAITING_one": "Wartet: {{count}} Import vor deinem.", + "WAITING": "Wartet: {{count}} Importe vor deinem.", + "WAITING_NEXT": "Startet gleich.", + "SAVING": "Wird in deine Bibliothek übernommen: {{saved}} / {{chosen}}", + "DETAILS": "Spielzeit und Stimmungen werden geladen.", + "DONE": "Fertig: {{added}} Spiele hinzugefügt, {{skipped}} übersprungen.", + "FAILED_one": "Der Import ist nach {{count}} Versuch stehen geblieben.", + "FAILED": "Der Import ist nach {{count}} Versuchen stehen geblieben.", + "RETRY": "Nochmal versuchen", + "RETRY_FAILED": "Der Import konnte nicht neu gestartet werden.", + "ATTEMPT": "Versuch {{attempt}}: der letzte Versuch kam nicht weiter." } } diff --git a/public/i18n/en.json b/public/i18n/en.json index 9ecfa2a..7583d9a 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -125,7 +125,10 @@ "TOO_MANY": "Too many wrong passwords. Please try again in {{minutes}} minutes.", "ERROR": "The account could not be deleted. Please try again later." }, - "PROFILE_TILES_HINT": "Choose what your profile shows, and where: open your profile and click \"Customize\"." + "PROFILE_TILES_HINT": "Choose what your profile shows, and where: open your profile and click \"Customize\".", + "FAVORITE_PLATFORM": "Favorite platform", + "FAVORITE_PLATFORM_HINT": "Selected first when you add a game on more than one platform.", + "FAVORITE_PLATFORM_NONE": "None" }, "VALIDATION": { "EMAIL_REQUIRED": "Email is required.", @@ -403,7 +406,10 @@ "REMOVE_YES": "Yes, remove", "REMOVE_NO": "Cancel", "REMOVED": "Game removed.", - "NO_ACHIEVEMENTS": "Achievements exist only for Steam games." + "NO_ACHIEVEMENTS": "Achievements exist only for Steam games.", + "SELECT": "Select {{name}}", + "SELECTED": "{{count}} selected", + "ADD_SELECTED": "Check and add" }, "STATS": { "TITLE": "Your stats", @@ -621,5 +627,81 @@ "SIZE_L": "Large", "SUMMARY": "Totals", "STATUS": "Status bar" + }, + "IMPORT": { + "OPEN": "Import", + "TITLE": "Import games", + "BACK": "Library", + "HINT": "Add many games at once, e.g. from a list or a spreadsheet. We search each line on IGDB; you check the matches before anything is saved.", + "SOURCE": "Input", + "SOURCE_TEXT": "List", + "SOURCE_CSV": "CSV file", + "SOURCE_SEARCH": "Search", + "TEXT_LABEL": "Game titles", + "TEXT_PLACEHOLDER": "One game per line", + "CSV_LABEL": "CSV file", + "CSV_HINT": "First row with column names: title (or name), platform, hours (or minutes), status.", + "CSV_NO_TITLE": "The file has no title or name column.", + "LINES": "{{count}} lines", + "TOO_MANY": "{{count}} lines: at most {{max}} per import.", + "START": "Search games", + "START_FAILED": "Could not start the import.", + "RUNNING": "Another import is still being matched. Wait until it is done.", + "LOAD_FAILED": "Could not load the import.", + "MATCHING": "Searching games: {{matched}} / {{total}}", + "MATCHING_HINT": "You can leave this page and come back later.", + "REVIEW_HINT": "Check the games to add. Only sure matches are checked already.", + "SAVE": "Add {{count}} games", + "SAVE_FAILED": "Could not save the import.", + "NEW": "New import", + "LINE": "Line {{line}}", + "SEARCH_FAILED": "Search failed", + "SEARCH_AGAIN": "Search again", + "NO_MATCH": "No game found. Search again to pick one.", + "MATCH": "Game for line {{line}}", + "PLATFORM": "Platform for line {{line}}", + "PICK_PLATFORM": "Pick a platform", + "STATUS_LABEL": "Status for line {{line}}", + "HOURS": "Hours for line {{line}}", + "HISTORY": "Earlier imports", + "OPEN_IMPORT": "Open", + "UNDO": "Undo", + "UNDO_CONFIRM": "Remove the {{count}} games of this import? {{changed}} of them you changed since.", + "UNDO_YES": "Yes, remove", + "UNDONE": "{{count}} games removed.", + "UNDO_FAILED": "Could not undo the import.", + "STATUS": { + "MATCHING": "Searching", + "READY": "To check", + "SAVED": "Saved", + "UNDONE": "Undone", + "QUEUED": "Queued", + "SAVING": "Saving", + "FAILED": "Failed" + }, + "LINES_one": "{{count}} line", + "SAVE_one": "Add {{count}} game", + "UNDONE_one": "{{count}} game removed.", + "UNDO_CONFIRM_one": "Remove the {{count}} game of this import? {{changed}} changed since.", + "STEPS": "Import steps", + "STEP": { + "QUEUED": "Queued", + "MATCHING": "Searching on IGDB", + "REVIEW": "Your review", + "SAVING": "Adding to library", + "DETAILS": "Time to beat and moods", + "DONE": "Done" + }, + "WAITING_one": "Waiting: {{count}} import before yours.", + "WAITING": "Waiting: {{count}} imports before yours.", + "WAITING_NEXT": "Starts in a moment.", + "SAVING": "Adding to your library: {{saved}} / {{chosen}}", + "DETAILS": "Loading time to beat and moods.", + "DONE": "Done: {{added}} games added, {{skipped}} skipped.", + "FAILED_one": "The import stopped after {{count}} try.", + "FAILED": "The import stopped after {{count}} tries.", + "RETRY": "Try again", + "RETRY_FAILED": "Could not start the import again.", + "ATTEMPT": "Try {{attempt}}: the last try made no progress." } } diff --git a/src/App.jsx b/src/App.jsx index 088f810..7fd0b1b 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -15,6 +15,7 @@ import GameDetail from './pages/GameDetail.jsx'; import WishlistPage from './pages/WishlistPage.jsx'; import PlayNextPage from './pages/PlayNextPage.jsx'; import StatsPage from './pages/StatsPage.jsx'; +import ImportPage from './pages/ImportPage.jsx'; import PublicProfilePage from './pages/PublicProfilePage.jsx'; import AppHeader from './components/AppHeader.jsx'; import {Toaster} from '@/components/ui/sonner'; @@ -72,6 +73,7 @@ function App() { }/> }/> }/> + }/> }/> }/> }/> diff --git a/src/components/AddGamePanel.jsx b/src/components/AddGamePanel.jsx index eb46f63..9755343 100644 --- a/src/components/AddGamePanel.jsx +++ b/src/components/AddGamePanel.jsx @@ -1,4 +1,4 @@ -import {useState} from 'react'; +import {useEffect, useState} from 'react'; import {useNavigate} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; import {toast} from 'sonner'; @@ -12,11 +12,14 @@ import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/c import GameCover from './GameCover.jsx'; import GameSearch from './GameSearch.jsx'; import useSessionExpired from '../hooks/useSessionExpired.js'; +import useStartImport from '../hooks/useStartImport.js'; import {GAME_STATUSES} from '../utils/backlog.js'; import {addGame, hoursToMinutes} from '../utils/manualGames.js'; +import {loadFavoritePlatform, preselectPlatform} from '../utils/platforms.js'; /** - * Search IGDB and add a game the user owns outside Steam, e.g. on PlayStation or Switch. + * Search IGDB and add a game the user owns outside Steam, e.g. on PlayStation or Switch; or check several and review + * them on the import page. */ export default function AddGamePanel({onClose}) { const {t} = useTranslation(); @@ -24,6 +27,29 @@ export default function AddGamePanel({onClose}) { const expireSession = useSessionExpired(); const [query, setQuery] = useState(''); const [picked, setPicked] = useState(null); + // igdbId -> hit, over several searches. + const [selected, setSelected] = useState(() => new Map()); + const [busy, setBusy] = useState(false); + const [favorite, setFavorite] = useState(null); + useEffect(() => { + loadFavoritePlatform().then(setFavorite); + }, []); + const startImport = useStartImport(); + + const toggle = (hit) => { + const next = new Map(selected); + if (!next.delete(hit.igdbId)) next.set(hit.igdbId, hit); + setSelected(next); + }; + + // Several games go through a SEARCH import: its review picks the platforms, and it can be undone. + const reviewSelected = async () => { + setBusy(true); + const id = await startImport('SEARCH', [...selected.values()] + .map((hit) => ({igdbId: hit.igdbId, title: hit.name.slice(0, 100)}))); + setBusy(false); + if (id) navigate(`/import?id=${id}`); + }; return ( @@ -38,20 +64,34 @@ export default function AddGamePanel({onClose}) { {picked ? ( - setPicked(null)} navigate={navigate} expireSession={expireSession}/> + setPicked(null)} navigate={navigate} + expireSession={expireSession}/> ) : ( <>

{t('MANUAL_GAME.ADD_HINT')}

- + + {selected.size > 0 && ( +
+ {t('MANUAL_GAME.SELECTED', {count: selected.size})} + +
+ )} )}
); } -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}) { {hit.platforms.length > 0 ? ( onToggle(hit)}/> + )} + - - ))} + {blocked && {t('MANUAL_GAME.IN_LIBRARY')}} + + + ); + })} )} diff --git a/src/components/ManualGameCard.jsx b/src/components/ManualGameCard.jsx index b3e7651..37a124d 100644 --- a/src/components/ManualGameCard.jsx +++ b/src/components/ManualGameCard.jsx @@ -9,10 +9,7 @@ import {Card} from '@/components/ui/card'; import {Input} from '@/components/ui/input'; import {Label} from '@/components/ui/label'; import useSessionExpired from '../hooks/useSessionExpired.js'; -import {hoursToMinutes, removeGame, updateManualGame} from '../utils/manualGames.js'; - -// Two decimals: then hoursToMinutes gives back the same minute, so an unchanged form is not dirty. -const toHours = (minutes) => (minutes > 0 ? String(Math.round((minutes / 60) * 100) / 100) : ''); +import {hoursToMinutes, removeGame, toHours, updateManualGame} from '../utils/manualGames.js'; /** * Platform and hours of a game added by hand, and removing it. Steam games get both from the sync. diff --git a/src/hooks/useStartImport.js b/src/hooks/useStartImport.js new file mode 100644 index 0000000..b4f8221 --- /dev/null +++ b/src/hooks/useStartImport.js @@ -0,0 +1,35 @@ +import {useCallback} from 'react'; +import {useTranslation} from 'react-i18next'; +import {toast} from 'sonner'; +import useSessionExpired from './useSessionExpired.js'; +import {createImport} from '../utils/imports.js'; + +/** + * Returns `start(source, rows)`: it creates an import in Core and resolves to its id, or shows why not (or ends the + * session on a 401) and resolves to null. + */ +export default function useStartImport() { + const {t} = useTranslation(); + const expireSession = useSessionExpired(); + + return useCallback(async (source, rows) => { + try { + const response = await createImport(source, rows); + if (response.status === 401) { + expireSession(); + } else if (response.status === 409) { + toast.error(t('IMPORT.RUNNING')); + } else if (response.status === 503) { + toast.error(t('MANUAL_GAME.UNAVAILABLE')); + } else if (!response.ok) { + toast.error(t('IMPORT.START_FAILED')); + } else { + return (await response.json()).id; + } + } catch (err) { + console.error('Import start error:', err); + toast.error(t('INFO.ERROR.NETWORK_FAILURE')); + } + return null; + }, [expireSession, t]); +} diff --git a/src/pages/GameDetail.jsx b/src/pages/GameDetail.jsx index 0bea6c4..4931b27 100644 --- a/src/pages/GameDetail.jsx +++ b/src/pages/GameDetail.jsx @@ -34,6 +34,7 @@ import {celebrationFor, celebrationLevel} from '../utils/confetti.js'; import {setGameHidden} from '../utils/hiddenGames.js'; import {hasSession} from '../utils/auth.js'; import {isSteamKey} from '../utils/manualGames.js'; +import {platformLabel} from '../utils/platforms.js'; const SPEEDRUN_PREFIX = 'https://www.speedrun.com/'; @@ -221,8 +222,8 @@ export default function GameDetail() { {t('GAME_DETAIL.PLAYTIME', {time: formatPlaytime(data.playtimeMinutes)})} - {data.platform && ( - {data.platform} + {platformLabel(data) && ( + {platformLabel(data)} )}
diff --git a/src/pages/GameLibrary.jsx b/src/pages/GameLibrary.jsx index 1731134..23563ee 100644 --- a/src/pages/GameLibrary.jsx +++ b/src/pages/GameLibrary.jsx @@ -7,7 +7,7 @@ import useForceSync from '../hooks/useForceSync.js'; import useSessionExpired from '../hooks/useSessionExpired.js'; import {formatPlaytime} from '../utils/games.js'; import {GAME_STATUSES, libraryQuery, parseStatus} from '../utils/backlog.js'; -import {ArrowUpDown, ChevronLeft, ChevronRight, Clock, EyeOff, Gamepad2, Library, Plus, RefreshCw, Search, SearchX, Timer, Trophy} from 'lucide-react'; +import {ArrowUpDown, ChevronLeft, ChevronRight, Clock, EyeOff, Gamepad2, Library, Plus, RefreshCw, Search, SearchX, Timer, Trophy, Upload} from 'lucide-react'; import {Badge} from '@/components/ui/badge'; import {Button} from '@/components/ui/button'; @@ -22,6 +22,7 @@ import GameStatusBadge from '../components/GameStatusBadge.jsx'; import PageContainer from '../components/PageContainer.jsx'; import {apiFetch} from '../utils/api.js'; import {hasSession} from '../utils/auth.js'; +import {platformLabel} from '../utils/platforms.js'; const PAGE_SIZES = [20, 40, 80]; const DEFAULT_SIZE = 20; @@ -195,6 +196,9 @@ export default function GameLibrary() { +
@@ -309,9 +313,9 @@ export default function GameLibrary() { {g.name ?? `App ${g.appid}`} - {g.platform && ( - - {g.platform} + {platformLabel(g) && ( + + {platformLabel(g)} )} diff --git a/src/pages/GameLibrary.test.jsx b/src/pages/GameLibrary.test.jsx index 5c01e64..98f2ac1 100644 --- a/src/pages/GameLibrary.test.jsx +++ b/src/pages/GameLibrary.test.jsx @@ -184,6 +184,15 @@ describe('GameLibrary with games added by hand', () => { expect(document.querySelector('img[src="https://images.igdb.com/z.jpg"]')).toBeTruthy(); }); + it('shows Steam as the platform of a Steam game, and links the import', async () => { + globalThis.fetch = answer([{appid: 220, name: 'Half-Life 2', playtimeMinutes: 0, status: 'BACKLOG', source: 'STEAM', + platform: null}], true); + renderLibrary(); + + expect(await screen.findByText('Steam')).toBeTruthy(); + expect(screen.getByRole('link', {name: 'IMPORT.OPEN'}).getAttribute('href')).toBe('/import'); + }); + it('hides the Steam sync for a user without a Steam ID but still offers adding a game', async () => { globalThis.fetch = answer([zelda], false); renderLibrary(); diff --git a/src/pages/ImportPage.jsx b/src/pages/ImportPage.jsx new file mode 100644 index 0000000..29f35c0 --- /dev/null +++ b/src/pages/ImportPage.jsx @@ -0,0 +1,554 @@ +import {memo, useCallback, useEffect, useRef, useState} from 'react'; +import {Link, useNavigate, useSearchParams} from 'react-router-dom'; +import {useTranslation} from 'react-i18next'; +import {toast} from 'sonner'; +import {ArrowLeft, CheckCircle2, Circle, Loader2, RotateCcw, Search, Upload, XCircle} from 'lucide-react'; + +import {Badge} from '@/components/ui/badge'; +import {Button} from '@/components/ui/button'; +import {Card} from '@/components/ui/card'; +import {Input} from '@/components/ui/input'; +import {Textarea} from '@/components/ui/textarea'; +import {cn} from '@/lib/utils'; +import GameSearch from '../components/GameSearch.jsx'; +import PageContainer from '../components/PageContainer.jsx'; +import useSessionExpired from '../hooks/useSessionExpired.js'; +import useStartImport from '../hooks/useStartImport.js'; +import {hasSession} from '../utils/auth.js'; +import {GAME_STATUSES} from '../utils/backlog.js'; +import { + checkUndo, getImport, listImports, MAX_IMPORT_ROWS, parseCsv, parseTextList, retryImport, saveImport, undoImport, +} from '../utils/imports.js'; +import {hoursToMinutes, MAX_PLATFORM_LENGTH, toHours} from '../utils/manualGames.js'; +import {loadFavoritePlatform, preselectPlatform} from '../utils/platforms.js'; + +const POLL_MS = 2000; +// Statuses a worker still has: the page polls while it shows one. +const RUNNING = ['QUEUED', 'MATCHING', 'SAVING']; +// The steps an import goes through, as shown to the user. +const STEPS = ['QUEUED', 'MATCHING', 'REVIEW', 'SAVING', 'DETAILS', 'DONE']; + +/** + * The step an import is in; a FAILED one is in the step it stopped in. + */ +export const stepOf = (data) => { + switch (data.status) { + case 'QUEUED': return 'QUEUED'; + case 'MATCHING': return 'MATCHING'; + case 'READY': return 'REVIEW'; + case 'SAVING': return data.step === 'DETAILS' ? 'DETAILS' : 'SAVING'; + case 'FAILED': return data.chosen > 0 ? 'SAVING' : 'MATCHING'; + default: return 'DONE'; + } +}; +// The native select: a review of 1000 lines would mount 3000 Radix selects. +const SELECT_CLASS = 'h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-2 text-sm dark:bg-input/30'; + +/** + * Many games at once: a list or a CSV goes to Core, which matches it with IGDB in the background; the user checks + * the matches and saves them. ?id= opens one import; the history below can undo a saved import. + */ +export default function ImportPage() { + const {t} = useTranslation(); + const navigate = useNavigate(); + const expireSession = useSessionExpired(); + const [searchParams, setSearchParams] = useSearchParams(); + const [imports, setImports] = useState(null); + const id = searchParams.get('id'); + + const loadImports = useCallback(async () => { + try { + const response = await listImports(); + if (response.status === 401) { + expireSession(); + return; + } + setImports(response.ok ? (await response.json()).imports : []); + } catch (err) { + console.error('Import list error:', err); + setImports([]); + } + }, [expireSession]); + + useEffect(() => { + if (!hasSession()) { + navigate('/login'); + return; + } + loadImports(); + }, [navigate, loadImports]); + + // Without ?id=, an import a worker still has is shown first; one to review or failed is in the history. + const open = imports?.find((entry) => RUNNING.includes(entry.status)); + const show = useCallback((next) => setSearchParams(next ? {id: String(next)} : {}, {replace: !id}), + [setSearchParams, id]); + // Into the URL, so the import stays shown when it leaves the worker (review, done). + useEffect(() => { + if (!id && open) show(open.id); + }, [id, open, show]); + const shownId = id ?? (open ? String(open.id) : null); + const done = () => { + show(null); + loadImports(); + }; + + return ( + +
+

{t('IMPORT.TITLE')}

+ +
+ {imports === null ? ( + + ) : shownId ? ( + + ) : ( + { + show(created); + loadImports(); + }}/> + )} + {imports?.length > 0 && } +
+ ); +} + +function ImportInput({onCreated}) { + const {t} = useTranslation(); + const [source, setSource] = useState('TEXT'); + const [text, setText] = useState(''); + const [csv, setCsv] = useState(null); + const [busy, setBusy] = useState(false); + const rows = source === 'TEXT' ? parseTextList(text) : csv?.rows ?? []; + const tooMany = rows.length > MAX_IMPORT_ROWS; + + const readFile = async (file) => setCsv(file ? parseCsv(await file.text()) : null); + + const startImport = useStartImport(); + const start = async () => { + setBusy(true); + const id = await startImport(source, rows); + setBusy(false); + if (id) onCreated(id); + }; + + return ( + +

{t('IMPORT.HINT')}

+
+ {['TEXT', 'CSV'].map((value) => ( + + ))} +
+ {source === 'TEXT' ? ( +