Page-split now creates independent sessions (no parent_session_id), parent marked as status='split' and hidden from list. Navigation uses useSearchParams for URL-based step tracking (browser back/forward works). page.tsx reduced from 684 to 443 lines via usePipelineNavigation hook. Box sub-sessions (column detection) remain unchanged. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
305 lines
11 KiB
TypeScript
305 lines
11 KiB
TypeScript
'use client'
|
|
|
|
import { useCallback, useEffect, useState } from 'react'
|
|
import type { OrientationResult, SessionInfo } 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
|
|
onSessionList?: () => void
|
|
}
|
|
|
|
export function StepOrientation({ sessionId: existingSessionId, onNext, onSessionList }: 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('')
|
|
|
|
// 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)
|
|
}
|
|
} 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])
|
|
|
|
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} unabhaengige Sessions erstellt
|
|
</div>
|
|
<p className="text-xs text-blue-600 dark:text-blue-400 mt-1">
|
|
Jede Seite wird als eigene Session durch die Pipeline 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">
|
|
{pageSplitResult?.multi_page ? (
|
|
<button
|
|
onClick={() => onSessionList?.()}
|
|
className="px-6 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 font-medium transition-colors"
|
|
>
|
|
Zur Session-Liste →
|
|
</button>
|
|
) : (
|
|
<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"
|
|
>
|
|
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>
|
|
)
|
|
}
|