Custom Shadcn File Upload for React and Tailwind CSS. Enables users to upload files to a server or application.
Browse 10 production-ready Shadcn File Upload components for dashboards, forms, and product UI. These examples follow the Radix UI implementation with accessible primitives from the Radix stack and stay fully compatible with Shadcn Create so radius, color, and typography match your configured theme.
Browse all 10 Shadcn File Upload components for copy-ready layouts, dashboards, and forms built with Tailwind CSS in the ReUI library.
import { useFileUpload } from "@/hooks/use-file-upload"const [{ files }, { openFileDialog, getInputProps }] = useFileUpload({
accept: "image/*",
multiple: false,
})
return (
<div>
<Button onClick={openFileDialog}>Upload Image</Button>
<input {...getInputProps()} className="sr-only" />
{files.map((file) => (
<div key={file.id}>{file.file.name}</div>
))}
</div>
)A custom hook for managing file upload state and interactions.
const [state, actions] = useFileUpload(options)type FileMetadata = {
name: string
size: number
type: string
url: string
id: string
}type FileWithPreview = {
file: File | FileMetadata
id: string
preview?: string
}A utility function to format a byte count into a human-readable string (e.g., 1.5 MB).
function formatBytes(bytes: number, decimals?: number): string"use client"
import { useFileUpload } from "@/hooks/use-file-upload"
import { Button } from "@/components/ui/button"
import { CircleUserRoundIcon } from 'lucide-react'
export function Pattern() {
const [{ files }, { removeFile, openFileDialog, getInputProps }] =
useFileUpload({
accept: "image/*",
})
const previewUrl = files[0]?.preview || null
const fileName = files[0]?.file.name || null
return (
<div className="flex flex-col items-center gap-2">
<div className="inline-flex items-center gap-2 align-top">
<div
className="border-input rounded-md relative flex size-9 shrink-0 items-center justify-center overflow-hidden border"
aria-label={
previewUrl ? "Preview of uploaded image" : "Default user avatar"
}
>
{previewUrl ? (
<img
className="size-full object-cover"
src={previewUrl}
alt="Preview of uploaded image"
width={32}
height={32}
/>
) : (
<CircleUserRoundIcon className="opacity-60" width="16" height="16" aria-hidden="true" />
)}
</div>
<div className="relative inline-block">
<Button onClick={openFileDialog} aria-haspopup="dialog">
{fileName ? "Change image" : "Upload image"}
</Button>
<input
{...getInputProps()}
className="sr-only"
aria-label="Upload image file"
tabIndex={-1}
/>
</div>
</div>
{fileName ? (
<div className="inline-flex gap-2 text-xs">
<p className="text-muted-foreground truncate" aria-live="polite">
{fileName}
</p>{" "}
<button
onClick={() => removeFile(files[0]?.id)}
className="text-destructive cursor-pointer font-medium hover:underline"
aria-label={`Remove ${fileName}`}
>
Remove
</button>
</div>
) : (
<div className="inline-flex gap-2 text-xs">
<p className="text-muted-foreground truncate" aria-live="polite">
No image attached
</p>
</div>
)}
</div>
)
}
"use client"
import {
formatBytes,
useFileUpload,
type FileWithPreview,
} from "@/hooks/use-file-upload"
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/reui/alert"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { CircleAlertIcon, UserIcon, XIcon } from 'lucide-react'
interface AvatarUploadProps {
maxSize?: number
className?: string
onFileChange?: (file: FileWithPreview | null) => void
defaultAvatar?: string
}
export function Pattern({
maxSize = 2 * 1024 * 1024, // 2MB
className,
onFileChange,
defaultAvatar,
}: AvatarUploadProps) {
const [
{ files, isDragging, errors },
{
removeFile,
handleDragEnter,
handleDragLeave,
handleDragOver,
handleDrop,
openFileDialog,
getInputProps,
},
] = useFileUpload({
maxFiles: 1,
maxSize,
accept: "image/*",
multiple: false,
onFilesChange: (files) => {
onFileChange?.(files[0] || null)
},
})
const currentFile = files[0]
const previewUrl = currentFile?.preview || defaultAvatar
const handleRemove = () => {
if (currentFile) {
removeFile(currentFile.id)
}
}
return (
<div className={cn("flex flex-col items-center gap-4", className)}>
{/* Avatar Preview */}
<div className="relative">
<div
className={cn(
"group/avatar relative h-24 w-24 cursor-pointer overflow-hidden rounded-full border border-dashed transition-colors",
isDragging
? "border-primary bg-primary/5"
: "border-muted-foreground/25 hover:border-muted-foreground/20",
previewUrl && "border-solid"
)}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={openFileDialog}
>
<input {...getInputProps()} className="sr-only" />
{previewUrl ? (
<img
src={previewUrl}
alt="Avatar"
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<UserIcon className="text-muted-foreground size-6" />
</div>
)}
</div>
{/* Remove Button - only show when file is uploaded */}
{currentFile && (
<Button
size="icon"
variant="outline"
onClick={handleRemove}
className="absolute end-0.5 top-0.5 z-10 size-6 rounded-full dark:bg-zinc-800 hover:dark:bg-zinc-700"
aria-label="Remove avatar"
>
<XIcon className="size-3.5" />
</Button>
)}
</div>
{/* Upload Instructions */}
<div className="space-y-0.5 text-center">
<p className="text-sm font-medium">
{currentFile ? "Avatar uploaded" : "Upload avatar"}
</p>
<p className="text-muted-foreground text-xs">
PNG, JPG up to {formatBytes(maxSize)}
</p>
</div>
{/* Error Messages */}
{errors.length > 0 && (
<Alert variant="destructive" className="mt-5">
<CircleAlertIcon />
<AlertTitle>File upload error(s)</AlertTitle>
<AlertDescription>
{errors.map((error, index) => (
<p key={index} className="last:mb-0">
{error}
</p>
))}
</AlertDescription>
</Alert>
)}
</div>
)
}
"use client"
import {
formatBytes,
useFileUpload,
type FileMetadata,
type FileWithPreview,
} from "@/hooks/use-file-upload"
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/reui/alert"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { CircleAlertIcon, FileIcon, PlusIcon, XIcon } from 'lucide-react'
interface FileUploadCompactProps {
maxFiles?: number
maxSize?: number
accept?: string
multiple?: boolean
className?: string
onFilesChange?: (files: FileWithPreview[]) => void
}
export function Pattern({
maxFiles = 3,
maxSize = 2 * 1024 * 1024, // 2MB
accept = "image/*",
multiple = true,
className,
onFilesChange,
}: FileUploadCompactProps) {
const [
{ files, isDragging, errors },
{
removeFile,
handleDragEnter,
handleDragLeave,
handleDragOver,
handleDrop,
openFileDialog,
getInputProps,
},
] = useFileUpload({
maxFiles,
maxSize,
accept,
multiple,
onFilesChange,
})
const isImage = (file: File | FileMetadata) => {
const type = file instanceof File ? file.type : file.type
return type.startsWith("image/")
}
return (
<div className={cn("w-full max-w-lg", className)}>
{/* Compact Upload Area */}
<div
className={cn(
"border-border rounded-lg flex items-center gap-3 border border-dashed p-4 transition-colors",
isDragging
? "border-primary bg-primary/5"
: "border-muted-foreground/25 hover:border-muted-foreground/50"
)}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<input {...getInputProps()} className="sr-only" />
{/* Upload Button */}
<Button
onClick={openFileDialog}
size="sm"
className={cn(isDragging && "animate-bounce")}
>
<PlusIcon className="h-4 w-4" />
Add files
</Button>
{/* File Previews */}
<div className="flex flex-1 items-center gap-2">
{files.length === 0 ? (
<p className="text-muted-foreground text-sm">
Drop files here or click to browse (max {maxFiles} files)
</p>
) : (
files.map((fileItem) => (
<div key={fileItem.id} className="group/item relative shrink-0">
{isImage(fileItem.file) && fileItem.preview ? (
<img
src={fileItem.preview}
alt={fileItem.file.name}
className="h-12 w-12 rounded-lg border object-cover"
title={`${fileItem.file.name} (${formatBytes(fileItem.file.size)})`}
/>
) : (
<div
className="bg-muted flex h-12 w-12 items-center justify-center rounded-lg border"
title={`${fileItem.file.name} (${formatBytes(fileItem.file.size)})`}
>
<FileIcon className="text-muted-foreground h-5 w-5" />
</div>
)}
{/* Remove Button */}
<Button
onClick={() => removeFile(fileItem.id)}
variant="outline"
size="icon"
className="absolute -end-2 -top-2 size-5 rounded-full opacity-0 shadow-md transition-opacity group-hover/item:opacity-100"
>
<XIcon className="size-3" />
</Button>
</div>
))
)}
</div>
{/* File Count */}
{files.length > 0 && (
<div className="text-muted-foreground shrink-0 text-xs">
{files.length}/{maxFiles}
</div>
)}
</div>
{/* Error Messages */}
{errors.length > 0 && (
<Alert variant="destructive" className="mt-5">
<CircleAlertIcon />
<AlertTitle>File upload error(s)</AlertTitle>
<AlertDescription>
{errors.map((error, index) => (
<p key={index} className="last:mb-0">
{error}
</p>
))}
</AlertDescription>
</Alert>
)}
</div>
)
}
"use client"
import { useState } from "react"
import {
formatBytes,
useFileUpload,
type FileMetadata,
type FileWithPreview,
} from "@/hooks/use-file-upload"
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/reui/alert"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Spinner } from "@/components/ui/spinner"
import { CircleAlertIcon, ImageIcon, UploadIcon, XIcon, ZoomInIcon } from 'lucide-react'
interface GalleryUploadProps {
maxFiles?: number
maxSize?: number
accept?: string
multiple?: boolean
className?: string
onFilesChange?: (files: FileWithPreview[]) => void
}
export function Pattern({
maxFiles = 10,
maxSize = 5 * 1024 * 1024, // 5MB
accept = "image/*",
multiple = true,
className,
onFilesChange,
}: GalleryUploadProps) {
const [selectedImage, setSelectedImage] = useState<string | null>(null)
const [loadingImages, setLoadingImages] = useState<Record<string, boolean>>(
{}
)
const [isPreviewLoading, setIsPreviewLoading] = useState(false)
// Create default images using FileMetadata type
const defaultImages: FileMetadata[] = [
{
id: "default-1",
name: "avatar-1.png",
size: 44608,
type: "image/png",
url: "https://picsum.photos/1000/800?random=1",
},
{
id: "default-2",
name: "avatar-2.png",
size: 42144,
type: "image/png",
url: "https://picsum.photos/1000/800?random=2",
},
{
id: "default-3",
name: "avatar-2.png",
size: 42144,
type: "image/png",
url: "https://picsum.photos/1000/800?random=3",
},
]
const [
{ files, isDragging, errors },
{
removeFile,
clearFiles,
handleDragEnter,
handleDragLeave,
handleDragOver,
handleDrop,
openFileDialog,
getInputProps,
},
] = useFileUpload({
maxFiles,
maxSize,
accept,
multiple,
initialFiles: defaultImages,
onFilesChange,
})
const isImage = (file: File | FileMetadata) => {
const type = file instanceof File ? file.type : file.type
return type.startsWith("image/")
}
return (
<div className={cn("w-full max-w-4xl", className)}>
{/* Upload Area */}
<div
className={cn(
"rounded-lg relative border border-dashed p-8 text-center transition-colors",
isDragging
? "border-primary bg-primary/5"
: "border-muted-foreground/25 hover:border-muted-foreground/50"
)}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<input {...getInputProps()} className="sr-only" />
<div className="flex flex-col items-center gap-4">
<div
className={cn(
"flex h-16 w-16 items-center justify-center rounded-full",
isDragging ? "bg-primary/10" : "bg-muted"
)}
>
<ImageIcon className="cn(
"h-5 w-5",
isDragging ? "text-primary" : "text-muted-foreground"
)" />
</div>
<div className="space-y-2">
<h3 className="text-lg font-semibold">Upload images to gallery</h3>
<p className="text-muted-foreground text-sm">
Drag and drop images here or click to browse
</p>
<p className="text-muted-foreground text-xs">
PNG, JPG, GIF up to {formatBytes(maxSize)} each (max {maxFiles}{" "}
files)
</p>
</div>
<Button onClick={openFileDialog}>
<UploadIcon className="h-4 w-4" />
Select images
</Button>
</div>
</div>
{/* Gallery Stats */}
{files.length > 0 && (
<div className="mt-6 flex items-center justify-between">
<div className="flex items-center gap-4">
<h4 className="text-sm font-medium">
Gallery ({files.length}/{maxFiles})
</h4>
<div className="text-muted-foreground text-xs">
Total:{" "}
{formatBytes(
files.reduce((acc, file) => acc + file.file.size, 0)
)}
</div>
</div>
<Button onClick={clearFiles} variant="outline" size="sm">
Clear all
</Button>
</div>
)}
{/* Image Grid */}
{files.length > 0 && (
<div className="mt-4 grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
{files.map((fileItem) => (
<div
key={fileItem.id}
className="group/item relative aspect-square"
>
{isImage(fileItem.file) && fileItem.preview ? (
<>
{loadingImages[fileItem.id] !== false && (
<div className="bg-muted/50 rounded-lg absolute inset-0 flex items-center justify-center border">
<Spinner className="text-muted-foreground size-6" />
</div>
)}
<img
src={fileItem.preview}
alt={fileItem.file.name}
onLoad={() =>
setLoadingImages((prev) => ({
...prev,
[fileItem.id]: false,
}))
}
className={cn(
"rounded-lg h-full w-full border object-cover transition-all group-hover/item:scale-105",
loadingImages[fileItem.id] !== false
? "opacity-0"
: "opacity-100"
)}
/>
</>
) : (
<div className="bg-muted rounded-lg flex h-full w-full items-center justify-center border">
<ImageIcon className="text-muted-foreground h-8 w-8" />
</div>
)}
{/* Overlay */}
<div className="bg-black/50 absolute inset-0 flex items-center justify-center gap-2 opacity-0 transition-opacity group-hover/item:opacity-100">
{/* View Button */}
{fileItem.preview && (
<Button
onClick={() => {
setSelectedImage(fileItem.preview!)
setIsPreviewLoading(true)
}}
variant="secondary"
size="icon"
className="size-7"
>
<ZoomInIcon className="opacity-100/80" />
</Button>
)}
{/* Remove Button */}
<Button
onClick={() => removeFile(fileItem.id)}
variant="secondary"
size="icon"
className="size-7"
>
<XIcon className="opacity-100/8" />
</Button>
</div>
{/* File Info */}
<div className="rounded-b-lg absolute right-0 bottom-0 left-0 bg-black/70 p-2 text-white opacity-0 transition-opacity group-hover:opacity-100">
<p className="truncate text-xs font-medium">
{fileItem.file.name}
</p>
<p className="text-xs text-gray-300">
{formatBytes(fileItem.file.size)}
</p>
</div>
</div>
))}
</div>
)}
{/* Error Messages */}
{errors.length > 0 && (
<Alert variant="destructive" className="mt-5">
<CircleAlertIcon />
<AlertTitle>File upload error(s)</AlertTitle>
<AlertDescription>
{errors.map((error, index) => (
<p key={index} className="last:mb-0">
{error}
</p>
))}
</AlertDescription>
</Alert>
)}
{/* Image Preview Dialog */}
<Dialog
open={!!selectedImage}
onOpenChange={(open) => !open && setSelectedImage(null)}
>
<DialogContent className="[&_[data-slot=dialog-close]]:text-muted-foreground [&_[data-slot=dialog-close]]:hover:text-foreground [&_[data-slot=dialog-close]]:bg-background w-full border-none bg-transparent p-0 shadow-none sm:max-w-xl [&_[data-slot=dialog-close]]:-end-7 [&_[data-slot=dialog-close]]:-top-7 [&_[data-slot=dialog-close]]:size-7 [&_[data-slot=dialog-close]]:rounded-full">
<DialogHeader className="sr-only">
<DialogTitle>Image Preview</DialogTitle>
</DialogHeader>
<div className="flex items-center justify-center">
{selectedImage && (
<>
{isPreviewLoading && (
<div className="absolute inset-0 flex items-center justify-center">
<Spinner className="size-8 text-white" />
</div>
)}
<img
src={selectedImage}
alt="Preview"
onLoad={() => setIsPreviewLoading(false)}
className={cn(
"rounded-lg h-full w-auto object-contain transition-opacity duration-300",
isPreviewLoading ? "opacity-0" : "opacity-100"
)}
/>
</>
)}
</div>
</DialogContent>
</Dialog>
</div>
)
}
"use client"
import { useEffect, useState } from "react"
import {
formatBytes,
useFileUpload,
type FileMetadata,
type FileWithPreview,
} from "@/hooks/use-file-upload"
import {
Alert,
AlertAction,
AlertDescription,
AlertTitle,
} from "@/components/reui/alert"
import { Badge } from "@/components/reui/badge"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Progress } from "@/components/ui/progress"
import { CircleAlertIcon, FileArchiveIcon, FileSpreadsheetIcon, FileTextIcon, HeadphonesIcon, ImageIcon, RefreshCwIcon, UploadIcon, VideoIcon, XIcon } from 'lucide-react'
interface FileUploadItem extends FileWithPreview {
progress: number
status: "uploading" | "completed" | "error"
error?: string
}
interface ProgressUploadProps {
maxFiles?: number
maxSize?: number
accept?: string
multiple?: boolean
className?: string
onFilesChange?: (files: FileWithPreview[]) => void
simulateUpload?: boolean
}
export function Pattern({
maxFiles = 5,
maxSize = 10 * 1024 * 1024, // 10MB
accept = "*",
multiple = true,
className,
onFilesChange,
simulateUpload = true,
}: ProgressUploadProps) {
// Create default images using FileMetadata type
const defaultImages: FileMetadata[] = [
{
id: "default-3",
name: "image-1.png",
size: 42048,
type: "image/png",
url: "https://picsum.photos/1000/800?grayscale&random=10",
},
{
id: "default-4",
name: "image-2.png",
size: 62807,
type: "image/png",
url: "https://picsum.photos/1000/800?grayscale&random=11",
},
]
// Convert default images to FileUploadItem format
const defaultUploadFiles: FileUploadItem[] = defaultImages.map((image) => ({
id: image.id,
file: {
name: image.name,
size: image.size,
type: image.type,
} as File,
preview: image.url,
progress: 100,
status: "completed" as const,
}))
const [uploadFiles, setUploadFiles] =
useState<FileUploadItem[]>(defaultUploadFiles)
const [
{ isDragging, errors },
{
removeFile,
clearFiles,
handleDragEnter,
handleDragLeave,
handleDragOver,
handleDrop,
openFileDialog,
getInputProps,
},
] = useFileUpload({
maxFiles,
maxSize,
accept,
multiple,
initialFiles: defaultImages,
onFilesChange: (newFiles) => {
// Convert to upload items when files change, preserving existing status
const newUploadFiles = newFiles.map((file) => {
// Check if this file already exists in uploadFiles
const existingFile = uploadFiles.find(
(existing) => existing.id === file.id
)
if (existingFile) {
// Preserve existing file status and progress
return {
...existingFile,
...file, // Update any changed properties from the file
}
} else {
// New file - set to uploading
return {
...file,
progress: 0,
status: "uploading" as const,
}
}
})
setUploadFiles(newUploadFiles)
onFilesChange?.(newFiles)
},
})
// Simulate upload progress
useEffect(() => {
if (!simulateUpload) return
const interval = setInterval(() => {
setUploadFiles((prev) =>
prev.map((file) => {
if (file.status !== "uploading") return file
const increment = Math.random() * 15 + 5 // 5-20% increment
const newProgress = Math.min(file.progress + increment, 100)
// Simulate occasional errors (10% chance when progress > 50%)
if (newProgress > 50 && Math.random() < 0.1) {
return {
...file,
status: "error" as const,
error: "Upload failed. Please try again.",
}
}
// Complete when progress reaches 100%
if (newProgress >= 100) {
return {
...file,
progress: 100,
status: "completed" as const,
}
}
return {
...file,
progress: newProgress,
}
})
)
}, 500)
return () => clearInterval(interval)
}, [simulateUpload])
const retryUpload = (fileId: string) => {
setUploadFiles((prev) =>
prev.map((file) =>
file.id === fileId
? {
...file,
progress: 0,
status: "uploading" as const,
error: undefined,
}
: file
)
)
}
const removeUploadFile = (fileId: string) => {
setUploadFiles((prev) => prev.filter((file) => file.id !== fileId))
removeFile(fileId)
}
const getFileIcon = (file: File | FileMetadata) => {
const type = file instanceof File ? file.type : file.type
if (type.startsWith("image/"))
return (
<ImageIcon className="size-4" />
)
if (type.startsWith("video/"))
return (
<VideoIcon className="size-4" />
)
if (type.startsWith("audio/"))
return (
<HeadphonesIcon className="size-4" />
)
if (type.includes("pdf"))
return (
<FileTextIcon className="size-4" />
)
if (type.includes("word") || type.includes("doc"))
return (
<FileTextIcon className="size-4" />
)
if (type.includes("excel") || type.includes("sheet"))
return (
<FileSpreadsheetIcon className="size-4" />
)
if (type.includes("zip") || type.includes("rar"))
return (
<FileArchiveIcon className="size-4" />
)
return (
<FileTextIcon className="size-4" />
)
}
const completedCount = uploadFiles.filter(
(f) => f.status === "completed"
).length
const errorCount = uploadFiles.filter((f) => f.status === "error").length
const uploadingCount = uploadFiles.filter(
(f) => f.status === "uploading"
).length
return (
<div className={cn("w-full max-w-2xl", className)}>
{/* Upload Area */}
<div
className={cn(
"rounded-lg relative border border-dashed p-8 text-center transition-colors",
isDragging
? "border-primary bg-primary/5"
: "border-muted-foreground/25 hover:border-muted-foreground/50"
)}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<input {...getInputProps()} className="sr-only" />
<div className="flex flex-col items-center gap-4">
<div
className={cn(
"flex h-16 w-16 items-center justify-center rounded-full",
isDragging ? "bg-primary/10" : "bg-muted"
)}
>
<UploadIcon className="cn(
"h-6",
isDragging ? "text-primary" : "text-muted-foreground"
)" />
</div>
<div className="space-y-2">
<h3 className="text-lg font-semibold">Upload your files</h3>
<p className="text-muted-foreground text-sm">
Drag and drop files here or click to browse
</p>
<p className="text-muted-foreground text-xs">
Support for multiple file types up to {formatBytes(maxSize)} each
</p>
</div>
<Button onClick={openFileDialog}>
<UploadIcon className="h-4 w-4" />
Select files
</Button>
</div>
</div>
{/* Upload Stats */}
{uploadFiles.length > 0 && (
<div className="mt-6 flex items-center justify-between">
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium">Upload Progress</h4>
<div className="flex items-center gap-2">
{completedCount > 0 && (
<Badge size="sm" variant="success-light">
Completed: {completedCount}
</Badge>
)}
{errorCount > 0 && (
<Badge size="sm" variant="destructive">
Failed: {errorCount}
</Badge>
)}
{uploadingCount > 0 && (
<Badge size="sm" variant="secondary">
Uploading: {uploadingCount}
</Badge>
)}
</div>
</div>
<Button onClick={clearFiles} variant="outline" size="sm">
Clear all
</Button>
</div>
)}
{/* File List */}
{uploadFiles.length > 0 && (
<div className="mt-4 space-y-3">
{uploadFiles.map((fileItem: FileUploadItem) => (
<div
key={fileItem.id}
className="border-border bg-card rounded-lg border p-2.5"
>
<div className="flex items-start gap-2.5">
{/* File Icon */}
<div className="shrink-0">
{fileItem.preview &&
fileItem.file.type.startsWith("image/") ? (
<img
src={fileItem.preview}
alt={fileItem.file.name}
className="rounded-lg h-12 w-12 border object-cover"
/>
) : (
<div className="border-border text-muted-foreground rounded-lg flex h-12 w-12 items-center justify-center border">
{getFileIcon(fileItem.file)}
</div>
)}
</div>
{/* File Info */}
<div className="min-w-0 flex-1">
<div className="mt-0.75 flex items-center justify-between">
<p className="inline-flex flex-col justify-center gap-1 truncate font-medium">
<span className="text-sm">{fileItem.file.name}</span>
<span className="text-muted-foreground text-xs">
{formatBytes(fileItem.file.size)}
</span>
</p>
<div className="flex items-center gap-2">
{/* Remove Button */}
<Button
onClick={() => removeUploadFile(fileItem.id)}
variant="ghost"
size="icon"
className="text-muted-foreground size-6 hover:bg-transparent hover:opacity-100"
>
<XIcon className="size-4" />
</Button>
</div>
</div>
{/* Progress Bar */}
{fileItem.status === "uploading" && (
<div className="mt-2">
<Progress value={fileItem.progress} className="h-1" />
</div>
)}
{/* Error Message */}
{fileItem.status === "error" && fileItem.error && (
<Alert variant="destructive" className="mt-2 px-2 py-1">
<CircleAlertIcon className="size-4" />
<AlertTitle className="text-xs">
{fileItem.error}
</AlertTitle>
<AlertAction>
<Button
onClick={() => retryUpload(fileItem.id)}
variant="ghost"
size="icon"
className="text-muted-foreground size-6 hover:bg-transparent hover:opacity-100"
>
<RefreshCwIcon className="size-3.5" />
</Button>
</AlertAction>
</Alert>
)}
</div>
</div>
</div>
))}
</div>
)}
{/* Error Messages */}
{errors.length > 0 && (
<Alert variant="destructive" className="mt-5">
<CircleAlertIcon />
<AlertTitle>File upload error(s)</AlertTitle>
<AlertDescription>
{errors.map((error, index) => (
<p key={index} className="last:mb-0">
{error}
</p>
))}
</AlertDescription>
</Alert>
)}
</div>
)
}
"use client"
import { useEffect, useState } from "react"
import {
formatBytes,
useFileUpload,
type FileMetadata,
type FileWithPreview,
} from "@/hooks/use-file-upload"
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/reui/alert"
import { Badge } from "@/components/reui/badge"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { CircleAlertIcon, CloudUploadIcon, DownloadIcon, FileArchiveIcon, FileSpreadsheetIcon, FileTextIcon, HeadphonesIcon, ImageIcon, RefreshCwIcon, Trash2Icon, UploadIcon, VideoIcon } from 'lucide-react'
interface FileUploadItem extends FileWithPreview {
progress: number
status: "uploading" | "completed" | "error"
error?: string
}
interface TableUploadProps {
maxFiles?: number
maxSize?: number
accept?: string
multiple?: boolean
className?: string
onFilesChange?: (files: FileWithPreview[]) => void
simulateUpload?: boolean
}
export function Pattern({
maxFiles = 10,
maxSize = 50 * 1024 * 1024, // 50MB
accept = "*",
multiple = true,
className,
onFilesChange,
simulateUpload = true,
}: TableUploadProps) {
// Create default files using FileMetadata type
const defaultFiles: FileMetadata[] = [
{
id: "default-doc-1",
name: "document.pdf",
size: 529254,
type: "application/pdf",
url: "/media/files/document.pdf",
},
{
id: "default-doc-2",
name: "intro.zip",
size: 252846,
type: "application/zip",
url: "/media/files/intro.zip",
},
{
id: "default-doc-3",
name: "conclusion.xlsx",
size: 353126,
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
url: "/media/files/conclusion.xlsx",
},
{
id: "default-doc-4",
name: "package.json",
size: 697,
type: "application/json",
url: "/media/files/package.json",
},
]
// Convert default files to FileUploadItem format
const defaultUploadFiles: FileUploadItem[] = defaultFiles.map((file) => ({
id: file.id,
file: {
name: file.name,
size: file.size,
type: file.type,
} as File,
preview: file.url,
progress: 100,
status: "completed" as const,
}))
const [uploadFiles, setUploadFiles] =
useState<FileUploadItem[]>(defaultUploadFiles)
const [
{ isDragging, errors },
{
removeFile,
clearFiles,
handleDragEnter,
handleDragLeave,
handleDragOver,
handleDrop,
openFileDialog,
getInputProps,
},
] = useFileUpload({
maxFiles,
maxSize,
accept,
multiple,
initialFiles: defaultFiles,
onFilesChange: (newFiles) => {
// Convert to upload items when files change, preserving existing status
const newUploadFiles = newFiles.map((file) => {
// Check if this file already exists in uploadFiles
const existingFile = uploadFiles.find(
(existing) => existing.id === file.id
)
if (existingFile) {
// Preserve existing file status and progress
return {
...existingFile,
...file, // Update any changed properties from the file
}
} else {
// New file - set to uploading
return {
...file,
progress: 0,
status: "uploading" as const,
}
}
})
setUploadFiles(newUploadFiles)
onFilesChange?.(newFiles)
},
})
// Simulate upload progress
useEffect(() => {
if (!simulateUpload) return
const interval = setInterval(() => {
setUploadFiles((prev) =>
prev.map((file) => {
if (file.status !== "uploading") return file
const increment = Math.random() * 15 + 5 // 5-20% increment
const newProgress = Math.min(file.progress + increment, 100)
if (newProgress >= 100) {
// Randomly decide if upload succeeds or fails
const shouldFail = Math.random() < 0.1 // 10% chance to fail
return {
...file,
progress: 100,
status: shouldFail ? ("error" as const) : ("completed" as const),
error: shouldFail
? "Upload failed. Please try again."
: undefined,
}
}
return { ...file, progress: newProgress }
})
)
}, 500)
return () => clearInterval(interval)
}, [simulateUpload])
const removeUploadFile = (fileId: string) => {
setUploadFiles((prev) => prev.filter((file) => file.id !== fileId))
removeFile(fileId)
}
const retryUpload = (fileId: string) => {
setUploadFiles((prev) =>
prev.map((file) =>
file.id === fileId
? {
...file,
progress: 0,
status: "uploading" as const,
error: undefined,
}
: file
)
)
}
const getFileIcon = (file: File | FileMetadata) => {
const type = file instanceof File ? file.type : file.type
if (type.startsWith("image/"))
return (
<ImageIcon className="size-4" />
)
if (type.startsWith("video/"))
return (
<VideoIcon className="size-4" />
)
if (type.startsWith("audio/"))
return (
<HeadphonesIcon className="size-4" />
)
if (type.includes("pdf"))
return (
<FileTextIcon className="size-4" />
)
if (type.includes("word") || type.includes("doc"))
return (
<FileTextIcon className="size-4" />
)
if (type.includes("excel") || type.includes("sheet"))
return (
<FileSpreadsheetIcon className="size-4" />
)
if (type.includes("zip") || type.includes("rar"))
return (
<FileArchiveIcon className="size-4" />
)
return (
<FileTextIcon className="size-4" />
)
}
const getFileTypeLabel = (file: File | FileMetadata) => {
const type = file instanceof File ? file.type : file.type
if (type.startsWith("image/")) return "Image"
if (type.startsWith("video/")) return "Video"
if (type.startsWith("audio/")) return "Audio"
if (type.includes("pdf")) return "PDF"
if (type.includes("word") || type.includes("doc")) return "Word"
if (type.includes("excel") || type.includes("sheet")) return "Excel"
if (type.includes("zip") || type.includes("rar")) return "Archive"
if (type.includes("json")) return "JSON"
if (type.includes("text")) return "Text"
return "File"
}
return (
<div className={cn("w-full space-y-4", className)}>
{/* Upload Area */}
<div
className={cn(
"relative rounded-lg border border-dashed p-6 text-center transition-colors",
isDragging
? "border-primary bg-primary/5"
: "border-muted-foreground/25 hover:border-muted-foreground/50"
)}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<input {...getInputProps()} className="sr-only" />
<div className="flex flex-col items-center gap-4">
<div
className={cn(
"bg-muted flex h-12 w-12 items-center justify-center rounded-full transition-colors",
isDragging
? "border-primary bg-primary/10"
: "border-muted-foreground/25"
)}
>
<UploadIcon className="text-muted-foreground h-5 w-5" />
</div>
<div className="space-y-2">
<p className="text-sm font-medium">
Drop files here or{" "}
<button
type="button"
onClick={openFileDialog}
className="text-primary cursor-pointer underline-offset-4 hover:underline"
>
browse files
</button>
</p>
<p className="text-muted-foreground text-xs">
Maximum file size: {formatBytes(maxSize)} • Maximum files:{" "}
{maxFiles}
</p>
</div>
</div>
</div>
{/* Files Table */}
{uploadFiles.length > 0 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">
Files ({uploadFiles.length})
</h3>
<div className="flex gap-2">
<Button onClick={openFileDialog} variant="outline" size="sm">
<CloudUploadIcon className="h-4 w-4" />
Add files
</Button>
<Button onClick={clearFiles} variant="outline" size="sm">
<Trash2Icon className="h-4 w-4" />
Remove all
</Button>
</div>
</div>
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow className="text-xs">
<TableHead className="h-9 ps-4">Name</TableHead>
<TableHead className="h-9">Type</TableHead>
<TableHead className="h-9">Size</TableHead>
<TableHead className="h-9 w-[100px] ps-4">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{uploadFiles.map((fileItem) => (
<TableRow key={fileItem.id}>
<TableCell className="py-2 ps-1.5">
<div className="flex items-center gap-1">
<div
className={cn(
"text-muted-foreground/80 relative flex size-8 shrink-0 items-center justify-center"
)}
>
{fileItem.status === "uploading" ? (
<div className="relative">
{/* Circular progress background */}
<svg
className="size-8 -rotate-90"
viewBox="0 0 32 32"
>
<circle
cx="16"
cy="16"
r="14"
fill="none"
stroke="currentColor"
strokeWidth="2"
className="text-muted-foreground/20"
/>
{/* Progress circle */}
<circle
cx="16"
cy="16"
r="14"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeDasharray={`${2 * Math.PI * 14}`}
strokeDashoffset={`${2 * Math.PI * 14 * (1 - fileItem.progress / 100)}`}
className="text-primary transition-all duration-300"
strokeLinecap="round"
/>
</svg>
{/* File icon in center */}
<div className="absolute inset-0 flex items-center justify-center">
{getFileIcon(fileItem.file)}
</div>
</div>
) : (
<div className="not-[]:size-8 flex items-center justify-center">
{getFileIcon(fileItem.file)}
</div>
)}
</div>
<p className="flex items-center gap-1 truncate text-sm font-medium">
{fileItem.file.name}
{fileItem.status === "error" && (
<Badge variant="destructive-light" size="sm">
Error
</Badge>
)}
</p>
</div>
</TableCell>
<TableCell className="py-2">
<Badge variant="secondary" className="text-xs">
{getFileTypeLabel(fileItem.file)}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground py-2 text-sm">
{formatBytes(fileItem.file.size)}
</TableCell>
<TableCell className="py-2">
<div className="flex items-center gap-1">
{fileItem.preview && (
<Button
size="icon"
variant="ghost"
className="size-8"
>
<DownloadIcon className="size-3.5" />
</Button>
)}
{fileItem.status === "error" ? (
<Button
onClick={() => retryUpload(fileItem.id)}
variant="ghost"
size="icon"
className="text-destructive/80 hover:text-destructive size-8"
>
<RefreshCwIcon className="size-3.5" />
</Button>
) : (
<Button
onClick={() => removeUploadFile(fileItem.id)}
variant="ghost"
size="icon"
className="size-8"
>
<Trash2Icon className="size-3.5" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
{/* Error Messages */}
{errors.length > 0 && (
<Alert variant="destructive" className="mt-5">
<CircleAlertIcon />
<AlertTitle>File upload error(s)</AlertTitle>
<AlertDescription>
{errors.map((error, index) => (
<p key={index} className="last:mb-0">
{error}
</p>
))}
</AlertDescription>
</Alert>
)}
</div>
)
}
"use client"
import { useCallback, useState } from "react"
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/reui/alert"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Progress } from "@/components/ui/progress"
import { CircleAlertIcon, CircleXIcon, CloudUploadIcon, ImageIcon, XIcon } from 'lucide-react'
interface ImageFile {
id: string
file: File
preview: string
progress: number
status: "uploading" | "completed" | "error"
error?: string
}
interface ImageUploadProps {
maxFiles?: number
maxSize?: number
accept?: string
className?: string
onImagesChange?: (images: ImageFile[]) => void
onUploadComplete?: (images: ImageFile[]) => void
}
export function Pattern({
maxFiles = 10,
maxSize = 2 * 1024 * 1024, // 2MB
accept = "image/*",
className,
onImagesChange,
onUploadComplete,
}: ImageUploadProps) {
const [images, setImages] = useState<ImageFile[]>([])
const [isDragging, setIsDragging] = useState(false)
const [errors, setErrors] = useState<string[]>([])
const [visibleDefaultImages, setVisibleDefaultImages] = useState([
{
id: "default-1",
src: "https://picsum.photos/1000/800?grayscale&random=4",
alt: "Product view 1",
},
{
id: "default-2",
src: "https://picsum.photos/1000/800?grayscale&random=5",
alt: "Product view 2",
},
{
id: "default-3",
src: "https://picsum.photos/1000/800?grayscale&random=6",
alt: "Product view 3",
},
{
id: "default-4",
src: "https://picsum.photos/1000/800?grayscale&random=7",
alt: "Product view 4",
},
])
const validateFile = (file: File): string | null => {
if (!file.type.startsWith("image/")) {
return "File must be an image"
}
if (file.size > maxSize) {
return `File size must be less than ${(maxSize / 1024 / 1024).toFixed(1)}MB`
}
if (images.length >= maxFiles) {
return `Maximum ${maxFiles} files allowed`
}
return null
}
const addImages = useCallback(
(files: FileList | File[]) => {
const newImages: ImageFile[] = []
const newErrors: string[] = []
Array.from(files).forEach((file) => {
const error = validateFile(file)
if (error) {
newErrors.push(`${file.name}: ${error}`)
return
}
const imageFile: ImageFile = {
id: `${Date.now()}-${Math.random()}`,
file,
preview: URL.createObjectURL(file),
progress: 0,
status: "uploading",
}
newImages.push(imageFile)
})
if (newErrors.length > 0) {
setErrors((prev) => [...prev, ...newErrors])
}
if (newImages.length > 0) {
const updatedImages = [...images, ...newImages]
setImages(updatedImages)
onImagesChange?.(updatedImages)
// Simulate upload progress
newImages.forEach((imageFile) => {
simulateUpload(imageFile)
})
}
},
[images, maxSize, maxFiles, onImagesChange]
)
const simulateUpload = (imageFile: ImageFile) => {
let progress = 0
const interval = setInterval(() => {
progress += Math.random() * 20
if (progress >= 100) {
progress = 100
clearInterval(interval)
setImages((prev) =>
prev.map((img) =>
img.id === imageFile.id
? { ...img, progress: 100, status: "completed" as const }
: img
)
)
// Check if all uploads are complete
const updatedImages = images.map((img) =>
img.id === imageFile.id
? { ...img, progress: 100, status: "completed" as const }
: img
)
if (updatedImages.every((img) => img.status === "completed")) {
onUploadComplete?.(updatedImages)
}
} else {
setImages((prev) =>
prev.map((img) =>
img.id === imageFile.id ? { ...img, progress } : img
)
)
}
}, 100)
}
const removeImage = useCallback((id: string) => {
// If it's a default image, remove it from visible defaults
if (id.startsWith("default-")) {
setVisibleDefaultImages((prev) => prev.filter((img) => img.id !== id))
return
}
// Remove uploaded image
setImages((prev) => {
const image = prev.find((img) => img.id === id)
if (image) {
URL.revokeObjectURL(image.preview)
}
return prev.filter((img) => img.id !== id)
})
}, [])
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}, [])
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
}, [])
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
}, [])
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
const files = e.dataTransfer.files
if (files.length > 0) {
addImages(files)
}
},
[addImages]
)
const openFileDialog = useCallback(() => {
const input = document.createElement("input")
input.type = "file"
input.multiple = true
input.accept = accept
input.onchange = (e) => {
const target = e.target as HTMLInputElement
if (target.files) {
addImages(target.files)
}
}
input.click()
}, [accept, addImages])
const formatBytes = (bytes: number): string => {
if (bytes === 0) return "0 Bytes"
const k = 1024
const sizes = ["Bytes", "KB", "MB", "GB"]
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i]
}
return (
<div className={cn("w-full max-w-4xl", className)}>
{/* Image Grid - Moved to top */}
<div className="mb-6">
<div className="grid grid-cols-4 gap-2.5">
{/* Always show all visible default images first */}
{visibleDefaultImages.map((defaultImg) => (
<Card
key={defaultImg.id}
className="bg-accent/50 group/item rounded-md relative flex shrink-0 items-center justify-center p-0 shadow-none"
>
<img
src={defaultImg.src}
className="rounded-md h-[120px] w-full object-cover"
alt={defaultImg.alt}
/>
{/* Remove Button Overlay for default images too */}
<Button
onClick={() => removeImage(defaultImg.id)}
variant="outline"
size="icon"
className="absolute end-1 top-1 size-6 rounded-full opacity-0 shadow-sm group-hover/item:opacity-100 dark:bg-zinc-800 hover:dark:bg-zinc-700"
>
<XIcon className="size-3.5" />
</Button>
</Card>
))}
</div>
{/* Show uploaded images in a separate grid below */}
{images.length > 0 && (
<div className="mt-4 grid grid-cols-4 gap-2.5">
{images.map((imageFile, index) => (
<Card
key={imageFile.id}
className="bg-accent/50 group/item relative flex shrink-0 items-center justify-center rounded-md p-0 shadow-none"
>
<img
src={imageFile.preview}
className="h-[120px] w-full rounded-md object-cover"
alt={`Product view ${index + 1}`}
/>
{/* Remove Button Overlay */}
<Button
onClick={() => removeImage(imageFile.id)}
variant="outline"
size="icon"
className="absolute end-2 top-2 size-6 rounded-full opacity-0 shadow-sm group-hover/item:opacity-100 dark:bg-zinc-800 hover:dark:bg-zinc-700"
>
<XIcon className="size-3.5" />
</Button>
</Card>
))}
</div>
)}
</div>
{/* Upload Area */}
<Card
className={cn(
"rounded-md border-dashed shadow-none transition-colors",
isDragging
? "border-primary bg-primary/5"
: "border-muted-foreground/25 hover:border-muted-foreground/50"
)}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<CardContent className="text-center">
<div className="border-border mx-auto mb-3 flex size-[32px] items-center justify-center rounded-full border">
<CloudUploadIcon className="size-4" />
</div>
<h3 className="text-2sm text-foreground mb-0.5 font-semibold">
Choose a file or drag & drop here.
</h3>
<span className="text-secondary-foreground mb-3 block text-xs font-normal">
JPEG, PNG, up to {formatBytes(maxSize)}.
</span>
<Button size="sm" onClick={openFileDialog}>
Browse File
</Button>
</CardContent>
</Card>
{/* Upload Progress Cards */}
{images.length > 0 && (
<div className="mt-6 space-y-3">
{images.map((imageFile) => (
<Card
key={imageFile.id}
className="rounded-md p-0 shadow-none"
>
<CardContent className="flex items-center gap-2 p-3">
<div className="border-border rounded-md flex size-[32px] shrink-0 items-center justify-center border">
<ImageIcon className="text-muted-foreground size-4" />
</div>
<div className="flex w-full flex-col gap-1.5">
<div className="-mt-2 flex w-full items-center justify-between gap-2.5">
<div className="flex items-center gap-2.5">
<span className="text-foreground text-xs leading-none font-medium">
{imageFile.file.name}
</span>
<span className="text-muted-foreground text-xs leading-none font-normal">
{formatBytes(imageFile.file.size)}
</span>
{imageFile.status === "uploading" && (
<p className="text-muted-foreground text-xs">
Uploading... {Math.round(imageFile.progress)}%
</p>
)}
</div>
<Button
onClick={() => removeImage(imageFile.id)}
variant="ghost"
size="icon"
className="size-6"
>
<CircleXIcon className="size-3.5" />
</Button>
</div>
<Progress
value={imageFile.progress}
className={cn(
"h-1 transition-all duration-300",
"[&>div]:bg-zinc-950 dark:[&>div]:bg-zinc-50"
)}
/>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Error Messages */}
{errors.length > 0 && (
<Alert variant="destructive" className="mt-5">
<CircleAlertIcon />
<AlertTitle>File upload error(s)</AlertTitle>
<AlertDescription>
{errors.map((error, index) => (
<p key={index} className="last:mb-0">
{error}
</p>
))}
</AlertDescription>
</Alert>
)}
</div>
)
}
"use client"
import { useCallback, useEffect, useState } from "react"
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/reui/alert"
import {
Sortable,
SortableItem,
SortableItemHandle,
} from "@/components/reui/sortable"
import { toast } from "sonner"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Progress } from "@/components/ui/progress"
import { CircleAlertIcon, CircleXIcon, CloudUploadIcon, GripVerticalIcon, ImageIcon, XIcon } from 'lucide-react'
interface ImageFile {
id: string
file: File
preview: string
progress: number
status: "uploading" | "completed" | "error"
error?: string
}
type SortableImage = {
id: string
src: string
alt: string
type: "default" | "uploaded"
}
interface ImageUploadProps {
maxFiles?: number
maxSize?: number
accept?: string
className?: string
onImagesChange?: (images: ImageFile[]) => void
onUploadComplete?: (images: ImageFile[]) => void
}
export function Pattern({
maxFiles = 5, // Changed to 5 as per UI reference
maxSize = 10 * 1024 * 1024, // 10MB as per UI reference
accept = "image/*",
className,
onImagesChange,
onUploadComplete,
}: ImageUploadProps) {
const [images, setImages] = useState<ImageFile[]>([])
const [isDragging, setIsDragging] = useState(false)
const [errors, setErrors] = useState<string[]>([])
const [allImages, setAllImages] = useState<SortableImage[]>([
{
id: "default-1",
src: "https://picsum.photos/1000/800?grayscale&random=6",
alt: "Product view 1",
type: "default",
},
{
id: "default-2",
src: "https://picsum.photos/1000/800?grayscale&random=7",
alt: "Product view 2",
type: "default",
},
{
id: "default-3",
src: "https://picsum.photos/1000/800?grayscale&random=8",
alt: "Product view 3",
type: "default",
},
{
id: "default-4",
src: "https://picsum.photos/1000/800?grayscale&random=9",
alt: "Product view 4",
type: "default",
},
{
id: "default-5",
src: "https://picsum.photos/1000/800?grayscale&random=10",
alt: "Product view 5",
type: "default",
},
])
// Helper function to create SortableImage from ImageFile
const createSortableImage = useCallback(
(imageFile: ImageFile): SortableImage => ({
id: imageFile.id,
src: imageFile.preview,
alt: imageFile.file.name,
type: "uploaded",
}),
[]
)
// Ensure arrays never contain undefined items
useEffect(() => {
setAllImages((prev) => prev.filter((item) => item && item.id))
setImages((prev) => prev.filter((item) => item && item.id))
}, [])
const validateFile = (file: File): string | null => {
if (!file.type.startsWith("image/")) {
return "File must be an image"
}
if (file.size > maxSize) {
return `File size must be less than ${(maxSize / 1024 / 1024).toFixed(1)}MB`
}
if (images.length >= maxFiles) {
return `Maximum ${maxFiles} files allowed`
}
return null
}
const addImages = useCallback(
(files: FileList | File[]) => {
const newImages: ImageFile[] = []
const newErrors: string[] = []
Array.from(files).forEach((file) => {
const error = validateFile(file)
if (error) {
newErrors.push(`${file.name}: ${error}`)
return
}
const imageFile: ImageFile = {
id: `${Date.now()}-${Math.random()}`,
file,
preview: URL.createObjectURL(file),
progress: 0,
status: "uploading",
}
newImages.push(imageFile)
})
if (newErrors.length > 0) {
setErrors((prev) => [...prev, ...newErrors])
}
if (newImages.length > 0) {
const updatedImages = [...images, ...newImages]
setImages(updatedImages)
onImagesChange?.(updatedImages)
// Add new images to allImages for sorting
const newSortableImages = newImages.map(createSortableImage)
setAllImages((prev) => [...prev, ...newSortableImages])
// Simulate upload progress
newImages.forEach((imageFile) => {
simulateUpload(imageFile)
})
}
},
[images, maxSize, maxFiles, onImagesChange, createSortableImage]
)
const simulateUpload = (imageFile: ImageFile) => {
let progress = 0
const interval = setInterval(() => {
progress += Math.random() * 20
if (progress >= 100) {
progress = 100
clearInterval(interval)
setImages((prev) =>
prev.map((img) =>
img.id === imageFile.id
? { ...img, progress: 100, status: "completed" as const }
: img
)
)
// Check if all uploads are complete
const updatedImages = images.map((img) =>
img.id === imageFile.id
? { ...img, progress: 100, status: "completed" as const }
: img
)
if (updatedImages.every((img) => img.status === "completed")) {
onUploadComplete?.(updatedImages)
}
} else {
setImages((prev) =>
prev.map((img) =>
img.id === imageFile.id ? { ...img, progress } : img
)
)
}
}, 100)
}
const removeImage = useCallback(
(id: string) => {
// Remove from allImages
setAllImages((prev) => prev.filter((img) => img.id !== id))
// If it's an uploaded image, also remove from images array and revoke URL
const uploadedImage = images.find((img) => img.id === id)
if (uploadedImage) {
URL.revokeObjectURL(uploadedImage.preview)
setImages((prev) => prev.filter((img) => img.id !== id))
}
},
[images]
)
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}, [])
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
}, [])
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
}, [])
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
const files = e.dataTransfer.files
if (files.length > 0) {
addImages(files)
}
},
[addImages]
)
const openFileDialog = useCallback(() => {
const input = document.createElement("input")
input.type = "file"
input.multiple = true
input.accept = accept
input.onchange = (e) => {
const target = e.target as HTMLInputElement
if (target.files) {
addImages(target.files)
}
}
input.click()
}, [accept, addImages])
const formatBytes = (bytes: number): string => {
if (bytes === 0) return "0 Bytes"
const k = 1024
const sizes = ["Bytes", "KB", "MB", "GB"]
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i]
}
return (
<div className={cn("w-full max-w-4xl", className)}>
{/* Instructions */}
<div className="mb-4 text-center">
<p className="text-muted-foreground text-sm">
Upload up to {maxFiles} images (JPG, PNG, GIF, WebP, max{" "}
{formatBytes(maxSize)} each). <br />
Drag and drop images to reorder.
{images.length > 0 && ` ${images.length}/${maxFiles} uploaded.`}
</p>
</div>
{/* Image Grid with Sortable */}
<div className="mb-6">
{/* Combined Images Sortable */}
<Sortable
value={allImages.map((item) => item.id)}
onValueChange={(newItemIds) => {
// Reconstruct the allImages array based on the new order
const newAllImages = newItemIds
.map((itemId) => {
// First try to find in allImages (default images)
const existingImage = allImages.find((img) => img.id === itemId)
if (existingImage) return existingImage
// If not found, it's a newly uploaded image
const uploadedImage = images.find((img) => img.id === itemId)
if (uploadedImage) {
return createSortableImage(uploadedImage)
}
return null
})
.filter((item): item is SortableImage => item !== null)
setAllImages(newAllImages)
toast.success("Images reordered successfully!", {
duration: 3000,
})
}}
getItemValue={(item) => item}
strategy="grid"
className="grid auto-rows-fr grid-cols-5 gap-2.5"
>
{allImages.map((item) => (
<SortableItem key={item.id} value={item.id}>
<div className="bg-accent/50 group/item border-border hover:bg-accent/70 rounded-md relative flex shrink-0 items-center justify-center border shadow-none transition-all duration-200 hover:z-10 data-[dragging=true]:z-50">
<img
src={item.src}
className="rounded-md pointer-events-none h-[120px] w-full object-cover"
alt={item.alt}
/>
{/* Drag Handle */}
<SortableItemHandle className="absolute start-2 top-2 cursor-grab opacity-0 group-hover/item:opacity-100 active:cursor-grabbing">
<Button
variant="outline"
size="icon"
className="size-6 rounded-full dark:bg-zinc-800 hover:dark:bg-zinc-700"
>
<GripVerticalIcon className="size-3.5" />
</Button>
</SortableItemHandle>
{/* Remove Button Overlay */}
<Button
onClick={() => removeImage(item.id)}
variant="outline"
size="icon"
className="absolute end-2 top-2 size-6 rounded-full opacity-0 shadow-sm group-hover/item:opacity-100 dark:bg-zinc-800 hover:dark:bg-zinc-700"
>
<XIcon className="size-3.5" />
</Button>
</div>
</SortableItem>
))}
</Sortable>
</div>
{/* Upload Area */}
<Card
className={cn(
"rounded-md border-dashed shadow-none transition-colors",
isDragging
? "border-primary bg-primary/5"
: "border-muted-foreground/25 hover:border-muted-foreground/50"
)}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<CardContent className="text-center">
<div className="border-border mx-auto mb-3 flex size-[32px] items-center justify-center rounded-full border">
<CloudUploadIcon className="size-4" />
</div>
<h3 className="text-2sm text-foreground mb-0.5 font-medium">
Choose a file or drag & drop here.
</h3>
<span className="text-secondary-foreground mb-3 block text-xs font-normal">
JPEG, PNG, up to {formatBytes(maxSize)}.
</span>
<Button size="sm" onClick={openFileDialog}>
Browse File
</Button>
</CardContent>
</Card>
{/* Upload Progress Cards */}
{images.length > 0 && (
<div className="mt-6 space-y-3">
{images.map((imageFile) => (
<Card
key={imageFile.id}
className="rounded-md shadow-none"
>
<CardContent className="flex items-center gap-2 p-2.5">
<div className="border-border rounded-md flex size-[32px] shrink-0 items-center justify-center border">
<ImageIcon className="text-muted-foreground size-4" />
</div>
<div className="flex w-full flex-col gap-1.5">
<div className="-mt-2 flex w-full items-center justify-between gap-2.5">
<div className="flex items-center gap-2.5">
<span className="text-foreground text-xs leading-none font-medium">
{imageFile.file.name}
</span>
<span className="text-muted-foreground text-xs leading-none font-normal">
{formatBytes(imageFile.file.size)}
</span>
{imageFile.status === "uploading" && (
<p className="text-muted-foreground text-xs">
Uploading... {Math.round(imageFile.progress)}%
</p>
)}
</div>
<Button
onClick={() => removeImage(imageFile.id)}
variant="ghost"
size="icon"
className="size-6"
>
<CircleXIcon className="size-3.5" />
</Button>
</div>
<Progress
value={imageFile.progress}
className={cn(
"h-1 transition-all duration-300",
"[&>div]:bg-zinc-950 dark:[&>div]:bg-zinc-50"
)}
/>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Error Messages */}
{errors.length > 0 && (
<Alert variant="destructive" className="mt-5">
<CircleAlertIcon />
<AlertTitle>File upload error(s)</AlertTitle>
<AlertDescription>
{errors.map((error, index) => (
<p key={index} className="last:mb-0">
{error}
</p>
))}
</AlertDescription>
</Alert>
)}
</div>
)
}