commit 7d3b71d05ba7b088b740c45c38462287ad3f1444
parent 4d523ddbd8b70965bd15143334a3fff5b0a8c57b
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Wed, 23 Sep 2026 09:00:13 -0400
sites: a preview control beside the production deploy
The production button gains the word "production": with a second deploy button
next to it, a button labelled only "Deploy" is the one somebody clicks by
reflex when they meant the safe one. The preview's branch input refuses through
the same previewBranchProblem the server does, so the button greys out on
exactly the names that would have come back as a 400 and with the same
sentence. The alias is a function of project and branch alone, so it is linked
while you type rather than fished out of the finished log.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
2 files changed, 138 insertions(+), 13 deletions(-)
diff --git a/editor/app/sites/[siteId]/publish/page.tsx b/editor/app/sites/[siteId]/publish/page.tsx
@@ -96,10 +96,15 @@ export default async function SitePublishPage({
<h3 className="font-semibold">Deploy static export</h3>
<p className="text-sm text-muted-foreground">
Publishes the most recently built site at{" "}
- <code>export/out/</code> to Cloudflare Pages. Build first.
+ <code>export/out/</code> to Cloudflare Pages — to production,
+ or to a preview branch you can look at first. Build first.
</p>
</div>
- <DeployButton siteId={siteId} siteTitle={siteTitle} />
+ <DeployButton
+ siteId={siteId}
+ siteTitle={siteTitle}
+ cloudflareProject={site.cloudflareProject ?? null}
+ />
</div>
</div>
</details>
diff --git a/editor/app/sites/components/DeployButton.tsx b/editor/app/sites/components/DeployButton.tsx
@@ -1,6 +1,12 @@
"use client";
+import { useState } from "react";
import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog";
+import {
+ MAX_PREVIEW_BRANCH,
+ previewAliasUrl,
+ previewBranchProblem,
+} from "yt-dlp-transcript-common/lib/pagesDeploy";
import { cancelJobAction } from "../../jobs/actions";
import { deployExportAction } from "../lib/deployAction";
@@ -8,21 +14,135 @@ type Props = {
// The site whose Publish tab this is — always a real site.
siteId: string;
siteTitle: string;
+ // The Cloudflare Pages project, when the site has one. Needed to SHOW the
+ // preview alias, which is a function of project + branch — so with no project
+ // there is no preview to offer, and the control says that instead of linking
+ // a hostname that cannot exist.
+ cloudflareProject?: string | null;
};
-export function DeployButton({ siteId, siteTitle }: Props) {
+const DEFAULT_PREVIEW_BRANCH = "preview";
+
+// Two deploys of the same already-built bundle: to production, or to a
+// Cloudflare Pages preview branch.
+//
+// THE PRODUCTION BUTTON SAYS "PRODUCTION" NOW. With a preview control beside
+// it, a button labelled only "Deploy" is the one an operator clicks by reflex
+// when they meant the safe one; the extra word is the whole cost of not doing
+// that.
+export function DeployButton({ siteId, siteTitle, cloudflareProject }: Props) {
return (
- <StreamActionLog
- trigger={() => deployExportAction(siteId)}
- cancelAction={cancelJobAction}
- buttonLabel="Deploy"
- runningLabel="Deploying…"
- label="Deploy export"
- extraControls={
+ <div className="flex flex-col gap-6">
+ <StreamActionLog
+ trigger={() => deployExportAction(siteId)}
+ cancelAction={cancelJobAction}
+ buttonLabel="Deploy to production"
+ runningLabel="Deploying…"
+ label="Deploy export"
+ extraControls={
+ <p className="text-sm text-muted-foreground">
+ Deploying <strong>{siteTitle}</strong> (<code>{siteId}</code>).
+ </p>
+ }
+ />
+ <PreviewDeploy
+ siteId={siteId}
+ cloudflareProject={cloudflareProject ?? null}
+ />
+ </div>
+ );
+}
+
+function PreviewDeploy({
+ siteId,
+ cloudflareProject,
+}: {
+ siteId: string;
+ cloudflareProject: string | null;
+}) {
+ const [branch, setBranch] = useState(DEFAULT_PREVIEW_BRANCH);
+ const [deployed, setDeployed] = useState<string | null>(null);
+ // The SAME function the action and the ops route refuse with, so the button
+ // greys out on exactly the names the server would have rejected and the
+ // sentence an operator reads here is the sentence they would have got back.
+ const problem = previewBranchProblem(branch);
+ // The alias is knowable the moment the name is valid — project + branch and
+ // nothing else — so it is shown before the deploy rather than fished out of
+ // the finished log afterwards.
+ const alias =
+ problem === null && cloudflareProject
+ ? previewAliasUrl(cloudflareProject, branch)
+ : null;
+
+ return (
+ <section
+ aria-label="Deploy preview"
+ className="flex flex-col gap-2 border-t border-border pt-4"
+ >
+ <div>
+ <h4 className="text-sm font-semibold">Deploy a preview</h4>
+ <p className="text-sm text-muted-foreground">
+ Publishes the same bundle to a Cloudflare Pages branch instead of
+ production, so you can look at it first. The live site is untouched.
+ Previews share the production archive bucket.
+ </p>
+ </div>
+ <StreamActionLog
+ trigger={() => deployExportAction(siteId, { previewBranch: branch })}
+ cancelAction={cancelJobAction}
+ buttonLabel="Deploy preview"
+ runningLabel="Deploying preview…"
+ label="Deploy preview"
+ disabled={problem !== null}
+ // `started` only says a job began — the link's wording is all that
+ // changes, and a failed deploy leaves the PREVIOUS preview at the same
+ // address, so the link is never a lie about what is there.
+ onSettled={(started) => {
+ if (started && alias) setDeployed(alias);
+ }}
+ extraControls={
+ <label className="flex items-center gap-2 text-sm">
+ <span className="text-muted-foreground">Branch</span>
+ <input
+ type="text"
+ aria-label="preview branch"
+ placeholder="preview"
+ value={branch}
+ // Twice the limit, so an over-long name is refused with the
+ // sentence that says WHY rather than silently truncated into a
+ // different branch than the one that was typed.
+ maxLength={MAX_PREVIEW_BRANCH * 2}
+ onChange={(e) => setBranch(e.target.value)}
+ className="w-44 rounded-md border border-border bg-background px-2 py-1 font-mono text-sm"
+ />
+ </label>
+ }
+ />
+ {problem ? (
+ <p
+ role="status"
+ aria-label="preview branch problem"
+ className="text-sm text-destructive"
+ >
+ {problem}
+ </p>
+ ) : alias ? (
+ <p className="text-sm text-muted-foreground break-all">
+ {deployed === alias ? "Deployed to " : "Will deploy to "}
+ <a
+ className="font-mono underline"
+ href={alias}
+ target="_blank"
+ rel="noreferrer"
+ >
+ {alias}
+ </a>
+ </p>
+ ) : (
<p className="text-sm text-muted-foreground">
- Deploying <strong>{siteTitle}</strong> (<code>{siteId}</code>).
+ Set a Cloudflare Pages project for this site to preview it.
</p>
- }
- />
+ )}
+ </section>
);
}