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 use Base UI primitives from @base-ui/react 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>
)
}