A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
455 lines
15 KiB
TypeScript
455 lines
15 KiB
TypeScript
/**
|
||
* 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
|