/** * Batch Uploader Component * * Multi-file upload with drag & drop, progress tracking, and SSE integration. * Supports batch OCR processing for multiple images. * * Phase 2.1: Batch Processing UI */ 'use client' import { useState, useRef, useCallback, useEffect } from 'react' interface UploadedFile { id: string file: File preview: string status: 'pending' | 'processing' | 'completed' | 'error' result?: { text: string confidence: number processing_time_ms: number } error?: string } interface BatchUploaderProps { /** API base URL */ apiBase: string /** Maximum files allowed */ maxFiles?: number /** Whether to auto-start OCR after upload */ autoProcess?: boolean /** Callback when all files are processed */ onComplete?: (results: UploadedFile[]) => void /** Callback when a single file is processed */ onFileProcessed?: (file: UploadedFile) => void /** Custom class names */ className?: string } /** * Generate unique ID */ function generateId(): string { return Math.random().toString(36).substring(2, 11) } /** * Batch Uploader Component */ export function BatchUploader({ apiBase, maxFiles = 20, autoProcess = false, onComplete, onFileProcessed, className = '' }: BatchUploaderProps) { const [files, setFiles] = useState([]) const [isProcessing, setIsProcessing] = useState(false) const [isDragActive, setIsDragActive] = useState(false) const [progress, setProgress] = useState({ current: 0, total: 0 }) const fileInputRef = useRef(null) const eventSourceRef = useRef(null) // Add files to the list const addFiles = useCallback((newFiles: FileList | File[]) => { const fileArray = Array.from(newFiles) const imageFiles = fileArray.filter(f => f.type.startsWith('image/')) if (imageFiles.length === 0) return const uploadedFiles: UploadedFile[] = imageFiles .slice(0, maxFiles - files.length) .map(file => ({ id: generateId(), file, preview: URL.createObjectURL(file), status: 'pending' as const })) setFiles(prev => [...prev, ...uploadedFiles].slice(0, maxFiles)) // Auto-process if enabled if (autoProcess && uploadedFiles.length > 0) { setTimeout(() => startProcessing(), 100) } }, [files.length, maxFiles, autoProcess]) // Remove a file const removeFile = useCallback((id: string) => { setFiles(prev => { const file = prev.find(f => f.id === id) if (file) { URL.revokeObjectURL(file.preview) } return prev.filter(f => f.id !== id) }) }, []) // Clear all files const clearAll = useCallback(() => { files.forEach(f => URL.revokeObjectURL(f.preview)) setFiles([]) setProgress({ current: 0, total: 0 }) }, [files]) // Start batch processing with SSE const startProcessing = useCallback(async () => { if (isProcessing || files.length === 0) return const pendingFiles = files.filter(f => f.status === 'pending') if (pendingFiles.length === 0) return setIsProcessing(true) setProgress({ current: 0, total: pendingFiles.length }) // Close any existing connection if (eventSourceRef.current) { eventSourceRef.current.close() } // Use SSE for progress updates const eventSource = new EventSource( `${apiBase}/api/v1/admin/training/ocr/stream?images_count=${pendingFiles.length}` ) eventSourceRef.current = eventSource let processedIndex = 0 eventSource.onmessage = (event) => { try { const data = JSON.parse(event.data) if (data.type === 'progress') { setProgress({ current: data.current, total: data.total }) // Update file status if (processedIndex < pendingFiles.length) { const currentFile = pendingFiles[processedIndex] setFiles(prev => prev.map(f => f.id === currentFile.id ? { ...f, status: 'completed' as const, result: data.result } : f )) onFileProcessed?.({ ...currentFile, status: 'completed', result: data.result }) processedIndex++ } } else if (data.type === 'error') { if (processedIndex < pendingFiles.length) { const currentFile = pendingFiles[processedIndex] setFiles(prev => prev.map(f => f.id === currentFile.id ? { ...f, status: 'error' as const, error: data.error } : f )) processedIndex++ } } else if (data.type === 'complete') { eventSource.close() setIsProcessing(false) onComplete?.(files) } } catch (e) { console.error('SSE parse error:', e) } } eventSource.onerror = () => { eventSource.close() setIsProcessing(false) // Mark remaining as error setFiles(prev => prev.map(f => f.status === 'pending' || f.status === 'processing' ? { ...f, status: 'error' as const, error: 'Verbindung unterbrochen' } : f )) } // Mark files as processing setFiles(prev => prev.map(f => f.status === 'pending' ? { ...f, status: 'processing' as const } : f )) }, [apiBase, files, isProcessing, onComplete, onFileProcessed]) // Stop processing const stopProcessing = useCallback(() => { if (eventSourceRef.current) { eventSourceRef.current.close() eventSourceRef.current = null } setIsProcessing(false) setFiles(prev => prev.map(f => f.status === 'processing' ? { ...f, status: 'pending' as const } : f )) }, []) // Drag handlers const handleDragEnter = (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() setIsDragActive(true) } const handleDragLeave = (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() setIsDragActive(false) } const handleDrop = (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() setIsDragActive(false) addFiles(e.dataTransfer.files) } // Cleanup on unmount useEffect(() => { return () => { files.forEach(f => URL.revokeObjectURL(f.preview)) if (eventSourceRef.current) { eventSourceRef.current.close() } } }, []) const completedCount = files.filter(f => f.status === 'completed').length const errorCount = files.filter(f => f.status === 'error').length const avgConfidence = files .filter(f => f.result) .reduce((sum, f) => sum + (f.result?.confidence || 0), 0) / Math.max(1, files.filter(f => f.result).length) return (
{/* Upload area */}
e.preventDefault()} onDragLeave={handleDragLeave} onDrop={handleDrop} onClick={() => fileInputRef.current?.click()} >
📁
{isDragActive ? 'Loslassen zum Hochladen' : 'Bilder hierher ziehen oder klicken'}
Bis zu {maxFiles} Bilder (PNG, JPG)
e.target.files && addFiles(e.target.files)} />
{/* File list */} {files.length > 0 && (
{/* Header with stats */}
{files.length} Bilder ausgewaehlt {completedCount > 0 && ( ({completedCount} verarbeitet, {(avgConfidence * 100).toFixed(0)}% Konfidenz) )} {errorCount > 0 && ( ({errorCount} Fehler) )}
{isProcessing ? ( ) : ( )}
{/* Progress bar */} {isProcessing && (
Verarbeite Bild {progress.current} von {progress.total} {((progress.current / progress.total) * 100).toFixed(0)}%
)} {/* File thumbnails */}
{files.map((file) => (
{/* Thumbnail */} {file.file.name} {/* Overlay for status */} {file.status === 'processing' && (
)} {file.status === 'completed' && file.result && (
{(file.result.confidence * 100).toFixed(0)}%
{file.result.text.substring(0, 30)}...
)} {file.status === 'error' && (
Fehler
)} {/* Status badge */}
{/* Remove button */} {!isProcessing && ( )} {/* File name */}
{file.file.name}
))}
)} {/* Results summary */} {completedCount > 0 && !isProcessing && (

Verarbeitung abgeschlossen

{completedCount}
Erfolgreich
{(avgConfidence * 100).toFixed(0)}%
Durchschnittl. Konfidenz
{errorCount}
Fehler
)}
) } export default BatchUploader