"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>
);
}