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 41s
CI / test-go-edu-search (push) Successful in 45s
CI / test-python-klausur (push) Failing after 2m46s
CI / test-python-agent-core (push) Successful in 35s
CI / test-nodejs-website (push) Successful in 35s
- Use box_bg_hex for border color (from Step 7 structure detection) - Numbered color badges per box - Show color name in box header - Add box_bg_color/box_bg_hex to GridZone type Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
283 lines
10 KiB
TypeScript
283 lines
10 KiB
TypeScript
'use client'
|
|
|
|
import { useCallback, useEffect, useState } from 'react'
|
|
import { useGridEditor } from '@/components/grid-editor/useGridEditor'
|
|
import type { GridZone } from '@/components/grid-editor/types'
|
|
import { GridTable } from '@/components/grid-editor/GridTable'
|
|
|
|
const KLAUSUR_API = '/klausur-api'
|
|
|
|
type BoxLayoutType = 'flowing' | 'columnar' | 'bullet_list' | 'header_only'
|
|
|
|
const LAYOUT_LABELS: Record<BoxLayoutType, string> = {
|
|
flowing: 'Fließtext',
|
|
columnar: 'Tabelle/Spalten',
|
|
bullet_list: 'Aufzählung',
|
|
header_only: 'Überschrift',
|
|
}
|
|
|
|
interface StepBoxGridReviewProps {
|
|
sessionId: string | null
|
|
onNext: () => void
|
|
}
|
|
|
|
export function StepBoxGridReview({ sessionId, onNext }: StepBoxGridReviewProps) {
|
|
const {
|
|
grid,
|
|
loading,
|
|
saving,
|
|
error,
|
|
dirty,
|
|
selectedCell,
|
|
setSelectedCell,
|
|
loadGrid,
|
|
saveGrid,
|
|
updateCellText,
|
|
toggleColumnBold,
|
|
toggleRowHeader,
|
|
undo,
|
|
redo,
|
|
canUndo,
|
|
canRedo,
|
|
getAdjacentCell,
|
|
commitUndoPoint,
|
|
selectedCells,
|
|
toggleCellSelection,
|
|
clearCellSelection,
|
|
toggleSelectedBold,
|
|
setCellColor,
|
|
deleteColumn,
|
|
addColumn,
|
|
deleteRow,
|
|
addRow,
|
|
} = useGridEditor(sessionId)
|
|
|
|
const [building, setBuilding] = useState(false)
|
|
const [buildError, setBuildError] = useState<string | null>(null)
|
|
|
|
// Load grid on mount
|
|
useEffect(() => {
|
|
if (sessionId) loadGrid()
|
|
}, [sessionId]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// Get box zones
|
|
const boxZones: GridZone[] = (grid?.zones || []).filter(
|
|
(z: GridZone) => z.zone_type === 'box'
|
|
)
|
|
|
|
// Build box grids via backend
|
|
const buildBoxGrids = useCallback(async (overrides?: Record<string, string>) => {
|
|
if (!sessionId) return
|
|
setBuilding(true)
|
|
setBuildError(null)
|
|
try {
|
|
const res = await fetch(
|
|
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/build-box-grids`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ overrides: overrides || {} }),
|
|
},
|
|
)
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}))
|
|
throw new Error(data.detail || `HTTP ${res.status}`)
|
|
}
|
|
await loadGrid()
|
|
} catch (e) {
|
|
setBuildError(e instanceof Error ? e.message : String(e))
|
|
} finally {
|
|
setBuilding(false)
|
|
}
|
|
}, [sessionId, loadGrid])
|
|
|
|
// Handle layout type change for a specific box zone
|
|
const changeLayoutType = useCallback(async (boxIdx: number, layoutType: string) => {
|
|
await buildBoxGrids({ [String(boxIdx)]: layoutType })
|
|
}, [buildBoxGrids])
|
|
|
|
// Auto-build on first load if box zones have no cells
|
|
useEffect(() => {
|
|
if (!grid || loading || building) return
|
|
const needsBuild = boxZones.length === 0 || boxZones.some(z => !z.cells || z.cells.length === 0)
|
|
// Only auto-build if we know there are boxes (check structure_result via a quick fetch)
|
|
if (needsBuild && sessionId) {
|
|
buildBoxGrids()
|
|
}
|
|
}, [grid?.zones?.length, loading]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-16">
|
|
<div className="w-8 h-8 border-4 border-teal-500 border-t-transparent rounded-full animate-spin" />
|
|
<span className="ml-3 text-gray-500">Lade Grid...</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// No boxes after build attempt — skip step
|
|
if (!building && boxZones.length === 0) {
|
|
return (
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-8 text-center">
|
|
<div className="text-4xl mb-3">📦</div>
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
|
Keine Boxen erkannt
|
|
</h3>
|
|
<p className="text-gray-500 dark:text-gray-400 mb-6">
|
|
Auf dieser Seite wurden keine eingebetteten Boxen (Grammatik-Tipps, Übungen etc.) erkannt.
|
|
</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>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
|
Box-Review ({boxZones.length} {boxZones.length === 1 ? 'Box' : 'Boxen'})
|
|
</h3>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
Eingebettete Boxen prüfen und korrigieren. Layout-Typ kann pro Box angepasst werden.
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{dirty && (
|
|
<button
|
|
onClick={saveGrid}
|
|
disabled={saving}
|
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium disabled:opacity-50"
|
|
>
|
|
{saving ? 'Speichere...' : 'Speichern'}
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => buildBoxGrids()}
|
|
disabled={building}
|
|
className="px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors text-sm font-medium disabled:opacity-50"
|
|
>
|
|
{building ? 'Verarbeite...' : 'Alle Boxen neu aufbauen'}
|
|
</button>
|
|
<button
|
|
onClick={async () => {
|
|
if (dirty) await saveGrid()
|
|
onNext()
|
|
}}
|
|
className="px-5 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors text-sm font-medium"
|
|
>
|
|
Weiter →
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Errors */}
|
|
{(error || buildError) && (
|
|
<div className="p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-300 text-sm">
|
|
{error || buildError}
|
|
</div>
|
|
)}
|
|
|
|
{building && (
|
|
<div className="flex items-center gap-3 p-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
|
|
<div className="w-5 h-5 border-2 border-amber-500 border-t-transparent rounded-full animate-spin" />
|
|
<span className="text-amber-700 dark:text-amber-300 text-sm">Box-Grids werden aufgebaut...</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Box zones */}
|
|
{boxZones.map((zone, boxIdx) => {
|
|
const boxColor = zone.box_bg_hex || '#d97706' // amber fallback
|
|
const boxColorName = zone.box_bg_color || 'box'
|
|
return (
|
|
<div
|
|
key={zone.zone_index}
|
|
className="bg-white dark:bg-gray-800 rounded-xl overflow-hidden"
|
|
style={{ border: `3px solid ${boxColor}` }}
|
|
>
|
|
{/* Box header */}
|
|
<div
|
|
className="flex items-center justify-between px-4 py-3 border-b"
|
|
style={{ backgroundColor: `${boxColor}15`, borderColor: `${boxColor}30` }}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<div
|
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-white text-sm font-bold"
|
|
style={{ backgroundColor: boxColor }}
|
|
>
|
|
{boxIdx + 1}
|
|
</div>
|
|
<div>
|
|
<span className="font-medium text-gray-900 dark:text-white">
|
|
Box {boxIdx + 1}
|
|
</span>
|
|
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2">
|
|
{zone.bbox_px?.w}x{zone.bbox_px?.h}px
|
|
{zone.cells?.length ? ` | ${zone.cells.length} Zellen` : ''}
|
|
{zone.box_layout_type ? ` | ${LAYOUT_LABELS[zone.box_layout_type as BoxLayoutType] || zone.box_layout_type}` : ''}
|
|
{boxColorName !== 'box' ? ` | ${boxColorName}` : ''}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<label className="text-xs text-gray-500 dark:text-gray-400">Layout:</label>
|
|
<select
|
|
value={zone.box_layout_type || 'flowing'}
|
|
onChange={(e) => changeLayoutType(boxIdx, e.target.value)}
|
|
disabled={building}
|
|
className="text-xs px-2 py-1 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200"
|
|
>
|
|
{Object.entries(LAYOUT_LABELS).map(([key, label]) => (
|
|
<option key={key} value={key}>{label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Box grid table */}
|
|
<div className="p-3">
|
|
{zone.cells && zone.cells.length > 0 ? (
|
|
<GridTable
|
|
zone={zone}
|
|
selectedCell={selectedCell}
|
|
selectedCells={selectedCells}
|
|
onSelectCell={setSelectedCell}
|
|
onCellTextChange={updateCellText}
|
|
onToggleColumnBold={toggleColumnBold}
|
|
onToggleRowHeader={toggleRowHeader}
|
|
onNavigate={(cellId, dir) => {
|
|
const next = getAdjacentCell(cellId, dir)
|
|
if (next) setSelectedCell(next)
|
|
}}
|
|
onDeleteColumn={deleteColumn}
|
|
onAddColumn={addColumn}
|
|
onDeleteRow={deleteRow}
|
|
onAddRow={addRow}
|
|
onToggleCellSelection={toggleCellSelection}
|
|
onSetCellColor={setCellColor}
|
|
/>
|
|
) : (
|
|
<div className="text-center py-8 text-gray-400">
|
|
<p className="text-sm">Keine Zellen erkannt.</p>
|
|
<button
|
|
onClick={() => buildBoxGrids({ [String(boxIdx)]: 'flowing' })}
|
|
className="mt-2 text-xs text-amber-600 hover:text-amber-700"
|
|
>
|
|
Als Fließtext verarbeiten
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|