[split-required] Split 58 monoliths across Python, Go, TypeScript (Phases 1-3)

Phase 1 — Python (klausur-service): 5 monoliths → 36 files
- dsfa_corpus_ingestion.py (1,828 LOC → 5 files)
- cv_ocr_engines.py (2,102 LOC → 7 files)
- cv_layout.py (3,653 LOC → 10 files)
- vocab_worksheet_api.py (2,783 LOC → 8 files)
- grid_build_core.py (1,958 LOC → 6 files)

Phase 2 — Go (edu-search-service, school-service): 8 monoliths → 19 files
- staff_crawler.go (1,402 → 4), policy/store.go (1,168 → 3)
- policy_handlers.go (700 → 2), repository.go (684 → 2)
- search.go (592 → 2), ai_extraction_handlers.go (554 → 2)
- seed_data.go (591 → 2), grade_service.go (646 → 2)

Phase 3 — TypeScript (admin-lehrer): 45 monoliths → 220+ files
- sdk/types.ts (2,108 → 16 domain files)
- ai/rag/page.tsx (2,686 → 14 files)
- 22 page.tsx files split into _components/ + _hooks/
- 11 component files split into sub-components
- 10 SDK data catalogs added to loc-exceptions
- Deleted dead backup index_original.ts (4,899 LOC)

All original public APIs preserved via re-export facades.
Zero new errors: Python imports verified, Go builds clean,
TypeScript tsc --noEmit shows only pre-existing errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-24 17:28:57 +02:00
parent 9ba420fa91
commit b681ddb131
251 changed files with 30016 additions and 25037 deletions

View File

@@ -0,0 +1,148 @@
'use client'
import type { EditableCell } from './overlay-reconstruction-types'
import type { WordPosition } from './usePixelWordPositions'
interface OverlayCellRendererProps {
cell: EditableCell
displayText: string
edited: boolean
wordPositions: WordPosition[] | undefined
medianCellHeightPx: number
fontScale: number
globalBold: boolean
colorValue: string
handleTextChange: (cellId: string, newText: string) => void
handleKeyDown: (e: React.KeyboardEvent, cellId: string) => void
resetCell: (cellId: string) => void
}
/** Renders a single cell in the overlay -- either as pixel-positioned word spans or a fallback input. */
export function OverlayCellRenderer({
cell,
displayText,
edited,
wordPositions,
medianCellHeightPx,
fontScale,
globalBold,
colorValue,
handleTextChange,
handleKeyDown,
resetCell,
}: OverlayCellRendererProps) {
const bboxPct = cell.bboxPct
const fontFamily = "'Liberation Sans', Arial, sans-serif"
// Pixel-analysed: render word-groups at detected positions
if (wordPositions && wordPositions.length > 0) {
return (
<>
{wordPositions.map((wp, i) => {
const autoFontPx = medianCellHeightPx * wp.fontRatio * fontScale
const fs = Math.max(6, autoFontPx)
if (wordPositions.length > 1) {
return (
<span
key={`${cell.cellId}_wp_${i}`}
className="absolute leading-none pointer-events-none select-none"
style={{
left: `${wp.xPct}%`,
top: `${wp.yPct}%`,
width: `${wp.wPct}%`,
height: `${wp.hPct}%`,
fontSize: `${fs}px`,
fontWeight: globalBold ? 'bold' : 'normal',
fontFamily,
display: 'flex',
alignItems: 'center',
whiteSpace: 'nowrap',
overflow: 'visible',
color: colorValue,
}}
>
{wp.text}
</span>
)
}
return (
<div key={`${cell.cellId}_wp_${i}`} className="absolute group" style={{
left: `${wp.xPct}%`,
top: `${wp.yPct}%`,
width: `${wp.wPct}%`,
height: `${wp.hPct}%`,
}}>
<input
id={`cell-${cell.cellId}`}
type="text"
value={displayText}
onChange={(e) => handleTextChange(cell.cellId, e.target.value)}
onKeyDown={(e) => handleKeyDown(e, cell.cellId)}
className={`w-full h-full bg-transparent border-0 outline-none px-0 transition-colors ${
edited ? 'bg-green-50/30' : ''
}`}
style={{
fontSize: `${fs}px`,
fontWeight: globalBold ? 'bold' : 'normal',
fontFamily,
lineHeight: '1',
color: colorValue,
}}
title={`${cell.cellId} (${cell.colType})`}
/>
<ResetButton edited={edited} cellId={cell.cellId} resetCell={resetCell} />
</div>
)
})}
</>
)
}
// Fallback: no pixel data -- single input at cell bbox
if (!cell.text) return null
const fontSize = Math.max(6, medianCellHeightPx * fontScale)
return (
<div key={cell.cellId} className="absolute group" style={{
left: `${bboxPct.x}%`,
top: `${bboxPct.y}%`,
width: `${bboxPct.w}%`,
height: `${bboxPct.h}%`,
}}>
<input
id={`cell-${cell.cellId}`}
type="text"
value={displayText}
onChange={(e) => handleTextChange(cell.cellId, e.target.value)}
onKeyDown={(e) => handleKeyDown(e, cell.cellId)}
className={`w-full h-full bg-transparent border-0 outline-none px-0 transition-colors ${
edited ? 'bg-green-50/30' : ''
}`}
style={{
fontSize: `${fontSize}px`,
fontWeight: globalBold ? 'bold' : 'normal',
fontFamily,
lineHeight: '1',
color: colorValue,
}}
title={`${cell.cellId} (${cell.colType})`}
/>
<ResetButton edited={edited} cellId={cell.cellId} resetCell={resetCell} />
</div>
)
}
function ResetButton({ edited, cellId, resetCell }: { edited: boolean; cellId: string; resetCell: (id: string) => void }) {
if (!edited) return null
return (
<button
onClick={() => resetCell(cellId)}
className="absolute -top-1 -right-1 w-4 h-4 bg-red-500 text-white rounded-full text-[9px] leading-none opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
title="Zuruecksetzen"
>
&times;
</button>
)
}

