Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website, Klausur-Service, School-Service, Voice-Service, Geo-Service, BreakPilot Drive, Agent-Core Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
295 lines
9.9 KiB
TypeScript
295 lines
9.9 KiB
TypeScript
'use client'
|
|
|
|
/**
|
|
* CellCorrectionDialog Component
|
|
*
|
|
* Modal dialog for manually correcting OCR text in problematic or recognized cells.
|
|
* Shows cropped image of the cell for reference and allows text input.
|
|
*/
|
|
|
|
import { useState, useEffect } from 'react'
|
|
import type { GridCell } from './GridOverlay'
|
|
|
|
interface CellCorrectionDialogProps {
|
|
cell: GridCell
|
|
columnType: 'english' | 'german' | 'example' | 'unknown'
|
|
sessionId: string
|
|
pageNumber: number
|
|
onSave: (text: string) => void
|
|
onRetryOCR?: () => void
|
|
onClose: () => void
|
|
}
|
|
|
|
export function CellCorrectionDialog({
|
|
cell,
|
|
columnType,
|
|
sessionId,
|
|
pageNumber,
|
|
onSave,
|
|
onRetryOCR,
|
|
onClose,
|
|
}: CellCorrectionDialogProps) {
|
|
const [text, setText] = useState(cell.text || '')
|
|
const [loading, setLoading] = useState(false)
|
|
const [retrying, setRetrying] = useState(false)
|
|
const [cropUrl, setCropUrl] = useState<string | null>(null)
|
|
|
|
const KLAUSUR_API = '/klausur-api'
|
|
|
|
// Load cell crop image
|
|
useEffect(() => {
|
|
const loadCrop = async () => {
|
|
try {
|
|
const res = await fetch(
|
|
`${KLAUSUR_API}/api/v1/vocab/sessions/${sessionId}/cell-crop/${pageNumber}/${cell.row}/${cell.col}`
|
|
)
|
|
if (res.ok) {
|
|
const blob = await res.blob()
|
|
setCropUrl(URL.createObjectURL(blob))
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load cell crop:', e)
|
|
}
|
|
}
|
|
loadCrop()
|
|
|
|
return () => {
|
|
if (cropUrl) {
|
|
URL.revokeObjectURL(cropUrl)
|
|
}
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [sessionId, pageNumber, cell.row, cell.col])
|
|
|
|
const handleSave = async () => {
|
|
if (!text.trim()) return
|
|
|
|
setLoading(true)
|
|
try {
|
|
const formData = new FormData()
|
|
formData.append('text', text)
|
|
|
|
const res = await fetch(
|
|
`${KLAUSUR_API}/api/v1/vocab/sessions/${sessionId}/cell/${cell.row}/${cell.col}`,
|
|
{
|
|
method: 'PUT',
|
|
body: formData,
|
|
}
|
|
)
|
|
|
|
if (res.ok) {
|
|
onSave(text)
|
|
onClose()
|
|
} else {
|
|
console.error('Failed to save cell:', await res.text())
|
|
}
|
|
} catch (e) {
|
|
console.error('Save failed:', e)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleRetryOCR = async () => {
|
|
if (!onRetryOCR) return
|
|
|
|
setRetrying(true)
|
|
try {
|
|
await onRetryOCR()
|
|
} finally {
|
|
setRetrying(false)
|
|
}
|
|
}
|
|
|
|
const getColumnLabel = () => {
|
|
switch (columnType) {
|
|
case 'english':
|
|
return 'Englisch'
|
|
case 'german':
|
|
return 'Deutsch'
|
|
case 'example':
|
|
return 'Beispielsatz'
|
|
default:
|
|
return 'Text'
|
|
}
|
|
}
|
|
|
|
const getPlaceholder = () => {
|
|
switch (columnType) {
|
|
case 'english':
|
|
return 'Englisches Wort eingeben...'
|
|
case 'german':
|
|
return 'Deutsche Ubersetzung eingeben...'
|
|
case 'example':
|
|
return 'Beispielsatz eingeben...'
|
|
default:
|
|
return 'Text eingeben...'
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
{/* Backdrop */}
|
|
<div
|
|
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
|
onClick={onClose}
|
|
/>
|
|
|
|
{/* Dialog */}
|
|
<div className="relative w-full max-w-lg bg-white rounded-xl shadow-xl overflow-hidden">
|
|
{/* Header */}
|
|
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-slate-900">
|
|
{cell.status === 'problematic' ? 'Nicht erkannter Bereich' : 'Text bearbeiten'}
|
|
</h3>
|
|
<p className="text-sm text-slate-500">
|
|
Zeile {cell.row + 1}, Spalte {cell.col + 1} ({getColumnLabel()})
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
className="p-2 rounded-lg hover:bg-slate-100 transition-colors"
|
|
>
|
|
<svg className="w-5 h-5 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="p-6 space-y-4">
|
|
{/* Cell image preview */}
|
|
<div className="border rounded-lg p-3 bg-slate-50">
|
|
<p className="text-xs text-slate-500 mb-2 font-medium">Originalbild:</p>
|
|
{cropUrl ? (
|
|
<img
|
|
src={cropUrl}
|
|
alt="Zellinhalt"
|
|
className="max-h-32 mx-auto rounded border border-slate-200 bg-white"
|
|
/>
|
|
) : (
|
|
<div className="h-20 flex items-center justify-center text-slate-400 text-sm">
|
|
Lade Vorschau...
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Status indicator */}
|
|
{cell.status === 'problematic' && (
|
|
<div className="flex items-center gap-2 p-3 bg-orange-50 border border-orange-200 rounded-lg">
|
|
<svg className="w-5 h-5 text-orange-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
</svg>
|
|
<span className="text-sm text-orange-700">
|
|
Diese Zelle konnte nicht automatisch erkannt werden.
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Current recognized text */}
|
|
{cell.status === 'recognized' && cell.text && (
|
|
<div className="p-3 bg-green-50 border border-green-200 rounded-lg">
|
|
<p className="text-xs text-green-600 font-medium mb-1">Erkannter Text:</p>
|
|
<p className="text-sm text-green-800">{cell.text}</p>
|
|
{cell.confidence < 1 && (
|
|
<p className="text-xs text-green-600 mt-1">
|
|
Konfidenz: {Math.round(cell.confidence * 100)}%
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Text input */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
|
{cell.status === 'problematic' ? 'Text eingeben' : 'Text korrigieren'}
|
|
</label>
|
|
{columnType === 'example' ? (
|
|
<textarea
|
|
value={text}
|
|
onChange={(e) => setText(e.target.value)}
|
|
placeholder={getPlaceholder()}
|
|
rows={3}
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-slate-900"
|
|
/>
|
|
) : (
|
|
<input
|
|
type="text"
|
|
value={text}
|
|
onChange={(e) => setText(e.target.value)}
|
|
placeholder={getPlaceholder()}
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-slate-900"
|
|
autoFocus
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
handleSave()
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="px-6 py-4 border-t border-slate-200 flex items-center justify-between bg-slate-50">
|
|
<div>
|
|
{onRetryOCR && cell.status === 'problematic' && (
|
|
<button
|
|
onClick={handleRetryOCR}
|
|
disabled={retrying}
|
|
className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-50 flex items-center gap-2"
|
|
>
|
|
{retrying ? (
|
|
<>
|
|
<svg className="animate-spin w-4 h-4" 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 12h4z" />
|
|
</svg>
|
|
Erneut versuchen...
|
|
</>
|
|
) : (
|
|
<>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
</svg>
|
|
OCR erneut versuchen
|
|
</>
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex gap-3">
|
|
<button
|
|
onClick={onClose}
|
|
className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50"
|
|
>
|
|
Abbrechen
|
|
</button>
|
|
<button
|
|
onClick={handleSave}
|
|
disabled={loading || !text.trim()}
|
|
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 disabled:opacity-50 flex items-center gap-2"
|
|
>
|
|
{loading ? (
|
|
<>
|
|
<svg className="animate-spin w-4 h-4" 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 12h4z" />
|
|
</svg>
|
|
Speichern...
|
|
</>
|
|
) : (
|
|
'Speichern'
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default CellCorrectionDialog
|