# Attachments uploader

> A drop zone that uploads files to a record and lists them with a download link.

- Block: [attachments](https://bettersupabase.com/blocks/attachments.md)
- Kind: component
- Data: loaded on the server through the block's client
- Page: https://bettersupabase.com/recipes/attachments-uploader

## Install the block

```bash
npx better-supabase add attachments
```

## Web (Next.js)

Add the shadcn components:

```bash
npx shadcn add alert-dialog badge button card empty
```

From `apps/examples/nextjs/src/features/customers/components/attachments-uploader.tsx` (https://github.com/ScaleDockHQ/better-supabase/blob/main/apps/examples/nextjs/src/features/customers/components/attachments-uploader.tsx):

```tsx
type FileState = "unfinished" | "scanning" | "ready" | "blocked" | "failed";

function stateOf(file: AttachmentRow): FileState {
  switch (file.status) {
    case "pending": {
      return file.uploaded ? "scanning" : "unfinished";
    }

    case "clean": {
      return "ready";
    }

    case "infected": {
      return "blocked";
    }

    case "failed": {
      return "failed";
    }

    default: {
      const unhandled: never = file.status;

      return unhandled;
    }
  }
}

const VARIANT = {
  unfinished: "warning-light",
  scanning: "info-light",
  ready: "success-light",
  blocked: "destructive-light",
  failed: "destructive-light",
} as const satisfies Record<FileState, string>;

/**
 * Files on the customer, in the private `attachments` bucket. The server
 * records each file and signs a one-time upload; the browser sends the
 * bytes. A file downloads once the scanner marked it clean.
 */
export function AttachmentsUploader({
  customerId,
  files,
  canUpload,
  canRemove,
}: {
  customerId: string;
  files: Loaded<readonly AttachmentRow[]>;
  canUpload: boolean;
  canRemove: boolean;
}) {
  const t = useExtracted("customers");

  return (
    <Card size="sm" data-recipe="attachments-uploader">
      <CardHeader>
        <CardTitle>{t("Files")}</CardTitle>
        <CardDescription>
          {t("Files download after the virus scan marks them clean.")}
        </CardDescription>
        {canUpload && files.kind === "ok" && files.data.length > 0 ? (
          <CardAction>
            <UploadButton customerId={customerId} />
          </CardAction>
        ) : null}
      </CardHeader>
      <CardContent>
        {files.kind === "error" ? (
          <RecordLoadError title={t("The files didn't load")} />
        ) : files.data.length === 0 ? (
          <Empty className="border">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <PaperclipIcon />
              </EmptyMedia>
              <EmptyTitle>{t("No files yet")}</EmptyTitle>
              <EmptyDescription>
                {t(
                  "Attach contracts, quotes or anything else about this customer.",
                )}
              </EmptyDescription>
            </EmptyHeader>
            {canUpload ? (
              <EmptyContent>
                <UploadButton customerId={customerId} />
              </EmptyContent>
            ) : null}
          </Empty>
        ) : (
          <FileList files={files.data} canRemove={canRemove} />
        )}
      </CardContent>
    </Card>
  );
}

function FileList({
  files,
  canRemove,
}: {
  files: readonly AttachmentRow[];
  canRemove: boolean;
}) {
  const t = useExtracted("customers");
  const format = useFormatter();
  const errorMessage = useErrorMessage();

  const label: Record<FileState, string> = {
    unfinished: t("Upload unfinished"),
    scanning: t("Waiting for scan"),
    ready: t("Ready"),
    blocked: t("Blocked"),
    failed: t("Scan failed"),
  };

  const download = useAction(downloadAttachment, {
    onSuccess: ({ signedUrl }) => {
      window.location.assign(signedUrl);
    },
    onError: (error) => {
      toast.error(errorMessage(error));
    },
  });

  return (
    <ul className="flex flex-col divide-y" data-testid="attachments">
      {files.map((file) => {
        const state = stateOf(file);

        return (
          <li
            key={file.id}
            className="flex items-center justify-between gap-2 py-2"
          >
            <div className="flex min-w-0 flex-col gap-1">
              <span className="truncate text-sm font-medium">{file.name}</span>
              <div className="flex items-center gap-2">
                <Badge variant={VARIANT[state]}>{label[state]}</Badge>
                <span className="text-xs text-muted-foreground">
                  {format.number(file.size / 1024, {
                    style: "unit",
                    unit: "kilobyte",
                    maximumFractionDigits: 1,
                  })}
                </span>
              </div>
            </div>
            <div className="flex items-center gap-1">
              <Button
                size="icon-sm"
                variant="ghost"
                aria-label={t("Download {name}", { name: file.name })}
                disabled={state !== "ready" || download.pending}
                onClick={() => {
                  void download.run({ id: file.id });
                }}
              >
                <DownloadIcon />
              </Button>
              {canRemove ? <RemoveFile id={file.id} name={file.name} /> : null}
            </div>
          </li>
        );
      })}
    </ul>
  );
}

function UploadButton({ customerId }: { customerId: string }) {
  const t = useExtracted("customers");
  const errorMessage = useErrorMessage();
  const supabase = useSupabase();
  const input = useRef<HTMLInputElement>(null);
  const [uploading, setUploading] = useState(false);

  const start = useAction(startAttachmentUpload);
  const confirm = useAction(confirmAttachment);

  const upload = async (file: File) => {
    const started = await start.run({
      customerId,
      name: file.name,
      mimeType: file.type,
      size: file.size,
    });

    if (!started.ok) {
      toast.error(errorMessage(started.error));

      return;
    }

    const { id, bucket, path, token } = started.data;

    const sent = await supabase.storage
      .from(bucket)
      .uploadToSignedUrl(path, token, file, { contentType: file.type });

    if (sent.error) {
      toast.error(t("The upload failed. Try again."));

      return;
    }

    const confirmed = await confirm.run({ id });

    if (confirmed.ok) {
      toast.success(t("File uploaded"));
    } else {
      toast.error(errorMessage(confirmed.error));
    }
  };

  const pick = (event: ChangeEvent<HTMLInputElement>) => {
    const file = event.target.files?.[0];
    event.target.value = "";

    if (!file) {
      return;
    }

    setUploading(true);
    void upload(file).finally(() => {
      setUploading(false);
    });
  };

  return (
    <>
      <input
        ref={input}
        type="file"
        className="sr-only"
        tabIndex={-1}
        aria-hidden
        onChange={pick}
      />
      <Button
        size="sm"
        disabled={uploading}
        onClick={() => input.current?.click()}
      >
        <UploadIcon />
        {uploading ? t("Uploading") : t("Upload file")}
      </Button>
    </>
  );
}

function RemoveFile({ id, name }: { id: string; name: string }) {
  const t = useExtracted("customers");
  const errorMessage = useErrorMessage();

  const remove = useAction(removeAttachment, {
    onSuccess: () => {
      toast.success(t("File removed"));
    },
    onError: (error) => {
      toast.error(errorMessage(error));
    },
  });

  return (
    <AlertDialog>
      <AlertDialogTrigger
        render={<Button size="sm" variant="ghost" disabled={remove.pending} />}
      >
        {t("Remove")}
      </AlertDialogTrigger>
      <AlertDialogContent>
        <AlertDialogHeader>
          <AlertDialogTitle>{t("Remove {name}?", { name })}</AlertDialogTitle>
          <AlertDialogDescription>
            {t("The file is deleted from storage. This can't be undone.")}
          </AlertDialogDescription>
        </AlertDialogHeader>
        <AlertDialogFooter>
          <AlertDialogCancel>{t("Cancel")}</AlertDialogCancel>
          <AlertDialogAction
            variant="destructive"
            onClick={() => {
              void remove.run({ id });
            }}
          >
            {t("Remove")}
          </AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  );
}

/** The files card while its rows load. */
export function AttachmentsUploaderSkeleton() {
  return <RecordCardSkeleton rows={2} />;
}
```

## Prompt

Paste this into a coding agent to build the recipe in your app.

### Web

```text
Build "Attachments uploader" in a React web app that uses shadcn/ui components. A drop zone that uploads files to a record and lists them with a download link.

It uses the better-supabase `attachments` block (Attachments). It loads its data on the server through the block's client.

Steps:

1. If the app doesn't have the block yet, add the SQL with `npx better-supabase add attachments` and apply the migration.
2. Add the shadcn components the recipe uses: `npx shadcn add alert-dialog badge button card empty`.
3. The recipe needs no glue: it calls the block's client on the server.
4. Write the component. Load the data on the server with the block's client, starting from the recipe's snippet (`apps/examples/nextjs/src/features/customers/components/attachments-uploader.tsx`). Adapt it to the app's existing components, styling and data loading instead of copying it as is.
5. Check the result against the recipe and the block's docs: it typechecks, it reads the same block data, and it handles the loading and empty states the snippet handles.

Read the full code first:

- Recipe: https://bettersupabase.com/recipes/attachments-uploader.md
- Block docs: https://bettersupabase.com/docs/blocks/attachments
```
