"use client"; import { useState, useCallback, useEffect } from "react"; import { useRouter } from "next/navigation"; import { NexusNav } from "@/app/components/NexusNav"; import { StatusBar } from "@/app/components/StatusBar"; import { TerminalLog } from "@/app/components/TerminalLog"; import { MediaUpload } from "@/app/components/MediaUpload"; import { ProjectCard } from "@/app/components/ProjectCard"; import { PipelineResults } from "@/app/components/PipelineResults"; import { PromptBar } from "@/app/components/PromptBar"; import { AssistantPanel } from "@/app/components/AssistantPanel"; import { ProjectsPanel } from "@/app/components/ProjectsPanel"; import { EbookPipeline } from "@/app/components/EbookPipeline"; import { SermonAssistantPanel } from "@/app/components/SermonAssistantPanel"; import type { EbookPipelineSnapshot } from "@/app/components/EbookPipeline"; import { EbookJobStateSchema } from "@/lib/schemas/ebook"; import type { EbookManifest } from "@/lib/schemas/ebook"; import { LogicTransformResultSchema } from "@/lib/schemas/blueprint"; import { UiManifestResultSchema } from "@/lib/schemas/ui-manifest"; import { AcademyPackageSchema } from "@/lib/schemas/academy"; import type { LogicTransformResult } from "@/lib/schemas/blueprint"; import type { UiManifestResult } from "@/lib/schemas/ui-manifest"; import type { AcademyPackage } from "@/lib/schemas/academy"; import type { IngestResult } from "@/lib/schemas/blueprint"; import type { LogEntry, ModelState, ModelHandle, PipelineStage } from "@/lib/types"; import { SiteConfigSchema } from "@/lib/schemas/site-config"; import type { SiteConfig } from "@/lib/schemas/site-config"; import { listProjects, saveProject, deleteProject, generateProjectId, } from "@/lib/project-store"; import { listEbookProjects, saveEbookProject, deleteEbookProject, } from "@/lib/ebook-project-store"; import type { ProjectSnapshot, ChatMessage } from "@/lib/project-store"; const INITIAL_MODELS: ModelState[] = [ { name: "Gemini", handle: "gemini", role: "Analyst", status: "standby" }, { name: "DeepSeek", handle: "deepseek", role: "Engineer", status: "standby" }, { name: "Claude", handle: "claude", role: "Designer", status: "standby" }, { name: "Curator", handle: "curator", role: "Academy Producer", status: "standby" }, { name: "Manus", handle: "manus", role: "Executive", status: "standby" } ]; export default function HomePage() { const router = useRouter(); const [logs, setLogs] = useState([]); const [blueprint, setBlueprint] = useState(null); const [logicResult, setLogicResult] = useState(null); const [uiResult, setUiResult] = useState(null); const [academyResult, setAcademyResult] = useState(null); const [rawTranscript, setRawTranscript] = useState(""); const [deliveryInstructions, setDeliveryInstructions] = useState(""); const [assistantOpen, setAssistantOpen] = useState(false); const [siteConfig, setSiteConfig] = useState(() => SiteConfigSchema.parse({})); const [stage, setStage] = useState("idle"); const [models, setModels] = useState(INITIAL_MODELS); const [activeNav, setActiveNav] = useState("overview"); // Ebook pipeline state — lifted so the AI assistant can read and edit the book const [ebookManifest, setEbookManifest] = useState(null); const [ebookSnapshot, setEbookSnapshot] = useState(null); // Incrementing this remounts so it re-reads localStorage after a project load const [ebookPipelineKey, setEbookPipelineKey] = useState(0); // Project persistence const [projects, setProjects] = useState([]); const [currentProjectId, setCurrentProjectId] = useState(""); const [chatHistory, setChatHistory] = useState([]); const [panelLoadKey, setPanelLoadKey] = useState(""); // Load persisted state client-side only (avoids SSR hydration mismatch) useEffect(() => { void (async () => { try { const main = await listProjects(); const mainIds = new Set(main.map((p) => p.id)); const ebookOnly = (await listEbookProjects().catch(() => [])) .filter((e) => !mainIds.has(e.id)) .map((e) => ({ id: e.id, name: e.name, createdAt: e.createdAt, updatedAt: e.updatedAt, academy: null, siteConfig: SiteConfigSchema.parse({}), deliveryInstructions: "", chatHistory: [], blueprint: null, logicResult: null, uiResult: null, ebookManifest: null, ebookJobState: e.jobState, publishedSlug: e.publishedSlug, coverImageUrl: e.coverImageUrl, authorImageUrl: e.authorImageUrl, })); const mergedLocal = [...main, ...ebookOnly]; setProjects(mergedLocal); // ── Background R2 bidirectional sync ────────────────────────────── void (async () => { try { const r2res = await fetch("/api/projects"); if (!r2res.ok) return; const { projects: r2projects } = await r2res.json() as { projects: ProjectSnapshot[] }; if (!Array.isArray(r2projects) || r2projects.length === 0) { // R2 is empty — push all local projects up (initial upload) for (const p of mergedLocal) { await fetch("/api/projects", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ project: p }), }).catch(() => {}); } return; } const r2ById = new Map(r2projects.map((p: ProjectSnapshot) => [p.id, p])); const localById = new Map(mergedLocal.map((p) => [p.id, p])); const toPullLocal: ProjectSnapshot[] = []; const toPushR2: ProjectSnapshot[] = []; // Pull: R2 has newer or unknown project for (const r2p of r2projects) { const local = localById.get(r2p.id); if (!local || new Date(r2p.updatedAt) > new Date(local.updatedAt)) { toPullLocal.push(r2p as ProjectSnapshot); } } // Push: local has newer or unknown project for (const localP of mergedLocal) { const r2p = r2ById.get(localP.id); if (!r2p || new Date(localP.updatedAt) > new Date((r2p as ProjectSnapshot).updatedAt)) { toPushR2.push(localP); } } for (const p of toPullLocal) { await saveProject(p).catch(() => {}); } for (const p of toPushR2) { await fetch("/api/projects", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ project: p }), }).catch(() => {}); } if (toPullLocal.length > 0) { const refreshedMain = await listProjects(); const refreshedMainIds = new Set(refreshedMain.map((p) => p.id)); const refreshedEbook = (await listEbookProjects().catch(() => [])) .filter((e) => !refreshedMainIds.has(e.id)) .map((e) => ({ id: e.id, name: e.name, createdAt: e.createdAt, updatedAt: e.updatedAt, academy: null, siteConfig: SiteConfigSchema.parse({}), deliveryInstructions: "", chatHistory: [], blueprint: null, logicResult: null, uiResult: null, ebookManifest: null, ebookJobState: e.jobState, publishedSlug: e.publishedSlug, coverImageUrl: e.coverImageUrl, authorImageUrl: e.authorImageUrl, })); setProjects([...refreshedMain, ...refreshedEbook]); } } catch { /* R2 sync is best-effort */ } })(); } catch { /* ignore */ } try { setDeliveryInstructions(localStorage.getItem("nexus_delivery_instructions") ?? ""); const raw = localStorage.getItem("nexus_site_config"); if (raw) setSiteConfig(SiteConfigSchema.parse(JSON.parse(raw) as unknown)); } catch { /* ignore */ } })(); }, []); // Populate boot logs client-side only to avoid server/client timestamp mismatch. useEffect(() => { const now = new Date().toISOString(); setLogs([ { id: "sys-1", level: "init", message: "Nexus Director shell online", timestamp: now }, { id: "sys-2", level: "init", message: "5-agent pipeline staged: Analyst → Engineer → Designer → Curator → Executive", timestamp: now }, { id: "sys-3", level: "success", message: "Dynamic viewport lock enforced (dvh + safe-area)", timestamp: now } ]); }, []); const addLog = useCallback((entry: Omit) => { const id = `log-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; setLogs((prev) => [...prev, { ...entry, id, timestamp: new Date().toISOString() }]); if (entry.model !== undefined) { const handle: ModelHandle = entry.model; setModels((prev) => prev.map((m) => m.handle === handle ? { ...m, status: entry.level === "error" ? "error" : entry.level === "success" ? "standby" : "active" } : m ) ); } }, []); const handleAssistantUpdate = useCallback((updated: AcademyPackage, summary: string) => { setAcademyResult(updated); localStorage.setItem("nexus_academy_preview", JSON.stringify(updated)); addLog({ level: "success", message: `Director: ${summary}`, model: "curator" }); }, [addLog]); const handleSiteUpdate = useCallback((config: SiteConfig, summary: string) => { setSiteConfig(config); localStorage.setItem("nexus_site_config", JSON.stringify(config)); addLog({ level: "success", message: `Director: ${summary}`, model: "curator" }); }, [addLog]); // Keep ebookManifest in sync whenever the ebook pipeline finishes or is edited const handleEbookManifestReady = useCallback((manifest: EbookManifest) => { setEbookManifest(manifest); }, []); const EBOOK_JOB_KEY = "nexus_ebook_job_state"; const EBOOK_PENDING_MOUNT_KEY = "nexus_ebook_pending_mount"; const handleSaveProject = useCallback(async (name: string) => { const id = currentProjectId || generateProjectId(); const existingProject = projects.find((p) => p.id === id); const existingEbookProject = await listEbookProjects() .then((items) => items.find((p) => p.id === id)) .catch(() => undefined); // Read the live ebook job state from localStorage (pipeline auto-saves there) let ebookJobState = null; try { const raw = localStorage.getItem(EBOOK_JOB_KEY); if (raw) ebookJobState = EbookJobStateSchema.parse(JSON.parse(raw) as unknown); } catch { /* ignore — job state is optional */ } const snapshot: ProjectSnapshot = { id, name, createdAt: currentProjectId ? (projects.find((p) => p.id === id)?.createdAt ?? new Date().toISOString()) : new Date().toISOString(), updatedAt: new Date().toISOString(), academy: academyResult, siteConfig, deliveryInstructions, chatHistory, blueprint: blueprint ?? null, logicResult: logicResult ?? null, uiResult: uiResult ?? null, ebookManifest: ebookManifest ?? null, ebookJobState: ebookJobState ?? undefined, publishedSlug: existingProject?.publishedSlug ?? existingEbookProject?.publishedSlug, coverImageUrl: existingProject?.coverImageUrl ?? existingEbookProject?.coverImageUrl, authorImageUrl: existingProject?.authorImageUrl ?? existingEbookProject?.authorImageUrl, }; try { await saveProject(snapshot); if (ebookJobState) { await saveEbookProject({ id, name, createdAt: snapshot.createdAt, updatedAt: snapshot.updatedAt, bookTitle: ebookJobState.architecture?.bookTitle ?? name, chapterCount: ebookJobState.chapters?.length ?? 0, totalWordCount: (ebookJobState.chapters ?? []).reduce((sum, chapter) => sum + (chapter.totalWordCount ?? 0), 0), status: ebookJobState.status, jobState: ebookJobState, publishedSlug: snapshot.publishedSlug, coverImageUrl: snapshot.coverImageUrl, authorImageUrl: snapshot.authorImageUrl, }).catch(() => {}); } setCurrentProjectId(id); const main = await listProjects(); const mainIds = new Set(main.map((p) => p.id)); const ebookOnly = (await listEbookProjects().catch(() => [])) .filter((e) => !mainIds.has(e.id)) .map((e) => ({ id: e.id, name: e.name, createdAt: e.createdAt, updatedAt: e.updatedAt, academy: null, siteConfig: SiteConfigSchema.parse({}), deliveryInstructions: "", chatHistory: [], blueprint: null, logicResult: null, uiResult: null, ebookManifest: null, ebookJobState: e.jobState, publishedSlug: e.publishedSlug, coverImageUrl: e.coverImageUrl, authorImageUrl: e.authorImageUrl, })); setProjects([...main, ...ebookOnly]); addLog({ level: "success", message: `Project "${name}" saved.` }); // Sync to R2 (fire-and-forget) fetch("/api/projects", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ project: snapshot }), }).catch(() => {}); } catch (err) { const msg = err instanceof Error ? err.message : "Save failed"; addLog({ level: "error", message: `Could not save project: ${msg}` }); } }, [currentProjectId, projects, academyResult, siteConfig, deliveryInstructions, chatHistory, blueprint, logicResult, uiResult, ebookManifest, addLog]); const handleLoadProject = useCallback(async (id: string) => { const p = projects.find((proj) => proj.id === id); if (!p) return; const legacyJobState = (p as ProjectSnapshot & { jobState?: unknown }).jobState; const rawEbookJobState = p.ebookJobState ?? legacyJobState; const decodedEbookJobState = (() => { if (!rawEbookJobState) return null; let value: unknown = rawEbookJobState; for (let i = 0; i < 3 && typeof value === "string"; i++) { try { value = JSON.parse(value) as unknown; } catch { return null; } } return value; })(); const parsedEbookJobState = decodedEbookJobState ? EbookJobStateSchema.safeParse(decodedEbookJobState) : null; const synthesizedFromManifest = (() => { if (!p.ebookManifest) return null; const nowIso = new Date().toISOString(); return { jobId: p.ebookManifest.jobId || p.id, status: "complete", audioFileNames: [], transcripts: [], masterTranscript: "", filteredTranscript: "", filterRemovedCount: 0, voiceDNA: p.ebookManifest.voiceDNA ?? null, contentMap: { totalEstimatedWords: p.ebookManifest.totalWordCount, overarchingThemes: [], teachingArc: "", coreThesis: "", targetAudience: "", uniqueVocabulary: [], toneMap: "", segments: [], allQuotes: p.ebookManifest.allQuotes ?? [], }, architecture: { bookTitle: p.ebookManifest.bookTitle, subtitle: p.ebookManifest.subtitle, authorName: p.ebookManifest.authorName, estimatedTotalWords: p.ebookManifest.totalWordCount, chapters: [], frontMatterNotes: "", backMatterNotes: "", seriesArc: [], droppedSegments: [], }, sectionAssignments: [], sections: [], chapters: p.ebookManifest.chapters ?? [], frontMatter: p.ebookManifest.frontMatter, backMatter: p.ebookManifest.backMatter ?? null, exportUrls: null, currentStage: "complete", progress: { total: p.ebookManifest.chapters?.length ?? 0, completed: p.ebookManifest.chapters?.length ?? 0, }, errorLog: [], createdAt: p.createdAt ?? nowIso, updatedAt: p.updatedAt ?? nowIso, }; })(); const parsedManifestJobState = synthesizedFromManifest ? EbookJobStateSchema.safeParse(synthesizedFromManifest) : null; const loadableEbookJobState = parsedEbookJobState?.success ? parsedEbookJobState.data : parsedManifestJobState?.success ? parsedManifestJobState.data : null; setBlueprint(p.blueprint); setLogicResult(p.logicResult); setUiResult(p.uiResult); setAcademyResult(p.academy); setSiteConfig(SiteConfigSchema.parse(p.siteConfig ?? {})); setDeliveryInstructions(p.deliveryInstructions ?? ""); setChatHistory(Array.isArray(p.chatHistory) ? p.chatHistory : []); setPanelLoadKey(p.id); setCurrentProjectId(p.id); setEbookManifest(p.ebookManifest ?? null); // Restore full ebook pipeline state so the pipeline can resume from where it left off if (loadableEbookJobState) { try { localStorage.setItem(EBOOK_JOB_KEY, JSON.stringify(loadableEbookJobState)); localStorage.setItem(EBOOK_PENDING_MOUNT_KEY, JSON.stringify({ projectId: p.id, projectName: p.name, jobState: loadableEbookJobState, ebookManifest: p.ebookManifest ?? null, coverImageUrl: p.coverImageUrl ?? null, authorImageUrl: p.authorImageUrl ?? null, ts: Date.now(), })); setEbookPipelineKey((k) => k + 1); // remount pipeline to pick up restored state } catch { /* ignore quota errors */ } await saveEbookProject({ id: p.id, name: p.name, createdAt: p.createdAt, updatedAt: new Date().toISOString(), bookTitle: loadableEbookJobState.architecture?.bookTitle ?? p.name, chapterCount: loadableEbookJobState.chapters?.length ?? 0, totalWordCount: (loadableEbookJobState.chapters ?? []).reduce((sum, chapter) => sum + (chapter.totalWordCount ?? 0), 0), status: loadableEbookJobState.status, jobState: loadableEbookJobState, publishedSlug: p.publishedSlug, coverImageUrl: p.coverImageUrl, authorImageUrl: p.authorImageUrl, }).catch(() => {}); } if (p.blueprint) setStage("done"); // Navigate to ebook tab if the project has a book; otherwise overview if (loadableEbookJobState) { router.push(`/ebook?tab=pipeline&load=${encodeURIComponent(p.id)}`); } else if (p.ebookManifest) { addLog({ level: "warn", message: `Project "${p.name}" has no resumable ebook job state.` }); router.push(`/ebook?tab=pipeline&load=${encodeURIComponent(p.id)}`); } else { setActiveNav("overview"); } // Update the shared localStorage keys so preview pages also see the loaded data if (p.academy) localStorage.setItem("nexus_academy_preview", JSON.stringify(p.academy)); localStorage.setItem("nexus_site_config", JSON.stringify(p.siteConfig)); localStorage.setItem("nexus_delivery_instructions", p.deliveryInstructions); addLog({ level: "success", message: `Project "${p.name}" loaded.` }); }, [projects, addLog, router]); useEffect(() => { if (activeNav === "ebook") { router.replace("/ebook?tab=pipeline"); } }, [activeNav, router]); const handleDeleteProject = useCallback(async (id: string) => { await deleteProject(id); await deleteEbookProject(id).catch(() => {}); const main = await listProjects(); const mainIds = new Set(main.map((p) => p.id)); const ebookOnly = (await listEbookProjects().catch(() => [])) .filter((e) => !mainIds.has(e.id)) .map((e) => ({ id: e.id, name: e.name, createdAt: e.createdAt, updatedAt: e.updatedAt, academy: null, siteConfig: SiteConfigSchema.parse({}), deliveryInstructions: "", chatHistory: [], blueprint: null, logicResult: null, uiResult: null, ebookManifest: null, ebookJobState: e.jobState, publishedSlug: e.publishedSlug, coverImageUrl: e.coverImageUrl, authorImageUrl: e.authorImageUrl, })); setProjects([...main, ...ebookOnly]); 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]); const handleImportProject = useCallback(async (snapshot: ProjectSnapshot) => { await saveProject(snapshot); if (snapshot.ebookJobState) { await saveEbookProject({ id: snapshot.id, name: snapshot.name, createdAt: snapshot.createdAt, updatedAt: snapshot.updatedAt, bookTitle: snapshot.ebookJobState.architecture?.bookTitle ?? snapshot.name, chapterCount: snapshot.ebookJobState.chapters?.length ?? 0, totalWordCount: (snapshot.ebookJobState.chapters ?? []).reduce((sum, chapter) => sum + (chapter.totalWordCount ?? 0), 0), status: snapshot.ebookJobState.status, jobState: snapshot.ebookJobState, publishedSlug: snapshot.publishedSlug, coverImageUrl: snapshot.coverImageUrl, authorImageUrl: snapshot.authorImageUrl, }).catch(() => {}); } const main = await listProjects(); const mainIds = new Set(main.map((p) => p.id)); const ebookOnly = (await listEbookProjects().catch(() => [])) .filter((e) => !mainIds.has(e.id)) .map((e) => ({ id: e.id, name: e.name, createdAt: e.createdAt, updatedAt: e.updatedAt, academy: null, siteConfig: SiteConfigSchema.parse({}), deliveryInstructions: "", chatHistory: [], blueprint: null, logicResult: null, uiResult: null, ebookManifest: null, ebookJobState: e.jobState, publishedSlug: e.publishedSlug, coverImageUrl: e.coverImageUrl, authorImageUrl: e.authorImageUrl, })); setProjects([...main, ...ebookOnly]); addLog({ level: "success", message: `Project "${snapshot.name}" imported.` }); fetch("/api/projects", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ project: snapshot }), }).catch(() => {}); }, [addLog]); const handlePublishProject = useCallback(async (snapshot: ProjectSnapshot): Promise => { const job = snapshot.ebookJobState; const manifest = snapshot.ebookManifest; if (!job?.architecture && !manifest) { addLog({ level: "error", message: "No ebook content found — run the ebook pipeline first." }); return null; } // Always prefer job.chapters (fresher — includes premiseLine, latest edits) over manifest.chapters. // Never filter by status: a chapter that finished writing but isn't "complete" would be silently dropped. const jobChapters = job?.chapters?.length ? job.chapters : undefined; const publishManifest = manifest ? { ...manifest, chapters: jobChapters ?? manifest.chapters, frontMatter: job?.frontMatter ?? manifest.frontMatter, } : { 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: number, c: { totalWordCount?: number }) => s + (c.totalWordCount ?? 0), 0), allQuotes: job!.contentMap?.allQuotes ?? [], generatedAt: job!.updatedAt ?? new Date().toISOString(), selectedTemplate: "devotional", printSpec: { trimSize: "6x9", runningHeaders: true }, }; const body = { manifest: publishManifest, coverAccent: "amber", ...(snapshot.coverImageUrl ? { coverImageUrl: snapshot.coverImageUrl } : {}), ...(snapshot.authorImageUrl ? { authorImageUrl: snapshot.authorImageUrl } : {}), }; try { const res = await fetch("/api/ebook/publish", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!res.ok) { const err = await res.json() as { error?: string }; addLog({ level: "error", message: err.error ?? "Publish failed." }); return null; } const { slug } = await res.json() as { slug: string }; await saveProject({ ...snapshot, publishedSlug: slug }); // Also update the ebook store so /ebook panel shows View in Library / Republish const ebookProjects = await listEbookProjects().catch(() => []); const ep = ebookProjects.find((e) => e.id === snapshot.id); if (ep) await saveEbookProject({ ...ep, publishedSlug: slug }).catch(() => {}); // Reload merged project list const freshMain = await listProjects(); const freshMainIds = new Set(freshMain.map((p) => p.id)); const freshEbook = (await listEbookProjects().catch(() => [])).filter((e) => !freshMainIds.has(e.id)).map((e) => ({ id: e.id, name: e.name, createdAt: e.createdAt, updatedAt: e.updatedAt, academy: null, siteConfig: SiteConfigSchema.parse({}), deliveryInstructions: "", chatHistory: [], blueprint: null, logicResult: null, uiResult: null, ebookManifest: null, ebookJobState: e.jobState, publishedSlug: e.publishedSlug, })); setProjects([...freshMain, ...freshEbook]); addLog({ level: "success", message: `Published to /library/${slug}` }); // Sync updated publishedSlug to R2 fetch("/api/projects", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ project: { ...snapshot, publishedSlug: slug } }), }).catch(() => {}); return slug; } catch (err) { addLog({ level: "error", message: err instanceof Error ? err.message : "Publish failed." }); return null; } }, [addLog]); const handleUpdateImages = useCallback(async (id: string, coverImageUrl?: string, authorImageUrl?: string) => { const p = projects.find((proj) => proj.id === id); if (!p) return; const updated: ProjectSnapshot = { ...p, ...(coverImageUrl !== undefined ? { coverImageUrl } : {}), ...(authorImageUrl !== undefined ? { authorImageUrl } : {}), }; await saveProject(updated); const ebookProjects = await listEbookProjects().catch(() => []); const ep = ebookProjects.find((e) => e.id === id); if (ep) { await saveEbookProject({ ...ep, ...(coverImageUrl !== undefined ? { coverImageUrl } : {}), ...(authorImageUrl !== undefined ? { authorImageUrl } : {}), }).catch(() => {}); } const main = await listProjects(); const mainIds = new Set(main.map((p) => p.id)); const ebookOnly = (await listEbookProjects().catch(() => [])) .filter((e) => !mainIds.has(e.id)) .map((e) => ({ id: e.id, name: e.name, createdAt: e.createdAt, updatedAt: e.updatedAt, academy: null, siteConfig: SiteConfigSchema.parse({}), deliveryInstructions: "", chatHistory: [], blueprint: null, logicResult: null, uiResult: null, ebookManifest: null, ebookJobState: e.jobState, publishedSlug: e.publishedSlug, coverImageUrl: e.coverImageUrl, authorImageUrl: e.authorImageUrl, })); setProjects([...main, ...ebookOnly]); fetch("/api/projects", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ project: updated }), }).catch(() => {}); // If already published, push the new images to the library immediately if (updated.publishedSlug) { handlePublishProject(updated).catch(() => {}); } }, [projects, handlePublishProject]); const handleUnpublishProject = useCallback(async (snapshot: ProjectSnapshot): Promise => { if (!snapshot.publishedSlug) return false; try { const res = await fetch("/api/ebook/publish", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ slug: snapshot.publishedSlug }), }); if (!res.ok) { const err = await res.json() as { error?: string }; addLog({ level: "error", message: err.error ?? "Unpublish failed." }); return false; } // Clear publishedSlug from both stores await saveProject({ ...snapshot, publishedSlug: undefined }); const ebookProjects = await listEbookProjects().catch(() => []); const ep = ebookProjects.find((e) => e.id === snapshot.id); if (ep) await saveEbookProject({ ...ep, publishedSlug: undefined }).catch(() => {}); // Refresh project list const main = await listProjects(); const mainIds = new Set(main.map((p) => p.id)); const ebookOnly = (await listEbookProjects().catch(() => [])) .filter((e) => !mainIds.has(e.id)) .map((e) => ({ id: e.id, name: e.name, createdAt: e.createdAt, updatedAt: e.updatedAt, academy: null, siteConfig: SiteConfigSchema.parse({}), deliveryInstructions: "", chatHistory: [], blueprint: null, logicResult: null, uiResult: null, ebookManifest: null, ebookJobState: e.jobState, publishedSlug: e.publishedSlug, coverImageUrl: e.coverImageUrl, authorImageUrl: e.authorImageUrl, })); setProjects([...main, ...ebookOnly]); addLog({ level: "success", message: `"${snapshot.name}" removed from library.` }); // Sync cleared publishedSlug to R2 fetch("/api/projects", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ project: { ...snapshot, publishedSlug: undefined } }), }).catch(() => {}); return true; } catch (err) { addLog({ level: "error", message: err instanceof Error ? err.message : "Unpublish failed." }); return false; } }, [addLog]); const handleStageChange = useCallback((s: PipelineStage) => { setStage(s); if (s === "done" || s === "error" || s === "idle") { setModels((prev) => prev.map((m) => ({ ...m, status: "standby" }))); } }, []); // ── Shared stages 2-4 runner ────────────────────────────────────────────── // Pass existing results to skip completed stages (used by resume). const runDownstream = useCallback(async ( ingestResult: IngestResult, sourceText: string, existingLogic: LogicTransformResult | null, existingUi: UiManifestResult | null, ) => { try { let logicData = existingLogic; let uiData = existingUi; // ── Stage 2: DeepSeek Engineer → execution logic graph ──────────── if (!logicData) { handleStageChange("reasoning"); addLog({ level: "info", message: "Blueprint dispatched to Engineer — building execution graph…", model: "deepseek" }); const logicRes = await fetch("/api/generate-logic", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ objective: `${ingestResult.title}: ${ingestResult.summary}`, constraints: [], blueprint: { ...ingestResult, createdAtIso: new Date().toISOString() } }) }); if (!logicRes.ok) { const e = await logicRes.json().catch(() => ({ error: `HTTP ${logicRes.status}` })) as { error?: string; detail?: string }; throw new Error(e.detail ?? e.error ?? `Logic stage: HTTP ${logicRes.status}`); } logicData = LogicTransformResultSchema.parse(await logicRes.json() as unknown); setLogicResult(logicData); setActiveNav("architect"); addLog({ level: "success", message: `Execution graph ready — ${logicData.executionPlan.length} step${logicData.executionPlan.length !== 1 ? "s" : ""} planned`, model: "deepseek" }); } else { addLog({ level: "info", message: "Execution graph already complete — skipping Stage 2" }); } // ── Stage 3: Claude Designer → UI manifest ──────────────────────── if (!uiData) { handleStageChange("generating"); addLog({ level: "info", message: "Execution plan dispatched to Designer — synthesising UI manifest…", model: "claude" }); const uiRes = await fetch("/api/generate-ui", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ objective: `${ingestResult.title}: ${ingestResult.summary}`, domain: "Digital Product Academy", constraints: [ "iPad Safari safe — dvh units only", "Dark mode only", "48px minimum touch targets", "No hover-only interactions" ] }) }); if (!uiRes.ok) { const e = await uiRes.json().catch(() => ({ error: `HTTP ${uiRes.status}` })) as { error?: string; detail?: string }; throw new Error(e.detail ?? e.error ?? `UI stage: HTTP ${uiRes.status}`); } uiData = UiManifestResultSchema.parse(await uiRes.json() as unknown); setUiResult(uiData); setActiveNav("design"); addLog({ level: "success", message: `UI manifest complete — ${uiData.components.length} component${uiData.components.length !== 1 ? "s" : ""} · ${uiData.interactions.length} interaction pattern${uiData.interactions.length !== 1 ? "s" : ""}`, model: "claude" }); } else { addLog({ level: "info", message: "UI manifest already complete — skipping Stage 3" }); } // ── Stage 4: DeepSeek Curator → academy package ─────────────────── handleStageChange("producing"); addLog({ level: "info", message: "UI spec dispatched to Curator — packaging full academy…", model: "curator" }); const produceRes = await fetch("/api/produce", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: ingestResult.title, summary: ingestResult.summary, assets: ingestResult.assets, workflow: ingestResult.workflow, executionPlan: logicData.executionPlan, entities: logicData.entities, visualDirection: uiData.visualDirection, rawTranscript: sourceText, deliveryInstructions, }) }); if (!produceRes.ok || !produceRes.body) { const e = await produceRes.json().catch(() => ({ error: `HTTP ${produceRes.status}` })) as { error?: string; detail?: string }; throw new Error(e.detail ?? e.error ?? `Produce stage: HTTP ${produceRes.status}`); } const reader = produceRes.body.getReader(); const decoder = new TextDecoder(); let fullBuffer = ""; while (true) { const { done, value } = await reader.read(); if (done) break; fullBuffer += decoder.decode(value, { stream: true }); } let academyRaw: unknown = null; for (const line of fullBuffer.split("\n")) { if (!line.startsWith("data: ")) continue; const parsed = JSON.parse(line.slice(6)) as { error?: string } & Record; if (parsed.error) throw new Error(parsed.error); academyRaw = parsed; break; } if (!academyRaw) throw new Error("Produce stage: empty response from server"); const academyData = AcademyPackageSchema.parse(academyRaw); setAcademyResult(academyData); localStorage.setItem("nexus_academy_preview", JSON.stringify(academyData)); setActiveNav("produce"); addLog({ level: "success", message: `Academy package ready — ${academyData.curriculum.length} module${academyData.curriculum.length !== 1 ? "s" : ""}, ${academyData.pricing.length} pricing tiers`, model: "curator" }); handleStageChange("done"); } catch (err) { const msg = err instanceof Error ? err.message : "Pipeline error"; addLog({ level: "error", message: msg }); handleStageChange("error"); } }, [deliveryInstructions, addLog, handleStageChange]); const handleBlueprint = useCallback(async (ingestResult: IngestResult, sourceText = "") => { setLogicResult(null); setUiResult(null); setAcademyResult(null); setBlueprint(ingestResult); setRawTranscript(sourceText); setActiveNav("analyse"); addLog({ level: "success", message: `Blueprint ready — ${ingestResult.workflow.length} step${ingestResult.workflow.length !== 1 ? "s" : ""}, ${ingestResult.assets.length} asset${ingestResult.assets.length !== 1 ? "s" : ""}`, }); if (ingestResult.assets.length === 0 || ingestResult.workflow.length === 0) { addLog({ level: "warn", message: "Blueprint has no assets or workflow — skipping logic + design stages" }); handleStageChange("done"); return; } await runDownstream(ingestResult, sourceText, null, null); }, [addLog, handleStageChange, runDownstream]); // Resume from the first incomplete stage using whatever is already in state const handleResume = useCallback(async () => { if (!blueprint) return; const resumeStage = !logicResult ? 2 : !uiResult ? 3 : 4; addLog({ level: "info", message: `Resuming pipeline from Stage ${resumeStage}…` }); await runDownstream(blueprint, rawTranscript, logicResult, uiResult); }, [blueprint, rawTranscript, logicResult, uiResult, runDownstream, addLog]); // Map left-nav items to the PipelineResults tab they drive const NAV_TAB: Record = { overview: null, analyse: "blueprint", architect: "logic", design: "ui", produce: "academy", deploy: null, projects: null, ebook: null, sermon: null, }; const focusedTab = NAV_TAB[activeNav] ?? undefined; const isFocused = activeNav !== "overview"; const isSermonView = activeNav === "sermon"; const isBookView = activeNav === "ebook"; const showActivityPanel = !isSermonView && !isBookView; const handleNavSelect = useCallback((id: string) => { if (id === "ebook") { router.push("/ebook?tab=pipeline"); return; } if (id === "translate") { router.push("/translate"); return; } setActiveNav(id); }, [router]); return (
{/* Content column — reserves bottom space for mobile bottom nav (~60px) */}
setAssistantOpen((v) => !v)} assistantActive={assistantOpen} /> {/* Mobile: flex-col + overflow-y-auto so panels stack and scroll. Desktop (lg+): grid 5-cols + overflow-hidden so panels fill height. */}
{/* ── Focused agent views (non-overview nav) ── */} {isFocused ? ( <> {/* Terminal — hidden for sermon mode to maximize writing/presentation space */} {showActivityPanel && (
)} {/* Primary panel — full width on mobile, sermon uses full desktop width */}
{activeNav === "projects" ? ( ) : activeNav === "ebook" ? (
) : activeNav === "sermon" ? ( ) : activeNav === "deploy" ? (

Deploy

{blueprint?.title ?? "No project yet"}

{blueprint ? (
Launch Landing Page Preview Open Course Player

Pipeline Status

{stage === "done" ? "Build complete — ready to deploy" : stage === "error" ? "Pipeline error" : stage === "idle" ? "Standing by" : "Pipeline running…"}

{stage === "error" && blueprint && ( )}
) : (

Run the pipeline first to generate a deployable academy.

)}
) : blueprint ? ( ) : (

Run the pipeline to see {activeNav} results

)}
) : ( /* ── Overview (default) layout ── */ <> {/* Terminal log — full width mobile, 3/5 desktop */}
{/* Right column — full width mobile (stacked), 2/5 desktop (grid-rows-2) */}
{blueprint !== null ? ( <> {stage === "error" && ( )} ) : ( )}
)}
{!isSermonView && !isBookView && ( )}
{/* Director AI drawer — toggled from StatusBar */} setAssistantOpen(false)} academy={academyResult} onUpdate={handleAssistantUpdate} siteConfig={siteConfig} onSiteUpdate={handleSiteUpdate} ebookManifest={ebookManifest} onEbookUpdate={(updated, summary) => { setEbookManifest(updated); if (summary) addLog({ level: "success", message: `Director: ${summary}`, model: "curator" }); }} ebookPipelineSnapshot={ebookSnapshot} loadedHistory={chatHistory} loadKey={panelLoadKey} onChatChange={setChatHistory} />
); }