zazzy
Blocks

File dropzone

A drag-and-drop upload zone driving the presigned files flow (createUpload → PUT → confirmUpload), with a recent-files list. Shows a storage-limit hint when flags is present.

A drag-and-drop upload block over the org-scoped files router. It drives the presigned flow end to end — files.createUpload (validate + presign) → a plain fetch PUT straight to the bucket → files.confirmUpload (persist) — using only native drag events and fetch, no extra dependencies. Chosen files are validated client-side first against the same per-purpose config the server enforces (a fast rejection before any network call; the router re-validates). A recent-files list reads files.list.

When the flags feature is present, an optional region surfaces the plan's storage limit via flags.entitlements. That region calls the procedure but never imports @/features/flags, so add strips it cleanly when installing into an app without flags.

Requires: files

Preview

Upload a file
Drag a file here or browse to upload it to your organization.

or drag and drop

Storage limit on this plan: 5 GB

Recent files

  • quarterly-report.pdf2.2 MB
  • logo-final.png47.2 KB
"use client";import { useReducer, useRef, useState } from "react";import { Button } from "@/components/ui/button";import {  Card,  CardContent,  CardDescription,  CardHeader,  CardTitle,} from "@/components/ui/card";import { cn } from "@/lib/utils";import { trpc } from "@/lib/trpc";import {  getFileConstraints,  type FilePurpose,} from "@/features/files/lib/config";import { formatBytes } from "./format-bytes";import {  initialUploadState,  isUploadBusy,  uploadReducer,} from "./upload-reducer";/** * A drag-and-drop upload block over the org-scoped `files` router. It drives the * presigned flow end to end — `files.createUpload` (validate + presign) → a plain * `fetch` PUT straight to the bucket → `files.confirmUpload` (persist) — using * only native drag events and `fetch`, no extra dependencies. Chosen files are * validated **client-side first** against the same `@/features/files` per-purpose * config the server enforces (UX, not the security boundary — the router * re-validates). A recent-files list reads `files.list`. * * The optional `@feature:flags` region surfaces the plan's storage limit via the * `flags.entitlements` query. Per PD-3 it only *calls* the procedure (through the * shared tRPC client) — it never imports the flags feature module — so the region * strips cleanly at install time in an app without flags (the marker lines drop, * taking the hook and its banner with them). *//** A local mirror of the `files.list` row shape (the router's `toFileView`). */type FileRow = {  id: string;  name: string;  size: number;  createdAt: string | Date;};export type FileDropzoneProps = {  /** Which purpose's caps/allowlist to validate against and tag uploads with. */  purpose?: FilePurpose;};/** * Validate a chosen file against its purpose's fixed constraints, returning a * user-facing message to reject it or `null` to allow. Mirrors the server's * `assertWithinConstraints` so the client fails fast before any network call. */function validate(  purpose: FilePurpose,  file: { type: string; size: number },): string | null {  const { maxBytes, allowedContentTypes } = getFileConstraints(purpose);  if (!allowedContentTypes.includes(file.type)) {    return `That file type${file.type ? ` (${file.type})` : ""} isn't accepted here.`;  }  if (file.size > maxBytes) {    return `That file is too large — the limit is ${formatBytes(maxBytes)}.`;  }  return null;}export function FileDropzone({ purpose = "attachment" }: FileDropzoneProps) {  const utils = trpc.useUtils();  const inputRef = useRef<HTMLInputElement>(null);  const [dragging, setDragging] = useState(false);  const [state, dispatch] = useReducer(uploadReducer, initialUploadState);  const recent = trpc.files.list.useQuery({ purpose });  const createUpload = trpc.files.createUpload.useMutation();  const confirmUpload = trpc.files.confirmUpload.useMutation();  // @feature:flags:start  const storageEntitlement = trpc.flags.entitlements.useQuery();  const storageLimitBytes =    storageEntitlement.data?.quotas.storageBytes ?? null;  // @feature:flags:end  const busy = isUploadBusy(state);  const files = (recent.data ?? []) as FileRow[];  async function handleFile(file: File) {    const rejection = validate(purpose, file);    if (rejection) {      dispatch({ type: "fail", message: rejection });      return;    }    dispatch({ type: "start" });    try {      const { fileId, uploadUrl } = await createUpload.mutateAsync({        purpose,        name: file.name,        contentType: file.type,        size: file.size,      });      // Presigned PUT straight to the bucket — the content type is bound into the      // signature server-side, so it must match what we declared above.      const put = await fetch(uploadUrl, {        method: "PUT",        body: file,        headers: { "content-type": file.type },      });      if (!put.ok) {        throw new Error(`Upload failed (${put.status}).`);      }      dispatch({ type: "confirm" });      await confirmUpload.mutateAsync({ id: fileId, size: file.size });      dispatch({ type: "done" });      await utils.files.list.invalidate();    } catch (caught) {      dispatch({        type: "fail",        message: caught instanceof Error ? caught.message : "Something went wrong.",      });    }  }  function onInputChange(event: React.ChangeEvent<HTMLInputElement>) {    const file = event.target.files?.[0];    // Clear the input so choosing the same file again re-fires `change`.    event.target.value = "";    if (file) void handleFile(file);  }  function onDrop(event: React.DragEvent) {    event.preventDefault();    setDragging(false);    if (busy) return;    const file = event.dataTransfer.files?.[0];    if (file) void handleFile(file);  }  return (    <Card>      <CardHeader>        <CardTitle>Upload a file</CardTitle>        <CardDescription>          Drag a file here or browse to upload it to your organization.        </CardDescription>      </CardHeader>      <CardContent className="flex flex-col gap-3">        <div          onDragOver={(event) => {            event.preventDefault();            if (!busy) setDragging(true);          }}          onDragLeave={() => setDragging(false)}          onDrop={onDrop}          className={cn(            "flex flex-col items-center gap-2 rounded-md border border-dashed p-6 text-center text-sm transition-colors",            dragging ? "border-primary bg-muted" : "border-border",          )}        >          <input            ref={inputRef}            type="file"            className="sr-only"            disabled={busy}            onChange={onInputChange}          />          <Button            type="button"            variant="outline"            size="sm"            disabled={busy}            onClick={() => inputRef.current?.click()}          >            {state.phase === "uploading"              ? "Uploading…"              : state.phase === "confirming"                ? "Saving…"                : "Choose a file"}          </Button>          <p className="text-xs text-muted-foreground">or drag and drop</p>        </div>        {/* @feature:flags:start */}        {storageLimitBytes !== null && storageLimitBytes >= 0 && (          <p className="text-xs text-muted-foreground">            Storage limit on this plan: {formatBytes(storageLimitBytes)}          </p>        )}        {/* @feature:flags:end */}        {state.error && (          <p role="alert" className="text-sm text-destructive">            {state.error}          </p>        )}        <div className="flex flex-col gap-2">          <p className="text-sm font-medium">Recent files</p>          {recent.isPending && (            <p className="text-sm text-muted-foreground">Loading files…</p>          )}          {recent.isError && (            <p role="alert" className="text-sm text-destructive">              Could not load files: {recent.error.message}            </p>          )}          {recent.data && files.length === 0 && (            <p className="text-sm text-muted-foreground">No files yet.</p>          )}          {files.length > 0 && (            <ul className="flex flex-col gap-1 text-sm">              {files.map((file) => (                <li                  key={file.id}                  className="flex items-center justify-between gap-3"                >                  <span className="truncate">{file.name}</span>                  <span className="shrink-0 text-muted-foreground">                    {formatBytes(file.size)}                  </span>                </li>              ))}            </ul>          )}        </div>      </CardContent>    </Card>  );}

Offline preview

The docs site has no backend, so the preview seeds a couple of recent files and a storage entitlement (the Pro tier's 5 GiB limit hint). Dropping or choosing a file would start the real presigned flow; in an app the uploads and list come from the files router.

Install

npx create-saas-starter add file-dropzone

Run it from your app's root. add only writes new files — wiring is up to you:

import { FileDropzone } from "@/components/blocks/file-dropzone/file-dropzone";

On this page