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:
296
studio-v2/components/worksheet-editor/AIImageGenerator.tsx
Normal file
296
studio-v2/components/worksheet-editor/AIImageGenerator.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
257
studio-v2/components/worksheet-editor/AIPromptBar.tsx
Normal file
257
studio-v2/components/worksheet-editor/AIPromptBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
136
studio-v2/components/worksheet-editor/CanvasControls.tsx
Normal file
136
studio-v2/components/worksheet-editor/CanvasControls.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
765
studio-v2/components/worksheet-editor/CleanupPanel.tsx
Normal file
765
studio-v2/components/worksheet-editor/CleanupPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
363
studio-v2/components/worksheet-editor/DocumentImporter.tsx
Normal file
363
studio-v2/components/worksheet-editor/DocumentImporter.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
330
studio-v2/components/worksheet-editor/EditorToolbar.tsx
Normal file
330
studio-v2/components/worksheet-editor/EditorToolbar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
216
studio-v2/components/worksheet-editor/ExportPanel.tsx
Normal file
216
studio-v2/components/worksheet-editor/ExportPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
434
studio-v2/components/worksheet-editor/FabricCanvas.tsx
Normal file
434
studio-v2/components/worksheet-editor/FabricCanvas.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
471
studio-v2/components/worksheet-editor/OCRImportPanel.tsx
Normal file
471
studio-v2/components/worksheet-editor/OCRImportPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
126
studio-v2/components/worksheet-editor/PageNavigator.tsx
Normal file
126
studio-v2/components/worksheet-editor/PageNavigator.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
443
studio-v2/components/worksheet-editor/PropertiesPanel.tsx
Normal file
443
studio-v2/components/worksheet-editor/PropertiesPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
16
studio-v2/components/worksheet-editor/index.ts
Normal file
16
studio-v2/components/worksheet-editor/index.ts
Normal 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'
|
||||
Reference in New Issue
Block a user