import { useEffect, useMemo, useState, type FC } from "hono/jsx/dom"; import { BOOK_STATUS_MAP } from "../../../constants"; import { StatusSelect, RatingSelect, DeleteButton, BookCover, updateBook as updateBookApi, deleteBook as deleteBookApi, } from "../bookActions"; import type { ImportEvent, ImportRow, BookLoadEvent, BookUploadEvent, BookFailedEvent, ImportErrorEvent, } from "./types"; const STORAGE_KEY = "bookhive_import_results"; // --- Failed row with manual matching --- const FailedRow: FC<{ row: { title: string; author: string; isbn10?: string; isbn13?: string; status?: string; finishedAt?: string; stars?: number; review?: string; reason?: string; }; onResolved: (success: Extract, failedKey: string) => void; }> = ({ row, onResolved }) => { const getReasonText = (reason?: string): string | null => { if (!reason || reason === "no_match") return null; switch (reason) { case "processing_error": return "Processing error"; case "update_error": return "Upload error"; default: return reason; } }; const reasonText = getReasonText(row.reason); const query = encodeURIComponent(`${row.title} ${row.author}`); const [isOpen, setOpen] = useState(false); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const search = async () => { setLoading(true); try { const res = await fetch(`/xrpc/buzz.bookhive.searchBooks?q=${query}&limit=20`); const data = await res.json(); const results = Array.isArray(data) ? data : (data?.books ?? []); if (!results.length) { const res = await fetch( `/xrpc/buzz.bookhive.searchBooks?q=${encodeURIComponent(`${row.title}`)}&limit=20`, ); const data = await res.json(); return setResults(Array.isArray(data) ? data : (data?.books ?? [])); } setResults(results); } catch { setResults([]); } finally { setLoading(false); } }; const resolveImport = async (hiveId: string) => { try { const res = await fetch("/api/update-book", { method: "POST", headers: { "Content-Type": "application/json", accept: "application/json", }, body: JSON.stringify({ hiveId, title: row.title, authors: row.author, status: row.status, finishedAt: row.finishedAt, stars: row.stars, review: row.review, }), }); if (!res.ok) return; const bookRes = await fetch(`/xrpc/buzz.bookhive.searchBooks?id=${hiveId}&limit=1`); const data = await bookRes.json(); const arr = Array.isArray(data) ? data : (data?.books ?? []); const b = arr[0] ?? null; if (b) { onResolved( { success: true, hiveId: b.id, title: b.title, authors: b.authors, coverImage: b.cover ?? b.thumbnail ?? undefined, } as any, `${row.title}::${row.author}`, ); setOpen(false); } else { onResolved( { success: true, hiveId, title: row.title, authors: row.author, } as any, `${row.title}::${row.author}`, ); setOpen(false); } } catch {} }; return (
{row.title}
{row.author}
{reasonText &&
{reasonText}
}
{isOpen && (
{loading ? (
Searching…
) : results.length === 0 ? (
No results
) : (
    {results.map((b) => (
  • {b.thumbnail && ( )}
    {b.title}
    {(b.authors || "").split("\t").join(", ")}
    View
  • ))}
)}
)}
); }; // --- Success row matching EditableLibraryTable layout --- const SuccessRow: FC<{ row: Extract; onUpdate: (next: Partial) => void; onDelete: (hiveId: string) => void; }> = ({ row, onUpdate, onDelete }) => { const { hiveId, title, authors, coverImage, stars, status } = row; return ( (window.location.href = `/books/${hiveId}`)} >

{title}

{authors.split("\t").join(", ")}

