Files
breakpilot-lehrer/admin-lehrer/components/ocr-pipeline/StepReconstruction.tsx
Benjamin Admin b681ddb131 [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>
2026-04-24 17:28:57 +02:00

358 lines
13 KiB
TypeScript

'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import dynamic from 'next/dynamic'
import { usePixelWordPositions } from './usePixelWordPositions'
import type { EditableCell, UndoAction, StepReconstructionProps } from './StepReconstructionTypes'
import { KLAUSUR_API } from './StepReconstructionTypes'
import { useReconstructionData } from './useReconstructionData'
import { ReconstructionToolbar } from './ReconstructionToolbar'
import { ReconstructionOverlay } from './ReconstructionOverlay'
import { ReconstructionSimpleView } from './ReconstructionSimpleView'
// Lazy-load Fabric.js canvas editor (SSR-incompatible)
const FabricReconstructionCanvas = dynamic(
() => import('./FabricReconstructionCanvas').then(m => ({ default: m.FabricReconstructionCanvas })),
{ ssr: false, loading: () => <div className="py-8 text-center text-sm text-gray-400">Editor wird geladen...</div> }
)
export function StepReconstruction({ sessionId, onNext }: StepReconstructionProps) {
const [editedTexts, setEditedTexts] = useState<Map<string, string>>(new Map())
const [zoom, setZoom] = useState(100)
const [imageNaturalH, setImageNaturalH] = useState(0)
const [showEmptyHighlight, setShowEmptyHighlight] = useState(true)
const [fontScale, setFontScale] = useState(0.7)
const [globalBold, setGlobalBold] = useState(false)
const [showStructure, setShowStructure] = useState(true)
const [undoStack, setUndoStack] = useState<UndoAction[]>([])
const [redoStack, setRedoStack] = useState<UndoAction[]>([])
const imageRef = useRef<HTMLImageElement>(null)
const resetEditing = useCallback(() => {
setEditedTexts(new Map())
setUndoStack([])
setRedoStack([])
}, [])
const data = useReconstructionData(sessionId, resetEditing)
// Pixel-based word positions for overlay mode
const overlayImageUrl = sessionId
? `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/cropped`
: ''
const cellWordPositions = usePixelWordPositions(
overlayImageUrl,
data.mergedGridCells,
data.editorMode === 'overlay',
data.imageRotation,
)
// Track image natural dimensions for font scaling and structure layer
const handleImageLoad = useCallback(() => {
if (imageRef.current) {
setImageNaturalH(imageRef.current.naturalHeight)
if (!data.imageNaturalSize) {
data.setImageNaturalSize({ w: imageRef.current.naturalWidth, h: imageRef.current.naturalHeight })
}
}
}, [data])
// --- Cell editing callbacks ---
const handleTextChange = useCallback((cellId: string, newText: string) => {
setEditedTexts(prev => {
const oldText = prev.get(cellId)
const cell = data.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
})
}, [data.cells])
const undo = useCallback(() => {
setUndoStack(stack => {
if (stack.length === 0) return stack
const action = stack[stack.length - 1]
setRedoStack(rs => [...rs, action])
setEditedTexts(prev => {
const next = new Map(prev)
next.set(action.cellId, action.oldText)
return next
})
return stack.slice(0, -1)
})
}, [])
const redo = useCallback(() => {
setRedoStack(stack => {
if (stack.length === 0) return stack
const action = stack[stack.length - 1]
setUndoStack(us => [...us, action])
setEditedTexts(prev => {
const next = new Map(prev)
next.set(action.cellId, action.newText)
return next
})
return stack.slice(0, -1)
})
}, [])
const resetCell = useCallback((cellId: string) => {
const cell = data.cells.find(c => c.cellId === cellId)
if (!cell) return
setEditedTexts(prev => { const next = new Map(prev); next.delete(cellId); return next })
}, [data.cells])
// Global keyboard shortcuts for undo/redo
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 data.cells) { if (isEdited(cell)) count++ }
return count
}, [data.cells, isEdited])
const emptyCellIds = useMemo(() => {
const required = new Set(['column_en', 'column_de'])
const ids = new Set<string>()
for (const cell of data.cells) {
if (required.has(cell.colType) && !cell.text.trim()) ids.add(cell.cellId)
}
return ids
}, [data.cells])
const sortedCellIds = useMemo(() => {
return [...data.cells]
.sort((a, b) => a.rowIndex !== b.rowIndex ? a.rowIndex - b.rowIndex : a.colIndex - b.colIndex)
.map(c => c.cellId)
}, [data.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) {
document.getElementById(`cell-${sortedCellIds[nextIdx]}`)?.focus()
}
}
}, [sortedCellIds])
const saveReconstruction = useCallback(async () => {
if (!sessionId) return
data.setStatus('saving')
try {
const cellUpdates = Array.from(editedTexts.entries())
.filter(([cellId, text]) => {
const cell = data.cells.find(c => c.cellId === cellId)
return cell && text !== cell.originalText
})
.map(([cellId, text]) => ({ cell_id: cellId, text }))
if (cellUpdates.length === 0) { data.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 d = await res.json().catch(() => ({}))
throw new Error(d.detail || `HTTP ${res.status}`)
}
data.setStatus('saved')
} catch (e: unknown) {
data.setError(e instanceof Error ? e.message : String(e))
data.setStatus('error')
}
}, [sessionId, editedTexts, data])
const handleFabricCellsChanged = useCallback((updates: { cell_id: string; text: string }[]) => {
for (const u of updates) {
setEditedTexts(prev => { const next = new Map(prev); next.set(u.cell_id, u.text); return next })
}
}, [])
const boxZonesPct = useMemo(() =>
data.parentZones
.filter(z => z.zone_type === 'box' && z.box)
.map(z => {
const imgH = data.imageNaturalSize?.h || 1
return {
topPct: (z.box!.y / imgH) * 100,
bottomPct: ((z.box!.y + z.box!.height) / imgH) * 100,
}
}),
[data.parentZones, data.imageNaturalSize]
)
const dewarpedUrl = sessionId
? `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/cropped`
: ''
const hasStructureElements = data.structureBoxes.length > 0 || data.structureGraphics.length > 0
// --- Status screens ---
if (!sessionId) {
return <div className="text-center py-12 text-gray-400">Bitte zuerst eine Session auswaehlen.</div>
}
if (data.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 (data.status === 'error') {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="text-5xl mb-4">&#x26A0;&#xFE0F;</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">{data.error}</p>
<div className="flex gap-3">
<button onClick={() => { data.setError(''); data.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 &rarr;
</button>
</div>
</div>
)
}
if (data.status === 'saved') {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="text-5xl mb-4">&#x2705;</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 &rarr;
</button>
</div>
)
}
// --- Main rendering ---
return (
<div className="space-y-3">
<ReconstructionToolbar
editorMode={data.editorMode}
setEditorMode={data.setEditorMode}
isParentWithBoxes={data.isParentWithBoxes}
cellCount={data.cells.length}
changedCount={changedCount}
emptyCellCount={emptyCellIds.size}
showEmptyHighlight={showEmptyHighlight}
setShowEmptyHighlight={setShowEmptyHighlight}
showStructure={showStructure}
setShowStructure={setShowStructure}
hasStructureElements={hasStructureElements}
zoom={zoom}
setZoom={setZoom}
undoCount={undoStack.length}
redoCount={redoStack.length}
onUndo={undo}
onRedo={redo}
status={data.status}
onSave={saveReconstruction}
fontScale={fontScale}
setFontScale={setFontScale}
globalBold={globalBold}
setGlobalBold={setGlobalBold}
imageRotation={data.imageRotation}
setImageRotation={data.setImageRotation}
/>
{data.editorMode === 'overlay' ? (
<ReconstructionOverlay
cells={data.cells}
dewarpedUrl={dewarpedUrl}
imageNaturalSize={data.imageNaturalSize}
parentColumns={data.parentColumns}
parentRows={data.parentRows}
parentZones={data.parentZones}
structureBoxes={data.structureBoxes}
structureGraphics={data.structureGraphics}
showStructure={showStructure}
fontScale={fontScale}
globalBold={globalBold}
boxZonesPct={boxZonesPct}
cellWordPositions={cellWordPositions}
onTextChange={handleTextChange}
onKeyDown={handleKeyDown}
onResetCell={resetCell}
onImageNaturalSize={data.setImageNaturalSize}
getDisplayText={getDisplayText}
isEdited={isEdited}
/>
) : data.editorMode === 'editor' && sessionId ? (
<FabricReconstructionCanvas
sessionId={sessionId}
cells={data.gridCells}
onCellsChanged={handleFabricCellsChanged}
/>
) : (
<ReconstructionSimpleView
cells={data.cells}
dewarpedUrl={dewarpedUrl}
zoom={zoom}
imageNaturalSize={data.imageNaturalSize}
imageNaturalH={imageNaturalH}
emptyCellIds={emptyCellIds}
showEmptyHighlight={showEmptyHighlight}
structureBoxes={data.structureBoxes}
structureGraphics={data.structureGraphics}
showStructure={showStructure}
onTextChange={handleTextChange}
onKeyDown={handleKeyDown}
onResetCell={resetCell}
onImageLoad={handleImageLoad}
getDisplayText={getDisplayText}
isEdited={isEdited}
imageRef={imageRef}
/>
)}
<div className="flex justify-end">
<button
onClick={() => { changedCount > 0 ? saveReconstruction() : 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 \u2192' : 'Weiter \u2192'}
</button>
</div>
</div>
)
}