Vectrum
Sign In
267 lines · 12.0 KB · TypeScript
"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>
  );
}