Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website, Klausur-Service, School-Service, Voice-Service, Geo-Service, BreakPilot Drive, Agent-Core Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
671 lines
26 KiB
TypeScript
671 lines
26 KiB
TypeScript
'use client'
|
|
|
|
/**
|
|
* GroundTruthPanel — Step-through UI for labeling OCR ground truth.
|
|
*
|
|
* Shows page image with SVG overlay (color-coded bounding boxes),
|
|
* alongside crops of the current entry and editable text fields.
|
|
* Keyboard-driven: Enter=confirm, Tab=skip, Arrow keys=navigate.
|
|
*/
|
|
|
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
|
|
// ---------- Types ----------
|
|
|
|
interface BBox {
|
|
x: number
|
|
y: number
|
|
w: number
|
|
h: number
|
|
}
|
|
|
|
interface GTEntry {
|
|
row_index: number
|
|
english: string
|
|
german: string
|
|
example: string
|
|
confidence: number
|
|
bbox: BBox
|
|
bbox_en: BBox
|
|
bbox_de: BBox
|
|
bbox_ex: BBox
|
|
status?: 'pending' | 'confirmed' | 'edited' | 'skipped'
|
|
}
|
|
|
|
interface GroundTruthPanelProps {
|
|
sessionId: string
|
|
selectedPage: number
|
|
pageImageUrl: string
|
|
}
|
|
|
|
// ---------- Helpers ----------
|
|
|
|
const STATUS_COLORS: Record<string, { fill: string; stroke: string }> = {
|
|
current: { fill: 'rgba(250,204,21,0.25)', stroke: '#eab308' }, // yellow
|
|
confirmed: { fill: 'rgba(34,197,94,0.18)', stroke: '#16a34a' }, // green
|
|
edited: { fill: 'rgba(59,130,246,0.18)', stroke: '#2563eb' }, // blue
|
|
skipped: { fill: 'rgba(148,163,184,0.15)', stroke: '#94a3b8' }, // gray
|
|
pending: { fill: 'rgba(0,0,0,0)', stroke: '#cbd5e1' }, // outline only
|
|
}
|
|
|
|
function getEntryColor(entry: GTEntry, index: number, currentIndex: number) {
|
|
if (index === currentIndex) return STATUS_COLORS.current
|
|
return STATUS_COLORS[entry.status || 'pending']
|
|
}
|
|
|
|
// ---------- ImageCrop ----------
|
|
|
|
function ImageCrop({ imageUrl, bbox, naturalWidth, naturalHeight, maxWidth = 380, label }: {
|
|
imageUrl: string
|
|
bbox: BBox
|
|
naturalWidth: number
|
|
naturalHeight: number
|
|
maxWidth?: number
|
|
label?: string
|
|
}) {
|
|
if (!bbox || bbox.w === 0 || bbox.h === 0) return null
|
|
|
|
const cropWPx = (bbox.w / 100) * naturalWidth
|
|
const cropHPx = (bbox.h / 100) * naturalHeight
|
|
if (cropWPx < 1 || cropHPx < 1) return null
|
|
|
|
const scale = maxWidth / cropWPx
|
|
const displayH = cropHPx * scale
|
|
|
|
return (
|
|
<div>
|
|
{label && <div className="text-xs font-medium text-slate-500 mb-1">{label}</div>}
|
|
<div
|
|
className="rounded-lg border border-slate-200 overflow-hidden bg-white"
|
|
style={{ width: maxWidth, height: Math.min(displayH, 120), overflow: 'hidden', position: 'relative' }}
|
|
>
|
|
<img
|
|
src={imageUrl}
|
|
alt=""
|
|
draggable={false}
|
|
style={{
|
|
position: 'absolute',
|
|
width: naturalWidth * scale,
|
|
height: naturalHeight * scale,
|
|
left: -(bbox.x / 100) * naturalWidth * scale,
|
|
top: -(bbox.y / 100) * naturalHeight * scale,
|
|
maxWidth: 'none',
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ---------- Main Component ----------
|
|
|
|
export function GroundTruthPanel({ sessionId, selectedPage, pageImageUrl }: GroundTruthPanelProps) {
|
|
const KLAUSUR_API = '/klausur-api'
|
|
|
|
// State
|
|
const [entries, setEntries] = useState<GTEntry[]>([])
|
|
const [currentIndex, setCurrentIndex] = useState(0)
|
|
const [loading, setLoading] = useState(false)
|
|
const [saving, setSaving] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [imageNatural, setImageNatural] = useState({ w: 0, h: 0 })
|
|
const [showSummary, setShowSummary] = useState(false)
|
|
const [savedMessage, setSavedMessage] = useState<string | null>(null)
|
|
const [isFullscreen, setIsFullscreen] = useState(false)
|
|
const [imageUrl, setImageUrl] = useState(pageImageUrl)
|
|
const [deskewAngle, setDeskewAngle] = useState<number | null>(null)
|
|
|
|
// Editable fields for current entry
|
|
const [editEn, setEditEn] = useState('')
|
|
const [editDe, setEditDe] = useState('')
|
|
const [editEx, setEditEx] = useState('')
|
|
|
|
const panelRef = useRef<HTMLDivElement>(null)
|
|
const enInputRef = useRef<HTMLInputElement>(null)
|
|
|
|
// Reset image URL when page changes
|
|
useEffect(() => {
|
|
setImageUrl(pageImageUrl)
|
|
setDeskewAngle(null)
|
|
}, [pageImageUrl])
|
|
|
|
// Load natural image dimensions
|
|
useEffect(() => {
|
|
if (!imageUrl) return
|
|
const img = new Image()
|
|
img.onload = () => setImageNatural({ w: img.naturalWidth, h: img.naturalHeight })
|
|
img.src = imageUrl
|
|
}, [imageUrl])
|
|
|
|
// Sync edit fields when current entry changes
|
|
useEffect(() => {
|
|
const entry = entries[currentIndex]
|
|
if (entry) {
|
|
setEditEn(entry.english)
|
|
setEditDe(entry.german)
|
|
setEditEx(entry.example)
|
|
}
|
|
}, [currentIndex, entries])
|
|
|
|
// ---------- Actions ----------
|
|
|
|
const handleExtract = useCallback(async () => {
|
|
setLoading(true)
|
|
setError(null)
|
|
setShowSummary(false)
|
|
setSavedMessage(null)
|
|
try {
|
|
const res = await fetch(`${KLAUSUR_API}/api/v1/vocab/sessions/${sessionId}/extract-with-boxes/${selectedPage}`, {
|
|
method: 'POST',
|
|
})
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ detail: res.statusText }))
|
|
throw new Error(err.detail || 'Extract failed')
|
|
}
|
|
const data = await res.json()
|
|
const loaded: GTEntry[] = (data.entries || []).map((e: GTEntry) => ({ ...e, status: 'pending' as const }))
|
|
setEntries(loaded)
|
|
setCurrentIndex(0)
|
|
|
|
// Switch to deskewed image if available
|
|
if (data.deskewed) {
|
|
setImageUrl(`${KLAUSUR_API}/api/v1/vocab/sessions/${sessionId}/deskewed-image/${selectedPage}`)
|
|
setDeskewAngle(data.deskew_angle)
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Extraction failed')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [sessionId, selectedPage])
|
|
|
|
const confirmEntry = useCallback(() => {
|
|
if (entries.length === 0) return
|
|
const entry = entries[currentIndex]
|
|
const isEdited = editEn !== entry.english || editDe !== entry.german || editEx !== entry.example
|
|
const updated = [...entries]
|
|
updated[currentIndex] = {
|
|
...entry,
|
|
english: editEn,
|
|
german: editDe,
|
|
example: editEx,
|
|
status: isEdited ? 'edited' : 'confirmed',
|
|
}
|
|
setEntries(updated)
|
|
if (currentIndex < entries.length - 1) {
|
|
setCurrentIndex(currentIndex + 1)
|
|
} else {
|
|
setShowSummary(true)
|
|
}
|
|
}, [entries, currentIndex, editEn, editDe, editEx])
|
|
|
|
const skipEntry = useCallback(() => {
|
|
if (entries.length === 0) return
|
|
const updated = [...entries]
|
|
updated[currentIndex] = { ...updated[currentIndex], status: 'skipped' }
|
|
setEntries(updated)
|
|
if (currentIndex < entries.length - 1) {
|
|
setCurrentIndex(currentIndex + 1)
|
|
} else {
|
|
setShowSummary(true)
|
|
}
|
|
}, [entries, currentIndex])
|
|
|
|
const goTo = useCallback((idx: number) => {
|
|
if (idx >= 0 && idx < entries.length) {
|
|
setCurrentIndex(idx)
|
|
setShowSummary(false)
|
|
}
|
|
}, [entries.length])
|
|
|
|
const handleSave = useCallback(async () => {
|
|
setSaving(true)
|
|
setError(null)
|
|
try {
|
|
const res = await fetch(`${KLAUSUR_API}/api/v1/vocab/sessions/${sessionId}/ground-truth/${selectedPage}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ entries }),
|
|
})
|
|
if (!res.ok) throw new Error('Save failed')
|
|
const data = await res.json()
|
|
setSavedMessage(`Gespeichert: ${data.confirmed} bestaetigt, ${data.edited} editiert, ${data.skipped} uebersprungen`)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Save failed')
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}, [sessionId, selectedPage, entries])
|
|
|
|
// ---------- Keyboard shortcuts ----------
|
|
|
|
useEffect(() => {
|
|
const handler = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape' && isFullscreen) {
|
|
e.preventDefault()
|
|
setIsFullscreen(false)
|
|
return
|
|
}
|
|
|
|
if (entries.length === 0 || showSummary) return
|
|
|
|
// Don't capture when typing in inputs
|
|
const tag = (e.target as HTMLElement)?.tagName
|
|
const isInput = tag === 'INPUT' || tag === 'TEXTAREA'
|
|
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault()
|
|
confirmEntry()
|
|
} else if (e.key === 'Tab' && !e.shiftKey) {
|
|
if (!isInput) {
|
|
e.preventDefault()
|
|
skipEntry()
|
|
}
|
|
} else if (e.key === 'ArrowLeft' && !isInput) {
|
|
e.preventDefault()
|
|
goTo(currentIndex - 1)
|
|
} else if (e.key === 'ArrowRight' && !isInput) {
|
|
e.preventDefault()
|
|
goTo(currentIndex + 1)
|
|
}
|
|
}
|
|
|
|
window.addEventListener('keydown', handler)
|
|
return () => window.removeEventListener('keydown', handler)
|
|
}, [entries.length, showSummary, isFullscreen, confirmEntry, skipEntry, goTo, currentIndex])
|
|
|
|
// ---------- Computed ----------
|
|
|
|
const currentEntry = entries[currentIndex]
|
|
const confirmedCount = entries.filter(e => e.status === 'confirmed').length
|
|
const editedCount = entries.filter(e => e.status === 'edited').length
|
|
const skippedCount = entries.filter(e => e.status === 'skipped').length
|
|
const processedCount = confirmedCount + editedCount + skippedCount
|
|
const progress = entries.length > 0 ? Math.round((processedCount / entries.length) * 100) : 0
|
|
|
|
// ---------- Render: No entries yet ----------
|
|
|
|
if (entries.length === 0) {
|
|
return (
|
|
<div className="bg-white rounded-xl border border-slate-200 p-8 text-center" ref={panelRef}>
|
|
<h3 className="text-lg font-semibold text-slate-900 mb-2">Ground Truth Labeling</h3>
|
|
<p className="text-sm text-slate-500 mb-6">
|
|
Erkennung starten um Vokabeln mit Positionen zu extrahieren.
|
|
Danach jede Zeile durchgehen und bestaetigen oder korrigieren.
|
|
</p>
|
|
<button
|
|
onClick={handleExtract}
|
|
disabled={loading}
|
|
className="px-6 py-3 bg-teal-600 text-white rounded-lg font-medium hover:bg-teal-700 disabled:opacity-50 transition-colors"
|
|
>
|
|
{loading ? (
|
|
<span className="flex items-center gap-2">
|
|
<svg className="animate-spin w-5 h-5" fill="none" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
</svg>
|
|
Erkennung laeuft...
|
|
</span>
|
|
) : 'Erkennung starten'}
|
|
</button>
|
|
{error && (
|
|
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">{error}</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ---------- Render: Summary ----------
|
|
|
|
if (showSummary) {
|
|
return (
|
|
<div className={`bg-white rounded-xl border border-slate-200 p-6 ${
|
|
isFullscreen ? 'fixed inset-0 z-50 overflow-auto m-0 rounded-none' : ''
|
|
}`} ref={panelRef}>
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-lg font-semibold text-slate-900">Zusammenfassung</h3>
|
|
<button
|
|
onClick={() => setIsFullscreen(!isFullscreen)}
|
|
className="p-1.5 rounded-lg hover:bg-slate-100 text-slate-500 hover:text-slate-700 transition-colors"
|
|
title={isFullscreen ? 'Vollbild verlassen (Esc)' : 'Vollbild'}
|
|
>
|
|
{isFullscreen ? (
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 9V4.5M9 9H4.5M9 9 3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5 5.25 5.25" />
|
|
</svg>
|
|
) : (
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
</div>
|
|
<div className="grid grid-cols-3 gap-4 mb-6">
|
|
<div className="bg-green-50 border border-green-200 rounded-lg p-4 text-center">
|
|
<div className="text-2xl font-bold text-green-700">{confirmedCount}</div>
|
|
<div className="text-sm text-green-600">Bestaetigt</div>
|
|
</div>
|
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-center">
|
|
<div className="text-2xl font-bold text-blue-700">{editedCount}</div>
|
|
<div className="text-sm text-blue-600">Editiert</div>
|
|
</div>
|
|
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4 text-center">
|
|
<div className="text-2xl font-bold text-slate-700">{skippedCount}</div>
|
|
<div className="text-sm text-slate-500">Uebersprungen</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-3">
|
|
<button
|
|
onClick={handleSave}
|
|
disabled={saving}
|
|
className="flex-1 px-4 py-2.5 bg-teal-600 text-white rounded-lg font-medium hover:bg-teal-700 disabled:opacity-50"
|
|
>
|
|
{saving ? 'Speichern...' : 'Ground Truth speichern'}
|
|
</button>
|
|
<button
|
|
onClick={() => { setShowSummary(false); setCurrentIndex(0) }}
|
|
className="px-4 py-2.5 bg-slate-100 text-slate-700 rounded-lg font-medium hover:bg-slate-200"
|
|
>
|
|
Nochmal durchgehen
|
|
</button>
|
|
</div>
|
|
|
|
{savedMessage && (
|
|
<div className="mt-4 p-3 bg-green-50 border border-green-200 rounded-lg text-green-700 text-sm">
|
|
{savedMessage}
|
|
</div>
|
|
)}
|
|
{error && (
|
|
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">{error}</div>
|
|
)}
|
|
|
|
{/* Entry list for quick review */}
|
|
<div className="mt-6 max-h-96 overflow-y-auto">
|
|
<table className="w-full text-sm">
|
|
<thead className="sticky top-0 bg-white">
|
|
<tr className="border-b border-slate-200">
|
|
<th className="text-left py-2 px-2 text-slate-500">#</th>
|
|
<th className="text-left py-2 px-2 text-slate-500">English</th>
|
|
<th className="text-left py-2 px-2 text-slate-500">Deutsch</th>
|
|
<th className="text-left py-2 px-2 text-slate-500">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{entries.map((e, i) => (
|
|
<tr
|
|
key={i}
|
|
onClick={() => goTo(i)}
|
|
className="border-b border-slate-100 hover:bg-slate-50 cursor-pointer"
|
|
>
|
|
<td className="py-1.5 px-2 text-slate-400">{i + 1}</td>
|
|
<td className="py-1.5 px-2">{e.english}</td>
|
|
<td className="py-1.5 px-2">{e.german}</td>
|
|
<td className="py-1.5 px-2">
|
|
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
|
|
e.status === 'confirmed' ? 'bg-green-100 text-green-700' :
|
|
e.status === 'edited' ? 'bg-blue-100 text-blue-700' :
|
|
e.status === 'skipped' ? 'bg-slate-100 text-slate-500' :
|
|
'bg-yellow-100 text-yellow-700'
|
|
}`}>
|
|
{e.status === 'confirmed' ? 'OK' :
|
|
e.status === 'edited' ? 'Editiert' :
|
|
e.status === 'skipped' ? 'Skip' : 'Offen'}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ---------- Render: Main Review UI ----------
|
|
|
|
return (
|
|
<div className={`bg-white rounded-xl border border-slate-200 overflow-hidden ${
|
|
isFullscreen ? 'fixed inset-0 z-50 overflow-auto m-0 rounded-none bg-white' : ''
|
|
}`} ref={panelRef}>
|
|
{/* Header with progress + fullscreen toggle */}
|
|
<div className="flex items-center gap-2 px-4 pt-2">
|
|
<div className="flex-1 h-1.5 bg-slate-100 rounded-full">
|
|
<div
|
|
className="h-full bg-teal-500 transition-all duration-300 rounded-full"
|
|
style={{ width: `${progress}%` }}
|
|
/>
|
|
</div>
|
|
<span className="text-xs text-slate-400 whitespace-nowrap">{currentIndex + 1}/{entries.length}</span>
|
|
{deskewAngle !== null && (
|
|
<span className="text-xs text-teal-600 whitespace-nowrap" title="Bild wurde begradigt">
|
|
{deskewAngle.toFixed(1)}°
|
|
</span>
|
|
)}
|
|
<button
|
|
onClick={() => setIsFullscreen(!isFullscreen)}
|
|
className="p-1.5 rounded-lg hover:bg-slate-100 text-slate-500 hover:text-slate-700 transition-colors"
|
|
title={isFullscreen ? 'Vollbild verlassen (Esc)' : 'Vollbild'}
|
|
>
|
|
{isFullscreen ? (
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 9V4.5M9 9H4.5M9 9 3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5 5.25 5.25" />
|
|
</svg>
|
|
) : (
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
<div className={`flex flex-col ${isFullscreen ? 'lg:flex-row h-[calc(100vh-3rem)]' : 'lg:flex-row'}`}>
|
|
{/* Left: Page image with SVG overlay (2/3) */}
|
|
<div className={`${isFullscreen ? 'lg:w-2/3 p-4 overflow-y-auto h-full' : 'lg:w-2/3 p-4'}`}>
|
|
<div className="relative bg-slate-50 rounded-lg overflow-hidden">
|
|
{imageUrl && (
|
|
<img
|
|
src={imageUrl}
|
|
alt={`Seite ${selectedPage + 1}`}
|
|
className="w-full"
|
|
draggable={false}
|
|
/>
|
|
)}
|
|
{/* SVG Overlay */}
|
|
<svg
|
|
viewBox="0 0 100 100"
|
|
preserveAspectRatio="none"
|
|
className="absolute inset-0 w-full h-full"
|
|
style={{ pointerEvents: 'none' }}
|
|
>
|
|
{entries.map((entry, i) => {
|
|
const colors = getEntryColor(entry, i, currentIndex)
|
|
return (
|
|
<rect
|
|
key={i}
|
|
x={entry.bbox.x}
|
|
y={entry.bbox.y}
|
|
width={entry.bbox.w}
|
|
height={entry.bbox.h}
|
|
fill={colors.fill}
|
|
stroke={colors.stroke}
|
|
strokeWidth={i === currentIndex ? 0.3 : 0.15}
|
|
style={{ cursor: 'pointer', pointerEvents: 'all' }}
|
|
onClick={() => goTo(i)}
|
|
/>
|
|
)
|
|
})}
|
|
</svg>
|
|
</div>
|
|
|
|
{/* Legend */}
|
|
<div className="flex items-center gap-4 mt-3 text-xs text-slate-500">
|
|
<span className="flex items-center gap-1">
|
|
<span className="w-3 h-3 rounded-sm" style={{ background: STATUS_COLORS.current.fill, border: `1px solid ${STATUS_COLORS.current.stroke}` }} /> Aktuell
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<span className="w-3 h-3 rounded-sm" style={{ background: STATUS_COLORS.confirmed.fill, border: `1px solid ${STATUS_COLORS.confirmed.stroke}` }} /> Bestaetigt
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<span className="w-3 h-3 rounded-sm" style={{ background: STATUS_COLORS.edited.fill, border: `1px solid ${STATUS_COLORS.edited.stroke}` }} /> Editiert
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<span className="w-3 h-3 rounded-sm" style={{ background: STATUS_COLORS.skipped.fill, border: `1px solid ${STATUS_COLORS.skipped.stroke}` }} /> Uebersprungen
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right: Crops + Edit fields (1/3) */}
|
|
<div className={`lg:w-1/3 border-l border-slate-200 p-4 space-y-4 ${isFullscreen ? 'overflow-y-auto h-full' : ''}`}>
|
|
{currentEntry && (
|
|
<>
|
|
{/* Row crop */}
|
|
{imageNatural.w > 0 && (
|
|
<ImageCrop
|
|
imageUrl={imageUrl}
|
|
bbox={currentEntry.bbox}
|
|
naturalWidth={imageNatural.w}
|
|
naturalHeight={imageNatural.h}
|
|
label="Gesamte Zeile"
|
|
/>
|
|
)}
|
|
|
|
{/* Column crops */}
|
|
{imageNatural.w > 0 && (
|
|
<div className="grid grid-cols-3 gap-2">
|
|
{currentEntry.bbox_en.w > 0 && (
|
|
<ImageCrop
|
|
imageUrl={imageUrl}
|
|
bbox={currentEntry.bbox_en}
|
|
naturalWidth={imageNatural.w}
|
|
naturalHeight={imageNatural.h}
|
|
maxWidth={120}
|
|
label="EN"
|
|
/>
|
|
)}
|
|
{currentEntry.bbox_de.w > 0 && (
|
|
<ImageCrop
|
|
imageUrl={imageUrl}
|
|
bbox={currentEntry.bbox_de}
|
|
naturalWidth={imageNatural.w}
|
|
naturalHeight={imageNatural.h}
|
|
maxWidth={120}
|
|
label="DE"
|
|
/>
|
|
)}
|
|
{currentEntry.bbox_ex.w > 0 && (
|
|
<ImageCrop
|
|
imageUrl={imageUrl}
|
|
bbox={currentEntry.bbox_ex}
|
|
naturalWidth={imageNatural.w}
|
|
naturalHeight={imageNatural.h}
|
|
maxWidth={120}
|
|
label="EX"
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Confidence badge */}
|
|
<div className="flex items-center gap-2">
|
|
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
|
currentEntry.confidence >= 70 ? 'bg-green-100 text-green-700' :
|
|
currentEntry.confidence >= 40 ? 'bg-yellow-100 text-yellow-700' :
|
|
'bg-red-100 text-red-700'
|
|
}`}>
|
|
Konfidenz: {currentEntry.confidence}%
|
|
</span>
|
|
</div>
|
|
|
|
{/* Edit fields */}
|
|
<div className="space-y-3">
|
|
<div>
|
|
<label className="block text-xs font-medium text-slate-500 mb-1">English</label>
|
|
<input
|
|
ref={enInputRef}
|
|
type="text"
|
|
value={editEn}
|
|
onChange={e => setEditEn(e.target.value)}
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-slate-500 mb-1">Deutsch</label>
|
|
<input
|
|
type="text"
|
|
value={editDe}
|
|
onChange={e => setEditDe(e.target.value)}
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-slate-500 mb-1">Beispiel</label>
|
|
<input
|
|
type="text"
|
|
value={editEx}
|
|
onChange={e => setEditEx(e.target.value)}
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Action buttons */}
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={confirmEntry}
|
|
className="flex-1 px-4 py-2.5 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 text-sm"
|
|
title="Enter"
|
|
>
|
|
OK (Enter)
|
|
</button>
|
|
<button
|
|
onClick={skipEntry}
|
|
className="flex-1 px-4 py-2.5 bg-slate-200 text-slate-700 rounded-lg font-medium hover:bg-slate-300 text-sm"
|
|
title="Tab"
|
|
>
|
|
Skip (Tab)
|
|
</button>
|
|
</div>
|
|
|
|
{/* Navigation */}
|
|
<div className="flex items-center justify-between">
|
|
<button
|
|
onClick={() => goTo(currentIndex - 1)}
|
|
disabled={currentIndex === 0}
|
|
className="px-3 py-1.5 bg-slate-100 rounded-lg text-sm text-slate-600 hover:bg-slate-200 disabled:opacity-30"
|
|
>
|
|
← Zurueck
|
|
</button>
|
|
<span className="text-sm text-slate-500 font-medium">
|
|
{currentIndex + 1} / {entries.length}
|
|
</span>
|
|
<button
|
|
onClick={() => goTo(currentIndex + 1)}
|
|
disabled={currentIndex === entries.length - 1}
|
|
className="px-3 py-1.5 bg-slate-100 rounded-lg text-sm text-slate-600 hover:bg-slate-200 disabled:opacity-30"
|
|
>
|
|
Weiter →
|
|
</button>
|
|
</div>
|
|
|
|
{/* Progress stats */}
|
|
<div className="text-xs text-slate-400 text-center">
|
|
{confirmedCount} bestaetigt · {editedCount} editiert · {skippedCount} uebersprungen · {progress}%
|
|
</div>
|
|
|
|
{/* Keyboard hints */}
|
|
<div className="text-xs text-slate-400 text-center border-t border-slate-100 pt-2">
|
|
Enter = Bestaetigen · Tab = Ueberspringen · ←→ = Navigieren{isFullscreen ? ' \u00B7 Esc = Vollbild verlassen' : ''}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="mx-4 mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">{error}</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|