File Upload Dropzone

PreviousNext

Modern drag & drop file uploader with progress tracking and validation

Docs
react-marketcomponent

Preview

Loading preview…
file-upload.tsx
"use client"

import type * as React from "react"
import { useCallback, useState } from "react"
import { FileIcon, UploadCloud, X, AlertCircle, CheckCircle2, Loader2 } from "lucide-react"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Progress } from "@/components/ui/progress"

export interface FileUploadProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Maximum file size in bytes (default: 5MB) */
  maxSize?: number
  /** Allowed file types (e.g. ['image/png', 'image/jpeg']) */
  accept?: string[]
  /** Maximum number of files (default: 5) */
  maxFiles?: number
  /** Whether to allow multiple file selection (default: true) */
  multiple?: boolean
  /** Whether to disable the upload component */
  disabled?: boolean
  /** Function called when files are added */
  onFilesAdded?: (files: File[]) => void | Promise<void>
  /** Function to handle file upload (simulate progress for demo) */
  onUpload?: (files: File[]) => Promise<void>
}

type FileWithProgress = {
  file: File
  progress: number
  error?: string
  uploaded?: boolean
}

export function FileUpload({
  maxSize = 5 * 1024 * 1024, // 5MB
  accept = [],
  maxFiles = 5,
  multiple = true,
  disabled = false,
  onFilesAdded,
  onUpload,
  className,
  ...props
}: FileUploadProps) {
  const [isDragging, setIsDragging] = useState(false)
  const [files, setFiles] = useState<FileWithProgress[]>([])
  const [isUploading, setIsUploading] = useState(false)

  const handleDragEnter = useCallback(
    (e: React.DragEvent<HTMLDivElement>) => {
      e.preventDefault()
      e.stopPropagation()
      if (!disabled) setIsDragging(true)
    },
    [disabled],
  )

  const handleDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault()
    e.stopPropagation()
    setIsDragging(false)
  }, [])

  const handleDragOver = useCallback(
    (e: React.DragEvent<HTMLDivElement>) => {
      e.preventDefault()
      e.stopPropagation()
      if (!disabled) setIsDragging(true)
    },
    [disabled],
  )

  const validateFile = (file: File): string | null => {
    if (file.size > maxSize) {
      return `File is too large. Maximum size is ${maxSize / (1024 * 1024)}MB.`
    }

    if (accept.length > 0 && !accept.includes(file.type)) {
      return `File type not supported. Accepted types: ${accept.join(", ")}`
    }

    return null
  }

  const processFiles = useCallback(
    (fileList: FileList | null) => {
      if (!fileList || disabled) return

      const newFiles: File[] = []

      // Convert FileList to array and validate
      Array.from(fileList).forEach((file) => {
        const error = validateFile(file)
        if (!error && files.length + newFiles.length < maxFiles) {
          newFiles.push(file)
        }
      })

      if (newFiles.length > 0) {
        const filesWithProgress = newFiles.map((file) => ({ file, progress: 0 }))
        setFiles((prev) => [...prev, ...filesWithProgress])

        if (onFilesAdded) {
          onFilesAdded(newFiles)
        }
      }
    },
    [disabled, files.length, maxFiles, onFilesAdded],
  )

  const handleDrop = useCallback(
    (e: React.DragEvent<HTMLDivElement>) => {
      e.preventDefault()
      e.stopPropagation()
      setIsDragging(false)

      processFiles(e.dataTransfer.files)
    },
    [processFiles],
  )

  const handleFileInputChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      processFiles(e.target.files)
      // Reset the input value so the same file can be selected again
      e.target.value = ""
    },
    [processFiles],
  )

  const removeFile = useCallback((indexToRemove: number) => {
    setFiles((prev) => prev.filter((_, index) => index !== indexToRemove))
  }, [])

  const handleUpload = async () => {
    if (files.length === 0 || !onUpload || isUploading) return

    setIsUploading(true)

    try {
      // Simulate upload progress for each file
      const filesToUpload = files.filter((f) => !f.uploaded && !f.error)

      if (filesToUpload.length === 0) {
        setIsUploading(false)
        return
      }

      // Update progress for each file
      const updateProgress = () => {
        setFiles((prev) =>
          prev.map((fileData) => {
            if (fileData.uploaded || fileData.error) return fileData

            const newProgress = Math.min(fileData.progress + Math.random() * 20, 100)
            return {
              ...fileData,
              progress: newProgress,
              uploaded: newProgress >= 100,
            }
          }),
        )
      }

      // Simulate progress updates
      const progressInterval = setInterval(updateProgress, 500)

      // Call the actual upload function
      await onUpload(filesToUpload.map((f) => f.file))

      // Ensure all files show as completed
      clearInterval(progressInterval)
      setFiles((prev) =>
        prev.map((fileData) => {
          if (fileData.uploaded || fileData.error) return fileData
          return { ...fileData, progress: 100, uploaded: true }
        }),
      )
    } catch (error) {
      console.error("Upload failed:", error)
    } finally {
      setIsUploading(false)
    }
  }

  const isMaxFilesReached = files.length >= maxFiles

  return (
    <div className={cn("space-y-4", className)} {...props}>
      <div
        onDragEnter={handleDragEnter}
        onDragOver={handleDragOver}
        onDragLeave={handleDragLeave}
        onDrop={handleDrop}
        className={cn(
          "relative flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-6 transition-colors",
          isDragging ? "border-primary bg-primary/5" : "border-muted-foreground/25",
          disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer hover:border-primary/50",
          "focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2",
        )}
      >
        <input
          id="file-upload"
          type="file"
          className="sr-only"
          multiple={multiple}
          accept={accept.join(",")}
          disabled={disabled || isMaxFilesReached}
          onChange={handleFileInputChange}
        />

        <label
          htmlFor="file-upload"
          className={cn(
            "flex flex-col items-center justify-center gap-2 text-center",
            disabled || isMaxFilesReached ? "cursor-not-allowed" : "cursor-pointer",
          )}
        >
          <div className="rounded-full bg-primary/10 p-3">
            <UploadCloud className="h-8 w-8 text-primary" />
          </div>
          <div className="space-y-1">
            <p className="text-sm font-medium">{isDragging ? "Drop files here" : "Drag and drop files here"}</p>
            <p className="text-xs text-muted-foreground">
              or <span className="text-primary font-medium">browse</span> to upload
            </p>
          </div>
          <div className="text-xs text-muted-foreground mt-2">
            {accept.length > 0 && <p>Accepted file types: {accept.join(", ")}</p>}
            <p>Max file size: {maxSize / (1024 * 1024)}MB</p>
            <p>
              Files: {files.length}/{maxFiles}
            </p>
          </div>
        </label>
      </div>

      {files.length > 0 && (
        <div className="space-y-4">
          <div className="space-y-2">
            {files.map((fileData, index) => (
              <div key={`${fileData.file.name}-${index}`} className="flex items-center gap-2 rounded-md border p-2">
                <div className="shrink-0 p-2">
                  <FileIcon className="h-6 w-6 text-muted-foreground" />
                </div>
                <div className="min-w-0 flex-1">
                  <div className="flex items-center justify-between">
                    <p className="truncate text-sm font-medium">{fileData.file.name}</p>
                    <div className="flex items-center gap-2">
                      {fileData.error ? (
                        <AlertCircle className="h-4 w-4 text-destructive" />
                      ) : fileData.uploaded ? (
                        <CheckCircle2 className="h-4 w-4 text-primary" />
                      ) : fileData.progress > 0 ? (
                        <span className="text-xs text-muted-foreground">{Math.round(fileData.progress)}%</span>
                      ) : null}
                      <Button
                        variant="ghost"
                        size="icon"
                        className="h-6 w-6"
                        onClick={() => removeFile(index)}
                        disabled={isUploading}
                      >
                        <X className="h-4 w-4" />
                        <span className="sr-only">Remove file</span>
                      </Button>
                    </div>
                  </div>
                  <div className="mt-1">
                    {fileData.error ? (
                      <p className="text-xs text-destructive">{fileData.error}</p>
                    ) : (
                      <Progress value={fileData.progress} className="h-1" />
                    )}
                  </div>
                  <p className="mt-1 text-xs text-muted-foreground">{(fileData.file.size / 1024).toFixed(1)} KB</p>
                </div>
              </div>
            ))}
          </div>

          <div className="flex justify-end">
            <Button onClick={handleUpload} disabled={isUploading || files.every((f) => f.uploaded || f.error)}>
              {isUploading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
              {isUploading ? "Uploading..." : "Upload Files"}
            </Button>
          </div>
        </div>
      )}
    </div>
  )
}

Installation

npx shadcn@latest add @react-market/file-upload-dropzone

Usage

import { FileUploadDropzone } from "@/components/file-upload-dropzone"
<FileUploadDropzone />