carbon-tokenization / frontend /src /components /PublishDialog.tsx
tfrere's picture
tfrere HF Staff
fix(publish-dialog): show Space URL instead of /published/<doc> path
8d2864f
Raw
History Blame Contribute Delete
9.1 kB
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<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",
};
// 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 `<dialog>` 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<HTMLDialogElement, Props>(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<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]);
// Readers land on the Space root: when a published article exists,
// `backend/src/create-app.ts` (`app.get("*")`) serves it at `/`. The
// `/published/<docName>/...` 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 (
<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>
);
});