feat: BreakPilot PWA - Full codebase (clean push without large binaries)
Some checks failed
Tests / Go Tests (push) Has been cancelled
Tests / Python Tests (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / Go Lint (push) Has been cancelled
Tests / Python Lint (push) Has been cancelled
Tests / Security Scan (push) Has been cancelled
Tests / All Checks Passed (push) Has been cancelled
Security Scanning / Secret Scanning (push) Has been cancelled
Security Scanning / Dependency Vulnerability Scan (push) Has been cancelled
Security Scanning / Go Security Scan (push) Has been cancelled
Security Scanning / Python Security Scan (push) Has been cancelled
Security Scanning / Node.js Security Scan (push) Has been cancelled
Security Scanning / Docker Image Security (push) Has been cancelled
Security Scanning / Security Summary (push) Has been cancelled
CI/CD Pipeline / Go Tests (push) Has been cancelled
CI/CD Pipeline / Python Tests (push) Has been cancelled
CI/CD Pipeline / Website Tests (push) Has been cancelled
CI/CD Pipeline / Linting (push) Has been cancelled
CI/CD Pipeline / Security Scan (push) Has been cancelled
CI/CD Pipeline / Docker Build & Push (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / CI Summary (push) Has been cancelled
ci/woodpecker/manual/build-ci-image Pipeline was successful
ci/woodpecker/manual/main Pipeline failed
Some checks failed
Tests / Go Tests (push) Has been cancelled
Tests / Python Tests (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / Go Lint (push) Has been cancelled
Tests / Python Lint (push) Has been cancelled
Tests / Security Scan (push) Has been cancelled
Tests / All Checks Passed (push) Has been cancelled
Security Scanning / Secret Scanning (push) Has been cancelled
Security Scanning / Dependency Vulnerability Scan (push) Has been cancelled
Security Scanning / Go Security Scan (push) Has been cancelled
Security Scanning / Python Security Scan (push) Has been cancelled
Security Scanning / Node.js Security Scan (push) Has been cancelled
Security Scanning / Docker Image Security (push) Has been cancelled
Security Scanning / Security Summary (push) Has been cancelled
CI/CD Pipeline / Go Tests (push) Has been cancelled
CI/CD Pipeline / Python Tests (push) Has been cancelled
CI/CD Pipeline / Website Tests (push) Has been cancelled
CI/CD Pipeline / Linting (push) Has been cancelled
CI/CD Pipeline / Security Scan (push) Has been cancelled
CI/CD Pipeline / Docker Build & Push (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / CI Summary (push) Has been cancelled
ci/woodpecker/manual/build-ci-image Pipeline was successful
ci/woodpecker/manual/main Pipeline failed
All services: admin-v2, studio-v2, website, ai-compliance-sdk, consent-service, klausur-service, voice-service, and infrastructure. Large PDFs and compiled binaries excluded via .gitignore.
This commit is contained in:
454
admin-v2/components/ai/BatchUploader.tsx
Normal file
454
admin-v2/components/ai/BatchUploader.tsx
Normal file
@@ -0,0 +1,454 @@
|
||||
/**
|
||||
* 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
|
||||
Reference in New Issue
Block a user