'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([]) const [editedTexts, setEditedTexts] = useState>(new Map()) const [zoom, setZoom] = useState(100) const [containerSize, setContainerSize] = useState<{ w: number; h: number } | null>(null) const containerRef = useRef(null) const imageRef = useRef(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 = { 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
Bitte zuerst eine Session auswaehlen.
} if (status === 'loading') { return (
Rekonstruktionsdaten werden geladen...
) } if (status === 'error') { return (
⚠️

Fehler

{error}

) } if (status === 'saved') { return (

Rekonstruktion gespeichert

{changedCount > 0 ? `${changedCount} Zellen wurden aktualisiert.` : 'Keine Aenderungen vorgenommen.'}

) } return (
{/* Toolbar */}

Schritt 7: Rekonstruktion

{cells.length} Zellen · {changedCount} geaendert
{/* Zoom controls */} {zoom}%
{/* Reconstruction canvas */}
{/* Background image at reduced opacity */} {/* eslint-disable-next-line @next/next/no-img-element */} Dewarped {/* Editable text fields at bbox positions */} {cells.map((cell) => { const displayText = getDisplayText(cell) const edited = isEdited(cell) return ( 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})`} /> ) })}
{/* Bottom action */}
) }