"use client"; import { useRouter } from "next/navigation"; import { useId, useState } from "react"; import { Attachment, DropZone, Select, Switch, TabList, TabPanel, Tabs, Textarea } from "@unlocalhosted/metalui"; import type { ReleaseResult } from "@/app/actions/releases"; import { ConfirmButton } from "@/components/confirm-button"; import { BranchIcon, CodeIcon, CommitIcon, EyeIcon, PackageIcon, TagIcon, UploadIcon } from "@/components/icons"; import { MarkdownPreview } from "@/components/markdown-preview"; import { liftRowClass, plateHeadingClass } from "@/components/repo-rows"; import { TextField } from "@/components/settings-fields"; import { PlateBody } from "@/components/settings-plate"; import { Alert, Box, Button, ButtonLink } from "@/components/ui"; import { encodePath, formatBytes } from "@/lib/format"; import type { ReleaseAsset, ReleaseInput } from "@/lib/releases"; type Pending = { key: string; file: File; progress?: number; error?: string; done?: boolean }; /** PUTs a file to the release's download URL, reporting progress; rejects with the server's reason. */ function upload(url: string, file: File, onProgress: (percent: number) => void) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open("PUT", url); xhr.upload.onprogress = (e) => e.lengthComputable && onProgress(Math.round((e.loaded / e.total) * 100)); xhr.onload = () => (xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(new Error(xhr.responseText.trim() || `Failed (${xhr.status})`))); xhr.onerror = () => reject(new Error("Network error")); xhr.send(file); }); } export type EditableRelease = Omit & { id: string; draft: boolean; assets: ReleaseAsset[] }; /** * Drafting or editing a release. Saving goes through a server action; the files go straight to * the release's download URLs afterwards, so they can be as large as the disk allows. A new release * with files is saved as a draft until they are all uploaded, then published. */ export function ReleaseForm({ base, release, defaultTag = "", defaultTarget, branches, tags, saveAction, publishAction, deleteAssetAction, }: { base: string; release?: EditableRelease; defaultTag?: string; defaultTarget: string; branches: string[]; /** Existing tags, offered as suggestions. */ tags: string[]; saveAction: (releaseId: string | null, input: ReleaseInput) => Promise; publishAction: (releaseId: string) => Promise; deleteAssetAction: (releaseId: string, assetId: string) => Promise; }) { const router = useRouter(); const tagListId = useId(); const [tag, setTag] = useState(release?.tag ?? defaultTag); const [target, setTarget] = useState(release?.target || defaultTarget); const [title, setTitle] = useState(release?.title ?? ""); const [body, setBody] = useState(release?.body ?? ""); const [prerelease, setPrerelease] = useState(release?.prerelease ?? false); const [files, setFiles] = useState([]); const [error, setError] = useState(null); const [busy, setBusy] = useState<"draft" | "publish" | null>(null); // Once saved here (say, before an upload failed), saving again updates that release. const [saved, setSaved] = useState<{ id: string; draft: boolean } | null>(release ? { id: release.id, draft: release.draft } : null); const tagExists = tags.includes(tag.trim()); const isDraft = saved?.draft ?? true; const targets = branches.includes(target) || !target ? branches : [target, ...branches]; const patch = (key: string, change: Partial) => setFiles((current) => current.map((f) => (f.key === key ? { ...f, ...change } : f))); async function submit(intent: "draft" | "publish") { setError(null); setBusy(intent); try { const pending = files.filter((f) => !f.done); // Hold back publishing until the files are in, so nobody sees the release without them. const holdForFiles = intent === "publish" && isDraft && pending.length > 0; const draft = isDraft && (intent === "draft" || holdForFiles); const result = await saveAction(saved?.id ?? null, { tag, target, title, body, prerelease, draft }); if (!result.release) return setError(result.error ?? "The release couldn’t be saved."); const { id, tag: savedTag } = result.release; setSaved({ id, draft }); let failed = 0; for (const f of pending) { patch(f.key, { progress: 0, error: undefined }); try { await upload(`${base}/releases/download/${encodePath(savedTag)}/${encodeURIComponent(f.file.name)}`, f.file, (progress) => patch(f.key, { progress })); patch(f.key, { progress: undefined, done: true }); } catch (e) { failed++; patch(f.key, { progress: undefined, error: e instanceof Error ? e.message : "Failed" }); } } if (failed) { return setError( `${failed === 1 ? "A file" : `${failed} files`} didn’t upload. The release is saved${draft ? " as a draft" : ""}; try again to upload the rest.`, ); } if (holdForFiles) { const published = await publishAction(id); if (published.error) return setError(published.error); } router.push(`${base}/releases/tag/${encodePath(savedTag)}`); router.refresh(); } finally { setBusy(null); } } const add = (taken: File[]) => setFiles((current) => { // A file with the name of one already chosen replaces it. const names = new Set(taken.map((f) => f.name)); return [...current.filter((f) => !names.has(f.file.name)), ...taken.map((file) => ({ key: `${file.name}:${file.size}:${file.lastModified}`, file }))]; }); return ( // Not a
: saving is done by the buttons (Enter in a field mustn't publish a release), and // the files' delete buttons are forms of their own.
} mono required value={tag} onChange={(e) => setTag(e.target.value)} placeholder="v1.0.0" list={tagListId} autoFocus={!release} description={tagExists ? "An existing tag." : tag.trim() ? "A new tag, created when the release is published." : "Choose a tag, or name a new one."} /> {tags.map((t) => ( {!tagExists && (