import { Loader2Icon } from "lucide-react"; import { useRef, useState } from "react"; import type { PublishVisibility } from "../../../publish/artifact"; import { apiFetch } from "../hooks/use-api"; import { buildPublishExportRequest, downloadPublishArtifactFile, PUBLISH_ACCESS_OPTIONS, type PublishExportResponse, } from "../lib/publish-export"; import { Button } from "./ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "./ui/dialog"; import { Input } from "./ui/input"; interface PublishExportDialogProps { onClose: () => void; target: string; title: string; } /** Mount a fresh dialog for each export so access and secrets never carry over. */ export function PublishExportDialog({ onClose, target, title, }: PublishExportDialogProps) { const [visibility, setVisibility] = useState(null); const [passphrase, setPassphrase] = useState(""); const [passphraseConfirmation, setPassphraseConfirmation] = useState(""); const [audienceConfirmed, setAudienceConfirmed] = useState(false); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const exporting = useRef(false); const [returnFocus] = useState(() => { const active = document.activeElement; const menuTriggerId = active ?.closest('[role="menu"]') ?.getAttribute("aria-labelledby"); const trigger = menuTriggerId ? document.getElementById(menuTriggerId) : active; return trigger instanceof HTMLElement ? trigger : null; }); const selected = PUBLISH_ACCESS_OPTIONS.find( ({ value }) => value === visibility ); const encryptionReady = visibility !== "encrypted" || (passphrase.trim().length > 0 && passphrase === passphraseConfirmation); const handleExport = async () => { if (exporting.current) return; setError(null); try { const body = buildPublishExportRequest({ target, visibility, passphrase, passphraseConfirmation, audienceConfirmed, }); exporting.current = true; setBusy(true); const result = await apiFetch( "/api/publish/export", { method: "POST", body: JSON.stringify(body), } ); if (result.error || !result.data) { setError( result.error ?? "The export did not return an artifact. Try again." ); return; } // Refuse a mismatched response rather than downloading a broader artifact. if ( result.data.artifact.spaces.length !== 1 || result.data.artifact.spaces[0]?.visibility !== visibility || result.data.artifact.version !== (visibility === "encrypted" ? 2 : 1) ) { setError( "The returned artifact does not match the reviewed access. Nothing was downloaded." ); return; } downloadPublishArtifactFile(result.data); setPassphrase(""); setPassphraseConfirmation(""); onClose(); } catch (cause) { setError( cause instanceof Error ? cause.message : "Failed to export. Try again." ); } finally { exporting.current = false; setBusy(false); } }; return ( { if (!open && !exporting.current) onClose(); }} > { if (returnFocus?.isConnected) { event.preventDefault(); returnFocus.focus(); } }} showCloseButton={!busy} > Export for gno.sh Review who can read “{title}”. This downloads a local file; upload and publish it separately in Studio.
Who can read this? {PUBLISH_ACCESS_OPTIONS.map((option) => ( ))}

Local GNO cannot check your hosted account. Studio checks plan availability and Egress Policy before publishing. See{" "} current plans .

{visibility === "encrypted" && (
Local encryption

Your local GNO server encrypts the notes and bundled assets. The passphrase is never sent to gno.sh or included in the downloaded file. Keep it safe: lost passphrases cannot be recovered. Switching into or out of encrypted sharing requires a new local export.

{passphraseConfirmation && passphrase !== passphraseConfirmation && (

The passphrases do not match.

)}
)} {selected && ( )} {error && (

{error}

)}
); }