import { forwardRef, useEffect, useRef, useState } from "react"; import { Eye, UploadCloud, CheckCircle2, AlertTriangle, Sparkles, ExternalLink, } from "lucide-react"; export type PublishState = "idle" | "loading" | "success" | "error"; /** * Stage events emitted by the backend during publish. Kept in sync with * `PublishStage` in `backend/src/publisher/index.ts` and the sub-stages * emitted by `pdf-generator.ts`. We accept `string` so an unknown stage * never breaks the UI. */ export type PublishStage = | "extract" | "bibliography" | "render" | "load" | "thumbnail" | "pdf-setup" | "pdf-toc" | "rasterize" | "pdf-render" | "write" | "upload" | string; export interface PublishStageEvent { stage: PublishStage; detail?: { index?: number; total?: number; entries?: number; }; } interface Props { state: PublishState; error: string; docName: string; stageEvent: PublishStageEvent | null; onClose: () => void; onPublish: () => void; } const STAGE_LABELS: Record = { extract: "Extracting content", bibliography: "Formatting references", render: "Rendering HTML", load: "Loading article", thumbnail: "Capturing thumbnail", "pdf-setup": "Preparing PDF layout", "pdf-toc": "Building table of contents", rasterize: "Rasterizing embeds", "pdf-render": "Rendering PDF", write: "Writing files", upload: "Uploading to storage", }; // Canonical order used to compute a progress ratio. Stages not in // this list still render their label but don't retract the bar. const STAGE_ORDER: PublishStage[] = [ "extract", "bibliography", "render", "load", "thumbnail", "pdf-setup", "pdf-toc", "rasterize", "pdf-render", "write", "upload", ]; function formatStage(event: PublishStageEvent | null): string { if (!event) return "Publishing..."; const base = STAGE_LABELS[event.stage] ?? "Publishing..."; if (event.stage === "rasterize" && event.detail?.index && event.detail?.total) { return `${base} (${event.detail.index}/${event.detail.total})`; } return base; } /** * Compute a 0..1 progress ratio for `event`, blending rasterize * sub-progress. Returns `null` for unknown stages so the caller can * hold the previous value instead of retracting the bar. */ function computeProgress(event: PublishStageEvent | null): number | null { if (!event) return 0.04; const idx = STAGE_ORDER.indexOf(event.stage); if (idx === -1) return null; const slots = STAGE_ORDER.length + 1; let base = (idx + 1) / slots; if ( event.stage === "rasterize" && event.detail?.index && event.detail?.total ) { const rasterFrac = Math.min(1, event.detail.index / event.detail.total); base = idx / slots + rasterFrac / slots; } return Math.max(0.04, Math.min(0.98, base)); } /** * Native `` shown when the user clicks "Publish" in the top bar. * Displays live stage progress during the long PDF pipeline (typically * 15-25s) thanks to the Server-Sent Events stream from `/api/publish/stream`. */ export const PublishDialog = forwardRef(function PublishDialog( { state, error, docName, stageEvent, onClose, onPublish }, ref ) { const stageLabel = formatStage(stageEvent); // Monotonic progress. The backend can emit unknown stages or // re-emit earlier ones during retries; the bar should never walk // backwards on camera. const progressRef = useRef(0.04); const [progress, setProgress] = useState(0.04); useEffect(() => { if (state === "idle") { progressRef.current = 0.04; setProgress(0.04); return; } if (state === "success") { progressRef.current = 1; setProgress(1); return; } if (state === "error") return; const next = computeProgress(stageEvent); if (next == null) return; if (next > progressRef.current) { progressRef.current = next; setProgress(next); } }, [stageEvent, state]); // Readers land on the Space root: when a published article exists, // `backend/src/create-app.ts` (`app.get("*")`) serves it at `/`. The // `/published//...` path is an implementation detail used by // the publisher pipeline (PDF generation, og:image, ...), not the // canonical reader URL. So we surface the Space URL itself. const origin = typeof window !== "undefined" ? window.location.origin : ""; const publishedUrl = origin ? `${origin}/` : "/"; const displayUrl = origin || "/"; return (
{state === "idle" && } {state === "loading" && } {state === "success" && } {state === "error" && }

{state === "idle" && "Publish article"} {state === "loading" && "Publishing your article"} {state === "success" && "Published!"} {state === "error" && "Publish failed"}

{state === "idle" && "Static HTML, print-ready PDF and thumbnail."} {state === "loading" && stageLabel} {state === "success" && "Your update is live for every reader."} {state === "error" && "We couldn't finish the publish run."}

{state === "idle" && (
  • Static HTML page ready to share.
  • Print-ready PDF with full typography.
  • Social thumbnail captured automatically.

Takes 15-25s. Collaborators can keep editing in the meantime.

)} {state === "loading" && (

Rasterizing embeds and rendering the PDF can take a moment. Keep this tab open until it finishes.

)} {state === "success" && (
Live at {displayUrl}
View published article
)} {state === "error" && (

{error || "An error occurred while publishing."}

)}
); });