From 13a1192dc25c56aa295d97fc67665d6616e4aae0 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Thu, 2 Oct 2025 10:52:24 +0200 Subject: [PATCH] feat: add advanced dropzone component - Create comprehensive file dropzone component using react-dropzone - Support file type validation, size limits, and file count restrictions - Add drag-and-drop functionality with visual feedback - Implement file preview and replacement capabilities - Include context-based state management for file handling - Support custom empty states and content rendering - Add file size formatting and validation error handling --- .../ui/shadcn-io/dropzone/index.tsx | 202 ++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 web/src/components/ui/shadcn-io/dropzone/index.tsx diff --git a/web/src/components/ui/shadcn-io/dropzone/index.tsx b/web/src/components/ui/shadcn-io/dropzone/index.tsx new file mode 100644 index 00000000..0d74a783 --- /dev/null +++ b/web/src/components/ui/shadcn-io/dropzone/index.tsx @@ -0,0 +1,202 @@ +'use client'; + +import { UploadIcon } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { createContext, useContext } from 'react'; +import type { DropEvent, DropzoneOptions, FileRejection } from 'react-dropzone'; +import { useDropzone } from 'react-dropzone'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +type DropzoneContextType = { + src?: File[]; + accept?: DropzoneOptions['accept']; + maxSize?: DropzoneOptions['maxSize']; + minSize?: DropzoneOptions['minSize']; + maxFiles?: DropzoneOptions['maxFiles']; +}; + +const renderBytes = (bytes: number) => { + const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + let size = bytes; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + return `${size.toFixed(2)}${units[unitIndex]}`; +}; + +const DropzoneContext = createContext( + undefined +); + +export type DropzoneProps = Omit & { + src?: File[]; + className?: string; + onDrop?: ( + acceptedFiles: File[], + fileRejections: FileRejection[], + event: DropEvent + ) => void; + children?: ReactNode; +}; + +export const Dropzone = ({ + accept, + maxFiles = 1, + maxSize, + minSize, + onDrop, + onError, + disabled, + src, + className, + children, + ...props +}: DropzoneProps) => { + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + accept, + maxFiles, + maxSize, + minSize, + onError, + disabled, + onDrop: (acceptedFiles, fileRejections, event) => { + if (fileRejections.length > 0) { + const message = fileRejections.at(0)?.errors.at(0)?.message; + onError?.(new Error(message)); + return; + } + + onDrop?.(acceptedFiles, fileRejections, event); + }, + ...props, + }); + + return ( + + + + ); +}; + +const useDropzoneContext = () => { + const context = useContext(DropzoneContext); + + if (!context) { + throw new Error('useDropzoneContext must be used within a Dropzone'); + } + + return context; +}; + +export type DropzoneContentProps = { + children?: ReactNode; + className?: string; +}; + +const maxLabelItems = 3; + +export const DropzoneContent = ({ + children, + className, +}: DropzoneContentProps) => { + const { src } = useDropzoneContext(); + + if (!src) { + return null; + } + + if (children) { + return children; + } + + return ( +
+
+ +
+

+ {src.length > maxLabelItems + ? `${new Intl.ListFormat('en').format( + src.slice(0, maxLabelItems).map((file) => file.name) + )} and ${src.length - maxLabelItems} more` + : new Intl.ListFormat('en').format(src.map((file) => file.name))} +

+

+ Drag and drop or click to replace +

+
+ ); +}; + +export type DropzoneEmptyStateProps = { + children?: ReactNode; + className?: string; +}; + +export const DropzoneEmptyState = ({ + children, + className, +}: DropzoneEmptyStateProps) => { + const { src, accept, maxSize, minSize, maxFiles } = useDropzoneContext(); + + if (src) { + return null; + } + + if (children) { + return children; + } + + let caption = ''; + + if (accept) { + caption += 'Accepts '; + caption += new Intl.ListFormat('en').format(Object.keys(accept)); + } + + if (minSize && maxSize) { + caption += ` between ${renderBytes(minSize)} and ${renderBytes(maxSize)}`; + } else if (minSize) { + caption += ` at least ${renderBytes(minSize)}`; + } else if (maxSize) { + caption += ` less than ${renderBytes(maxSize)}`; + } + + return ( +
+
+ +
+

+ Upload {maxFiles === 1 ? 'a file' : 'files'} +

+

+ Drag and drop or click to upload +

+ {caption && ( +

{caption}.

+ )} +
+ ); +};