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

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:
BreakPilot Dev
2026-02-11 13:25:58 +01:00
commit 19855efacc
2512 changed files with 933814 additions and 0 deletions

View 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