249 lines
7.7 KiB
TypeScript
249 lines
7.7 KiB
TypeScript
|
|
import { useState, useRef } from 'react'
|
||
|
|
import { Button } from './ui/button'
|
||
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card'
|
||
|
|
import { Label } from './ui/label'
|
||
|
|
import { Upload, X, Image as ImageIcon, Check } from 'lucide-react'
|
||
|
|
import { Badge } from './ui/badge'
|
||
|
|
|
||
|
|
interface UploadedImage {
|
||
|
|
id: string
|
||
|
|
file: File
|
||
|
|
preview: string
|
||
|
|
base64: string
|
||
|
|
}
|
||
|
|
|
||
|
|
interface BulkCardUploadProps {
|
||
|
|
onImagesUploaded: (images: UploadedImage[]) => void
|
||
|
|
onClose: () => void
|
||
|
|
}
|
||
|
|
|
||
|
|
export function BulkCardUpload({ onImagesUploaded, onClose }: BulkCardUploadProps) {
|
||
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||
|
|
const [uploadedImages, setUploadedImages] = useState<UploadedImage[]>([])
|
||
|
|
const [isDragging, setIsDragging] = useState(false)
|
||
|
|
|
||
|
|
const handleFileSelect = async (files: FileList) => {
|
||
|
|
const newImages: UploadedImage[] = []
|
||
|
|
|
||
|
|
for (let i = 0; i < files.length; i++) {
|
||
|
|
const file = files[i]
|
||
|
|
|
||
|
|
// Validate file type
|
||
|
|
if (!file.type.startsWith('image/')) {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
// Validate file size (max 5MB)
|
||
|
|
const fileSizeMB = file.size / (1024 * 1024)
|
||
|
|
if (fileSizeMB > 5) {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const base64 = await fileToBase64(file)
|
||
|
|
const preview = URL.createObjectURL(file)
|
||
|
|
|
||
|
|
newImages.push({
|
||
|
|
id: `${Date.now()}-${i}`,
|
||
|
|
file,
|
||
|
|
preview,
|
||
|
|
base64,
|
||
|
|
})
|
||
|
|
} catch (error) {
|
||
|
|
console.error('Error processing file:', error)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
setUploadedImages((prev) => [...prev, ...newImages])
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
|
|
const files = e.target.files
|
||
|
|
if (files && files.length > 0) {
|
||
|
|
handleFileSelect(files)
|
||
|
|
}
|
||
|
|
// Reset input value to allow selecting the same files again
|
||
|
|
if (fileInputRef.current) {
|
||
|
|
fileInputRef.current.value = ''
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleDragOver = (e: React.DragEvent) => {
|
||
|
|
e.preventDefault()
|
||
|
|
e.stopPropagation()
|
||
|
|
setIsDragging(true)
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleDragLeave = (e: React.DragEvent) => {
|
||
|
|
e.preventDefault()
|
||
|
|
e.stopPropagation()
|
||
|
|
setIsDragging(false)
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleDrop = (e: React.DragEvent) => {
|
||
|
|
e.preventDefault()
|
||
|
|
e.stopPropagation()
|
||
|
|
setIsDragging(false)
|
||
|
|
|
||
|
|
const files = e.dataTransfer.files
|
||
|
|
if (files && files.length > 0) {
|
||
|
|
handleFileSelect(files)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleRemove = (id: string) => {
|
||
|
|
setUploadedImages((prev) => {
|
||
|
|
const image = prev.find((img) => img.id === id)
|
||
|
|
if (image) {
|
||
|
|
URL.revokeObjectURL(image.preview)
|
||
|
|
}
|
||
|
|
return prev.filter((img) => img.id !== id)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleClearAll = () => {
|
||
|
|
uploadedImages.forEach((img) => URL.revokeObjectURL(img.preview))
|
||
|
|
setUploadedImages([])
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleContinue = () => {
|
||
|
|
if (uploadedImages.length > 0) {
|
||
|
|
onImagesUploaded(uploadedImages)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-4">
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<div>
|
||
|
|
<h2 className="text-2xl font-bold">Bulk Card Upload</h2>
|
||
|
|
<p className="text-sm text-muted-foreground">
|
||
|
|
Upload multiple images, then fill in card details one by one
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
<Button variant="outline" onClick={onClose}>
|
||
|
|
<X className="h-4 w-4 mr-2" />
|
||
|
|
Cancel
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle>Upload Images</CardTitle>
|
||
|
|
<CardDescription>
|
||
|
|
Select multiple image files or drag and drop them here
|
||
|
|
</CardDescription>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
<div
|
||
|
|
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${
|
||
|
|
isDragging
|
||
|
|
? 'border-primary bg-primary/5'
|
||
|
|
: 'border-muted-foreground/25 hover:border-muted-foreground/50'
|
||
|
|
}`}
|
||
|
|
onDragOver={handleDragOver}
|
||
|
|
onDragLeave={handleDragLeave}
|
||
|
|
onDrop={handleDrop}
|
||
|
|
onClick={() => fileInputRef.current?.click()}
|
||
|
|
>
|
||
|
|
<input
|
||
|
|
ref={fileInputRef}
|
||
|
|
type="file"
|
||
|
|
accept="image/*"
|
||
|
|
multiple
|
||
|
|
onChange={handleInputChange}
|
||
|
|
className="hidden"
|
||
|
|
/>
|
||
|
|
<div className="flex flex-col items-center justify-center space-y-4">
|
||
|
|
<Upload className="h-12 w-12 text-muted-foreground" />
|
||
|
|
<div className="text-lg">
|
||
|
|
<span className="text-primary font-medium">Click to upload</span> or drag and drop
|
||
|
|
</div>
|
||
|
|
<p className="text-sm text-muted-foreground">
|
||
|
|
PNG, JPG, GIF up to 5MB each. Multiple files supported.
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
{uploadedImages.length > 0 && (
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<div>
|
||
|
|
<CardTitle>Uploaded Images ({uploadedImages.length})</CardTitle>
|
||
|
|
<CardDescription>
|
||
|
|
Review uploaded images and continue to fill in card details
|
||
|
|
</CardDescription>
|
||
|
|
</div>
|
||
|
|
<Button variant="outline" size="sm" onClick={handleClearAll}>
|
||
|
|
<X className="h-4 w-4 mr-2" />
|
||
|
|
Clear All
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 max-h-[500px] overflow-y-auto">
|
||
|
|
{uploadedImages.map((image) => (
|
||
|
|
<div key={image.id} className="relative group">
|
||
|
|
<div className="relative aspect-square border rounded-lg overflow-hidden bg-muted">
|
||
|
|
<img
|
||
|
|
src={image.preview}
|
||
|
|
alt={image.file.name}
|
||
|
|
className="w-full h-full object-cover"
|
||
|
|
/>
|
||
|
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/50 transition-colors flex items-center justify-center">
|
||
|
|
<Button
|
||
|
|
variant="destructive"
|
||
|
|
size="sm"
|
||
|
|
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||
|
|
onClick={(e) => {
|
||
|
|
e.stopPropagation()
|
||
|
|
handleRemove(image.id)
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<X className="h-4 w-4" />
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<p className="text-xs text-muted-foreground mt-1 truncate" title={image.file.name}>
|
||
|
|
{image.file.name}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{uploadedImages.length > 0 && (
|
||
|
|
<div className="flex justify-end space-x-2">
|
||
|
|
<Button variant="outline" onClick={onClose}>
|
||
|
|
Cancel
|
||
|
|
</Button>
|
||
|
|
<Button onClick={handleContinue}>
|
||
|
|
<Check className="h-4 w-4 mr-2" />
|
||
|
|
Continue to Fill Details ({uploadedImages.length} cards)
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Helper function to convert file to base64
|
||
|
|
function fileToBase64(file: File): Promise<string> {
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
const reader = new FileReader()
|
||
|
|
reader.onload = () => {
|
||
|
|
const result = reader.result as string
|
||
|
|
// Remove data:image/...;base64, prefix
|
||
|
|
const base64 = result.split(',')[1]
|
||
|
|
resolve(base64)
|
||
|
|
}
|
||
|
|
reader.onerror = reject
|
||
|
|
reader.readAsDataURL(file)
|
||
|
|
})
|
||
|
|
}
|