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>
267 lines
9.7 KiB
TypeScript
267 lines
9.7 KiB
TypeScript
'use client'
|
|
|
|
/**
|
|
* Magic Help - Handschrift-OCR
|
|
*
|
|
* Ermöglicht das Erkennen von Handschrift in Bildern.
|
|
* Backend: POST /api/klausur/trocr/recognize
|
|
*/
|
|
|
|
import { useState, useCallback } from 'react'
|
|
|
|
// Backend URL - dynamisch basierend auf Protokoll
|
|
const getBackendUrl = () => {
|
|
if (typeof window === 'undefined') return 'http://localhost:8000'
|
|
const { hostname, protocol } = window.location
|
|
return hostname === 'localhost' ? 'http://localhost:8000' : `${protocol}//${hostname}:8000`
|
|
}
|
|
|
|
interface OCRResult {
|
|
text: string
|
|
confidence: number
|
|
processing_time_ms: number
|
|
model: string
|
|
}
|
|
|
|
export default function MagicHelpPage() {
|
|
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
|
const [ocrResult, setOcrResult] = useState<OCRResult | null>(null)
|
|
const [loading, setLoading] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
// Datei auswählen
|
|
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0]
|
|
if (file) {
|
|
setSelectedFile(file)
|
|
setPreviewUrl(URL.createObjectURL(file))
|
|
setOcrResult(null)
|
|
setError(null)
|
|
}
|
|
}, [])
|
|
|
|
// Drag & Drop
|
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault()
|
|
const file = e.dataTransfer.files[0]
|
|
if (file && file.type.startsWith('image/')) {
|
|
setSelectedFile(file)
|
|
setPreviewUrl(URL.createObjectURL(file))
|
|
setOcrResult(null)
|
|
setError(null)
|
|
}
|
|
}, [])
|
|
|
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault()
|
|
}, [])
|
|
|
|
// OCR ausführen
|
|
const runOCR = useCallback(async () => {
|
|
if (!selectedFile) return
|
|
|
|
setLoading(true)
|
|
setError(null)
|
|
|
|
try {
|
|
const formData = new FormData()
|
|
formData.append('file', selectedFile)
|
|
|
|
const res = await fetch(`${getBackendUrl()}/api/klausur/trocr/recognize`, {
|
|
method: 'POST',
|
|
body: formData,
|
|
})
|
|
|
|
if (!res.ok) {
|
|
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
|
|
}
|
|
|
|
const data = await res.json()
|
|
setOcrResult(data)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Fehler bei der OCR-Erkennung')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [selectedFile])
|
|
|
|
// Reset
|
|
const handleReset = useCallback(() => {
|
|
setSelectedFile(null)
|
|
setPreviewUrl(null)
|
|
setOcrResult(null)
|
|
setError(null)
|
|
}, [])
|
|
|
|
return (
|
|
<div className="max-w-4xl mx-auto">
|
|
{/* Header */}
|
|
<div className="mb-8">
|
|
<h1 className="text-2xl font-bold text-slate-800">Magic Help</h1>
|
|
<p className="text-slate-500 mt-1">Handschrift-Erkennung mit TrOCR</p>
|
|
</div>
|
|
|
|
{/* Upload Area */}
|
|
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-6 mb-6">
|
|
<h2 className="font-semibold text-slate-700 mb-4">Bild hochladen</h2>
|
|
|
|
{!previewUrl ? (
|
|
<div
|
|
onDrop={handleDrop}
|
|
onDragOver={handleDragOver}
|
|
className="border-2 border-dashed border-slate-300 rounded-lg p-12 text-center hover:border-primary-500 transition-colors cursor-pointer"
|
|
>
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
onChange={handleFileSelect}
|
|
className="hidden"
|
|
id="file-upload"
|
|
/>
|
|
<label htmlFor="file-upload" className="cursor-pointer">
|
|
<svg
|
|
className="w-12 h-12 mx-auto text-slate-400 mb-4"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={1.5}
|
|
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
|
/>
|
|
</svg>
|
|
<p className="text-slate-600 font-medium">Bild hier ablegen oder klicken</p>
|
|
<p className="text-sm text-slate-400 mt-1">PNG, JPG, JPEG bis 10MB</p>
|
|
</label>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{/* Preview */}
|
|
<div className="relative bg-slate-100 rounded-lg overflow-hidden">
|
|
<img
|
|
src={previewUrl}
|
|
alt="Preview"
|
|
className="max-h-96 mx-auto object-contain"
|
|
/>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex gap-3">
|
|
<button
|
|
onClick={runOCR}
|
|
disabled={loading}
|
|
className="flex-1 bg-primary-500 text-white px-6 py-3 rounded-lg font-medium hover:bg-primary-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
|
>
|
|
{loading ? (
|
|
<>
|
|
<svg className="animate-spin w-5 h-5" fill="none" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
|
</svg>
|
|
Wird erkannt...
|
|
</>
|
|
) : (
|
|
<>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
|
|
</svg>
|
|
Text erkennen
|
|
</>
|
|
)}
|
|
</button>
|
|
|
|
<button
|
|
onClick={handleReset}
|
|
className="px-6 py-3 rounded-lg font-medium border border-slate-300 text-slate-600 hover:bg-slate-50 transition-colors"
|
|
>
|
|
Zurücksetzen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Error */}
|
|
{error && (
|
|
<div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-6">
|
|
<div className="flex items-start gap-3">
|
|
<svg className="w-5 h-5 text-red-500 mt-0.5" 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>
|
|
<div>
|
|
<p className="font-medium text-red-800">Fehler</p>
|
|
<p className="text-sm text-red-600 mt-1">{error}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Result */}
|
|
{ocrResult && (
|
|
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-6">
|
|
<h2 className="font-semibold text-slate-700 mb-4">Erkannter Text</h2>
|
|
|
|
{/* Text Output */}
|
|
<div className="bg-slate-50 rounded-lg p-4 mb-4 font-mono text-slate-800 whitespace-pre-wrap">
|
|
{ocrResult.text || '(Kein Text erkannt)'}
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
<div className="grid grid-cols-3 gap-4 text-sm">
|
|
<div className="bg-slate-50 rounded-lg p-3">
|
|
<div className="text-slate-500">Konfidenz</div>
|
|
<div className="font-semibold text-slate-800">
|
|
{(ocrResult.confidence * 100).toFixed(1)}%
|
|
</div>
|
|
</div>
|
|
<div className="bg-slate-50 rounded-lg p-3">
|
|
<div className="text-slate-500">Dauer</div>
|
|
<div className="font-semibold text-slate-800">
|
|
{ocrResult.processing_time_ms}ms
|
|
</div>
|
|
</div>
|
|
<div className="bg-slate-50 rounded-lg p-3">
|
|
<div className="text-slate-500">Modell</div>
|
|
<div className="font-semibold text-slate-800 truncate">
|
|
{ocrResult.model}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Copy Button */}
|
|
<button
|
|
onClick={() => navigator.clipboard.writeText(ocrResult.text)}
|
|
className="mt-4 px-4 py-2 rounded-lg font-medium border border-slate-300 text-slate-600 hover:bg-slate-50 transition-colors text-sm flex items-center gap-2"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
|
</svg>
|
|
Text kopieren
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Info Box */}
|
|
{!previewUrl && !ocrResult && (
|
|
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
|
<div className="flex items-start gap-3">
|
|
<svg className="w-5 h-5 text-blue-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
<div>
|
|
<p className="font-medium text-blue-800">Tipp</p>
|
|
<p className="text-sm text-blue-600 mt-1">
|
|
Laden Sie ein Bild mit handgeschriebenem Text hoch. Der TrOCR-Dienst erkennt
|
|
deutsche Handschrift und gibt den Text zurück.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|