Files
breakpilot-lehrer/studio-v2/app/worksheet-cleanup/page.tsx
Benjamin Admin b6983ab1dc [split-required] Split 500-1000 LOC files across all services
backend-lehrer (5 files):
- alerts_agent/db/repository.py (992 → 5), abitur_docs_api.py (956 → 3)
- teacher_dashboard_api.py (951 → 3), services/pdf_service.py (916 → 3)
- mail/mail_db.py (987 → 6)

klausur-service (5 files):
- legal_templates_ingestion.py (942 → 3), ocr_pipeline_postprocess.py (929 → 4)
- ocr_pipeline_words.py (876 → 3), ocr_pipeline_ocr_merge.py (616 → 2)
- KorrekturPage.tsx (956 → 6)

website (5 pages):
- mail (985 → 9), edu-search (958 → 8), mac-mini (950 → 7)
- ocr-labeling (946 → 7), audit-workspace (871 → 4)

studio-v2 (5 files + 1 deleted):
- page.tsx (946 → 5), MessagesContext.tsx (925 → 4)
- korrektur (914 → 6), worksheet-cleanup (899 → 6)
- useVocabWorksheet.ts (888 → 3)
- Deleted dead page-original.tsx (934 LOC)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 23:35:37 +02:00

257 lines
14 KiB
TypeScript

