Files
breakpilot-lehrer/admin-lehrer/components/ocr-pipeline/StepLlmReview.tsx
Benjamin Admin bcd97e7d78
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 34s
CI / test-go-edu-search (push) Successful in 31s
CI / test-python-klausur (push) Failing after 2m9s
CI / test-python-agent-core (push) Successful in 20s
CI / test-nodejs-website (push) Successful in 24s
feat: Overlay-Modus fuer ganzseitige Tabellenrekonstruktion mit Pixel-Positionierung
- usePixelWordPositions Hook extrahiert (shared zwischen StepLlmReview und StepReconstruction)
- StepReconstruction: neuer Overlay-Modus mit 50/50 Layout (Original + Rekonstruktion)
- Sub-Session-Zellen werden in Parent-Koordinaten konvertiert und zusammengefuehrt
- Spalten-/Zeilenlinien und Box-Zone-Markierung aus column_result/row_result
- Schriftgroesse-Slider und Bold-Toggle fuer Overlay
- StepLlmReview: ~140 Zeilen Pixel-Analyse durch Hook ersetzt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 16:18:47 +01:00

923 lines
38 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { GridCell, GridResult, WordEntry, ColumnMeta } from '@/app/(admin)/ai/ocr-pipeline/types'
import { usePixelWordPositions } from './usePixelWordPositions'
const KLAUSUR_API = '/klausur-api'
interface LlmChange {
row_index: number
field: 'english' | 'german' | 'example'
old: string
new: string
}
interface StepLlmReviewProps {
sessionId: string | null
onNext: () => void
}
interface ReviewMeta {
total_entries: number
to_review: number
skipped: number
model: string
skipped_indices?: number[]
}
interface StreamProgress {
current: number
total: number
}
const FIELD_LABELS: Record<string, string> = {
english: 'EN',
german: 'DE',
example: 'Beispiel',
source_page: 'Seite',
marker: 'Marker',
text: 'Text',
}
/** Map column type to WordEntry field name */
const COL_TYPE_TO_FIELD: Record<string, string> = {
column_en: 'english',
column_de: 'german',
column_example: 'example',
page_ref: 'source_page',
column_marker: 'marker',
column_text: 'text',
}
/** Column type → color class */
const COL_TYPE_COLOR: Record<string, string> = {
column_en: 'text-blue-600 dark:text-blue-400',
column_de: 'text-green-600 dark:text-green-400',
column_example: 'text-orange-600 dark:text-orange-400',
page_ref: 'text-cyan-600 dark:text-cyan-400',
column_marker: 'text-gray-500 dark:text-gray-400',
column_text: 'text-gray-700 dark:text-gray-300',
}
type RowStatus = 'pending' | 'active' | 'reviewed' | 'corrected' | 'skipped'
export function StepLlmReview({ sessionId, onNext }: StepLlmReviewProps) {
// Core state
const [status, setStatus] = useState<'idle' | 'loading' | 'ready' | 'running' | 'done' | 'error' | 'applied'>('idle')
const [meta, setMeta] = useState<ReviewMeta | null>(null)
const [changes, setChanges] = useState<LlmChange[]>([])
const [progress, setProgress] = useState<StreamProgress | null>(null)
const [totalDuration, setTotalDuration] = useState(0)
const [error, setError] = useState('')
const [accepted, setAccepted] = useState<Set<number>>(new Set())
const [applying, setApplying] = useState(false)
// Full vocab table state
const [vocabEntries, setVocabEntries] = useState<WordEntry[]>([])
const [columnsUsed, setColumnsUsed] = useState<ColumnMeta[]>([])
const [activeRowIndices, setActiveRowIndices] = useState<Set<number>>(new Set())
const [reviewedRows, setReviewedRows] = useState<Set<number>>(new Set())
const [skippedRows, setSkippedRows] = useState<Set<number>>(new Set())
const [correctedMap, setCorrectedMap] = useState<Map<number, LlmChange[]>>(new Map())
// Image
const [imageNaturalSize, setImageNaturalSize] = useState<{ w: number; h: number } | null>(null)
// Overlay view state
const [viewMode, setViewMode] = useState<'table' | 'overlay'>('table')
const [fontScale, setFontScale] = useState(0.7)
const [leftPaddingPct, setLeftPaddingPct] = useState(0)
const [globalBold, setGlobalBold] = useState(false)
const [cells, setCells] = useState<GridCell[]>([])
const reconRef = useRef<HTMLDivElement>(null)
const [reconWidth, setReconWidth] = useState(0)
// Pixel-analysed word positions via shared hook
const overlayImageUrl = sessionId
? `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/cropped`
: ''
const cellWordPositions = usePixelWordPositions(overlayImageUrl, cells, viewMode === 'overlay')
const tableRef = useRef<HTMLDivElement>(null)
const activeRowRef = useRef<HTMLTableRowElement>(null)
// Track reconstruction container width for font size calculation
useEffect(() => {
const el = reconRef.current
if (!el) return
const obs = new ResizeObserver(entries => {
for (const entry of entries) setReconWidth(entry.contentRect.width)
})
obs.observe(el)
return () => obs.disconnect()
}, [viewMode])
// Load session data on mount
useEffect(() => {
if (!sessionId) return
loadSessionData()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId])
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
}
const entries = wordResult.vocab_entries || wordResult.entries || []
setVocabEntries(entries)
setColumnsUsed(wordResult.columns_used || [])
setCells(wordResult.cells || [])
// Check if LLM review was already run
const llmReview = wordResult.llm_review
if (llmReview && llmReview.changes) {
const existingChanges: LlmChange[] = llmReview.changes as LlmChange[]
setChanges(existingChanges)
setTotalDuration(llmReview.duration_ms || 0)
// Mark all rows as reviewed
const allReviewed = new Set(entries.map((_: WordEntry, i: number) => i))
setReviewedRows(allReviewed)
// Build corrected map
const cMap = new Map<number, LlmChange[]>()
for (const c of existingChanges) {
const existing = cMap.get(c.row_index) || []
existing.push(c)
cMap.set(c.row_index, existing)
}
setCorrectedMap(cMap)
// Default: all accepted
setAccepted(new Set(existingChanges.map((_: LlmChange, i: number) => i)))
setMeta({
total_entries: entries.length,
to_review: llmReview.entries_corrected !== undefined ? entries.length : entries.length,
skipped: 0,
model: llmReview.model_used || 'unknown',
})
setStatus('done')
} else {
setStatus('ready')
}
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e))
setStatus('error')
}
}
const runReview = useCallback(async () => {
if (!sessionId) return
setStatus('running')
setError('')
setChanges([])
setProgress(null)
setMeta(null)
setTotalDuration(0)
setActiveRowIndices(new Set())
setReviewedRows(new Set())
setSkippedRows(new Set())
setCorrectedMap(new Map())
try {
const res = await fetch(
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/llm-review?stream=true`,
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) },
)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.detail || `HTTP ${res.status}`)
}
const reader = res.body!.getReader()
const decoder = new TextDecoder()
let buffer = ''
let allChanges: LlmChange[] = []
let allReviewed = new Set<number>()
let allSkipped = new Set<number>()
let cMap = new Map<number, LlmChange[]>()
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
while (buffer.includes('\n\n')) {
const idx = buffer.indexOf('\n\n')
const chunk = buffer.slice(0, idx).trim()
buffer = buffer.slice(idx + 2)
if (!chunk.startsWith('data: ')) continue
const dataStr = chunk.slice(6)
let event: any
try { event = JSON.parse(dataStr) } catch { continue }
if (event.type === 'meta') {
setMeta({
total_entries: event.total_entries,
to_review: event.to_review,
skipped: event.skipped,
model: event.model,
skipped_indices: event.skipped_indices,
})
// Mark skipped rows
if (event.skipped_indices) {
allSkipped = new Set(event.skipped_indices)
setSkippedRows(allSkipped)
}
}
if (event.type === 'batch') {
const batchChanges: LlmChange[] = event.changes || []
const batchRows: number[] = event.entries_reviewed || []
// Update active rows (currently being reviewed)
setActiveRowIndices(new Set(batchRows))
// Accumulate changes
allChanges = [...allChanges, ...batchChanges]
setChanges(allChanges)
setProgress(event.progress)
// Update corrected map
for (const c of batchChanges) {
const existing = cMap.get(c.row_index) || []
existing.push(c)
cMap.set(c.row_index, [...existing])
}
setCorrectedMap(new Map(cMap))
// Mark batch rows as reviewed
for (const r of batchRows) {
allReviewed.add(r)
}
setReviewedRows(new Set(allReviewed))
// Scroll to active row in table
setTimeout(() => {
activeRowRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, 50)
}
if (event.type === 'complete') {
setActiveRowIndices(new Set())
setTotalDuration(event.duration_ms)
setAccepted(new Set(allChanges.map((_: LlmChange, i: number) => i)))
// Mark all non-skipped as reviewed
const allEntryIndices = vocabEntries.map((_: WordEntry, i: number) => i)
for (const i of allEntryIndices) {
if (!allSkipped.has(i)) allReviewed.add(i)
}
setReviewedRows(new Set(allReviewed))
setStatus('done')
}
if (event.type === 'error') {
throw new Error(event.detail || 'Unbekannter Fehler')
}
}
}
// If stream ended without complete event
if (allChanges.length === 0) {
setStatus('done')
}
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e)
setError(msg)
setStatus('error')
}
}, [sessionId, vocabEntries])
const toggleChange = (index: number) => {
setAccepted(prev => {
const next = new Set(prev)
if (next.has(index)) next.delete(index)
else next.add(index)
return next
})
}
const toggleAll = () => {
if (accepted.size === changes.length) {
setAccepted(new Set())
} else {
setAccepted(new Set(changes.map((_: LlmChange, i: number) => i)))
}
}
const applyChanges = useCallback(async () => {
if (!sessionId) return
setApplying(true)
try {
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/llm-review/apply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accepted_indices: Array.from(accepted) }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.detail || `HTTP ${res.status}`)
}
setStatus('applied')
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setApplying(false)
}
}, [sessionId, accepted])
const getRowStatus = (rowIndex: number): RowStatus => {
if (activeRowIndices.has(rowIndex)) return 'active'
if (skippedRows.has(rowIndex)) return 'skipped'
if (correctedMap.has(rowIndex)) return 'corrected'
if (reviewedRows.has(rowIndex)) return 'reviewed'
return 'pending'
}
const dewarpedUrl = sessionId
? `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/cropped`
: ''
// Snap all cells in the same column to consistent x/w positions
// Uses the median x and width per col_index so columns align vertically
const colPositions = useMemo(() => {
const byCol = new Map<number, { xs: number[]; ws: number[] }>()
for (const cell of cells) {
if (!cell.bbox_pct) continue
const entry = byCol.get(cell.col_index) || { xs: [], ws: [] }
entry.xs.push(cell.bbox_pct.x)
entry.ws.push(cell.bbox_pct.w)
byCol.set(cell.col_index, entry)
}
const result = new Map<number, { x: number; w: number }>()
for (const [colIdx, { xs, ws }] of byCol) {
xs.sort((a, b) => a - b)
ws.sort((a, b) => a - b)
const medianX = xs[Math.floor(xs.length / 2)]
const medianW = ws[Math.floor(ws.length / 2)]
result.set(colIdx, { x: medianX, w: medianW })
}
return result
}, [cells])
if (!sessionId) {
return <div className="text-center py-12 text-gray-400">Bitte zuerst eine Session auswaehlen.</div>
}
// --- Loading session data ---
if (status === 'loading' || status === 'idle') {
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">Session-Daten werden geladen...</span>
</div>
)
}
// --- Error ---
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 bei OCR-Zeichenkorrektur</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>
)
}
// --- Applied ---
if (status === 'applied') {
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">Korrekturen uebernommen</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
{accepted.size} von {changes.length} Korrekturen wurden angewendet.
</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>
)
}
// Active entry for highlighting on image
const activeEntry = vocabEntries.find((_: WordEntry, i: number) => activeRowIndices.has(i))
const pct = progress ? Math.round((progress.current / progress.total) * 100) : 0
/** Handle inline edit of a cell in the overlay */
const handleCellEdit = (cellId: string, rowIndex: number, newText: string | null) => {
if (newText === null) return
setCells(prev => prev.map(c => c.cell_id === cellId ? { ...c, text: newText } : c))
// Also update vocabEntries if this cell maps to a known field
const cell = cells.find(c => c.cell_id === cellId)
if (cell) {
const field = COL_TYPE_TO_FIELD[cell.col_type]
if (field) {
setVocabEntries(prev => prev.map((e, i) =>
i === rowIndex ? { ...e, [field]: newText } : e
))
}
}
}
// --- Ready / Running / Done: 2-column layout ---
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-base font-medium text-gray-700 dark:text-gray-300">
Schritt 6: Korrektur
</h3>
<p className="text-xs text-gray-400 mt-0.5">
{status === 'ready' && `${vocabEntries.length} Eintraege bereit zur Pruefung`}
{status === 'running' && meta && `${meta.model} · ${meta.to_review} zu pruefen, ${meta.skipped} uebersprungen`}
{status === 'done' && (
<>
{changes.length} Korrektur{changes.length !== 1 ? 'en' : ''} gefunden
{meta && <> · {meta.skipped} uebersprungen</>}
{' '}· {totalDuration}ms · {meta?.model}
</>
)}
</p>
</div>
<div className="flex items-center gap-2">
{status === 'ready' && (
<button onClick={runReview}
className="px-5 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors text-sm font-medium">
Korrektur starten
</button>
)}
{status === 'running' && (
<div className="flex items-center gap-2 text-sm text-teal-600 dark:text-teal-400">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-teal-500" />
{progress ? `${progress.current}/${progress.total}` : 'Startet...'}
</div>
)}
{status === 'done' && changes.length > 0 && (
<button onClick={toggleAll}
className="text-xs px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors text-gray-600 dark:text-gray-400">
{accepted.size === changes.length ? 'Keine' : 'Alle'} auswaehlen
</button>
)}
</div>
</div>
{/* Progress bar (while running) */}
{status === 'running' && progress && (
<div className="space-y-1">
<div className="flex justify-between text-xs text-gray-400">
<span>{progress.current} / {progress.total} Eintraege geprueft</span>
<span>{pct}%</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div className="bg-teal-500 h-2 rounded-full transition-all duration-500" style={{ width: `${pct}%` }} />
</div>
</div>
)}
{/* View mode toggle */}
<div className="flex items-center gap-1">
<button
onClick={() => setViewMode('table')}
className={`px-3 py-1.5 text-xs rounded-l-lg border transition-colors ${
viewMode === 'table'
? 'bg-teal-600 text-white border-teal-600'
: 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
Tabelle
</button>
<button
onClick={() => setViewMode('overlay')}
className={`px-3 py-1.5 text-xs rounded-r-lg border transition-colors ${
viewMode === 'overlay'
? 'bg-teal-600 text-white border-teal-600'
: 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
Overlay
</button>
</div>
{/* Overlay toolbar */}
{viewMode === 'overlay' && (
<div className="flex items-center gap-4 flex-wrap bg-gray-50 dark:bg-gray-800/50 rounded-lg px-3 py-2">
<label className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
Schrift
<input
type="range" min={30} max={120} value={Math.round(fontScale * 100)}
onChange={e => setFontScale(Number(e.target.value) / 100)}
className="w-24 h-1 accent-teal-600"
/>
<span className="w-8 text-right font-mono">{Math.round(fontScale * 100)}%</span>
</label>
<label className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
Einrueckung
<input
type="range" min={0} max={20} step={0.5} value={leftPaddingPct}
onChange={e => setLeftPaddingPct(Number(e.target.value))}
className="w-24 h-1 accent-teal-600"
/>
<span className="w-8 text-right font-mono">{leftPaddingPct}%</span>
</label>
<button
onClick={() => setGlobalBold(b => !b)}
className={`px-2 py-1 text-xs rounded border transition-colors font-bold ${
globalBold
? 'bg-teal-600 text-white border-teal-600'
: 'bg-white dark:bg-gray-700 text-gray-600 dark:text-gray-400 border-gray-300 dark:border-gray-600'
}`}
>
B
</button>
</div>
)}
{/* 2-column layout: Image + Table/Overlay */}
<div className={`grid gap-4 ${viewMode === 'overlay' ? 'grid-cols-2' : 'grid-cols-3'}`}>
{/* Left: Dewarped Image with highlight overlay */}
<div className="col-span-1">
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
Originalbild
</div>
<div className="border rounded-lg overflow-hidden dark:border-gray-700 bg-gray-50 dark:bg-gray-900 relative sticky top-4">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={dewarpedUrl}
alt="Dewarped"
className="w-full h-auto"
onLoad={(e) => {
const img = e.target as HTMLImageElement
setImageNaturalSize({ w: img.naturalWidth, h: img.naturalHeight })
}}
/>
{/* Highlight overlay for active row */}
{activeEntry?.bbox && (
<div
className="absolute border-2 border-yellow-400 bg-yellow-400/20 pointer-events-none animate-pulse"
style={{
left: `${activeEntry.bbox.x}%`,
top: `${activeEntry.bbox.y}%`,
width: `${activeEntry.bbox.w}%`,
height: `${activeEntry.bbox.h}%`,
}}
/>
)}
</div>
</div>
{/* Right: Table or Overlay */}
<div className={viewMode === 'table' ? 'col-span-2' : 'col-span-1'} ref={tableRef}>
{viewMode === 'table' ? (
<>
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
{columnsUsed.length === 1 && columnsUsed[0]?.type === 'column_text' ? 'Tabelle' : 'Vokabeltabelle'} ({vocabEntries.length} Eintraege)
</div>
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div className="max-h-[70vh] overflow-y-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 z-10">
<tr className="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<th className="px-2 py-2 text-left text-gray-500 dark:text-gray-400 font-medium w-10">#</th>
{columnsUsed.length > 0 ? (
columnsUsed.map((col, i) => {
const field = COL_TYPE_TO_FIELD[col.type]
if (!field) return null
return (
<th key={i} className={`px-2 py-2 text-left font-medium ${COL_TYPE_COLOR[col.type] || 'text-gray-500 dark:text-gray-400'}`}>
{FIELD_LABELS[field] || field}
</th>
)
})
) : (
<>
<th className="px-2 py-2 text-left text-gray-500 dark:text-gray-400 font-medium">EN</th>
<th className="px-2 py-2 text-left text-gray-500 dark:text-gray-400 font-medium">DE</th>
<th className="px-2 py-2 text-left text-gray-500 dark:text-gray-400 font-medium">Beispiel</th>
</>
)}
<th className="px-2 py-2 text-center text-gray-500 dark:text-gray-400 font-medium w-16">Status</th>
</tr>
</thead>
<tbody>
{vocabEntries.map((entry, idx) => {
const rowStatus = getRowStatus(idx)
const rowChanges = correctedMap.get(idx)
const rowBg = {
pending: '',
active: 'bg-yellow-50 dark:bg-yellow-900/20',
reviewed: '',
corrected: 'bg-teal-50/50 dark:bg-teal-900/10',
skipped: 'bg-gray-50 dark:bg-gray-800/50',
}[rowStatus]
return (
<tr
key={idx}
ref={rowStatus === 'active' ? activeRowRef : undefined}
className={`border-b border-gray-100 dark:border-gray-700/50 ${rowBg} ${
rowStatus === 'active' ? 'ring-1 ring-yellow-400 ring-inset' : ''
}`}
>
<td className="px-2 py-1.5 text-gray-400 font-mono text-xs">{idx}</td>
{columnsUsed.length > 0 ? (
columnsUsed.map((col, i) => {
const field = COL_TYPE_TO_FIELD[col.type]
if (!field) return null
const text = (entry as Record<string, unknown>)[field] as string || ''
return (
<td key={i} className="px-2 py-1.5 text-xs">
<CellContent text={text} field={field} rowChanges={rowChanges} />
</td>
)
})
) : (
<>
<td className="px-2 py-1.5">
<CellContent text={entry.english} field="english" rowChanges={rowChanges} />
</td>
<td className="px-2 py-1.5">
<CellContent text={entry.german} field="german" rowChanges={rowChanges} />
</td>
<td className="px-2 py-1.5 text-xs">
<CellContent text={entry.example} field="example" rowChanges={rowChanges} />
</td>
</>
)}
<td className="px-2 py-1.5 text-center">
<StatusIcon status={rowStatus} />
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
</>
) : (
<>
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
Text-Rekonstruktion ({cells.filter(c => c.text).length} Zellen)
</div>
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-white dark:bg-white">
<div
ref={reconRef}
className="relative"
style={{
aspectRatio: imageNaturalSize ? `${imageNaturalSize.w} / ${imageNaturalSize.h}` : '3 / 4',
}}
>
{cells.map(cell => {
if (!cell.bbox_pct || !cell.text) return null
const col = colPositions.get(cell.col_index)
const cellX = col?.x ?? cell.bbox_pct.x
const cellW = col?.w ?? cell.bbox_pct.w
const aspect = imageNaturalSize ? imageNaturalSize.h / imageNaturalSize.w : 4 / 3
const containerH = reconWidth * aspect
const cellHeightPx = containerH * (cell.bbox_pct.h / 100)
const wordPos = cellWordPositions.get(cell.cell_id)
// Pixel-analysed: render word-groups at detected positions
if (wordPos) {
return wordPos.map((wp, i) => {
// Auto font-size from pixel analysis, scaled by user slider
const autoFontPx = cellHeightPx * wp.fontRatio * fontScale
const fs = Math.max(6, autoFontPx)
return (
<span
key={`${cell.cell_id}_${i}`}
className="absolute leading-none pointer-events-none select-none"
style={{
left: `${wp.xPct}%`,
top: `${cell.bbox_pct.y}%`,
width: `${wp.wPct}%`,
height: `${cell.bbox_pct.h}%`,
fontSize: `${fs}px`,
fontWeight: globalBold ? 'bold' : (cell.is_bold ? 'bold' : 'normal'),
fontFamily: "'Liberation Sans', Arial, sans-serif",
display: 'flex',
alignItems: 'center',
whiteSpace: 'nowrap',
overflow: 'visible',
color: '#1a1a1a',
}}
>
{wp.text}
</span>
)
})
}
// Fallback: no pixel data — single span for entire cell
const fontSize = Math.max(6, cellHeightPx * fontScale)
return (
<span
key={cell.cell_id}
className="absolute leading-none pointer-events-none select-none"
style={{
left: `${cellX}%`,
top: `${cell.bbox_pct.y}%`,
width: `${cellW}%`,
height: `${cell.bbox_pct.h}%`,
fontSize: `${fontSize}px`,
fontWeight: globalBold ? 'bold' : (cell.is_bold ? 'bold' : 'normal'),
paddingLeft: `${leftPaddingPct}%`,
fontFamily: "'Liberation Sans', Arial, sans-serif",
display: 'flex',
alignItems: 'center',
whiteSpace: 'pre',
overflow: 'visible',
color: '#1a1a1a',
}}
>
{cell.text}
</span>
)
})}
</div>
</div>
</>
)}
</div>
</div>
{/* Done state: summary + actions */}
{status === 'done' && (
<div className="space-y-4">
{/* Summary */}
<div className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-3 text-xs text-gray-500 dark:text-gray-400">
{changes.length === 0 ? (
<span>Keine Korrekturen noetig alle Eintraege sind korrekt.</span>
) : (
<span>
{changes.length} Korrektur{changes.length !== 1 ? 'en' : ''} gefunden ·{' '}
{accepted.size} ausgewaehlt ·{' '}
{meta?.skipped || 0} uebersprungen (Lautschrift) ·{' '}
{totalDuration}ms
</span>
)}
</div>
{/* Corrections detail list (if any) */}
{changes.length > 0 && (
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div className="bg-gray-50 dark:bg-gray-800 px-3 py-2 border-b border-gray-200 dark:border-gray-700">
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">
Korrekturvorschlaege ({accepted.size}/{changes.length} ausgewaehlt)
</span>
</div>
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50/50 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-700">
<th className="w-10 px-3 py-1.5 text-center">
<input type="checkbox" checked={accepted.size === changes.length} onChange={toggleAll}
className="rounded border-gray-300 dark:border-gray-600" />
</th>
<th className="px-2 py-1.5 text-left text-gray-500 dark:text-gray-400 font-medium text-xs">Zeile</th>
<th className="px-2 py-1.5 text-left text-gray-500 dark:text-gray-400 font-medium text-xs">Feld</th>
<th className="px-2 py-1.5 text-left text-gray-500 dark:text-gray-400 font-medium text-xs">Vorher</th>
<th className="px-2 py-1.5 text-left text-gray-500 dark:text-gray-400 font-medium text-xs">Nachher</th>
</tr>
</thead>
<tbody>
{changes.map((change, idx) => (
<tr key={idx} className={`border-b border-gray-100 dark:border-gray-700/50 ${
accepted.has(idx) ? 'bg-teal-50/50 dark:bg-teal-900/10' : ''
}`}>
<td className="px-3 py-1.5 text-center">
<input type="checkbox" checked={accepted.has(idx)} onChange={() => toggleChange(idx)}
className="rounded border-gray-300 dark:border-gray-600" />
</td>
<td className="px-2 py-1.5 text-gray-500 dark:text-gray-400 font-mono text-xs">R{change.row_index}</td>
<td className="px-2 py-1.5">
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
{FIELD_LABELS[change.field] || change.field}
</span>
</td>
<td className="px-2 py-1.5"><span className="line-through text-red-500 dark:text-red-400 text-xs">{change.old}</span></td>
<td className="px-2 py-1.5"><span className="text-green-600 dark:text-green-400 font-medium text-xs">{change.new}</span></td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Actions */}
<div className="flex items-center justify-between pt-2">
<p className="text-xs text-gray-400">
{changes.length > 0 ? `${accepted.size} von ${changes.length} ausgewaehlt` : ''}
</p>
<div className="flex gap-3">
{changes.length > 0 && (
<button onClick={onNext}
className="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors text-gray-600 dark:text-gray-400">
Alle ablehnen
</button>
)}
{changes.length > 0 ? (
<button onClick={applyChanges} disabled={applying || accepted.size === 0}
className="px-5 py-2 text-sm bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium">
{applying ? 'Wird uebernommen...' : `${accepted.size} Korrektur${accepted.size !== 1 ? 'en' : ''} uebernehmen`}
</button>
) : (
<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>
</div>
</div>
)}
</div>
)
}
/** Cell content with inline diff for corrections */
function CellContent({ text, field, rowChanges }: {
text: string
field: string
rowChanges?: LlmChange[]
}) {
const change = rowChanges?.find(c => c.field === field)
if (!text && !change) {
return <span className="text-gray-300 dark:text-gray-600">&mdash;</span>
}
if (change) {
return (
<span>
<span className="line-through text-red-400 dark:text-red-500 text-xs mr-1">{change.old}</span>
<span className="text-green-600 dark:text-green-400 font-medium text-xs">{change.new}</span>
</span>
)
}
return <span className="text-gray-700 dark:text-gray-300 text-xs">{text}</span>
}
/** Status icon for each row */
function StatusIcon({ status }: { status: RowStatus }) {
switch (status) {
case 'pending':
return <span className="text-gray-300 dark:text-gray-600 text-xs"></span>
case 'active':
return (
<span className="inline-block w-3 h-3 rounded-full bg-yellow-400 animate-pulse" title="Wird geprueft" />
)
case 'reviewed':
return (
<svg className="w-4 h-4 text-green-500 inline-block" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)
case 'corrected':
return (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-teal-100 dark:bg-teal-900/30 text-teal-700 dark:text-teal-400">
korr.
</span>
)
case 'skipped':
return (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">
skip
</span>
)
}
}