466 lines
14 KiB
TypeScript
466 lines
14 KiB
TypeScript
'use client'
|
|
|
|
// React Imports
|
|
import { useEffect, useState } from 'react'
|
|
|
|
// MUI Imports
|
|
import type { BoxProps } from '@mui/material/Box'
|
|
import Button from '@mui/material/Button'
|
|
import IconButton from '@mui/material/IconButton'
|
|
import List from '@mui/material/List'
|
|
import ListItem from '@mui/material/ListItem'
|
|
import Typography from '@mui/material/Typography'
|
|
import Chip from '@mui/material/Chip'
|
|
import LinearProgress from '@mui/material/LinearProgress'
|
|
import { styled } from '@mui/material/styles'
|
|
|
|
// Third-party Imports
|
|
import { useDropzone } from 'react-dropzone'
|
|
|
|
// Component Imports
|
|
import Link from '@components/Link'
|
|
import CustomAvatar from '@core/components/mui/Avatar'
|
|
|
|
// Styled Component Imports
|
|
import AppReactDropzone from '@/libs/styles/AppReactDropzone'
|
|
|
|
type FileProp = {
|
|
name: string
|
|
type: string
|
|
size: number
|
|
}
|
|
|
|
type UploadedImage = {
|
|
id: string
|
|
url: string
|
|
name: string
|
|
size: number
|
|
}
|
|
|
|
type UploadProgress = {
|
|
[fileId: string]: number
|
|
}
|
|
|
|
interface MultipleImageUploadProps {
|
|
// Required props
|
|
onUpload: (files: File[]) => Promise<string[]> | string[] // Returns array of image URLs
|
|
onSingleUpload?: (file: File) => Promise<string> | string // For individual file upload
|
|
|
|
// Optional customization props
|
|
title?: string | null
|
|
currentImages?: UploadedImage[]
|
|
onImagesChange?: (images: UploadedImage[]) => void
|
|
onImageRemove?: (imageId: string) => void
|
|
|
|
// Upload state
|
|
isUploading?: boolean
|
|
uploadProgress?: UploadProgress
|
|
|
|
// Limits
|
|
maxFiles?: number
|
|
maxFileSize?: number // in bytes
|
|
acceptedFileTypes?: string[]
|
|
|
|
// UI customization
|
|
showUrlOption?: boolean
|
|
uploadButtonText?: string
|
|
browseButtonText?: string
|
|
dragDropText?: string
|
|
replaceText?: string
|
|
maxFilesText?: string
|
|
|
|
// Style customization
|
|
className?: string
|
|
disabled?: boolean
|
|
|
|
// Upload modes
|
|
uploadMode?: 'batch' | 'individual' // batch: upload all at once, individual: upload one by one
|
|
}
|
|
|
|
// Styled Dropzone Component
|
|
const Dropzone = styled(AppReactDropzone)<BoxProps>(({ theme }) => ({
|
|
'& .dropzone': {
|
|
minHeight: 'unset',
|
|
padding: theme.spacing(12),
|
|
[theme.breakpoints.down('sm')]: {
|
|
paddingInline: theme.spacing(5)
|
|
},
|
|
'&+.MuiList-root .MuiListItem-root .file-name': {
|
|
fontWeight: theme.typography.body1.fontWeight
|
|
}
|
|
}
|
|
}))
|
|
|
|
const MultipleImageUpload: React.FC<MultipleImageUploadProps> = ({
|
|
onUpload,
|
|
onSingleUpload,
|
|
title = null,
|
|
currentImages = [],
|
|
onImagesChange,
|
|
onImageRemove,
|
|
isUploading = false,
|
|
uploadProgress = {},
|
|
maxFiles = 10,
|
|
maxFileSize = 5 * 1024 * 1024, // 5MB default
|
|
acceptedFileTypes = ['image/*'],
|
|
showUrlOption = true,
|
|
uploadButtonText = 'Upload All',
|
|
browseButtonText = 'Browse Images',
|
|
dragDropText = 'Drag and Drop Your Images Here.',
|
|
replaceText = 'Drop Images to Add More',
|
|
maxFilesText = 'Maximum {max} files allowed',
|
|
className = '',
|
|
disabled = false,
|
|
uploadMode = 'batch'
|
|
}) => {
|
|
// States
|
|
const [files, setFiles] = useState<File[]>([])
|
|
const [error, setError] = useState<string>('')
|
|
const [individualUploading, setIndividualUploading] = useState<Set<string>>(new Set())
|
|
|
|
const handleBatchUpload = async () => {
|
|
if (!files.length) return
|
|
|
|
try {
|
|
setError('')
|
|
const imageUrls = await onUpload(files)
|
|
|
|
if (Array.isArray(imageUrls)) {
|
|
const newImages: UploadedImage[] = files.map((file, index) => ({
|
|
id: `${Date.now()}-${index}`,
|
|
url: imageUrls[index],
|
|
name: file.name,
|
|
size: file.size
|
|
}))
|
|
|
|
const updatedImages = [...currentImages, ...newImages]
|
|
onImagesChange?.(updatedImages)
|
|
setFiles([]) // Clear files after successful upload
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Upload failed')
|
|
}
|
|
}
|
|
|
|
const handleIndividualUpload = async (file: File, fileIndex: number) => {
|
|
if (!onSingleUpload) return
|
|
|
|
const fileId = `${file.name}-${fileIndex}`
|
|
setIndividualUploading(prev => new Set(prev).add(fileId))
|
|
|
|
try {
|
|
setError('')
|
|
const imageUrl = await onSingleUpload(file)
|
|
|
|
if (typeof imageUrl === 'string') {
|
|
const newImage: UploadedImage = {
|
|
id: `${Date.now()}-${fileIndex}`,
|
|
url: imageUrl,
|
|
name: file.name,
|
|
size: file.size
|
|
}
|
|
|
|
const updatedImages = [...currentImages, newImage]
|
|
onImagesChange?.(updatedImages)
|
|
|
|
// Remove uploaded file from pending files
|
|
setFiles(prev => prev.filter((_, index) => index !== fileIndex))
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Upload failed')
|
|
} finally {
|
|
setIndividualUploading(prev => {
|
|
const newSet = new Set(prev)
|
|
newSet.delete(fileId)
|
|
return newSet
|
|
})
|
|
}
|
|
}
|
|
|
|
// Hooks
|
|
const { getRootProps, getInputProps } = useDropzone({
|
|
onDrop: (acceptedFiles: File[]) => {
|
|
setError('')
|
|
|
|
if (acceptedFiles.length === 0) return
|
|
|
|
const totalFiles = currentImages.length + files.length + acceptedFiles.length
|
|
|
|
if (totalFiles > maxFiles) {
|
|
setError(`Cannot upload more than ${maxFiles} files. Current: ${currentImages.length + files.length}`)
|
|
return
|
|
}
|
|
|
|
// Validate file sizes
|
|
const invalidFiles = acceptedFiles.filter(file => file.size > maxFileSize)
|
|
if (invalidFiles.length > 0) {
|
|
setError(`Some files exceed ${formatFileSize(maxFileSize)} limit`)
|
|
return
|
|
}
|
|
|
|
// Add to existing files
|
|
setFiles(prev => [...prev, ...acceptedFiles])
|
|
},
|
|
accept: acceptedFileTypes.reduce((acc, type) => ({ ...acc, [type]: [] }), {}),
|
|
disabled: disabled || isUploading,
|
|
multiple: true
|
|
})
|
|
|
|
const formatFileSize = (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(2)) + ' ' + sizes[i]
|
|
}
|
|
|
|
const renderFilePreview = (file: FileProp) => {
|
|
if (file.type.startsWith('image')) {
|
|
return (
|
|
<img
|
|
width={38}
|
|
height={38}
|
|
alt={file.name}
|
|
src={URL.createObjectURL(file as any)}
|
|
className='rounded object-cover'
|
|
/>
|
|
)
|
|
} else {
|
|
return <i className='tabler-file-description' />
|
|
}
|
|
}
|
|
|
|
const handleRemoveFile = (fileIndex: number) => {
|
|
setFiles(prev => prev.filter((_, index) => index !== fileIndex))
|
|
setError('')
|
|
}
|
|
|
|
const handleRemoveCurrentImage = (imageId: string) => {
|
|
onImageRemove?.(imageId)
|
|
}
|
|
|
|
const handleRemoveAllFiles = () => {
|
|
setFiles([])
|
|
setError('')
|
|
}
|
|
|
|
const isIndividualUploading = (file: File, index: number) => {
|
|
const fileId = `${file.name}-${index}`
|
|
return individualUploading.has(fileId)
|
|
}
|
|
|
|
const fileList = files.map((file: File, index: number) => {
|
|
const isFileUploading = isIndividualUploading(file, index)
|
|
const progress = uploadProgress[`${file.name}-${index}`] || 0
|
|
|
|
return (
|
|
<ListItem key={`${file.name}-${index}`} className='pis-4 plb-3'>
|
|
<div className='file-details flex-1'>
|
|
<div className='file-preview'>{renderFilePreview(file)}</div>
|
|
<div className='flex-1'>
|
|
<Typography className='file-name font-medium' color='text.primary'>
|
|
{file.name}
|
|
</Typography>
|
|
<Typography className='file-size' variant='body2'>
|
|
{formatFileSize(file.size)}
|
|
</Typography>
|
|
{isFileUploading && progress > 0 && (
|
|
<LinearProgress variant='determinate' value={progress} className='mt-1' />
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className='flex items-center gap-2'>
|
|
{uploadMode === 'individual' && onSingleUpload && (
|
|
<Button
|
|
variant='outlined'
|
|
size='small'
|
|
onClick={() => handleIndividualUpload(file, index)}
|
|
disabled={isUploading || isFileUploading}
|
|
>
|
|
{isFileUploading ? 'Uploading...' : 'Upload'}
|
|
</Button>
|
|
)}
|
|
<IconButton onClick={() => handleRemoveFile(index)} disabled={isUploading || isFileUploading}>
|
|
<i className='tabler-x text-xl' />
|
|
</IconButton>
|
|
</div>
|
|
</ListItem>
|
|
)
|
|
})
|
|
|
|
const currentImagesList = currentImages.map(image => (
|
|
<ListItem key={image.id} className='pis-4 plb-3'>
|
|
<div className='file-details flex-1'>
|
|
<div className='file-preview'>
|
|
<img width={38} height={38} alt={image.name} src={image.url} className='rounded object-cover' />
|
|
</div>
|
|
<div className='flex-1'>
|
|
<Typography className='file-name font-medium' color='text.primary'>
|
|
{image.name}
|
|
</Typography>
|
|
<Typography className='file-size' variant='body2'>
|
|
{formatFileSize(image.size)}
|
|
</Typography>
|
|
</div>
|
|
</div>
|
|
<div className='flex items-center gap-2'>
|
|
<Chip label='Uploaded' color='success' size='small' />
|
|
{onImageRemove && (
|
|
<IconButton onClick={() => handleRemoveCurrentImage(image.id)} color='error' disabled={isUploading}>
|
|
<i className='tabler-x text-xl' />
|
|
</IconButton>
|
|
)}
|
|
</div>
|
|
</ListItem>
|
|
))
|
|
|
|
return (
|
|
<Dropzone className={className}>
|
|
{/* Conditional title and URL option header */}
|
|
{title && (
|
|
<div className='flex justify-between items-center mb-4'>
|
|
<Typography variant='h6' component='h2'>
|
|
{title}
|
|
</Typography>
|
|
{showUrlOption && (
|
|
<Typography component={Link} color='primary.main' className='font-medium'>
|
|
Add media from URL
|
|
</Typography>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* File limits info */}
|
|
<div className='flex justify-between items-center mb-4'>
|
|
<Typography variant='body2' color='text.secondary'>
|
|
{maxFilesText.replace('{max}', maxFiles.toString())}
|
|
</Typography>
|
|
<Typography variant='body2' color='text.secondary'>
|
|
{currentImages.length + files.length} / {maxFiles} files
|
|
</Typography>
|
|
</div>
|
|
|
|
<div {...getRootProps({ className: 'dropzone' })}>
|
|
<input {...getInputProps()} />
|
|
<div className='flex items-center flex-col gap-2 text-center'>
|
|
<CustomAvatar variant='rounded' skin='light' color='secondary'>
|
|
<i className='tabler-upload' />
|
|
</CustomAvatar>
|
|
<Typography variant='h4'>
|
|
{currentImages.length > 0 || files.length > 0 ? replaceText : dragDropText}
|
|
</Typography>
|
|
<Typography color='text.disabled'>or</Typography>
|
|
<Button variant='tonal' size='small' disabled={disabled || isUploading}>
|
|
{browseButtonText}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Error Message */}
|
|
{error && (
|
|
<Typography color='error' variant='body2' className='mt-2 text-center'>
|
|
{error}
|
|
</Typography>
|
|
)}
|
|
|
|
{/* Current uploaded images */}
|
|
{currentImages.length > 0 && (
|
|
<div className='mt-4'>
|
|
<Typography variant='subtitle2' className='mb-2'>
|
|
Uploaded Images ({currentImages.length}):
|
|
</Typography>
|
|
<List>{currentImagesList}</List>
|
|
</div>
|
|
)}
|
|
|
|
{/* Pending files list and upload buttons */}
|
|
{files.length > 0 && (
|
|
<div className='mt-4'>
|
|
<Typography variant='subtitle2' className='mb-2'>
|
|
Pending Files ({files.length}):
|
|
</Typography>
|
|
<List>{fileList}</List>
|
|
<div className='buttons flex gap-2 mt-3'>
|
|
<Button color='error' variant='tonal' onClick={handleRemoveAllFiles} disabled={isUploading}>
|
|
Remove All
|
|
</Button>
|
|
{uploadMode === 'batch' && (
|
|
<Button variant='contained' onClick={handleBatchUpload} disabled={isUploading || files.length === 0}>
|
|
{isUploading ? 'Uploading...' : `${uploadButtonText} (${files.length})`}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Dropzone>
|
|
)
|
|
}
|
|
|
|
export default MultipleImageUpload
|
|
|
|
// ===== USAGE EXAMPLES =====
|
|
|
|
// 1. Batch upload mode (upload all files at once)
|
|
// const [images, setImages] = useState<UploadedImage[]>([])
|
|
//
|
|
// <MultipleImageUpload
|
|
// title="Product Images"
|
|
// onUpload={handleBatchUpload}
|
|
// currentImages={images}
|
|
// onImagesChange={setImages}
|
|
// onImageRemove={(id) => setImages(prev => prev.filter(img => img.id !== id))}
|
|
// maxFiles={5}
|
|
// uploadMode="batch"
|
|
// />
|
|
|
|
// 2. Individual upload mode (upload files one by one)
|
|
// <MultipleImageUpload
|
|
// title="Gallery Images"
|
|
// onUpload={handleBatchUpload}
|
|
// onSingleUpload={handleSingleUpload}
|
|
// currentImages={images}
|
|
// onImagesChange={setImages}
|
|
// onImageRemove={(id) => setImages(prev => prev.filter(img => img.id !== id))}
|
|
// maxFiles={10}
|
|
// uploadMode="individual"
|
|
// uploadProgress={uploadProgress}
|
|
// />
|
|
|
|
// 3. Without title, custom limits
|
|
// <MultipleImageUpload
|
|
// title={null}
|
|
// onUpload={handleBatchUpload}
|
|
// currentImages={images}
|
|
// onImagesChange={setImages}
|
|
// maxFiles={3}
|
|
// maxFileSize={2 * 1024 * 1024} // 2MB
|
|
// acceptedFileTypes={['image/jpeg', 'image/png']}
|
|
// />
|
|
|
|
// 4. Example upload handlers
|
|
// const handleBatchUpload = async (files: File[]): Promise<string[]> => {
|
|
// const formData = new FormData()
|
|
// files.forEach(file => formData.append('images', file))
|
|
//
|
|
// const response = await fetch('/api/upload-multiple', {
|
|
// method: 'POST',
|
|
// body: formData
|
|
// })
|
|
//
|
|
// const result = await response.json()
|
|
// return result.urls // Array of uploaded image URLs
|
|
// }
|
|
//
|
|
// const handleSingleUpload = async (file: File): Promise<string> => {
|
|
// const formData = new FormData()
|
|
// formData.append('image', file)
|
|
//
|
|
// const response = await fetch('/api/upload-single', {
|
|
// method: 'POST',
|
|
// body: formData
|
|
// })
|
|
//
|
|
// const result = await response.json()
|
|
// return result.url // Single uploaded image URL
|
|
// }
|