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>
208 lines
7.3 KiB
TypeScript
208 lines
7.3 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState } from 'react'
|
|
import type { CropResult } from '@/app/(admin)/ai/ocr-pipeline/types'
|
|
import { ImageCompareView } from './ImageCompareView'
|
|
|
|
const KLAUSUR_API = '/klausur-api'
|
|
|
|
interface StepCropProps {
|
|
sessionId: string | null
|
|
onNext: () => void
|
|
}
|
|
|
|
export function StepCrop({ sessionId, onNext }: StepCropProps) {
|
|
const [cropResult, setCropResult] = useState<CropResult | null>(null)
|
|
const [cropping, setCropping] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [hasRun, setHasRun] = useState(false)
|
|
|
|
// Reset state when sessionId changes (e.g. switching sub-sessions)
|
|
useEffect(() => {
|
|
setCropResult(null)
|
|
setHasRun(false)
|
|
setError(null)
|
|
}, [sessionId])
|
|
|
|
// Auto-trigger crop on mount
|
|
useEffect(() => {
|
|
if (!sessionId || hasRun) return
|
|
setHasRun(true)
|
|
|
|
const runCrop = async () => {
|
|
setCropping(true)
|
|
setError(null)
|
|
|
|
try {
|
|
// Check if session already has crop result
|
|
const sessionRes = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}`)
|
|
if (sessionRes.ok) {
|
|
const sessionData = await sessionRes.json()
|
|
if (sessionData.crop_result) {
|
|
setCropResult(sessionData.crop_result)
|
|
setCropping(false)
|
|
return
|
|
}
|
|
}
|
|
|
|
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/crop`, {
|
|
method: 'POST',
|
|
})
|
|
|
|
if (!res.ok) {
|
|
throw new Error('Zuschnitt fehlgeschlagen')
|
|
}
|
|
|
|
const data = await res.json()
|
|
setCropResult(data)
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
|
} finally {
|
|
setCropping(false)
|
|
}
|
|
}
|
|
|
|
runCrop()
|
|
}, [sessionId, hasRun])
|
|
|
|
const handleSkip = async () => {
|
|
if (!sessionId) return
|
|
try {
|
|
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/crop/skip`, {
|
|
method: 'POST',
|
|
})
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setCropResult(data)
|
|
}
|
|
} catch (e) {
|
|
console.error('Skip crop failed:', e)
|
|
}
|
|
onNext()
|
|
}
|
|
|
|
if (!sessionId) {
|
|
return <div className="text-sm text-gray-400">Keine Session ausgewaehlt.</div>
|
|
}
|
|
|
|
const dewarpedUrl = `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/dewarped`
|
|
const croppedUrl = cropResult
|
|
? `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/cropped`
|
|
: null
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Loading indicator */}
|
|
{cropping && (
|
|
<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" />
|
|
Scannerraender werden erkannt...
|
|
</div>
|
|
)}
|
|
|
|
{/* Image comparison */}
|
|
<ImageCompareView
|
|
originalUrl={dewarpedUrl}
|
|
deskewedUrl={croppedUrl}
|
|
showGrid={false}
|
|
showBinarized={false}
|
|
binarizedUrl={null}
|
|
leftLabel="Entzerrt"
|
|
rightLabel="Zugeschnitten"
|
|
/>
|
|
|
|
{/* Crop result info */}
|
|
{cropResult && (
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
|
<div className="flex flex-wrap items-center gap-3 text-sm">
|
|
{(cropResult as Record<string, unknown>).multi_page ? (
|
|
<>
|
|
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400 text-xs font-medium">
|
|
Mehrseitig: {(cropResult as Record<string, unknown>).page_count as number} Seiten erkannt
|
|
</span>
|
|
{((cropResult as Record<string, unknown>).sub_sessions as Array<{id: string; name: string; page_index: number}> | undefined)?.map((sub) => (
|
|
<span key={sub.id} className="text-gray-400 text-xs">
|
|
Seite {sub.page_index + 1}
|
|
</span>
|
|
))}
|
|
</>
|
|
) : cropResult.crop_applied ? (
|
|
<>
|
|
<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">
|
|
Zugeschnitten
|
|
</span>
|
|
{cropResult.detected_format && (
|
|
<>
|
|
<div className="h-4 w-px bg-gray-300 dark:bg-gray-600" />
|
|
<span className="text-gray-600 dark:text-gray-400">
|
|
Format: <span className="font-medium">{cropResult.detected_format}</span>
|
|
{cropResult.format_confidence != null && (
|
|
<span className="text-gray-400 ml-1">
|
|
({Math.round(cropResult.format_confidence * 100)}%)
|
|
</span>
|
|
)}
|
|
</span>
|
|
</>
|
|
)}
|
|
{cropResult.original_size && cropResult.cropped_size && (
|
|
<>
|
|
<div className="h-4 w-px bg-gray-300 dark:bg-gray-600" />
|
|
<span className="text-gray-400 text-xs">
|
|
{cropResult.original_size.width}x{cropResult.original_size.height} → {cropResult.cropped_size.width}x{cropResult.cropped_size.height}
|
|
</span>
|
|
</>
|
|
)}
|
|
{cropResult.border_fractions && (
|
|
<>
|
|
<div className="h-4 w-px bg-gray-300 dark:bg-gray-600" />
|
|
<span className="text-gray-400 text-xs">
|
|
Raender: O={pct(cropResult.border_fractions.top)} U={pct(cropResult.border_fractions.bottom)} L={pct(cropResult.border_fractions.left)} R={pct(cropResult.border_fractions.right)}
|
|
</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">
|
|
Kein Zuschnitt noetig
|
|
</span>
|
|
)}
|
|
{cropResult.duration_seconds != null && (
|
|
<span className="text-gray-400 text-xs ml-auto">
|
|
{cropResult.duration_seconds}s
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Action buttons */}
|
|
{cropResult && (
|
|
<div className="flex justify-between">
|
|
<button
|
|
onClick={handleSkip}
|
|
className="px-4 py-2 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
|
|
>
|
|
Ueberspringen
|
|
</button>
|
|
<button
|
|
onClick={onNext}
|
|
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>
|
|
)
|
|
}
|
|
|
|
function pct(v: number): string {
|
|
return `${(v * 100).toFixed(1)}%`
|
|
}
|