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>
358 lines
13 KiB
TypeScript
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">⚠️</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 →
|
|
</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">✅</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>
|
|
)
|
|
}
|
|
|
|
// --- 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>
|
|
)
|
|
}
|