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 24s
CI / test-go-edu-search (push) Successful in 25s
CI / test-python-klausur (push) Failing after 2m5s
CI / test-python-agent-core (push) Successful in 16s
CI / test-nodejs-website (push) Successful in 16s
The page-split detection was only implemented in the regular pipeline page but not in the OCR Overlay page where the user actually tests with Kombi mode. Now the overlay page has full sub-session support: - openSession: handles sub_sessions, parent_session_id, skip logic for page-split vs crop-based sub-sessions, preserves current mode - handleOrientationComplete: async, fetches API to detect sub-sessions - BoxSessionTabs: shown between stepper and step content - handleNext: returns to parent after sub-session completion - handleSessionChange/handleBoxSessionsCreated: session switching Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
751 lines
31 KiB
TypeScript
751 lines
31 KiB
TypeScript
'use client'
|
|
|
|
import { useCallback, useEffect, useState, useRef } from 'react'
|
|
import { useSearchParams } from 'next/navigation'
|
|
import { PagePurpose } from '@/components/common/PagePurpose'
|
|
import { PipelineStepper } from '@/components/ocr-pipeline/PipelineStepper'
|
|
import { StepOrientation } from '@/components/ocr-pipeline/StepOrientation'
|
|
import { StepDeskew } from '@/components/ocr-pipeline/StepDeskew'
|
|
import { StepDewarp } from '@/components/ocr-pipeline/StepDewarp'
|
|
import { StepCrop } from '@/components/ocr-pipeline/StepCrop'
|
|
import { StepStructureDetection } from '@/components/ocr-pipeline/StepStructureDetection'
|
|
import { StepRowDetection } from '@/components/ocr-pipeline/StepRowDetection'
|
|
import { StepWordRecognition } from '@/components/ocr-pipeline/StepWordRecognition'
|
|
import { OverlayReconstruction } from '@/components/ocr-overlay/OverlayReconstruction'
|
|
import { PaddleDirectStep } from '@/components/ocr-overlay/PaddleDirectStep'
|
|
import { GridEditor } from '@/components/grid-editor/GridEditor'
|
|
import { StepGridReview } from '@/components/ocr-pipeline/StepGridReview'
|
|
import { BoxSessionTabs } from '@/components/ocr-pipeline/BoxSessionTabs'
|
|
import { OVERLAY_PIPELINE_STEPS, PADDLE_DIRECT_STEPS, KOMBI_STEPS, DOCUMENT_CATEGORIES, dbStepToOverlayUi, type PipelineStep, type SessionListItem, type DocumentCategory } from './types'
|
|
import type { SubSession } from '../ocr-pipeline/types'
|
|
|
|
const KLAUSUR_API = '/klausur-api'
|
|
|
|
export default function OcrOverlayPage() {
|
|
const [mode, setMode] = useState<'pipeline' | 'paddle-direct' | 'kombi'>('pipeline')
|
|
const [currentStep, setCurrentStep] = useState(0)
|
|
const [sessionId, setSessionId] = useState<string | null>(null)
|
|
const [sessionName, setSessionName] = useState<string>('')
|
|
const [sessions, setSessions] = useState<SessionListItem[]>([])
|
|
const [loadingSessions, setLoadingSessions] = useState(true)
|
|
const [editingName, setEditingName] = useState<string | null>(null)
|
|
const [editNameValue, setEditNameValue] = useState('')
|
|
const [editingCategory, setEditingCategory] = useState<string | null>(null)
|
|
const [activeCategory, setActiveCategory] = useState<DocumentCategory | undefined>(undefined)
|
|
const [editingActiveCategory, setEditingActiveCategory] = useState(false)
|
|
const [subSessions, setSubSessions] = useState<SubSession[]>([])
|
|
const [parentSessionId, setParentSessionId] = useState<string | null>(null)
|
|
const [isGroundTruth, setIsGroundTruth] = useState(false)
|
|
const [gtSaving, setGtSaving] = useState(false)
|
|
const [gtMessage, setGtMessage] = useState('')
|
|
const [steps, setSteps] = useState<PipelineStep[]>(
|
|
OVERLAY_PIPELINE_STEPS.map((s, i) => ({
|
|
...s,
|
|
status: i === 0 ? 'active' : 'pending',
|
|
})),
|
|
)
|
|
|
|
const searchParams = useSearchParams()
|
|
const deepLinkHandled = useRef(false)
|
|
const gridSaveRef = useRef<(() => Promise<void>) | null>(null)
|
|
|
|
useEffect(() => {
|
|
loadSessions()
|
|
}, [])
|
|
|
|
const loadSessions = async () => {
|
|
setLoadingSessions(true)
|
|
try {
|
|
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions`)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
// Filter to only show top-level sessions (no sub-sessions)
|
|
setSessions((data.sessions || []).filter((s: SessionListItem) => !s.parent_session_id))
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load sessions:', e)
|
|
} finally {
|
|
setLoadingSessions(false)
|
|
}
|
|
}
|
|
|
|
const openSession = useCallback(async (sid: string, keepSubSessions?: boolean) => {
|
|
try {
|
|
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}`)
|
|
if (!res.ok) return
|
|
const data = await res.json()
|
|
|
|
setSessionId(sid)
|
|
setSessionName(data.name || data.filename || '')
|
|
setActiveCategory(data.document_category || undefined)
|
|
setIsGroundTruth(!!data.ground_truth?.build_grid_reference)
|
|
setGtMessage('')
|
|
|
|
// Sub-session handling
|
|
if (data.sub_sessions && data.sub_sessions.length > 0) {
|
|
setSubSessions(data.sub_sessions)
|
|
setParentSessionId(sid)
|
|
} else if (data.parent_session_id) {
|
|
setParentSessionId(data.parent_session_id)
|
|
} else if (!keepSubSessions) {
|
|
setSubSessions([])
|
|
setParentSessionId(null)
|
|
}
|
|
|
|
const isSubSession = !!data.parent_session_id
|
|
|
|
// Mode detection for root sessions with word_result
|
|
const ocrEngine = data.word_result?.ocr_engine
|
|
const isPaddleDirect = ocrEngine === 'paddle_direct'
|
|
const isKombi = ocrEngine === 'kombi' || ocrEngine === 'rapid_kombi'
|
|
|
|
let activeMode = mode // keep current mode for sub-sessions
|
|
if (!isSubSession && (isPaddleDirect || isKombi)) {
|
|
activeMode = isKombi ? 'kombi' : 'paddle-direct'
|
|
setMode(activeMode)
|
|
} else if (!isSubSession && !ocrEngine) {
|
|
// Unprocessed root session: keep the user's selected mode
|
|
activeMode = mode
|
|
}
|
|
|
|
const baseSteps = activeMode === 'kombi' ? KOMBI_STEPS
|
|
: activeMode === 'paddle-direct' ? PADDLE_DIRECT_STEPS
|
|
: OVERLAY_PIPELINE_STEPS
|
|
|
|
// Determine UI step
|
|
let uiStep: number
|
|
const skipIds: string[] = []
|
|
|
|
if (!isSubSession && (isPaddleDirect || isKombi)) {
|
|
const hasGrid = isKombi && data.grid_editor_result
|
|
const hasStructure = isKombi && data.structure_result
|
|
uiStep = hasGrid ? 6 : hasStructure ? 6 : data.word_result ? 5 : 4
|
|
if (isPaddleDirect) uiStep = data.word_result ? 4 : 4
|
|
} else {
|
|
const dbStep = data.current_step || 1
|
|
if (dbStep <= 2) uiStep = 0
|
|
else if (dbStep === 3) uiStep = 1
|
|
else if (dbStep === 4) uiStep = 2
|
|
else if (dbStep === 5) uiStep = 3
|
|
else uiStep = 4
|
|
|
|
// Sub-session skip logic
|
|
if (isSubSession) {
|
|
if (dbStep >= 5) {
|
|
skipIds.push('orientation', 'deskew', 'dewarp', 'crop')
|
|
if (uiStep < 4) uiStep = 4
|
|
} else if (dbStep >= 2) {
|
|
skipIds.push('orientation')
|
|
}
|
|
}
|
|
}
|
|
|
|
setSteps(
|
|
baseSteps.map((s, i) => ({
|
|
...s,
|
|
status: skipIds.includes(s.id)
|
|
? 'skipped'
|
|
: i < uiStep ? 'completed' : i === uiStep ? 'active' : 'pending',
|
|
})),
|
|
)
|
|
setCurrentStep(uiStep)
|
|
} catch (e) {
|
|
console.error('Failed to open session:', e)
|
|
}
|
|
}, [mode])
|
|
|
|
// Handle deep-link: ?session=xxx&mode=kombi (from GT Queue page)
|
|
useEffect(() => {
|
|
if (deepLinkHandled.current) return
|
|
const urlSession = searchParams.get('session')
|
|
const urlMode = searchParams.get('mode')
|
|
if (urlSession) {
|
|
deepLinkHandled.current = true
|
|
if (urlMode === 'kombi' || urlMode === 'paddle-direct') {
|
|
setMode(urlMode)
|
|
const baseSteps = urlMode === 'kombi' ? KOMBI_STEPS : PADDLE_DIRECT_STEPS
|
|
setSteps(baseSteps.map((s, i) => ({ ...s, status: i === 0 ? 'active' : 'pending' })))
|
|
}
|
|
openSession(urlSession)
|
|
}
|
|
}, [searchParams, openSession])
|
|
|
|
const deleteSession = useCallback(async (sid: string) => {
|
|
try {
|
|
await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}`, { method: 'DELETE' })
|
|
setSessions((prev) => prev.filter((s) => s.id !== sid))
|
|
if (sessionId === sid) {
|
|
setSessionId(null)
|
|
setCurrentStep(0)
|
|
setSubSessions([])
|
|
setParentSessionId(null)
|
|
const baseSteps = mode === 'kombi' ? KOMBI_STEPS : mode === 'paddle-direct' ? PADDLE_DIRECT_STEPS : OVERLAY_PIPELINE_STEPS
|
|
setSteps(baseSteps.map((s, i) => ({ ...s, status: i === 0 ? 'active' : 'pending' })))
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to delete session:', e)
|
|
}
|
|
}, [sessionId, mode])
|
|
|
|
const renameSession = useCallback(async (sid: string, newName: string) => {
|
|
try {
|
|
await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: newName }),
|
|
})
|
|
setSessions((prev) => prev.map((s) => (s.id === sid ? { ...s, name: newName } : s)))
|
|
if (sessionId === sid) setSessionName(newName)
|
|
} catch (e) {
|
|
console.error('Failed to rename session:', e)
|
|
}
|
|
setEditingName(null)
|
|
}, [sessionId])
|
|
|
|
const updateCategory = useCallback(async (sid: string, category: DocumentCategory) => {
|
|
try {
|
|
await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ document_category: category }),
|
|
})
|
|
setSessions((prev) => prev.map((s) => (s.id === sid ? { ...s, document_category: category } : s)))
|
|
if (sessionId === sid) setActiveCategory(category)
|
|
} catch (e) {
|
|
console.error('Failed to update category:', e)
|
|
}
|
|
setEditingCategory(null)
|
|
}, [sessionId])
|
|
|
|
const handleStepClick = (index: number) => {
|
|
if (index <= currentStep || steps[index].status === 'completed') {
|
|
setCurrentStep(index)
|
|
}
|
|
}
|
|
|
|
const goToStep = (step: number) => {
|
|
setCurrentStep(step)
|
|
setSteps((prev) =>
|
|
prev.map((s, i) => ({
|
|
...s,
|
|
status: i < step ? 'completed' : i === step ? 'active' : 'pending',
|
|
})),
|
|
)
|
|
}
|
|
|
|
const handleNext = () => {
|
|
if (currentStep >= steps.length - 1) {
|
|
// Sub-session completed — switch back to parent
|
|
if (parentSessionId && sessionId !== parentSessionId) {
|
|
setSubSessions((prev) =>
|
|
prev.map((s) => s.id === sessionId ? { ...s, status: 'completed', current_step: 10 } : s)
|
|
)
|
|
handleSessionChange(parentSessionId)
|
|
return
|
|
}
|
|
// Last step completed — return to session list
|
|
const baseSteps = mode === 'kombi' ? KOMBI_STEPS : mode === 'paddle-direct' ? PADDLE_DIRECT_STEPS : OVERLAY_PIPELINE_STEPS
|
|
setSteps(baseSteps.map((s, i) => ({ ...s, status: i === 0 ? 'active' : 'pending' })))
|
|
setCurrentStep(0)
|
|
setSessionId(null)
|
|
setSubSessions([])
|
|
setParentSessionId(null)
|
|
loadSessions()
|
|
return
|
|
}
|
|
|
|
const nextStep = currentStep + 1
|
|
setSteps((prev) =>
|
|
prev.map((s, i) => {
|
|
if (i === currentStep) return { ...s, status: 'completed' }
|
|
if (i === nextStep) return { ...s, status: 'active' }
|
|
return s
|
|
}),
|
|
)
|
|
setCurrentStep(nextStep)
|
|
}
|
|
|
|
const handleOrientationComplete = async (sid: string) => {
|
|
setSessionId(sid)
|
|
loadSessions()
|
|
|
|
// Check for page-split sub-sessions directly from API
|
|
try {
|
|
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}`)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
if (data.sub_sessions?.length > 0) {
|
|
const subs: SubSession[] = data.sub_sessions.map((s: SubSession) => ({
|
|
id: s.id,
|
|
name: s.name,
|
|
box_index: s.box_index,
|
|
current_step: s.current_step,
|
|
}))
|
|
setSubSessions(subs)
|
|
setParentSessionId(sid)
|
|
openSession(subs[0].id, true)
|
|
return
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to check for sub-sessions:', e)
|
|
}
|
|
|
|
handleNext()
|
|
}
|
|
|
|
const handleBoxSessionsCreated = useCallback((subs: SubSession[]) => {
|
|
setSubSessions(subs)
|
|
if (sessionId) setParentSessionId(sessionId)
|
|
}, [sessionId])
|
|
|
|
const handleSessionChange = useCallback((newSessionId: string) => {
|
|
openSession(newSessionId, true)
|
|
}, [openSession])
|
|
|
|
const handleNewSession = () => {
|
|
setSessionId(null)
|
|
setSessionName('')
|
|
setCurrentStep(0)
|
|
setSubSessions([])
|
|
setParentSessionId(null)
|
|
const baseSteps = mode === 'kombi' ? KOMBI_STEPS : mode === 'paddle-direct' ? PADDLE_DIRECT_STEPS : OVERLAY_PIPELINE_STEPS
|
|
setSteps(baseSteps.map((s, i) => ({ ...s, status: i === 0 ? 'active' : 'pending' })))
|
|
}
|
|
|
|
const stepNames: Record<number, string> = {
|
|
1: 'Orientierung',
|
|
2: 'Begradigung',
|
|
3: 'Entzerrung',
|
|
4: 'Zuschneiden',
|
|
5: 'Zeilen',
|
|
6: 'Woerter',
|
|
7: 'Overlay',
|
|
}
|
|
|
|
const reprocessFromStep = useCallback(async (uiStep: number) => {
|
|
if (!sessionId) return
|
|
// Map overlay UI step to DB step
|
|
const dbStepMap: Record<number, number> = { 0: 2, 1: 3, 2: 4, 3: 5, 4: 7, 5: 8, 6: 9 }
|
|
const dbStep = dbStepMap[uiStep] || uiStep + 1
|
|
if (!confirm(`Ab Schritt ${uiStep + 1} (${stepNames[uiStep + 1] || '?'}) neu verarbeiten?`)) return
|
|
try {
|
|
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/reprocess`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ from_step: dbStep }),
|
|
})
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}))
|
|
console.error('Reprocess failed:', data.detail || res.status)
|
|
return
|
|
}
|
|
goToStep(uiStep)
|
|
} catch (e) {
|
|
console.error('Reprocess error:', e)
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [sessionId, goToStep])
|
|
|
|
const handleMarkGroundTruth = async () => {
|
|
if (!sessionId) return
|
|
setGtSaving(true)
|
|
setGtMessage('')
|
|
try {
|
|
// Auto-save grid editor before marking GT (so DB has latest edits)
|
|
if (gridSaveRef.current) {
|
|
await gridSaveRef.current()
|
|
}
|
|
const resp = await fetch(
|
|
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/mark-ground-truth?pipeline=${mode}`,
|
|
{ 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)
|
|
}
|
|
}
|
|
|
|
const isLastStep = currentStep === steps.length - 1
|
|
const showGtButton = isLastStep && sessionId != null
|
|
|
|
const renderStep = () => {
|
|
if (mode === 'paddle-direct' || mode === 'kombi') {
|
|
switch (currentStep) {
|
|
case 0:
|
|
return <StepOrientation sessionId={sessionId} onNext={handleOrientationComplete} onSubSessionsCreated={handleBoxSessionsCreated} />
|
|
case 1:
|
|
return <StepDeskew sessionId={sessionId} onNext={handleNext} />
|
|
case 2:
|
|
return <StepDewarp sessionId={sessionId} onNext={handleNext} />
|
|
case 3:
|
|
return <StepCrop sessionId={sessionId} onNext={handleNext} />
|
|
case 4:
|
|
if (mode === 'kombi') {
|
|
return (
|
|
<PaddleDirectStep
|
|
sessionId={sessionId}
|
|
onNext={handleNext}
|
|
endpoint="paddle-kombi"
|
|
title="Kombi-Modus"
|
|
description="PP-OCRv5 und Tesseract laufen parallel. Koordinaten werden gewichtet gemittelt fuer optimale Positionierung."
|
|
icon="🔀"
|
|
buttonLabel="PP-OCRv5 + Tesseract starten"
|
|
runningLabel="PP-OCRv5 + Tesseract laufen..."
|
|
engineKey="kombi"
|
|
/>
|
|
)
|
|
}
|
|
return <PaddleDirectStep sessionId={sessionId} onNext={handleNext} />
|
|
case 5:
|
|
return mode === 'kombi' ? (
|
|
<StepStructureDetection sessionId={sessionId} onNext={handleNext} />
|
|
) : null
|
|
case 6:
|
|
return mode === 'kombi' ? (
|
|
<StepGridReview sessionId={sessionId} onNext={handleNext} saveRef={gridSaveRef} />
|
|
) : null
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
switch (currentStep) {
|
|
case 0:
|
|
return <StepOrientation sessionId={sessionId} onNext={handleOrientationComplete} onSubSessionsCreated={handleBoxSessionsCreated} />
|
|
case 1:
|
|
return <StepDeskew sessionId={sessionId} onNext={handleNext} />
|
|
case 2:
|
|
return <StepDewarp sessionId={sessionId} onNext={handleNext} />
|
|
case 3:
|
|
return <StepCrop sessionId={sessionId} onNext={handleNext} />
|
|
case 4:
|
|
return <StepRowDetection sessionId={sessionId} onNext={handleNext} />
|
|
case 5:
|
|
return <StepWordRecognition sessionId={sessionId} onNext={handleNext} goToStep={goToStep} skipHealGaps />
|
|
case 6:
|
|
return <OverlayReconstruction sessionId={sessionId} onNext={handleNext} />
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<PagePurpose
|
|
title="OCR Overlay"
|
|
purpose="Ganzseitige Overlay-Rekonstruktion: Scan begradigen, Zeilen und Woerter erkennen, dann pixelgenau ueber das Bild legen. Ohne Spaltenerkennung — ideal fuer Arbeitsblaetter."
|
|
audience={['Entwickler']}
|
|
architecture={{
|
|
services: ['klausur-service (FastAPI)', 'OpenCV', 'Tesseract'],
|
|
databases: ['PostgreSQL Sessions'],
|
|
}}
|
|
relatedPages={[
|
|
{ name: 'OCR Pipeline', href: '/ai/ocr-pipeline', description: 'Volle Pipeline mit Spalten' },
|
|
{ name: 'OCR Vergleich', href: '/ai/ocr-compare', description: 'Methoden-Vergleich' },
|
|
]}
|
|
defaultCollapsed
|
|
/>
|
|
|
|
{/* Session List */}
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
Sessions ({sessions.length})
|
|
</h3>
|
|
<button
|
|
onClick={handleNewSession}
|
|
className="text-xs px-3 py-1.5 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors"
|
|
>
|
|
+ Neue Session
|
|
</button>
|
|
</div>
|
|
|
|
{loadingSessions ? (
|
|
<div className="text-sm text-gray-400 py-2">Lade Sessions...</div>
|
|
) : sessions.length === 0 ? (
|
|
<div className="text-sm text-gray-400 py-2">Noch keine Sessions vorhanden.</div>
|
|
) : (
|
|
<div className="space-y-1.5 max-h-[320px] overflow-y-auto">
|
|
{sessions.map((s) => {
|
|
const catInfo = DOCUMENT_CATEGORIES.find(c => c.value === s.document_category)
|
|
return (
|
|
<div
|
|
key={s.id}
|
|
className={`relative flex items-start gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors cursor-pointer ${
|
|
sessionId === s.id
|
|
? 'bg-teal-50 dark:bg-teal-900/30 border border-teal-200 dark:border-teal-700'
|
|
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
|
}`}
|
|
>
|
|
{/* Thumbnail */}
|
|
<div
|
|
className="flex-shrink-0 w-12 h-12 rounded-md overflow-hidden bg-gray-100 dark:bg-gray-700"
|
|
onClick={() => openSession(s.id)}
|
|
>
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${s.id}/thumbnail?size=96`}
|
|
alt=""
|
|
className="w-full h-full object-cover"
|
|
loading="lazy"
|
|
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Info */}
|
|
<div className="flex-1 min-w-0" onClick={() => openSession(s.id)}>
|
|
{editingName === s.id ? (
|
|
<input
|
|
autoFocus
|
|
value={editNameValue}
|
|
onChange={(e) => setEditNameValue(e.target.value)}
|
|
onBlur={() => renameSession(s.id, editNameValue)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') renameSession(s.id, editNameValue)
|
|
if (e.key === 'Escape') setEditingName(null)
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="w-full px-1 py-0.5 text-sm border rounded dark:bg-gray-700 dark:border-gray-600"
|
|
/>
|
|
) : (
|
|
<div className="truncate font-medium text-gray-700 dark:text-gray-300">
|
|
{s.name || s.filename}
|
|
</div>
|
|
)}
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
navigator.clipboard.writeText(s.id)
|
|
const btn = e.currentTarget
|
|
btn.textContent = 'Kopiert!'
|
|
setTimeout(() => { btn.textContent = `ID: ${s.id.slice(0, 8)}` }, 1500)
|
|
}}
|
|
className="text-[10px] font-mono text-gray-400 hover:text-teal-500 transition-colors"
|
|
title={`Volle ID: ${s.id} — Klick zum Kopieren`}
|
|
>
|
|
ID: {s.id.slice(0, 8)}
|
|
</button>
|
|
<div className="text-xs text-gray-400 flex gap-2 mt-0.5">
|
|
<span>{new Date(s.created_at).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' })}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Category Badge */}
|
|
<div className="flex flex-col gap-1 items-end flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
|
<button
|
|
onClick={() => setEditingCategory(editingCategory === s.id ? null : s.id)}
|
|
className={`text-[10px] px-1.5 py-0.5 rounded-full border transition-colors ${
|
|
catInfo
|
|
? 'bg-teal-50 dark:bg-teal-900/30 border-teal-200 dark:border-teal-700 text-teal-700 dark:text-teal-300'
|
|
: 'bg-gray-50 dark:bg-gray-700 border-gray-200 dark:border-gray-600 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
|
}`}
|
|
title="Kategorie setzen"
|
|
>
|
|
{catInfo ? `${catInfo.icon} ${catInfo.label}` : '+ Kategorie'}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex flex-col gap-0.5 flex-shrink-0">
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
setEditNameValue(s.name || s.filename)
|
|
setEditingName(s.id)
|
|
}}
|
|
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
|
title="Umbenennen"
|
|
>
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
if (confirm('Session loeschen?')) deleteSession(s.id)
|
|
}}
|
|
className="p-1 text-gray-400 hover:text-red-500"
|
|
title="Loeschen"
|
|
>
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Category dropdown */}
|
|
{editingCategory === s.id && (
|
|
<div
|
|
className="absolute right-0 top-full mt-1 z-20 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg p-2 grid grid-cols-2 gap-1 w-64"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{DOCUMENT_CATEGORIES.map((cat) => (
|
|
<button
|
|
key={cat.value}
|
|
onClick={() => updateCategory(s.id, cat.value)}
|
|
className={`text-xs px-2 py-1.5 rounded-md text-left transition-colors ${
|
|
s.document_category === cat.value
|
|
? 'bg-teal-100 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300'
|
|
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-400'
|
|
}`}
|
|
>
|
|
{cat.icon} {cat.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Active session info + category picker */}
|
|
{sessionId && sessionName && (
|
|
<div className="relative flex items-center gap-3 text-sm text-gray-500 dark:text-gray-400">
|
|
<span>Aktive Session: <span className="font-medium text-gray-700 dark:text-gray-300">{sessionName}</span></span>
|
|
<button
|
|
onClick={() => setEditingActiveCategory(!editingActiveCategory)}
|
|
className={`text-xs px-2.5 py-1 rounded-full border transition-colors ${
|
|
activeCategory
|
|
? 'bg-teal-50 dark:bg-teal-900/30 border-teal-200 dark:border-teal-700 text-teal-700 dark:text-teal-300 hover:bg-teal-100 dark:hover:bg-teal-900/50'
|
|
: 'bg-amber-50 dark:bg-amber-900/20 border-amber-300 dark:border-amber-700 text-amber-700 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-900/40 animate-pulse'
|
|
}`}
|
|
>
|
|
{activeCategory ? (() => {
|
|
const cat = DOCUMENT_CATEGORIES.find(c => c.value === activeCategory)
|
|
return cat ? `${cat.icon} ${cat.label}` : activeCategory
|
|
})() : 'Kategorie setzen'}
|
|
</button>
|
|
{isGroundTruth && (
|
|
<span className="text-xs px-2 py-0.5 rounded-full bg-amber-50 dark:bg-amber-900/20 border border-amber-300 dark:border-amber-700 text-amber-700 dark:text-amber-300">
|
|
GT
|
|
</span>
|
|
)}
|
|
{editingActiveCategory && (
|
|
<div className="absolute left-0 top-full mt-1 z-20 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg p-2 grid grid-cols-2 gap-1 w-64">
|
|
{DOCUMENT_CATEGORIES.map((cat) => (
|
|
<button
|
|
key={cat.value}
|
|
onClick={() => {
|
|
updateCategory(sessionId, cat.value)
|
|
setEditingActiveCategory(false)
|
|
}}
|
|
className={`text-xs px-2 py-1.5 rounded-md text-left transition-colors ${
|
|
activeCategory === cat.value
|
|
? 'bg-teal-100 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300'
|
|
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-400'
|
|
}`}
|
|
>
|
|
{cat.icon} {cat.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Mode Toggle */}
|
|
<div className="flex items-center gap-1 bg-gray-100 dark:bg-gray-800 rounded-lg p-1 w-fit">
|
|
<button
|
|
onClick={() => {
|
|
if (mode === 'pipeline') return
|
|
setMode('pipeline')
|
|
setCurrentStep(0)
|
|
setSessionId(null)
|
|
setSteps(OVERLAY_PIPELINE_STEPS.map((s, i) => ({ ...s, status: i === 0 ? 'active' : 'pending' })))
|
|
}}
|
|
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
|
mode === 'pipeline'
|
|
? 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 shadow-sm'
|
|
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
|
|
}`}
|
|
>
|
|
Pipeline (7 Schritte)
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
if (mode === 'paddle-direct') return
|
|
setMode('paddle-direct')
|
|
setCurrentStep(0)
|
|
setSessionId(null)
|
|
setSteps(PADDLE_DIRECT_STEPS.map((s, i) => ({ ...s, status: i === 0 ? 'active' : 'pending' })))
|
|
}}
|
|
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
|
mode === 'paddle-direct'
|
|
? 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 shadow-sm'
|
|
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
|
|
}`}
|
|
>
|
|
PP-OCRv5 Direct (5 Schritte)
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
if (mode === 'kombi') return
|
|
setMode('kombi')
|
|
setCurrentStep(0)
|
|
setSessionId(null)
|
|
setSteps(KOMBI_STEPS.map((s, i) => ({ ...s, status: i === 0 ? 'active' : 'pending' })))
|
|
}}
|
|
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
|
mode === 'kombi'
|
|
? 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 shadow-sm'
|
|
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
|
|
}`}
|
|
>
|
|
Kombi (7 Schritte)
|
|
</button>
|
|
</div>
|
|
|
|
<PipelineStepper
|
|
steps={steps}
|
|
currentStep={currentStep}
|
|
onStepClick={handleStepClick}
|
|
onReprocess={mode === 'pipeline' && sessionId != null ? reprocessFromStep : undefined}
|
|
/>
|
|
|
|
{subSessions.length > 0 && parentSessionId && sessionId && (
|
|
<BoxSessionTabs
|
|
parentSessionId={parentSessionId}
|
|
subSessions={subSessions}
|
|
activeSessionId={sessionId}
|
|
onSessionChange={handleSessionChange}
|
|
/>
|
|
)}
|
|
|
|
<div className="min-h-[400px]">{renderStep()}</div>
|
|
|
|
{/* Ground Truth button bar — visible on last step */}
|
|
{showGtButton && (
|
|
<div className="sticky bottom-0 bg-white dark:bg-gray-900 border-t dark:border-gray-700 py-3 px-4 -mx-1 flex items-center justify-between rounded-b-xl">
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
|
{gtMessage && (
|
|
<span className={gtMessage.includes('fehlgeschlagen') ? 'text-red-500' : 'text-amber-600 dark:text-amber-400'}>
|
|
{gtMessage}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={handleMarkGroundTruth}
|
|
disabled={gtSaving}
|
|
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>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|