Files
breakpilot-lehrer/admin-lehrer/components/ocr-kombi/StepUpload.tsx
Benjamin Admin 9f68bd3425 feat: Implement page-split step with auto-detection and sub-session naming
StepPageSplit now:
- Auto-calls POST /page-split on step entry
- Shows oriented image + detection result
- If double page: creates sub-sessions named "Title — S. 1/2"
- If single page: green badge "keine Trennung noetig"
- Manual "Weiter" button (no auto-advance)

Also:
- StepOrientation wrapper simplified (no page-split in orientation)
- StepUpload passes name back via onUploaded(sid, name)
- page.tsx: after page-split "Weiter" switches to first sub-session
- useKombiPipeline exposes setSessionName

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 17:56:45 +01:00

304 lines
11 KiB
TypeScript

'use client'
import { useState, useCallback, useEffect } from 'react'
import { DOCUMENT_CATEGORIES, type DocumentCategory } from '@/app/(admin)/ai/ocr-pipeline/types'
const KLAUSUR_API = '/klausur-api'
interface StepUploadProps {
sessionId: string | null
onUploaded: (sessionId: string, name: string) => void
onNext: () => void
}
export function StepUpload({ sessionId, onUploaded, onNext }: StepUploadProps) {
const [dragging, setDragging] = useState(false)
const [uploading, setUploading] = useState(false)
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [preview, setPreview] = useState<string | null>(null)
const [title, setTitle] = useState('')
const [category, setCategory] = useState<DocumentCategory>('vokabelseite')
const [error, setError] = useState('')
// Clean up preview URL on unmount
useEffect(() => {
return () => { if (preview) URL.revokeObjectURL(preview) }
}, [preview])
const handleFileSelect = useCallback((file: File) => {
setSelectedFile(file)
setError('')
if (file.type.startsWith('image/')) {
setPreview(URL.createObjectURL(file))
} else {
setPreview(null)
}
// Auto-fill title from filename if empty
if (!title.trim()) {
setTitle(file.name.replace(/\.[^.]+$/, ''))
}
}, [title])
const handleUpload = useCallback(async () => {
if (!selectedFile) return
setUploading(true)
setError('')
try {
const formData = new FormData()
formData.append('file', selectedFile)
if (title.trim()) formData.append('name', title.trim())
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions`, {
method: 'POST',
body: formData,
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.detail || `Upload fehlgeschlagen (${res.status})`)
}
const data = await res.json()
const sid = data.session_id || data.id
// Set category
if (category) {
await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ document_category: category }),
})
}
onUploaded(sid, title.trim() || selectedFile.name)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setUploading(false)
}
}, [selectedFile, title, category, onUploaded])
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
setDragging(false)
const file = e.dataTransfer.files[0]
if (file) handleFileSelect(file)
}, [handleFileSelect])
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) handleFileSelect(file)
}, [handleFileSelect])
const clearFile = useCallback(() => {
setSelectedFile(null)
if (preview) URL.revokeObjectURL(preview)
setPreview(null)
}, [preview])
// ---- Phase 2: Uploaded → show result + "Weiter" ----
if (sessionId) {
return (
<div className="space-y-4">
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
<div className="flex items-center gap-2 text-green-700 dark:text-green-300 text-sm font-medium mb-3">
<span>&#10003;</span> Dokument hochgeladen
</div>
<div className="flex gap-4">
<div className="w-48 h-64 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-700 flex-shrink-0 border border-gray-200 dark:border-gray-600">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image`}
alt="Hochgeladenes Dokument"
className="w-full h-full object-contain"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
/>
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
<div className="font-medium text-gray-700 dark:text-gray-300 mb-1">
{title || 'Dokument'}
</div>
<div className="text-xs text-gray-400 mt-1">
Kategorie: {DOCUMENT_CATEGORIES.find(c => c.value === category)?.label || category}
</div>
<div className="text-xs font-mono text-gray-400 mt-1">
Session: {sessionId.slice(0, 8)}...
</div>
</div>
</div>
</div>
<div className="flex justify-end">
<button
onClick={onNext}
className="px-6 py-2.5 bg-teal-600 text-white text-sm font-medium rounded-lg hover:bg-teal-700 transition-colors"
>
Weiter &rarr;
</button>
</div>
</div>
)
}
// ---- Phase 1b: File selected → preview + "Hochladen" ----
if (selectedFile) {
return (
<div className="space-y-4">
{/* Title input */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Titel
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="z.B. Vokabeln Unit 3"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-sm"
/>
</div>
{/* Category selector */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Kategorie
</label>
<div className="grid grid-cols-4 gap-1.5">
{DOCUMENT_CATEGORIES.map(cat => (
<button
key={cat.value}
onClick={() => setCategory(cat.value)}
className={`text-xs px-2 py-1.5 rounded-md text-left transition-colors ${
category === cat.value
? 'bg-teal-100 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300 ring-1 ring-teal-400'
: 'bg-gray-50 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-100'
}`}
>
{cat.icon} {cat.label}
</button>
))}
</div>
</div>
{/* File preview */}
<div className="border border-gray-200 dark:border-gray-700 rounded-xl p-4">
<div className="flex items-start gap-4">
{preview ? (
<div className="w-36 h-48 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-700 flex-shrink-0 border border-gray-200 dark:border-gray-600">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={preview} alt="Vorschau" className="w-full h-full object-contain" />
</div>
) : (
<div className="w-36 h-48 rounded-lg bg-gray-100 dark:bg-gray-700 flex-shrink-0 flex items-center justify-center border border-gray-200 dark:border-gray-600">
<span className="text-3xl">&#128196;</span>
</div>
)}
<div className="flex-1 min-w-0">
<div className="font-medium text-sm text-gray-700 dark:text-gray-300 truncate">
{selectedFile.name}
</div>
<div className="text-xs text-gray-400 mt-1">
{(selectedFile.size / 1024 / 1024).toFixed(1)} MB
</div>
<button
onClick={clearFile}
className="text-xs text-red-500 hover:text-red-700 mt-2"
>
Andere Datei waehlen
</button>
</div>
</div>
<button
onClick={handleUpload}
disabled={uploading}
className="mt-4 w-full px-4 py-2.5 bg-teal-600 text-white text-sm font-medium rounded-lg hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{uploading ? 'Wird hochgeladen...' : 'Hochladen'}
</button>
</div>
{error && (
<div className="text-sm text-red-500 bg-red-50 dark:bg-red-900/20 p-3 rounded-lg">
{error}
</div>
)}
</div>
)
}
// ---- Phase 1a: No file → drop zone ----
return (
<div className="space-y-4">
{/* Title input */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Titel (optional)
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="z.B. Vokabeln Unit 3"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-sm"
/>
</div>
{/* Category selector */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Kategorie
</label>
<div className="grid grid-cols-4 gap-1.5">
{DOCUMENT_CATEGORIES.map(cat => (
<button
key={cat.value}
onClick={() => setCategory(cat.value)}
className={`text-xs px-2 py-1.5 rounded-md text-left transition-colors ${
category === cat.value
? 'bg-teal-100 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300 ring-1 ring-teal-400'
: 'bg-gray-50 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-100'
}`}
>
{cat.icon} {cat.label}
</button>
))}
</div>
</div>
{/* Drop zone */}
<div
onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
className={`border-2 border-dashed rounded-xl p-12 text-center transition-colors ${
dragging
? 'border-teal-400 bg-teal-50 dark:bg-teal-900/20'
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400'
}`}
>
<div className="text-4xl mb-3">&#128228;</div>
<div className="text-sm text-gray-600 dark:text-gray-400 mb-2">
Bild oder PDF hierher ziehen
</div>
<label className="inline-block px-4 py-2 bg-teal-600 text-white text-sm rounded-lg cursor-pointer hover:bg-teal-700">
Datei auswaehlen
<input
type="file"
accept="image/*,.pdf"
onChange={handleInputChange}
className="hidden"
/>
</label>
</div>
{error && (
<div className="text-sm text-red-500 bg-red-50 dark:bg-red-900/20 p-3 rounded-lg">
{error}
</div>
)}
</div>
)
}