e.stopPropagation()}> { onUpdate({ status: nextStatus }); void updateBookApi(hiveId, { status: nextStatus }); }} /> e.stopPropagation()}> { onUpdate({ stars: rating }); void updateBookApi(hiveId, { stars: rating }); }} /> e.stopPropagation()}> { onDelete(hiveId); void deleteBookApi(hiveId); }} /> ); }; // --- Mobile card for success rows --- const SuccessCard: FC<{ row: Extract; onUpdate: (next: Partial) => void; onDelete: (hiveId: string) => void; }> = ({ row }) => { const { hiveId, title, authors, coverImage, stars, status } = row; return (
{title}
{authors.split("\t").join(", ")}
{status && ( {BOOK_STATUS_MAP[status as keyof typeof BOOK_STATUS_MAP] || status} )} {stars && ( {"★".repeat(Math.floor(stars / 2))} {stars % 2 === 1 ? "½" : ""} )}
); }; const normalizeStr = (s: string) => (s || "").normalize("NFKC").toLowerCase().replace(/\s+/g, " ").trim(); // --- Progress display --- type ProgressState = { stage: "starting" | "matching" | "saving" | "complete" | "error"; current: number; total: number; message?: string; bookTitle?: string; bookAuthor?: string; shareText?: string; uploadedCount?: number; }; const ProgressCard: FC<{ progress: ProgressState; onImportMore: () => void }> = ({ progress, onImportMore, }) => { const pct = progress.total > 0 ? Math.min(100, Math.round((progress.current / progress.total) * 100)) : 0; const isComplete = progress.stage === "complete"; const isError = progress.stage === "error"; const stageLabel = { starting: "Starting import...", matching: "Matching books...", saving: "Saving to library...", complete: "Import complete!", error: "Import failed", }[progress.stage]; return (
{stageLabel} {!isComplete && !isError && ( )}
{progress.total > 0 && ( {progress.current}/{progress.total} )} {isComplete && ( )}
{!isComplete && !isError && (
{progress.message && (
{progress.message}
)}
)} {isComplete && progress.message && (

{progress.message}

)} {isComplete && progress.shareText && ( )} {isError && progress.message && (

{progress.message}

)}
); }; // --- Main component --- export const ImportTableApp: FC = () => { const [rows, setRows] = useState([]); const [progress, setProgress] = useState(null); const [errors, setErrors] = useState([]); // load from storage useEffect(() => { try { const raw = localStorage.getItem(STORAGE_KEY); if (raw) setRows(JSON.parse(raw)); } catch {} }, []); // persist useEffect(() => { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(rows)); } catch {} }, [rows]); useEffect(() => { const handler = (e: Event) => { const ce = e as CustomEvent; const data = ce.detail; if (!data) return; switch (data.event) { case "import-start": { try { localStorage.removeItem(STORAGE_KEY); } catch {} setRows([]); setErrors([]); setProgress({ stage: "starting", current: 0, total: 0, message: data.stageProgress?.message as string, }); break; } case "book-load": { const ev = data as BookLoadEvent; const sp = ev.stageProgress; setProgress({ stage: "matching", current: Number(sp?.current ?? 0), total: Number(sp?.total ?? 0), message: sp?.message as string, bookTitle: ev.title, bookAuthor: ev.author, }); break; } case "book-upload": { const ev = data as BookUploadEvent; setProgress((prev) => ({ stage: "saving", current: ev.processed, total: ev.total, message: ev.stageProgress?.message as string, uploadedCount: (prev?.uploadedCount ?? 0) + (ev.book && !ev.book.alreadyExists ? 1 : 0), })); if (ev.book) { setRows((prev) => { const next = prev.some((r) => r.success && r.hiveId === ev.book.hiveId) ? prev : prev.concat([{ success: true, ...ev.book }]); return next; }); } break; } case "book-failed": { const ev = data as BookFailedEvent; setRows((prev) => { const newTitle = normalizeStr(ev.failedBook.title); const newAuthor = normalizeStr(ev.failedBook.author); const exists = prev.some( (r) => !r.success && normalizeStr(r.title) === newTitle && normalizeStr((r as any).author) === newAuthor, ); return exists ? prev : prev.concat([{ success: false, ...ev.failedBook }]); }); break; } case "import-error": { const ev = data as ImportErrorEvent; setErrors((prev) => [...prev, ev.error]); break; } case "import-complete": { const sp = data.stageProgress; setProgress((prev) => ({ stage: "complete", current: Number(sp?.current ?? 0), total: Number(sp?.total ?? 0), message: sp?.message as string, uploadedCount: prev?.uploadedCount ?? 0, shareText: (data as any).shareText, })); break; } } }; window.addEventListener("bookhive:import-event" as any, handler); return () => window.removeEventListener("bookhive:import-event" as any, handler); }, []); const updateRow = (hiveId: string, next: Partial) => { setRows((prev) => prev.map((r) => (r.success && r.hiveId === hiveId ? ({ ...r, ...next } as ImportRow) : r)), ); }; const deleteRow = (hiveId: string) => { setRows((prev) => prev.filter((r) => !(r.success && r.hiveId === hiveId))); }; const successRows = useMemo( () => rows .filter((r) => r.success) .sort((a, b) => (a as any).title.localeCompare((b as any).title, undefined, { sensitivity: "base", }), ), [rows], ); const failedRows = useMemo( () => rows .filter((r) => !r.success) .sort((a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: "base" })), [rows], ); const alreadyCount = (successRows as any[]).filter((r) => r.alreadyExists).length; const onImportMore = () => { try { localStorage.removeItem(STORAGE_KEY); } catch {} window.location.reload(); }; return (
{/* Progress */} {progress && } {/* Errors */} {errors.length > 0 && (
Import errors ({errors.length})
{errors.map((err, i) => (
{err}
))}
)} {/* Summary cards */} {(rows.length > 0 || progress?.stage === "complete") && (
Imported
{successRows.length}
Failed
{failedRows.length}
Already in library
{alreadyCount}
)} {/* Failed imports */} {failedRows.length > 0 && (
Failed imports ({failedRows.length})
{failedRows.map((row) => ( { const normalize = (s: string) => s?.normalize("NFKC").toLowerCase().replace(/\s+/g, " ").trim(); const [fkTitle, fkAuthor] = failedKey.split("::"); const failedKeyNorm = `${normalize(fkTitle!)}::${normalize(fkAuthor!)}`; setRows((prev) => { const next = prev .filter((r) => { const isFailed = !(r as any).success; if (!isFailed) return true; const rt = normalize((r as any).title || ""); const ra = normalize((r as any).author || ""); return `${rt}::${ra}` !== failedKeyNorm; }) .concat([success as any]); try { localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } catch {} return next; }); }} /> ))}
)} {/* Successfully imported — desktop table matching EditableLibraryTable */} {successRows.length > 0 && (
Successfully imported ({successRows.length})
{/* Desktop: table view */}
{successRows.map((row) => ( ) => { if (row.success) updateRow(row.hiveId, next); }} onDelete={(hiveId: string) => deleteRow(hiveId)} /> ))}
Book Status Rating Actions
{/* Mobile: compact card list */}
{successRows.map((row) => ( ) => { if (row.success) updateRow(row.hiveId, next); }} onDelete={(hiveId: string) => deleteRow(hiveId)} /> ))}
)}
); };