Files
breakpilot-lehrer/admin-lehrer/components/grid-editor/GridEditor.tsx
Benjamin Admin e019dde01b
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 27s
CI / test-go-edu-search (push) Successful in 36s
CI / test-python-klausur (push) Failing after 2m9s
CI / test-python-agent-core (push) Successful in 17s
CI / test-nodejs-website (push) Successful in 21s
Extract page number as metadata instead of silently removing it
_filter_footer_words now returns page number info (text, y_pct, number)
instead of just removing footer words. The page number is included in
the grid result as `page_number` and displayed in the frontend summary
bar as "S. 233".

This preserves page numbers for later page concatenation in the
customer frontend while still removing them from the grid content.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 08:52:09 +01:00

305 lines
9.6 KiB
TypeScript

'use client'
import { useCallback, useEffect, useState } from 'react'
import { useGridEditor } from './useGridEditor'
import type { GridZone } from './types'
import { GridToolbar } from './GridToolbar'
import { GridTable } from './GridTable'
import { GridImageOverlay } from './GridImageOverlay'
interface GridEditorProps {
sessionId: string | null
onNext?: () => void
}
export function GridEditor({ sessionId, onNext }: GridEditorProps) {
const {
grid,
loading,
saving,
error,
dirty,
selectedCell,
setSelectedCell,
buildGrid,
loadGrid,
saveGrid,
updateCellText,
toggleColumnBold,
toggleRowHeader,
undo,
redo,
canUndo,
canRedo,
getAdjacentCell,
deleteColumn,
addColumn,
deleteRow,
addRow,
ipaMode,
setIpaMode,
syllableMode,
setSyllableMode,
} = useGridEditor(sessionId)
const [showOverlay, setShowOverlay] = useState(false)
// Load grid on mount
useEffect(() => {
if (sessionId) {
loadGrid()
}
}, [sessionId, loadGrid])
// Keyboard shortcuts
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) {
e.preventDefault()
undo()
} else if ((e.metaKey || e.ctrlKey) && e.key === 'z' && e.shiftKey) {
e.preventDefault()
redo()
} else if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault()
saveGrid()
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [undo, redo, saveGrid])
const handleNavigate = useCallback(
(cellId: string, direction: 'up' | 'down' | 'left' | 'right') => {
const target = getAdjacentCell(cellId, direction)
if (target) {
setSelectedCell(target)
// Focus the input
setTimeout(() => {
const el = document.getElementById(`cell-${target}`)
if (el) {
el.focus()
if (el instanceof HTMLInputElement) el.select()
}
}, 0)
}
},
[getAdjacentCell, setSelectedCell],
)
if (!sessionId) {
return (
<div className="text-center py-12 text-gray-400">
Keine Session ausgewaehlt.
</div>
)
}
if (loading) {
return (
<div className="flex items-center justify-center py-16">
<div className="flex items-center gap-3 text-gray-500 dark:text-gray-400">
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Grid wird aufgebaut...
</div>
</div>
)
}
if (error) {
return (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<p className="text-sm text-red-700 dark:text-red-300">
Fehler: {error}
</p>
<button
onClick={buildGrid}
className="mt-2 text-xs px-3 py-1.5 bg-red-600 text-white rounded hover:bg-red-700"
>
Erneut versuchen
</button>
</div>
)
}
if (!grid || !grid.zones.length) {
return (
<div className="text-center py-12">
<p className="text-gray-400 mb-4">Kein Grid vorhanden.</p>
<button
onClick={buildGrid}
className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 text-sm"
>
Grid aus OCR-Ergebnissen erstellen
</button>
</div>
)
}
return (
<div className="space-y-4">
{/* Summary bar */}
<div className="flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
<span>{grid.summary.total_zones} Zone(n)</span>
<span>{grid.summary.total_columns} Spalten</span>
<span>{grid.summary.total_rows} Zeilen</span>
<span>{grid.summary.total_cells} Zellen</span>
{grid.boxes_detected > 0 && (
<span className="text-amber-600 dark:text-amber-400">
{grid.boxes_detected} Box(en) erkannt
</span>
)}
{grid.summary.color_stats && Object.entries(grid.summary.color_stats)
.filter(([name]) => name !== 'black')
.map(([name, count]) => (
<span key={name} className="inline-flex items-center gap-1">
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: {
red: '#dc2626', blue: '#2563eb', green: '#16a34a',
orange: '#ea580c', purple: '#9333ea', yellow: '#ca8a04',
}[name] || '#6b7280' }} />
<span>{count} {name}</span>
</span>
))
}
{(grid.summary.recovered_colored ?? 0) > 0 && (
<span className="text-purple-600 dark:text-purple-400">
+{grid.summary.recovered_colored} recovered
</span>
)}
{grid.dictionary_detection?.is_dictionary && (
<span className="px-1.5 py-0.5 rounded bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 border border-blue-200 dark:border-blue-800">
Woerterbuch ({Math.round(grid.dictionary_detection.confidence * 100)}%)
</span>
)}
{grid.page_number?.text && (
<span className="px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 border border-gray-200 dark:border-gray-600">
S. {grid.page_number.text}
</span>
)}
<span className="text-gray-400">
{grid.duration_seconds.toFixed(1)}s
</span>
</div>
{/* Toolbar */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 px-3 py-2">
<GridToolbar
dirty={dirty}
saving={saving}
canUndo={canUndo}
canRedo={canRedo}
showOverlay={showOverlay}
ipaMode={ipaMode}
syllableMode={syllableMode}
onSave={saveGrid}
onUndo={undo}
onRedo={redo}
onRebuild={buildGrid}
onToggleOverlay={() => setShowOverlay(!showOverlay)}
onIpaModeChange={setIpaMode}
onSyllableModeChange={setSyllableMode}
/>
</div>
{/* Image overlay */}
{showOverlay && (
<GridImageOverlay sessionId={sessionId} grid={grid} />
)}
{/* Zone tables — group vsplit zones side by side */}
<div className="space-y-4">
{(() => {
// Group consecutive zones with same vsplit_group
const groups: GridZone[][] = []
for (const zone of grid.zones) {
const prev = groups[groups.length - 1]
if (
prev &&
zone.vsplit_group != null &&
prev[0].vsplit_group === zone.vsplit_group
) {
prev.push(zone)
} else {
groups.push([zone])
}
}
return groups.map((group) =>
group.length === 1 ? (
<div
key={group[0].zone_index}
className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden"
>
<GridTable
zone={group[0]}
layoutMetrics={grid.layout_metrics}
selectedCell={selectedCell}
onSelectCell={setSelectedCell}
onCellTextChange={updateCellText}
onToggleColumnBold={toggleColumnBold}
onToggleRowHeader={toggleRowHeader}
onNavigate={handleNavigate}
onDeleteColumn={deleteColumn}
onAddColumn={addColumn}
onDeleteRow={deleteRow}
onAddRow={addRow}
/>
</div>
) : (
<div
key={`vsplit-${group[0].vsplit_group}`}
className="flex gap-2"
>
{group.map((zone) => (
<div
key={zone.zone_index}
className="flex-1 min-w-0 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden"
>
<GridTable
zone={zone}
layoutMetrics={grid.layout_metrics}
selectedCell={selectedCell}
onSelectCell={setSelectedCell}
onCellTextChange={updateCellText}
onToggleColumnBold={toggleColumnBold}
onToggleRowHeader={toggleRowHeader}
onNavigate={handleNavigate}
/>
</div>
))}
</div>
),
)
})()}
</div>
{/* Tip */}
<div className="text-[11px] text-gray-400 dark:text-gray-500 flex items-center gap-4">
<span>Tab: naechste Zelle</span>
<span>Enter: Zeile runter</span>
<span>Spalte fett: Klick auf Spaltenkopf</span>
<span>Header: Klick auf Zeilennummer</span>
<span>Ctrl+Z/Y: Undo/Redo</span>
<span>Ctrl+S: Speichern</span>
</div>
{/* Next step button */}
{onNext && (
<div className="flex justify-end">
<button
onClick={async () => {
if (dirty) await saveGrid()
onNext()
}}
className="px-4 py-2 bg-teal-600 text-white text-sm rounded-lg hover:bg-teal-700 transition-colors"
>
Fertig
</button>
</div>
)}
</div>
)
}