[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:
@@ -1,278 +1,21 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type {
|
||||
GridCell, ColumnMeta, ImageRegion, ImageStyle,
|
||||
} from '@/app/(admin)/ai/ocr-kombi/types'
|
||||
import { IMAGE_STYLES as STYLES } from '@/app/(admin)/ai/ocr-kombi/types'
|
||||
|
||||
const KLAUSUR_API = '/klausur-api'
|
||||
|
||||
const COL_TYPE_COLORS: Record<string, string> = {
|
||||
column_en: '#3b82f6',
|
||||
column_de: '#22c55e',
|
||||
column_example: '#f97316',
|
||||
column_text: '#a855f7',
|
||||
page_ref: '#06b6d4',
|
||||
column_marker: '#6b7280',
|
||||
}
|
||||
|
||||
interface StepGroundTruthProps {
|
||||
sessionId: string | null
|
||||
onNext: () => void
|
||||
}
|
||||
|
||||
interface SessionData {
|
||||
cells: GridCell[]
|
||||
columnsUsed: ColumnMeta[]
|
||||
imageWidth: number
|
||||
imageHeight: number
|
||||
originalImageUrl: string
|
||||
}
|
||||
import type { StepGroundTruthProps, ImageRegionWithState } from './ground-truth-types'
|
||||
import { useGroundTruthSession } from './useGroundTruthSession'
|
||||
import { ReconstructionPanel } from './ReconstructionPanel'
|
||||
import { ImageRegionsPanel } from './ImageRegionsPanel'
|
||||
import { ValidationPanel } from './ValidationPanel'
|
||||
|
||||
export function StepGroundTruth({ sessionId, onNext }: StepGroundTruthProps) {
|
||||
const [status, setStatus] = useState<'loading' | 'ready' | 'saving' | 'saved' | 'error'>('loading')
|
||||
const [error, setError] = useState('')
|
||||
const [session, setSession] = useState<SessionData | null>(null)
|
||||
const [imageRegions, setImageRegions] = useState<(ImageRegion & { generating?: boolean })[]>([])
|
||||
const [detecting, setDetecting] = useState(false)
|
||||
const [zoom, setZoom] = useState(100)
|
||||
const [syncScroll, setSyncScroll] = useState(true)
|
||||
const [notes, setNotes] = useState('')
|
||||
const [score, setScore] = useState<number | null>(null)
|
||||
const [drawingRegion, setDrawingRegion] = useState(false)
|
||||
const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(null)
|
||||
const [dragEnd, setDragEnd] = useState<{ x: number; y: number } | null>(null)
|
||||
const [isGroundTruth, setIsGroundTruth] = useState(false)
|
||||
const [gtSaving, setGtSaving] = useState(false)
|
||||
const [gtMessage, setGtMessage] = useState('')
|
||||
const s = useGroundTruthSession(sessionId)
|
||||
|
||||
const leftPanelRef = useRef<HTMLDivElement>(null)
|
||||
const rightPanelRef = useRef<HTMLDivElement>(null)
|
||||
const reconRef = useRef<HTMLDivElement>(null)
|
||||
const [reconWidth, setReconWidth] = useState(0)
|
||||
|
||||
// Track reconstruction container width for font size calculation
|
||||
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()
|
||||
}, [session])
|
||||
|
||||
// Load session data
|
||||
useEffect(() => {
|
||||
if (!sessionId) return
|
||||
loadSessionData()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessionId])
|
||||
|
||||
const loadSessionData = async () => {
|
||||
if (!sessionId) return
|
||||
setStatus('loading')
|
||||
try {
|
||||
const resp = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}`)
|
||||
if (!resp.ok) throw new Error(`Failed to load session: ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
|
||||
const wordResult = data.word_result || {}
|
||||
setSession({
|
||||
cells: wordResult.cells || [],
|
||||
columnsUsed: wordResult.columns_used || [],
|
||||
imageWidth: wordResult.image_width || data.image_width || 800,
|
||||
imageHeight: wordResult.image_height || data.image_height || 600,
|
||||
originalImageUrl: data.original_image_url
|
||||
? `${KLAUSUR_API}${data.original_image_url}`
|
||||
: `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/original`,
|
||||
})
|
||||
|
||||
// Check if session has ground truth reference
|
||||
const gt = data.ground_truth
|
||||
setIsGroundTruth(!!gt?.build_grid_reference)
|
||||
|
||||
// Load existing validation data
|
||||
const valResp = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/reconstruction/validation`)
|
||||
if (valResp.ok) {
|
||||
const valData = await valResp.json()
|
||||
const validation = valData.validation
|
||||
if (validation) {
|
||||
setImageRegions(validation.image_regions || [])
|
||||
setNotes(validation.notes || '')
|
||||
setScore(validation.score ?? null)
|
||||
}
|
||||
}
|
||||
|
||||
setStatus('ready')
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setStatus('error')
|
||||
}
|
||||
const handleUpdateRegion = (index: number, update: Partial<ImageRegionWithState>) => {
|
||||
s.setImageRegions(prev => prev.map((r, j) =>
|
||||
j === index ? { ...r, ...update } : r
|
||||
))
|
||||
}
|
||||
|
||||
// Sync scroll between panels
|
||||
const handleScroll = useCallback((source: 'left' | 'right') => {
|
||||
if (!syncScroll) return
|
||||
const from = source === 'left' ? leftPanelRef.current : rightPanelRef.current
|
||||
const to = source === 'left' ? rightPanelRef.current : leftPanelRef.current
|
||||
if (from && to) {
|
||||
to.scrollTop = from.scrollTop
|
||||
to.scrollLeft = from.scrollLeft
|
||||
}
|
||||
}, [syncScroll])
|
||||
|
||||
// Detect images via VLM
|
||||
const handleDetectImages = async () => {
|
||||
if (!sessionId) return
|
||||
setDetecting(true)
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/reconstruction/detect-images`,
|
||||
{ method: 'POST' }
|
||||
)
|
||||
if (!resp.ok) throw new Error(`Detection failed: ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
setImageRegions(data.regions || [])
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setDetecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate image for a region
|
||||
const handleGenerateImage = async (index: number) => {
|
||||
if (!sessionId) return
|
||||
const region = imageRegions[index]
|
||||
if (!region) return
|
||||
|
||||
setImageRegions(prev => prev.map((r, i) => i === index ? { ...r, generating: true } : r))
|
||||
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/reconstruction/generate-image`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
region_index: index,
|
||||
prompt: region.prompt,
|
||||
style: region.style,
|
||||
}),
|
||||
}
|
||||
)
|
||||
if (!resp.ok) throw new Error(`Generation failed: ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
|
||||
setImageRegions(prev => prev.map((r, i) =>
|
||||
i === index ? { ...r, image_b64: data.image_b64, generating: false } : r
|
||||
))
|
||||
} catch (e) {
|
||||
setImageRegions(prev => prev.map((r, i) => i === index ? { ...r, generating: false } : r))
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
|
||||
// Save validation
|
||||
const handleSave = async () => {
|
||||
if (!sessionId) {
|
||||
setError('Keine Session-ID vorhanden')
|
||||
return
|
||||
}
|
||||
setStatus('saving')
|
||||
setError('')
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/reconstruction/validate`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ notes, score: score ?? 0 }),
|
||||
}
|
||||
)
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text().catch(() => '')
|
||||
throw new Error(`Speichern fehlgeschlagen (${resp.status}): ${body}`)
|
||||
}
|
||||
setStatus('saved')
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setStatus('ready')
|
||||
}
|
||||
}
|
||||
|
||||
// Mark/update ground truth reference
|
||||
const handleMarkGroundTruth = async () => {
|
||||
if (!sessionId) return
|
||||
setGtSaving(true)
|
||||
setGtMessage('')
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/mark-ground-truth?pipeline=ocr-pipeline`,
|
||||
{ method: 'POST' }
|
||||
)
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text().catch(() => '')
|
||||
throw new Error(`Ground Truth fehlgeschlagen (${resp.status}): ${body}`)
|
||||
}
|
||||
const data = await resp.json()
|
||||
setIsGroundTruth(true)
|
||||
setGtMessage(`Ground Truth gespeichert (${data.cells_saved} Zellen)`)
|
||||
setTimeout(() => setGtMessage(''), 5000)
|
||||
} catch (e) {
|
||||
setGtMessage(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setGtSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle manual region drawing on reconstruction
|
||||
const handleReconMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!drawingRegion) return
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const x = ((e.clientX - rect.left) / rect.width) * 100
|
||||
const y = ((e.clientY - rect.top) / rect.height) * 100
|
||||
setDragStart({ x, y })
|
||||
setDragEnd({ x, y })
|
||||
}
|
||||
|
||||
const handleReconMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!dragStart) return
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const x = ((e.clientX - rect.left) / rect.width) * 100
|
||||
const y = ((e.clientY - rect.top) / rect.height) * 100
|
||||
setDragEnd({ x, y })
|
||||
}
|
||||
|
||||
const handleReconMouseUp = () => {
|
||||
if (!dragStart || !dragEnd) return
|
||||
const x = Math.min(dragStart.x, dragEnd.x)
|
||||
const y = Math.min(dragStart.y, dragEnd.y)
|
||||
const w = Math.abs(dragEnd.x - dragStart.x)
|
||||
const h = Math.abs(dragEnd.y - dragStart.y)
|
||||
|
||||
if (w > 2 && h > 2) {
|
||||
setImageRegions(prev => [...prev, {
|
||||
bbox_pct: { x, y, w, h },
|
||||
prompt: '',
|
||||
description: 'Manually selected region',
|
||||
image_b64: null,
|
||||
style: 'educational' as ImageStyle,
|
||||
}])
|
||||
}
|
||||
|
||||
setDragStart(null)
|
||||
setDragEnd(null)
|
||||
setDrawingRegion(false)
|
||||
}
|
||||
|
||||
const handleRemoveRegion = (index: number) => {
|
||||
setImageRegions(prev => prev.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
if (status === 'loading') {
|
||||
if (s.status === 'loading') {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-teal-500 mr-3" />
|
||||
@@ -281,20 +24,20 @@ export function StepGroundTruth({ sessionId, onNext }: StepGroundTruthProps) {
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'error' && !session) {
|
||||
if (s.status === 'error' && !s.session) {
|
||||
return (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-red-500">{error}</p>
|
||||
<button onClick={loadSessionData} className="mt-4 px-4 py-2 bg-teal-600 text-white rounded hover:bg-teal-700">
|
||||
<p className="text-red-500">{s.error}</p>
|
||||
<button onClick={s.loadSessionData} className="mt-4 px-4 py-2 bg-teal-600 text-white rounded hover:bg-teal-700">
|
||||
Erneut laden
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!session) return null
|
||||
if (!s.session) return null
|
||||
|
||||
const aspect = session.imageHeight / session.imageWidth
|
||||
const aspect = s.session.imageHeight / s.session.imageWidth
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -305,33 +48,33 @@ export function StepGroundTruth({ sessionId, onNext }: StepGroundTruthProps) {
|
||||
</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleDetectImages}
|
||||
disabled={detecting}
|
||||
onClick={s.handleDetectImages}
|
||||
disabled={s.detecting}
|
||||
className="px-3 py-1.5 text-sm bg-indigo-600 text-white rounded hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
{detecting ? 'Erkennung laeuft...' : 'Bilder erkennen'}
|
||||
{s.detecting ? 'Erkennung laeuft...' : 'Bilder erkennen'}
|
||||
</button>
|
||||
<label className="flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={syncScroll}
|
||||
onChange={e => setSyncScroll(e.target.checked)}
|
||||
checked={s.syncScroll}
|
||||
onChange={e => s.setSyncScroll(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Sync Scroll
|
||||
</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button onClick={() => setZoom(z => Math.max(50, z - 25))} className="px-2 py-1 text-sm border rounded dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700">-</button>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400 w-12 text-center">{zoom}%</span>
|
||||
<button onClick={() => setZoom(z => Math.min(200, z + 25))} className="px-2 py-1 text-sm border rounded dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700">+</button>
|
||||
<button onClick={() => s.setZoom(z => Math.max(50, z - 25))} className="px-2 py-1 text-sm border rounded dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700">-</button>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400 w-12 text-center">{s.zoom}%</span>
|
||||
<button onClick={() => s.setZoom(z => Math.min(200, z + 25))} className="px-2 py-1 text-sm border rounded dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
{s.error && (
|
||||
<div className="p-2 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-sm rounded">
|
||||
{error}
|
||||
<button onClick={() => setError('')} className="ml-2 underline">Schliessen</button>
|
||||
{s.error}
|
||||
<button onClick={() => s.setError('')} className="ml-2 underline">Schliessen</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -343,13 +86,13 @@ export function StepGroundTruth({ sessionId, onNext }: StepGroundTruthProps) {
|
||||
Original
|
||||
</div>
|
||||
<div
|
||||
ref={leftPanelRef}
|
||||
ref={s.leftPanelRef}
|
||||
className="flex-1 overflow-auto"
|
||||
onScroll={() => handleScroll('left')}
|
||||
onScroll={() => s.handleScroll('left')}
|
||||
>
|
||||
<div style={{ width: `${zoom}%`, minWidth: '100%' }}>
|
||||
<div style={{ width: `${s.zoom}%`, minWidth: '100%' }}>
|
||||
<img
|
||||
src={session.originalImageUrl}
|
||||
src={s.session.originalImageUrl}
|
||||
alt="Original"
|
||||
className="w-full h-auto"
|
||||
draggable={false}
|
||||
@@ -359,282 +102,47 @@ export function StepGroundTruth({ sessionId, onNext }: StepGroundTruthProps) {
|
||||
</div>
|
||||
|
||||
{/* Right: Reconstruction */}
|
||||
<div className="border rounded-lg dark:border-gray-700 overflow-hidden flex flex-col">
|
||||
<div className="px-3 py-1.5 bg-gray-50 dark:bg-gray-800 text-sm font-medium text-gray-600 dark:text-gray-400 border-b dark:border-gray-700 flex items-center justify-between">
|
||||
<span>Rekonstruktion</span>
|
||||
<button
|
||||
onClick={() => setDrawingRegion(!drawingRegion)}
|
||||
className={`text-xs px-2 py-0.5 rounded ${drawingRegion ? 'bg-indigo-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400'}`}
|
||||
>
|
||||
{drawingRegion ? 'Region zeichnen...' : '+ Region'}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref={rightPanelRef}
|
||||
className="flex-1 overflow-auto"
|
||||
onScroll={() => handleScroll('right')}
|
||||
>
|
||||
<div style={{ width: `${zoom}%`, minWidth: '100%' }}>
|
||||
{/* Reconstruction container */}
|
||||
<div
|
||||
ref={reconRef}
|
||||
className="relative bg-white"
|
||||
style={{
|
||||
paddingBottom: `${aspect * 100}%`,
|
||||
cursor: drawingRegion ? 'crosshair' : 'default',
|
||||
}}
|
||||
onMouseDown={handleReconMouseDown}
|
||||
onMouseMove={handleReconMouseMove}
|
||||
onMouseUp={handleReconMouseUp}
|
||||
>
|
||||
{/* Row separator lines — derive from cells */}
|
||||
{(() => {
|
||||
const rowYs = new Set<number>()
|
||||
for (const cell of session.cells) {
|
||||
if (cell.col_index === 0 && cell.bbox_pct) {
|
||||
rowYs.add(cell.bbox_pct.y)
|
||||
}
|
||||
}
|
||||
return Array.from(rowYs).map((y, i) => (
|
||||
<div
|
||||
key={`row-${i}`}
|
||||
className="absolute left-0 right-0"
|
||||
style={{
|
||||
top: `${y}%`,
|
||||
height: '1px',
|
||||
backgroundColor: 'rgba(0,0,0,0.06)',
|
||||
}}
|
||||
/>
|
||||
))
|
||||
})()}
|
||||
|
||||
{/* Cell texts — black on white, font size derived from cell height */}
|
||||
{session.cells.map(cell => {
|
||||
if (!cell.bbox_pct || !cell.text) return null
|
||||
// Container height in px = reconWidth * aspect
|
||||
// Cell height in px = containerHeightPx * (bbox_pct.h / 100)
|
||||
// Font size ≈ 70% of cell height
|
||||
const containerH = reconWidth * aspect
|
||||
const cellHeightPx = containerH * (cell.bbox_pct.h / 100)
|
||||
const fontSize = Math.max(6, cellHeightPx * 0.7)
|
||||
return (
|
||||
<span
|
||||
key={cell.cell_id}
|
||||
className="absolute leading-none overflow-hidden whitespace-nowrap"
|
||||
style={{
|
||||
left: `${cell.bbox_pct.x}%`,
|
||||
top: `${cell.bbox_pct.y}%`,
|
||||
width: `${cell.bbox_pct.w}%`,
|
||||
height: `${cell.bbox_pct.h}%`,
|
||||
color: '#1a1a1a',
|
||||
fontSize: `${fontSize}px`,
|
||||
fontWeight: cell.is_bold ? 'bold' : 'normal',
|
||||
fontFamily: "'Liberation Sans', 'DejaVu Sans', Arial, sans-serif",
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 1px',
|
||||
}}
|
||||
title={`${cell.cell_id}: ${cell.text}`}
|
||||
>
|
||||
{cell.text}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Generated images at region positions */}
|
||||
{imageRegions.map((region, i) => (
|
||||
<div
|
||||
key={`region-${i}`}
|
||||
className="absolute border-2 border-dashed border-indigo-400"
|
||||
style={{
|
||||
left: `${region.bbox_pct.x}%`,
|
||||
top: `${region.bbox_pct.y}%`,
|
||||
width: `${region.bbox_pct.w}%`,
|
||||
height: `${region.bbox_pct.h}%`,
|
||||
}}
|
||||
>
|
||||
{region.image_b64 ? (
|
||||
<img src={region.image_b64} alt={region.description} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-indigo-50/50 text-indigo-400 text-[0.5em]">
|
||||
{region.generating ? '...' : `Bild ${i + 1}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Drawing rectangle */}
|
||||
{dragStart && dragEnd && (
|
||||
<div
|
||||
className="absolute border-2 border-dashed border-red-500 bg-red-100/20 pointer-events-none"
|
||||
style={{
|
||||
left: `${Math.min(dragStart.x, dragEnd.x)}%`,
|
||||
top: `${Math.min(dragStart.y, dragEnd.y)}%`,
|
||||
width: `${Math.abs(dragEnd.x - dragStart.x)}%`,
|
||||
height: `${Math.abs(dragEnd.y - dragStart.y)}%`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ReconstructionPanel
|
||||
cells={s.session.cells}
|
||||
aspect={aspect}
|
||||
zoom={s.zoom}
|
||||
reconWidth={s.reconWidth}
|
||||
imageRegions={s.imageRegions}
|
||||
drawingRegion={s.drawingRegion}
|
||||
dragStart={s.dragStart}
|
||||
dragEnd={s.dragEnd}
|
||||
panelRef={s.rightPanelRef}
|
||||
reconRef={s.reconRef}
|
||||
onScroll={() => s.handleScroll('right')}
|
||||
onToggleDrawing={() => s.setDrawingRegion(!s.drawingRegion)}
|
||||
onMouseDown={s.handleReconMouseDown}
|
||||
onMouseMove={s.handleReconMouseMove}
|
||||
onMouseUp={s.handleReconMouseUp}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Image regions panel */}
|
||||
{imageRegions.length > 0 && (
|
||||
<div className="border rounded-lg dark:border-gray-700 p-4">
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Bildbereiche ({imageRegions.length} gefunden)
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{imageRegions.map((region, i) => (
|
||||
<div key={i} className="flex items-start gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
{/* Preview thumbnail */}
|
||||
<div className="w-16 h-16 flex-shrink-0 border rounded dark:border-gray-600 overflow-hidden bg-white">
|
||||
{region.image_b64 ? (
|
||||
<img src={region.image_b64} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-400 text-xs">
|
||||
{Math.round(region.bbox_pct.w)}x{Math.round(region.bbox_pct.h)}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Image regions editor */}
|
||||
<ImageRegionsPanel
|
||||
imageRegions={s.imageRegions}
|
||||
onUpdateRegion={handleUpdateRegion}
|
||||
onGenerateImage={s.handleGenerateImage}
|
||||
onRemoveRegion={s.handleRemoveRegion}
|
||||
/>
|
||||
|
||||
{/* Prompt + controls */}
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0">
|
||||
Bereich {i + 1}:
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={region.prompt}
|
||||
onChange={e => {
|
||||
setImageRegions(prev => prev.map((r, j) =>
|
||||
j === i ? { ...r, prompt: e.target.value } : r
|
||||
))
|
||||
}}
|
||||
placeholder="Beschreibung / Prompt..."
|
||||
className="flex-1 text-sm px-2 py-1 border rounded dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={region.style}
|
||||
onChange={e => {
|
||||
setImageRegions(prev => prev.map((r, j) =>
|
||||
j === i ? { ...r, style: e.target.value as ImageStyle } : r
|
||||
))
|
||||
}}
|
||||
className="text-sm px-2 py-1 border rounded dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
{STYLES.map(s => (
|
||||
<option key={s.value} value={s.value}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => handleGenerateImage(i)}
|
||||
disabled={!!region.generating || !region.prompt}
|
||||
className="px-3 py-1 text-sm bg-teal-600 text-white rounded hover:bg-teal-700 disabled:opacity-50"
|
||||
>
|
||||
{region.generating ? 'Generiere...' : 'Generieren'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRemoveRegion(i)}
|
||||
className="px-2 py-1 text-sm text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
{region.description && region.description !== region.prompt && (
|
||||
<p className="text-xs text-gray-400">{region.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes and score */}
|
||||
<div className="border rounded-lg dark:border-gray-700 p-4 space-y-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Bewertung (1-10):
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={score ?? ''}
|
||||
onChange={e => setScore(e.target.value ? parseInt(e.target.value) : null)}
|
||||
className="w-20 text-sm px-2 py-1 border rounded dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(v => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => setScore(v)}
|
||||
className={`w-7 h-7 text-xs rounded ${score === v ? 'bg-teal-600 text-white' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'}`}
|
||||
>
|
||||
{v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 block mb-1">
|
||||
Notizen:
|
||||
</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Anmerkungen zur Qualitaet der Rekonstruktion..."
|
||||
className="w-full text-sm px-3 py-2 border rounded dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions — sticky bottom bar */}
|
||||
<div className="sticky bottom-0 bg-white dark:bg-gray-900 border-t dark:border-gray-700 py-3 px-1 -mx-1 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{status === 'saved' && <span className="text-green-600 dark:text-green-400">Validierung gespeichert</span>}
|
||||
{status === 'saving' && <span>Speichere...</span>}
|
||||
{gtMessage && (
|
||||
<span className={gtMessage.includes('fehlgeschlagen') ? 'text-red-500' : 'text-amber-600 dark:text-amber-400'}>
|
||||
{gtMessage}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleMarkGroundTruth}
|
||||
disabled={gtSaving || status === 'saving'}
|
||||
className="px-4 py-2 text-sm bg-amber-600 text-white rounded hover:bg-amber-700 disabled:opacity-50"
|
||||
>
|
||||
{gtSaving ? 'Speichere...' : isGroundTruth ? 'Ground Truth aktualisieren' : 'Als Ground Truth markieren'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={status === 'saving'}
|
||||
className="px-4 py-2 text-sm bg-gray-600 text-white rounded hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await handleSave()
|
||||
onNext()
|
||||
}}
|
||||
disabled={status === 'saving'}
|
||||
className="px-4 py-2 text-sm bg-teal-600 text-white rounded hover:bg-teal-700 disabled:opacity-50"
|
||||
>
|
||||
Abschliessen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Notes, score, and action bar */}
|
||||
<ValidationPanel
|
||||
notes={s.notes}
|
||||
score={s.score}
|
||||
status={s.status}
|
||||
isGroundTruth={s.isGroundTruth}
|
||||
gtSaving={s.gtSaving}
|
||||
gtMessage={s.gtMessage}
|
||||
onNotesChange={s.setNotes}
|
||||
onScoreChange={s.setScore}
|
||||
onSave={s.handleSave}
|
||||
onMarkGroundTruth={s.handleMarkGroundTruth}
|
||||
onFinish={onNext}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user