"use client"; import { Suspense, useState, useCallback, useRef, useEffect } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { EbookPipeline } from "@/app/components/EbookPipeline"; import { EbookProjectsPanel } from "@/app/components/EbookProjectsPanel"; import { AssistantPanel } from "@/app/components/AssistantPanel"; import { NexusNav } from "@/app/components/NexusNav"; import { StatusBar } from "@/app/components/StatusBar"; import { SiteConfigSchema } from "@/lib/schemas/site-config"; import { EbookManifestSchema, EbookJobStateSchema } from "@/lib/schemas/ebook"; import type { EbookManifest, EbookJobState } from "@/lib/schemas/ebook"; import type { SiteConfig } from "@/lib/schemas/site-config"; import type { EbookPipelineSnapshot } from "@/app/components/EbookPipeline"; import { listEbookProjects, saveEbookProject, deleteEbookProject, generateEbookProjectId, } from "@/lib/ebook-project-store"; import type { EbookProject } from "@/lib/ebook-project-store"; const JOB_STATE_KEY = "nexus_ebook_job_state"; const PENDING_MOUNT_KEY = "nexus_ebook_pending_mount"; const VOICE_STUDIO_STORAGE_PREFIX = "nexus_voice_studio_"; const VALID_JOB_STATUSES = new Set([ "idle", "transcribing", "filtering", "analyzing", "mapping", "architecting", "assigning", "writing", "polishing", "frontmatter", "exporting", "complete", "failed", ]); type Tab = "pipeline" | "projects"; export default function EbookPage() { return ( Loading book workspace... )}> ); } function EbookPageClient() { const router = useRouter(); const searchParams = useSearchParams(); const [activeTab, setActiveTab] = useState("pipeline"); const [ebookManifest, setEbookManifest] = useState(null); const [ebookPipelineSnapshot, setEbookPipelineSnapshot] = useState(null); const [assistantOpen, setAssistantOpen] = useState(false); const [statusMsg, setStatusMsg] = useState<{ type: "success" | "error"; text: string } | null>(null); const [siteConfig] = useState(() => SiteConfigSchema.parse({})); // Project persistence const [projects, setProjects] = useState([]); const [currentProjectId, setCurrentProjectId] = useState(""); // Incrementing this key remounts so it re-reads localStorage on load const [pipelineKey, setPipelineKey] = useState(0); const hydratedLoadRef = useRef(null); useEffect(() => { void (async () => { const localProjects = await listEbookProjects().catch(() => []); setProjects(localProjects); try { const res = await fetch("/api/projects"); if (!res.ok) return; const payload = await res.json() as { projects?: Array<{ id?: string; name?: string; createdAt?: string; updatedAt?: string; ebookJobState?: unknown; jobState?: unknown; publishedSlug?: string; coverImageUrl?: string; authorImageUrl?: string; }>; }; const remote = Array.isArray(payload.projects) ? payload.projects : []; const localById = new Map(localProjects.map((p) => [p.id, p])); let changed = false; for (const item of remote) { if (!item.id || !item.name) continue; const sourceJobState = item.ebookJobState ?? item.jobState; if (!sourceJobState) continue; const rawState = typeof sourceJobState === "string" ? (() => { try { return JSON.parse(sourceJobState) as unknown; } catch { return null; } })() : sourceJobState; if (!rawState || typeof rawState !== "object") continue; const record = rawState as Record; const rawStatus = typeof record.status === "string" ? record.status : "idle"; const normalizedState = { ...record, jobId: typeof record.jobId === "string" && record.jobId ? record.jobId : item.id, status: VALID_JOB_STATUSES.has(rawStatus) ? rawStatus : "idle", createdAt: (() => { const source = typeof record.createdAt === "string" ? record.createdAt : item.createdAt; const ts = source ? Date.parse(source) : NaN; return Number.isFinite(ts) ? new Date(ts).toISOString() : new Date().toISOString(); })(), updatedAt: (() => { const source = typeof record.updatedAt === "string" ? record.updatedAt : item.updatedAt; const ts = source ? Date.parse(source) : NaN; return Number.isFinite(ts) ? new Date(ts).toISOString() : new Date().toISOString(); })(), }; const parsed = EbookJobStateSchema.safeParse(normalizedState); if (!parsed.success) continue; const existing = localById.get(item.id); const localTs = existing ? new Date(existing.updatedAt).getTime() : 0; const remoteTs = new Date(item.updatedAt ?? item.createdAt ?? 0).getTime(); const hasRemoteImageUpdates = Boolean( (item.coverImageUrl && !existing?.coverImageUrl) || (item.authorImageUrl && !existing?.authorImageUrl) || (item.publishedSlug && !existing?.publishedSlug) ); if (existing && localTs >= remoteTs && !hasRemoteImageUpdates) continue; const job = parsed.data; const normalized: EbookProject = { id: item.id, name: item.name, createdAt: item.createdAt ?? new Date().toISOString(), updatedAt: item.updatedAt ?? new Date().toISOString(), bookTitle: job.architecture?.bookTitle ?? item.name, chapterCount: job.chapters?.length ?? 0, totalWordCount: (job.chapters ?? []).reduce((sum, chapter) => sum + (chapter.totalWordCount ?? 0), 0), status: job.status, jobState: job, publishedSlug: item.publishedSlug ?? existing?.publishedSlug, coverImageUrl: item.coverImageUrl ?? existing?.coverImageUrl, authorImageUrl: item.authorImageUrl ?? existing?.authorImageUrl, }; await saveEbookProject(normalized).catch(() => {}); changed = true; } if (changed) { setProjects(await listEbookProjects()); } } catch { // Cloud sync is best-effort; local projects remain usable offline. } })(); }, []); const requestedTab = searchParams.get("tab"); const requestedLoad = searchParams.get("load"); useEffect(() => { if (requestedTab === "projects" || requestedTab === "pipeline") { setActiveTab(requestedTab); return; } setActiveTab("pipeline"); }, [requestedTab]); useEffect(() => { try { const raw = localStorage.getItem(PENDING_MOUNT_KEY); if (!raw) return; const parsed = JSON.parse(raw) as { projectId?: string; projectName?: string; jobState?: unknown; ebookManifest?: unknown; coverImageUrl?: string | null; authorImageUrl?: string | null; ts?: number; }; if (typeof parsed.ts !== "number" || Date.now() - parsed.ts > 120000) { localStorage.removeItem(PENDING_MOUNT_KEY); return; } const jobParsed = EbookJobStateSchema.safeParse(parsed.jobState); if (!jobParsed.success) { localStorage.removeItem(PENDING_MOUNT_KEY); return; } localStorage.setItem(JOB_STATE_KEY, JSON.stringify(jobParsed.data)); if (typeof parsed.projectId === "string") setCurrentProjectId(parsed.projectId); const manifestParsed = EbookManifestSchema.safeParse(parsed.ebookManifest); if (manifestParsed.success) { setEbookManifest({ ...manifestParsed.data, coverImageUrl: manifestParsed.data.coverImageUrl ?? parsed.coverImageUrl ?? null, authorImageUrl: manifestParsed.data.authorImageUrl ?? parsed.authorImageUrl ?? null, }); } setPipelineKey((k) => k + 1); setActiveTab("pipeline"); setStatusMsg({ type: "success", text: `"${parsed.projectName ?? "Project"}" mounted in standalone pipeline.` }); localStorage.removeItem(PENDING_MOUNT_KEY); } catch { localStorage.removeItem(PENDING_MOUNT_KEY); } }, []); useEffect(() => { if (!requestedLoad || projects.length === 0) return; if (hydratedLoadRef.current === requestedLoad) return; const project = projects.find((p) => p.id === requestedLoad); if (!project) return; try { localStorage.setItem(JOB_STATE_KEY, JSON.stringify(project.jobState)); setCurrentProjectId(project.id); const job = project.jobState; if (job.architecture && job.frontMatter && job.contentMap) { setEbookManifest({ jobId: job.jobId, bookTitle: job.architecture.bookTitle, subtitle: job.architecture.subtitle, authorName: job.architecture.authorName, frontMatter: job.frontMatter, chapters: job.chapters ?? [], totalWordCount: (job.chapters ?? []).reduce((sum, chapter) => sum + (chapter.totalWordCount ?? 0), 0), allQuotes: job.contentMap.allQuotes ?? [], generatedAt: new Date().toISOString(), selectedTemplate: "devotional", printSpec: { trimSize: "6x9", runningHeaders: true, bleed: false, cropMarks: false }, coverImageUrl: project.coverImageUrl ?? null, authorImageUrl: project.authorImageUrl ?? null, }); } setPipelineKey((k) => k + 1); setActiveTab("pipeline"); hydratedLoadRef.current = requestedLoad; setStatusMsg({ type: "success", text: `"${project.name}" mounted in standalone pipeline.` }); router.replace("/ebook?tab=pipeline"); } catch (err) { setStatusMsg({ type: "error", text: err instanceof Error ? err.message : "Project mount failed." }); } }, [requestedLoad, projects, router]); const suggestedName = ebookPipelineSnapshot?.bookTitle ?? ebookManifest?.bookTitle ?? ""; const readNarrationUrls = useCallback((jobId: string): Record | undefined => { try { const raw = localStorage.getItem(`${VOICE_STUDIO_STORAGE_PREFIX}${jobId}`); if (!raw) return undefined; const parsed = JSON.parse(raw) as { chapters?: Array<{ chapterId?: string; status?: string; audioUrl?: string | null }>; }; const entries = (parsed.chapters ?? []) .filter((chapter): chapter is { chapterId: string; status: string; audioUrl: string } => ( typeof chapter.chapterId === "string" && chapter.chapterId.length > 0 && chapter.status === "done" && typeof chapter.audioUrl === "string" && chapter.audioUrl.length > 0 )) .map((chapter) => [chapter.chapterId, chapter.audioUrl] as const); if (entries.length === 0) return undefined; return Object.fromEntries(entries); } catch { return undefined; } }, []); // ── Project handlers ────────────────────────────────────────────────────── const handleSaveProject = useCallback(async (name: string) => { try { const raw = localStorage.getItem(JOB_STATE_KEY); const fallbackProject = currentProjectId ? projects.find((p) => p.id === currentProjectId) : null; const parsedRaw = raw ? JSON.parse(raw) as unknown : fallbackProject?.jobState; if (!parsedRaw) { setStatusMsg({ type: "error", text: "Nothing to save yet — start the pipeline first." }); return; } const jobState = EbookJobStateSchema.parse(parsedRaw); const id = currentProjectId || generateEbookProjectId(); const existing = projects.find((p) => p.id === id); const project: EbookProject = { id, name, createdAt: existing?.createdAt ?? new Date().toISOString(), updatedAt: new Date().toISOString(), bookTitle: jobState.architecture?.bookTitle ?? name, chapterCount: jobState.chapters?.length ?? 0, totalWordCount: (jobState.chapters ?? []).reduce((s, c) => s + (c.totalWordCount ?? 0), 0), status: jobState.status, jobState, publishedSlug: existing?.publishedSlug, coverImageUrl: existing?.coverImageUrl, authorImageUrl: existing?.authorImageUrl, }; await saveEbookProject(project); localStorage.setItem(JOB_STATE_KEY, JSON.stringify(project.jobState)); setCurrentProjectId(id); setProjects(await listEbookProjects()); setStatusMsg({ type: "success", text: `"${name}" saved.` }); // Sync to R2 as a ProjectSnapshot (fire-and-forget) fetch("/api/projects", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ project: { id: project.id, name: project.name, createdAt: project.createdAt, updatedAt: project.updatedAt, academy: null, siteConfig: {}, deliveryInstructions: "", chatHistory: [], blueprint: null, logicResult: null, uiResult: null, ebookManifest: null, ebookJobState: project.jobState, publishedSlug: project.publishedSlug, coverImageUrl: project.coverImageUrl, authorImageUrl: project.authorImageUrl, }, }), }).catch(() => {}); } catch (err) { setStatusMsg({ type: "error", text: err instanceof Error ? err.message : "Save failed." }); } }, [currentProjectId, projects]); const handleLoadProject = useCallback((id: string) => { const p = projects.find((proj) => proj.id === id); if (!p) return; try { localStorage.setItem(JOB_STATE_KEY, JSON.stringify(p.jobState)); setCurrentProjectId(p.id); const job = p.jobState; if (job.architecture && job.frontMatter && job.contentMap) { setEbookManifest({ jobId: job.jobId, bookTitle: job.architecture.bookTitle, subtitle: job.architecture.subtitle, authorName: job.architecture.authorName, frontMatter: job.frontMatter, chapters: job.chapters ?? [], totalWordCount: (job.chapters ?? []).reduce((sum, chapter) => sum + (chapter.totalWordCount ?? 0), 0), allQuotes: job.contentMap.allQuotes ?? [], generatedAt: new Date().toISOString(), selectedTemplate: "devotional", printSpec: { trimSize: "6x9", runningHeaders: true, bleed: false, cropMarks: false }, coverImageUrl: p.coverImageUrl ?? null, authorImageUrl: p.authorImageUrl ?? null, }); } else { setEbookManifest(null); } setActiveTab("pipeline"); setStatusMsg({ type: "success", text: `"${p.name}" loaded — resuming pipeline.` }); setPipelineKey((k) => k + 1); } catch (err) { setStatusMsg({ type: "error", text: err instanceof Error ? err.message : "Load failed." }); } }, [projects]); const handleDeleteProject = useCallback(async (id: string) => { await deleteEbookProject(id); setProjects(await listEbookProjects()); if (currentProjectId === id) setCurrentProjectId(""); // Remove from R2 (fire-and-forget) fetch("/api/projects", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id }), }).catch(() => {}); }, [currentProjectId]); // ── Unpublish handler ───────────────────────────────────────────────────── const handleUnpublish = useCallback(async (project: EbookProject): Promise => { if (!project.publishedSlug) return false; try { const res = await fetch("/api/ebook/publish", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ slug: project.publishedSlug }), }); if (!res.ok) { const err = await res.json() as { error?: string }; setStatusMsg({ type: "error", text: err.error ?? "Remove from library failed." }); return false; } // Clear publishedSlug from local project record const updated: EbookProject = { ...project, publishedSlug: undefined }; await saveEbookProject(updated); setProjects(await listEbookProjects()); setStatusMsg({ type: "success", text: `"${project.name}" removed from the library.` }); // Sync cleared slug to R2 fetch("/api/projects", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ project: { id: updated.id, name: updated.name, createdAt: updated.createdAt, updatedAt: updated.updatedAt, academy: null, siteConfig: {}, deliveryInstructions: "", chatHistory: [], blueprint: null, logicResult: null, uiResult: null, ebookManifest: null, ebookJobState: updated.jobState, publishedSlug: undefined, }, }), }).catch(() => {}); return true; } catch (err) { setStatusMsg({ type: "error", text: err instanceof Error ? err.message : "Remove failed." }); return false; } }, []); const handleImportProject = useCallback(async (project: EbookProject) => { await saveEbookProject(project); setProjects(await listEbookProjects()); setCurrentProjectId(project.id); localStorage.setItem(JOB_STATE_KEY, JSON.stringify(project.jobState)); setPipelineKey((k) => k + 1); // Mirror imported project to cloud snapshot store (best-effort) fetch("/api/projects", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ project: { id: project.id, name: project.name, createdAt: project.createdAt, updatedAt: project.updatedAt, academy: null, siteConfig: {}, deliveryInstructions: "", chatHistory: [], blueprint: null, logicResult: null, uiResult: null, ebookManifest: null, ebookJobState: project.jobState, publishedSlug: project.publishedSlug, coverImageUrl: project.coverImageUrl, authorImageUrl: project.authorImageUrl, }, }), }).catch(() => {}); setStatusMsg({ type: "success", text: `"${project.name}" imported and loaded.` }); }, []); // ── Publish handler ─────────────────────────────────────────────────────── const handlePublish = useCallback(async (project: EbookProject): Promise => { const job = project.jobState; if (!job.architecture || !job.frontMatter || !job.chapters?.length) { setStatusMsg({ type: "error", text: "Book must be complete before publishing." }); return null; } const manifest: EbookManifest = { jobId: job.jobId, bookTitle: job.architecture.bookTitle, subtitle: job.architecture.subtitle, authorName: job.architecture.authorName, frontMatter: job.frontMatter, chapters: job.chapters, totalWordCount: job.chapters.reduce((s, c) => s + (c.totalWordCount ?? 0), 0), allQuotes: job.contentMap?.allQuotes ?? [], generatedAt: job.updatedAt ?? new Date().toISOString(), selectedTemplate: "devotional", printSpec: { trimSize: "6x9", runningHeaders: true, bleed: false, cropMarks: false }, coverImageUrl: project.coverImageUrl ?? null, authorImageUrl: project.authorImageUrl ?? null, narrationUrls: readNarrationUrls(job.jobId), }; try { const res = await fetch("/api/ebook/publish", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ manifest, coverAccent: "amber" }), }); if (!res.ok) { const err = await res.json() as { error?: string }; setStatusMsg({ type: "error", text: err.error ?? "Publish failed." }); return null; } const { slug } = await res.json() as { slug: string }; const updated: EbookProject = { ...project, publishedSlug: slug }; await saveEbookProject(updated); setProjects(await listEbookProjects()); setStatusMsg({ type: "success", text: `"${project.name}" published to /library/${slug}` }); return slug; } catch (err) { setStatusMsg({ type: "error", text: err instanceof Error ? err.message : "Publish failed." }); return null; } }, [readNarrationUrls]); const handleUpdateImages = useCallback(async ( id: string, coverImageUrl?: string, authorImageUrl?: string, ) => { const p = projects.find((proj) => proj.id === id); if (!p) return; const updated: EbookProject = { ...p, ...(coverImageUrl !== undefined ? { coverImageUrl } : {}), ...(authorImageUrl !== undefined ? { authorImageUrl } : {}), }; await saveEbookProject(updated); setProjects(await listEbookProjects()); // Sync to R2 fetch("/api/projects", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ project: { id: updated.id, name: updated.name, createdAt: updated.createdAt, updatedAt: updated.updatedAt, academy: null, siteConfig: {}, deliveryInstructions: "", chatHistory: [], blueprint: null, logicResult: null, uiResult: null, ebookManifest: null, ebookJobState: updated.jobState, publishedSlug: updated.publishedSlug, coverImageUrl: updated.coverImageUrl, authorImageUrl: updated.authorImageUrl, }, }), }).catch(() => {}); // If already published, push the new images to the library immediately if (updated.publishedSlug) { handlePublish(updated).catch(() => {}); } }, [projects, handlePublish]); // ── Manifest handlers ───────────────────────────────────────────────────── const buildManifestFromJob = useCallback((job: EbookJobState): EbookManifest | null => { if (!job.architecture || !job.frontMatter || !job.contentMap) return null; return { jobId: job.jobId, bookTitle: job.architecture.bookTitle, subtitle: job.architecture.subtitle, authorName: job.architecture.authorName, frontMatter: job.frontMatter, chapters: job.chapters ?? [], totalWordCount: (job.chapters ?? []).reduce((sum, chapter) => sum + (chapter.totalWordCount ?? 0), 0), allQuotes: job.contentMap.allQuotes ?? [], generatedAt: new Date().toISOString(), selectedTemplate: "devotional", printSpec: { trimSize: "6x9", runningHeaders: true, bleed: false, cropMarks: false }, }; }, []); const handleManifestReady = useCallback((manifest: EbookManifest) => { setEbookManifest(manifest); }, []); const handleEbookUpdate = useCallback((manifest: EbookManifest) => { setEbookManifest(manifest); // Write the AI-edited manifest back to localStorage so the pipeline display, // saves, and reloads all reflect the changes immediately. try { const raw = localStorage.getItem(JOB_STATE_KEY); if (raw) { const existing = JSON.parse(raw) as Record; const updatedJobState = { ...existing, chapters: manifest.chapters, frontMatter: manifest.frontMatter, ...(existing.architecture ? { architecture: { ...(existing.architecture as Record), bookTitle: manifest.bookTitle, subtitle: manifest.subtitle, authorName: manifest.authorName, }, } : {}), }; localStorage.setItem(JOB_STATE_KEY, JSON.stringify(updatedJobState)); } } catch { // localStorage unavailable — in-memory state still updated correctly } }, []); const handlePipelineSnapshotChange = useCallback((snapshot: EbookPipelineSnapshot | null) => { setEbookPipelineSnapshot(snapshot); }, []); const handleExportJson = useCallback(() => { if (!ebookManifest) return; const blob = new Blob([JSON.stringify({ ebookManifest }, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = `${ebookManifest.bookTitle.replace(/[^a-z0-9]/gi, "_").toLowerCase()}_ebook_manifest.json`; link.click(); URL.revokeObjectURL(url); }, [ebookManifest]); const handleStartFreshProject = useCallback(() => { const confirmed = window.confirm( "Start a fresh book project? This will clear the current in-progress pipeline from this screen, but your saved projects will remain available." ); if (!confirmed) return; try { localStorage.removeItem(JOB_STATE_KEY); localStorage.removeItem(PENDING_MOUNT_KEY); } catch { // localStorage unavailable; in-memory reset still applies } hydratedLoadRef.current = null; setCurrentProjectId(""); setEbookManifest(null); setEbookPipelineSnapshot(null); setAssistantOpen(false); setActiveTab("pipeline"); setPipelineKey((k) => k + 1); setStatusMsg({ type: "success", text: "Started a fresh book project." }); router.replace("/ebook?tab=pipeline"); }, [router]); const handleNavSelect = useCallback((id: string) => { if (id === "ebook") { router.push("/ebook?tab=pipeline"); return; } if (id === "translate") { router.push("/translate"); return; } router.push("/"); }, [router]); return ( Ebook Production Studio Audio → Voice DNA → Chapters → PDF + EPUB New Project New {ebookManifest && ( <> setAssistantOpen(true)} className="flex min-h-[44px] items-center gap-2 rounded-xl border border-cyan-500/30 bg-cyan-500/10 px-3.5 py-2 text-xs font-semibold text-cyan-300 transition hover:border-cyan-400/60 hover:bg-cyan-500/15 active:scale-[0.97]" > Director AI Export > )} setActiveTab("pipeline")} className={`flex items-center gap-2 border-b-2 px-4 py-2.5 text-sm font-semibold transition-colors ${activeTab === "pipeline" ? "border-cyan-400 text-cyan-300" : "border-transparent text-slate-400 hover:text-slate-200"}`} > Pipeline setActiveTab("projects")} className={`flex items-center gap-2 border-b-2 px-4 py-2.5 text-sm font-semibold transition-colors ${activeTab === "projects" ? "border-cyan-400 text-cyan-300" : "border-transparent text-slate-400 hover:text-slate-200"}`} > Projects {projects.length > 0 && ( {projects.length} )} {statusMsg && ( {statusMsg.text} )} void handleSaveProject(name)} /> { setEbookManifest(manifest); setActiveTab("pipeline"); setStatusMsg({ type: "success", text: `"${manifest.bookTitle}" loaded into pipeline.` }); }} /> setAssistantOpen(false)} academy={null} onUpdate={() => {}} siteConfig={siteConfig} onSiteUpdate={() => {}} ebookManifest={ebookManifest} onEbookUpdate={handleEbookUpdate} ebookPipelineSnapshot={ebookPipelineSnapshot} /> ); }
Audio → Voice DNA → Chapters → PDF + EPUB
{statusMsg.text}