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 25s
CI / test-go-edu-search (push) Successful in 25s
CI / test-python-klausur (push) Failing after 1m53s
CI / test-python-agent-core (push) Successful in 15s
CI / test-nodejs-website (push) Successful in 17s
Step components (Deskew, Dewarp, Crop, Orientation) had local state guards that prevented reloading when sessionId changed via sub-session tab clicks. Added useEffect reset hooks that clear all local state when sessionId changes, allowing the component to properly reload the new session's data. Also renamed "Box N" to "Seite N" in BoxSessionTabs per user feedback. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
314 lines
11 KiB
TypeScript
314 lines
11 KiB
TypeScript
'use client'
|
|
|
|
import { useCallback, useEffect, useState } from 'react'
|
|
import type { OrientationResult, SessionInfo, SubSession } from '@/app/(admin)/ai/ocr-pipeline/types'
|
|
import { ImageCompareView } from './ImageCompareView'
|
|
|
|
const KLAUSUR_API = '/klausur-api'
|
|
|
|
interface PageSplitResult {
|
|
multi_page: boolean
|
|
page_count?: number
|
|
sub_sessions?: { id: string; name: string; page_index: number }[]
|
|
used_original?: boolean
|
|
duration_seconds?: number
|
|
}
|
|
|
|
interface StepOrientationProps {
|
|
sessionId?: string | null
|
|
onNext: (sessionId: string) => void
|
|
onSubSessionsCreated?: (subs: SubSession[]) => void
|
|
}
|
|
|
|
export function StepOrientation({ sessionId: existingSessionId, onNext, onSubSessionsCreated }: StepOrientationProps) {
|
|
const [session, setSession] = useState<SessionInfo | null>(null)
|
|
const [orientationResult, setOrientationResult] = useState<OrientationResult | null>(null)
|
|
const [pageSplitResult, setPageSplitResult] = useState<PageSplitResult | null>(null)
|
|
const [uploading, setUploading] = useState(false)
|
|
const [detecting, setDetecting] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [dragOver, setDragOver] = useState(false)
|
|
const [sessionName, setSessionName] = useState('')
|
|
|
|
// Reset state when sessionId changes
|
|
useEffect(() => {
|
|
setSession(null)
|
|
setOrientationResult(null)
|
|
setPageSplitResult(null)
|
|
setError(null)
|
|
}, [existingSessionId])
|
|
|
|
// Reload session data when navigating back
|
|
useEffect(() => {
|
|
if (!existingSessionId || session) return
|
|
|
|
const loadSession = async () => {
|
|
try {
|
|
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${existingSessionId}`)
|
|
if (!res.ok) return
|
|
const data = await res.json()
|
|
|
|
const sessionInfo: SessionInfo = {
|
|
session_id: data.session_id,
|
|
filename: data.filename,
|
|
image_width: data.image_width,
|
|
image_height: data.image_height,
|
|
original_image_url: `${KLAUSUR_API}${data.original_image_url}`,
|
|
}
|
|
setSession(sessionInfo)
|
|
|
|
if (data.orientation_result) {
|
|
setOrientationResult(data.orientation_result)
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to reload session:', e)
|
|
}
|
|
}
|
|
|
|
loadSession()
|
|
}, [existingSessionId, session])
|
|
|
|
const handleUpload = useCallback(async (file: File) => {
|
|
setUploading(true)
|
|
setError(null)
|
|
setOrientationResult(null)
|
|
|
|
try {
|
|
const formData = new FormData()
|
|
formData.append('file', file)
|
|
if (sessionName.trim()) {
|
|
formData.append('name', sessionName.trim())
|
|
}
|
|
|
|
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions`, {
|
|
method: 'POST',
|
|
body: formData,
|
|
})
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ detail: res.statusText }))
|
|
throw new Error(err.detail || 'Upload fehlgeschlagen')
|
|
}
|
|
|
|
const data: SessionInfo = await res.json()
|
|
data.original_image_url = `${KLAUSUR_API}${data.original_image_url}`
|
|
setSession(data)
|
|
|
|
// Auto-trigger orientation detection
|
|
setDetecting(true)
|
|
const orientRes = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${data.session_id}/orientation`, {
|
|
method: 'POST',
|
|
})
|
|
|
|
if (!orientRes.ok) {
|
|
throw new Error('Orientierungserkennung fehlgeschlagen')
|
|
}
|
|
|
|
const orientData = await orientRes.json()
|
|
setOrientationResult({
|
|
orientation_degrees: orientData.orientation_degrees,
|
|
corrected: orientData.corrected,
|
|
duration_seconds: orientData.duration_seconds,
|
|
})
|
|
|
|
// Auto-trigger page-split detection (double-page book spreads)
|
|
try {
|
|
const splitRes = await fetch(
|
|
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${data.session_id}/page-split`,
|
|
{ method: 'POST' },
|
|
)
|
|
if (splitRes.ok) {
|
|
const splitData: PageSplitResult = await splitRes.json()
|
|
setPageSplitResult(splitData)
|
|
if (splitData.multi_page && splitData.sub_sessions && onSubSessionsCreated) {
|
|
onSubSessionsCreated(
|
|
splitData.sub_sessions.map((s) => ({
|
|
id: s.id,
|
|
name: s.name,
|
|
box_index: s.page_index,
|
|
current_step: splitData.used_original ? 1 : 2,
|
|
}))
|
|
)
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('Page-split detection failed:', e)
|
|
// Not critical — continue as single page
|
|
}
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
|
} finally {
|
|
setUploading(false)
|
|
setDetecting(false)
|
|
}
|
|
}, [sessionName, onSubSessionsCreated])
|
|
|
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault()
|
|
setDragOver(false)
|
|
const file = e.dataTransfer.files[0]
|
|
if (file) handleUpload(file)
|
|
}, [handleUpload])
|
|
|
|
const handleFileInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0]
|
|
if (file) handleUpload(file)
|
|
}, [handleUpload])
|
|
|
|
// Upload area (no session yet)
|
|
if (!session) {
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Session name input */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-600 dark:text-gray-400 mb-1">
|
|
Session-Name (optional)
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={sessionName}
|
|
onChange={(e) => setSessionName(e.target.value)}
|
|
placeholder="z.B. Unit 3 Seite 42"
|
|
className="w-full max-w-sm px-3 py-2 text-sm border rounded-lg dark:bg-gray-800 dark:border-gray-600 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-teal-500"
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
|
|
onDragLeave={() => setDragOver(false)}
|
|
onDrop={handleDrop}
|
|
className={`border-2 border-dashed rounded-xl p-12 text-center transition-colors ${
|
|
dragOver
|
|
? 'border-teal-400 bg-teal-50 dark:bg-teal-900/20'
|
|
: 'border-gray-300 dark:border-gray-600 hover:border-teal-400'
|
|
}`}
|
|
>
|
|
{uploading ? (
|
|
<div className="text-gray-500">
|
|
<div className="animate-spin inline-block w-8 h-8 border-2 border-teal-500 border-t-transparent rounded-full mb-3" />
|
|
<p>Wird hochgeladen...</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="text-4xl mb-3">📄</div>
|
|
<p className="text-gray-600 dark:text-gray-400 mb-2">
|
|
PDF oder Bild hierher ziehen
|
|
</p>
|
|
<p className="text-sm text-gray-400 mb-4">oder</p>
|
|
<label className="inline-block px-4 py-2 bg-teal-600 text-white rounded-lg cursor-pointer hover:bg-teal-700 transition-colors">
|
|
Datei auswaehlen
|
|
<input
|
|
type="file"
|
|
accept=".pdf,.png,.jpg,.jpeg,.tiff,.tif"
|
|
onChange={handleFileInput}
|
|
className="hidden"
|
|
/>
|
|
</label>
|
|
</>
|
|
)}
|
|
</div>
|
|
{error && (
|
|
<div className="p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-lg text-sm">
|
|
{error}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Session active: show orientation result
|
|
const orientedUrl = orientationResult
|
|
? `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${session.session_id}/image/oriented`
|
|
: null
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Filename */}
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
|
Datei: <span className="font-medium text-gray-700 dark:text-gray-300">{session.filename}</span>
|
|
{' '}({session.image_width} x {session.image_height} px)
|
|
</div>
|
|
|
|
{/* Loading indicator */}
|
|
{detecting && (
|
|
<div className="flex items-center gap-2 text-teal-600 dark:text-teal-400 text-sm">
|
|
<div className="animate-spin w-4 h-4 border-2 border-teal-500 border-t-transparent rounded-full" />
|
|
Orientierung wird erkannt...
|
|
</div>
|
|
)}
|
|
|
|
{/* Image comparison */}
|
|
<ImageCompareView
|
|
originalUrl={session.original_image_url}
|
|
deskewedUrl={orientedUrl}
|
|
showGrid={false}
|
|
showBinarized={false}
|
|
binarizedUrl={null}
|
|
leftLabel="Original"
|
|
rightLabel="Orientiert"
|
|
/>
|
|
|
|
{/* Orientation result badge */}
|
|
{orientationResult && (
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
|
<div className="flex items-center gap-3 text-sm">
|
|
{orientationResult.corrected ? (
|
|
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-400 text-xs font-medium">
|
|
🔄 {orientationResult.orientation_degrees}° korrigiert
|
|
</span>
|
|
) : (
|
|
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400 text-xs font-medium">
|
|
✓ 0° (keine Drehung noetig)
|
|
</span>
|
|
)}
|
|
<span className="text-gray-400 text-xs">
|
|
{orientationResult.duration_seconds}s
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Page-split result */}
|
|
{pageSplitResult?.multi_page && (
|
|
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-700 p-4">
|
|
<div className="text-sm font-medium text-blue-700 dark:text-blue-300">
|
|
Doppelseite erkannt — {pageSplitResult.page_count} Seiten
|
|
</div>
|
|
<p className="text-xs text-blue-600 dark:text-blue-400 mt-1">
|
|
Jede Seite wird einzeln durch die Pipeline (Begradigung, Entzerrung, Zuschnitt, ...) verarbeitet.
|
|
{pageSplitResult.used_original && ' (Seitentrennung auf dem Originalbild, da die Orientierung die Doppelseite gedreht hat.)'}
|
|
</p>
|
|
<div className="flex gap-2 mt-2">
|
|
{pageSplitResult.sub_sessions?.map((s) => (
|
|
<span
|
|
key={s.id}
|
|
className="text-xs px-2 py-1 rounded-md bg-blue-100 dark:bg-blue-800/40 text-blue-700 dark:text-blue-300"
|
|
>
|
|
{s.name}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Next button */}
|
|
{orientationResult && (
|
|
<div className="flex justify-end">
|
|
<button
|
|
onClick={() => onNext(session.session_id)}
|
|
className="px-6 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 font-medium transition-colors"
|
|
>
|
|
{pageSplitResult?.multi_page ? 'Seiten verarbeiten' : 'Weiter'} →
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-lg text-sm">
|
|
{error}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|