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>
257 lines
14 KiB
TypeScript
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>
|
|
)
|
|
}
|