This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/admin-v2/components/ai/BatchUploader.tsx
Benjamin Admin 21a844cb8a fix: Restore all files lost during destructive rebase
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>
2026-02-09 09:51:32 +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