{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "dropzone-nextjs", "title": "Dropzone (Next.js)", "description": "A file upload dropzone component powered by Convex storage. Supports drag and drop, file validation, upload progress, and full Convex backend.", "dependencies": [ "lucide-react", "react-dropzone", "convex@latest", "@auth/core", "@convex-dev/auth@latest" ], "registryDependencies": ["button"], "files": [ { "path": "src/registry/convex/blocks/dropzone-nextjs/components/dropzone.tsx", "content": "\"use client\";\n\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card, CardContent } from \"@/components/ui/card\";\nimport { cn } from \"@/lib/utils\";\nimport {\n AlertCircle,\n CheckCircle,\n FileAudioIcon,\n FileIcon,\n FileTextIcon,\n FileVideoIcon,\n ImageIcon,\n Loader2,\n Upload,\n X,\n} from \"lucide-react\";\nimport { useCallback, useMemo } from \"react\";\nimport { type FileError } from \"react-dropzone\";\nimport { type UseConvexUploadReturn } from \"../hooks/use-convex-upload\";\n\ninterface FileWithPreview extends File {\n preview?: string;\n errors: readonly FileError[];\n}\n\nfunction formatBytes(bytes: number): string {\n if (bytes === 0) return \"0 Bytes\";\n const k = 1024;\n const sizes = [\"Bytes\", \"KB\", \"MB\", \"GB\"];\n const i = Math.floor(Math.log(bytes) / Math.log(k));\n return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + \" \" + sizes[i];\n}\n\nfunction getFileIcon(type: string): typeof FileIcon {\n if (type.startsWith(\"image/\")) return ImageIcon;\n if (type.startsWith(\"video/\")) return FileVideoIcon;\n if (type.startsWith(\"audio/\")) return FileAudioIcon;\n if (type.startsWith(\"text/\") || type.includes(\"document\"))\n return FileTextIcon;\n return FileIcon;\n}\n\ninterface DropzoneProps {\n upload: UseConvexUploadReturn;\n className?: string;\n}\n\nexport function Dropzone({ upload, className }: DropzoneProps) {\n const {\n files,\n setFiles,\n loading,\n errors,\n successes,\n isSuccess,\n onUpload,\n maxFileSize,\n maxFiles,\n getRootProps,\n getInputProps,\n isDragActive,\n open,\n } = upload;\n\n const removeFile = useCallback(\n (fileName: string) => {\n setFiles(files.filter((f: FileWithPreview) => f.name !== fileName));\n },\n [files, setFiles],\n );\n\n const hasErrors = useMemo(() => {\n return (\n files.some((f: FileWithPreview) => f.errors.length > 0) ||\n errors.length > 0\n );\n }, [files, errors]);\n\n const canUpload = useMemo(() => {\n return files.length > 0 && !loading && !hasErrors && !isSuccess;\n }, [files.length, loading, hasErrors, isSuccess]);\n\n return (\n
\n \n \n \n \n \n
\n
\n

\n {isDragActive\n ? \"Drop your files here\"\n : \"Drag and drop files here\"}\n

\n

\n or{\" \"}\n \n click to browse\n \n

\n
\n \n \n Max {maxFiles} file{maxFiles !== 1 ? \"s\" : \"\"} • Up to{\" \"}\n {formatBytes(maxFileSize)} each\n \n \n \n \n\n {files.length > 0 && (\n
\n {files.map((file: FileWithPreview) => {\n const FileIconComponent = getFileIcon(file.type);\n const hasFileErrors = file.errors.length > 0;\n const uploadError = errors.find(\n (e: { name: string; message: string }) => e.name === file.name,\n );\n const isUploaded = successes.includes(file.name);\n\n return (\n \n \n \n {file.type.startsWith(\"image/\") && file.preview ? (\n \n ) : (\n \n )}\n
\n
\n
\n

\n {file.name}\n

\n {isUploaded && (\n \n \n Uploaded\n \n )}\n {(hasFileErrors || uploadError) && (\n \n \n Error\n \n )}\n
\n

\n {formatBytes(file.size)}\n

\n {hasFileErrors && (\n

\n {file.errors\n .map((e: FileError) => e.message)\n .join(\", \")}\n

\n )}\n {uploadError && (\n

\n {uploadError.message}\n

\n )}\n
\n removeFile(file.name)}\n disabled={loading}\n className=\"shrink-0\"\n >\n \n \n \n \n );\n })}\n \n )}\n\n {files.length > 0 && (\n \n {loading ? (\n <>\n \n Uploading {files.length} file{files.length !== 1 ? \"s\" : \"\"}...\n \n ) : isSuccess ? (\n <>\n \n All files uploaded successfully\n \n ) : (\n <>\n \n Upload {files.length} file{files.length !== 1 ? \"s\" : \"\"}\n \n )}\n \n )}\n \n );\n}\n", "type": "registry:component", "target": "src/components/dropzone.tsx" }, { "path": "src/registry/convex/blocks/dropzone-nextjs/hooks/use-convex-upload.ts", "content": "\"use client\";\n\nimport { useMutation } from \"convex/react\";\nimport { api } from \"@/convex/_generated/api\";\nimport { useCallback, useEffect, useMemo, useState } from \"react\";\nimport {\n type FileError,\n type FileRejection,\n useDropzone,\n} from \"react-dropzone\";\n\ninterface FileWithPreview extends File {\n preview?: string;\n errors: readonly FileError[];\n}\n\ntype UseConvexUploadOptions = {\n /**\n * Allowed MIME types for each file upload (e.g `image/png`, `text/html`, etc). Wildcards are also supported (e.g `image/*`).\n *\n * Defaults to allowing uploading of all MIME types.\n */\n allowedMimeTypes?: string[];\n /**\n * Maximum upload size of each file allowed in bytes. (e.g 1000 bytes = 1 KB)\n */\n maxFileSize?: number;\n /**\n * Maximum number of files allowed per upload.\n */\n maxFiles?: number;\n};\n\ntype UseConvexUploadReturn = ReturnType;\n\nconst useConvexUpload = (options: UseConvexUploadOptions = {}) => {\n const {\n allowedMimeTypes = [],\n maxFileSize = Number.POSITIVE_INFINITY,\n maxFiles = 1,\n } = options;\n\n const generateUploadUrl = useMutation(api.files.generateUploadUrl);\n const saveFile = useMutation(api.files.saveFile);\n\n const [files, setFiles] = useState([]);\n const [loading, setLoading] = useState(false);\n const [errors, setErrors] = useState<{ name: string; message: string }[]>([]);\n const [successes, setSuccesses] = useState([]);\n\n const isSuccess = useMemo(() => {\n if (errors.length === 0 && successes.length === 0) {\n return false;\n }\n if (errors.length === 0 && successes.length === files.length) {\n return true;\n }\n return false;\n }, [errors.length, successes.length, files.length]);\n\n const onDrop = useCallback(\n (acceptedFiles: File[], fileRejections: FileRejection[]) => {\n const validFiles = acceptedFiles\n .filter(\n (file: File) =>\n !files.find((x: FileWithPreview) => x.name === file.name),\n )\n .map((file: File) => {\n (file as FileWithPreview).preview = URL.createObjectURL(file);\n (file as FileWithPreview).errors = [];\n return file as FileWithPreview;\n });\n\n const invalidFiles = fileRejections.map(\n ({ file, errors }: FileRejection) => {\n (file as FileWithPreview).preview = URL.createObjectURL(file);\n (file as FileWithPreview).errors = errors;\n return file as FileWithPreview;\n },\n );\n\n const newFiles = [...files, ...validFiles, ...invalidFiles];\n\n setFiles(newFiles);\n },\n [files, setFiles],\n );\n\n const dropzoneProps = useDropzone({\n onDrop,\n noClick: true,\n accept: allowedMimeTypes.reduce(\n (acc: Record, type: string) => ({ ...acc, [type]: [] }),\n {},\n ),\n maxSize: maxFileSize,\n maxFiles: maxFiles,\n multiple: maxFiles !== 1,\n });\n\n const onUpload = useCallback(async () => {\n setLoading(true);\n\n // Handle partial successes - retry files with errors\n const filesWithErrors = errors.map(\n (x: { name: string; message: string }) => x.name,\n );\n const filesToUpload =\n filesWithErrors.length > 0\n ? [\n ...files.filter((f: FileWithPreview) =>\n filesWithErrors.includes(f.name),\n ),\n ...files.filter(\n (f: FileWithPreview) => !successes.includes(f.name),\n ),\n ]\n : files;\n\n const responses = await Promise.all(\n filesToUpload.map(async (file: FileWithPreview) => {\n try {\n // Generate upload URL\n const uploadUrl = await generateUploadUrl();\n\n // Upload the file to Convex storage\n const response = await fetch(uploadUrl, {\n method: \"POST\",\n headers: { \"Content-Type\": file.type },\n body: file,\n });\n\n if (!response.ok) {\n throw new Error(\"Failed to upload file\");\n }\n\n const { storageId } = await response.json();\n\n // Save file metadata to database\n await saveFile({\n storageId,\n name: file.name,\n type: file.type,\n size: file.size,\n });\n\n return { name: file.name, message: undefined };\n } catch (error) {\n return {\n name: file.name,\n message: error instanceof Error ? error.message : \"Upload failed\",\n };\n }\n }),\n );\n\n const responseErrors = responses.filter(\n (x: { name: string; message: string | undefined }) =>\n x.message !== undefined,\n ) as {\n name: string;\n message: string;\n }[];\n setErrors(responseErrors);\n\n const responseSuccesses = responses.filter(\n (x: { name: string; message: string | undefined }) =>\n x.message === undefined,\n );\n const newSuccesses = Array.from(\n new Set([\n ...successes,\n ...responseSuccesses.map(\n (x: { name: string; message: string | undefined }) => x.name,\n ),\n ]),\n );\n setSuccesses(newSuccesses);\n\n setLoading(false);\n }, [files, errors, successes, generateUploadUrl, saveFile]);\n\n useEffect(() => {\n if (files.length === 0) {\n setErrors([]);\n }\n\n // Remove 'Too many files' error if count is now valid\n if (files.length <= maxFiles) {\n let changed = false;\n const newFiles = files.map((file: FileWithPreview) => {\n if (file.errors.some((e: FileError) => e.code === \"too-many-files\")) {\n file.errors = file.errors.filter(\n (e: FileError) => e.code !== \"too-many-files\",\n );\n changed = true;\n }\n return file;\n });\n if (changed) {\n setFiles(newFiles);\n }\n }\n }, [files.length, setFiles, maxFiles, files]);\n\n return {\n files,\n setFiles,\n successes,\n isSuccess,\n loading,\n errors,\n setErrors,\n onUpload,\n maxFileSize: maxFileSize,\n maxFiles: maxFiles,\n allowedMimeTypes,\n ...dropzoneProps,\n };\n};\n\nexport {\n useConvexUpload,\n type UseConvexUploadOptions,\n type UseConvexUploadReturn,\n};\n", "type": "registry:hook", "target": "src/hooks/use-convex-upload.ts" }, { "path": "src/registry/convex/blocks/dropzone-nextjs/convex/schema.ts", "content": "import { defineSchema, defineTable } from \"convex/server\";\nimport { v } from \"convex/values\";\n\n/**\n * Schema for file upload/storage functionality.\n *\n * This schema supports both authenticated and demo modes:\n * - Authenticated: userId links files to the user\n * - Demo mode: sessionId tracks files without authentication\n *\n * Security considerations:\n * - Files are scoped to users/sessions for isolation\n * - storageId references Convex's built-in storage system\n * - File metadata (name, type, size) is stored separately from content\n */\nexport default defineSchema({\n // File uploads tracking table\n files: defineTable({\n // Reference to Convex storage\n storageId: v.id(\"_storage\"),\n\n // User reference (optional for demo mode)\n userId: v.optional(v.id(\"users\")),\n\n // Session identifier for demo mode tracking\n sessionId: v.optional(v.string()),\n\n // File metadata\n name: v.string(),\n type: v.string(),\n size: v.number(),\n })\n .index(\"by_user\", [\"userId\"])\n .index(\"by_session\", [\"sessionId\"]),\n\n // Optional: Users table if you want to track users\n // Uncomment if using with authentication\n // users: defineTable({\n // name: v.optional(v.string()),\n // email: v.optional(v.string()),\n // image: v.optional(v.string()),\n // }),\n});\n", "type": "registry:file", "target": "convex/schema.ts" }, { "path": "src/registry/convex/blocks/dropzone-nextjs/convex/files.ts", "content": "import { query, mutation } from \"./_generated/server\";\nimport type { QueryCtx, MutationCtx } from \"./_generated/server\";\nimport { v } from \"convex/values\";\nimport { ConvexError } from \"convex/values\";\nimport type { Id } from \"./_generated/dataModel\";\n\n/**\n * File validator for return types.\n */\nconst fileValidator = v.object({\n _id: v.id(\"files\"),\n _creationTime: v.number(),\n storageId: v.id(\"_storage\"),\n userId: v.optional(v.id(\"users\")),\n sessionId: v.optional(v.string()),\n name: v.string(),\n type: v.string(),\n size: v.number(),\n});\n\n/**\n * Maximum file size (50MB).\n */\nconst MAX_FILE_SIZE = 50 * 1024 * 1024;\n\n/**\n * Maximum filename length.\n */\nconst MAX_FILENAME_LENGTH = 255;\n\n/**\n * Generate a secure upload URL for file uploads.\n *\n * This URL can be used to upload a file directly to Convex storage.\n * The URL is temporary and expires after a short time.\n *\n * Note: This is a public mutation that allows uploads.\n * For production, consider adding rate limiting or authentication.\n */\nexport const generateUploadUrl = mutation({\n args: {},\n returns: v.string(),\n handler: async (ctx: MutationCtx) => {\n return await ctx.storage.generateUploadUrl();\n },\n});\n\n/**\n * Save file metadata after successful upload.\n *\n * Call this after uploading a file to the URL from generateUploadUrl.\n * Associates the uploaded file with a user or session.\n *\n * Security:\n * - Validates file metadata\n * - Associates file with user (if authenticated) or session\n */\nexport const saveFile = mutation({\n args: {\n storageId: v.id(\"_storage\"),\n name: v.string(),\n type: v.string(),\n size: v.number(),\n sessionId: v.optional(v.string()),\n },\n returns: v.id(\"files\"),\n handler: async (\n ctx: MutationCtx,\n args: {\n storageId: Id<\"_storage\">;\n name: string;\n type: string;\n size: number;\n sessionId?: string;\n },\n ) => {\n // Validate file size\n if (args.size > MAX_FILE_SIZE) {\n throw new ConvexError({\n code: \"FILE_TOO_LARGE\",\n message: `File too large. Maximum size is ${MAX_FILE_SIZE / 1024 / 1024}MB.`,\n });\n }\n\n // Validate and sanitize filename\n const name = args.name.trim().slice(0, MAX_FILENAME_LENGTH);\n if (!name) {\n throw new ConvexError({\n code: \"INVALID_FILENAME\",\n message: \"Filename cannot be empty.\",\n });\n }\n\n // Try to get authenticated user ID\n let userId: Id<\"users\"> | undefined;\n try {\n const { getAuthUserId } = await import(\"@convex-dev/auth/server\");\n const authUserId = await getAuthUserId(ctx);\n userId = authUserId !== null ? (authUserId as Id<\"users\">) : undefined;\n } catch {\n // Auth not configured - demo mode only\n }\n\n return await ctx.db.insert(\"files\", {\n storageId: args.storageId,\n userId,\n sessionId: args.sessionId,\n name,\n type: args.type,\n size: args.size,\n });\n },\n});\n\n/**\n * Get a download URL for a file.\n *\n * Returns a temporary URL that can be used to download the file.\n */\nexport const getUrl = query({\n args: { storageId: v.id(\"_storage\") },\n returns: v.union(v.string(), v.null()),\n handler: async (ctx: QueryCtx, args: { storageId: Id<\"_storage\"> }) => {\n return await ctx.storage.getUrl(args.storageId);\n },\n});\n\n/**\n * List files for the current user or session.\n *\n * Returns files with their download URLs.\n *\n * Security:\n * - Only returns files belonging to the authenticated user\n * - Or files belonging to the provided session (demo mode)\n */\nexport const list = query({\n args: { sessionId: v.optional(v.string()) },\n returns: v.array(\n v.object({\n ...fileValidator.fields,\n url: v.union(v.string(), v.null()),\n }),\n ),\n handler: async (ctx: QueryCtx, args: { sessionId?: string }) => {\n // Try to get authenticated user ID\n let userId: Id<\"users\"> | null = null;\n try {\n const { getAuthUserId } = await import(\"@convex-dev/auth/server\");\n const authUserId = await getAuthUserId(ctx);\n userId = authUserId !== null ? (authUserId as Id<\"users\">) : null;\n } catch {\n // Auth not configured\n }\n\n let files;\n if (userId) {\n // Authenticated: show user's files\n files = await ctx.db\n .query(\"files\")\n .withIndex(\"by_user\", (q: (typeof ctx.db.query)[\"arguments\"]) =>\n q.eq(\"userId\", userId),\n )\n .collect();\n } else if (args.sessionId) {\n // Demo mode: show session's files\n files = await ctx.db\n .query(\"files\")\n .withIndex(\"by_session\", (q: (typeof ctx.db.query)[\"arguments\"]) =>\n q.eq(\"sessionId\", args.sessionId),\n )\n .collect();\n } else {\n return [];\n }\n\n // Add download URLs to each file\n return await Promise.all(\n files.map(async (file: (typeof files)[number]) => ({\n ...file,\n url: await ctx.storage.getUrl(file.storageId),\n })),\n );\n },\n});\n\n/**\n * Delete a file.\n *\n * Security:\n * - Only allows deletion if:\n * 1. User owns the file (userId matches), OR\n * 2. Session owns the file (sessionId matches), OR\n * 3. File has no owner (demo file)\n * - Also deletes the file from storage\n * - Idempotent - returns success even if file doesn't exist\n */\nexport const remove = mutation({\n args: {\n fileId: v.id(\"files\"),\n sessionId: v.optional(v.string()),\n },\n returns: v.null(),\n handler: async (\n ctx: MutationCtx,\n args: { fileId: Id<\"files\">; sessionId?: string },\n ) => {\n const file = await ctx.db.get(args.fileId);\n if (!file) {\n return null; // Idempotent - already deleted\n }\n\n // Try to get authenticated user ID\n let userId: Id<\"users\"> | null = null;\n try {\n const { getAuthUserId } = await import(\"@convex-dev/auth/server\");\n const authUserId = await getAuthUserId(ctx);\n userId = authUserId !== null ? (authUserId as Id<\"users\">) : null;\n } catch {\n // Auth not configured\n }\n\n // Check ownership\n const canDelete =\n // No owner (demo file)\n !file.userId ||\n // User owns it\n file.userId === userId ||\n // Session owns it\n (file.sessionId && file.sessionId === args.sessionId);\n\n if (canDelete) {\n // Delete from storage first\n await ctx.storage.delete(file.storageId);\n // Then delete metadata\n await ctx.db.delete(args.fileId);\n }\n\n return null;\n },\n});\n", "type": "registry:file", "target": "convex/files.ts" }, { "path": "src/registry/convex/clients/nextjs/lib/convex/client.ts", "content": "\"use client\";\n\nimport { ConvexReactClient } from \"convex/react\";\n\nconst convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL!;\n\nexport const convex = new ConvexReactClient(convexUrl);\n", "type": "registry:lib", "target": "src/lib/convex/client.ts" }, { "path": "src/registry/convex/clients/nextjs/lib/convex/provider.tsx", "content": "\"use client\";\n\nimport { ConvexAuthNextjsProvider } from \"@convex-dev/auth/nextjs\";\nimport { ConvexReactClient } from \"convex/react\";\nimport { ReactNode } from \"react\";\n\nconst convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);\n\nexport function ConvexClientProvider({ children }: { children: ReactNode }) {\n return (\n \n {children}\n \n );\n}\n", "type": "registry:lib", "target": "src/lib/convex/provider.tsx" }, { "path": "src/registry/convex/clients/nextjs/lib/convex/server.ts", "content": "import {\n fetchAction,\n fetchMutation,\n fetchQuery,\n preloadQuery,\n} from \"convex/nextjs\";\n\nimport { api } from \"@/convex/_generated/api\";\n\nexport { fetchQuery, fetchMutation, fetchAction, preloadQuery, api };\n", "type": "registry:lib", "target": "src/lib/convex/server.ts" }, { "path": "src/registry/convex/clients/nextjs/proxy.ts", "content": "import {\n convexAuthNextjsMiddleware,\n createRouteMatcher,\n} from \"@convex-dev/auth/nextjs/server\";\n\nconst isProtectedRoute = createRouteMatcher([\"/protected(.*)\"]);\n\n// Named export for Next.js 16+ (proxy.ts)\nexport const proxy = convexAuthNextjsMiddleware((request, { convexAuth }) => {\n if (isProtectedRoute(request) && !convexAuth.isAuthenticated()) {\n return Response.redirect(new URL(\"/auth/login\", request.url));\n }\n});\n\nexport const config = {\n matcher: [\"/((?!.*\\\\..*|_next).*)\", \"/\", \"/(api|trpc)(.*)\"],\n};\n", "type": "registry:lib", "target": "src/proxy.ts" } ], "envVars": { "CONVEX_DEPLOYMENT": "", "NEXT_PUBLIC_CONVEX_URL": "" }, "docs": "## Post-Install Setup\n\n1. Run `npx convex dev` to start the Convex dev server.\n\n2. Copy the deployment URL to your `.env.local` file as `NEXT_PUBLIC_CONVEX_URL`.\n\n3. The Convex schema and functions will be automatically deployed when you run `npx convex dev`.\n\n## Post-Install Setup\n\n1. Run `npx convex dev` to start the Convex dev server and get your deployment URL.\n\n2. Copy the deployment URL to your `.env.local` file as `NEXT_PUBLIC_CONVEX_URL`.\n\n3. Wrap your app with the `ConvexAuthProvider` from `lib/convex/provider.tsx`.", "type": "registry:component" }