Initial commit: breakpilot-lehrer - Lehrer KI Platform

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>
This commit is contained in:
Benjamin Boenisch
2026-02-11 23:47:26 +01:00
commit 5a31f52310
1224 changed files with 425430 additions and 0 deletions

View File

@@ -0,0 +1,296 @@
'use client'
import { useState } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { useLanguage } from '@/lib/LanguageContext'
import { useWorksheet } from '@/lib/worksheet-editor/WorksheetContext'
import type { AIImageStyle } from '@/app/worksheet-editor/types'
interface AIImageGeneratorProps {
isOpen: boolean
onClose: () => void
}
const AI_STYLES: { id: AIImageStyle; name: string; description: string }[] = [
{ id: 'educational', name: 'Bildung', description: 'Klare, lehrreiche Illustrationen' },
{ id: 'cartoon', name: 'Cartoon', description: 'Bunte, kindgerechte Zeichnungen' },
{ id: 'realistic', name: 'Realistisch', description: 'Fotorealistische Darstellungen' },
{ id: 'sketch', name: 'Skizze', description: 'Handgezeichneter Stil' },
{ id: 'clipart', name: 'Clipart', description: 'Einfache, flache Grafiken' },
]
const SIZE_OPTIONS = [
{ width: 256, height: 256, label: '256 x 256' },
{ width: 512, height: 512, label: '512 x 512' },
{ width: 512, height: 256, label: '512 x 256 (Breit)' },
{ width: 256, height: 512, label: '256 x 512 (Hoch)' },
]
export function AIImageGenerator({ isOpen, onClose }: AIImageGeneratorProps) {
const { isDark } = useTheme()
const { t } = useLanguage()
const { canvas, setActiveTool, saveToHistory } = useWorksheet()
const [prompt, setPrompt] = useState('')
const [style, setStyle] = useState<AIImageStyle>('educational')
const [sizeIndex, setSizeIndex] = useState(1) // Default: 512x512
const [isGenerating, setIsGenerating] = useState(false)
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const selectedSize = SIZE_OPTIONS[sizeIndex]
const handleGenerate = async () => {
if (!prompt.trim()) {
setError('Bitte geben Sie eine Beschreibung ein.')
return
}
setIsGenerating(true)
setError(null)
setPreviewUrl(null)
const { hostname, protocol } = window.location
const apiBase = hostname === 'localhost' ? 'http://localhost:8086' : `${protocol}//${hostname}:8086`
try {
const response = await fetch(`${apiBase}/api/v1/worksheet/ai-image`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt,
style,
width: selectedSize.width,
height: selectedSize.height
})
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.detail || 'Bildgenerierung fehlgeschlagen')
}
const data = await response.json()
if (data.error) {
throw new Error(data.error)
}
setPreviewUrl(data.image_base64)
} catch (err: any) {
console.error('AI Image generation failed:', err)
setError(err.message || 'Verbindung zum KI-Server fehlgeschlagen. Bitte überprüfen Sie, ob Ollama läuft.')
} finally {
setIsGenerating(false)
}
}
const handleInsert = () => {
if (!previewUrl || !canvas) return
// Add image to canvas
if ((canvas as any).addImage) {
(canvas as any).addImage(previewUrl)
}
// Reset and close
setPrompt('')
setPreviewUrl(null)
setActiveTool('select')
onClose()
}
if (!isOpen) return null
// Glassmorphism styles
const overlayStyle = 'fixed inset-0 bg-black/50 backdrop-blur-sm z-50'
const modalStyle = isDark
? 'backdrop-blur-xl bg-white/10 border border-white/20'
: 'backdrop-blur-xl bg-white/90 border border-black/10 shadow-2xl'
const inputStyle = isDark
? 'bg-white/10 border-white/20 text-white placeholder-white/40 focus:border-purple-400'
: 'bg-white/50 border-black/10 text-slate-900 placeholder-slate-400 focus:border-purple-500'
const labelStyle = isDark ? 'text-white/70' : 'text-slate-600'
return (
<div className={overlayStyle} onClick={onClose}>
<div
className={`fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-xl rounded-3xl p-6 ${modalStyle}`}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${
isDark ? 'bg-purple-500/20' : 'bg-purple-100'
}`}>
<svg className={`w-6 h-6 ${isDark ? 'text-purple-300' : 'text-purple-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
</svg>
</div>
<div>
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
KI-Bild generieren
</h2>
<p className={`text-sm ${labelStyle}`}>
Beschreiben Sie das gewünschte Bild
</p>
</div>
</div>
<button
onClick={onClose}
className={`p-2 rounded-xl transition-colors ${
isDark ? 'hover:bg-white/10 text-white/70' : 'hover:bg-slate-100 text-slate-500'
}`}
>
<svg className="w-6 h-6" 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>
{/* Prompt Input */}
<div className="mb-4">
<label className={`block text-sm font-medium mb-2 ${labelStyle}`}>
Beschreibung
</label>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="z.B. Ein freundlicher Cartoon-Hund, der ein Buch liest"
rows={3}
className={`w-full px-4 py-3 rounded-xl border resize-none focus:outline-none focus:ring-2 focus:ring-purple-500/50 ${inputStyle}`}
/>
</div>
{/* Style Selection */}
<div className="mb-4">
<label className={`block text-sm font-medium mb-2 ${labelStyle}`}>
Stil
</label>
<div className="grid grid-cols-5 gap-2">
{AI_STYLES.map((s) => (
<button
key={s.id}
onClick={() => setStyle(s.id)}
className={`p-3 rounded-xl text-center transition-all ${
style === s.id
? isDark
? 'bg-purple-500/30 text-purple-300 border border-purple-400/50'
: 'bg-purple-100 text-purple-700 border border-purple-300'
: isDark
? 'bg-white/5 text-white/70 border border-transparent hover:bg-white/10'
: 'bg-slate-50 text-slate-600 border border-transparent hover:bg-slate-100'
}`}
>
<span className="text-xs font-medium">{s.name}</span>
</button>
))}
</div>
</div>
{/* Size Selection */}
<div className="mb-6">
<label className={`block text-sm font-medium mb-2 ${labelStyle}`}>
Größe
</label>
<div className="grid grid-cols-4 gap-2">
{SIZE_OPTIONS.map((size, index) => (
<button
key={size.label}
onClick={() => setSizeIndex(index)}
className={`py-2 px-3 rounded-xl text-xs font-medium transition-all ${
sizeIndex === index
? isDark
? 'bg-purple-500/30 text-purple-300 border border-purple-400/50'
: 'bg-purple-100 text-purple-700 border border-purple-300'
: isDark
? 'bg-white/5 text-white/70 border border-transparent hover:bg-white/10'
: 'bg-slate-50 text-slate-600 border border-transparent hover:bg-slate-100'
}`}
>
{size.label}
</button>
))}
</div>
</div>
{/* Error Message */}
{error && (
<div className={`mb-4 p-3 rounded-xl ${
isDark ? 'bg-red-500/20 text-red-300' : 'bg-red-50 text-red-600'
}`}>
<p className="text-sm">{error}</p>
</div>
)}
{/* Preview */}
{previewUrl && (
<div className="mb-6">
<label className={`block text-sm font-medium mb-2 ${labelStyle}`}>
Vorschau
</label>
<div className={`rounded-xl overflow-hidden ${
isDark ? 'bg-white/5' : 'bg-slate-100'
}`}>
<img
src={previewUrl}
alt="Generated preview"
className="w-full h-auto"
/>
</div>
</div>
)}
{/* Actions */}
<div className="flex gap-3">
<button
onClick={handleGenerate}
disabled={isGenerating || !prompt.trim()}
className={`flex-1 py-3 rounded-xl font-medium transition-all flex items-center justify-center gap-2 ${
isGenerating || !prompt.trim()
? isDark
? 'bg-white/10 text-white/30 cursor-not-allowed'
: 'bg-slate-100 text-slate-400 cursor-not-allowed'
: isDark
? 'bg-purple-500/30 text-purple-300 hover:bg-purple-500/40'
: 'bg-purple-600 text-white hover:bg-purple-700'
}`}
>
{isGenerating ? (
<>
<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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Generiere...
</>
) : (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
</svg>
Generieren
</>
)}
</button>
{previewUrl && (
<button
onClick={handleInsert}
className={`flex-1 py-3 rounded-xl font-medium transition-all ${
isDark
? 'bg-green-500/30 text-green-300 hover:bg-green-500/40'
: 'bg-green-600 text-white hover:bg-green-700'
}`}
>
Einfügen
</button>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,257 @@
'use client'
import { useState, useCallback, useRef, useEffect } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { useWorksheet } from '@/lib/worksheet-editor/WorksheetContext'
interface AIPromptBarProps {
className?: string
}
export function AIPromptBar({ className = '' }: AIPromptBarProps) {
const { isDark } = useTheme()
const { canvas, saveToHistory } = useWorksheet()
const [prompt, setPrompt] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [lastResult, setLastResult] = useState<string | null>(null)
const [showHistory, setShowHistory] = useState(false)
const [promptHistory, setPromptHistory] = useState<string[]>([])
const inputRef = useRef<HTMLInputElement>(null)
// Load prompt history from localStorage
useEffect(() => {
const stored = localStorage.getItem('worksheet_prompt_history')
if (stored) {
try {
setPromptHistory(JSON.parse(stored))
} catch (e) {
// Ignore parse errors
}
}
}, [])
// Save prompt to history
const addToHistory = (newPrompt: string) => {
const updated = [newPrompt, ...promptHistory.filter(p => p !== newPrompt)].slice(0, 10)
setPromptHistory(updated)
localStorage.setItem('worksheet_prompt_history', JSON.stringify(updated))
}
// Handle AI prompt submission
const handleSubmit = useCallback(async () => {
if (!prompt.trim() || !canvas || isLoading) return
setIsLoading(true)
setLastResult(null)
addToHistory(prompt.trim())
try {
// Get current canvas state
const canvasJSON = JSON.stringify(canvas.toJSON())
// Get API base URL (use same protocol as page)
const { hostname, protocol } = window.location
const apiBase = hostname === 'localhost' ? 'http://localhost:8086' : `${protocol}//${hostname}:8086`
// Send prompt to AI endpoint with timeout (3 minutes for large models)
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 180000) // 3 minutes
try {
const response = await fetch(`${apiBase}/api/v1/worksheet/ai-modify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: prompt.trim(),
canvas_json: canvasJSON,
model: 'qwen2.5vl:32b'
}),
signal: controller.signal
})
clearTimeout(timeoutId)
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Unknown error' }))
throw new Error(error.detail || `HTTP ${response.status}`)
}
const result = await response.json()
// Apply changes to canvas if we got new JSON
if (result.modified_canvas_json) {
const parsedJSON = JSON.parse(result.modified_canvas_json)
// Preserve current background if not specified in response
const currentBg = canvas.backgroundColor
canvas.loadFromJSON(parsedJSON, () => {
// Ensure white background is set (Fabric.js sometimes loses it)
if (!canvas.backgroundColor || canvas.backgroundColor === 'transparent' || canvas.backgroundColor === '#000000') {
canvas.backgroundColor = parsedJSON.background || currentBg || '#ffffff'
}
canvas.renderAll()
saveToHistory(`AI: ${prompt.trim().substring(0, 30)}`)
})
setLastResult(result.message || 'Aenderungen angewendet')
} else if (result.message) {
setLastResult(result.message)
}
setPrompt('')
} catch (fetchError) {
clearTimeout(timeoutId)
throw fetchError
}
} catch (error) {
console.error('AI prompt error:', error)
if (error instanceof Error && error.name === 'AbortError') {
setLastResult('Zeitüberschreitung - das KI-Modell braucht zu lange. Bitte versuchen Sie es erneut.')
} else {
setLastResult(`Fehler: ${error instanceof Error ? error.message : 'Unbekannter Fehler'}`)
}
} finally {
setIsLoading(false)
}
}, [prompt, canvas, isLoading, saveToHistory])
// Handle keyboard shortcuts
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit()
}
if (e.key === 'ArrowUp' && promptHistory.length > 0 && !prompt) {
e.preventDefault()
setPrompt(promptHistory[0])
}
if (e.key === 'Escape') {
setPrompt('')
setShowHistory(false)
inputRef.current?.blur()
}
}
// Example prompts
const examplePrompts = [
'Fuege eine Ueberschrift "Arbeitsblatt" oben hinzu',
'Erstelle ein 3x4 Raster fuer Aufgaben',
'Fuege Linien fuer Schueler-Antworten hinzu',
'Mache alle Texte groesser',
'Zentriere alle Elemente',
'Fuege Nummerierung 1-10 hinzu'
]
// Glassmorphism styles
const barStyle = isDark
? 'backdrop-blur-xl bg-white/10 border border-white/20'
: 'backdrop-blur-xl bg-white/70 border border-black/10 shadow-lg'
const inputStyle = isDark
? 'bg-white/10 border-white/20 text-white placeholder-white/40 focus:border-purple-400 focus:ring-purple-400/30'
: 'bg-white/50 border-black/10 text-slate-900 placeholder-slate-400 focus:border-purple-500 focus:ring-purple-500/30'
const buttonStyle = isDark
? 'bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:shadow-lg hover:shadow-purple-500/30'
: 'bg-gradient-to-r from-purple-600 to-pink-600 text-white hover:shadow-lg hover:shadow-purple-600/30'
return (
<div className={`rounded-2xl p-4 ${barStyle} ${className}`}>
{/* Prompt Input */}
<div className="flex items-center gap-3">
<div className="flex-shrink-0">
<span className="text-xl"></span>
</div>
<div className="flex-1 relative">
<input
ref={inputRef}
type="text"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => setShowHistory(true)}
onBlur={() => setTimeout(() => setShowHistory(false), 200)}
placeholder="Beschreibe, was du aendern moechtest... (z.B. 'Fuege eine Ueberschrift hinzu')"
disabled={isLoading}
className={`w-full px-4 py-3 rounded-xl border text-sm transition-all focus:outline-none focus:ring-2 ${inputStyle} ${
isLoading ? 'opacity-50 cursor-not-allowed' : ''
}`}
/>
{/* History Dropdown */}
{showHistory && promptHistory.length > 0 && !prompt && (
<div className={`absolute top-full left-0 right-0 mt-2 rounded-xl border overflow-hidden z-50 ${
isDark ? 'bg-slate-800 border-white/20' : 'bg-white border-slate-200 shadow-lg'
}`}>
<div className={`px-3 py-2 text-xs font-medium ${isDark ? 'text-white/50' : 'text-slate-400'}`}>
Letzte Prompts
</div>
{promptHistory.map((historyItem, idx) => (
<button
key={idx}
onClick={() => setPrompt(historyItem)}
className={`w-full text-left px-3 py-2 text-sm transition-colors ${
isDark ? 'text-white/80 hover:bg-white/10' : 'text-slate-700 hover:bg-slate-50'
}`}
>
{historyItem}
</button>
))}
</div>
)}
</div>
<button
onClick={handleSubmit}
disabled={isLoading || !prompt.trim()}
className={`flex-shrink-0 px-5 py-3 rounded-xl font-medium transition-all disabled:opacity-50 disabled:cursor-not-allowed ${buttonStyle}`}
>
{isLoading ? (
<div className="flex items-center gap-2">
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
<span>KI denkt...</span>
</div>
) : (
<div className="flex items-center gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span>Anwenden</span>
</div>
)}
</button>
</div>
{/* Result Message */}
{lastResult && (
<div className={`mt-3 px-4 py-2 rounded-lg text-sm ${
lastResult.startsWith('Fehler')
? isDark ? 'bg-red-500/20 text-red-300' : 'bg-red-50 text-red-700'
: isDark ? 'bg-green-500/20 text-green-300' : 'bg-green-50 text-green-700'
}`}>
{lastResult}
</div>
)}
{/* Example Prompts */}
{!prompt && !isLoading && (
<div className="mt-3 flex flex-wrap gap-2">
{examplePrompts.slice(0, 4).map((example, idx) => (
<button
key={idx}
onClick={() => setPrompt(example)}
className={`px-3 py-1 rounded-lg text-xs font-medium transition-colors ${
isDark
? 'bg-white/10 text-white/70 hover:bg-white/20 hover:text-white'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200 hover:text-slate-900'
}`}
>
{example}
</button>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,136 @@
'use client'
import { useTheme } from '@/lib/ThemeContext'
import { useLanguage } from '@/lib/LanguageContext'
import { useWorksheet } from '@/lib/worksheet-editor/WorksheetContext'
interface CanvasControlsProps {
className?: string
}
export function CanvasControls({ className = '' }: CanvasControlsProps) {
const { isDark } = useTheme()
const { t } = useLanguage()
const {
zoom,
setZoom,
zoomIn,
zoomOut,
zoomToFit,
showGrid,
setShowGrid,
snapToGrid,
setSnapToGrid,
gridSize,
setGridSize
} = useWorksheet()
// Glassmorphism styles
const controlsStyle = isDark
? 'backdrop-blur-xl bg-white/10 border border-white/20'
: 'backdrop-blur-xl bg-white/70 border border-black/10 shadow-xl'
const buttonStyle = (active: boolean) => isDark
? active
? 'bg-purple-500/30 text-purple-300'
: 'text-white/70 hover:bg-white/10 hover:text-white'
: active
? 'bg-purple-100 text-purple-700'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
const labelStyle = isDark ? 'text-white/70' : 'text-slate-600'
return (
<div className={`flex items-center gap-4 p-3 rounded-2xl ${controlsStyle} ${className}`}>
{/* Zoom Controls */}
<div className="flex items-center gap-2">
<button
onClick={zoomOut}
className={`w-8 h-8 flex items-center justify-center rounded-lg transition-colors ${buttonStyle(false)}`}
title="Verkleinern"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
</svg>
</button>
<div className={`w-16 text-center text-sm font-medium ${labelStyle}`}>
{Math.round(zoom * 100)}%
</div>
<button
onClick={zoomIn}
className={`w-8 h-8 flex items-center justify-center rounded-lg transition-colors ${buttonStyle(false)}`}
title="Vergrößern"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</button>
<button
onClick={zoomToFit}
className={`px-3 py-1.5 text-sm rounded-lg transition-colors ${buttonStyle(false)}`}
title="An Fenster anpassen"
>
Fit
</button>
{/* Zoom Slider */}
<input
type="range"
min="25"
max="400"
value={zoom * 100}
onChange={(e) => setZoom(parseInt(e.target.value) / 100)}
className="w-24"
/>
</div>
{/* Divider */}
<div className={`h-8 border-l ${isDark ? 'border-white/20' : 'border-slate-200'}`} />
{/* Grid Controls */}
<div className="flex items-center gap-2">
<button
onClick={() => setShowGrid(!showGrid)}
className={`px-3 py-1.5 text-sm rounded-lg transition-colors flex items-center gap-2 ${buttonStyle(showGrid)}`}
title="Raster anzeigen"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5a1 1 0 011-1h14a1 1 0 011 1v14a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 9h16M4 13h16M4 17h16M9 4v16M13 4v16M17 4v16" />
</svg>
Raster
</button>
<button
onClick={() => setSnapToGrid(!snapToGrid)}
className={`px-3 py-1.5 text-sm rounded-lg transition-colors flex items-center gap-2 ${buttonStyle(snapToGrid)}`}
title="Am Raster ausrichten"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
Snap
</button>
{/* Grid Size */}
<select
value={gridSize}
onChange={(e) => setGridSize(parseInt(e.target.value))}
className={`px-2 py-1.5 text-sm rounded-lg border ${
isDark
? 'bg-white/10 border-white/20 text-white'
: 'bg-white/50 border-black/10 text-slate-900'
}`}
title="Rastergröße"
>
<option value="5">5mm</option>
<option value="10">10mm</option>
<option value="15">15mm</option>
<option value="20">20mm</option>
</select>
</div>
</div>
)
}

View File

@@ -0,0 +1,765 @@
'use client'
import { useState, useCallback } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { useWorksheet } from '@/lib/worksheet-editor/WorksheetContext'
interface CleanupPanelProps {
isOpen: boolean
onClose: () => void
}
interface CleanupCapabilities {
opencv_available: boolean
lama_available: boolean
paddleocr_available: boolean
}
interface PreviewResult {
has_handwriting: boolean
confidence: number
handwriting_ratio: number
image_width: number
image_height: number
estimated_times_ms: {
detection: number
inpainting: number
reconstruction: number
total: number
}
capabilities: {
lama_available: boolean
}
}
interface PipelineResult {
success: boolean
handwriting_detected: boolean
handwriting_removed: boolean
layout_reconstructed: boolean
cleaned_image_base64?: string
fabric_json?: any
metadata: any
}
export function CleanupPanel({ isOpen, onClose }: CleanupPanelProps) {
const { isDark } = useTheme()
const { canvas, saveToHistory } = useWorksheet()
const [file, setFile] = useState<File | null>(null)
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const [cleanedUrl, setCleanedUrl] = useState<string | null>(null)
const [maskUrl, setMaskUrl] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [isPreviewing, setIsPreviewing] = useState(false)
const [isProcessing, setIsProcessing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [previewResult, setPreviewResult] = useState<PreviewResult | null>(null)
const [pipelineResult, setPipelineResult] = useState<PipelineResult | null>(null)
const [capabilities, setCapabilities] = useState<CleanupCapabilities | null>(null)
// Options
const [removeHandwriting, setRemoveHandwriting] = useState(true)
const [reconstructLayout, setReconstructLayout] = useState(true)
const [inpaintingMethod, setInpaintingMethod] = useState<string>('auto')
// Step tracking
const [currentStep, setCurrentStep] = useState<'upload' | 'preview' | 'result'>('upload')
const getApiUrl = useCallback(() => {
if (typeof window === 'undefined') return 'http://localhost:8086'
const { hostname, protocol } = window.location
return hostname === 'localhost' ? 'http://localhost:8086' : `${protocol}//${hostname}:8086`
}, [])
// Load capabilities on mount
const loadCapabilities = useCallback(async () => {
try {
const response = await fetch(`${getApiUrl()}/api/v1/worksheet/capabilities`)
if (response.ok) {
const data = await response.json()
setCapabilities(data)
}
} catch (err) {
console.error('Failed to load capabilities:', err)
}
}, [getApiUrl])
// Handle file selection
const handleFileSelect = useCallback((selectedFile: File) => {
setFile(selectedFile)
setError(null)
setPreviewResult(null)
setPipelineResult(null)
setCleanedUrl(null)
setMaskUrl(null)
// Create preview URL
const url = URL.createObjectURL(selectedFile)
setPreviewUrl(url)
setCurrentStep('upload')
}, [])
// Handle drop
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
const droppedFile = e.dataTransfer.files[0]
if (droppedFile && droppedFile.type.startsWith('image/')) {
handleFileSelect(droppedFile)
}
}, [handleFileSelect])
// Preview cleanup
const handlePreview = useCallback(async () => {
if (!file) return
setIsPreviewing(true)
setError(null)
try {
const formData = new FormData()
formData.append('image', file)
const response = await fetch(`${getApiUrl()}/api/v1/worksheet/preview-cleanup`, {
method: 'POST',
body: formData
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const result = await response.json()
setPreviewResult(result)
setCurrentStep('preview')
// Also load capabilities
await loadCapabilities()
} catch (err) {
console.error('Preview failed:', err)
setError(err instanceof Error ? err.message : 'Vorschau fehlgeschlagen')
} finally {
setIsPreviewing(false)
}
}, [file, getApiUrl, loadCapabilities])
// Run full cleanup pipeline
const handleCleanup = useCallback(async () => {
if (!file) return
setIsProcessing(true)
setError(null)
try {
const formData = new FormData()
formData.append('image', file)
formData.append('remove_handwriting', String(removeHandwriting))
formData.append('reconstruct', String(reconstructLayout))
formData.append('inpainting_method', inpaintingMethod)
const response = await fetch(`${getApiUrl()}/api/v1/worksheet/cleanup-pipeline`, {
method: 'POST',
body: formData
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }))
throw new Error(errorData.detail || `HTTP ${response.status}`)
}
const result: PipelineResult = await response.json()
setPipelineResult(result)
// Create cleaned image URL
if (result.cleaned_image_base64) {
const cleanedBlob = await fetch(`data:image/png;base64,${result.cleaned_image_base64}`).then(r => r.blob())
setCleanedUrl(URL.createObjectURL(cleanedBlob))
}
setCurrentStep('result')
} catch (err) {
console.error('Cleanup failed:', err)
setError(err instanceof Error ? err.message : 'Bereinigung fehlgeschlagen')
} finally {
setIsProcessing(false)
}
}, [file, removeHandwriting, reconstructLayout, inpaintingMethod, getApiUrl])
// Import to canvas
const handleImportToCanvas = useCallback(async () => {
if (!pipelineResult?.fabric_json || !canvas) return
try {
// Clear canvas and load new content
canvas.clear()
canvas.loadFromJSON(pipelineResult.fabric_json, () => {
canvas.renderAll()
saveToHistory('Imported: Cleaned worksheet')
})
onClose()
} catch (err) {
console.error('Import failed:', err)
setError('Import in Canvas fehlgeschlagen')
}
}, [pipelineResult, canvas, saveToHistory, onClose])
// Get detection mask
const handleGetMask = useCallback(async () => {
if (!file) return
try {
const formData = new FormData()
formData.append('image', file)
const response = await fetch(`${getApiUrl()}/api/v1/worksheet/detect-handwriting/mask`, {
method: 'POST',
body: formData
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const blob = await response.blob()
setMaskUrl(URL.createObjectURL(blob))
} catch (err) {
console.error('Mask fetch failed:', err)
}
}, [file, getApiUrl])
if (!isOpen) return null
// Styles
const overlayStyle = 'fixed inset-0 bg-black/50 backdrop-blur-sm z-50'
const modalStyle = isDark
? 'backdrop-blur-xl bg-white/10 border border-white/20'
: 'backdrop-blur-xl bg-white/90 border border-black/10 shadow-2xl'
const labelStyle = isDark ? 'text-white/70' : 'text-slate-600'
const cardStyle = isDark
? 'bg-white/5 border-white/10 hover:bg-white/10'
: 'bg-white/50 border-slate-200 hover:bg-slate-50'
return (
<div className={overlayStyle} onClick={onClose}>
<div
className={`fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-4xl max-h-[90vh] rounded-3xl p-6 overflow-hidden flex flex-col ${modalStyle}`}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
isDark ? 'bg-orange-500/20' : 'bg-orange-100'
}`}>
<svg className={`w-7 h-7 ${isDark ? 'text-orange-300' : 'text-orange-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
</svg>
</div>
<div>
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Arbeitsblatt bereinigen
</h2>
<p className={`text-sm ${labelStyle}`}>
Handschrift entfernen und Layout rekonstruieren
</p>
</div>
</div>
<button
onClick={onClose}
className={`p-2 rounded-xl transition-colors ${
isDark ? 'hover:bg-white/10 text-white/70' : 'hover:bg-slate-100 text-slate-500'
}`}
>
<svg className="w-6 h-6" 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>
{/* Step Indicator */}
<div className="flex items-center gap-2 mb-6">
{['upload', 'preview', 'result'].map((step, idx) => (
<div key={step} className="flex items-center">
<div className={`w-8 h-8 rounded-full flex items-center justify-center font-medium ${
currentStep === step
? isDark ? 'bg-purple-500 text-white' : 'bg-purple-600 text-white'
: idx < ['upload', 'preview', 'result'].indexOf(currentStep)
? isDark ? 'bg-green-500 text-white' : 'bg-green-600 text-white'
: isDark ? 'bg-white/10 text-white/50' : 'bg-slate-200 text-slate-400'
}`}>
{idx < ['upload', 'preview', 'result'].indexOf(currentStep) ? '✓' : idx + 1}
</div>
{idx < 2 && (
<div className={`w-12 h-0.5 ${
idx < ['upload', 'preview', 'result'].indexOf(currentStep)
? isDark ? 'bg-green-500' : 'bg-green-600'
: isDark ? 'bg-white/20' : 'bg-slate-200'
}`} />
)}
</div>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
{/* Error Display */}
{error && (
<div className={`p-4 rounded-xl mb-4 ${
isDark ? 'bg-red-500/20 text-red-300' : 'bg-red-50 text-red-700'
}`}>
{error}
</div>
)}
{/* Step 1: Upload */}
{currentStep === 'upload' && (
<div className="space-y-6">
{/* Dropzone */}
<div
className={`border-2 border-dashed rounded-2xl p-8 text-center cursor-pointer transition-all ${
isDark
? 'border-white/20 hover:border-purple-400/50 hover:bg-white/5'
: 'border-slate-300 hover:border-purple-400 hover:bg-purple-50/30'
}`}
onDrop={handleDrop}
onDragOver={(e) => e.preventDefault()}
onClick={() => document.getElementById('file-input')?.click()}
>
<input
id="file-input"
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && handleFileSelect(e.target.files[0])}
className="hidden"
/>
{previewUrl ? (
<div className="space-y-4">
<img
src={previewUrl}
alt="Preview"
className="max-h-64 mx-auto rounded-xl shadow-lg"
/>
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
{file?.name}
</p>
<p className={`text-sm ${labelStyle}`}>
Klicke zum Ändern oder ziehe eine andere Datei hierher
</p>
</div>
) : (
<>
<svg className={`w-16 h-16 mx-auto mb-4 ${isDark ? 'text-white/30' : 'text-slate-300'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p className={`text-lg font-medium mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Bild hochladen
</p>
<p className={labelStyle}>
Ziehe ein Bild hierher oder klicke zum Auswählen
</p>
<p className={`text-xs mt-2 ${labelStyle}`}>
Unterstützt: PNG, JPG, JPEG
</p>
</>
)}
</div>
{/* Options */}
{file && (
<div className="space-y-4">
<h3 className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
Optionen
</h3>
<label className={`flex items-center gap-3 p-4 rounded-xl border cursor-pointer transition-all ${cardStyle}`}>
<input
type="checkbox"
checked={removeHandwriting}
onChange={(e) => setRemoveHandwriting(e.target.checked)}
className="w-5 h-5 rounded"
/>
<div>
<span className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
Handschrift entfernen
</span>
<p className={`text-sm ${labelStyle}`}>
Erkennt und entfernt handgeschriebene Inhalte
</p>
</div>
</label>
<label className={`flex items-center gap-3 p-4 rounded-xl border cursor-pointer transition-all ${cardStyle}`}>
<input
type="checkbox"
checked={reconstructLayout}
onChange={(e) => setReconstructLayout(e.target.checked)}
className="w-5 h-5 rounded"
/>
<div>
<span className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
Layout rekonstruieren
</span>
<p className={`text-sm ${labelStyle}`}>
Erstellt bearbeitbare Fabric.js Objekte
</p>
</div>
</label>
{removeHandwriting && (
<div className="space-y-2">
<label className={`block text-sm font-medium ${labelStyle}`}>
Inpainting-Methode
</label>
<select
value={inpaintingMethod}
onChange={(e) => setInpaintingMethod(e.target.value)}
className={`w-full p-3 rounded-xl border ${
isDark
? 'bg-white/10 border-white/20 text-white'
: 'bg-white border-slate-200 text-slate-900'
}`}
>
<option value="auto">Automatisch (empfohlen)</option>
<option value="opencv_telea">OpenCV Telea (schnell)</option>
<option value="opencv_ns">OpenCV NS (glatter)</option>
{capabilities?.lama_available && (
<option value="lama">LaMa (beste Qualität)</option>
)}
</select>
</div>
)}
</div>
)}
</div>
)}
{/* Step 2: Preview */}
{currentStep === 'preview' && previewResult && (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-6">
{/* Detection Result */}
<div className={`p-4 rounded-xl border ${cardStyle}`}>
<h3 className={`font-medium mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Erkennungsergebnis
</h3>
<div className="space-y-2">
<div className="flex justify-between">
<span className={labelStyle}>Handschrift gefunden:</span>
<span className={previewResult.has_handwriting
? isDark ? 'text-orange-300' : 'text-orange-600'
: isDark ? 'text-green-300' : 'text-green-600'
}>
{previewResult.has_handwriting ? 'Ja' : 'Nein'}
</span>
</div>
<div className="flex justify-between">
<span className={labelStyle}>Konfidenz:</span>
<span className={isDark ? 'text-white' : 'text-slate-900'}>
{(previewResult.confidence * 100).toFixed(0)}%
</span>
</div>
<div className="flex justify-between">
<span className={labelStyle}>Handschrift-Anteil:</span>
<span className={isDark ? 'text-white' : 'text-slate-900'}>
{(previewResult.handwriting_ratio * 100).toFixed(1)}%
</span>
</div>
<div className="flex justify-between">
<span className={labelStyle}>Bildgröße:</span>
<span className={isDark ? 'text-white' : 'text-slate-900'}>
{previewResult.image_width} × {previewResult.image_height}
</span>
</div>
</div>
</div>
{/* Time Estimates */}
<div className={`p-4 rounded-xl border ${cardStyle}`}>
<h3 className={`font-medium mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Geschätzte Zeit
</h3>
<div className="space-y-2">
<div className="flex justify-between">
<span className={labelStyle}>Erkennung:</span>
<span className={isDark ? 'text-white' : 'text-slate-900'}>
~{Math.round(previewResult.estimated_times_ms.detection / 1000)}s
</span>
</div>
{removeHandwriting && previewResult.has_handwriting && (
<div className="flex justify-between">
<span className={labelStyle}>Bereinigung:</span>
<span className={isDark ? 'text-white' : 'text-slate-900'}>
~{Math.round(previewResult.estimated_times_ms.inpainting / 1000)}s
</span>
</div>
)}
{reconstructLayout && (
<div className="flex justify-between">
<span className={labelStyle}>Layout:</span>
<span className={isDark ? 'text-white' : 'text-slate-900'}>
~{Math.round(previewResult.estimated_times_ms.reconstruction / 1000)}s
</span>
</div>
)}
<div className={`flex justify-between pt-2 border-t ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
<span className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>Gesamt:</span>
<span className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
~{Math.round(previewResult.estimated_times_ms.total / 1000)}s
</span>
</div>
</div>
</div>
</div>
{/* Preview Images */}
<div className="grid grid-cols-2 gap-6">
<div>
<h3 className={`font-medium mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Original
</h3>
{previewUrl && (
<img
src={previewUrl}
alt="Original"
className="w-full rounded-xl shadow-lg"
/>
)}
</div>
<div>
<div className="flex items-center justify-between mb-3">
<h3 className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
Maske
</h3>
<button
onClick={handleGetMask}
className={`text-sm px-3 py-1 rounded-lg ${
isDark
? 'bg-white/10 text-white/70 hover:bg-white/20'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
Maske laden
</button>
</div>
{maskUrl ? (
<img
src={maskUrl}
alt="Mask"
className="w-full rounded-xl shadow-lg"
/>
) : (
<div className={`aspect-video rounded-xl flex items-center justify-center ${
isDark ? 'bg-white/5' : 'bg-slate-100'
}`}>
<span className={labelStyle}>Klicke "Maske laden" zum Anzeigen</span>
</div>
)}
</div>
</div>
</div>
)}
{/* Step 3: Result */}
{currentStep === 'result' && pipelineResult && (
<div className="space-y-6">
{/* Status */}
<div className={`p-4 rounded-xl ${
pipelineResult.success
? isDark ? 'bg-green-500/20' : 'bg-green-50'
: isDark ? 'bg-red-500/20' : 'bg-red-50'
}`}>
<div className="flex items-center gap-3">
{pipelineResult.success ? (
<svg className={`w-6 h-6 ${isDark ? 'text-green-300' : 'text-green-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
) : (
<svg className={`w-6 h-6 ${isDark ? 'text-red-300' : 'text-red-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)}
<div>
<h3 className={`font-medium ${
pipelineResult.success
? isDark ? 'text-green-300' : 'text-green-700'
: isDark ? 'text-red-300' : 'text-red-700'
}`}>
{pipelineResult.success ? 'Erfolgreich bereinigt' : 'Bereinigung fehlgeschlagen'}
</h3>
<p className={`text-sm ${labelStyle}`}>
{pipelineResult.handwriting_detected
? pipelineResult.handwriting_removed
? 'Handschrift wurde erkannt und entfernt'
: 'Handschrift erkannt, aber nicht entfernt'
: 'Keine Handschrift gefunden'}
</p>
</div>
</div>
</div>
{/* Result Images */}
<div className="grid grid-cols-2 gap-6">
<div>
<h3 className={`font-medium mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Original
</h3>
{previewUrl && (
<img
src={previewUrl}
alt="Original"
className="w-full rounded-xl shadow-lg"
/>
)}
</div>
<div>
<h3 className={`font-medium mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Bereinigt
</h3>
{cleanedUrl ? (
<img
src={cleanedUrl}
alt="Cleaned"
className="w-full rounded-xl shadow-lg"
/>
) : (
<div className={`aspect-video rounded-xl flex items-center justify-center ${
isDark ? 'bg-white/5' : 'bg-slate-100'
}`}>
<span className={labelStyle}>Kein Bild</span>
</div>
)}
</div>
</div>
{/* Layout Info */}
{pipelineResult.layout_reconstructed && pipelineResult.metadata?.layout && (
<div className={`p-4 rounded-xl border ${cardStyle}`}>
<h3 className={`font-medium mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Layout-Rekonstruktion
</h3>
<div className="grid grid-cols-3 gap-4">
<div>
<span className={labelStyle}>Elemente:</span>
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
{pipelineResult.metadata.layout.element_count}
</p>
</div>
<div>
<span className={labelStyle}>Tabellen:</span>
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
{pipelineResult.metadata.layout.table_count}
</p>
</div>
<div>
<span className={labelStyle}>Größe:</span>
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
{pipelineResult.metadata.layout.page_width} × {pipelineResult.metadata.layout.page_height}
</p>
</div>
</div>
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="mt-6 flex items-center justify-between">
<div>
{currentStep !== 'upload' && (
<button
onClick={() => setCurrentStep(currentStep === 'result' ? 'preview' : 'upload')}
className={`px-4 py-2 rounded-xl font-medium transition-colors ${
isDark
? 'bg-white/10 text-white hover:bg-white/20'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
Zurück
</button>
)}
</div>
<div className="flex items-center gap-3">
<button
onClick={onClose}
className={`px-5 py-2.5 rounded-xl font-medium transition-colors ${
isDark
? 'bg-white/10 text-white hover:bg-white/20'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
Abbrechen
</button>
{currentStep === 'upload' && file && (
<button
onClick={handlePreview}
disabled={isPreviewing}
className={`px-5 py-2.5 rounded-xl font-medium transition-all flex items-center gap-2 ${
isDark
? 'bg-purple-500/30 text-purple-300 hover:bg-purple-500/40'
: 'bg-purple-600 text-white hover:bg-purple-700'
} disabled:opacity-50`}
>
{isPreviewing ? (
<>
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
Analysiere...
</>
) : (
'Vorschau'
)}
</button>
)}
{currentStep === 'preview' && (
<button
onClick={handleCleanup}
disabled={isProcessing}
className={`px-5 py-2.5 rounded-xl font-medium transition-all flex items-center gap-2 ${
isDark
? 'bg-gradient-to-r from-orange-500 to-red-500 text-white hover:shadow-lg hover:shadow-orange-500/30'
: 'bg-gradient-to-r from-orange-600 to-red-600 text-white hover:shadow-lg hover:shadow-orange-600/30'
} disabled:opacity-50`}
>
{isProcessing ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Verarbeite...
</>
) : (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
</svg>
Bereinigen
</>
)}
</button>
)}
{currentStep === 'result' && pipelineResult?.success && (
<button
onClick={handleImportToCanvas}
className={`px-5 py-2.5 rounded-xl font-medium transition-all flex items-center gap-2 ${
isDark
? 'bg-gradient-to-r from-green-500 to-emerald-500 text-white hover:shadow-lg hover:shadow-green-500/30'
: 'bg-gradient-to-r from-green-600 to-emerald-600 text-white hover:shadow-lg hover:shadow-green-600/30'
}`}
>
<svg className="w-5 h-5" 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-8l-4-4m0 0L8 8m4-4v12" />
</svg>
In Editor übernehmen
</button>
)}
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,363 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { useLanguage } from '@/lib/LanguageContext'
import { useWorksheet } from '@/lib/worksheet-editor/WorksheetContext'
interface Session {
id: string
name: string
description?: string
vocabulary_count: number
page_count: number
status: string
created_at?: string
}
interface DocumentImporterProps {
isOpen: boolean
onClose: () => void
}
export function DocumentImporter({ isOpen, onClose }: DocumentImporterProps) {
const { isDark } = useTheme()
const { t } = useLanguage()
const { canvas, saveToHistory } = useWorksheet()
const [sessions, setSessions] = useState<Session[]>([])
const [selectedSession, setSelectedSession] = useState<Session | null>(null)
const [selectedPage, setSelectedPage] = useState(1)
const [isLoading, setIsLoading] = useState(false)
const [isImporting, setIsImporting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [includeImages, setIncludeImages] = useState(true)
// Load available sessions
const loadSessions = useCallback(async () => {
setIsLoading(true)
setError(null)
try {
const { hostname, protocol } = window.location
const apiBase = hostname === 'localhost' ? 'http://localhost:8086' : `${protocol}//${hostname}:8086`
const response = await fetch(`${apiBase}/api/v1/worksheet/sessions/available`)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const data = await response.json()
setSessions(data.sessions || [])
} catch (err) {
console.error('Failed to load sessions:', err)
setError('Konnte Sessions nicht laden. Ist der Server erreichbar?')
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => {
if (isOpen) {
loadSessions()
}
}, [isOpen, loadSessions])
// Handle import
const handleImport = async () => {
if (!selectedSession || !canvas) return
setIsImporting(true)
setError(null)
try {
const { hostname, protocol } = window.location
const apiBase = hostname === 'localhost' ? 'http://localhost:8086' : `${protocol}//${hostname}:8086`
const response = await fetch(`${apiBase}/api/v1/worksheet/reconstruct-from-session`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: selectedSession.id,
page_number: selectedPage,
include_images: includeImages,
regenerate_graphics: false
})
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }))
throw new Error(errorData.detail || `HTTP ${response.status}`)
}
const result = await response.json()
// Load canvas JSON
if (result.canvas_json) {
const canvasData = JSON.parse(result.canvas_json)
// Clear current canvas and load new content
canvas.clear()
canvas.loadFromJSON(canvasData, () => {
canvas.renderAll()
saveToHistory(`Imported: ${selectedSession.name} Page ${selectedPage}`)
})
// Close modal
onClose()
}
} catch (err) {
console.error('Import failed:', err)
setError(err instanceof Error ? err.message : 'Import fehlgeschlagen')
} finally {
setIsImporting(false)
}
}
if (!isOpen) return null
// Glassmorphism styles
const overlayStyle = 'fixed inset-0 bg-black/50 backdrop-blur-sm z-50'
const modalStyle = isDark
? 'backdrop-blur-xl bg-white/10 border border-white/20'
: 'backdrop-blur-xl bg-white/90 border border-black/10 shadow-2xl'
const cardStyle = (selected: boolean) => isDark
? selected
? 'bg-purple-500/30 border-purple-400/50'
: 'bg-white/5 border-white/10 hover:bg-white/10'
: selected
? 'bg-purple-100 border-purple-300'
: 'bg-white/50 border-slate-200 hover:bg-slate-50'
const labelStyle = isDark ? 'text-white/70' : 'text-slate-600'
return (
<div className={overlayStyle} onClick={onClose}>
<div
className={`fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-2xl max-h-[80vh] rounded-3xl p-6 overflow-hidden flex flex-col ${modalStyle}`}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
isDark ? 'bg-blue-500/20' : 'bg-blue-100'
}`}>
<svg className={`w-7 h-7 ${isDark ? 'text-blue-300' : 'text-blue-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<div>
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Dokument importieren
</h2>
<p className={`text-sm ${labelStyle}`}>
Rekonstruiere ein Arbeitsblatt aus einer Vokabel-Session
</p>
</div>
</div>
<button
onClick={onClose}
className={`p-2 rounded-xl transition-colors ${
isDark ? 'hover:bg-white/10 text-white/70' : 'hover:bg-slate-100 text-slate-500'
}`}
>
<svg className="w-6 h-6" 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">
{/* Loading State */}
{isLoading && (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-4 border-purple-400 border-t-transparent rounded-full animate-spin" />
</div>
)}
{/* Error State */}
{error && (
<div className={`p-4 rounded-xl mb-4 ${
isDark ? 'bg-red-500/20 text-red-300' : 'bg-red-50 text-red-700'
}`}>
{error}
</div>
)}
{/* No Sessions */}
{!isLoading && sessions.length === 0 && (
<div className={`text-center py-12 ${labelStyle}`}>
<svg className="w-16 h-16 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p className="text-lg font-medium mb-2">Keine Sessions gefunden</p>
<p className="text-sm opacity-70">
Verarbeite zuerst ein Dokument im Vokabel-Arbeitsblatt Generator
</p>
</div>
)}
{/* Session List */}
{!isLoading && sessions.length > 0 && (
<div className="space-y-3">
<label className={`block text-sm font-medium mb-2 ${labelStyle}`}>
Wähle eine Session:
</label>
{sessions.map((session) => (
<button
key={session.id}
onClick={() => {
setSelectedSession(session)
setSelectedPage(1)
}}
className={`w-full p-4 rounded-xl border text-left transition-all ${cardStyle(selectedSession?.id === session.id)}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
{session.name}
</h3>
{session.description && (
<p className={`text-sm mt-1 ${labelStyle}`}>
{session.description}
</p>
)}
<div className={`flex items-center gap-4 mt-2 text-xs ${labelStyle}`}>
<span className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
{session.vocabulary_count} Vokabeln
</span>
<span className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
{session.page_count} Seiten
</span>
<span className={`px-2 py-0.5 rounded-full ${
session.status === 'completed'
? isDark ? 'bg-green-500/20 text-green-300' : 'bg-green-100 text-green-700'
: isDark ? 'bg-yellow-500/20 text-yellow-300' : 'bg-yellow-100 text-yellow-700'
}`}>
{session.status}
</span>
</div>
</div>
{selectedSession?.id === session.id && (
<div className={`w-6 h-6 rounded-full flex items-center justify-center ${
isDark ? 'bg-purple-500' : 'bg-purple-600'
}`}>
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
)}
</div>
</button>
))}
</div>
)}
{/* Page Selection */}
{selectedSession && selectedSession.page_count > 1 && (
<div className="mt-6">
<label className={`block text-sm font-medium mb-2 ${labelStyle}`}>
Welche Seite importieren?
</label>
<div className="flex items-center gap-2 flex-wrap">
{Array.from({ length: selectedSession.page_count }, (_, i) => i + 1).map((page) => (
<button
key={page}
onClick={() => setSelectedPage(page)}
className={`w-10 h-10 rounded-lg font-medium transition-all ${
selectedPage === page
? isDark
? 'bg-purple-500 text-white'
: 'bg-purple-600 text-white'
: isDark
? 'bg-white/10 text-white/70 hover:bg-white/20'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{page}
</button>
))}
</div>
</div>
)}
{/* Options */}
{selectedSession && (
<div className="mt-6 space-y-3">
<label className={`block text-sm font-medium mb-2 ${labelStyle}`}>
Optionen:
</label>
<label className={`flex items-center gap-3 p-3 rounded-xl border cursor-pointer transition-all ${
isDark
? 'bg-white/5 border-white/10 hover:bg-white/10'
: 'bg-white/50 border-slate-200 hover:bg-slate-50'
}`}>
<input
type="checkbox"
checked={includeImages}
onChange={(e) => setIncludeImages(e.target.checked)}
className="w-5 h-5 rounded"
/>
<div>
<span className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
Bilder extrahieren
</span>
<p className={`text-sm ${labelStyle}`}>
Versuche Grafiken aus dem Original-PDF zu übernehmen
</p>
</div>
</label>
</div>
)}
</div>
{/* Footer */}
<div className="mt-6 flex items-center justify-end gap-3">
<button
onClick={onClose}
className={`px-5 py-2.5 rounded-xl font-medium transition-colors ${
isDark
? 'bg-white/10 text-white hover:bg-white/20'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
Abbrechen
</button>
<button
onClick={handleImport}
disabled={!selectedSession || isImporting}
className={`px-5 py-2.5 rounded-xl font-medium transition-all flex items-center gap-2 ${
isDark
? 'bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:shadow-lg hover:shadow-purple-500/30'
: 'bg-gradient-to-r from-purple-600 to-pink-600 text-white hover:shadow-lg hover:shadow-purple-600/30'
} disabled:opacity-50 disabled:cursor-not-allowed`}
>
{isImporting ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Rekonstruiere...
</>
) : (
<>
<svg className="w-5 h-5" 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-8l-4-4m0 0L8 8m4-4v12" />
</svg>
Importieren
</>
)}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,330 @@
'use client'
import { useRef } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { useLanguage } from '@/lib/LanguageContext'
import { useWorksheet } from '@/lib/worksheet-editor/WorksheetContext'
import type { EditorTool } from '@/app/worksheet-editor/types'
interface EditorToolbarProps {
onOpenAIGenerator: () => void
onOpenDocumentImporter: () => void
onOpenCleanupPanel?: () => void
onOpenOCRImport?: () => void
className?: string
}
interface ToolButtonProps {
tool: EditorTool
icon: React.ReactNode
label: string
isActive: boolean
onClick: () => void
isDark: boolean
}
function ToolButton({ tool, icon, label, isActive, onClick, isDark }: ToolButtonProps) {
return (
<button
onClick={onClick}
title={label}
className={`w-12 h-12 flex items-center justify-center rounded-xl transition-all ${
isActive
? isDark
? 'bg-purple-500/30 text-purple-300 shadow-lg'
: 'bg-purple-100 text-purple-700 shadow-lg'
: isDark
? 'text-white/70 hover:bg-white/10 hover:text-white'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
}`}
>
{icon}
</button>
)
}
export function EditorToolbar({ onOpenAIGenerator, onOpenDocumentImporter, onOpenCleanupPanel, onOpenOCRImport, className = '' }: EditorToolbarProps) {
const { isDark } = useTheme()
const { t } = useLanguage()
const fileInputRef = useRef<HTMLInputElement>(null)
const {
activeTool,
setActiveTool,
canvas,
canUndo,
canRedo,
undo,
redo,
} = useWorksheet()
const handleToolClick = (tool: EditorTool) => {
setActiveTool(tool)
}
const handleImageUpload = () => {
fileInputRef.current?.click()
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file || !canvas) return
const reader = new FileReader()
reader.onload = (event) => {
const url = event.target?.result as string
if ((canvas as any).addImage) {
(canvas as any).addImage(url)
}
}
reader.readAsDataURL(file)
// Reset input
e.target.value = ''
}
// Glassmorphism styles
const toolbarStyle = isDark
? 'backdrop-blur-xl bg-white/10 border border-white/20'
: 'backdrop-blur-xl bg-white/70 border border-black/10 shadow-xl'
const dividerStyle = isDark
? 'border-white/20'
: 'border-slate-200'
return (
<div className={`flex flex-col gap-2 p-2 rounded-2xl ${toolbarStyle} ${className}`}>
{/* Selection Tool */}
<ToolButton
tool="select"
isActive={activeTool === 'select'}
onClick={() => handleToolClick('select')}
isDark={isDark}
label="Auswählen"
icon={
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122" />
</svg>
}
/>
<div className={`border-t ${dividerStyle}`} />
{/* Text Tool */}
<ToolButton
tool="text"
isActive={activeTool === 'text'}
onClick={() => handleToolClick('text')}
isDark={isDark}
label="Text"
icon={
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
}
/>
<div className={`border-t ${dividerStyle}`} />
{/* Shape Tools */}
<ToolButton
tool="rectangle"
isActive={activeTool === 'rectangle'}
onClick={() => handleToolClick('rectangle')}
isDark={isDark}
label="Rechteck"
icon={
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h16M4 18h16M4 6v12M20 6v12" />
</svg>
}
/>
<ToolButton
tool="circle"
isActive={activeTool === 'circle'}
onClick={() => handleToolClick('circle')}
isDark={isDark}
label="Kreis"
icon={
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9" strokeWidth={1.5} />
</svg>
}
/>
<ToolButton
tool="line"
isActive={activeTool === 'line'}
onClick={() => handleToolClick('line')}
isDark={isDark}
label="Linie"
icon={
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 20L20 4" />
</svg>
}
/>
<ToolButton
tool="arrow"
isActive={activeTool === 'arrow'}
onClick={() => handleToolClick('arrow')}
isDark={isDark}
label="Pfeil"
icon={
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
}
/>
<div className={`border-t ${dividerStyle}`} />
{/* Image Tools */}
<ToolButton
tool="image"
isActive={activeTool === 'image'}
onClick={handleImageUpload}
isDark={isDark}
label="Bild hochladen"
icon={
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
}
/>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileChange}
className="hidden"
/>
{/* AI Image Generator */}
<ToolButton
tool="ai-image"
isActive={activeTool === 'ai-image'}
onClick={onOpenAIGenerator}
isDark={isDark}
label="KI-Bild generieren"
icon={
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
</svg>
}
/>
<div className={`border-t ${dividerStyle}`} />
{/* Document Import */}
<button
onClick={onOpenDocumentImporter}
title="Dokument importieren"
className={`w-12 h-12 flex items-center justify-center rounded-xl transition-all ${
isDark
? 'text-blue-300 hover:bg-blue-500/20 hover:text-blue-200'
: 'text-blue-600 hover:bg-blue-100 hover:text-blue-700'
}`}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 3v6a1 1 0 001 1h6" />
</svg>
</button>
{/* Cleanup Panel - Handwriting Removal */}
{onOpenCleanupPanel && (
<button
onClick={onOpenCleanupPanel}
title="Arbeitsblatt bereinigen (Handschrift entfernen)"
className={`w-12 h-12 flex items-center justify-center rounded-xl transition-all ${
isDark
? 'text-orange-300 hover:bg-orange-500/20 hover:text-orange-200'
: 'text-orange-600 hover:bg-orange-100 hover:text-orange-700'
}`}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
</svg>
</button>
)}
{/* OCR Import */}
{onOpenOCRImport && (
<button
onClick={onOpenOCRImport}
title="OCR Daten importieren (aus Grid Analyse)"
className={`w-12 h-12 flex items-center justify-center rounded-xl transition-all ${
isDark
? 'text-green-300 hover:bg-green-500/20 hover:text-green-200'
: 'text-green-600 hover:bg-green-100 hover:text-green-700'
}`}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
</button>
)}
<div className={`border-t ${dividerStyle}`} />
{/* Table Tool */}
<ToolButton
tool="table"
isActive={activeTool === 'table'}
onClick={() => handleToolClick('table')}
isDark={isDark}
label="Tabelle"
icon={
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 10h18M3 14h18m-9-4v8m-7 0h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
}
/>
<div className={`border-t ${dividerStyle} mt-auto`} />
{/* Undo/Redo */}
<button
onClick={undo}
disabled={!canUndo}
title="Rückgängig (Ctrl+Z)"
className={`w-12 h-12 flex items-center justify-center rounded-xl transition-all ${
canUndo
? isDark
? 'text-white/70 hover:bg-white/10 hover:text-white'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
: isDark
? 'text-white/30 cursor-not-allowed'
: 'text-slate-300 cursor-not-allowed'
}`}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
</svg>
</button>
<button
onClick={redo}
disabled={!canRedo}
title="Wiederholen (Ctrl+Y)"
className={`w-12 h-12 flex items-center justify-center rounded-xl transition-all ${
canRedo
? isDark
? 'text-white/70 hover:bg-white/10 hover:text-white'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
: isDark
? 'text-white/30 cursor-not-allowed'
: 'text-slate-300 cursor-not-allowed'
}`}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 10h-10a8 8 0 00-8 8v2M21 10l-6 6m6-6l-6-6" />
</svg>
</button>
</div>
)
}

View File

@@ -0,0 +1,216 @@
'use client'
import { useState } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { useLanguage } from '@/lib/LanguageContext'
import { useWorksheet } from '@/lib/worksheet-editor/WorksheetContext'
interface ExportPanelProps {
isOpen: boolean
onClose: () => void
}
export function ExportPanel({ isOpen, onClose }: ExportPanelProps) {
const { isDark } = useTheme()
const { t } = useLanguage()
const { exportToPDF, exportToImage, document, saveDocument, isDirty } = useWorksheet()
const [isExporting, setIsExporting] = useState(false)
const [exportFormat, setExportFormat] = useState<'pdf' | 'png' | 'jpg'>('pdf')
const handleExport = async () => {
setIsExporting(true)
try {
let blob: Blob
let filename: string
if (exportFormat === 'pdf') {
blob = await exportToPDF()
filename = `${document?.title || 'Arbeitsblatt'}.pdf`
} else {
blob = await exportToImage(exportFormat)
filename = `${document?.title || 'Arbeitsblatt'}.${exportFormat}`
}
// Download file
const url = URL.createObjectURL(blob)
const a = window.document.createElement('a')
a.href = url
a.download = filename
window.document.body.appendChild(a)
a.click()
window.document.body.removeChild(a)
URL.revokeObjectURL(url)
onClose()
} catch (error) {
console.error('Export failed:', error)
alert('Export fehlgeschlagen. Bitte versuchen Sie es erneut.')
} finally {
setIsExporting(false)
}
}
const handleSave = async () => {
setIsExporting(true)
try {
await saveDocument()
alert('Dokument gespeichert!')
} catch (error) {
console.error('Save failed:', error)
alert('Speichern fehlgeschlagen.')
} finally {
setIsExporting(false)
}
}
if (!isOpen) return null
// Glassmorphism styles
const overlayStyle = 'fixed inset-0 bg-black/50 backdrop-blur-sm z-50'
const modalStyle = isDark
? 'backdrop-blur-xl bg-white/10 border border-white/20'
: 'backdrop-blur-xl bg-white/90 border border-black/10 shadow-2xl'
const buttonStyle = (active: boolean) => isDark
? active
? 'bg-purple-500/30 text-purple-300 border border-purple-400/50'
: 'bg-white/5 text-white/70 border border-transparent hover:bg-white/10'
: active
? 'bg-purple-100 text-purple-700 border border-purple-300'
: 'bg-slate-50 text-slate-600 border border-transparent hover:bg-slate-100'
const labelStyle = isDark ? 'text-white/70' : 'text-slate-600'
return (
<div className={overlayStyle} onClick={onClose}>
<div
className={`fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md rounded-3xl p-6 ${modalStyle}`}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${
isDark ? 'bg-purple-500/20' : 'bg-purple-100'
}`}>
<svg className={`w-6 h-6 ${isDark ? 'text-purple-300' : 'text-purple-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</div>
<div>
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Exportieren
</h2>
<p className={`text-sm ${labelStyle}`}>
Arbeitsblatt speichern oder exportieren
</p>
</div>
</div>
<button
onClick={onClose}
className={`p-2 rounded-xl transition-colors ${
isDark ? 'hover:bg-white/10 text-white/70' : 'hover:bg-slate-100 text-slate-500'
}`}
>
<svg className="w-6 h-6" 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>
{/* Save Section */}
<div className="mb-6">
<button
onClick={handleSave}
disabled={isExporting}
className={`w-full py-4 rounded-xl font-medium transition-all flex items-center justify-center gap-3 ${
isDark
? 'bg-green-500/30 text-green-300 hover:bg-green-500/40'
: 'bg-green-600 text-white hover:bg-green-700'
} ${isExporting ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
</svg>
Speichern
{isDirty && (
<span className={`px-2 py-0.5 rounded-full text-xs ${
isDark ? 'bg-yellow-500/30 text-yellow-300' : 'bg-yellow-100 text-yellow-700'
}`}>
Ungespeichert
</span>
)}
</button>
</div>
{/* Export Format Selection */}
<div className="mb-4">
<label className={`block text-sm font-medium mb-3 ${labelStyle}`}>
Export-Format
</label>
<div className="grid grid-cols-3 gap-3">
<button
onClick={() => setExportFormat('pdf')}
className={`py-4 rounded-xl transition-all flex flex-col items-center gap-2 ${buttonStyle(exportFormat === 'pdf')}`}
>
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<span className="text-sm font-medium">PDF</span>
</button>
<button
onClick={() => setExportFormat('png')}
className={`py-4 rounded-xl transition-all flex flex-col items-center gap-2 ${buttonStyle(exportFormat === 'png')}`}
>
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span className="text-sm font-medium">PNG</span>
</button>
<button
onClick={() => setExportFormat('jpg')}
className={`py-4 rounded-xl transition-all flex flex-col items-center gap-2 ${buttonStyle(exportFormat === 'jpg')}`}
>
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span className="text-sm font-medium">JPG</span>
</button>
</div>
</div>
{/* Export Button */}
<button
onClick={handleExport}
disabled={isExporting}
className={`w-full py-4 rounded-xl font-medium transition-all flex items-center justify-center gap-2 ${
isDark
? 'bg-purple-500/30 text-purple-300 hover:bg-purple-500/40'
: 'bg-purple-600 text-white hover:bg-purple-700'
} ${isExporting ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{isExporting ? (
<>
<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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Exportiere...
</>
) : (
<>
<svg className="w-5 h-5" 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>
Als {exportFormat.toUpperCase()} exportieren
</>
)}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,434 @@
'use client'
import { useEffect, useRef, useCallback, useState } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { useWorksheet } from '@/lib/worksheet-editor/WorksheetContext'
import type { EditorTool } from '@/app/worksheet-editor/types'
// Fabric.js types
declare const fabric: any
// A4 dimensions in pixels at 96 DPI
const A4_WIDTH = 794 // 210mm * 3.78
const A4_HEIGHT = 1123 // 297mm * 3.78
interface FabricCanvasProps {
className?: string
}
export function FabricCanvas({ className = '' }: FabricCanvasProps) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const { isDark } = useTheme()
const [fabricLoaded, setFabricLoaded] = useState(false)
const [fabricCanvas, setFabricCanvas] = useState<any>(null)
const {
setCanvas,
activeTool,
setActiveTool,
setSelectedObjects,
zoom,
showGrid,
snapToGrid,
gridSize,
saveToHistory,
document
} = useWorksheet()
// Load Fabric.js dynamically
useEffect(() => {
const loadFabric = async () => {
if (typeof window !== 'undefined' && !(window as any).fabric) {
const fabricModule = await import('fabric')
// Fabric 6.x exports directly, not via .fabric
;(window as any).fabric = fabricModule
}
setFabricLoaded(true)
}
loadFabric()
}, [])
// Initialize canvas
useEffect(() => {
if (!fabricLoaded || !canvasRef.current || fabricCanvas) return
const fabric = (window as any).fabric
if (!fabric) return
try {
const canvas = new fabric.Canvas(canvasRef.current, {
width: A4_WIDTH,
height: A4_HEIGHT,
backgroundColor: '#ffffff',
selection: true,
preserveObjectStacking: true,
enableRetinaScaling: true,
})
// Store canvas reference
setFabricCanvas(canvas)
setCanvas(canvas)
return () => {
canvas.dispose()
setFabricCanvas(null)
setCanvas(null)
}
} catch (error) {
console.error('Failed to initialize Fabric.js canvas:', error)
}
}, [fabricLoaded, setCanvas])
// Save initial history entry after canvas is ready
useEffect(() => {
if (fabricCanvas && saveToHistory) {
// Small delay to ensure canvas is fully initialized
const timeout = setTimeout(() => {
saveToHistory('initial')
}, 100)
return () => clearTimeout(timeout)
}
}, [fabricCanvas, saveToHistory])
// Handle selection events
useEffect(() => {
if (!fabricCanvas) return
const handleSelection = () => {
const activeObjects = fabricCanvas.getActiveObjects()
setSelectedObjects(activeObjects)
}
const handleSelectionCleared = () => {
setSelectedObjects([])
}
fabricCanvas.on('selection:created', handleSelection)
fabricCanvas.on('selection:updated', handleSelection)
fabricCanvas.on('selection:cleared', handleSelectionCleared)
return () => {
fabricCanvas.off('selection:created', handleSelection)
fabricCanvas.off('selection:updated', handleSelection)
fabricCanvas.off('selection:cleared', handleSelectionCleared)
}
}, [fabricCanvas, setSelectedObjects])
// Handle object modifications
useEffect(() => {
if (!fabricCanvas) return
const handleModified = () => {
saveToHistory('object:modified')
}
fabricCanvas.on('object:modified', handleModified)
fabricCanvas.on('object:added', handleModified)
fabricCanvas.on('object:removed', handleModified)
return () => {
fabricCanvas.off('object:modified', handleModified)
fabricCanvas.off('object:added', handleModified)
fabricCanvas.off('object:removed', handleModified)
}
}, [fabricCanvas, saveToHistory])
// Handle zoom
useEffect(() => {
if (!fabricCanvas) return
fabricCanvas.setZoom(zoom)
fabricCanvas.setDimensions({
width: A4_WIDTH * zoom,
height: A4_HEIGHT * zoom
})
fabricCanvas.renderAll()
}, [fabricCanvas, zoom])
// Draw grid
useEffect(() => {
if (!fabricCanvas) return
// Remove existing grid
const existingGrid = fabricCanvas.getObjects().filter((obj: any) => obj.isGrid)
existingGrid.forEach((obj: any) => fabricCanvas.remove(obj))
if (showGrid) {
const fabric = (window as any).fabric
const gridSpacing = gridSize * 3.78 // Convert mm to pixels
// Vertical lines
for (let x = gridSpacing; x < A4_WIDTH; x += gridSpacing) {
const line = new fabric.Line([x, 0, x, A4_HEIGHT], {
stroke: isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)',
strokeWidth: 0.5,
selectable: false,
evented: false,
isGrid: true,
})
fabricCanvas.add(line)
fabricCanvas.sendObjectToBack(line)
}
// Horizontal lines
for (let y = gridSpacing; y < A4_HEIGHT; y += gridSpacing) {
const line = new fabric.Line([0, y, A4_WIDTH, y], {
stroke: isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)',
strokeWidth: 0.5,
selectable: false,
evented: false,
isGrid: true,
})
fabricCanvas.add(line)
fabricCanvas.sendObjectToBack(line)
}
}
fabricCanvas.renderAll()
}, [fabricCanvas, showGrid, gridSize, isDark])
// Handle snap to grid
useEffect(() => {
if (!fabricCanvas) return
if (snapToGrid) {
const gridSpacing = gridSize * 3.78
fabricCanvas.on('object:moving', (e: any) => {
const obj = e.target
obj.set({
left: Math.round(obj.left / gridSpacing) * gridSpacing,
top: Math.round(obj.top / gridSpacing) * gridSpacing
})
})
}
}, [fabricCanvas, snapToGrid, gridSize])
// Handle tool changes
useEffect(() => {
if (!fabricCanvas) return
const fabric = (window as any).fabric
// Reset drawing mode
fabricCanvas.isDrawingMode = false
fabricCanvas.selection = activeTool === 'select'
// Handle canvas click based on tool
const handleMouseDown = (e: any) => {
if (e.target) return // Clicked on an object
const pointer = fabricCanvas.getPointer(e.e)
switch (activeTool) {
case 'text': {
const text = new fabric.IText('Text eingeben', {
left: pointer.x,
top: pointer.y,
fontFamily: 'Arial',
fontSize: 16,
fill: isDark ? '#ffffff' : '#000000',
})
fabricCanvas.add(text)
fabricCanvas.setActiveObject(text)
text.enterEditing()
setActiveTool('select')
break
}
case 'rectangle': {
const rect = new fabric.Rect({
left: pointer.x,
top: pointer.y,
width: 100,
height: 60,
fill: 'transparent',
stroke: isDark ? '#ffffff' : '#000000',
strokeWidth: 2,
})
fabricCanvas.add(rect)
fabricCanvas.setActiveObject(rect)
setActiveTool('select')
break
}
case 'circle': {
const circle = new fabric.Circle({
left: pointer.x,
top: pointer.y,
radius: 40,
fill: 'transparent',
stroke: isDark ? '#ffffff' : '#000000',
strokeWidth: 2,
})
fabricCanvas.add(circle)
fabricCanvas.setActiveObject(circle)
setActiveTool('select')
break
}
case 'line': {
const line = new fabric.Line([pointer.x, pointer.y, pointer.x + 100, pointer.y], {
stroke: isDark ? '#ffffff' : '#000000',
strokeWidth: 2,
})
fabricCanvas.add(line)
fabricCanvas.setActiveObject(line)
setActiveTool('select')
break
}
case 'arrow': {
// Create arrow using path
const arrowPath = `M ${pointer.x} ${pointer.y} L ${pointer.x + 80} ${pointer.y} L ${pointer.x + 70} ${pointer.y - 8} M ${pointer.x + 80} ${pointer.y} L ${pointer.x + 70} ${pointer.y + 8}`
const arrow = new fabric.Path(arrowPath, {
fill: 'transparent',
stroke: isDark ? '#ffffff' : '#000000',
strokeWidth: 2,
})
fabricCanvas.add(arrow)
fabricCanvas.setActiveObject(arrow)
setActiveTool('select')
break
}
}
}
fabricCanvas.on('mouse:down', handleMouseDown)
return () => {
fabricCanvas.off('mouse:down', handleMouseDown)
}
}, [fabricCanvas, activeTool, isDark, setActiveTool])
// Keyboard shortcuts
useEffect(() => {
if (!fabricCanvas) return
const handleKeyDown = (e: KeyboardEvent) => {
// Don't handle if typing in input
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
// Delete selected objects
if (e.key === 'Delete' || e.key === 'Backspace') {
const activeObjects = fabricCanvas.getActiveObjects()
if (activeObjects.length > 0) {
activeObjects.forEach((obj: any) => {
if (!obj.isGrid) {
fabricCanvas.remove(obj)
}
})
fabricCanvas.discardActiveObject()
fabricCanvas.renderAll()
saveToHistory('delete')
}
}
// Ctrl/Cmd shortcuts
if (e.ctrlKey || e.metaKey) {
switch (e.key.toLowerCase()) {
case 'c': // Copy
if (fabricCanvas.getActiveObject()) {
fabricCanvas.getActiveObject().clone((cloned: any) => {
(window as any)._clipboard = cloned
})
}
break
case 'v': // Paste
if ((window as any)._clipboard) {
(window as any)._clipboard.clone((cloned: any) => {
cloned.set({
left: cloned.left + 20,
top: cloned.top + 20,
})
fabricCanvas.add(cloned)
fabricCanvas.setActiveObject(cloned)
fabricCanvas.renderAll()
saveToHistory('paste')
})
}
break
case 'a': // Select all
e.preventDefault()
const objects = fabricCanvas.getObjects().filter((obj: any) => !obj.isGrid)
const fabric = (window as any).fabric
const selection = new fabric.ActiveSelection(objects, { canvas: fabricCanvas })
fabricCanvas.setActiveObject(selection)
fabricCanvas.renderAll()
break
case 'd': // Duplicate
e.preventDefault()
if (fabricCanvas.getActiveObject()) {
fabricCanvas.getActiveObject().clone((cloned: any) => {
cloned.set({
left: cloned.left + 20,
top: cloned.top + 20,
})
fabricCanvas.add(cloned)
fabricCanvas.setActiveObject(cloned)
fabricCanvas.renderAll()
saveToHistory('duplicate')
})
}
break
}
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [fabricCanvas, saveToHistory])
// Add image to canvas
const addImage = useCallback((url: string) => {
if (!fabricCanvas) return
const fabric = (window as any).fabric
fabric.Image.fromURL(url, (img: any) => {
// Scale image to fit within canvas
const maxWidth = A4_WIDTH * 0.8
const maxHeight = A4_HEIGHT * 0.5
const scale = Math.min(maxWidth / img.width, maxHeight / img.height, 1)
img.set({
left: (A4_WIDTH - img.width * scale) / 2,
top: (A4_HEIGHT - img.height * scale) / 2,
scaleX: scale,
scaleY: scale,
})
fabricCanvas.add(img)
fabricCanvas.setActiveObject(img)
fabricCanvas.renderAll()
saveToHistory('image:added')
setActiveTool('select')
}, { crossOrigin: 'anonymous' })
}, [fabricCanvas, saveToHistory, setActiveTool])
// Expose addImage to context
useEffect(() => {
if (fabricCanvas) {
(fabricCanvas as any).addImage = addImage
}
}, [fabricCanvas, addImage])
// Background styling
const canvasContainerStyle = isDark
? 'bg-slate-800 shadow-2xl'
: 'bg-slate-200 shadow-xl'
return (
<div
ref={containerRef}
className={`flex items-center justify-center overflow-auto p-8 ${className}`}
style={{ minHeight: '100%' }}
>
<div className={`rounded-lg overflow-hidden ${canvasContainerStyle}`}>
<canvas ref={canvasRef} id="worksheet-canvas" />
</div>
</div>
)
}

View File

@@ -0,0 +1,471 @@
'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>
)
}

View File

@@ -0,0 +1,126 @@
'use client'
import { useTheme } from '@/lib/ThemeContext'
import { useLanguage } from '@/lib/LanguageContext'
import { useWorksheet } from '@/lib/worksheet-editor/WorksheetContext'
interface PageNavigatorProps {
className?: string
}
export function PageNavigator({ className = '' }: PageNavigatorProps) {
const { isDark } = useTheme()
const { t } = useLanguage()
const {
document,
currentPageIndex,
setCurrentPageIndex,
addPage,
deletePage,
canvas
} = useWorksheet()
const pages = document?.pages || []
const handlePageChange = (index: number) => {
if (!canvas || !document || index === currentPageIndex) return
// Save current page state
const currentPage = document.pages[currentPageIndex]
if (currentPage) {
currentPage.canvasJSON = JSON.stringify(canvas.toJSON())
}
// Load new page
setCurrentPageIndex(index)
const newPage = document.pages[index]
if (newPage?.canvasJSON) {
canvas.loadFromJSON(JSON.parse(newPage.canvasJSON), () => {
canvas.renderAll()
})
} else {
// Clear canvas for new page
canvas.clear()
canvas.backgroundColor = '#ffffff'
canvas.renderAll()
}
}
// Glassmorphism styles
const containerStyle = isDark
? 'backdrop-blur-xl bg-white/10 border border-white/20'
: 'backdrop-blur-xl bg-white/70 border border-black/10 shadow-xl'
const pageButtonStyle = (isActive: boolean) => isDark
? isActive
? 'bg-purple-500/30 text-purple-300 border-purple-400/50'
: 'bg-white/5 text-white/70 border-white/10 hover:bg-white/10'
: isActive
? 'bg-purple-100 text-purple-700 border-purple-300'
: 'bg-white/50 text-slate-600 border-slate-200 hover:bg-slate-100'
const labelStyle = isDark ? 'text-white/70' : 'text-slate-600'
return (
<div className={`flex items-center gap-3 p-3 rounded-2xl ${containerStyle} ${className}`}>
{/* Page Label */}
<span className={`text-sm font-medium ${labelStyle}`}>
Seiten:
</span>
{/* Page Buttons */}
<div className="flex items-center gap-2">
{pages.map((page, index) => (
<div key={page.id} className="relative group">
<button
onClick={() => handlePageChange(index)}
className={`w-10 h-10 flex items-center justify-center rounded-lg border text-sm font-medium transition-all ${pageButtonStyle(index === currentPageIndex)}`}
>
{index + 1}
</button>
{/* Delete button (visible on hover, not for single page) */}
{pages.length > 1 && (
<button
onClick={(e) => {
e.stopPropagation()
deletePage(index)
}}
className={`absolute -top-2 -right-2 w-5 h-5 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity ${
isDark
? 'bg-red-500/80 text-white hover:bg-red-500'
: 'bg-red-500 text-white hover:bg-red-600'
}`}
title="Seite löschen"
>
<svg className="w-3 h-3" 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>
))}
{/* Add Page Button */}
<button
onClick={addPage}
className={`w-10 h-10 flex items-center justify-center rounded-lg border border-dashed transition-all ${
isDark
? 'border-white/30 text-white/50 hover:border-white/50 hover:text-white/70 hover:bg-white/5'
: 'border-slate-300 text-slate-400 hover:border-slate-400 hover:text-slate-500 hover:bg-slate-50'
}`}
title="Seite hinzufügen"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
{/* Page Info */}
<span className={`text-sm ${labelStyle}`}>
{currentPageIndex + 1} / {pages.length}
</span>
</div>
)
}

View File

@@ -0,0 +1,443 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { useLanguage } from '@/lib/LanguageContext'
import { useWorksheet } from '@/lib/worksheet-editor/WorksheetContext'
import { AVAILABLE_FONTS, DEFAULT_TYPOGRAPHY_PRESETS } from '@/app/worksheet-editor/types'
interface PropertiesPanelProps {
className?: string
}
export function PropertiesPanel({ className = '' }: PropertiesPanelProps) {
const { isDark } = useTheme()
const { t } = useLanguage()
const { selectedObjects, canvas, saveToHistory } = useWorksheet()
// Local state for properties
const [fontFamily, setFontFamily] = useState('Arial')
const [fontSize, setFontSize] = useState(16)
const [fontWeight, setFontWeight] = useState<'normal' | 'bold'>('normal')
const [fontStyle, setFontStyle] = useState<'normal' | 'italic'>('normal')
const [textAlign, setTextAlign] = useState<'left' | 'center' | 'right'>('left')
const [lineHeight, setLineHeight] = useState(1.4)
const [charSpacing, setCharSpacing] = useState(0)
const [fill, setFill] = useState('#000000')
const [stroke, setStroke] = useState('#000000')
const [strokeWidth, setStrokeWidth] = useState(2)
const [opacity, setOpacity] = useState(100)
// Get selected object (cast to any for Fabric.js properties)
const selectedObject = selectedObjects[0] as any
const objType = selectedObject?.type
const isText = objType === 'i-text' || objType === 'text' || objType === 'textbox'
const isShape = objType === 'rect' || objType === 'circle' || objType === 'line' || objType === 'path'
const isImage = objType === 'image'
// Update local state when selection changes
useEffect(() => {
if (!selectedObject) return
if (isText) {
setFontFamily(selectedObject.fontFamily || 'Arial')
setFontSize(selectedObject.fontSize || 16)
setFontWeight(selectedObject.fontWeight || 'normal')
setFontStyle(selectedObject.fontStyle || 'normal')
setTextAlign(selectedObject.textAlign || 'left')
setLineHeight(selectedObject.lineHeight || 1.4)
setCharSpacing(selectedObject.charSpacing || 0)
setFill(selectedObject.fill || '#000000')
}
if (isShape) {
setFill(selectedObject.fill || 'transparent')
setStroke(selectedObject.stroke || '#000000')
setStrokeWidth(selectedObject.strokeWidth || 2)
}
setOpacity(Math.round((selectedObject.opacity || 1) * 100))
}, [selectedObject, isText, isShape])
// Update object property
const updateProperty = useCallback((property: string, value: any) => {
if (!selectedObject || !canvas) return
selectedObject.set(property, value)
canvas.renderAll()
saveToHistory(`property:${property}`)
}, [selectedObject, canvas, saveToHistory])
// Glassmorphism styles
const panelStyle = isDark
? 'backdrop-blur-xl bg-white/10 border border-white/20'
: 'backdrop-blur-xl bg-white/70 border border-black/10 shadow-xl'
const inputStyle = isDark
? 'bg-white/10 border-white/20 text-white placeholder-white/40 focus:border-purple-400'
: 'bg-white/50 border-black/10 text-slate-900 placeholder-slate-400 focus:border-purple-500'
const labelStyle = isDark ? 'text-white/70' : 'text-slate-600'
const selectStyle = isDark
? 'bg-white/10 border-white/20 text-white'
: 'bg-white/50 border-black/10 text-slate-900'
// No selection
if (!selectedObject) {
return (
<div className={`flex flex-col p-4 rounded-2xl ${panelStyle} ${className}`}>
<h3 className={`font-semibold text-lg mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Eigenschaften
</h3>
<p className={`text-sm ${labelStyle}`}>
Wählen Sie ein Element aus, um seine Eigenschaften zu bearbeiten.
</p>
</div>
)
}
return (
<div className={`flex flex-col p-4 rounded-2xl overflow-y-auto ${panelStyle} ${className}`}>
<h3 className={`font-semibold text-lg mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Eigenschaften
</h3>
{/* Text Properties */}
{isText && (
<div className="space-y-4">
{/* Typography Presets */}
<div>
<label className={`block text-sm font-medium mb-2 ${labelStyle}`}>Vorlage</label>
<select
className={`w-full px-3 py-2 rounded-xl border text-sm ${selectStyle}`}
onChange={(e) => {
const preset = DEFAULT_TYPOGRAPHY_PRESETS.find(p => p.id === e.target.value)
if (preset) {
updateProperty('fontFamily', preset.fontFamily)
updateProperty('fontSize', preset.fontSize)
updateProperty('fontWeight', preset.fontWeight)
updateProperty('lineHeight', preset.lineHeight)
setFontFamily(preset.fontFamily)
setFontSize(preset.fontSize)
setFontWeight(preset.fontWeight as 'normal' | 'bold')
setLineHeight(preset.lineHeight)
}
}}
>
<option value="">Vorlage wählen...</option>
{DEFAULT_TYPOGRAPHY_PRESETS.map(preset => (
<option key={preset.id} value={preset.id}>{preset.name}</option>
))}
</select>
</div>
{/* Font Family */}
<div>
<label className={`block text-sm font-medium mb-2 ${labelStyle}`}>Schriftart</label>
<select
value={fontFamily}
onChange={(e) => {
setFontFamily(e.target.value)
updateProperty('fontFamily', e.target.value)
}}
className={`w-full px-3 py-2 rounded-xl border text-sm ${selectStyle}`}
>
{AVAILABLE_FONTS.map(font => (
<option key={font.name} value={font.name} style={{ fontFamily: font.family }}>
{font.name}
</option>
))}
</select>
</div>
{/* Font Size */}
<div>
<label className={`block text-sm font-medium mb-2 ${labelStyle}`}>Schriftgröße</label>
<div className="flex items-center gap-2">
<input
type="range"
min="8"
max="120"
value={fontSize}
onChange={(e) => {
const value = parseInt(e.target.value)
setFontSize(value)
updateProperty('fontSize', value)
}}
className="flex-1"
/>
<input
type="number"
min="8"
max="120"
value={fontSize}
onChange={(e) => {
const value = parseInt(e.target.value) || 16
setFontSize(value)
updateProperty('fontSize', value)
}}
className={`w-16 px-2 py-1 rounded-lg border text-sm text-center ${inputStyle}`}
/>
</div>
</div>
{/* Font Style Buttons */}
<div>
<label className={`block text-sm font-medium mb-2 ${labelStyle}`}>Stil</label>
<div className="flex gap-2">
<button
onClick={() => {
const newWeight = fontWeight === 'bold' ? 'normal' : 'bold'
setFontWeight(newWeight)
updateProperty('fontWeight', newWeight)
}}
className={`flex-1 py-2 rounded-xl font-bold transition-all ${
fontWeight === 'bold'
? isDark ? 'bg-purple-500/30 text-purple-300' : 'bg-purple-100 text-purple-700'
: isDark ? 'bg-white/10 text-white/70' : 'bg-slate-100 text-slate-600'
}`}
>
B
</button>
<button
onClick={() => {
const newStyle = fontStyle === 'italic' ? 'normal' : 'italic'
setFontStyle(newStyle)
updateProperty('fontStyle', newStyle)
}}
className={`flex-1 py-2 rounded-xl italic transition-all ${
fontStyle === 'italic'
? isDark ? 'bg-purple-500/30 text-purple-300' : 'bg-purple-100 text-purple-700'
: isDark ? 'bg-white/10 text-white/70' : 'bg-slate-100 text-slate-600'
}`}
>
I
</button>
</div>
</div>
{/* Text Alignment */}
<div>
<label className={`block text-sm font-medium mb-2 ${labelStyle}`}>Ausrichtung</label>
<div className="flex gap-2">
{(['left', 'center', 'right'] as const).map(align => (
<button
key={align}
onClick={() => {
setTextAlign(align)
updateProperty('textAlign', align)
}}
className={`flex-1 py-2 rounded-xl transition-all ${
textAlign === align
? isDark ? 'bg-purple-500/30 text-purple-300' : 'bg-purple-100 text-purple-700'
: isDark ? 'bg-white/10 text-white/70' : 'bg-slate-100 text-slate-600'
}`}
>
{align === 'left' && (
<svg className="w-5 h-5 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h10M4 18h14" />
</svg>
)}
{align === 'center' && (
<svg className="w-5 h-5 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M7 12h10M5 18h14" />
</svg>
)}
{align === 'right' && (
<svg className="w-5 h-5 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M10 12h10M6 18h14" />
</svg>
)}
</button>
))}
</div>
</div>
{/* Line Height */}
<div>
<label className={`block text-sm font-medium mb-2 ${labelStyle}`}>Zeilenhöhe</label>
<div className="flex items-center gap-2">
<input
type="range"
min="0.8"
max="3"
step="0.1"
value={lineHeight}
onChange={(e) => {
const value = parseFloat(e.target.value)
setLineHeight(value)
updateProperty('lineHeight', value)
}}
className="flex-1"
/>
<span className={`w-12 text-center text-sm ${labelStyle}`}>{lineHeight.toFixed(1)}</span>
</div>
</div>
{/* Text Color */}
<div>
<label className={`block text-sm font-medium mb-2 ${labelStyle}`}>Textfarbe</label>
<div className="flex items-center gap-2">
<input
type="color"
value={fill as string}
onChange={(e) => {
setFill(e.target.value)
updateProperty('fill', e.target.value)
}}
className="w-10 h-10 rounded-lg cursor-pointer"
/>
<input
type="text"
value={fill as string}
onChange={(e) => {
setFill(e.target.value)
updateProperty('fill', e.target.value)
}}
className={`flex-1 px-3 py-2 rounded-xl border text-sm ${inputStyle}`}
/>
</div>
</div>
</div>
)}
{/* Shape Properties */}
{isShape && (
<div className="space-y-4">
{/* Fill Color */}
<div>
<label className={`block text-sm font-medium mb-2 ${labelStyle}`}>Füllfarbe</label>
<div className="flex items-center gap-2">
<input
type="color"
value={fill === 'transparent' ? '#ffffff' : (fill as string)}
onChange={(e) => {
setFill(e.target.value)
updateProperty('fill', e.target.value)
}}
className="w-10 h-10 rounded-lg cursor-pointer"
/>
<input
type="text"
value={fill as string}
onChange={(e) => {
setFill(e.target.value)
updateProperty('fill', e.target.value)
}}
className={`flex-1 px-3 py-2 rounded-xl border text-sm ${inputStyle}`}
/>
<button
onClick={() => {
setFill('transparent')
updateProperty('fill', 'transparent')
}}
className={`px-3 py-2 rounded-xl text-sm ${
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>
{/* Stroke Color */}
<div>
<label className={`block text-sm font-medium mb-2 ${labelStyle}`}>Rahmenfarbe</label>
<div className="flex items-center gap-2">
<input
type="color"
value={stroke}
onChange={(e) => {
setStroke(e.target.value)
updateProperty('stroke', e.target.value)
}}
className="w-10 h-10 rounded-lg cursor-pointer"
/>
<input
type="text"
value={stroke}
onChange={(e) => {
setStroke(e.target.value)
updateProperty('stroke', e.target.value)
}}
className={`flex-1 px-3 py-2 rounded-xl border text-sm ${inputStyle}`}
/>
</div>
</div>
{/* Stroke Width */}
<div>
<label className={`block text-sm font-medium mb-2 ${labelStyle}`}>Rahmenstärke</label>
<div className="flex items-center gap-2">
<input
type="range"
min="0"
max="20"
value={strokeWidth}
onChange={(e) => {
const value = parseInt(e.target.value)
setStrokeWidth(value)
updateProperty('strokeWidth', value)
}}
className="flex-1"
/>
<span className={`w-12 text-center text-sm ${labelStyle}`}>{strokeWidth}px</span>
</div>
</div>
</div>
)}
{/* Image Properties */}
{isImage && (
<div className="space-y-4">
<p className={`text-sm ${labelStyle}`}>
Bildgröße und Position können durch Ziehen der Eckpunkte angepasst werden.
</p>
</div>
)}
{/* Common Properties */}
<div className="mt-6 pt-4 border-t border-white/10 space-y-4">
{/* Opacity */}
<div>
<label className={`block text-sm font-medium mb-2 ${labelStyle}`}>Deckkraft</label>
<div className="flex items-center gap-2">
<input
type="range"
min="0"
max="100"
value={opacity}
onChange={(e) => {
const value = parseInt(e.target.value)
setOpacity(value)
updateProperty('opacity', value / 100)
}}
className="flex-1"
/>
<span className={`w-12 text-center text-sm ${labelStyle}`}>{opacity}%</span>
</div>
</div>
{/* Delete Button */}
<button
onClick={() => {
if (canvas && selectedObject) {
canvas.remove(selectedObject)
canvas.discardActiveObject()
canvas.renderAll()
saveToHistory('delete')
}
}}
className={`w-full py-3 rounded-xl text-sm font-medium transition-all ${
isDark
? 'bg-red-500/20 text-red-400 hover:bg-red-500/30'
: 'bg-red-50 text-red-600 hover:bg-red-100'
}`}
>
Element löschen
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,16 @@
/**
* Worksheet Editor Components
*
* Export all worksheet editor components for easy importing
*/
export { FabricCanvas } from './FabricCanvas'
export { EditorToolbar } from './EditorToolbar'
export { PropertiesPanel } from './PropertiesPanel'
export { AIImageGenerator } from './AIImageGenerator'
export { CanvasControls } from './CanvasControls'
export { PageNavigator } from './PageNavigator'
export { ExportPanel } from './ExportPanel'
export { DocumentImporter } from './DocumentImporter'
export { CleanupPanel } from './CleanupPanel'
export { OCRImportPanel } from './OCRImportPanel'