Vectrum
Sign In
138 lines · 4.6 KB · TypeScript
"use client";

import { useActionState, useEffect, useId, useRef, useState } from "react";
import { Attachment, DropZone, type DropRefusal } from "@unlocalhosted/metalui";
import type { FormState } from "@/app/actions/types";
import { formatBytes } from "@/lib/format";
import { CommitDialog } from "./commit-dialog";
import { CommitIcon, UploadIcon } from "./icons";
import { Alert, Button, ButtonLink, RefChip } from "./ui";

const MAX_BYTES = 10 * 1024 * 1024;

const REFUSED: Record<DropRefusal["reason"], string> = {
  size: "larger than 10 MB",
  type: "not a file that can be added",
  count: "one file too many",
};

export function UploadForm({
  action,
  repoName,
  branch,
  baseSha,
  dir,
  cancelHref,
}: {
  action: (prev: FormState, formData: FormData) => Promise<FormState>;
  repoName: string;
  branch: string;
  baseSha: string;
  dir: string;
  cancelHref: string;
}) {
  const formId = useId();
  const [state, formAction, pending] = useActionState(action, undefined);
  // The DropZone's own input has no name; this hidden one carries the chosen files with the form.
  const inputRef = useRef<HTMLInputElement>(null);
  const [files, setFiles] = useState<File[]>([]);
  const [refused, setRefused] = useState<DropRefusal[]>([]);
  const [dialogOpen, setDialogOpen] = useState(false);

  // Keep the hidden <input> in step with the list, so the files are submitted with the form. React
  // also resets the form after a failed action; this puts the chosen files back so a retry works.
  useEffect(() => {
    const input = inputRef.current;
    if (!input) return;
    const transfer = new DataTransfer();
    files.forEach((f) => transfer.items.add(f));
    input.files = transfer.files;
  }, [files, state]);

  const add = (taken: File[], no: DropRefusal[]) => {
    setRefused(no);
    setFiles((current) => {
      const names = new Set(current.map((f) => f.name));
      return [...current, ...taken.filter((f) => !names.has(f.name) && names.add(f.name))];
    });
  };

  const total = files.reduce((n, f) => n + f.size, 0);

  return (
    <form id={formId} action={formAction} className="space-y-4">
      <input type="hidden" name="dir" value={dir} />
      <input ref={inputRef} type="file" name="files" multiple hidden tabIndex={-1} aria-hidden />

      <div className="flex flex-wrap items-center gap-2">
        <span className="type-title text-ink">{repoName}</span>
        <span className="text-ink3">/</span>
        {dir && <span className="font-mono text-[12.5px] text-ink">{dir}/</span>}
        <span className="inline-flex items-center gap-1.5 type-meta text-ink2">
          on <RefChip>{branch}</RefChip>
        </span>
      </div>

      <DropZone
        onFiles={add}
        maxSize={MAX_BYTES}
        icon={<UploadIcon />}
        title="Drop files to add them to your repository"
        description="Any file, up to 10 MB each"
        chooseLabel="or browse your computer"
        overTitle="Let go to add them"
      />

      {refused.length > 0 && (
        <Alert>
          {refused.length === 1 ? "One file wasn’t added: " : `${refused.length} files weren’t added: `}
          {refused.map((r, i) => (
            <span key={`${r.file.name}-${i}`}>
              {i > 0 && "; "}
              <span className="font-mono">{r.file.name}</span> is {REFUSED[r.reason]}
            </span>
          ))}
          .
        </Alert>
      )}

      {files.length > 0 && (
        <div className="space-y-2.5">
          <div className="grid grid-cols-[repeat(auto-fill,minmax(240px,1fr))] gap-2">
            {files.map((f) => (
              <Attachment
                key={f.name}
                name={f.name}
                size={f.size}
                onRemove={() => setFiles((current) => current.filter((x) => x.name !== f.name))}
              />
            ))}
          </div>
          <p className="type-readout text-ink2 tabular-nums">
            {files.length} file{files.length === 1 ? "" : "s"} · {formatBytes(total)}
          </p>
        </div>
      )}

      {state?.error && !dialogOpen && <Alert>{state.error}</Alert>}

      <div className="flex justify-end gap-2">
        <ButtonLink href={cancelHref}>Cancel</ButtonLink>
        <Button type="button" variant="primary" icon={<CommitIcon />} disabled={files.length === 0} onClick={() => setDialogOpen(true)}>
          Commit changes…
        </Button>
      </div>

      <CommitDialog
        formId={formId}
        branch={branch}
        baseSha={baseSha}
        defaultMessage={files.length === 1 ? `Add ${files[0].name}` : `Add ${files.length} files`}
        pending={pending}
        error={state?.error}
        open={dialogOpen}
        onOpenChange={setDialogOpen}
      />
    </form>
  );
}