"use client"; import { useRef, useState, useEffect } from "react"; import type { EbookProject } from "@/lib/ebook-project-store"; import type { EbookJobState, EbookManifest } from "@/lib/schemas/ebook"; import { EbookManifestSchema, EbookJobStateSchema } from "@/lib/schemas/ebook"; import type { ProjectSnapshot } from "@/lib/project-store"; import type { PublishedBookEntry } from "@/lib/schemas/published-book"; type EbookProjectsPanelProps = { projects: EbookProject[]; suggestedName: string; canSave: boolean; onSave: (name: string) => void; onLoad: (id: string) => void; onDelete: (id: string) => void; onImport: (project: EbookProject) => void; /** Called with the parsed job state so the page can build a manifest from it */ onImportManifestJson?: (job: EbookJobState) => EbookManifest | null; /** Called when a manifest/job JSON is successfully parsed from a device file */ onManifestLoaded?: (manifest: EbookManifest) => void; /** Publish a completed project to the Library — returns the slug on success */ onPublish?: (project: EbookProject) => Promise; /** Remove a published book from the Library catalog */ onUnpublish?: (project: EbookProject) => Promise; /** Called after a cover or author image is uploaded, to persist the new URL */ onUpdateImages?: (id: string, coverImageUrl?: string, authorImageUrl?: string) => Promise; }; function exportProject(p: EbookProject) { const json = JSON.stringify(p, null, 2); const blob = new Blob([json], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `${p.name.replace(/[^a-z0-9]/gi, "_").toLowerCase()}_ebook.json`; a.click(); URL.revokeObjectURL(url); } export function EbookProjectsPanel({ projects, suggestedName, canSave, onSave, onLoad, onDelete, onImport, onImportManifestJson, onManifestLoaded, onPublish, onUnpublish, onUpdateImages, }: EbookProjectsPanelProps) { const [name, setName] = useState(suggestedName); const [confirmDelete, setConfirmDelete] = useState(null); const [confirmUnpublish, setConfirmUnpublish] = useState(null); const [importError, setImportError] = useState(null); const [importSuccess, setImportSuccess] = useState(null); const [publishingId, setPublishingId] = useState(null); const [unpublishingId, setUnpublishingId] = useState(null); // Image upload state: tracks which project/type is currently uploading const [imageUploading, setImageUploading] = useState<{ id: string; type: "cover" | "author" } | null>(null); const imageTargetRef = useRef<{ id: string; type: "cover" | "author" } | null>(null); const imageInputRef = useRef(null); const projectFileRef = useRef(null); const manifestFileRef = useRef(null); // Live published catalog fetched from R2 via API const [liveBooks, setLiveBooks] = useState([]); const [catalogLoading, setCatalogLoading] = useState(false); const [removingSlug, setRemovingSlug] = useState(null); const [confirmRemoveSlug, setConfirmRemoveSlug] = useState(null); async function fetchLiveCatalog() { setCatalogLoading(true); try { const res = await fetch("/api/ebook/publish"); if (res.ok) { const data = await res.json() as { books?: PublishedBookEntry[] }; setLiveBooks(data.books ?? []); } } catch { /* silently ignore */ } finally { setCatalogLoading(false); } } useEffect(() => { void fetchLiveCatalog(); }, []); // Keep the name input in sync when the pipeline produces a title useEffect(() => { if (suggestedName && !name) setName(suggestedName); }, [suggestedName, name]); function handleProjectFileImport(e: React.ChangeEvent) { const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = (ev) => { try { const parsed = JSON.parse(ev.target?.result as string) as EbookProject; const now = new Date().toISOString(); // Accept both Book-only exports and workspace-style project snapshots that carry ebookJobState. if (parsed && typeof parsed === "object" && "ebookJobState" in parsed) { const snapshot = parsed as unknown as ProjectSnapshot; if (!snapshot.id || !snapshot.name || !snapshot.ebookJobState) { throw new Error("Invalid ebook project file."); } const jobParse = EbookJobStateSchema.safeParse(snapshot.ebookJobState); if (!jobParse.success) throw new Error("Invalid ebook job state in project file."); onImport({ id: snapshot.id, name: snapshot.name, createdAt: snapshot.createdAt ?? now, updatedAt: now, bookTitle: jobParse.data.architecture?.bookTitle ?? snapshot.name, chapterCount: jobParse.data.chapters?.length ?? 0, totalWordCount: (jobParse.data.chapters ?? []).reduce((sum, chapter) => sum + (chapter.totalWordCount ?? 0), 0), status: jobParse.data.status, jobState: jobParse.data, publishedSlug: snapshot.publishedSlug, coverImageUrl: snapshot.coverImageUrl, authorImageUrl: snapshot.authorImageUrl, }); setImportSuccess(`"${snapshot.name}" imported into saved projects.`); setImportError(null); return; } if (!parsed.id || !parsed.name || !parsed.jobState) throw new Error("Invalid ebook project file."); const jobParse = EbookJobStateSchema.safeParse(parsed.jobState); if (!jobParse.success) throw new Error("Invalid ebook job state in project file."); onImport({ ...parsed, jobState: jobParse.data, id: `ebook-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, updatedAt: now, }); setImportSuccess(`"${parsed.name}" imported into saved projects.`); setImportError(null); } catch { setImportError("Could not read file — make sure it's a valid Nexus ebook project export."); setImportSuccess(null); } }; reader.readAsText(file); e.target.value = ""; } function handleManifestFileImport(e: React.ChangeEvent) { const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = (ev) => { try { const raw = JSON.parse((ev.target?.result as string) ?? "") as unknown; const wrapped = raw && typeof raw === "object" ? raw as Record : null; // Try: direct manifest, wrapped manifest, or wrapped job state const candidateManifest = wrapped?.manifest ?? wrapped?.ebookManifest ?? raw; const candidateJob = wrapped?.job ?? wrapped?.jobState ?? raw; const manifestParse = EbookManifestSchema.safeParse(candidateManifest); if (manifestParse.success) { onManifestLoaded?.(manifestParse.data); setImportSuccess(`"${manifestParse.data.bookTitle}" loaded into pipeline.`); setImportError(null); return; } const jobParse = EbookJobStateSchema.safeParse(candidateJob); if (jobParse.success && onImportManifestJson) { const manifest = onImportManifestJson(jobParse.data); if (!manifest) throw new Error("Job file is valid but book is not yet complete."); onManifestLoaded?.(manifest); setImportSuccess(`"${manifest.bookTitle}" loaded into pipeline.`); setImportError(null); return; } throw new Error("Unsupported file format — import a Nexus ebook manifest or saved project JSON."); } catch (err) { setImportError(err instanceof Error ? err.message : "Could not import file."); setImportSuccess(null); } }; reader.readAsText(file); e.target.value = ""; } async function handleImageFileChange(e: React.ChangeEvent) { const file = e.target.files?.[0]; e.target.value = ""; const target = imageTargetRef.current; if (!file || !target || !onUpdateImages) return; setImageUploading(target); try { // 1. Get a presigned upload URL from R2 const presignRes = await fetch("/api/r2-presign", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ filename: file.name, contentType: file.type, prefix: "images" }), }); if (!presignRes.ok) throw new Error("Could not get upload URL."); const { presignedUrl, publicUrl } = await presignRes.json() as { presignedUrl: string; publicUrl: string | null }; if (!presignedUrl || !publicUrl) throw new Error("Storage not configured."); // 2. Upload directly to R2 const uploadRes = await fetch(presignedUrl, { method: "PUT", headers: { "Content-Type": file.type }, body: file, }); if (!uploadRes.ok) throw new Error("Upload failed."); // 3. Persist the URL on the project await onUpdateImages( target.id, target.type === "cover" ? publicUrl : undefined, target.type === "author" ? publicUrl : undefined, ); } catch { /* silently ignore — user can retry */ } finally { setImageUploading(null); imageTargetRef.current = null; } } return (
{/* ── Status messages ─────────────────────────────────────────────── */} {importError && (

{importError}

)} {importSuccess && (

{importSuccess}

)} {/* ── Save current book ────────────────────────────────────────────── */}

Save Current Book

setName(e.target.value)} placeholder="Enter a name for this book project…" className="min-h-12 flex-1 rounded-xl border border-slate-600 bg-slate-800/60 px-4 text-base text-slate-100 placeholder-slate-500 focus:border-cyan-500 focus:outline-none" />

Saves your pipeline progress to this device. Resume any time.

{/* ── Import from device ───────────────────────────────────────────── */}

Import from Device

{onManifestLoaded && ( )}

Import Project File — restores a full project (all pipeline stages). {onManifestLoaded && " Load Manifest JSON — loads a completed ebook export into the pipeline."}

{/* ── Saved project list ───────────────────────────────────────────── */}

Saved Books {projects.length > 0 && `· ${projects.length}`}

{projects.length === 0 ? (

No saved books yet.

Complete some pipeline stages, then hit Save above.

) : (
{projects.map((p) => (

{p.name}

{p.chapterCount > 0 ? `${p.chapterCount} chapter${p.chapterCount !== 1 ? "s" : ""}` : p.status === "complete" ? "Complete" : p.status} {p.totalWordCount > 0 && ` · ${p.totalWordCount.toLocaleString()} words`} {" · "} {new Date(p.updatedAt).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}

{confirmDelete === p.id ? (
) : ( )}
{/* ── Book images ── */} {onUpdateImages && (
{/* Cover image slot */} {/* Author photo slot */}

{p.coverImageUrl ? "Cover uploaded" : "Tap to add cover"}

{p.authorImageUrl ? "Author photo set" : "Tap circle for author photo"}

)} {/* Publish / Published row */} {onPublish && (
{p.publishedSlug ? ( <> View in Library {/* Remove from Library (confirm) */} {onUnpublish && ( confirmUnpublish === p.id ? (
) : ( ) )} ) : ( )}
)}
))}
)}
{/* ── Library Books (live catalog from R2) ─────────────────────────── */}

Library Books {liveBooks.length > 0 && `· ${liveBooks.length}`}

{liveBooks.length === 0 ? (

{catalogLoading ? "Loading…" : "No books in library yet."}

) : (
{liveBooks.map((book) => (
{(() => { const linkedProject = projects.find((p) => p.publishedSlug === book.slug); const coverUrl = book.coverImageUrl ?? linkedProject?.coverImageUrl; const authorUrl = book.authorImageUrl ?? linkedProject?.authorImageUrl; return ( <> {/* Book info */}

{book.title}

{book.subtitle && (

{book.subtitle}

)}

{book.chapterCount} ch · {book.wordCount.toLocaleString()} words {" · "} {new Date(book.publishedAt).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}

{coverUrl ? ( /* eslint-disable-next-line @next/next/no-img-element */ Cover ) : ( )}
{authorUrl ? ( /* eslint-disable-next-line @next/next/no-img-element */ Author ) : ( )}

{coverUrl ? "Cover uploaded" : "No cover image"}

{authorUrl ? "Author photo set" : "No author image"}

{/* Actions */}
View {confirmRemoveSlug === book.slug ? ( <> ) : ( )}
); })()}
))}
)}
); }