"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<void>((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<ReleaseInput, "draft"> & { 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<ReleaseResult>;
publishAction: (releaseId: string) => Promise<ReleaseResult>;
deleteAssetAction: (releaseId: string, assetId: string) => Promise<void>;
}) {
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<Pending[]>([]);
const [error, setError] = useState<string | null>(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<Pending>) => 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 <form>: 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.
<div className="flex flex-col gap-4">
<Box>
<PlateBody>
<div className="grid gap-4 sm:grid-cols-2">
<TextField
label="Tag"
icon={<TagIcon />}
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."}
/>
<datalist id={tagListId}>
{tags.map((t) => (
<option key={t} value={t} />
))}
</datalist>
{!tagExists && (
<label className="flex flex-col gap-1.5">
<span className="type-ui text-ink">Create the tag from</span>
<Select
aria-label="Target branch"
value={target}
onValueChange={(v) => setTarget(String(v))}
options={targets.map((b) => ({ value: b, label: branches.includes(b) ? b : b.slice(0, 7), lead: branches.includes(b) ? <BranchIcon /> : <CommitIcon /> }))}
/>
<span className="type-meta text-ink3">The tag points at this branch’s latest commit.</span>
</label>
)}
</div>
<TextField label="Title" value={title} onChange={(e) => setTitle(e.target.value)} maxLength={256} placeholder={tag.trim() || "Release title"} optional />
<div className="flex flex-col gap-1.5">
<span className="type-ui text-ink">Release notes</span>
<Tabs defaultValue="write">
<div className="overflow-hidden rounded-plate recipe-well-field">
<div className="rule-b px-2 py-1.5">
<TabList
aria-label="Notes view"
size="compact"
items={[
{ value: "write", label: "Write", icon: <CodeIcon /> },
{ value: "preview", label: "Preview", icon: <EyeIcon /> },
]}
/>
</div>
<TabPanel value="write" keepMounted className="p-1">
<Textarea
value={body}
onChange={(e) => setBody(e.target.value)}
minRows={8}
maxRows={30}
maxLength={65536}
placeholder="What’s new, what changed, how to upgrade… Markdown is supported."
aria-label="Release notes"
/>
</TabPanel>
<TabPanel value="preview" className="min-h-48 px-5 py-4">
<MarkdownPreview>{body}</MarkdownPreview>
</TabPanel>
</div>
</Tabs>
</div>
<label className="flex cursor-pointer items-start gap-3 select-none">
<Switch size="small" checked={prerelease} onCheckedChange={setPrerelease} className="mt-0.5" />
<span className="flex flex-col gap-0.5">
<span className="type-ui text-ink">This is a pre-release</span>
<span className="type-meta text-ink3">Shown as not ready for production; the latest release stays the one before it.</span>
</span>
</label>
</PlateBody>
</Box>
<Box className="p-1.5">
<h2 className={plateHeadingClass}>Files</h2>
{release && release.assets.length > 0 && (
<ul className="mb-1.5">
{release.assets.map((a) => (
<li key={a.id} className={`${liftRowClass} flex min-h-11 items-center gap-3 px-3 py-1.5`}>
<span className="icon-tile size-7 shrink-0 rounded-row">
<PackageIcon width={14} height={14} />
</span>
<span className="min-w-0 truncate font-mono text-[12px] text-ink">{a.name}</span>
<span className="ml-auto shrink-0 type-readout text-ink2">{formatBytes(a.size)}</span>
<ConfirmButton action={deleteAssetAction.bind(null, release.id, a.id)} message={`Delete ${a.name}? Links to it stop working.`} variant="default">
Delete
</ConfirmButton>
</li>
))}
</ul>
)}
<div className="flex flex-col gap-2.5 px-1.5 pb-1.5">
<DropZone
onFiles={add}
icon={<UploadIcon />}
title="Drop binaries, installers or archives here"
description="Any file, any size. A file named like an existing one replaces it."
chooseLabel="or browse your computer"
overTitle="Let go to attach them"
disabled={busy !== null}
/>
{files.length > 0 && (
<div className="grid grid-cols-[repeat(auto-fill,minmax(240px,1fr))] gap-2">
{files.map((f) => (
<Attachment
key={f.key}
name={f.file.name}
size={f.file.size}
progress={f.progress}
error={f.error}
onRemove={busy || f.done ? undefined : () => setFiles((current) => current.filter((x) => x.key !== f.key))}
/>
))}
</div>
)}
</div>
</Box>
{error && <Alert>{error}</Alert>}
<div className="flex flex-wrap items-center justify-end gap-2">
<ButtonLink href={release ? `${base}/releases/tag/${encodePath(release.tag)}` : `${base}/releases`} variant="invisible" className="mr-auto">
Cancel
</ButtonLink>
{isDraft && (
<Button type="button" disabled={busy !== null} onClick={() => submit("draft")}>
{busy === "draft" ? "Saving…" : "Save Draft"}
</Button>
)}
<Button type="button" variant="primary" icon={<TagIcon />} disabled={busy !== null || !tag.trim()} onClick={() => submit("publish")}>
{busy === "publish" ? (isDraft ? "Publishing…" : "Saving…") : isDraft ? "Publish Release" : "Update Release"}
</Button>
</div>
</div>
);
}