This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/studio-v2/components/worksheet-editor/OCRImportPanel.tsx
BreakPilot Dev 916ecef476 feat(worksheet-editor): Add OCR import panel for grid analysis data
Add OCRImportPanel component and ocr-integration utilities to import
OCR-analyzed data from the grid detection service into the worksheet editor.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 23:50:35 +01:00

472 lines
16 KiB
TypeScript

'use client'
/**
* OCR Import Panel
*
* Loads OCR export data from the klausur-service API (shared between admin-v2 and studio-v2)
* and imports recognized words as editable text objects onto the Fabric.js canvas.
*/
import { useState, useEffect, useCallback } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { useWorksheet } from '@/lib/worksheet-editor/WorksheetContext'
import type { OCRExportData, OCRWord } from '@/lib/worksheet-editor/ocr-integration'
import { createTextProps, getColumnColor } from '@/lib/worksheet-editor/ocr-integration'
interface OCRImportPanelProps {
isOpen: boolean
onClose: () => void
}
export function OCRImportPanel({ isOpen, onClose }: OCRImportPanelProps) {
const { isDark } = useTheme()
const { canvas, saveToHistory } = useWorksheet()
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [ocrData, setOcrData] = useState<OCRExportData | null>(null)
const [selectedWords, setSelectedWords] = useState<Set<number>>(new Set())
const [importing, setImporting] = useState(false)
const [importSuccess, setImportSuccess] = useState(false)
// Load OCR data when panel opens
useEffect(() => {
if (!isOpen) return
loadOCRData()
}, [isOpen])
const loadOCRData = useCallback(async () => {
setLoading(true)
setError(null)
setOcrData(null)
setSelectedWords(new Set())
setImportSuccess(false)
try {
const res = await fetch('/klausur-api/api/v1/vocab/ocr-export/latest')
if (!res.ok) {
throw new Error('not_found')
}
const data: OCRExportData = await res.json()
setOcrData(data)
// Select all words by default
setSelectedWords(new Set(data.words.map((_, i) => i)))
} catch {
setError(
'Keine OCR-Daten gefunden. Bitte zuerst im OCR-Compare Tool "Zum Editor exportieren" klicken.'
)
} finally {
setLoading(false)
}
}, [])
const toggleWord = useCallback((index: number) => {
setSelectedWords(prev => {
const next = new Set(prev)
if (next.has(index)) {
next.delete(index)
} else {
next.add(index)
}
return next
})
}, [])
const selectAll = useCallback(() => {
if (!ocrData) return
setSelectedWords(new Set(ocrData.words.map((_, i) => i)))
}, [ocrData])
const deselectAll = useCallback(() => {
setSelectedWords(new Set())
}, [])
const handleImport = useCallback(async () => {
if (!ocrData || !canvas || selectedWords.size === 0) return
setImporting(true)
try {
// Dynamic import of fabric to avoid SSR issues
const { IText } = await import('fabric')
// Save current state for undo
if (saveToHistory) {
saveToHistory('OCR Import')
}
const wordsToImport = ocrData.words.filter((_, i) => selectedWords.has(i))
for (const word of wordsToImport) {
const props = createTextProps(word)
const textObj = new IText(props.text, {
left: props.left,
top: props.top,
fontSize: props.fontSize,
fontFamily: props.fontFamily,
fill: props.fill,
editable: true,
})
canvas.add(textObj)
}
canvas.renderAll()
setImportSuccess(true)
// Close after brief delay
setTimeout(() => {
onClose()
}, 1500)
} catch (e) {
console.error('Import failed:', e)
setError('Import fehlgeschlagen. Bitte erneut versuchen.')
} finally {
setImporting(false)
}
}, [ocrData, canvas, selectedWords, saveToHistory, onClose])
if (!isOpen) return null
// Count column types
const columnSummary = ocrData
? ocrData.words.reduce<Record<string, number>>((acc, w) => {
acc[w.column_type] = (acc[w.column_type] || 0) + 1
return acc
}, {})
: {}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Panel */}
<div
className={`relative w-full max-w-2xl max-h-[85vh] rounded-3xl overflow-hidden flex flex-col ${
isDark
? 'bg-slate-900/95 border border-white/10'
: 'bg-white/95 border border-slate-200'
} backdrop-blur-xl`}
>
{/* Header */}
<div
className={`flex items-center justify-between px-6 py-4 border-b ${
isDark ? 'border-white/10' : 'border-slate-200'
}`}
>
<div>
<h2
className={`text-xl font-bold ${
isDark ? 'text-white' : 'text-slate-900'
}`}
>
OCR Daten importieren
</h2>
<p
className={`text-sm mt-0.5 ${
isDark ? 'text-white/50' : 'text-slate-500'
}`}
>
Erkannte Texte aus der Grid-Analyse einfuegen
</p>
</div>
<button
onClick={onClose}
className={`p-2 rounded-lg transition-colors ${
isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'
}`}
>
<svg
className={`w-5 h-5 ${isDark ? 'text-white' : 'text-slate-600'}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{/* Loading */}
{loading && (
<div className="flex flex-col items-center justify-center py-12">
<div className="animate-spin w-8 h-8 border-4 border-green-500 border-t-transparent rounded-full" />
<p
className={`mt-3 text-sm ${
isDark ? 'text-white/50' : 'text-slate-500'
}`}
>
Lade OCR-Daten...
</p>
</div>
)}
{/* Error */}
{error && (
<div
className={`p-4 rounded-xl text-center ${
isDark
? 'bg-red-500/10 text-red-300'
: 'bg-red-50 text-red-700'
}`}
>
<svg
className="w-8 h-8 mx-auto mb-2 opacity-50"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
<p className="text-sm">{error}</p>
<button
onClick={loadOCRData}
className={`mt-3 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
isDark
? 'bg-white/10 text-white hover:bg-white/20'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
Erneut versuchen
</button>
</div>
)}
{/* Success State */}
{importSuccess && (
<div
className={`p-6 rounded-xl text-center ${
isDark
? 'bg-green-500/10 text-green-300'
: 'bg-green-50 text-green-700'
}`}
>
<svg
className="w-12 h-12 mx-auto mb-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
<p className="font-medium">
{selectedWords.size} Texte erfolgreich importiert!
</p>
</div>
)}
{/* OCR Data Display */}
{ocrData && !importSuccess && (
<>
{/* Summary */}
<div
className={`p-4 rounded-xl mb-4 ${
isDark ? 'bg-white/5' : 'bg-slate-50'
}`}
>
<div className="flex flex-wrap gap-3 mb-2">
<span
className={`px-3 py-1 rounded-lg text-sm font-medium ${
isDark
? 'bg-green-500/20 text-green-300'
: 'bg-green-100 text-green-700'
}`}
>
{ocrData.words.length} Woerter
</span>
{Object.entries(columnSummary).map(([type, count]) => (
<span
key={type}
className="px-3 py-1 rounded-lg text-sm font-medium"
style={{
backgroundColor: getColumnColor(type as any) + '15',
color: getColumnColor(type as any),
}}
>
{type === 'english'
? 'Englisch'
: type === 'german'
? 'Deutsch'
: type === 'example'
? 'Beispiel'
: 'Unbekannt'}
: {count}
</span>
))}
</div>
<p
className={`text-xs ${
isDark ? 'text-white/40' : 'text-slate-400'
}`}
>
Session: {ocrData.session_id.slice(0, 8)}... | Seite{' '}
{ocrData.page_number} | Exportiert:{' '}
{new Date(ocrData.exported_at).toLocaleString('de-DE')}
</p>
</div>
{/* Selection Controls */}
<div className="flex items-center justify-between mb-3">
<span
className={`text-sm font-medium ${
isDark ? 'text-white/70' : 'text-slate-600'
}`}
>
{selectedWords.size} von {ocrData.words.length} ausgewaehlt
</span>
<div className="flex gap-2">
<button
onClick={selectAll}
className={`px-3 py-1 rounded-lg text-xs font-medium transition-colors ${
isDark
? 'bg-white/10 text-white/70 hover:bg-white/20'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
Alle
</button>
<button
onClick={deselectAll}
className={`px-3 py-1 rounded-lg text-xs font-medium transition-colors ${
isDark
? 'bg-white/10 text-white/70 hover:bg-white/20'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
Keine
</button>
</div>
</div>
{/* Word List */}
<div className="space-y-1 max-h-[40vh] overflow-y-auto">
{ocrData.words.map((word, idx) => (
<label
key={idx}
className={`flex items-center gap-3 p-2 rounded-lg cursor-pointer transition-colors ${
selectedWords.has(idx)
? isDark
? 'bg-green-500/10'
: 'bg-green-50'
: isDark
? 'hover:bg-white/5'
: 'hover:bg-slate-50'
}`}
>
<input
type="checkbox"
checked={selectedWords.has(idx)}
onChange={() => toggleWord(idx)}
className="rounded border-slate-300"
/>
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{
backgroundColor: getColumnColor(word.column_type),
}}
/>
<span
className={`text-sm flex-1 ${
isDark ? 'text-white' : 'text-slate-900'
}`}
>
{word.text}
</span>
<span
className={`text-xs px-2 py-0.5 rounded ${
isDark ? 'bg-white/10 text-white/40' : 'bg-slate-100 text-slate-400'
}`}
>
{word.column_type === 'english'
? 'EN'
: word.column_type === 'german'
? 'DE'
: word.column_type === 'example'
? 'Ex'
: '?'}
</span>
</label>
))}
</div>
</>
)}
</div>
{/* Footer */}
{ocrData && !importSuccess && (
<div
className={`px-6 py-4 border-t ${
isDark ? 'border-white/10' : 'border-slate-200'
}`}
>
<div className="flex items-center justify-between">
<button
onClick={onClose}
className={`px-4 py-2 rounded-xl text-sm font-medium transition-colors ${
isDark
? 'text-white/60 hover:bg-white/10'
: 'text-slate-600 hover:bg-slate-100'
}`}
>
Abbrechen
</button>
<button
onClick={handleImport}
disabled={importing || selectedWords.size === 0}
className={`px-6 py-2.5 rounded-xl text-sm font-medium transition-colors flex items-center gap-2 ${
selectedWords.size > 0
? 'bg-green-600 text-white hover:bg-green-700'
: isDark
? 'bg-white/10 text-white/30 cursor-not-allowed'
: 'bg-slate-200 text-slate-400 cursor-not-allowed'
}`}
>
{importing ? (
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : (
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
)}
{selectedWords.size} Texte importieren
</button>
</div>
</div>
)}
</div>
</div>
)
}