| import { forwardRef, useEffect, useRef, useState } from "react"; |
| import { |
| Eye, |
| UploadCloud, |
| CheckCircle2, |
| AlertTriangle, |
| Sparkles, |
| ExternalLink, |
| } from "lucide-react"; |
|
|
| export type PublishState = "idle" | "loading" | "success" | "error"; |
|
|
| |
| |
| |
| |
| |
| |
| 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<string, string> = { |
| 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", |
| }; |
|
|
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| 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)); |
| } |
|
|
| |
| |
| |
| |
| |
| export const PublishDialog = forwardRef<HTMLDialogElement, Props>(function PublishDialog( |
| { state, error, docName, stageEvent, onClose, onPublish }, |
| ref |
| ) { |
| const stageLabel = formatStage(stageEvent); |
|
|
| |
| |
| |
| const progressRef = useRef<number>(0.04); |
| const [progress, setProgress] = useState<number>(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]); |
|
|
| |
| |
| |
| |
| |
| const origin = typeof window !== "undefined" ? window.location.origin : ""; |
| const publishedUrl = origin ? `${origin}/` : "/"; |
| const displayUrl = origin || "/"; |
|
|
| return ( |
| <dialog |
| ref={ref} |
| className={`ed-dialog ed-dialog--publish ed-dialog--publish-${state}`} |
| onClose={onClose} |
| > |
| <header className="publish-dialog__header"> |
| <div className={`publish-dialog__icon publish-dialog__icon--${state}`}> |
| {state === "idle" && <UploadCloud size={22} strokeWidth={1.75} />} |
| {state === "loading" && <span className="spinner" aria-hidden />} |
| {state === "success" && <CheckCircle2 size={22} strokeWidth={1.75} />} |
| {state === "error" && <AlertTriangle size={22} strokeWidth={1.75} />} |
| </div> |
| <div className="publish-dialog__heading"> |
| <h2 className="publish-dialog__title"> |
| {state === "idle" && "Publish article"} |
| {state === "loading" && "Publishing your article"} |
| {state === "success" && "Published!"} |
| {state === "error" && "Publish failed"} |
| </h2> |
| <p className="publish-dialog__subtitle"> |
| {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."} |
| </p> |
| </div> |
| </header> |
| |
| <div className="publish-dialog__body"> |
| {state === "idle" && ( |
| <div className="publish-dialog__intro"> |
| <ul className="publish-dialog__checklist"> |
| <li> |
| <span className="publish-dialog__bullet" aria-hidden /> |
| <span> |
| <strong>Static HTML</strong> page ready to share. |
| </span> |
| </li> |
| <li> |
| <span className="publish-dialog__bullet" aria-hidden /> |
| <span> |
| <strong>Print-ready PDF</strong> with full typography. |
| </span> |
| </li> |
| <li> |
| <span className="publish-dialog__bullet" aria-hidden /> |
| <span> |
| <strong>Social thumbnail</strong> captured automatically. |
| </span> |
| </li> |
| </ul> |
| <p className="publish-dialog__note"> |
| <Sparkles size={12} strokeWidth={2} aria-hidden /> |
| Takes 15-25s. Collaborators can keep editing in the meantime. |
| </p> |
| </div> |
| )} |
| |
| {state === "loading" && ( |
| <div className="publish-dialog__progress"> |
| <div |
| className="publish-dialog__bar" |
| role="progressbar" |
| aria-valuemin={0} |
| aria-valuemax={100} |
| aria-valuenow={Math.round(progress * 100)} |
| > |
| <span |
| className="publish-dialog__bar-fill" |
| style={{ width: `${Math.round(progress * 100)}%` }} |
| /> |
| </div> |
| <p className="publish-dialog__hint"> |
| Rasterizing embeds and rendering the PDF can take a moment. Keep |
| this tab open until it finishes. |
| </p> |
| </div> |
| )} |
| |
| {state === "success" && ( |
| <div className="publish-dialog__success"> |
| <div className="publish-dialog__success-url"> |
| <span className="publish-dialog__url-label">Live at</span> |
| <code className="publish-dialog__url-code">{displayUrl}</code> |
| </div> |
| <a |
| href={publishedUrl} |
| target="_blank" |
| rel="noopener noreferrer" |
| className="btn btn--primary publish-dialog__view-btn" |
| > |
| <Eye size={16} /> |
| View published article |
| <ExternalLink size={13} strokeWidth={2} aria-hidden /> |
| </a> |
| </div> |
| )} |
| |
| {state === "error" && ( |
| <p className="publish-dialog__error"> |
| {error || "An error occurred while publishing."} |
| </p> |
| )} |
| </div> |
| |
| <footer className="publish-dialog__actions"> |
| {state === "idle" && ( |
| <> |
| <button className="btn" onClick={onClose}>Cancel</button> |
| <button className="btn btn--primary" onClick={onPublish}> |
| <UploadCloud size={14} strokeWidth={2} aria-hidden /> |
| Publish |
| </button> |
| </> |
| )} |
| {(state === "success" || state === "error") && ( |
| <button className="btn" onClick={onClose}>Close</button> |
| )} |
| </footer> |
| </dialog> |
| ); |
| }); |
|
|