StepLlmReview: Show full vocab table with image overlay, row-level
status tracking (pending/active/reviewed/corrected/skipped), and
auto-scroll during SSE streaming. Load previous results on mount.
StepReconstruction: New step 7 with editable text fields at original
bbox positions over dewarped image. Zoom controls, tab navigation,
color-coded columns, save to backend.
Backend: Add POST /sessions/{id}/reconstruction endpoint.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
345 lines
12 KiB
TypeScript
345 lines
12 KiB
TypeScript
'use client'
|
||
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||
import type { GridResult, GridCell, WordEntry } from '@/app/(admin)/ai/ocr-pipeline/types'
|
||
|
||
const KLAUSUR_API = '/klausur-api'
|
||
|
||
interface StepReconstructionProps {
|
||
sessionId: string | null
|
||
onNext: () => void
|
||
}
|
||
|
||
interface EditableCell {
|
||
cellId: string
|
||
text: string
|
||
originalText: string
|
||
bboxPct: { x: number; y: number; w: number; h: number }
|
||
colType: string
|
||
rowIndex: number
|
||
colIndex: number
|
||
}
|
||
|
||
export function StepReconstruction({ sessionId, onNext }: StepReconstructionProps) {
|
||
const [status, setStatus] = useState<'loading' | 'ready' | 'saving' | 'saved' | 'error'>('loading')
|
||
const [error, setError] = useState('')
|
||
const [cells, setCells] = useState<EditableCell[]>([])
|
||
const [editedTexts, setEditedTexts] = useState<Map<string, string>>(new Map())
|
||
const [zoom, setZoom] = useState(100)
|
||
const [containerSize, setContainerSize] = useState<{ w: number; h: number } | null>(null)
|
||
|
||
const containerRef = useRef<HTMLDivElement>(null)
|
||
const imageRef = useRef<HTMLImageElement>(null)
|
||
|
||
// Load session data on mount
|
||
useEffect(() => {
|
||
if (!sessionId) return
|
||
loadSessionData()
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [sessionId])
|
||
|
||
// Track container size for font scaling
|
||
useEffect(() => {
|
||
if (!containerRef.current) return
|
||
const observer = new ResizeObserver((entries) => {
|
||
for (const entry of entries) {
|
||
setContainerSize({ w: entry.contentRect.width, h: entry.contentRect.height })
|
||
}
|
||
})
|
||
observer.observe(containerRef.current)
|
||
return () => observer.disconnect()
|
||
}, [])
|
||
|
||
const loadSessionData = async () => {
|
||
if (!sessionId) return
|
||
setStatus('loading')
|
||
try {
|
||
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}`)
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||
const data = await res.json()
|
||
|
||
const wordResult: GridResult | undefined = data.word_result
|
||
if (!wordResult) {
|
||
setError('Keine Worterkennungsdaten gefunden. Bitte zuerst Schritt 5 abschliessen.')
|
||
setStatus('error')
|
||
return
|
||
}
|
||
|
||
// Build editable cells from grid cells
|
||
const gridCells: GridCell[] = wordResult.cells || []
|
||
const editableCells: EditableCell[] = gridCells
|
||
.filter(c => c.text.trim() !== '')
|
||
.map(c => ({
|
||
cellId: c.cell_id,
|
||
text: c.text,
|
||
originalText: c.text,
|
||
bboxPct: c.bbox_pct,
|
||
colType: c.col_type,
|
||
rowIndex: c.row_index,
|
||
colIndex: c.col_index,
|
||
}))
|
||
|
||
setCells(editableCells)
|
||
setStatus('ready')
|
||
} catch (e: unknown) {
|
||
setError(e instanceof Error ? e.message : String(e))
|
||
setStatus('error')
|
||
}
|
||
}
|
||
|
||
const handleTextChange = useCallback((cellId: string, newText: string) => {
|
||
setEditedTexts(prev => {
|
||
const next = new Map(prev)
|
||
next.set(cellId, newText)
|
||
return next
|
||
})
|
||
}, [])
|
||
|
||
const getDisplayText = useCallback((cell: EditableCell): string => {
|
||
return editedTexts.get(cell.cellId) ?? cell.text
|
||
}, [editedTexts])
|
||
|
||
const isEdited = useCallback((cell: EditableCell): boolean => {
|
||
const edited = editedTexts.get(cell.cellId)
|
||
return edited !== undefined && edited !== cell.originalText
|
||
}, [editedTexts])
|
||
|
||
const changedCount = useMemo(() => {
|
||
let count = 0
|
||
for (const cell of cells) {
|
||
if (isEdited(cell)) count++
|
||
}
|
||
return count
|
||
}, [cells, isEdited])
|
||
|
||
// Sort cells for tab navigation: by row, then by column
|
||
const sortedCellIds = useMemo(() => {
|
||
return [...cells]
|
||
.sort((a, b) => a.rowIndex !== b.rowIndex ? a.rowIndex - b.rowIndex : a.colIndex - b.colIndex)
|
||
.map(c => c.cellId)
|
||
}, [cells])
|
||
|
||
const handleKeyDown = useCallback((e: React.KeyboardEvent, cellId: string) => {
|
||
if (e.key === 'Tab') {
|
||
e.preventDefault()
|
||
const idx = sortedCellIds.indexOf(cellId)
|
||
const nextIdx = e.shiftKey ? idx - 1 : idx + 1
|
||
if (nextIdx >= 0 && nextIdx < sortedCellIds.length) {
|
||
const nextId = sortedCellIds[nextIdx]
|
||
const el = document.getElementById(`cell-${nextId}`)
|
||
el?.focus()
|
||
}
|
||
}
|
||
}, [sortedCellIds])
|
||
|
||
const saveReconstruction = useCallback(async () => {
|
||
if (!sessionId) return
|
||
setStatus('saving')
|
||
try {
|
||
const cellUpdates = Array.from(editedTexts.entries())
|
||
.filter(([cellId, text]) => {
|
||
const cell = cells.find(c => c.cellId === cellId)
|
||
return cell && text !== cell.originalText
|
||
})
|
||
.map(([cellId, text]) => ({ cell_id: cellId, text }))
|
||
|
||
if (cellUpdates.length === 0) {
|
||
// Nothing changed, just advance
|
||
setStatus('saved')
|
||
return
|
||
}
|
||
|
||
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/reconstruction`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ cells: cellUpdates }),
|
||
})
|
||
|
||
if (!res.ok) {
|
||
const data = await res.json().catch(() => ({}))
|
||
throw new Error(data.detail || `HTTP ${res.status}`)
|
||
}
|
||
|
||
setStatus('saved')
|
||
} catch (e: unknown) {
|
||
setError(e instanceof Error ? e.message : String(e))
|
||
setStatus('error')
|
||
}
|
||
}, [sessionId, editedTexts, cells])
|
||
|
||
const dewarpedUrl = sessionId
|
||
? `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/dewarped`
|
||
: ''
|
||
|
||
const colTypeColor = (colType: string): string => {
|
||
const colors: Record<string, string> = {
|
||
column_en: 'border-blue-400/40 focus:border-blue-500',
|
||
column_de: 'border-green-400/40 focus:border-green-500',
|
||
column_example: 'border-orange-400/40 focus:border-orange-500',
|
||
column_text: 'border-purple-400/40 focus:border-purple-500',
|
||
}
|
||
return colors[colType] || 'border-gray-400/40 focus:border-gray-500'
|
||
}
|
||
|
||
if (!sessionId) {
|
||
return <div className="text-center py-12 text-gray-400">Bitte zuerst eine Session auswaehlen.</div>
|
||
}
|
||
|
||
if (status === 'loading') {
|
||
return (
|
||
<div className="flex items-center gap-3 justify-center py-12">
|
||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-teal-500" />
|
||
<span className="text-gray-500">Rekonstruktionsdaten werden geladen...</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (status === 'error') {
|
||
return (
|
||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||
<div className="text-5xl mb-4">⚠️</div>
|
||
<h3 className="text-lg font-medium text-red-600 dark:text-red-400 mb-2">Fehler</h3>
|
||
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-lg mb-4">{error}</p>
|
||
<div className="flex gap-3">
|
||
<button onClick={() => { setError(''); loadSessionData() }}
|
||
className="px-5 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors text-sm">
|
||
Erneut versuchen
|
||
</button>
|
||
<button onClick={onNext}
|
||
className="px-5 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors text-sm">
|
||
Ueberspringen →
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (status === 'saved') {
|
||
return (
|
||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||
<div className="text-5xl mb-4">✅</div>
|
||
<h3 className="text-lg font-medium text-gray-700 dark:text-gray-300 mb-2">Rekonstruktion gespeichert</h3>
|
||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||
{changedCount > 0 ? `${changedCount} Zellen wurden aktualisiert.` : 'Keine Aenderungen vorgenommen.'}
|
||
</p>
|
||
<button onClick={onNext}
|
||
className="px-6 py-2.5 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors font-medium">
|
||
Weiter →
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
{/* Toolbar */}
|
||
<div className="flex items-center justify-between bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 px-3 py-2">
|
||
<div className="flex items-center gap-2">
|
||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||
Schritt 7: Rekonstruktion
|
||
</h3>
|
||
<span className="text-xs text-gray-400">
|
||
{cells.length} Zellen · {changedCount} geaendert
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{/* Zoom controls */}
|
||
<button
|
||
onClick={() => setZoom(z => Math.max(50, z - 25))}
|
||
className="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700"
|
||
>
|
||
−
|
||
</button>
|
||
<span className="text-xs text-gray-500 w-10 text-center">{zoom}%</span>
|
||
<button
|
||
onClick={() => setZoom(z => Math.min(200, z + 25))}
|
||
className="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700"
|
||
>
|
||
+
|
||
</button>
|
||
<button
|
||
onClick={() => setZoom(100)}
|
||
className="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700"
|
||
>
|
||
Fit
|
||
</button>
|
||
|
||
<div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1" />
|
||
|
||
<button
|
||
onClick={saveReconstruction}
|
||
disabled={status === 'saving'}
|
||
className="px-4 py-1.5 text-xs bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50 transition-colors font-medium"
|
||
>
|
||
{status === 'saving' ? 'Speichert...' : 'Speichern'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Reconstruction canvas */}
|
||
<div className="border rounded-lg overflow-auto dark:border-gray-700 bg-gray-100 dark:bg-gray-900" style={{ maxHeight: '75vh' }}>
|
||
<div
|
||
ref={containerRef}
|
||
className="relative inline-block"
|
||
style={{ transform: `scale(${zoom / 100})`, transformOrigin: 'top left' }}
|
||
>
|
||
{/* Background image at reduced opacity */}
|
||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||
<img
|
||
ref={imageRef}
|
||
src={dewarpedUrl}
|
||
alt="Dewarped"
|
||
className="block"
|
||
style={{ opacity: 0.3 }}
|
||
/>
|
||
|
||
{/* Editable text fields at bbox positions */}
|
||
{cells.map((cell) => {
|
||
const displayText = getDisplayText(cell)
|
||
const edited = isEdited(cell)
|
||
|
||
return (
|
||
<input
|
||
key={cell.cellId}
|
||
id={`cell-${cell.cellId}`}
|
||
type="text"
|
||
value={displayText}
|
||
onChange={(e) => handleTextChange(cell.cellId, e.target.value)}
|
||
onKeyDown={(e) => handleKeyDown(e, cell.cellId)}
|
||
className={`absolute bg-transparent text-black dark:text-white border px-0.5 outline-none transition-colors ${
|
||
colTypeColor(cell.colType)
|
||
} ${edited ? 'border-green-500 bg-green-50/30 dark:bg-green-900/20' : ''}`}
|
||
style={{
|
||
left: `${cell.bboxPct.x}%`,
|
||
top: `${cell.bboxPct.y}%`,
|
||
width: `${cell.bboxPct.w}%`,
|
||
height: `${cell.bboxPct.h}%`,
|
||
fontSize: `${Math.max(8, Math.min(16, (cell.bboxPct.h / 100) * (containerSize?.h || 800) * 0.6))}px`,
|
||
lineHeight: '1',
|
||
}}
|
||
title={`${cell.cellId} (${cell.colType})`}
|
||
/>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Bottom action */}
|
||
<div className="flex justify-end">
|
||
<button
|
||
onClick={() => {
|
||
if (changedCount > 0) {
|
||
saveReconstruction()
|
||
} else {
|
||
onNext()
|
||
}
|
||
}}
|
||
className="px-6 py-2.5 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors font-medium text-sm"
|
||
>
|
||
{changedCount > 0 ? 'Speichern & Weiter →' : 'Weiter →'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|