"use client"; import { useEffect, useRef, useState } from "react"; import { useDeploy } from "@/lib/use-deploy"; import { useT } from "@/lib/i18n"; import { useStore, selectActiveTask, type DeploymentRecord, } from "@/lib/store"; const EMPTY_DEPLOYMENTS: readonly DeploymentRecord[] = []; /** * Publish-to-Vercel control rendered next to the preview-pane toolbar * actions. Renders three things in one rounded popover-anchor block: * * 1. The Publish / Deploying… / coral pill — primary CTA. Disabled when * the task has no html yet. * 2. A compact result line — "Live at [Copy] [Open]" — appearing * immediately below the button after a successful deploy. Persists * until the user navigates away from the task. * 3. A "Past deployments" dropdown listing the bounded ring (latest 5 * entries, hash-tagged so the user can tell which version of the html * each url corresponds to). * * Cloudflare Pages provider is intentionally absent until the wasm-blake3 * dependency lands in a follow-up PR — Settings → Deploy already shows a * "coming soon" placeholder for it. */ export function DeployControl({ onRequestConfigureDeploy, configRev = 0, }: { onRequestConfigureDeploy: () => void; configRev?: number; }) { const html = useStore((s) => selectActiveTask(s)?.html ?? ""); const taskId = useStore((s) => s.activeTaskId); const deployments = useStore( (s) => selectActiveTask(s)?.deployments ?? EMPTY_DEPLOYMENTS, ); const removeDeploymentFor = useStore((s) => s.removeDeploymentFor); const t = useT(); const { status, error, latest, deploy } = useDeploy(); const [historyOpen, setHistoryOpen] = useState(false); const [copiedUrl, setCopiedUrl] = useState(null); const [dismissedLatestId, setDismissedLatestId] = useState( null, ); // null = unknown (still loading), true/false = checked state. Probed via // the same /api/deploy/config endpoint Settings → Deploy reads, so the // toolbar Publish button knows whether to deploy or steer the user to // configure a token first. const [configured, setConfigured] = useState(null); const popoverRef = useRef(null); useEffect(() => { let cancelled = false; fetch("/api/deploy/config?provider=vercel") .then((r) => r.json()) .then((d: { configured?: boolean }) => { if (!cancelled) setConfigured(!!d?.configured); }) .catch(() => { if (!cancelled) setConfigured(false); }); return () => { cancelled = true; }; }, [configRev]); // Close the history dropdown on outside click. useEffect(() => { if (!historyOpen) return; const onClick = (e: MouseEvent) => { if (!popoverRef.current?.contains(e.target as Node)) { setHistoryOpen(false); } }; document.addEventListener("mousedown", onClick); return () => document.removeEventListener("mousedown", onClick); }, [historyOpen]); // Auto-clear the "Copied" feedback after 1.5 s. useEffect(() => { if (!copiedUrl) return; const id = setTimeout(() => setCopiedUrl(null), 1500); return () => clearTimeout(id); }, [copiedUrl]); const isDeploying = status === "deploying"; const isUnconfigured = configured === false; // The button is clickable in two cases: a normal deploy (configured + has html), // or an unconfigured-state click that steers the user to Settings. Anything else // (deploying, or configured-but-no-html) disables the button. const disabled = isDeploying || (!isUnconfigured && html.length === 0); const onClickPublish = () => { if (isDeploying) return; if (isUnconfigured) { onRequestConfigureDeploy(); return; } if (html.length === 0) return; void deploy({ taskId, provider: "vercel", html }); }; const onCopy = async (url: string) => { try { await navigator.clipboard.writeText(url); setCopiedUrl(url); } catch { /* clipboard may be denied — surface nothing, the URL is still visible */ } }; // Newest record first; `latest` may be the same as `deployments[0]` so we // dedupe by id to avoid rendering it twice in the result line + history. const visibleLatest = latest && latest.id !== dismissedLatestId ? latest : null; const visibleHistory = deployments.filter((d) => d.id !== visibleLatest?.id); return (
{deployments.length > 0 && ( )} {/* Inline result row — shown right after a successful deploy. */} {visibleLatest && (
{visibleLatest.status === "ready" ? t("deploy.success.label") : visibleLatest.status === "protected" ? t("deploy.protected.label") : t("deploy.delayed.label")}
{visibleLatest.url} {visibleLatest.status !== "ready" && visibleLatest.statusMessage && (
{visibleLatest.statusMessage}
)}
↗ {t("deploy.success.open")}
)} {/* History dropdown — past deployments other than `latest`. */} {historyOpen && visibleHistory.length > 0 && (
{t("deploy.history.title")}
{visibleHistory.map((d) => ( removeDeploymentFor(taskId, d.id)} onCopy={() => onCopy(d.url)} copied={copiedUrl === d.url} t={t} /> ))}
)} {error && status === "error" && (
{t("deploy.error.label")}
{error}
)}
); } function HistoryRow({ d, onForget, onCopy, copied, t, }: { d: DeploymentRecord; onForget: () => void; onCopy: () => void; copied: boolean; t: ReturnType; }) { const ts = new Date(d.deployedAt); const stamp = `${ts.getMonth() + 1}/${ts.getDate()} ${String(ts.getHours()).padStart(2, "0")}:${String(ts.getMinutes()).padStart(2, "0")}`; return (
{d.url}
{d.provider} · {stamp} {d.htmlHash && ( <> · #{d.htmlHash.slice(0, 6)} )}
); }