mnemo_cards/mnemo_cards_admin/src/components/BulkCardUpload.tsx
Dmitry 7d1300a7a4
Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Deploy Admin Panel / Deploy Admin Panel (push) Waiting to run
Deploy Admin Panel / Admin Panel Verification (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions
image
2025-12-17 02:08:37 +03:00

286 lines
9.3 KiB
TypeScript

import { useState, useRef, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Button } from './ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card'
import { Label } from './ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'
import { Upload, X, Check } from 'lucide-react'
import { packsApi } from '@/api/packs'
interface UploadedImage {
id: string
file: File
preview: string
base64: string
}
interface BulkCardUploadProps {
onImagesUploaded: (images: UploadedImage[], packId?: string) => 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 [selectedPackId, setSelectedPackId] = useState<string>('')
// Fetch packs for pack selection
const { data: packsData } = useQuery({
queryKey: ['packs', 1, 100, ''],
queryFn: () => packsApi.getPacks({ page: 1, limit: 100, search: '' }),
})
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, selectedPackId || undefined)
}
}
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="space-y-4 mb-4">
<div className="space-y-2">
<Label htmlFor="packId">Pack (optional)</Label>
<Select
value={selectedPackId || undefined}
onValueChange={(value: string) => {
const packId = value === '__none__' ? '' : value
setSelectedPackId(packId)
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a pack (optional)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">None</SelectItem>
{packsData?.items && packsData.items.length > 0 ? (
packsData.items.map((pack) => (
<SelectItem key={pack.id} value={pack.id}>
{pack.title || pack.id}
</SelectItem>
))
) : null}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Selected pack will be applied to all uploaded cards
</p>
</div>
</div>
<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)
})
}