Files
breakpilot-lehrer/admin-lehrer/components/ai/BatchUploader.tsx
Benjamin Boenisch 5a31f52310 Initial commit: breakpilot-lehrer - Lehrer KI Platform
Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website,
Klausur-Service, School-Service, Voice-Service, Geo-Service,
BreakPilot Drive, Agent-Core

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:47:26 +01:00

455 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<UploadedFile[]>([])
const [isProcessing, setIsProcessing] = useState(false)
const [isDragActive, setIsDragActive] = useState(false)
const [progress, setProgress] = useState({ current: 0, total: 0 })
const fileInputRef = useRef<HTMLInputElement>(null)
const eventSourceRef = useRef<EventSource | null>(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 (
<div className={`space-y-4 ${className}`}>
{/* Upload area */}
<div
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all ${
isDragActive
? 'border-purple-500 bg-purple-50'
: 'border-slate-300 hover:border-purple-400 hover:bg-purple-50/50'
}`}
onDragEnter={handleDragEnter}
onDragOver={(e) => e.preventDefault()}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<div className="text-4xl mb-3">📁</div>
<div className="text-slate-700 font-medium">
{isDragActive
? 'Loslassen zum Hochladen'
: 'Bilder hierher ziehen oder klicken'}
</div>
<div className="text-sm text-slate-400 mt-1">
Bis zu {maxFiles} Bilder (PNG, JPG)
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => e.target.files && addFiles(e.target.files)}
/>
</div>
{/* File list */}
{files.length > 0 && (
<div className="space-y-3">
{/* Header with stats */}
<div className="flex items-center justify-between">
<div className="text-sm text-slate-600">
{files.length} Bilder ausgewaehlt
{completedCount > 0 && (
<span className="text-green-600 ml-2">
({completedCount} verarbeitet, {(avgConfidence * 100).toFixed(0)}% Konfidenz)
</span>
)}
{errorCount > 0 && (
<span className="text-red-600 ml-2">({errorCount} Fehler)</span>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={clearAll}
className="px-3 py-1 text-sm text-slate-600 hover:text-slate-900 transition-colors"
>
Alle entfernen
</button>
{isProcessing ? (
<button
onClick={stopProcessing}
className="px-4 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm font-medium transition-colors"
>
Stoppen
</button>
) : (
<button
onClick={startProcessing}
disabled={files.filter(f => f.status === 'pending').length === 0}
className="px-4 py-1.5 bg-purple-600 hover:bg-purple-700 disabled:bg-slate-300 text-white rounded-lg text-sm font-medium transition-colors"
>
OCR starten
</button>
)}
</div>
</div>
{/* Progress bar */}
{isProcessing && (
<div className="bg-slate-100 rounded-lg p-3">
<div className="flex items-center justify-between text-sm mb-2">
<span className="text-slate-600">
Verarbeite Bild {progress.current} von {progress.total}
</span>
<span className="text-purple-600 font-medium">
{((progress.current / progress.total) * 100).toFixed(0)}%
</span>
</div>
<div className="h-2 bg-slate-200 rounded-full overflow-hidden">
<div
className="h-full bg-purple-600 transition-all duration-300"
style={{ width: `${(progress.current / progress.total) * 100}%` }}
/>
</div>
</div>
)}
{/* File thumbnails */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
{files.map((file) => (
<div
key={file.id}
className={`relative group rounded-lg overflow-hidden border-2 ${
file.status === 'completed'
? 'border-green-300'
: file.status === 'error'
? 'border-red-300'
: file.status === 'processing'
? 'border-purple-300'
: 'border-slate-200'
}`}
>
{/* Thumbnail */}
<img
src={file.preview}
alt={file.file.name}
className="w-full h-24 object-cover"
/>
{/* Overlay for status */}
{file.status === 'processing' && (
<div className="absolute inset-0 bg-purple-900/50 flex items-center justify-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-white"></div>
</div>
)}
{file.status === 'completed' && file.result && (
<div className="absolute inset-0 bg-green-900/70 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<div className="text-center text-white text-xs p-2">
<div className="font-medium">
{(file.result.confidence * 100).toFixed(0)}%
</div>
<div className="text-green-200 truncate max-w-full">
{file.result.text.substring(0, 30)}...
</div>
</div>
</div>
)}
{file.status === 'error' && (
<div className="absolute inset-0 bg-red-900/70 flex items-center justify-center">
<div className="text-white text-xs text-center px-2">
Fehler
</div>
</div>
)}
{/* Status badge */}
<div className={`absolute top-1 right-1 w-3 h-3 rounded-full ${
file.status === 'completed'
? 'bg-green-500'
: file.status === 'error'
? 'bg-red-500'
: file.status === 'processing'
? 'bg-purple-500 animate-pulse'
: 'bg-slate-400'
}`} />
{/* Remove button */}
{!isProcessing && (
<button
onClick={(e) => {
e.stopPropagation()
removeFile(file.id)
}}
className="absolute top-1 left-1 w-5 h-5 bg-red-500 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center text-xs"
>
×
</button>
)}
{/* File name */}
<div className="absolute bottom-0 left-0 right-0 bg-black/60 text-white text-xs px-2 py-1 truncate">
{file.file.name}
</div>
</div>
))}
</div>
</div>
)}
{/* Results summary */}
{completedCount > 0 && !isProcessing && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<h4 className="font-medium text-green-800 mb-2">
Verarbeitung abgeschlossen
</h4>
<div className="grid grid-cols-3 gap-4 text-center text-sm">
<div>
<div className="text-2xl font-bold text-green-700">{completedCount}</div>
<div className="text-green-600">Erfolgreich</div>
</div>
<div>
<div className="text-2xl font-bold text-slate-700">
{(avgConfidence * 100).toFixed(0)}%
</div>
<div className="text-slate-600">Durchschnittl. Konfidenz</div>
</div>
<div>
<div className="text-2xl font-bold text-red-700">{errorCount}</div>
<div className="text-red-600">Fehler</div>
</div>
</div>
</div>
)}
</div>
)
}
export default BatchUploader