View File

@@ -1,297 +1,38 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { GridResult, GridCell, RowResult, RowItem } from '@/app/(admin)/ai/ocr-overlay/types'
import { usePixelWordPositions } from './usePixelWordPositions'
import { useSlideWordPositions } from './useSlideWordPositions'
const KLAUSUR_API = '/klausur-api'
interface OverlayReconstructionProps {
sessionId: string | null
onNext: () => void
/** When set, use this data directly instead of fetching from the session API. */
wordResultOverride?: { cells: GridCell[]; image_width: number; image_height: number; [key: string]: unknown }
}
interface EditableCell {
cellId: string
text: string
originalText: string
bboxPct: { x: number; y: number; w: number; h: number }
colType: string
rowIndex: number
colIndex: number
}
type UndoAction = { cellId: string; oldText: string; newText: string }
import type { OverlayReconstructionProps } from './overlay-reconstruction-types'
import { KLAUSUR_API } from './overlay-reconstruction-types'
import { useOverlayReconstructionState } from './useOverlayReconstructionState'
import { OverlayToolbar } from './OverlayToolbar'
import { OverlayCellRenderer } from './OverlayCellRenderer'
export function OverlayReconstruction({ sessionId, onNext, wordResultOverride }: OverlayReconstructionProps) {
const [status, setStatus] = useState<'loading' | 'ready' | 'saving' | 'saved' | 'error'>('loading')
const [error, setError] = useState('')
const [cells, setCells] = useState<EditableCell[]>([])
const [gridCells, setGridCells] = useState<GridCell[]>([])
const [editedTexts, setEditedTexts] = useState<Map<string, string>>(new Map())
const state = useOverlayReconstructionState(sessionId, wordResultOverride)
// Undo/Redo
const [undoStack, setUndoStack] = useState<UndoAction[]>([])
const [redoStack, setRedoStack] = useState<UndoAction[]>([])
// Overlay state
const [rows, setRows] = useState<RowItem[]>([])
const [imageNaturalSize, setImageNaturalSize] = useState<{ w: number; h: number } | null>(null)
const [fontScale, setFontScale] = useState(0.7)
const [globalBold, setGlobalBold] = useState(false)
const [imageRotation, setImageRotation] = useState<0 | 180>(0)
const [textOpacity, setTextOpacity] = useState(100)
const [textColor, setTextColor] = useState<'red' | 'blue' | 'black'>('red')
const [positioningMode, setPositioningMode] = useState<'cluster' | 'slide'>('slide')
const reconRef = useRef<HTMLDivElement>(null)
const [reconWidth, setReconWidth] = useState(0)
// Pixel-based word positions (both algorithms run, toggle selects which to use)
const overlayImageUrl = sessionId
? `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/cropped`
: ''
const clusterPositions = usePixelWordPositions(
overlayImageUrl,
gridCells,
status === 'ready',
imageRotation,
)
const slidePositions = useSlideWordPositions(
overlayImageUrl,
gridCells,
status === 'ready',
imageRotation,
)
const cellWordPositions = positioningMode === 'slide' ? slidePositions : clusterPositions
// Track container width
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()
}, [status])
// Load session data
useEffect(() => {
if (wordResultOverride) {
applyWordResult(wordResultOverride)
return
}
if (!sessionId) return
loadSessionData()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, wordResultOverride])
const applyWordResult = (wordResult: { cells: GridCell[]; image_width: number; image_height: number; [key: string]: unknown }) => {
const rawGridCells: GridCell[] = wordResult.cells || []
setGridCells(rawGridCells)
const editableCells: EditableCell[] = rawGridCells.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)
setEditedTexts(new Map())
setUndoStack([])
setRedoStack([])
if (wordResult.image_width && wordResult.image_height) {
setImageNaturalSize({ w: wordResult.image_width, h: wordResult.image_height })
}
setStatus('ready')
}
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 den Woerter-Schritt abschliessen.')
setStatus('error')
return
}
applyWordResult(wordResult as unknown as { cells: GridCell[]; image_width: number; image_height: number })
// Load rows
const rowResult: RowResult | undefined = data.row_result
if (rowResult?.rows) setRows(rowResult.rows)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e))
setStatus('error')
}
}
const handleTextChange = useCallback((cellId: string, newText: string) => {
setEditedTexts(prev => {
const oldText = prev.get(cellId)
const cell = cells.find(c => c.cellId === cellId)
const prevText = oldText ?? cell?.text ?? ''
setUndoStack(stack => [...stack, { cellId, oldText: prevText, newText }])
setRedoStack([])
const next = new Map(prev)
next.set(cellId, newText)
return next
})
}, [cells])
const undo = useCallback(() => {
setUndoStack(stack => {
if (stack.length === 0) return stack
const action = stack[stack.length - 1]
const newStack = stack.slice(0, -1)
setRedoStack(rs => [...rs, action])
setEditedTexts(prev => {
const next = new Map(prev)
next.set(action.cellId, action.oldText)
return next
})
return newStack
})
}, [])
const redo = useCallback(() => {
setRedoStack(stack => {
if (stack.length === 0) return stack
const action = stack[stack.length - 1]
const newStack = stack.slice(0, -1)
setUndoStack(us => [...us, action])
setEditedTexts(prev => {
const next = new Map(prev)
next.set(action.cellId, action.newText)
return next
})
return newStack
})
}, [])
const resetCell = useCallback((cellId: string) => {
setEditedTexts(prev => {
const next = new Map(prev)
next.delete(cellId)
return next
})
}, [])
// Keyboard shortcuts
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'z') {
e.preventDefault()
if (e.shiftKey) redo()
else undo()
}
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [undo, redo])
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])
// Tab navigation
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) {
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/cropped`
: ''
// Compute median cell height (in px) for consistent font sizing
// Must be before early returns (Rules of Hooks)
const medianCellHeightPx = useMemo(() => {
const imgWVal = imageNaturalSize?.w || 1
const imgHVal = imageNaturalSize?.h || 1
const cH = reconWidth * (imgHVal / imgWVal)
if (cells.length === 0 || cH === 0) return 40
const heights = cells.map(c => cH * (c.bboxPct.h / 100)).sort((a, b) => a - b)
const mid = Math.floor(heights.length / 2)
return heights.length % 2 === 0 ? (heights[mid - 1] + heights[mid]) / 2 : heights[mid]
}, [cells, reconWidth, imageNaturalSize])
const {
status,
error,
cells,
rows,
imageNaturalSize,
textOpacity,
textColor,
changedCount,
medianCellHeightPx,
fontScale,
globalBold,
cellWordPositions,
reconRef,
handleTextChange,
handleKeyDown,
getDisplayText,
isEdited,
resetCell,
saveReconstruction,
loadSessionData,
setError,
setImageNaturalSize,
} = state
if (!sessionId) {
return <div className="text-center py-12 text-gray-400">Bitte zuerst eine Session auswaehlen.</div>
@@ -344,129 +85,15 @@ export function OverlayReconstruction({ sessionId, onNext, wordResultOverride }:
const imgW = imageNaturalSize?.w || 1
const imgH = imageNaturalSize?.h || 1
const containerH = reconWidth * (imgH / imgW)
const colorValue = textColor === 'black' ? '#1a1a1a' : textColor
const dewarpedUrl = sessionId
? `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/cropped`
: ''
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">
Overlay-Rekonstruktion
</h3>
<span className="text-xs text-gray-400">
{cells.length} Zellen &middot; {changedCount} geaendert
</span>
</div>
<div className="flex items-center gap-2">
{/* Undo/Redo */}
<button
onClick={undo}
disabled={undoStack.length === 0}
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 disabled:opacity-30"
title="Rueckgaengig (Ctrl+Z)"
>
&#x21A9;
</button>
<button
onClick={redo}
disabled={redoStack.length === 0}
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 disabled:opacity-30"
title="Wiederholen (Ctrl+Shift+Z)"
>
&#x21AA;
</button>
<div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1" />
{/* Font scale */}
<label className="flex items-center gap-1 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-20 h-1 accent-teal-600"
/>
<span className="w-8 text-right font-mono">{Math.round(fontScale * 100)}%</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>
<button
onClick={() => setImageRotation(r => r === 0 ? 180 : 0)}
className={`px-2 py-1 text-xs rounded border transition-colors ${
imageRotation === 180
? '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'
}`}
title="Bild 180° drehen"
>
180°
</button>
<div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1" />
{/* Positioning mode toggle */}
<button
onClick={() => setPositioningMode(m => m === 'slide' ? 'cluster' : 'slide')}
className={`px-2 py-1 text-xs rounded border transition-colors ${
positioningMode === 'slide'
? 'bg-orange-500 text-white border-orange-500'
: 'bg-white dark:bg-gray-700 text-gray-600 dark:text-gray-400 border-gray-300 dark:border-gray-600'
}`}
title={positioningMode === 'slide'
? 'Slide-Modus: Woerter von links nach rechts schieben (klick fuer Cluster-Modus)'
: 'Cluster-Modus: Woerter an Pixel-Cluster zuordnen (klick fuer Slide-Modus)'}
>
{positioningMode === 'slide' ? 'Slide' : 'Cluster'}
</button>
<div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1" />
{/* Text color */}
{(['red', 'blue', 'black'] as const).map(c => (
<button
key={c}
onClick={() => setTextColor(c)}
className={`w-5 h-5 rounded-full border-2 transition-colors ${
textColor === c ? 'border-teal-500 ring-1 ring-teal-300' : 'border-gray-300 dark:border-gray-600'
}`}
style={{ backgroundColor: c === 'black' ? '#1a1a1a' : c }}
title={`Textfarbe: ${c}`}
/>
))}
<div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1" />
{/* Text opacity */}
<label className="flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400">
Text
<input
type="range" min={0} max={100} value={textOpacity}
onChange={e => setTextOpacity(Number(e.target.value))}
className="w-16 h-1 accent-teal-600"
/>
<span className="w-8 text-right font-mono">{textOpacity}%</span>
</label>
<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"
>
Speichern
</button>
</div>
</div>
<OverlayToolbar state={state} />
{/* True overlay: text layer on top of original image */}
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-gray-50 dark:bg-gray-900">
@@ -501,125 +128,23 @@ export function OverlayReconstruction({ sessionId, onNext, wordResultOverride }:
/>
))}
{/* Pixel-positioned words / editable inputs */}
{cells.map((cell) => {
const displayText = getDisplayText(cell)
const edited = isEdited(cell)
const wordPos = cellWordPositions.get(cell.cellId)
const bboxPct = cell.bboxPct
const colorValue = textColor === 'black' ? '#1a1a1a' : textColor
// Pixel-analysed: render word-groups at detected positions
if (wordPos && wordPos.length > 0) {
return wordPos.map((wp, i) => {
const autoFontPx = medianCellHeightPx * wp.fontRatio * fontScale
const fs = Math.max(6, autoFontPx)
if (wordPos.length > 1) {
return (
<span
key={`${cell.cellId}_wp_${i}`}
className="absolute leading-none pointer-events-none select-none"
style={{
left: `${wp.xPct}%`,
top: `${wp.yPct}%`,
width: `${wp.wPct}%`,
height: `${wp.hPct}%`,
fontSize: `${fs}px`,
fontWeight: globalBold ? 'bold' : 'normal',
fontFamily: "'Liberation Sans', Arial, sans-serif",
display: 'flex',
alignItems: 'center',
whiteSpace: 'nowrap',
overflow: 'visible',
color: colorValue,
}}
>
{wp.text}
</span>
)
}
return (
<div key={`${cell.cellId}_wp_${i}`} className="absolute group" style={{
left: `${wp.xPct}%`,
top: `${wp.yPct}%`,
width: `${wp.wPct}%`,
height: `${wp.hPct}%`,
}}>
<input
id={`cell-${cell.cellId}`}
type="text"
value={displayText}
onChange={(e) => handleTextChange(cell.cellId, e.target.value)}
onKeyDown={(e) => handleKeyDown(e, cell.cellId)}
className={`w-full h-full bg-transparent border-0 outline-none px-0 transition-colors ${
edited ? 'bg-green-50/30' : ''
}`}
style={{
fontSize: `${fs}px`,
fontWeight: globalBold ? 'bold' : 'normal',
fontFamily: "'Liberation Sans', Arial, sans-serif",
lineHeight: '1',
color: colorValue,
}}
title={`${cell.cellId} (${cell.colType})`}
/>
{edited && (
<button
onClick={() => resetCell(cell.cellId)}
className="absolute -top-1 -right-1 w-4 h-4 bg-red-500 text-white rounded-full text-[9px] leading-none opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
title="Zuruecksetzen"
>
&times;
</button>
)}
</div>
)
})
}
// Fallback: no pixel data — single input at cell bbox
if (!cell.text) return null
const fontSize = Math.max(6, medianCellHeightPx * fontScale)
return (
<div key={cell.cellId} className="absolute group" style={{
left: `${bboxPct.x}%`,
top: `${bboxPct.y}%`,
width: `${bboxPct.w}%`,
height: `${bboxPct.h}%`,
}}>
<input
id={`cell-${cell.cellId}`}
type="text"
value={displayText}
onChange={(e) => handleTextChange(cell.cellId, e.target.value)}
onKeyDown={(e) => handleKeyDown(e, cell.cellId)}
className={`w-full h-full bg-transparent border-0 outline-none px-0 transition-colors ${
edited ? 'bg-green-50/30' : ''
}`}
style={{
fontSize: `${fontSize}px`,
fontWeight: globalBold ? 'bold' : 'normal',
fontFamily: "'Liberation Sans', Arial, sans-serif",
lineHeight: '1',
color: colorValue,
}}
title={`${cell.cellId} (${cell.colType})`}
/>
{edited && (
<button
onClick={() => resetCell(cell.cellId)}
className="absolute -top-1 -right-1 w-4 h-4 bg-red-500 text-white rounded-full text-[9px] leading-none opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
title="Zuruecksetzen"
>
&times;
</button>
)}
</div>
)
})}
{/* Cells */}
{cells.map((cell) => (
<OverlayCellRenderer
key={cell.cellId}
cell={cell}
displayText={getDisplayText(cell)}
edited={isEdited(cell)}
wordPositions={cellWordPositions.get(cell.cellId)}
medianCellHeightPx={medianCellHeightPx}
fontScale={fontScale}
globalBold={globalBold}
colorValue={colorValue}
handleTextChange={handleTextChange}
handleKeyDown={handleKeyDown}
resetCell={resetCell}
/>
))}
</div>
</div>
</div>

View File

@@ -0,0 +1,153 @@
'use client'
import type { OverlayReconstructionState, TextColor, PositioningMode } from './overlay-reconstruction-types'
interface OverlayToolbarProps {
state: OverlayReconstructionState
}
export function OverlayToolbar({ state }: OverlayToolbarProps) {
const {
status,
cells,
changedCount,
undoStack,
redoStack,
fontScale,
globalBold,
imageRotation,
textOpacity,
textColor,
positioningMode,
undo,
redo,
setFontScale,
setGlobalBold,
setImageRotation,
setTextOpacity,
setTextColor,
setPositioningMode,
saveReconstruction,
} = state
return (
<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">
Overlay-Rekonstruktion
</h3>
<span className="text-xs text-gray-400">
{cells.length} Zellen &middot; {changedCount} geaendert
</span>
</div>
<div className="flex items-center gap-2">
{/* Undo/Redo */}
<button
onClick={undo}
disabled={undoStack.length === 0}
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 disabled:opacity-30"
title="Rueckgaengig (Ctrl+Z)"
>
&#x21A9;
</button>
<button
onClick={redo}
disabled={redoStack.length === 0}
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 disabled:opacity-30"
title="Wiederholen (Ctrl+Shift+Z)"
>
&#x21AA;
</button>
<div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1" />
{/* Font scale */}
<label className="flex items-center gap-1 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-20 h-1 accent-teal-600"
/>
<span className="w-8 text-right font-mono">{Math.round(fontScale * 100)}%</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>
<button
onClick={() => setImageRotation(r => r === 0 ? 180 : 0)}
className={`px-2 py-1 text-xs rounded border transition-colors ${
imageRotation === 180
? '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'
}`}
title="Bild 180° drehen"
>
180°
</button>
<div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1" />
{/* Positioning mode toggle */}
<button
onClick={() => setPositioningMode((m: PositioningMode) => m === 'slide' ? 'cluster' : 'slide')}
className={`px-2 py-1 text-xs rounded border transition-colors ${
positioningMode === 'slide'
? 'bg-orange-500 text-white border-orange-500'
: 'bg-white dark:bg-gray-700 text-gray-600 dark:text-gray-400 border-gray-300 dark:border-gray-600'
}`}
title={positioningMode === 'slide'
? 'Slide-Modus: Woerter von links nach rechts schieben (klick fuer Cluster-Modus)'
: 'Cluster-Modus: Woerter an Pixel-Cluster zuordnen (klick fuer Slide-Modus)'}
>
{positioningMode === 'slide' ? 'Slide' : 'Cluster'}
</button>
<div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1" />
{/* Text color */}
{(['red', 'blue', 'black'] as const).map((c: TextColor) => (
<button
key={c}
onClick={() => setTextColor(c)}
className={`w-5 h-5 rounded-full border-2 transition-colors ${
textColor === c ? 'border-teal-500 ring-1 ring-teal-300' : 'border-gray-300 dark:border-gray-600'
}`}
style={{ backgroundColor: c === 'black' ? '#1a1a1a' : c }}
title={`Textfarbe: ${c}`}
/>
))}
<div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1" />
{/* Text opacity */}
<label className="flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400">
Text
<input
type="range" min={0} max={100} value={textOpacity}
onChange={e => setTextOpacity(Number(e.target.value))}
className="w-16 h-1 accent-teal-600"
/>
<span className="w-8 text-right font-mono">{textOpacity}%</span>
</label>
<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"
>
Speichern
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,102 @@
import type { WordPosition } from './usePixelWordPositions'
export const KLAUSUR_API = '/klausur-api'
export interface OverlayReconstructionProps {
sessionId: string | null
onNext: () => void
/** When set, use this data directly instead of fetching from the session API. */
wordResultOverride?: WordResultData
}
export interface WordResultData {
cells: GridCellCompat[]
image_width: number
image_height: number
[key: string]: unknown
}
/** Minimal GridCell shape used by overlay reconstruction. */
export interface GridCellCompat {
cell_id: string
text: string
row_index: number
col_index: number
col_type: string
bbox_pct: { x: number; y: number; w: number; h: number }
confidence?: number
bbox_px?: { x: number; y: number; w: number; h: number }
ocr_engine?: string
is_bold?: boolean
status?: 'pending' | 'confirmed' | 'edited' | 'skipped'
word_boxes?: unknown[]
}
export interface EditableCell {
cellId: string
text: string
originalText: string
bboxPct: { x: number; y: number; w: number; h: number }
colType: string
rowIndex: number
colIndex: number
}
export type UndoAction = { cellId: string; oldText: string; newText: string }
export interface RowItemCompat {
y: number
}
export interface RowResultCompat {
rows?: RowItemCompat[]
}
export type OverlayStatus = 'loading' | 'ready' | 'saving' | 'saved' | 'error'
export type TextColor = 'red' | 'blue' | 'black'
export type PositioningMode = 'cluster' | 'slide'
export interface OverlayReconstructionState {
status: OverlayStatus
error: string
cells: EditableCell[]
gridCells: GridCellCompat[]
editedTexts: Map<string, string>
undoStack: UndoAction[]
redoStack: UndoAction[]
rows: RowItemCompat[]
imageNaturalSize: { w: number; h: number } | null
fontScale: number
globalBold: boolean
imageRotation: 0 | 180
textOpacity: number
textColor: TextColor
positioningMode: PositioningMode
changedCount: number
sortedCellIds: string[]
medianCellHeightPx: number
cellWordPositions: Map<string, WordPosition[]>
reconRef: React.RefObject<HTMLDivElement | null>
reconWidth: number
// Actions
setFontScale: (v: number) => void
setGlobalBold: (fn: (b: boolean) => boolean) => void
setImageRotation: (fn: (r: 0 | 180) => 0 | 180) => void
setTextOpacity: (v: number) => void
setTextColor: (c: TextColor) => void
setPositioningMode: (fn: (m: PositioningMode) => PositioningMode) => void
handleTextChange: (cellId: string, newText: string) => void
undo: () => void
redo: () => void
resetCell: (cellId: string) => void
handleKeyDown: (e: React.KeyboardEvent, cellId: string) => void
getDisplayText: (cell: EditableCell) => string
isEdited: (cell: EditableCell) => boolean
saveReconstruction: () => Promise<void>
loadSessionData: () => Promise<void>
setError: (msg: string) => void
setImageNaturalSize: (size: { w: number; h: number }) => void
}

View File

@@ -0,0 +1,328 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { usePixelWordPositions } from './usePixelWordPositions'
import { useSlideWordPositions } from './useSlideWordPositions'
import type {
EditableCell,
GridCellCompat,
OverlayReconstructionState,
OverlayStatus,
PositioningMode,
RowItemCompat,
RowResultCompat,
TextColor,
UndoAction,
WordResultData,
} from './overlay-reconstruction-types'
import { KLAUSUR_API } from './overlay-reconstruction-types'
/**
* All state and logic for OverlayReconstruction, extracted as a custom hook.
*/
export function useOverlayReconstructionState(
sessionId: string | null,
wordResultOverride?: WordResultData,
): OverlayReconstructionState {
const [status, setStatus] = useState<OverlayStatus>('loading')
const [error, setError] = useState('')
const [cells, setCells] = useState<EditableCell[]>([])
const [gridCells, setGridCells] = useState<GridCellCompat[]>([])
const [editedTexts, setEditedTexts] = useState<Map<string, string>>(new Map())
// Undo/Redo
const [undoStack, setUndoStack] = useState<UndoAction[]>([])
const [redoStack, setRedoStack] = useState<UndoAction[]>([])
// Overlay state
const [rows, setRows] = useState<RowItemCompat[]>([])
const [imageNaturalSize, setImageNaturalSize] = useState<{ w: number; h: number } | null>(null)
const [fontScale, setFontScale] = useState(0.7)
const [globalBold, setGlobalBold] = useState(false)
const [imageRotation, setImageRotation] = useState<0 | 180>(0)
const [textOpacity, setTextOpacity] = useState(100)
const [textColor, setTextColor] = useState<TextColor>('red')
const [positioningMode, setPositioningMode] = useState<PositioningMode>('slide')
const reconRef = useRef<HTMLDivElement>(null)
const [reconWidth, setReconWidth] = useState(0)
// Pixel-based word positions
const overlayImageUrl = sessionId
? `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/cropped`
: ''
const clusterPositions = usePixelWordPositions(
overlayImageUrl,
gridCells as never[],
status === 'ready',
imageRotation,
)
const slidePositions = useSlideWordPositions(
overlayImageUrl,
gridCells as never[],
status === 'ready',
imageRotation,
)
const cellWordPositions = positioningMode === 'slide' ? slidePositions : clusterPositions
// Track container width
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()
}, [status])
const applyWordResult = (wordResult: WordResultData) => {
const rawGridCells: GridCellCompat[] = wordResult.cells || []
setGridCells(rawGridCells)
const editableCells: EditableCell[] = rawGridCells.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)
setEditedTexts(new Map())
setUndoStack([])
setRedoStack([])
if (wordResult.image_width && wordResult.image_height) {
setImageNaturalSize({ w: wordResult.image_width, h: wordResult.image_height })
}
setStatus('ready')
}
const loadSessionData = useCallback(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 = data.word_result
if (!wordResult) {
setError('Keine Worterkennungsdaten gefunden. Bitte zuerst den Woerter-Schritt abschliessen.')
setStatus('error')
return
}
applyWordResult(wordResult as WordResultData)
const rowResult: RowResultCompat | undefined = data.row_result
if (rowResult?.rows) setRows(rowResult.rows)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e))
setStatus('error')
}
}, [sessionId])
// Load session data
useEffect(() => {
if (wordResultOverride) {
applyWordResult(wordResultOverride)
return
}
if (!sessionId) return
loadSessionData()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, wordResultOverride])
const handleTextChange = useCallback((cellId: string, newText: string) => {
setEditedTexts(prev => {
const oldText = prev.get(cellId)
const cell = cells.find(c => c.cellId === cellId)
const prevText = oldText ?? cell?.text ?? ''
setUndoStack(stack => [...stack, { cellId, oldText: prevText, newText }])
setRedoStack([])
const next = new Map(prev)
next.set(cellId, newText)
return next
})
}, [cells])
const undo = useCallback(() => {
setUndoStack(stack => {
if (stack.length === 0) return stack
const action = stack[stack.length - 1]
const newStack = stack.slice(0, -1)
setRedoStack(rs => [...rs, action])
setEditedTexts(prev => {
const next = new Map(prev)
next.set(action.cellId, action.oldText)
return next
})
return newStack
})
}, [])
const redo = useCallback(() => {
setRedoStack(stack => {
if (stack.length === 0) return stack
const action = stack[stack.length - 1]
const newStack = stack.slice(0, -1)
setUndoStack(us => [...us, action])
setEditedTexts(prev => {
const next = new Map(prev)
next.set(action.cellId, action.newText)
return next
})
return newStack
})
}, [])
const resetCell = useCallback((cellId: string) => {
setEditedTexts(prev => {
const next = new Map(prev)
next.delete(cellId)
return next
})
}, [])
// Keyboard shortcuts
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'z') {
e.preventDefault()
if (e.shiftKey) redo()
else undo()
}
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [undo, redo])
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])
// Tab navigation
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) {
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])
// Median cell height for consistent font sizing (must be before early returns)
const medianCellHeightPx = useMemo(() => {
const imgWVal = imageNaturalSize?.w || 1
const imgHVal = imageNaturalSize?.h || 1
const cH = reconWidth * (imgHVal / imgWVal)
if (cells.length === 0 || cH === 0) return 40
const heights = cells.map(c => cH * (c.bboxPct.h / 100)).sort((a, b) => a - b)
const mid = Math.floor(heights.length / 2)
return heights.length % 2 === 0 ? (heights[mid - 1] + heights[mid]) / 2 : heights[mid]
}, [cells, reconWidth, imageNaturalSize])
return {
status,
error,
cells,
gridCells,
editedTexts,
undoStack,
redoStack,
rows,
imageNaturalSize,
fontScale,
globalBold,
imageRotation,
textOpacity,
textColor,
positioningMode,
changedCount,
sortedCellIds,
medianCellHeightPx,
cellWordPositions,
reconRef,
reconWidth,
setFontScale,
setGlobalBold,
setImageRotation,
setTextOpacity,
setTextColor,
setPositioningMode,
handleTextChange,
undo,
redo,
resetCell,
handleKeyDown,
getDisplayText,
isEdited,
saveReconstruction,
loadSessionData,
setError,
setImageNaturalSize,
}
}