'use client'
import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { useTheme } from '@/lib/ThemeContext'
import { Sidebar } from '@/components/Sidebar'
import { ThemeToggle } from '@/components/ThemeToggle'
import { LanguageDropdown } from '@/components/LanguageDropdown'
import { QRCodeUpload, UploadedFile } from '@/components/QRCodeUpload'
import { GlassCard } from './_components/GlassCard'
import { UploadStep } from './_components/UploadStep'
import { PreviewStep } from './_components/PreviewStep'
import { ResultStep } from './_components/ResultStep'
const SESSION_ID_KEY = 'bp_cleanup_session'
interface PreviewResult {
has_handwriting: boolean; confidence: number; handwriting_ratio: number
image_width: number; image_height: number
estimated_times_ms: { detection: number; inpainting: number; reconstruction: number; total: number }
}
interface PipelineResult {
success: boolean; handwriting_detected: boolean; handwriting_removed: boolean
layout_reconstructed: boolean; cleaned_image_base64?: string; fabric_json?: any; metadata: any
}
export default function WorksheetCleanupPage() {
const { isDark } = useTheme()
const router = useRouter()
const [file, setFile] = useState<File | null>(null)
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const [cleanedUrl, setCleanedUrl] = useState<string | null>(null)
const [maskUrl, setMaskUrl] = useState<string | null>(null)
const [isPreviewing, setIsPreviewing] = useState(false)
const [isProcessing, setIsProcessing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [previewResult, setPreviewResult] = useState<PreviewResult | null>(null)
const [pipelineResult, setPipelineResult] = useState<PipelineResult | null>(null)
const [removeHandwriting, setRemoveHandwriting] = useState(true)
const [reconstructLayout, setReconstructLayout] = useState(true)
const [inpaintingMethod, setInpaintingMethod] = useState<string>('auto')
const [currentStep, setCurrentStep] = useState<'upload' | 'preview' | 'processing' | 'result'>('upload')
const [showQRModal, setShowQRModal] = useState(false)
const [uploadSessionId, setUploadSessionId] = useState('')
const [mobileUploadedFiles, setMobileUploadedFiles] = useState<UploadedFile[]>([])
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
useEffect(() => {
let sid = localStorage.getItem(SESSION_ID_KEY)
if (!sid) { sid = `cleanup-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; localStorage.setItem(SESSION_ID_KEY, sid) }
setUploadSessionId(sid)
}, [])
const getApiUrl = useCallback(() => {
if (typeof window === 'undefined') return 'http://localhost:8086'
const { hostname, protocol } = window.location
return hostname === 'localhost' ? 'http://localhost:8086' : `${protocol}//${hostname}:8086`
}, [])
const handleFileSelect = useCallback((selectedFile: File) => {
setFile(selectedFile); setError(null); setPreviewResult(null); setPipelineResult(null)
setCleanedUrl(null); setMaskUrl(null)
setPreviewUrl(URL.createObjectURL(selectedFile)); setCurrentStep('upload')
}, [])
const handleMobileFileSelect = useCallback(async (uploadedFile: UploadedFile) => {
try {
const base64Data = uploadedFile.dataUrl.split(',')[1]
const byteCharacters = atob(base64Data)
const byteNumbers = new Array(byteCharacters.length)
for (let i = 0; i < byteCharacters.length; i++) byteNumbers[i] = byteCharacters.charCodeAt(i)
const blob = new Blob([new Uint8Array(byteNumbers)], { type: uploadedFile.type })
handleFileSelect(new File([blob], uploadedFile.name, { type: uploadedFile.type }))
setShowQRModal(false)
} catch { setError('Fehler beim Laden der Datei vom Handy') }
}, [handleFileSelect])
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
const f = e.dataTransfer.files[0]
if (f && f.type.startsWith('image/')) handleFileSelect(f)
}, [handleFileSelect])
const handlePreview = useCallback(async () => {
if (!file) return; setIsPreviewing(true); setError(null)
try {
const fd = new FormData(); fd.append('image', file)
const res = await fetch(`${getApiUrl()}/api/v1/worksheet/preview-cleanup`, { method: 'POST', body: fd })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
setPreviewResult(await res.json()); setCurrentStep('preview')
} catch (err) { setError(err instanceof Error ? err.message : 'Vorschau fehlgeschlagen') }
finally { setIsPreviewing(false) }
}, [file, getApiUrl])
const handleCleanup = useCallback(async () => {
if (!file) return; setIsProcessing(true); setCurrentStep('processing'); setError(null)
try {
const fd = new FormData(); fd.append('image', file)
fd.append('remove_handwriting', String(removeHandwriting))
fd.append('reconstruct', String(reconstructLayout)); fd.append('inpainting_method', inpaintingMethod)
const res = await fetch(`${getApiUrl()}/api/v1/worksheet/cleanup-pipeline`, { method: 'POST', body: fd })
if (!res.ok) { const ed = await res.json().catch(() => ({ detail: 'Unknown error' })); throw new Error(ed.detail || `HTTP ${res.status}`) }
const result: PipelineResult = await res.json(); setPipelineResult(result)
if (result.cleaned_image_base64) {
const blob = await fetch(`data:image/png;base64,${result.cleaned_image_base64}`).then(r => r.blob())
setCleanedUrl(URL.createObjectURL(blob))
}
setCurrentStep('result')
} catch (err) { setError(err instanceof Error ? err.message : 'Bereinigung fehlgeschlagen'); setCurrentStep('preview') }
finally { setIsProcessing(false) }
}, [file, removeHandwriting, reconstructLayout, inpaintingMethod, getApiUrl])
const handleGetMask = useCallback(async () => {
if (!file) return
try {
const fd = new FormData(); fd.append('image', file)
const res = await fetch(`${getApiUrl()}/api/v1/worksheet/detect-handwriting/mask`, { method: 'POST', body: fd })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
setMaskUrl(URL.createObjectURL(await res.blob()))
} catch (err) { console.error('Mask fetch failed:', err) }
}, [file, getApiUrl])
const handleOpenInEditor = useCallback(() => {
if (pipelineResult?.fabric_json) {
sessionStorage.setItem('worksheetCleanupResult', JSON.stringify(pipelineResult.fabric_json))
router.push('/worksheet-editor')
}
}, [pipelineResult, router])
const handleReset = useCallback(() => {
setFile(null); setPreviewUrl(null); setCleanedUrl(null); setMaskUrl(null)
setPreviewResult(null); setPipelineResult(null); setError(null); setCurrentStep('upload')
}, [])
const steps = ['upload', 'preview', 'processing', 'result'] as const
const currentStepIdx = steps.indexOf(currentStep)
return (
<div className={`min-h-screen flex relative overflow-hidden ${
isDark ? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
}`}>
<div className={`absolute -top-40 -right-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob ${isDark ? 'bg-purple-500 opacity-30' : 'bg-purple-300 opacity-40'}`} />
<div className={`absolute top-1/2 -left-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-2000 ${isDark ? 'bg-pink-500 opacity-30' : 'bg-pink-300 opacity-40'}`} />
<div className={`absolute -bottom-40 right-1/3 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-4000 ${isDark ? 'bg-blue-500 opacity-30' : 'bg-blue-300 opacity-40'}`} />
<div className="relative z-10 p-4"><Sidebar /></div>
<div className="flex-1 flex flex-col relative z-10 p-6 overflow-y-auto">
<div className="flex items-center justify-between mb-8">
<div>
<h1 className={`text-3xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>Arbeitsblatt bereinigen</h1>
<p className={isDark ? 'text-white/50' : 'text-slate-500'}>Handschrift entfernen und Layout rekonstruieren</p>
</div>
<div className="flex items-center gap-3"><ThemeToggle /><LanguageDropdown /></div>
</div>
{/* Step Indicator */}
<div className="flex items-center justify-center gap-4 mb-8">
{steps.map((step, idx) => (
<div key={step} className="flex items-center">
<div className={`w-10 h-10 rounded-full flex items-center justify-center font-medium transition-all ${
currentStep === step ? 'bg-purple-500 text-white shadow-lg shadow-purple-500/50'
: currentStepIdx > idx ? 'bg-green-500 text-white'
: isDark ? 'bg-white/10 text-white/40' : 'bg-slate-200 text-slate-400'
}`}>
{currentStepIdx > idx ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
) : idx + 1}
</div>
{idx < 3 && <div className={`w-16 h-0.5 mx-2 ${currentStepIdx > idx ? 'bg-green-500' : isDark ? 'bg-white/20' : 'bg-slate-300'}`} />}
</div>
))}
</div>
{error && (
<GlassCard className="mb-6" size="sm" isDark={isDark}>
<div className="flex items-center gap-3 text-red-400">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span>{error}</span>
</div>
</GlassCard>
)}
<div className="flex-1">
{currentStep === 'upload' && (
<UploadStep isDark={isDark} previewUrl={previewUrl} file={file}
removeHandwriting={removeHandwriting} setRemoveHandwriting={setRemoveHandwriting}
reconstructLayout={reconstructLayout} setReconstructLayout={setReconstructLayout}
inpaintingMethod={inpaintingMethod} setInpaintingMethod={setInpaintingMethod}
isPreviewing={isPreviewing} onDrop={handleDrop} onFileSelect={handleFileSelect}
onPreview={handlePreview} onQRClick={() => setShowQRModal(true)} />
)}
{currentStep === 'preview' && previewResult && (
<PreviewStep previewResult={previewResult} previewUrl={previewUrl} maskUrl={maskUrl}
removeHandwriting={removeHandwriting} reconstructLayout={reconstructLayout}
isProcessing={isProcessing} onBack={() => setCurrentStep('upload')}
onCleanup={handleCleanup} onGetMask={handleGetMask} />
)}
{currentStep === 'processing' && (
<div className="flex flex-col items-center justify-center py-20">
<GlassCard className="text-center max-w-md" delay={0}>
<div className="w-20 h-20 mx-auto mb-6 relative">
<div className="absolute inset-0 rounded-full border-4 border-white/10" />
<div className="absolute inset-0 rounded-full border-4 border-purple-500 border-t-transparent animate-spin" />
</div>
<h3 className="text-xl font-semibold text-white mb-2">Verarbeite Bild...</h3>
<p className="text-white/50">{removeHandwriting ? 'Handschrift wird erkannt und entfernt' : 'Bild wird analysiert'}</p>
</GlassCard>
</div>
)}
{currentStep === 'result' && pipelineResult && (
<ResultStep pipelineResult={pipelineResult} previewUrl={previewUrl} cleanedUrl={cleanedUrl}
onReset={handleReset} onOpenInEditor={handleOpenInEditor} />
)}
</div>
</div>
{showQRModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setShowQRModal(false)} />
<div className="relative w-full max-w-md rounded-3xl bg-slate-900">
<QRCodeUpload sessionId={uploadSessionId} onClose={() => setShowQRModal(false)}
onFilesChanged={(files) => setMobileUploadedFiles(files)} />
{mobileUploadedFiles.length > 0 && (
<div className="p-4 border-t border-white/10">
<p className="text-white/60 text-sm mb-3">Datei auswählen:</p>
<div className="space-y-2 max-h-40 overflow-y-auto">
{mobileUploadedFiles.map((f) => (
<button key={f.id} onClick={() => handleMobileFileSelect(f)}
className="w-full flex items-center gap-3 p-3 rounded-xl text-left transition-all bg-white/5 hover:bg-white/10 border border-white/10">
<span className="text-xl">{f.type.startsWith('image/') ? '🖼️' : '📄'}</span>
<div className="flex-1 min-w-0">
<p className="text-white font-medium truncate">{f.name}</p>
<p className="text-white/50 text-xs">{formatFileSize(f.size)}</p>
</div>
<span className="text-purple-400 text-sm">Verwenden </span>
</button>
))}
</div>
</div>
)}
</div>
</div>
)}
</div>
)
}