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
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-dropzoneRun 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";API key manager
A settings panel to create, reveal once, and revoke the org's API keys, wired to the api-keys router. Degrades to an explanatory notice for non-admins.
Admin user lookup
A platform-admin back-office panel to search users as you type and impersonate one, wired to the admin router. Degrades to an explanatory notice for non-admins.