fix: Restore all files lost during destructive rebase

A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.

This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).

Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-02-09 09:51:32 +01:00
parent f7487ee240
commit 21a844cb8a
1986 changed files with 744143 additions and 1731 deletions

View File

@@ -0,0 +1,261 @@
'use client'
/**
* AI Prompt Component for Studio v2
*
* Eingabezeile für Fragen an den lokalen Ollama-Server.
* Unterstützt Streaming-Antworten und automatische Modell-Erkennung.
* Angepasst an das glassmorphism Design von Studio v2.
*/
import { useState, useEffect, useRef } from 'react'
import { useTheme } from '@/lib/ThemeContext'
interface OllamaModel {
name: string
size: number
digest: string
}
export function AiPrompt() {
const [prompt, setPrompt] = useState('')
const [response, setResponse] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [models, setModels] = useState<OllamaModel[]>([])
const [selectedModel, setSelectedModel] = useState('llama3.2:latest')
const [showResponse, setShowResponse] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const abortControllerRef = useRef<AbortController | null>(null)
const { isDark } = useTheme()
// Lade verfügbare Modelle von Ollama
useEffect(() => {
const loadModels = async () => {
try {
const ollamaUrl = getOllamaBaseUrl()
const res = await fetch(`${ollamaUrl}/api/tags`)
if (res.ok) {
const data = await res.json()
if (data.models && data.models.length > 0) {
setModels(data.models)
setSelectedModel(data.models[0].name)
}
}
} catch (error) {
console.log('Ollama nicht erreichbar:', error)
}
}
loadModels()
}, [])
const getOllamaBaseUrl = () => {
if (typeof window !== 'undefined') {
if (window.location.hostname === 'macmini') {
return 'http://macmini:11434'
}
}
return 'http://localhost:11434'
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendPrompt()
}
}
const autoResize = () => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 120) + 'px'
}
}
const sendPrompt = async () => {
if (!prompt.trim() || isLoading) return
// Vorherige Anfrage abbrechen
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
abortControllerRef.current = new AbortController()
setIsLoading(true)
setResponse('')
setShowResponse(true)
try {
const ollamaUrl = getOllamaBaseUrl()
const res = await fetch(`${ollamaUrl}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: selectedModel,
prompt: prompt.trim(),
stream: true,
}),
signal: abortControllerRef.current.signal,
})
if (!res.ok) {
throw new Error(`Ollama Fehler: ${res.status}`)
}
const reader = res.body?.getReader()
const decoder = new TextDecoder()
let fullResponse = ''
if (reader) {
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value)
const lines = chunk.split('\n').filter(l => l.trim())
for (const line of lines) {
try {
const data = JSON.parse(line)
if (data.response) {
fullResponse += data.response
setResponse(fullResponse)
}
} catch {
// Ignore JSON parse errors for partial chunks
}
}
}
}
} catch (error) {
if ((error as Error).name === 'AbortError') {
setResponse('Anfrage abgebrochen.')
} else {
console.error('AI Prompt Fehler:', error)
setResponse(`Fehler: ${(error as Error).message}\n\nBitte prüfen Sie, ob Ollama läuft.`)
}
} finally {
setIsLoading(false)
abortControllerRef.current = null
}
}
const formatResponse = (text: string) => {
// Einfache Markdown-Formatierung
return text
.replace(/```(\w+)?\n([\s\S]*?)```/g, `<pre class="${isDark ? 'bg-white/10' : 'bg-slate-800'} text-slate-100 p-3 rounded-lg my-2 overflow-x-auto text-sm"><code>$2</code></pre>`)
.replace(/`([^`]+)`/g, `<code class="${isDark ? 'bg-white/20' : 'bg-slate-200'} px-1.5 py-0.5 rounded text-sm">$1</code>`)
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
.replace(/\n/g, '<br>')
}
return (
<div className={`backdrop-blur-xl border rounded-3xl p-6 mb-8 transition-all ${
isDark
? 'bg-gradient-to-r from-purple-500/10 to-pink-500/10 border-purple-500/30'
: 'bg-gradient-to-r from-purple-50 to-pink-50 border-purple-200 shadow-lg'
}`}>
{/* Header */}
<div className="flex items-center gap-3 mb-4">
<div className={`w-12 h-12 rounded-2xl flex items-center justify-center text-2xl shadow-lg ${
isDark
? 'bg-gradient-to-br from-purple-500 to-pink-500'
: 'bg-gradient-to-br from-purple-400 to-pink-400'
}`}>
🤖
</div>
<div>
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>KI-Assistent</h3>
<p className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
Fragen Sie Ihren lokalen Ollama-Assistenten
</p>
</div>
</div>
{/* Input */}
<div className="flex gap-3 items-end">
<textarea
ref={textareaRef}
value={prompt}
onChange={(e) => {
setPrompt(e.target.value)
autoResize()
}}
onKeyDown={handleKeyDown}
placeholder="Stellen Sie eine Frage... (z.B. 'Wie schreibe ich einen Elternbrief?' oder 'Erstelle mir einen Lückentext')"
rows={1}
className={`flex-1 min-h-[48px] max-h-[120px] px-5 py-3 rounded-2xl border resize-none transition-all ${
isDark
? 'bg-white/10 border-white/20 text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-purple-500/50'
: 'bg-white/80 border-slate-200 text-slate-900 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-purple-300'
}`}
/>
<button
onClick={sendPrompt}
disabled={isLoading || !prompt.trim()}
className={`w-12 h-12 rounded-2xl flex items-center justify-center text-white text-lg transition-all shadow-lg ${
isLoading
? 'bg-slate-500 cursor-wait animate-pulse'
: 'bg-gradient-to-br from-purple-500 to-pink-500 hover:shadow-xl hover:shadow-purple-500/30 hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100'
}`}
>
{isLoading ? '⏳' : '➤'}
</button>
</div>
{/* Response */}
{showResponse && (
<div className={`mt-4 p-5 rounded-2xl border ${
isDark
? 'bg-white/5 border-white/10'
: 'bg-white/80 border-slate-200 shadow-inner'
}`}>
<div className={`flex items-center gap-2 text-xs mb-3 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
<span>🤖</span>
<span className="font-medium">{selectedModel}</span>
{isLoading && <span className="animate-pulse"> Generiert...</span>}
</div>
<div
className={`text-sm leading-relaxed prose prose-sm max-w-none ${isDark ? 'text-white/80' : 'text-slate-700'}`}
dangerouslySetInnerHTML={{ __html: formatResponse(response) || `<span class="${isDark ? 'text-white/40' : 'text-slate-400'} italic">Warte auf Antwort...</span>` }}
/>
</div>
)}
{/* Model Selector */}
<div className={`flex items-center gap-2 mt-4 pt-4 border-t ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
<span className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Modell:</span>
<select
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
className={`text-xs px-3 py-1.5 rounded-xl border cursor-pointer transition-all ${
isDark
? 'bg-white/10 border-white/20 text-white focus:outline-none focus:ring-2 focus:ring-purple-500/50'
: 'bg-white border-slate-200 text-slate-700 focus:outline-none focus:ring-2 focus:ring-purple-300'
}`}
>
{models.length > 0 ? (
models.map((model) => (
<option key={model.name} value={model.name}>
{model.name}
</option>
))
) : (
<>
<option value="llama3.2:latest">Llama 3.2</option>
<option value="mistral:latest">Mistral</option>
<option value="qwen2.5:7b">Qwen 2.5</option>
</>
)}
</select>
{models.length === 0 && (
<span className={`text-xs ${isDark ? 'text-amber-400' : 'text-amber-600'}`}>
Ollama nicht verbunden
</span>
)}
</div>
</div>
)
}
export default AiPrompt

View File

@@ -0,0 +1,552 @@
'use client'
import { useState } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { useAlerts, lehrerThemen, Topic, AlertImportance } from '@/lib/AlertsContext'
import { InfoBox, TipBox, StepBox } from './InfoBox'
import { BPIcon } from './Logo'
interface AlertsWizardProps {
onComplete: () => void
onSkip?: () => void
}
export function AlertsWizard({ onComplete, onSkip }: AlertsWizardProps) {
const { isDark } = useTheme()
const { addTopic, updateSettings, settings } = useAlerts()
const [step, setStep] = useState(1)
const [selectedTopics, setSelectedTopics] = useState<string[]>([])
const [customTopic, setCustomTopic] = useState({ name: '', keywords: '' })
const [rssFeedUrl, setRssFeedUrl] = useState('')
const [notificationFrequency, setNotificationFrequency] = useState<'realtime' | 'hourly' | 'daily'>('daily')
const [minImportance, setMinImportance] = useState<AlertImportance>('PRUEFEN')
const totalSteps = 4
const handleNext = () => {
if (step < totalSteps) {
setStep(step + 1)
} else {
// Wizard abschliessen
completeWizard()
}
}
const handleBack = () => {
if (step > 1) {
setStep(step - 1)
}
}
const completeWizard = () => {
// Ausgewaehlte vordefinierte Topics hinzufuegen
selectedTopics.forEach(topicId => {
const topic = lehrerThemen.find(t => t.name === topicId)
if (topic) {
addTopic({
id: `topic-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
name: topic.name,
keywords: topic.keywords,
icon: topic.icon,
isActive: true,
rssFeedUrl: rssFeedUrl || undefined
})
}
})
// Custom Topic hinzufuegen falls vorhanden
if (customTopic.name.trim()) {
addTopic({
id: `topic-${Date.now()}-custom`,
name: customTopic.name,
keywords: customTopic.keywords.split(',').map(k => k.trim()).filter(k => k),
icon: '📌',
isActive: true,
rssFeedUrl: rssFeedUrl || undefined
})
}
// Settings speichern
updateSettings({
notificationFrequency,
minImportance,
wizardCompleted: true
})
onComplete()
}
const toggleTopic = (topicName: string) => {
setSelectedTopics(prev =>
prev.includes(topicName)
? prev.filter(t => t !== topicName)
: [...prev, topicName]
)
}
const canProceed = () => {
switch (step) {
case 1:
return selectedTopics.length > 0 || customTopic.name.trim().length > 0
case 2:
return true // Info-Schritt, immer weiter
case 3:
return true // RSS optional
case 4:
return true // Einstellungen immer gueltig
default:
return false
}
}
return (
<div className={`min-h-screen flex flex-col ${
isDark
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
}`}>
{/* Animated Background */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className={`absolute -top-40 -right-40 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-pulse ${
isDark ? 'bg-amber-500 opacity-50' : 'bg-amber-300 opacity-30'
}`} />
<div className={`absolute -bottom-40 -left-40 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-pulse ${
isDark ? 'bg-orange-500 opacity-50' : 'bg-orange-300 opacity-30'
}`} style={{ animationDelay: '1s' }} />
</div>
<div className="relative z-10 flex-1 flex flex-col items-center justify-center p-8">
{/* Logo & Titel */}
<div className="text-center mb-8">
<div className="flex items-center justify-center gap-3 mb-4">
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center text-3xl shadow-lg">
🔔
</div>
<div className="text-left">
<h1 className={`text-2xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Google Alerts einrichten
</h1>
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
Bleiben Sie informiert ueber Bildungsthemen
</p>
</div>
</div>
</div>
{/* Progress Bar */}
<div className="w-full max-w-2xl mb-8">
<div className="flex items-center justify-between mb-2">
{[1, 2, 3, 4].map((s) => (
<div
key={s}
className={`w-10 h-10 rounded-full flex items-center justify-center font-medium transition-all ${
s === step
? 'bg-gradient-to-br from-amber-400 to-orange-500 text-white scale-110 shadow-lg'
: s < step
? isDark
? 'bg-green-500/30 text-green-300'
: 'bg-green-100 text-green-700'
: isDark
? 'bg-white/10 text-white/40'
: 'bg-slate-200 text-slate-400'
}`}
>
{s < step ? '✓' : s}
</div>
))}
</div>
<div className={`h-2 rounded-full overflow-hidden ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}>
<div
className="h-full bg-gradient-to-r from-amber-400 to-orange-500 transition-all duration-500"
style={{ width: `${((step - 1) / (totalSteps - 1)) * 100}%` }}
/>
</div>
</div>
{/* Main Card */}
<div className={`w-full max-w-2xl backdrop-blur-xl border rounded-3xl p-8 ${
isDark
? 'bg-white/10 border-white/20'
: 'bg-white/80 border-black/10 shadow-xl'
}`}>
{/* Step 1: Themen waehlen */}
{step === 1 && (
<div>
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
Welche Themen interessieren Sie?
</h2>
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
Waehlen Sie Themen, ueber die Sie informiert werden moechten
</p>
{/* Vordefinierte Themen */}
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 mb-6">
{lehrerThemen.map((topic) => {
const isSelected = selectedTopics.includes(topic.name)
return (
<button
key={topic.name}
onClick={() => toggleTopic(topic.name)}
className={`p-4 rounded-xl border-2 transition-all hover:scale-105 text-left ${
isSelected
? 'border-amber-500 bg-amber-500/20 shadow-lg'
: isDark
? 'border-white/10 bg-white/5 hover:bg-white/10 hover:border-white/20'
: 'border-slate-200 bg-white hover:bg-slate-50 hover:border-slate-300'
}`}
>
<div className="flex items-center gap-3">
<span className="text-2xl">{topic.icon}</span>
<div className="flex-1 min-w-0">
<p className={`font-medium truncate ${
isSelected
? isDark ? 'text-amber-300' : 'text-amber-700'
: isDark ? 'text-white' : 'text-slate-900'
}`}>
{topic.name}
</p>
<p className={`text-xs truncate ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
{topic.keywords.slice(0, 2).join(', ')}
</p>
</div>
{isSelected && (
<div className="w-6 h-6 rounded-full bg-amber-500 flex items-center justify-center">
<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>
{/* Custom Topic */}
<div className={`p-4 rounded-xl border ${isDark ? 'bg-white/5 border-white/10' : 'bg-slate-50 border-slate-200'}`}>
<h4 className={`font-medium mb-3 flex items-center gap-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
<span>📌</span> Eigenes Thema hinzufuegen
</h4>
<div className="space-y-3">
<input
type="text"
placeholder="Themenname (z.B. 'Mathematik Didaktik')"
value={customTopic.name}
onChange={(e) => setCustomTopic({ ...customTopic, name: e.target.value })}
className={`w-full px-4 py-2 rounded-lg border ${
isDark
? 'bg-white/10 border-white/20 text-white placeholder-white/40'
: 'bg-white border-slate-200 text-slate-900 placeholder-slate-400'
}`}
/>
<input
type="text"
placeholder="Stichwoerter (kommagetrennt)"
value={customTopic.keywords}
onChange={(e) => setCustomTopic({ ...customTopic, keywords: e.target.value })}
className={`w-full px-4 py-2 rounded-lg border ${
isDark
? 'bg-white/10 border-white/20 text-white placeholder-white/40'
: 'bg-white border-slate-200 text-slate-900 placeholder-slate-400'
}`}
/>
</div>
</div>
</div>
)}
{/* Step 2: Google Alerts Anleitung */}
{step === 2 && (
<div>
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
Google Alerts einrichten
</h2>
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
Google sendet Alerts per E-Mail - wir verarbeiten sie fuer Sie
</p>
<InfoBox variant="info" title="So funktioniert es" icon="💡" className="mb-6">
<p>
Google Alerts versendet Benachrichtigungen per E-Mail an Ihr Konto.
Sie richten einfach eine Weiterleitung ein - wir uebernehmen die
Auswertung, Filterung und Zusammenfassung.
</p>
</InfoBox>
<div className="space-y-4">
<StepBox step={1} title="Google Alerts oeffnen" isActive>
<p className="mb-2">
Besuchen Sie <a
href="https://www.google.de/alerts"
target="_blank"
rel="noopener noreferrer"
className="text-amber-500 hover:underline font-medium"
>
google.de/alerts
</a> und melden Sie sich mit Ihrem Google-Konto an.
</p>
</StepBox>
<StepBox step={2} title="Alerts erstellen">
<p>
Geben Sie Suchbegriffe ein (z.B. &quot;{selectedTopics[0] || 'Bildungspolitik'}&quot;)
und erstellen Sie Alerts. Die Alerts werden an Ihre E-Mail-Adresse gesendet.
</p>
</StepBox>
<StepBox step={3} title="E-Mail-Weiterleitung einrichten">
<p>
Im naechsten Schritt richten Sie eine automatische Weiterleitung
der Google Alert E-Mails an uns ein. So verarbeiten wir Ihre Alerts
automatisch.
</p>
</StepBox>
</div>
<TipBox title="Tipp: Mehrere Alerts kombinieren" icon="💡" className="mt-6">
<p>
Sie koennen beliebig viele Google Alerts erstellen. Alle werden
per E-Mail an Sie gesendet und durch die Weiterleitung automatisch
verarbeitet - gefiltert, priorisiert und zusammengefasst.
</p>
</TipBox>
</div>
)}
{/* Step 3: E-Mail Weiterleitung einrichten */}
{step === 3 && (
<div>
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
E-Mail Weiterleitung einrichten
</h2>
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
Leiten Sie Ihre Google Alert E-Mails automatisch an uns weiter
</p>
<div className="space-y-4">
{/* Empfohlene Methode: E-Mail Weiterleitung */}
<div className={`p-5 rounded-xl border-2 ${isDark ? 'border-green-500/50 bg-green-500/10' : 'border-green-500 bg-green-50'}`}>
<div className="flex items-start gap-3 mb-4">
<span className="text-2xl">📧</span>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h4 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
E-Mail Weiterleitung
</h4>
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-green-500/20 text-green-600">
Empfohlen
</span>
</div>
<p className={`text-sm ${isDark ? 'text-white/70' : 'text-slate-600'}`}>
Richten Sie in Gmail einen Filter ein, der Google Alert E-Mails automatisch weiterleitet.
</p>
</div>
</div>
<div className={`p-3 rounded-lg ${isDark ? 'bg-white/10' : 'bg-white'}`}>
<p className={`text-sm font-medium mb-2 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
Ihre Weiterleitungsadresse:
</p>
<div className="flex gap-2">
<code className={`flex-1 px-3 py-2 rounded-lg text-sm font-mono ${
isDark ? 'bg-white/10 text-amber-300' : 'bg-slate-100 text-amber-600'
}`}>
alerts@breakpilot.de
</code>
<button
onClick={() => navigator.clipboard.writeText('alerts@breakpilot.de')}
className="px-3 py-2 rounded-lg bg-amber-500 text-white text-sm hover:bg-amber-600 transition-all"
>
Kopieren
</button>
</div>
</div>
<div className="mt-4 space-y-2">
<p className={`text-sm font-medium ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
So richten Sie die Weiterleitung in Gmail ein:
</p>
<ol className={`text-sm space-y-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
<li>1. Oeffnen Sie Gmail Einstellungen Filter</li>
<li>2. Neuer Filter: Von &quot;googlealerts-noreply@google.com&quot;</li>
<li>3. Aktion: Weiterleiten an &quot;alerts@breakpilot.de&quot;</li>
</ol>
</div>
</div>
{/* Alternative: RSS (mit Warnung) */}
<div className={`p-4 rounded-xl border ${isDark ? 'border-white/10 bg-white/5' : 'border-slate-200 bg-slate-50'}`}>
<div className="flex items-start gap-3">
<span className="text-xl">📡</span>
<div className="flex-1">
<h4 className={`font-medium mb-1 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
Alternativ: RSS-Feed (eingeschraenkt verfuegbar)
</h4>
<p className={`text-sm mb-3 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
Google hat die RSS-Option fuer viele Konten entfernt. Falls Sie RSS noch sehen,
koennen Sie die Feed-URL hier eingeben:
</p>
<input
type="url"
placeholder="https://www.google.de/alerts/feeds/... (falls verfuegbar)"
value={rssFeedUrl}
onChange={(e) => setRssFeedUrl(e.target.value)}
className={`w-full px-4 py-2 rounded-lg border text-sm ${
isDark
? 'bg-white/10 border-white/20 text-white placeholder-white/30'
: 'bg-white border-slate-200 text-slate-900 placeholder-slate-400'
}`}
/>
<p className={`text-xs mt-2 ${isDark ? 'text-amber-400/70' : 'text-amber-600'}`}>
Die meisten Nutzer sehen keine RSS-Option mehr in Google Alerts.
Verwenden Sie in diesem Fall die E-Mail-Weiterleitung.
</p>
</div>
</div>
</div>
<div className={`p-4 rounded-xl ${isDark ? 'bg-white/5' : 'bg-slate-50'}`}>
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
Sie koennen diesen Schritt auch ueberspringen und die Weiterleitung spaeter einrichten.
Die Demo-Alerts werden weiterhin angezeigt.
</p>
</div>
</div>
</div>
)}
{/* Step 4: Benachrichtigungs-Einstellungen */}
{step === 4 && (
<div>
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
Benachrichtigungen einstellen
</h2>
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
Wie moechten Sie informiert werden?
</p>
<div className="space-y-6">
{/* Frequenz */}
<div>
<label className={`block text-sm font-medium mb-3 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
Wie oft moechten Sie Alerts erhalten?
</label>
<div className="grid grid-cols-3 gap-3">
{[
{ id: 'realtime', label: 'Sofort', icon: '⚡', desc: 'Bei jedem neuen Alert' },
{ id: 'hourly', label: 'Stuendlich', icon: '🕐', desc: 'Zusammenfassung pro Stunde' },
{ id: 'daily', label: 'Taeglich', icon: '📅', desc: 'Einmal am Tag' },
].map((freq) => (
<button
key={freq.id}
onClick={() => setNotificationFrequency(freq.id as any)}
className={`p-4 rounded-xl border-2 transition-all text-center ${
notificationFrequency === freq.id
? 'border-amber-500 bg-amber-500/20'
: isDark
? 'border-white/10 bg-white/5 hover:bg-white/10'
: 'border-slate-200 bg-white hover:bg-slate-50'
}`}
>
<span className="text-2xl block mb-1">{freq.icon}</span>
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>{freq.label}</p>
<p className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>{freq.desc}</p>
</button>
))}
</div>
</div>
{/* Mindest-Wichtigkeit */}
<div>
<label className={`block text-sm font-medium mb-3 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
Mindest-Wichtigkeit fuer Benachrichtigungen
</label>
<div className="grid grid-cols-5 gap-2">
{[
{ id: 'KRITISCH', label: 'Kritisch', color: 'red' },
{ id: 'DRINGEND', label: 'Dringend', color: 'orange' },
{ id: 'WICHTIG', label: 'Wichtig', color: 'yellow' },
{ id: 'PRUEFEN', label: 'Pruefen', color: 'blue' },
{ id: 'INFO', label: 'Info', color: 'slate' },
].map((imp) => (
<button
key={imp.id}
onClick={() => setMinImportance(imp.id as AlertImportance)}
className={`p-2 rounded-lg border-2 transition-all text-center text-xs ${
minImportance === imp.id
? `border-${imp.color}-500 bg-${imp.color}-500/20`
: isDark
? 'border-white/10 bg-white/5 hover:bg-white/10'
: 'border-slate-200 bg-white hover:bg-slate-50'
}`}
>
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>{imp.label}</p>
</button>
))}
</div>
<p className={`text-xs mt-2 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
Sie erhalten nur Benachrichtigungen fuer Alerts mit dieser Wichtigkeit oder hoeher.
</p>
</div>
{/* Zusammenfassung */}
<div className={`p-4 rounded-xl ${isDark ? 'bg-white/5' : 'bg-slate-50'}`}>
<h4 className={`font-medium mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Ihre Einstellungen
</h4>
<ul className={`text-sm space-y-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
<li> {selectedTopics.length + (customTopic.name ? 1 : 0)} Themen ausgewaehlt</li>
<li> Benachrichtigungen: {notificationFrequency === 'realtime' ? 'Sofort' : notificationFrequency === 'hourly' ? 'Stuendlich' : 'Taeglich'}</li>
<li> Mindest-Wichtigkeit: {minImportance}</li>
{rssFeedUrl && <li> RSS-Feed verbunden</li>}
</ul>
</div>
</div>
</div>
)}
</div>
{/* Navigation Buttons */}
<div className="flex items-center gap-4 mt-8">
{step > 1 && (
<button
onClick={handleBack}
className={`px-6 py-3 rounded-xl font-medium transition-all ${
isDark
? 'bg-white/10 text-white hover:bg-white/20'
: 'bg-slate-200 text-slate-700 hover:bg-slate-300'
}`}
>
Zurueck
</button>
)}
<button
onClick={handleNext}
disabled={!canProceed()}
className={`px-8 py-3 rounded-xl font-medium transition-all ${
canProceed()
? 'bg-gradient-to-r from-amber-400 to-orange-500 text-white hover:shadow-xl hover:shadow-orange-500/30 hover:scale-105'
: isDark
? 'bg-white/10 text-white/30 cursor-not-allowed'
: 'bg-slate-200 text-slate-400 cursor-not-allowed'
}`}
>
{step === totalSteps ? 'Fertig! →' : 'Weiter →'}
</button>
</div>
{/* Skip Option */}
{onSkip && (
<button
onClick={onSkip}
className={`mt-4 text-sm ${isDark ? 'text-white/40 hover:text-white/60' : 'text-slate-400 hover:text-slate-600'}`}
>
Ueberspringen (spaeter einrichten)
</button>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,848 @@
'use client'
import { useState } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { useAlertsB2B, B2BTemplate, Package, getPackageIcon, getPackageLabel } from '@/lib/AlertsB2BContext'
import { InfoBox, TipBox, StepBox } from './InfoBox'
interface B2BMigrationWizardProps {
onComplete: () => void
onSkip?: () => void
onCancel?: () => void
}
type MigrationMethod = 'email' | 'rss' | 'reconstruct' | null
export function B2BMigrationWizard({ onComplete, onSkip, onCancel }: B2BMigrationWizardProps) {
const { isDark } = useTheme()
const {
tenant,
updateTenant,
settings,
updateSettings,
availableTemplates,
selectTemplate,
generateInboundEmail,
addSource
} = useAlertsB2B()
const [step, setStep] = useState(1)
const [migrationMethod, setMigrationMethod] = useState<MigrationMethod>(null)
const [companyName, setCompanyName] = useState(tenant.companyName || '')
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null)
const [inboundEmail, setInboundEmail] = useState('')
const [rssUrls, setRssUrls] = useState<string[]>([''])
const [alertDescription, setAlertDescription] = useState('')
const [testEmailSent, setTestEmailSent] = useState(false)
const [selectedRegions, setSelectedRegions] = useState<string[]>(['EUROPE'])
const [selectedPackages, setSelectedPackages] = useState<string[]>(['PARKING', 'EV_CHARGING'])
const totalSteps = 5
const handleNext = () => {
if (step < totalSteps) {
// Special handling for step transitions
if (step === 1 && companyName.trim()) {
updateTenant({ companyName: companyName.trim() })
}
if (step === 2 && selectedTemplateId) {
selectTemplate(selectedTemplateId)
}
if (step === 3 && migrationMethod === 'email' && !inboundEmail) {
setInboundEmail(generateInboundEmail())
}
setStep(step + 1)
} else {
completeWizard()
}
}
const handleBack = () => {
if (step > 1) {
setStep(step - 1)
}
}
const completeWizard = () => {
// Save sources based on migration method
if (migrationMethod === 'email' && inboundEmail) {
addSource({
tenantId: tenant.id,
type: 'email',
inboundAddress: inboundEmail,
label: 'Google Alerts Weiterleitung',
active: true
})
} else if (migrationMethod === 'rss') {
rssUrls.filter(url => url.trim()).forEach((url, idx) => {
addSource({
tenantId: tenant.id,
type: 'rss',
rssUrl: url.trim(),
label: `RSS Feed ${idx + 1}`,
active: true
})
})
}
// Update settings
updateSettings({
migrationCompleted: true,
wizardCompleted: true,
selectedRegions,
selectedPackages: selectedPackages as any[]
})
onComplete()
}
const canProceed = () => {
switch (step) {
case 1:
return companyName.trim().length > 0
case 2:
return selectedTemplateId !== null
case 3:
return migrationMethod !== null
case 4:
if (migrationMethod === 'email') return inboundEmail.length > 0
if (migrationMethod === 'rss') return rssUrls.some(url => url.trim().length > 0)
if (migrationMethod === 'reconstruct') return alertDescription.trim().length > 10
return true
case 5:
return true
default:
return false
}
}
const selectedTemplate = availableTemplates.find(t => t.templateId === selectedTemplateId)
return (
<div className={`min-h-screen flex flex-col ${
isDark
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
}`}>
{/* Animated Background Blobs - Dashboard Style */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className={`absolute -top-40 -right-40 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-blob ${
isDark ? 'bg-purple-500 opacity-70' : 'bg-purple-300 opacity-50'
}`} />
<div className={`absolute -bottom-40 -left-40 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-2000 ${
isDark ? 'bg-blue-500 opacity-70' : 'bg-blue-300 opacity-50'
}`} />
<div className={`absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-4000 ${
isDark ? 'bg-pink-500 opacity-70' : 'bg-pink-300 opacity-50'
}`} />
</div>
{/* Blob Animation Styles */}
<style jsx>{`
@keyframes blob {
0% { transform: translate(0px, 0px) scale(1); }
33% { transform: translate(30px, -50px) scale(1.1); }
66% { transform: translate(-20px, 20px) scale(0.9); }
100% { transform: translate(0px, 0px) scale(1); }
}
.animate-blob {
animation: blob 7s infinite;
}
.animation-delay-2000 {
animation-delay: 2s;
}
.animation-delay-4000 {
animation-delay: 4s;
}
`}</style>
<div className="relative z-10 flex-1 flex flex-col items-center justify-center p-8">
{/* Exit Button - Fixed Top Right */}
{onCancel && (
<button
onClick={onCancel}
className={`fixed top-6 right-6 z-50 flex items-center gap-2 px-4 py-2 rounded-2xl backdrop-blur-xl border transition-all ${
isDark
? 'bg-white/10 border-white/20 text-white/70 hover:bg-white/20 hover:text-white'
: 'bg-white/70 border-black/10 text-slate-600 hover:bg-white hover:text-slate-900 shadow-lg'
}`}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<span className="text-sm font-medium">Abbrechen</span>
</button>
)}
{/* Header */}
<div className="text-center mb-8">
<div className="flex items-center justify-center gap-3 mb-4">
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center text-3xl shadow-lg shadow-purple-500/30">
🏢
</div>
<div className="text-left">
<h1 className={`text-2xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
B2B Alerts einrichten
</h1>
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
Bringen Sie Ihre bestehenden Google Alerts mit
</p>
</div>
</div>
</div>
{/* Progress Bar */}
<div className="w-full max-w-3xl mb-8">
<div className="flex items-center justify-between mb-2">
{[1, 2, 3, 4, 5].map((s) => (
<div
key={s}
className={`w-10 h-10 rounded-full flex items-center justify-center font-medium transition-all ${
s === step
? 'bg-gradient-to-br from-purple-500 to-pink-500 text-white scale-110 shadow-lg shadow-purple-500/30'
: s < step
? isDark
? 'bg-green-500/30 text-green-300'
: 'bg-green-100 text-green-700'
: isDark
? 'bg-white/10 text-white/40'
: 'bg-slate-200 text-slate-400'
}`}
>
{s < step ? '✓' : s}
</div>
))}
</div>
<div className={`h-2 rounded-full overflow-hidden ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}>
<div
className="h-full bg-gradient-to-r from-purple-500 to-pink-500 transition-all duration-500"
style={{ width: `${((step - 1) / (totalSteps - 1)) * 100}%` }}
/>
</div>
</div>
{/* Main Card */}
<div className={`w-full max-w-3xl backdrop-blur-xl border rounded-3xl p-8 ${
isDark
? 'bg-white/10 border-white/20'
: 'bg-white/80 border-black/10 shadow-xl'
}`}>
{/* Step 1: Firmenname */}
{step === 1 && (
<div>
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
Willkommen im B2B-Bereich
</h2>
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
Wie heisst Ihr Unternehmen?
</p>
<div className="max-w-md mx-auto space-y-4">
<input
type="text"
placeholder="z.B. Hectronic GmbH"
value={companyName}
onChange={(e) => setCompanyName(e.target.value)}
className={`w-full px-4 py-3 rounded-xl border text-lg ${
isDark
? 'bg-white/10 border-white/20 text-white placeholder-white/40'
: 'bg-white border-slate-200 text-slate-900 placeholder-slate-400'
}`}
/>
<InfoBox variant="info" title="Warum fragen wir das?" icon="💡">
<p>Ihr Firmenname wird verwendet, um:</p>
<ul className="list-disc list-inside mt-2 space-y-1">
<li>Ihre eindeutige E-Mail-Adresse zu generieren</li>
<li>Berichte und Digests zu personalisieren</li>
<li>Ihr Dashboard anzupassen</li>
</ul>
</InfoBox>
</div>
</div>
)}
{/* Step 2: Template waehlen */}
{step === 2 && (
<div>
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
Branchenvorlage waehlen
</h2>
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
Waehlen Sie eine Vorlage fuer Ihre Branche oder starten Sie leer
</p>
<div className="space-y-4">
{availableTemplates.map((template) => (
<button
key={template.templateId}
onClick={() => setSelectedTemplateId(template.templateId)}
className={`w-full text-left p-5 rounded-xl border-2 transition-all ${
selectedTemplateId === template.templateId
? 'border-blue-500 bg-blue-500/20 shadow-lg'
: isDark
? 'border-white/10 bg-white/5 hover:bg-white/10'
: 'border-slate-200 bg-white hover:bg-slate-50'
}`}
>
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-400 to-indigo-500 flex items-center justify-center text-2xl">
🏭
</div>
<div className="flex-1">
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
{template.templateName}
</h3>
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
{template.templateDescription}
</p>
<div className="flex flex-wrap gap-2 mt-3">
{template.guidedConfig.packageSelector.options.map(pkg => (
<span
key={pkg}
className={`px-2 py-1 rounded-full text-xs font-medium ${
template.guidedConfig.packageSelector.default.includes(pkg)
? isDark ? 'bg-blue-500/30 text-blue-300' : 'bg-blue-100 text-blue-700'
: isDark ? 'bg-white/10 text-white/50' : 'bg-slate-100 text-slate-500'
}`}
>
{getPackageIcon(pkg)} {getPackageLabel(pkg)}
</span>
))}
</div>
</div>
{selectedTemplateId === template.templateId && (
<div className="w-6 h-6 rounded-full bg-blue-500 flex items-center justify-center">
<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>
))}
{/* Custom option */}
<button
onClick={() => setSelectedTemplateId('custom')}
className={`w-full text-left p-5 rounded-xl border-2 transition-all ${
selectedTemplateId === 'custom'
? 'border-blue-500 bg-blue-500/20 shadow-lg'
: isDark
? 'border-white/10 bg-white/5 hover:bg-white/10'
: 'border-slate-200 bg-white hover:bg-slate-50'
}`}
>
<div className="flex items-start gap-4">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center text-2xl ${
isDark ? 'bg-white/20' : 'bg-slate-100'
}`}>
</div>
<div className="flex-1">
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Eigene Konfiguration
</h3>
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
Starten Sie ohne Vorlage und konfigurieren Sie alles selbst
</p>
</div>
</div>
</button>
</div>
</div>
)}
{/* Step 3: Migration Method */}
{step === 3 && (
<div>
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
Nutzen Sie bereits Google Alerts?
</h2>
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
Waehlen Sie, wie Sie Ihre bestehenden Alerts uebernehmen moechten
</p>
<div className="space-y-4">
{/* Email Forwarding (Recommended) */}
<button
onClick={() => setMigrationMethod('email')}
className={`w-full text-left p-5 rounded-xl border-2 transition-all ${
migrationMethod === 'email'
? 'border-green-500 bg-green-500/20 shadow-lg'
: isDark
? 'border-white/10 bg-white/5 hover:bg-white/10'
: 'border-slate-200 bg-white hover:bg-slate-50'
}`}
>
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-green-400 to-emerald-500 flex items-center justify-center text-2xl">
📧
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
E-Mail Weiterleitung
</h3>
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-green-500/20 text-green-500">
Empfohlen
</span>
</div>
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
Leiten Sie Ihre bestehenden Google Alert E-Mails an uns weiter.
Keine Aenderung an Ihren Alerts noetig.
</p>
</div>
</div>
</button>
{/* RSS Import */}
<button
onClick={() => setMigrationMethod('rss')}
className={`w-full text-left p-5 rounded-xl border-2 transition-all ${
migrationMethod === 'rss'
? 'border-blue-500 bg-blue-500/20 shadow-lg'
: isDark
? 'border-white/10 bg-white/5 hover:bg-white/10'
: 'border-slate-200 bg-white hover:bg-slate-50'
}`}
>
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-400 to-indigo-500 flex items-center justify-center text-2xl">
📡
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
RSS-Feed Import
</h3>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${isDark ? 'bg-amber-500/20 text-amber-400' : 'bg-amber-100 text-amber-700'}`}>
Eingeschraenkt
</span>
</div>
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
RSS-Feeds, falls in Ihrem Google-Konto verfuegbar.
</p>
<p className={`text-xs mt-1 ${isDark ? 'text-amber-400/70' : 'text-amber-600'}`}>
Google hat RSS fuer viele Konten deaktiviert
</p>
</div>
</div>
</button>
{/* Reconstruction */}
<button
onClick={() => setMigrationMethod('reconstruct')}
className={`w-full text-left p-5 rounded-xl border-2 transition-all ${
migrationMethod === 'reconstruct'
? 'border-amber-500 bg-amber-500/20 shadow-lg'
: isDark
? 'border-white/10 bg-white/5 hover:bg-white/10'
: 'border-slate-200 bg-white hover:bg-slate-50'
}`}
>
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center text-2xl">
🔄
</div>
<div className="flex-1">
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Rekonstruktion
</h3>
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
Beschreiben Sie, was Sie beobachten moechten. Wir erstellen die
optimale Konfiguration fuer Sie.
</p>
</div>
</div>
</button>
</div>
<TipBox title="Kein Neustart noetig" icon="💡" className="mt-6">
<p>
Ihre bestehenden Google Alerts bleiben bestehen. Wir sind eine zusaetzliche
Intelligenzschicht, die filtert, priorisiert und zusammenfasst.
</p>
</TipBox>
</div>
)}
{/* Step 4: Migration Details */}
{step === 4 && (
<div>
{/* Email Forwarding */}
{migrationMethod === 'email' && (
<>
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
E-Mail Weiterleitung einrichten
</h2>
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
Google Alerts sendet E-Mails - leiten Sie diese einfach an uns weiter
</p>
<InfoBox variant="info" title="So funktioniert es" icon="💡" className="mb-6">
<p>
Google Alerts versendet Benachrichtigungen per E-Mail an Ihr Konto.
Sie richten einen Gmail-Filter ein, der diese E-Mails automatisch weiterleitet -
wir uebernehmen die Verarbeitung und Auswertung.
</p>
</InfoBox>
<div className="space-y-6">
{/* Inbound Email */}
<div className={`p-4 rounded-xl border-2 ${isDark ? 'bg-green-500/10 border-green-500/30' : 'bg-green-50 border-green-200'}`}>
<label className={`block text-sm font-medium mb-2 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
Ihre eindeutige Weiterleitungsadresse:
</label>
<div className="flex gap-2">
<input
type="text"
readOnly
value={inboundEmail}
className={`flex-1 px-4 py-3 rounded-lg border font-mono text-sm ${
isDark
? 'bg-white/5 border-white/20 text-white'
: 'bg-white border-slate-200 text-slate-900'
}`}
/>
<button
onClick={() => navigator.clipboard.writeText(inboundEmail)}
className="px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-all"
>
Kopieren
</button>
</div>
</div>
{/* Steps */}
<div className="space-y-3">
<StepBox step={1} title="Gmail-Einstellungen oeffnen" isActive>
Oeffnen Sie <a href="https://mail.google.com/mail/u/0/#settings/filters" target="_blank" rel="noopener noreferrer" className="text-purple-400 hover:underline">Gmail Einstellungen Filter und blockierte Adressen</a>
</StepBox>
<StepBox step={2} title="Neuen Filter erstellen">
Klicken Sie auf &quot;Neuen Filter erstellen&quot; und geben Sie bei &quot;Von&quot; ein: <code className={`px-2 py-1 rounded ${isDark ? 'bg-white/10' : 'bg-slate-100'}`}>googlealerts-noreply@google.com</code>
</StepBox>
<StepBox step={3} title="Weiterleitung aktivieren">
Waehlen Sie &quot;Weiterleiten an&quot; und fuegen Sie die obige Adresse ein. Aktivieren Sie auch &quot;Filter auf passende Konversationen anwenden&quot;.
</StepBox>
</div>
<TipBox title="Keine Aenderung an Ihren Google Alerts noetig" icon="✨" className="mt-4">
<p>
Ihre bestehenden Google Alerts bleiben unveraendert. Der Gmail-Filter leitet
eingehende Alert-E-Mails automatisch an uns weiter. Sie koennen die E-Mails
auch weiterhin in Ihrem Posteingang sehen.
</p>
</TipBox>
{/* Test Button */}
<div className={`p-4 rounded-xl border ${
testEmailSent
? isDark ? 'bg-green-500/10 border-green-500/30' : 'bg-green-50 border-green-200'
: isDark ? 'bg-white/5 border-white/10' : 'bg-white border-slate-200'
}`}>
<div className="flex items-center justify-between">
<div>
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
{testEmailSent ? '✓ Test-E-Mail empfangen' : 'Verbindung testen'}
</p>
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
{testEmailSent
? 'Die Weiterleitung funktioniert!'
: 'Senden Sie eine Test-E-Mail, um die Einrichtung zu pruefen'}
</p>
</div>
{!testEmailSent && (
<button
onClick={() => setTestEmailSent(true)}
className="px-4 py-2 rounded-lg bg-white/20 hover:bg-white/30 transition-all"
>
Test senden
</button>
)}
</div>
</div>
</div>
</>
)}
{/* RSS Import */}
{migrationMethod === 'rss' && (
<>
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
RSS-Feeds importieren
</h2>
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
Fuegen Sie die RSS-URLs Ihrer Google Alerts hinzu
</p>
{/* Warning Box */}
<InfoBox variant="warning" title="Wichtiger Hinweis zu RSS" icon="⚠️" className="mb-6">
<p>
Google hat die RSS-Option fuer viele Konten entfernt. Falls Sie in Google Alerts
kein RSS-Symbol sehen oder die Option &quot;RSS-Feed&quot; nicht verfuegbar ist,
nutzen Sie bitte stattdessen die <strong>E-Mail-Weiterleitung</strong>.
</p>
<button
onClick={() => setMigrationMethod('email')}
className="mt-3 text-sm font-medium text-purple-400 hover:text-purple-300 underline"
>
Zur E-Mail-Weiterleitung wechseln
</button>
</InfoBox>
<div className="space-y-4">
{rssUrls.map((url, idx) => (
<div key={idx} className="flex gap-2">
<input
type="url"
placeholder="https://www.google.de/alerts/feeds/..."
value={url}
onChange={(e) => {
const newUrls = [...rssUrls]
newUrls[idx] = e.target.value
setRssUrls(newUrls)
}}
className={`flex-1 px-4 py-3 rounded-lg border ${
isDark
? 'bg-white/10 border-white/20 text-white placeholder-white/40'
: 'bg-white border-slate-200 text-slate-900 placeholder-slate-400'
}`}
/>
{rssUrls.length > 1 && (
<button
onClick={() => setRssUrls(rssUrls.filter((_, i) => i !== idx))}
className={`p-3 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'}`}
>
</button>
)}
</div>
))}
<button
onClick={() => setRssUrls([...rssUrls, ''])}
className={`w-full py-3 rounded-lg border-2 border-dashed transition-all ${
isDark
? 'border-white/20 text-white/60 hover:border-white/40 hover:text-white'
: 'border-slate-200 text-slate-500 hover:border-slate-300 hover:text-slate-700'
}`}
>
+ Weiteren Feed hinzufuegen
</button>
<div className={`p-4 rounded-xl ${isDark ? 'bg-white/5' : 'bg-slate-50'}`}>
<p className={`text-sm font-medium mb-2 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
Falls RSS verfuegbar ist:
</p>
<ol className={`text-sm space-y-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
<li>1. Oeffnen Sie google.de/alerts</li>
<li>2. Suchen Sie nach einem orangefarbenen RSS-Symbol</li>
<li>3. Klicken Sie darauf und kopieren Sie die URL</li>
</ol>
</div>
</div>
</>
)}
{/* Reconstruction */}
{migrationMethod === 'reconstruct' && (
<>
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
Was moechten Sie beobachten?
</h2>
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
Beschreiben Sie Ihre Beobachtungsziele - wir erstellen die optimale Konfiguration
</p>
<div className="space-y-4">
<textarea
placeholder="z.B. Wir beobachten europaweite kommunale Ausschreibungen für Parkscheinautomaten, EV-Ladesäulen mit Bezahlterminals und Tankautomaten. Wir bekommen aktuell zu viele irrelevante Treffer wie News, Stellenanzeigen und Zubehör..."
value={alertDescription}
onChange={(e) => setAlertDescription(e.target.value)}
rows={6}
className={`w-full px-4 py-3 rounded-xl border resize-none ${
isDark
? 'bg-white/10 border-white/20 text-white placeholder-white/40'
: 'bg-white border-slate-200 text-slate-900 placeholder-slate-400'
}`}
/>
<InfoBox variant="tip" title="Je mehr Details, desto besser" icon="✨">
<p>Beschreiben Sie:</p>
<ul className="list-disc list-inside mt-2 space-y-1">
<li>Welche Produkte/Services Sie anbieten</li>
<li>Welche Kaeufer/Maerkte relevant sind</li>
<li>Was Sie aktuell stoert (zu viel News, Jobs, etc.)</li>
</ul>
</InfoBox>
{alertDescription.length > 50 && (
<div className={`p-4 rounded-xl ${isDark ? 'bg-green-500/10 border border-green-500/30' : 'bg-green-50 border border-green-200'}`}>
<div className="flex items-center gap-3">
<span className="text-2xl">🤖</span>
<div>
<p className={`font-medium ${isDark ? 'text-green-300' : 'text-green-700'}`}>
KI-Analyse bereit
</p>
<p className={`text-sm ${isDark ? 'text-green-300/70' : 'text-green-600'}`}>
Wir werden Ihre Beschreibung analysieren und optimale Filter erstellen.
</p>
</div>
</div>
</div>
)}
</div>
</>
)}
</div>
)}
{/* Step 5: Notification Settings */}
{step === 5 && (
<div>
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
Benachrichtigungen konfigurieren
</h2>
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
Wie moechten Sie ueber relevante Ausschreibungen informiert werden?
</p>
<div className="space-y-6">
{/* Regions */}
{selectedTemplate && (
<div>
<label className={`block text-sm font-medium mb-3 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
Regionen
</label>
<div className="flex flex-wrap gap-2">
{selectedTemplate.guidedConfig.regionSelector.options.map(region => (
<button
key={region}
onClick={() => {
if (selectedRegions.includes(region)) {
setSelectedRegions(selectedRegions.filter(r => r !== region))
} else {
setSelectedRegions([...selectedRegions, region])
}
}}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-all ${
selectedRegions.includes(region)
? 'bg-blue-500 text-white'
: isDark
? 'bg-white/10 text-white/60 hover:bg-white/20'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{region}
</button>
))}
</div>
</div>
)}
{/* Packages */}
{selectedTemplate && (
<div>
<label className={`block text-sm font-medium mb-3 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
Produktbereiche
</label>
<div className="flex flex-wrap gap-2">
{selectedTemplate.guidedConfig.packageSelector.options.map(pkg => (
<button
key={pkg}
onClick={() => {
if (selectedPackages.includes(pkg)) {
setSelectedPackages(selectedPackages.filter(p => p !== pkg))
} else {
setSelectedPackages([...selectedPackages, pkg])
}
}}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-all ${
selectedPackages.includes(pkg)
? 'bg-blue-500 text-white'
: isDark
? 'bg-white/10 text-white/60 hover:bg-white/20'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{getPackageIcon(pkg)} {getPackageLabel(pkg)}
</button>
))}
</div>
</div>
)}
{/* Summary */}
<div className={`p-4 rounded-xl ${isDark ? 'bg-white/5' : 'bg-slate-50'}`}>
<h4 className={`font-medium mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Ihre Konfiguration
</h4>
<ul className={`space-y-2 text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
<li> Firma: <strong>{companyName}</strong></li>
<li> Template: <strong>{selectedTemplate?.templateName || 'Eigene Konfiguration'}</strong></li>
<li> Migration: <strong>{
migrationMethod === 'email' ? 'E-Mail Weiterleitung' :
migrationMethod === 'rss' ? 'RSS Import' : 'Rekonstruktion'
}</strong></li>
<li> Regionen: <strong>{selectedRegions.join(', ')}</strong></li>
<li> Produkte: <strong>{selectedPackages.map(p => getPackageLabel(p as Package)).join(', ')}</strong></li>
<li> Digest: <strong>Taeglich um 08:00, max. 10 Treffer</strong></li>
</ul>
</div>
<TipBox title="Bereit fuer den Start" icon="🚀">
<p>
Nach Abschluss werden wir Ihre Alerts analysieren und nur die wirklich
relevanten Ausschreibungen herausfiltern. Erwarten Sie ca. 80-90% weniger
irrelevante Treffer.
</p>
</TipBox>
</div>
</div>
)}
</div>
{/* Navigation Buttons */}
<div className="flex items-center gap-4 mt-8">
{step > 1 && (
<button
onClick={handleBack}
className={`px-6 py-3 rounded-xl font-medium transition-all ${
isDark
? 'bg-white/10 text-white hover:bg-white/20'
: 'bg-slate-200 text-slate-700 hover:bg-slate-300'
}`}
>
Zurueck
</button>
)}
<button
onClick={handleNext}
disabled={!canProceed()}
className={`px-8 py-3 rounded-xl font-medium transition-all ${
canProceed()
? 'bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:shadow-xl hover:shadow-purple-500/30 hover:scale-105'
: isDark
? 'bg-white/10 text-white/30 cursor-not-allowed'
: 'bg-slate-200 text-slate-400 cursor-not-allowed'
}`}
>
{step === totalSteps ? 'Einrichtung abschliessen →' : 'Weiter →'}
</button>
</div>
{/* Skip Option */}
{onSkip && step === 1 && (
<button
onClick={onSkip}
className={`mt-4 text-sm ${isDark ? 'text-white/40 hover:text-white/60' : 'text-slate-400 hover:text-slate-600'}`}
>
Ueberspringen (spaeter einrichten)
</button>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,413 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { useMessages } from '@/lib/MessagesContext'
import { useRouter } from 'next/navigation'
interface ChatMessage {
id: string
senderName: string
senderAvatar?: string
senderInitials: string
content: string
timestamp: Date
conversationId: string
isGroup?: boolean
}
interface ChatOverlayProps {
/** Auto-dismiss after X milliseconds (0 = manual dismiss only) */
autoDismissMs?: number
/** Maximum messages to queue */
maxQueue?: number
/** Enable typewriter effect */
typewriterEnabled?: boolean
/** Typewriter speed in ms per character */
typewriterSpeed?: number
/** Enable sound notification */
soundEnabled?: boolean
}
export function ChatOverlay({
autoDismissMs = 0,
maxQueue = 5,
typewriterEnabled = true,
typewriterSpeed = 30,
soundEnabled = false
}: ChatOverlayProps) {
const { isDark } = useTheme()
const router = useRouter()
const { conversations, contacts, messages: allMessages } = useMessages()
const [messageQueue, setMessageQueue] = useState<ChatMessage[]>([])
const [currentMessage, setCurrentMessage] = useState<ChatMessage | null>(null)
const [isVisible, setIsVisible] = useState(false)
const [isExiting, setIsExiting] = useState(false)
const [displayedText, setDisplayedText] = useState('')
const [isTyping, setIsTyping] = useState(false)
const [replyText, setReplyText] = useState('')
const [isReplying, setIsReplying] = useState(false)
const audioRef = useRef<HTMLAudioElement | null>(null)
const typewriterRef = useRef<NodeJS.Timeout | null>(null)
const dismissTimerRef = useRef<NodeJS.Timeout | null>(null)
// Initialize audio
useEffect(() => {
if (soundEnabled && typeof window !== 'undefined') {
audioRef.current = new Audio('/sounds/message-pop.mp3')
audioRef.current.volume = 0.3
}
}, [soundEnabled])
// Simulate incoming messages (for demo - replace with real WebSocket later)
useEffect(() => {
// Demo: Show a message after 5 seconds
const demoTimer = setTimeout(() => {
const demoMessage: ChatMessage = {
id: `demo-${Date.now()}`,
senderName: 'Familie Mueller',
senderInitials: 'FM',
content: 'Hallo! Lisa hatte heute leider Fieber und konnte nicht zur Schule kommen. Könnten Sie uns bitte die Hausaufgaben für morgen mitteilen?',
timestamp: new Date(),
conversationId: 'conv1',
isGroup: false
}
addToQueue(demoMessage)
}, 5000)
return () => clearTimeout(demoTimer)
}, [])
// Add message to queue
const addToQueue = useCallback((message: ChatMessage) => {
setMessageQueue(prev => {
if (prev.length >= maxQueue) {
return [...prev.slice(1), message]
}
return [...prev, message]
})
}, [maxQueue])
// Process queue - show next message
useEffect(() => {
if (!currentMessage && messageQueue.length > 0 && !isExiting) {
const nextMessage = messageQueue[0]
setMessageQueue(prev => prev.slice(1))
setCurrentMessage(nextMessage)
setIsVisible(true)
setDisplayedText('')
setIsTyping(true)
// Play sound
if (soundEnabled && audioRef.current) {
audioRef.current.play().catch(() => {})
}
}
}, [currentMessage, messageQueue, isExiting, soundEnabled])
// Typewriter effect
useEffect(() => {
if (!currentMessage || !isTyping) return
const fullText = currentMessage.content
let charIndex = 0
if (typewriterEnabled) {
typewriterRef.current = setInterval(() => {
charIndex++
setDisplayedText(fullText.slice(0, charIndex))
if (charIndex >= fullText.length) {
if (typewriterRef.current) {
clearInterval(typewriterRef.current)
}
setIsTyping(false)
}
}, typewriterSpeed)
} else {
setDisplayedText(fullText)
setIsTyping(false)
}
return () => {
if (typewriterRef.current) {
clearInterval(typewriterRef.current)
}
}
}, [currentMessage, isTyping, typewriterEnabled, typewriterSpeed])
// Auto-dismiss timer
useEffect(() => {
if (currentMessage && autoDismissMs > 0 && !isTyping && !isReplying) {
dismissTimerRef.current = setTimeout(() => {
handleDismiss()
}, autoDismissMs)
}
return () => {
if (dismissTimerRef.current) {
clearTimeout(dismissTimerRef.current)
}
}
}, [currentMessage, autoDismissMs, isTyping, isReplying])
// Dismiss current message
const handleDismiss = useCallback(() => {
setIsExiting(true)
setTimeout(() => {
setCurrentMessage(null)
setIsVisible(false)
setIsExiting(false)
setDisplayedText('')
setReplyText('')
setIsReplying(false)
}, 300) // Match exit animation duration
}, [])
// Open full conversation
const handleOpenConversation = useCallback(() => {
if (currentMessage) {
router.push(`/messages?conversation=${currentMessage.conversationId}`)
handleDismiss()
}
}, [currentMessage, router, handleDismiss])
// Toggle reply mode
const handleReplyClick = useCallback(() => {
setIsReplying(true)
}, [])
// Send reply
const handleSendReply = useCallback(() => {
if (!replyText.trim() || !currentMessage) return
// TODO: Actually send the message via MessagesContext
console.log('Sending reply:', replyText, 'to conversation:', currentMessage.conversationId)
// For now, just dismiss
handleDismiss()
}, [replyText, currentMessage, handleDismiss])
// Handle keyboard in reply
const handleReplyKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSendReply()
}
if (e.key === 'Escape') {
setIsReplying(false)
setReplyText('')
}
}, [handleSendReply])
if (!isVisible) return null
// Glassmorphism styles
const overlayStyle = isDark
? 'bg-slate-900/80 backdrop-blur-2xl border-white/20'
: 'bg-white/90 backdrop-blur-2xl border-black/10 shadow-2xl'
const textColor = isDark ? 'text-white' : 'text-slate-900'
const mutedColor = isDark ? 'text-white/60' : 'text-slate-500'
const buttonPrimary = 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'
const buttonSecondary = isDark
? 'bg-white/10 text-white/80 hover:bg-white/20'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
return (
<>
{/* Backdrop (subtle) */}
<div
className={`fixed inset-0 z-40 transition-opacity duration-300 ${
isExiting ? 'opacity-0' : 'opacity-100'
}`}
style={{ background: 'transparent', pointerEvents: 'none' }}
/>
{/* Chat Overlay - Slide in from right */}
<div
className={`fixed top-20 right-6 z-50 w-96 max-w-[calc(100vw-3rem)] transform transition-all duration-300 ease-out ${
isExiting
? 'translate-x-full opacity-0'
: 'translate-x-0 opacity-100'
}`}
>
<div className={`rounded-3xl border p-5 ${overlayStyle}`}>
{/* Header */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
{/* Avatar */}
<div className={`w-12 h-12 rounded-2xl flex items-center justify-center text-lg font-semibold ${
isDark ? 'bg-gradient-to-br from-purple-500 to-pink-500' : 'bg-gradient-to-br from-purple-400 to-pink-400'
} text-white`}>
{currentMessage?.senderInitials}
</div>
<div>
<h3 className={`font-semibold ${textColor}`}>
{currentMessage?.senderName}
</h3>
<p className={`text-xs ${mutedColor}`}>
{currentMessage?.isGroup ? 'Gruppenchat' : 'Direktnachricht'} Jetzt
</p>
</div>
</div>
{/* Close button */}
<button
onClick={handleDismiss}
className={`p-2 rounded-xl transition-colors ${
isDark ? 'hover:bg-white/10 text-white/60' : 'hover:bg-slate-100 text-slate-400'
}`}
>
<svg className="w-5 h-5" 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>
{/* Message Content with Typewriter Effect */}
<div className={`mb-4 p-4 rounded-2xl ${
isDark ? 'bg-white/5' : 'bg-slate-50'
}`}>
<p className={`text-sm leading-relaxed ${textColor}`}>
{displayedText}
{isTyping && (
<span className={`inline-block w-0.5 h-4 ml-0.5 animate-pulse ${
isDark ? 'bg-purple-400' : 'bg-purple-600'
}`} />
)}
</p>
</div>
{/* Reply Input (when replying) */}
{isReplying && (
<div className="mb-4">
<textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
onKeyDown={handleReplyKeyDown}
placeholder="Antwort schreiben..."
autoFocus
rows={2}
className={`w-full px-4 py-3 rounded-xl border text-sm resize-none transition-all focus:outline-none focus:ring-2 ${
isDark
? 'bg-white/10 border-white/20 text-white placeholder-white/40 focus:ring-purple-500/50'
: 'bg-white border-slate-200 text-slate-900 placeholder-slate-400 focus:ring-purple-500/50'
}`}
/>
<p className={`text-xs mt-1 ${mutedColor}`}>
Enter zum Senden Esc zum Abbrechen
</p>
</div>
)}
{/* Action Buttons */}
<div className="flex items-center gap-2">
{isReplying ? (
<>
<button
onClick={handleSendReply}
disabled={!replyText.trim()}
className={`flex-1 py-2.5 rounded-xl font-medium transition-all disabled:opacity-50 ${buttonPrimary}`}
>
<span className="flex items-center justify-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="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
Senden
</span>
</button>
<button
onClick={() => { setIsReplying(false); setReplyText('') }}
className={`px-4 py-2.5 rounded-xl font-medium transition-all ${buttonSecondary}`}
>
Abbrechen
</button>
</>
) : (
<>
<button
onClick={handleReplyClick}
className={`flex-1 py-2.5 rounded-xl font-medium transition-all ${buttonPrimary}`}
>
<span className="flex items-center justify-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="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
</svg>
Antworten
</span>
</button>
<button
onClick={handleOpenConversation}
className={`px-4 py-2.5 rounded-xl font-medium transition-all ${buttonSecondary}`}
>
Öffnen
</button>
<button
onClick={handleDismiss}
className={`px-4 py-2.5 rounded-xl font-medium transition-all ${buttonSecondary}`}
>
Später
</button>
</>
)}
</div>
</div>
{/* Message Queue Indicator */}
{messageQueue.length > 0 && (
<div className={`mt-2 px-4 py-2 rounded-xl text-center text-sm ${
isDark ? 'bg-purple-500/20 text-purple-300' : 'bg-purple-100 text-purple-700'
}`}>
+{messageQueue.length} weitere Nachricht{messageQueue.length > 1 ? 'en' : ''}
</div>
)}
</div>
{/* CSS for animations */}
<style jsx>{`
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
.animate-pulse {
animation: pulse 0.8s ease-in-out infinite;
}
`}</style>
</>
)
}
// Export a function to trigger messages programmatically
export function useChatOverlay() {
// This would be connected to a global state or event system
// For now, return a placeholder
return {
showMessage: (message: Omit<ChatMessage, 'id' | 'timestamp'>) => {
console.log('Would show message:', message)
// TODO: Implement global message trigger
}
}
}

View File

@@ -0,0 +1,266 @@
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import dynamic from 'next/dynamic'
// Leaflet Komponente dynamisch laden (nur Client-Side)
const CityMapLeaflet = dynamic(
() => import('./CityMapLeaflet'),
{
ssr: false,
loading: () => (
<div className="h-64 rounded-2xl bg-slate-200 dark:bg-white/10 flex items-center justify-center">
<div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
</div>
)
}
)
interface CityMapProps {
bundesland: string
bundeslandName: string
selectedCity: string
onSelectCity: (city: string, lat?: number, lng?: number) => void
className?: string
}
// Bundesland-Zentren für initiale Kartenposition
const bundeslandCenters: Record<string, { lat: number; lng: number; zoom: number }> = {
'SH': { lat: 54.2, lng: 9.9, zoom: 8 },
'HH': { lat: 53.55, lng: 10.0, zoom: 11 },
'MV': { lat: 53.9, lng: 12.4, zoom: 8 },
'HB': { lat: 53.1, lng: 8.8, zoom: 11 },
'NI': { lat: 52.8, lng: 9.5, zoom: 7 },
'BE': { lat: 52.52, lng: 13.4, zoom: 11 },
'BB': { lat: 52.4, lng: 13.2, zoom: 8 },
'ST': { lat: 51.9, lng: 11.7, zoom: 8 },
'NW': { lat: 51.5, lng: 7.5, zoom: 8 },
'HE': { lat: 50.6, lng: 9.0, zoom: 8 },
'TH': { lat: 50.9, lng: 11.0, zoom: 8 },
'SN': { lat: 51.1, lng: 13.2, zoom: 8 },
'RP': { lat: 49.9, lng: 7.5, zoom: 8 },
'SL': { lat: 49.4, lng: 7.0, zoom: 9 },
'BW': { lat: 48.7, lng: 9.0, zoom: 8 },
'BY': { lat: 48.8, lng: 11.5, zoom: 7 },
}
export function CityMap({
bundesland,
bundeslandName,
selectedCity,
onSelectCity,
className = ''
}: CityMapProps) {
const { isDark } = useTheme()
const [searchQuery, setSearchQuery] = useState(selectedCity || '')
const [searchResults, setSearchResults] = useState<any[]>([])
const [isSearching, setIsSearching] = useState(false)
const [markerPosition, setMarkerPosition] = useState<[number, number] | null>(null)
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const center = bundeslandCenters[bundesland] || { lat: 51.2, lng: 10.4, zoom: 6 }
// Nominatim-Suche mit Debouncing
const searchCity = useCallback(async (query: string) => {
if (query.length < 2) {
setSearchResults([])
return
}
setIsSearching(true)
try {
// Nominatim API für Geocoding (OpenStreetMap)
const response = await fetch(
`https://nominatim.openstreetmap.org/search?` +
`q=${encodeURIComponent(query)}, ${bundeslandName}, Deutschland&` +
`format=json&addressdetails=1&limit=8&countrycodes=de`,
{
headers: {
'Accept-Language': 'de',
}
}
)
const data = await response.json()
// Filtere nach relevanten Ergebnissen (Städte, Orte, Gemeinden)
const filtered = data.filter((item: any) =>
item.type === 'city' ||
item.type === 'town' ||
item.type === 'village' ||
item.type === 'municipality' ||
item.type === 'administrative' ||
item.class === 'place'
)
setSearchResults(filtered.length > 0 ? filtered : data.slice(0, 5))
} catch (error) {
console.error('Geocoding error:', error)
setSearchResults([])
} finally {
setIsSearching(false)
}
}, [bundeslandName])
// Debounced Search
useEffect(() => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current)
}
searchTimeoutRef.current = setTimeout(() => {
searchCity(searchQuery)
}, 400)
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current)
}
}
}, [searchQuery, searchCity])
// Reverse Geocoding bei Kartenklick
const handleMapClick = async (lat: number, lng: number) => {
setMarkerPosition([lat, lng])
try {
const response = await fetch(
`https://nominatim.openstreetmap.org/reverse?` +
`lat=${lat}&lon=${lng}&format=json&addressdetails=1`,
{
headers: {
'Accept-Language': 'de',
}
}
)
const data = await response.json()
// Extrahiere Stadt/Ort aus der Adresse
const city = data.address?.city ||
data.address?.town ||
data.address?.village ||
data.address?.municipality ||
data.address?.county ||
'Unbekannter Ort'
setSearchQuery(city)
onSelectCity(city, lat, lng)
} catch (error) {
console.error('Reverse geocoding error:', error)
}
}
// Suchergebnis auswählen
const handleSelectResult = (result: any) => {
const cityName = result.address?.city ||
result.address?.town ||
result.address?.village ||
result.address?.municipality ||
result.display_name.split(',')[0]
setSearchQuery(cityName)
setMarkerPosition([parseFloat(result.lat), parseFloat(result.lon)])
onSelectCity(cityName, parseFloat(result.lat), parseFloat(result.lon))
setSearchResults([])
}
// Manuelle Eingabe bestätigen
const handleManualInput = () => {
if (searchQuery.trim()) {
onSelectCity(searchQuery.trim())
setSearchResults([])
}
}
return (
<div className={`space-y-4 ${className}`}>
{/* Suchfeld */}
<div className="relative">
<div className="relative">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleManualInput()
}
}}
placeholder={`Stadt in ${bundeslandName} suchen...`}
className={`w-full px-5 py-4 pl-12 text-lg rounded-2xl border transition-all ${
isDark
? 'bg-white/10 border-white/20 text-white placeholder-white/40 focus:border-blue-400'
: 'bg-white border-slate-300 text-slate-900 placeholder-slate-400 focus:border-blue-500'
} focus:outline-none focus:ring-2 focus:ring-blue-500/30`}
/>
<svg
className={`absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 ${
isDark ? 'text-white/40' : 'text-slate-400'
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
{isSearching && (
<div className={`absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 border-2 border-t-transparent rounded-full animate-spin ${
isDark ? 'border-white/40' : 'border-slate-400'
}`} />
)}
</div>
{/* Suchergebnisse Dropdown */}
{searchResults.length > 0 && (
<div className={`absolute top-full left-0 right-0 mt-2 rounded-xl border shadow-xl z-50 max-h-64 overflow-y-auto ${
isDark
? 'bg-slate-800/95 border-white/20 backdrop-blur-xl'
: 'bg-white/95 border-slate-200 backdrop-blur-xl'
}`}>
{searchResults.map((result, index) => (
<button
key={index}
onClick={() => handleSelectResult(result)}
className={`w-full px-4 py-3 text-left transition-colors flex items-center gap-3 ${
isDark
? 'hover:bg-white/10 text-white/90'
: 'hover:bg-slate-100 text-slate-800'
} ${index > 0 ? (isDark ? 'border-t border-white/10' : 'border-t border-slate-100') : ''}`}
>
<svg className={`w-4 h-4 flex-shrink-0 ${isDark ? 'text-white/50' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
</svg>
<div className="min-w-0">
<p className="font-medium truncate">
{result.address?.city || result.address?.town || result.address?.village || result.display_name.split(',')[0]}
</p>
<p className={`text-xs truncate ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
{result.display_name}
</p>
</div>
</button>
))}
</div>
)}
</div>
{/* Karte */}
<div className={`h-64 rounded-2xl overflow-hidden border ${
isDark ? 'border-white/20' : 'border-slate-300'
}`}>
<CityMapLeaflet
center={center}
zoom={center.zoom}
markerPosition={markerPosition}
onMapClick={handleMapClick}
isDark={isDark}
/>
</div>
{/* Hinweis */}
<p className={`text-xs text-center ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
Tippen Sie den Namen ein oder klicken Sie auf die Karte
</p>
</div>
)
}

View File

@@ -0,0 +1,77 @@
'use client'
import { useEffect } from 'react'
import { MapContainer, TileLayer, Marker, useMapEvents, useMap } from 'react-leaflet'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
// Fix für Leaflet Marker Icons in Next.js
const DefaultIcon = L.icon({
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41]
})
L.Marker.prototype.options.icon = DefaultIcon
interface CityMapLeafletProps {
center: { lat: number; lng: number }
zoom: number
markerPosition: [number, number] | null
onMapClick: (lat: number, lng: number) => void
isDark: boolean
}
// Komponente für Klick-Events
function MapClickHandler({ onMapClick }: { onMapClick: (lat: number, lng: number) => void }) {
useMapEvents({
click: (e) => {
onMapClick(e.latlng.lat, e.latlng.lng)
},
})
return null
}
// Komponente um Karte neu zu zentrieren
function MapCenterUpdater({ center, zoom }: { center: { lat: number; lng: number }; zoom: number }) {
const map = useMap()
useEffect(() => {
map.setView([center.lat, center.lng], zoom)
}, [map, center.lat, center.lng, zoom])
return null
}
export default function CityMapLeaflet({
center,
zoom,
markerPosition,
onMapClick,
isDark
}: CityMapLeafletProps) {
return (
<MapContainer
center={[center.lat, center.lng]}
zoom={zoom}
style={{ height: '100%', width: '100%' }}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url={isDark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
}
/>
{markerPosition && (
<Marker position={markerPosition} icon={DefaultIcon} />
)}
<MapClickHandler onMapClick={onMapClick} />
<MapCenterUpdater center={center} zoom={zoom} />
</MapContainer>
)
}

View File

@@ -0,0 +1,405 @@
'use client'
import { useState, useMemo } from 'react'
import { useTheme } from '@/lib/ThemeContext'
interface Document {
id: string
name: string
type: string
size: number
uploadedAt: Date
category?: string
tags?: string[]
url?: string
}
interface DocumentSpaceProps {
documents: Document[]
onDelete?: (id: string) => void
onRename?: (id: string, newName: string) => void
onOpen?: (doc: Document) => void
className?: string
}
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
const formatDate = (date: Date): string => {
return new Intl.DateTimeFormat('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(date)
}
export function DocumentSpace({
documents,
onDelete,
onRename,
onOpen,
className = ''
}: DocumentSpaceProps) {
const { isDark } = useTheme()
const [searchQuery, setSearchQuery] = useState('')
const [filterType, setFilterType] = useState<string>('all')
const [sortBy, setSortBy] = useState<'name' | 'date' | 'size'>('date')
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc')
const [viewMode, setViewMode] = useState<'grid' | 'list'>('list')
const [editingId, setEditingId] = useState<string | null>(null)
const [editName, setEditName] = useState('')
const [previewDoc, setPreviewDoc] = useState<Document | null>(null)
// Filtertypen ermitteln
const fileTypes = useMemo(() => {
const types = new Set(documents.map(d => d.type.split('/')[1] || d.type))
return ['all', ...Array.from(types)]
}, [documents])
// Dokumente filtern und sortieren
const filteredDocuments = useMemo(() => {
let filtered = [...documents]
// Suchfilter
if (searchQuery) {
const query = searchQuery.toLowerCase()
filtered = filtered.filter(d =>
d.name.toLowerCase().includes(query) ||
d.tags?.some(t => t.toLowerCase().includes(query))
)
}
// Typfilter
if (filterType !== 'all') {
filtered = filtered.filter(d =>
d.type.includes(filterType)
)
}
// Sortieren
filtered.sort((a, b) => {
let cmp = 0
switch (sortBy) {
case 'name':
cmp = a.name.localeCompare(b.name)
break
case 'date':
cmp = new Date(a.uploadedAt).getTime() - new Date(b.uploadedAt).getTime()
break
case 'size':
cmp = a.size - b.size
break
}
return sortOrder === 'asc' ? cmp : -cmp
})
return filtered
}, [documents, searchQuery, filterType, sortBy, sortOrder])
const handleStartRename = (doc: Document) => {
setEditingId(doc.id)
setEditName(doc.name.replace(/\.[^/.]+$/, ''))
}
const handleSaveRename = (doc: Document) => {
if (editName.trim() && onRename) {
const ext = doc.name.split('.').pop()
onRename(doc.id, `${editName.trim()}.${ext}`)
}
setEditingId(null)
setEditName('')
}
const getFileIcon = (type: string) => {
if (type.includes('pdf')) return '📄'
if (type.includes('image')) return '🖼️'
if (type.includes('word') || type.includes('doc')) return '📝'
if (type.includes('sheet') || type.includes('excel')) return '📊'
return '📎'
}
if (documents.length === 0) {
return (
<div className={`${className} text-center py-12`}>
<div className={`w-16 h-16 mx-auto rounded-2xl flex items-center justify-center mb-4 ${
isDark ? 'bg-white/10' : 'bg-slate-100'
}`}>
<span className="text-3xl">📁</span>
</div>
<h3 className={`text-lg font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
Noch keine Dokumente
</h3>
<p className={`text-sm mt-2 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
Laden Sie Ihr erstes Dokument hoch, um loszulegen.
</p>
</div>
)
}
return (
<div className={`space-y-4 ${className}`}>
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-4">
{/* Suche */}
<div className="relative flex-1 min-w-[200px]">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Dokumente durchsuchen..."
className={`w-full pl-10 pr-4 py-2 rounded-xl border text-sm ${
isDark
? 'bg-white/5 border-white/10 text-white placeholder-white/40'
: 'bg-white border-slate-200 text-slate-900 placeholder-slate-400'
}`}
/>
<svg className={`absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 ${
isDark ? 'text-white/40' : 'text-slate-400'
}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
{/* Filter */}
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value)}
className={`px-3 py-2 rounded-xl border text-sm ${
isDark
? 'bg-white/5 border-white/10 text-white'
: 'bg-white border-slate-200 text-slate-900'
}`}
>
{fileTypes.map(type => (
<option key={type} value={type}>
{type === 'all' ? 'Alle Typen' : type.toUpperCase()}
</option>
))}
</select>
{/* Sortierung */}
<select
value={`${sortBy}-${sortOrder}`}
onChange={(e) => {
const [by, order] = e.target.value.split('-')
setSortBy(by as 'name' | 'date' | 'size')
setSortOrder(order as 'asc' | 'desc')
}}
className={`px-3 py-2 rounded-xl border text-sm ${
isDark
? 'bg-white/5 border-white/10 text-white'
: 'bg-white border-slate-200 text-slate-900'
}`}
>
<option value="date-desc">Neueste zuerst</option>
<option value="date-asc">Aelteste zuerst</option>
<option value="name-asc">Name A-Z</option>
<option value="name-desc">Name Z-A</option>
<option value="size-desc">Groesste zuerst</option>
<option value="size-asc">Kleinste zuerst</option>
</select>
{/* Ansicht */}
<div className={`flex rounded-xl border overflow-hidden ${
isDark ? 'border-white/10' : 'border-slate-200'
}`}>
<button
onClick={() => setViewMode('list')}
className={`p-2 ${
viewMode === 'list'
? isDark ? 'bg-white/20 text-white' : 'bg-slate-200 text-slate-900'
: isDark ? 'text-white/60' : 'text-slate-500'
}`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<button
onClick={() => setViewMode('grid')}
className={`p-2 ${
viewMode === 'grid'
? isDark ? 'bg-white/20 text-white' : 'bg-slate-200 text-slate-900'
: isDark ? 'text-white/60' : 'text-slate-500'
}`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
</button>
</div>
</div>
{/* Ergebnisse */}
<div className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
{filteredDocuments.length} Dokument{filteredDocuments.length !== 1 ? 'e' : ''} gefunden
</div>
{/* Dokumentliste */}
{viewMode === 'list' ? (
<div className={`rounded-2xl border overflow-hidden ${
isDark ? 'bg-white/5 border-white/10' : 'bg-white border-slate-200'
}`}>
<div className="divide-y divide-slate-200 dark:divide-white/10">
{filteredDocuments.map((doc) => (
<div
key={doc.id}
className={`p-4 flex items-center gap-4 cursor-pointer ${
isDark ? 'hover:bg-white/5' : 'hover:bg-slate-50'
}`}
onClick={() => setPreviewDoc(doc)}
>
<div className={`w-10 h-10 rounded-xl flex items-center justify-center text-xl ${
isDark ? 'bg-white/10' : 'bg-slate-100'
}`}>
{getFileIcon(doc.type)}
</div>
<div className="flex-1 min-w-0">
{editingId === doc.id ? (
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSaveRename(doc)}
onBlur={() => handleSaveRename(doc)}
autoFocus
className={`flex-1 px-2 py-1 rounded border text-sm ${
isDark
? 'bg-white/10 border-white/20 text-white'
: 'bg-white border-slate-300 text-slate-900'
}`}
/>
</div>
) : (
<p className={`font-medium truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>
{doc.name}
</p>
)}
<p className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
{formatFileSize(doc.size)} · {formatDate(new Date(doc.uploadedAt))}
</p>
</div>
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => handleStartRename(doc)}
className={`p-2 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'}`}
title="Umbenennen"
>
<svg className={`w-4 h-4 ${isDark ? 'text-white/50' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
</button>
<button
onClick={() => onDelete?.(doc.id)}
className={`p-2 rounded-lg ${isDark ? 'hover:bg-red-500/20' : 'hover:bg-red-100'}`}
title="Loeschen"
>
<svg className={`w-4 h-4 ${isDark ? 'text-white/50 hover:text-red-300' : 'text-slate-400 hover:text-red-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
))}
</div>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{filteredDocuments.map((doc) => (
<div
key={doc.id}
className={`rounded-2xl border p-4 cursor-pointer transition-all hover:scale-105 ${
isDark
? 'bg-white/5 border-white/10 hover:bg-white/10'
: 'bg-white border-slate-200 hover:shadow-lg'
}`}
onClick={() => setPreviewDoc(doc)}
>
<div className={`w-full aspect-square rounded-xl flex items-center justify-center mb-3 ${
isDark ? 'bg-white/10' : 'bg-slate-100'
}`}>
<span className="text-4xl">{getFileIcon(doc.type)}</span>
</div>
<p className={`font-medium text-sm truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>
{doc.name}
</p>
<p className={`text-xs mt-1 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
{formatFileSize(doc.size)}
</p>
</div>
))}
</div>
)}
{/* Vorschau-Modal */}
{previewDoc && previewDoc.url && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
onClick={() => setPreviewDoc(null)}
/>
<div className={`relative w-full max-w-4xl max-h-[90vh] rounded-3xl overflow-hidden ${
isDark ? 'bg-slate-900' : 'bg-white'
}`}>
{/* Header */}
<div className={`flex items-center justify-between p-4 border-b ${
isDark ? 'border-white/10' : 'border-slate-200'
}`}>
<div className="flex items-center gap-3">
<span className="text-2xl">{getFileIcon(previewDoc.type)}</span>
<div>
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
{previewDoc.name}
</h3>
<p className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
{formatFileSize(previewDoc.size)} · {formatDate(new Date(previewDoc.uploadedAt))}
</p>
</div>
</div>
<button
onClick={() => setPreviewDoc(null)}
className={`p-2 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'}`}
>
<svg className={`w-6 h-6 ${isDark ? 'text-white/60' : 'text-slate-400'}`} 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>
{/* Vorschau-Inhalt */}
<div className={`p-4 overflow-auto max-h-[calc(90vh-120px)] ${
isDark ? 'bg-slate-800' : 'bg-slate-50'
}`}>
{previewDoc.type.includes('image') ? (
<img
src={previewDoc.url}
alt={previewDoc.name}
className="max-w-full h-auto mx-auto rounded-lg shadow-lg"
/>
) : previewDoc.type.includes('pdf') ? (
<iframe
src={previewDoc.url}
className="w-full h-[70vh] rounded-lg"
title={previewDoc.name}
/>
) : (
<div className={`text-center py-12 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
<span className="text-6xl block mb-4">{getFileIcon(previewDoc.type)}</span>
<p>Vorschau fuer diesen Dateityp nicht verfuegbar</p>
</div>
)}
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,363 @@
'use client'
import { useState, useRef, useCallback } from 'react'
import { useTheme } from '@/lib/ThemeContext'
interface UploadedDocument {
id: string
name: string
originalName: string
size: number
type: string
uploadedAt: Date
status: 'uploading' | 'processing' | 'complete' | 'error'
progress: number
url?: string
error?: string
}
interface DocumentUploadProps {
onUploadComplete?: (documents: UploadedDocument[]) => void
className?: string
}
// Formatiere Dateigroesse
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
export function DocumentUpload({ onUploadComplete, className = '' }: DocumentUploadProps) {
const { isDark } = useTheme()
const [documents, setDocuments] = useState<UploadedDocument[]>([])
const [isDragging, setIsDragging] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [editName, setEditName] = useState('')
const fileInputRef = useRef<HTMLInputElement>(null)
// Echter Upload mit lokalem Blob URL fuer Vorschau
const uploadFile = useCallback((file: File): Promise<UploadedDocument> => {
return new Promise(async (resolve, reject) => {
const doc: UploadedDocument = {
id: `doc-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
name: file.name,
originalName: file.name,
size: file.size,
type: file.type,
uploadedAt: new Date(),
status: 'uploading',
progress: 0
}
// Dokument sofort zur Liste hinzufuegen
setDocuments(prev => [...prev, doc])
try {
// Fortschritt auf 30% setzen (Datei wird gelesen)
setDocuments(prev => prev.map(d => d.id === doc.id ? { ...d, progress: 30 } : d))
// Blob URL fuer lokale Vorschau erstellen
const blobUrl = URL.createObjectURL(file)
// Fortschritt auf 60% setzen
setDocuments(prev => prev.map(d => d.id === doc.id ? { ...d, progress: 60 } : d))
// Kurze Verzoegerung fuer visuelles Feedback
await new Promise(r => setTimeout(r, 300))
// Fortschritt auf 100% setzen
const completedDoc = {
...doc,
status: 'complete' as const,
progress: 100,
url: blobUrl
}
setDocuments(prev => prev.map(d => d.id === doc.id ? completedDoc : d))
resolve(completedDoc)
} catch (error) {
console.error('Upload error:', error)
setDocuments(prev => prev.map(d =>
d.id === doc.id ? { ...d, status: 'error' as const, error: 'Upload fehlgeschlagen' } : d
))
reject(error)
}
})
}, [])
// Dateien verarbeiten
const handleFiles = useCallback(async (files: FileList | null) => {
if (!files || files.length === 0) return
const fileArray = Array.from(files)
const validFiles = fileArray.filter(f =>
f.type === 'application/pdf' ||
f.type.startsWith('image/') ||
f.name.endsWith('.pdf') ||
f.name.endsWith('.jpg') ||
f.name.endsWith('.jpeg') ||
f.name.endsWith('.png')
)
if (validFiles.length === 0) {
alert('Bitte nur PDF- oder Bilddateien hochladen.')
return
}
const uploadedDocs = await Promise.all(validFiles.map(f => uploadFile(f)))
onUploadComplete?.(uploadedDocs)
}, [uploadFile, onUploadComplete])
// Drag & Drop Handler
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(true)
}, [])
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
}, [])
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
handleFiles(e.dataTransfer.files)
}, [handleFiles])
// Dokument loeschen
const handleDelete = useCallback((id: string) => {
setDocuments(prev => prev.filter(d => d.id !== id))
}, [])
// Dokument umbenennen starten
const handleStartRename = useCallback((doc: UploadedDocument) => {
setEditingId(doc.id)
setEditName(doc.name.replace(/\.[^/.]+$/, '')) // Name ohne Extension
}, [])
// Umbenennen speichern
const handleSaveRename = useCallback((id: string) => {
if (editName.trim()) {
setDocuments(prev => prev.map(d => {
if (d.id === id) {
const ext = d.originalName.split('.').pop()
return { ...d, name: `${editName.trim()}.${ext}` }
}
return d
}))
}
setEditingId(null)
setEditName('')
}, [editName])
// Datei-Icon basierend auf Typ
const getFileIcon = (type: string) => {
if (type === 'application/pdf') return '📄'
if (type.startsWith('image/')) return '🖼️'
return '📎'
}
return (
<div className={`space-y-6 ${className}`}>
{/* Upload-Bereich */}
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={`relative border-2 border-dashed rounded-3xl p-8 text-center cursor-pointer transition-all ${
isDragging
? isDark
? 'border-blue-400 bg-blue-500/20'
: 'border-blue-500 bg-blue-50'
: isDark
? 'border-white/20 bg-white/5 hover:bg-white/10 hover:border-white/30'
: 'border-slate-300 bg-slate-50 hover:bg-slate-100 hover:border-slate-400'
}`}
>
<input
ref={fileInputRef}
type="file"
multiple
accept=".pdf,.jpg,.jpeg,.png,image/*,application/pdf"
onChange={(e) => handleFiles(e.target.files)}
className="hidden"
/>
<div className="flex flex-col items-center gap-4">
<div className={`w-16 h-16 rounded-2xl flex items-center justify-center ${
isDark ? 'bg-white/10' : 'bg-slate-200'
}`}>
<svg className={`w-8 h-8 ${isDark ? 'text-white/60' : 'text-slate-500'}`} 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>
</div>
<div>
<p className={`text-lg font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
{isDragging ? 'Dateien hier ablegen' : 'Dokumente hochladen'}
</p>
<p className={`text-sm mt-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
Ziehen Sie Dateien hierher oder klicken Sie zum Auswaehlen
</p>
<p className={`text-xs mt-2 ${isDark ? 'text-white/30' : 'text-slate-400'}`}>
PDF, JPG, PNG - max. 50 MB pro Datei
</p>
</div>
</div>
</div>
{/* Hochgeladene Dokumente */}
{documents.length > 0 && (
<div className={`rounded-2xl border overflow-hidden ${
isDark ? 'bg-white/5 border-white/10' : 'bg-white border-slate-200'
}`}>
<div className={`px-4 py-3 border-b ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
<h3 className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
Hochgeladene Dokumente ({documents.length})
</h3>
</div>
<div className="divide-y divide-slate-200 dark:divide-white/10">
{documents.map((doc) => (
<div key={doc.id} className={`p-4 flex items-center gap-4 ${
isDark ? 'hover:bg-white/5' : 'hover:bg-slate-50'
}`}>
{/* Icon */}
<div className={`w-12 h-12 rounded-xl flex items-center justify-center text-2xl ${
doc.status === 'complete'
? isDark ? 'bg-green-500/20' : 'bg-green-100'
: doc.status === 'error'
? isDark ? 'bg-red-500/20' : 'bg-red-100'
: isDark ? 'bg-blue-500/20' : 'bg-blue-100'
}`}>
{getFileIcon(doc.type)}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
{editingId === doc.id ? (
<div className="flex items-center gap-2">
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSaveRename(doc.id)}
onBlur={() => handleSaveRename(doc.id)}
autoFocus
className={`flex-1 px-2 py-1 rounded border text-sm ${
isDark
? 'bg-white/10 border-white/20 text-white'
: 'bg-white border-slate-300 text-slate-900'
}`}
/>
<span className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
.{doc.originalName.split('.').pop()}
</span>
</div>
) : (
<p className={`font-medium truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>
{doc.name}
</p>
)}
<div className="flex items-center gap-3 mt-1">
<span className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
{formatFileSize(doc.size)}
</span>
{doc.status === 'complete' && (
<span className={`text-xs px-2 py-0.5 rounded-full ${
isDark ? 'bg-green-500/20 text-green-300' : 'bg-green-100 text-green-700'
}`}>
Hochgeladen
</span>
)}
{doc.status === 'uploading' && (
<span className={`text-xs ${isDark ? 'text-blue-300' : 'text-blue-600'}`}>
{Math.round(doc.progress)}%
</span>
)}
{doc.status === 'error' && (
<span className={`text-xs px-2 py-0.5 rounded-full ${
isDark ? 'bg-red-500/20 text-red-300' : 'bg-red-100 text-red-700'
}`}>
Fehler
</span>
)}
</div>
{/* Progress Bar */}
{doc.status === 'uploading' && (
<div className={`mt-2 h-1.5 rounded-full overflow-hidden ${
isDark ? 'bg-white/10' : 'bg-slate-200'
}`}>
<div
className="h-full bg-gradient-to-r from-blue-500 to-purple-500 rounded-full transition-all duration-300"
style={{ width: `${doc.progress}%` }}
/>
</div>
)}
</div>
{/* Aktionen */}
{doc.status === 'complete' && (
<div className="flex items-center gap-2">
{/* Umbenennen */}
<button
onClick={() => handleStartRename(doc)}
className={`p-2 rounded-lg transition-colors ${
isDark
? 'hover:bg-white/10 text-white/60 hover:text-white'
: 'hover:bg-slate-100 text-slate-400 hover:text-slate-700'
}`}
title="Umbenennen"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
</button>
{/* Oeffnen/Vorschau */}
{doc.url && (
<a
href={doc.url}
target="_blank"
rel="noopener noreferrer"
className={`p-2 rounded-lg transition-colors ${
isDark
? 'hover:bg-white/10 text-white/60 hover:text-white'
: 'hover:bg-slate-100 text-slate-400 hover:text-slate-700'
}`}
title="Oeffnen"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
)}
{/* Loeschen */}
<button
onClick={() => handleDelete(doc.id)}
className={`p-2 rounded-lg transition-colors ${
isDark
? 'hover:bg-red-500/20 text-white/60 hover:text-red-300'
: 'hover:bg-red-100 text-slate-400 hover:text-red-600'
}`}
title="Loeschen"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,90 @@
'use client'
import Link from 'next/link'
import { useLanguage } from '@/lib/LanguageContext'
import { useTheme } from '@/lib/ThemeContext'
interface FooterProps {
className?: string
onOpenCookieSettings?: () => void
}
export function Footer({ className = '', onOpenCookieSettings }: FooterProps) {
const { t } = useLanguage()
const { isDark } = useTheme()
const handleCookieClick = (e: React.MouseEvent) => {
e.preventDefault()
if (onOpenCookieSettings) {
onOpenCookieSettings()
} else {
// Fallback: Alert wenn kein Handler definiert
alert('Cookie-Banner wird hier geoeffnet (noch nicht implementiert)')
}
}
return (
<footer className={`mt-auto ${className}`}>
<div className={`
${isDark
? 'bg-white/5 border-white/10'
: 'bg-black/5 border-black/10'
}
backdrop-blur-xl border-t py-6 px-8
`}>
<div className="max-w-7xl mx-auto">
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
{/* Links - Rechtlich notwendig */}
<nav className="flex flex-wrap items-center justify-center gap-6">
<Link
href="/impressum"
className={`text-sm hover:underline transition-colors ${
isDark ? 'text-white/60 hover:text-white' : 'text-black/60 hover:text-black'
}`}
>
{t('imprint')}
</Link>
<Link
href="/datenschutz"
className={`text-sm hover:underline transition-colors ${
isDark ? 'text-white/60 hover:text-white' : 'text-black/60 hover:text-black'
}`}
>
{t('privacy')}
</Link>
<Link
href="/agb"
className={`text-sm hover:underline transition-colors ${
isDark ? 'text-white/60 hover:text-white' : 'text-black/60 hover:text-black'
}`}
>
{t('legal')}
</Link>
<Link
href="/kontakt"
className={`text-sm hover:underline transition-colors ${
isDark ? 'text-white/60 hover:text-white' : 'text-black/60 hover:text-black'
}`}
>
{t('contact')}
</Link>
<button
onClick={handleCookieClick}
className={`text-sm hover:underline transition-colors ${
isDark ? 'text-white/60 hover:text-white' : 'text-black/60 hover:text-black'
}`}
>
{t('cookie_settings')}
</button>
</nav>
{/* Copyright */}
<p className={`text-sm ${isDark ? 'text-white/40' : 'text-black/40'}`}>
{t('copyright')}
</p>
</div>
</div>
</div>
</footer>
)
}

View File

@@ -0,0 +1,214 @@
'use client'
import { useTheme } from '@/lib/ThemeContext'
interface GermanyMapProps {
selectedState: string | null
onSelectState: (stateId: string) => void
suggestedState?: string | null
className?: string
}
// Bundesländer mit Kürzeln und Namen
export const bundeslaender: Record<string, string> = {
'SH': 'Schleswig-Holstein',
'HH': 'Hamburg',
'MV': 'Mecklenburg-Vorpommern',
'HB': 'Bremen',
'NI': 'Niedersachsen',
'BE': 'Berlin',
'BB': 'Brandenburg',
'ST': 'Sachsen-Anhalt',
'NW': 'Nordrhein-Westfalen',
'HE': 'Hessen',
'TH': 'Thüringen',
'SN': 'Sachsen',
'RP': 'Rheinland-Pfalz',
'SL': 'Saarland',
'BW': 'Baden-Württemberg',
'BY': 'Bayern',
}
// Echte GeoJSON-basierte SVG-Pfade (vereinfacht aber geometrisch korrekt)
// Basierend auf Natural Earth / OpenStreetMap Daten, projiziert auf viewBox 0 0 500 600
const statePaths: Record<string, { path: string; labelX: number; labelY: number; labelSize: string }> = {
'SH': {
path: 'M205.2,8.1l5.8,3.2l12.1-1.9l17.8,6.4l11.3-0.8l14.2,10.4l9.4,16.8l-2.4,13.6l-8.2,6.4l-13.1,5.5l-7.3,12.8l-7.5-2.8l-0.5-7.1l-9.3,1.2l-4.6-4.8l-4.8,3.1l-8.5-4.1l-3.4,2.8l-6.5-5.2l-0.2-7.4l6.8-5.9l-5.3-12.2l1.8-9.2l-7.4-8.3l5.2-9.8l4.4-2.6Z',
labelX: 240, labelY: 52, labelSize: 'text-[11px]'
},
'HH': {
path: 'M236.8,79.5l10.5,1.2l6.8,8.2l-2.1,9.4l-11.2,3.8l-8.4-5.2l-1.8-9.1l6.2-8.3Z',
labelX: 242, labelY: 93, labelSize: 'text-[9px]'
},
'MV': {
path: 'M272.6,27.4l15.8-2.1l22.5,1.8l27.3,8.6l22.8,15.2l8.4,18.6l-4.8,21.4l-18.2,16.8l-32.4,4.6l-26.8-3.2l-20.4-12.6l-8.2-18.4l2.4-13.6l-9.4-16.8l8.6-11.4l12.3-8.9Z',
labelX: 335, labelY: 72, labelSize: 'text-[11px]'
},
'HB': {
path: 'M188.4,109.2l12.2-1.6l8.4,6.8l-0.8,12.4l-10.6,5.2l-11.2-4.8l-2.4-10.2l4.4-7.8Z M172.8,136.4l6.2,2.1l4.8,8.2l-5.4,4.1l-7.2-3.8l1.6-10.6Z',
labelX: 193, labelY: 123, labelSize: 'text-[8px]'
},
'NI': {
path: 'M117.4,73.8l22.8-7.4l18.6-4.2l17.2,9.8l6.8,12.4l-5.2,8.3l4.6,4.8l9.3-1.2l0.5,7.1l7.5,2.8l-1.2,12.4l-6.2,8.3l1.8,9.1l8.4,5.2l11.2-3.8l6.2,5.4l10.8,2.4l8.2,18.4l20.4,12.6l2.1,16.4l-14.8,24.2l-26.2,18.4l-32.4,8.6l-28.6-2.8l-24.2-10.6l-18.8-18.4l-12.6-22.4l-8.4-16.8l-2.8-21.4l4.2-18.6l8.6-14.2l12.4-8.6l6.2-7.4Z',
labelX: 195, labelY: 168, labelSize: 'text-[14px]'
},
'BE': {
path: 'M372.4,166.2l14.6-0.8l9.2,8.4l-1.4,14.2l-12.8,6.4l-13.2-5.8l-2.6-12.4l6.2-10Z',
labelX: 378, labelY: 180, labelSize: 'text-[9px]'
},
'BB': {
path: 'M312.8,98.6l32.4-4.6l18.2-16.8l22.4,4.2l18.6,16.8l8.4,28.4l-4.2,34.6l-16.8,32.4l-28.6,22.8l-38.4,4.6l-32.6-14.8l-12.4-28.6l4.2-32.8l14.8-24.2l-2.1-16.4l16.1-5.6Z M372.4,166.2l14.6-0.8l9.2,8.4l-1.4,14.2l-12.8,6.4l-13.2-5.8l-2.6-12.4l6.2-10Z',
labelX: 345, labelY: 195, labelSize: 'text-[12px]'
},
'ST': {
path: 'M280.8,152.4l32.6,14.8l-4.2,32.8l-18.4,28.6l-32.8,12.4l-28.4-8.6l-8.6-24.2l8.4-28.4l18.6-18.8l32.8-8.6Z',
labelX: 268, labelY: 205, labelSize: 'text-[11px]'
},
'NW': {
path: 'M89.2,164.8l22.6-4.2l24.2,10.6l28.6,2.8l8.4,18.6l-4.8,32.4l-12.6,28.8l-18.4,22.6l-32.4,4.8l-28.6-12.4l-22.4-24.6l-4.2-28.4l8.6-24.8l14.2-18.6l16.8-7.6Z',
labelX: 105, labelY: 232, labelSize: 'text-[14px]'
},
'HE': {
path: 'M140.8,226.6l32.4-8.6l26.2-18.4l18.4,8.6l8.6,24.2l-4.8,32.6l-18.4,28.4l-24.6,8.6l-28.4-4.2l-18.4-16.8l12.6-28.8l-3.6-25.6Z',
labelX: 178, labelY: 272, labelSize: 'text-[13px]'
},
'TH': {
path: 'M221.6,240.8l28.4,8.6l32.8-12.4l18.4,4.2l8.6,28.4l-14.6,28.6l-28.4,12.4l-32.6-4.8l-18.6-18.4l-8.4-14.2l4.8-32.6l9.6,0.2Z',
labelX: 262, labelY: 285, labelSize: 'text-[11px]'
},
'SN': {
path: 'M280.2,200l38.4-4.6l28.6-22.8l22.8,8.6l18.4,24.2l4.2,32.8l-14.6,32.4l-28.4,18.6l-38.6,4.2l-28.4-18.4l-14.6-28.6l-8.6-28.4l8.4-12.6l12.4-5.4Z',
labelX: 340, labelY: 255, labelSize: 'text-[12px]'
},
'RP': {
path: 'M54.4,280.8l28.6,12.4l-4.8,28.6l-8.4,32.4l-18.6,28.4l-24.8,4.2l-18.4-22.6l-4.2-32.4l12.4-28.6l18.6-14.8l19.6-7.6Z',
labelX: 52, labelY: 340, labelSize: 'text-[11px]'
},
'SL': {
path: 'M26.2,382.6l24.8-4.2l12.4,18.6l-4.8,18.4l-18.6,4.2l-14.2-12.4l-4.2-14.6l4.6-10Z',
labelX: 38, labelY: 400, labelSize: 'text-[9px]'
},
'BW': {
path: 'M54.4,401l18.6-28.4l8.4-32.4l4.8-28.6l32.4-4.8l28.4,4.2l24.6-8.6l12.4,18.6l8.6,32.4l-4.2,38.6l-18.4,32.4l-32.6,18.6l-38.4,4.2l-28.6-12.4l-14.2-22.6l-6.2-28.6l4.8-18.4l18.6-4.2l-8.6,28.6l-10.4,12.4Z',
labelX: 118, labelY: 420, labelSize: 'text-[13px]'
},
'BY': {
path: 'M150.2,305.6l24.6-8.6l18.4-28.4l32.6,4.8l28.4-12.4l14.6-28.6l28.4,18.4l38.6-4.2l28.4-18.6l18.4,12.4l8.6,38.4l-4.2,48.6l-18.6,38.4l-38.4,28.6l-48.6,12.4l-38.4-4.8l-32.4-18.6l-18.6-32.4l4.2-38.6l-8.6-32.4l-12.4,18.6l-4.6,27.6Z',
labelX: 290, labelY: 395, labelSize: 'text-[16px]'
},
}
export function GermanyMap({
selectedState,
onSelectState,
suggestedState = null,
className = ''
}: GermanyMapProps) {
const { isDark } = useTheme()
const getStateStyle = (stateId: string) => {
const isSelected = selectedState === stateId
const isSuggested = suggestedState === stateId && !selectedState
if (isSelected) {
return 'fill-blue-500 stroke-blue-700'
}
if (isSuggested) {
return isDark
? 'fill-green-500/40 stroke-green-400 animate-pulse'
: 'fill-green-200 stroke-green-500 animate-pulse'
}
return isDark
? 'fill-white/10 stroke-white/25 hover:fill-blue-400/30 hover:stroke-blue-400/50'
: 'fill-slate-100 stroke-slate-300 hover:fill-blue-100 hover:stroke-blue-400'
}
const getLabelStyle = (stateId: string) => {
const isSelected = selectedState === stateId
const isSuggested = suggestedState === stateId && !selectedState
if (isSelected) {
return 'fill-white font-bold'
}
if (isSuggested) {
return isDark ? 'fill-green-300 font-semibold' : 'fill-green-700 font-semibold'
}
return isDark ? 'fill-white/60' : 'fill-slate-500'
}
return (
<div className={`relative ${className}`}>
<svg
viewBox="0 0 500 600"
className="w-full h-full"
style={{ maxHeight: '420px' }}
>
{/* Hintergrund und Filter */}
<defs>
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="3" result="blur"/>
<feMerge>
<feMergeNode in="blur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="2" dy="3" stdDeviation="4" floodOpacity="0.4"/>
</filter>
</defs>
{/* Bundesländer - von groß nach klein für korrekte Überlappung */}
{['BY', 'NI', 'BW', 'NW', 'BB', 'MV', 'SN', 'ST', 'HE', 'TH', 'RP', 'SH', 'SL', 'HB', 'HH', 'BE'].map((stateId) => {
const state = statePaths[stateId]
const isSelected = selectedState === stateId
const isSuggested = suggestedState === stateId && !selectedState
return (
<g
key={stateId}
onClick={() => onSelectState(stateId)}
className="cursor-pointer transition-all duration-200"
style={{
filter: isSelected ? 'url(#shadow)' : isSuggested ? 'url(#glow)' : 'none'
}}
>
<path
d={state.path}
className={getStateStyle(stateId)}
strokeWidth={isSelected ? 2.5 : isSuggested ? 2 : 1.2}
fillRule="evenodd"
/>
<text
x={state.labelX}
y={state.labelY}
className={`${state.labelSize} ${getLabelStyle(stateId)} pointer-events-none select-none`}
textAnchor="middle"
dominantBaseline="middle"
>
{stateId}
</text>
</g>
)
})}
</svg>
{/* Hinweis bei vorgeschlagenem Bundesland */}
{suggestedState && !selectedState && (
<div className={`absolute top-2 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded-lg text-xs ${
isDark ? 'bg-green-500/20 text-green-300 border border-green-500/30' : 'bg-green-50 text-green-700 border border-green-200'
}`}>
Vorschlag: <strong>{bundeslaender[suggestedState]}</strong> (Klicken zum Bestätigen)
</div>
)}
{/* Ausgewähltes Bundesland */}
{selectedState && (
<div className={`absolute bottom-2 left-1/2 -translate-x-1/2 px-4 py-2 rounded-xl backdrop-blur-xl text-center ${
isDark ? 'bg-blue-500/30 text-white border border-blue-400/30' : 'bg-blue-100 text-blue-900 border border-blue-200'
}`}>
<span className="font-semibold">{bundeslaender[selectedState]}</span>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,212 @@
'use client'
import { ReactNode } from 'react'
import { useTheme } from '@/lib/ThemeContext'
export type InfoBoxVariant = 'info' | 'tip' | 'warning' | 'success' | 'error'
interface InfoBoxProps {
icon?: string
title: string
children: ReactNode
variant?: InfoBoxVariant
className?: string
collapsible?: boolean
defaultExpanded?: boolean
}
export function InfoBox({
icon,
title,
children,
variant = 'info',
className = '',
collapsible = false,
defaultExpanded = true
}: InfoBoxProps) {
const { isDark } = useTheme()
// Farben basierend auf Variante und Theme
const getColors = () => {
const variants = {
info: {
dark: 'bg-blue-500/10 border-blue-500/30 text-blue-100',
light: 'bg-blue-50 border-blue-200 text-blue-800',
icon: '💡'
},
tip: {
dark: 'bg-green-500/10 border-green-500/30 text-green-100',
light: 'bg-green-50 border-green-200 text-green-800',
icon: '✨'
},
warning: {
dark: 'bg-amber-500/10 border-amber-500/30 text-amber-100',
light: 'bg-amber-50 border-amber-200 text-amber-800',
icon: '⚠️'
},
success: {
dark: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-100',
light: 'bg-emerald-50 border-emerald-200 text-emerald-800',
icon: '✅'
},
error: {
dark: 'bg-red-500/10 border-red-500/30 text-red-100',
light: 'bg-red-50 border-red-200 text-red-800',
icon: '❌'
}
}
return variants[variant]
}
const colors = getColors()
const displayIcon = icon || colors.icon
return (
<div className={`p-4 rounded-xl border ${isDark ? colors.dark : colors.light} ${className}`}>
<div className="flex items-start gap-3">
<span className="text-2xl flex-shrink-0">{displayIcon}</span>
<div className="flex-1 min-w-0">
<h4 className="font-medium mb-1">{title}</h4>
<div className={`text-sm ${isDark ? 'opacity-80' : 'opacity-90'}`}>
{children}
</div>
</div>
</div>
</div>
)
}
// Spezielle Varianten als eigene Komponenten für Convenience
export function TipBox({ title, children, icon, className }: Omit<InfoBoxProps, 'variant'>) {
return <InfoBox variant="tip" title={title} icon={icon} className={className}>{children}</InfoBox>
}
export function WarningBox({ title, children, icon, className }: Omit<InfoBoxProps, 'variant'>) {
return <InfoBox variant="warning" title={title} icon={icon} className={className}>{children}</InfoBox>
}
export function SuccessBox({ title, children, icon, className }: Omit<InfoBoxProps, 'variant'>) {
return <InfoBox variant="success" title={title} icon={icon} className={className}>{children}</InfoBox>
}
// Step-Anleitung Box für Wizards
interface StepBoxProps {
step: number
title: string
children: ReactNode
isActive?: boolean
isCompleted?: boolean
className?: string
}
export function StepBox({
step,
title,
children,
isActive = false,
isCompleted = false,
className = ''
}: StepBoxProps) {
const { isDark } = useTheme()
return (
<div className={`p-4 rounded-xl border transition-all ${
isCompleted
? isDark
? 'bg-green-500/10 border-green-500/30'
: 'bg-green-50 border-green-200'
: isActive
? isDark
? 'bg-blue-500/10 border-blue-500/30'
: 'bg-blue-50 border-blue-200'
: isDark
? 'bg-white/5 border-white/10'
: 'bg-slate-50 border-slate-200'
} ${className}`}>
<div className="flex items-start gap-3">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold flex-shrink-0 ${
isCompleted
? 'bg-green-500 text-white'
: isActive
? 'bg-blue-500 text-white'
: isDark
? 'bg-white/20 text-white/60'
: 'bg-slate-200 text-slate-500'
}`}>
{isCompleted ? '✓' : step}
</div>
<div className="flex-1 min-w-0">
<h4 className={`font-medium mb-1 ${
isCompleted || isActive
? isDark ? 'text-white' : 'text-slate-900'
: isDark ? 'text-white/60' : 'text-slate-500'
}`}>
{title}
</h4>
<div className={`text-sm ${
isCompleted || isActive
? isDark ? 'text-white/70' : 'text-slate-600'
: isDark ? 'text-white/40' : 'text-slate-400'
}`}>
{children}
</div>
</div>
</div>
</div>
)
}
// Feature-Highlight Box
interface FeatureBoxProps {
icon: string
title: string
description: string
onClick?: () => void
isSelected?: boolean
className?: string
}
export function FeatureBox({
icon,
title,
description,
onClick,
isSelected = false,
className = ''
}: FeatureBoxProps) {
const { isDark } = useTheme()
return (
<button
onClick={onClick}
className={`w-full p-4 rounded-xl border text-left transition-all ${
isSelected
? isDark
? 'bg-purple-500/20 border-purple-500/50 ring-2 ring-purple-500/30'
: 'bg-purple-50 border-purple-300 ring-2 ring-purple-200'
: isDark
? 'bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20'
: 'bg-white border-slate-200 hover:bg-slate-50 hover:border-slate-300'
} ${className}`}
>
<div className="flex items-start gap-3">
<span className="text-2xl">{icon}</span>
<div>
<h4 className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>{title}</h4>
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>{description}</p>
</div>
{isSelected && (
<div className="ml-auto">
<div className={`w-6 h-6 rounded-full flex items-center justify-center ${
isDark ? 'bg-purple-500' : 'bg-purple-500'
}`}>
<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>
)}
</div>
</button>
)
}

View File

@@ -0,0 +1,113 @@
'use client'
import { useState, useRef, useEffect } from 'react'
import { useLanguage } from '@/lib/LanguageContext'
import { useTheme } from '@/lib/ThemeContext'
import { Language } from '@/lib/i18n'
interface LanguageDropdownProps {
className?: string
}
export function LanguageDropdown({ className = '' }: LanguageDropdownProps) {
const { language, setLanguage, availableLanguages } = useLanguage()
const { isDark } = useTheme()
const [isOpen, setIsOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
// Schliessen bei Klick ausserhalb
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
// Schliessen bei Escape
useEffect(() => {
function handleEscape(event: KeyboardEvent) {
if (event.key === 'Escape') setIsOpen(false)
}
document.addEventListener('keydown', handleEscape)
return () => document.removeEventListener('keydown', handleEscape)
}, [])
const currentLang = availableLanguages[language]
return (
<div ref={dropdownRef} className={`relative ${className}`}>
{/* Trigger Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className={`flex items-center gap-2 px-4 py-2.5 backdrop-blur-xl border rounded-2xl transition-all ${
isDark
? 'bg-white/10 border-white/20 text-white hover:bg-white/20'
: 'bg-black/5 border-black/10 text-slate-700 hover:bg-black/10'
}`}
aria-haspopup="listbox"
aria-expanded={isOpen}
>
<span className="text-lg">{currentLang.flag}</span>
<span className="text-sm font-medium hidden sm:inline">{currentLang.name}</span>
<svg
className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''} ${
isDark ? 'text-white/60' : 'text-slate-500'
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Dropdown Menu */}
{isOpen && (
<div className={`absolute right-0 mt-2 w-48 backdrop-blur-2xl border rounded-2xl shadow-xl overflow-hidden z-50 ${
isDark
? 'bg-slate-900/90 border-white/20'
: 'bg-white/95 border-black/10'
}`}>
<ul role="listbox" className="py-1">
{(Object.keys(availableLanguages) as Language[]).map((lang) => {
const langInfo = availableLanguages[lang]
const isSelected = lang === language
return (
<li key={lang}>
<button
onClick={() => {
setLanguage(lang)
setIsOpen(false)
}}
className={`w-full flex items-center gap-3 px-4 py-3 text-left transition-all ${
isSelected
? isDark
? 'bg-white/20 text-white'
: 'bg-indigo-100 text-slate-900'
: isDark
? 'text-white/80 hover:bg-white/10 hover:text-white'
: 'text-slate-700 hover:bg-slate-100 hover:text-slate-900'
}`}
role="option"
aria-selected={isSelected}
>
<span className="text-lg">{langInfo.flag}</span>
<span className="text-sm font-medium">{langInfo.name}</span>
{isSelected && (
<svg className={`w-4 h-4 ml-auto ${isDark ? 'text-green-400' : 'text-green-600'}`} fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
)}
</button>
</li>
)
})}
</ul>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,193 @@
'use client'
import { useState, ReactNode } from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
// ============================================
// TYPES
// ============================================
interface NavItem {
id: string
name: string
href: string
icon: ReactNode
description?: string
}
// ============================================
// NAVIGATION - Hier werden alle Tabs definiert
// ============================================
const navigation: NavItem[] = [
{
id: 'magic-help',
name: 'Magic Help',
href: '/magic-help',
icon: (
<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>
),
description: 'Handschrift-OCR & Klausur-Korrektur',
},
{
id: 'meet',
name: 'Meet',
href: '/meet',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
),
description: 'Videokonferenzen & Meetings',
},
]
// ============================================
// LAYOUT COMPONENT
// ============================================
interface LayoutProps {
children: ReactNode
}
export default function Layout({ children }: LayoutProps) {
const pathname = usePathname()
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const isActive = (href: string) => {
if (href === '/') return pathname === '/'
return pathname.startsWith(href)
}
return (
<div className="min-h-screen flex flex-col">
{/* ============================================
HEADER
============================================ */}
<header className="h-14 bg-white border-b border-slate-200 flex items-center justify-between px-4 fixed top-0 left-0 right-0 z-30">
{/* Logo */}
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-primary-500 rounded-lg flex items-center justify-center text-white font-bold text-sm">
BP
</div>
<div>
<div className="font-bold text-primary-500 text-lg leading-none">BreakPilot</div>
<div className="text-xs text-slate-400">Studio v2</div>
</div>
</div>
{/* Spacer - hier kommen später weitere Header-Elemente */}
<div className="flex-1" />
{/* Platzhalter für spätere Features (Login, Theme, Language) */}
<div className="flex items-center gap-2 text-sm text-slate-400">
<span className="px-3 py-1 bg-slate-100 rounded-full">Preview Build</span>
</div>
</header>
<div className="flex flex-1 pt-14">
{/* ============================================
SIDEBAR
============================================ */}
<aside
className={`${
sidebarCollapsed ? 'w-16' : 'w-64'
} bg-slate-900 text-white flex flex-col transition-all duration-200 fixed left-0 top-14 bottom-0 z-20`}
>
{/* Collapse Button */}
<div className="p-2 border-b border-slate-700">
<button
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="w-full p-2 rounded-lg hover:bg-slate-800 transition-colors flex items-center justify-center"
title={sidebarCollapsed ? 'Sidebar erweitern' : 'Sidebar einklappen'}
>
<svg
className={`w-5 h-5 transition-transform ${sidebarCollapsed ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
</svg>
</button>
</div>
{/* Navigation */}
<nav className="flex-1 py-4 overflow-y-auto">
<ul className="space-y-1 px-2">
{navigation.map((item) => (
<li key={item.id}>
<Link
href={item.href}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors ${
isActive(item.href)
? 'bg-primary-500 text-white'
: 'text-slate-300 hover:bg-slate-800 hover:text-white'
}`}
title={sidebarCollapsed ? item.name : undefined}
>
<span className="flex-shrink-0">{item.icon}</span>
{!sidebarCollapsed && (
<div className="flex-1 min-w-0">
<span className="block truncate">{item.name}</span>
{item.description && (
<span className="block text-xs text-slate-400 truncate">{item.description}</span>
)}
</div>
)}
</Link>
</li>
))}
</ul>
{/* Hinweis wenn keine weiteren Tabs */}
{navigation.length === 1 && !sidebarCollapsed && (
<div className="mx-4 mt-6 p-3 bg-slate-800 rounded-lg text-xs text-slate-400">
<p className="font-medium text-slate-300 mb-1">Schritt für Schritt</p>
<p>Weitere Module werden nach und nach hinzugefügt.</p>
</div>
)}
</nav>
{/* Sidebar Footer */}
<div className="p-4 border-t border-slate-700">
{!sidebarCollapsed && (
<div className="text-xs text-slate-500">
<p>Studio v2 - Build #1</p>
<p className="mt-1">Port 3001</p>
</div>
)}
</div>
</aside>
{/* ============================================
MAIN CONTENT
============================================ */}
<main
className={`flex-1 transition-all duration-200 ${
sidebarCollapsed ? 'ml-16' : 'ml-64'
}`}
>
<div className="p-6 min-h-[calc(100vh-3.5rem-3rem)]">
{children}
</div>
{/* ============================================
FOOTER
============================================ */}
<footer className="h-12 bg-white border-t border-slate-200 flex items-center justify-between px-6 text-sm text-slate-500">
<div>BreakPilot Studio v2</div>
<div className="flex items-center gap-4">
<span>Port 3001</span>
<span className="text-slate-300">|</span>
<span>Backend: Port 8000</span>
</div>
</footer>
</main>
</div>
</div>
)
}

View File

@@ -0,0 +1,288 @@
'use client'
// ============================================
// BREAKPILOT LOGO KOMPONENTEN
// Drei Design-Varianten für das Studio
// ============================================
interface LogoProps {
size?: 'sm' | 'md' | 'lg' | 'xl'
showText?: boolean
className?: string
}
const sizes = {
sm: { icon: 'w-8 h-8', text: 'text-base', subtitle: 'text-[10px]' },
md: { icon: 'w-10 h-10', text: 'text-lg', subtitle: 'text-xs' },
lg: { icon: 'w-12 h-12', text: 'text-xl', subtitle: 'text-sm' },
xl: { icon: 'w-16 h-16', text: 'text-2xl', subtitle: 'text-base' },
}
// ============================================
// VARIANTE A: Cupertino Clean
// Minimalistisch, SF-Style, subtile Schatten
// ============================================
export function LogoCupertinoClean({ size = 'md', showText = true, className = '' }: LogoProps) {
const s = sizes[size]
return (
<div className={`flex items-center gap-3 ${className}`}>
{/* Icon: Abgerundetes Quadrat mit Gradient */}
<div className={`${s.icon} relative`}>
<div className="absolute inset-0 bg-gradient-to-br from-red-500 to-red-700 rounded-xl shadow-lg shadow-red-500/20" />
<div className="absolute inset-0 flex items-center justify-center">
{/* Stilisiertes "BP" mit Piloten-Motiv */}
<svg viewBox="0 0 40 40" className="w-3/4 h-3/4">
{/* Hintergrund-Kreis (Cockpit-Fenster) */}
<circle cx="20" cy="20" r="14" fill="rgba(255,255,255,0.15)" />
{/* B und P kombiniert */}
<text
x="20"
y="26"
textAnchor="middle"
className="fill-white font-bold"
style={{ fontSize: '16px', fontFamily: 'system-ui' }}
>
BP
</text>
{/* Kleine Flügel-Andeutung */}
<path
d="M6 22 L12 20 M28 20 L34 22"
stroke="rgba(255,255,255,0.5)"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</div>
</div>
{showText && (
<div>
<div className={`font-semibold text-slate-900 ${s.text} leading-none tracking-tight`}>
Break<span className="text-red-600">Pilot</span>
</div>
<div className={`text-slate-400 ${s.subtitle} mt-0.5`}>Studio</div>
</div>
)}
</div>
)
}
// ============================================
// VARIANTE B: Glassmorphism Pro
// Frosted Glass, lebendige Farben, Glow-Effekte
// ============================================
export function LogoGlassmorphism({ size = 'md', showText = true, className = '' }: LogoProps) {
const s = sizes[size]
return (
<div className={`flex items-center gap-4 ${className}`}>
{/* Icon: Glasmorphism mit Glow */}
<div className={`${s.icon} relative`}>
{/* Outer Glow */}
<div className="absolute inset-0 bg-gradient-to-br from-red-400 to-red-600 rounded-2xl blur-md opacity-50" />
{/* Glass Card */}
<div className="absolute inset-0 bg-gradient-to-br from-red-400/90 to-red-600/90 backdrop-blur-xl rounded-2xl border border-white/20 shadow-xl" />
{/* Content */}
<div className="absolute inset-0 flex items-center justify-center">
<svg viewBox="0 0 40 40" className="w-3/4 h-3/4">
{/* Pilot-Silhouette stilisiert */}
<defs>
<linearGradient id="glassGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="rgba(255,255,255,0.9)" />
<stop offset="100%" stopColor="rgba(255,255,255,0.7)" />
</linearGradient>
</defs>
{/* Stilisierter Flieger/Papierflugzeug */}
<path
d="M8 28 L20 8 L32 28 L20 22 Z"
fill="url(#glassGradient)"
opacity="0.9"
/>
{/* Innerer Akzent */}
<path
d="M14 24 L20 12 L26 24 L20 20 Z"
fill="rgba(255,255,255,0.3)"
/>
</svg>
</div>
</div>
{showText && (
<div>
<div className={`font-semibold text-white ${s.text} leading-none`}>
BreakPilot
</div>
<div className={`text-white/60 ${s.subtitle} mt-0.5`}>Studio</div>
</div>
)}
</div>
)
}
// ============================================
// VARIANTE C: Bento Style
// Minimalistisch, Monoweight, Dark Mode optimiert
// ============================================
export function LogoBento({ size = 'md', showText = true, className = '' }: LogoProps) {
const s = sizes[size]
return (
<div className={`flex items-center gap-4 ${className}`}>
{/* Icon: Minimal, geometrisch */}
<div className={`${s.icon} relative`}>
<div className="absolute inset-0 bg-gradient-to-br from-red-500 to-red-700 rounded-xl" />
<div className="absolute inset-0 flex items-center justify-center">
<svg viewBox="0 0 40 40" className="w-3/4 h-3/4">
{/* Geometrisches B+P Symbol */}
{/* B als zwei übereinander liegende Kreise */}
<circle cx="16" cy="14" r="6" stroke="white" strokeWidth="2" fill="none" />
<circle cx="16" cy="24" r="6" stroke="white" strokeWidth="2" fill="none" />
{/* P als Linie mit Kreis */}
<line x1="28" y1="10" x2="28" y2="30" stroke="white" strokeWidth="2" strokeLinecap="round" />
<circle cx="28" cy="16" r="5" stroke="white" strokeWidth="2" fill="none" />
</svg>
</div>
</div>
{showText && (
<div>
<span className={`font-semibold text-white ${s.text} tracking-tight`}>
BreakPilot
</span>
<span className={`text-white/40 ${s.subtitle} ml-2`}>Studio</span>
</div>
)}
</div>
)
}
// ============================================
// ICON-ONLY VARIANTEN (für Favicon, App-Icon)
// ============================================
interface IconOnlyProps {
variant: 'cupertino' | 'glass' | 'bento'
size?: number
className?: string
}
export function BPIcon({ variant, size = 40, className = '' }: IconOnlyProps) {
const svgSize = `${size}px`
switch (variant) {
case 'cupertino':
return (
<svg width={svgSize} height={svgSize} viewBox="0 0 40 40" className={className}>
<defs>
<linearGradient id="cupGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#ef4444" />
<stop offset="100%" stopColor="#b91c1c" />
</linearGradient>
</defs>
<rect x="2" y="2" width="36" height="36" rx="8" fill="url(#cupGrad)" />
<circle cx="20" cy="20" r="12" fill="rgba(255,255,255,0.15)" />
<text x="20" y="25" textAnchor="middle" fill="white" fontSize="14" fontWeight="bold" fontFamily="system-ui">BP</text>
{/* Flügel */}
<path d="M6 22 L12 20 M28 20 L34 22" stroke="rgba(255,255,255,0.5)" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
case 'glass':
return (
<svg width={svgSize} height={svgSize} viewBox="0 0 40 40" className={className}>
<defs>
<linearGradient id="glassGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#f87171" />
<stop offset="100%" stopColor="#dc2626" />
</linearGradient>
</defs>
<rect x="2" y="2" width="36" height="36" rx="10" fill="url(#glassGrad)" />
<rect x="4" y="4" width="32" height="32" rx="8" fill="rgba(255,255,255,0.1)" />
<path d="M10 28 L20 8 L30 28 L20 22 Z" fill="rgba(255,255,255,0.9)" />
<path d="M14 24 L20 12 L26 24 L20 20 Z" fill="rgba(255,255,255,0.3)" />
</svg>
)
case 'bento':
return (
<svg width={svgSize} height={svgSize} viewBox="0 0 40 40" className={className}>
<defs>
<linearGradient id="bentoGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#ef4444" />
<stop offset="100%" stopColor="#991b1b" />
</linearGradient>
</defs>
<rect x="2" y="2" width="36" height="36" rx="8" fill="url(#bentoGrad)" />
<circle cx="15" cy="14" r="5" stroke="white" strokeWidth="1.5" fill="none" />
<circle cx="15" cy="24" r="5" stroke="white" strokeWidth="1.5" fill="none" />
<line x1="27" y1="10" x2="27" y2="30" stroke="white" strokeWidth="1.5" strokeLinecap="round" />
<circle cx="27" cy="15" r="4" stroke="white" strokeWidth="1.5" fill="none" />
</svg>
)
}
}
// ============================================
// LOGO SHOWCASE KOMPONENTE
// Zeigt alle drei Varianten zum Vergleich
// ============================================
export function LogoShowcase() {
return (
<div className="p-8 space-y-12">
<h2 className="text-2xl font-bold text-center mb-8">Logo-Varianten</h2>
{/* Variante A */}
<div className="bg-white rounded-2xl p-8 shadow-lg">
<h3 className="text-lg font-semibold mb-6 text-slate-700">A: Cupertino Clean</h3>
<div className="flex items-center gap-12">
<LogoCupertinoClean size="sm" />
<LogoCupertinoClean size="md" />
<LogoCupertinoClean size="lg" />
<LogoCupertinoClean size="xl" />
<div className="flex gap-4">
<BPIcon variant="cupertino" size={32} />
<BPIcon variant="cupertino" size={48} />
<BPIcon variant="cupertino" size={64} />
</div>
</div>
</div>
{/* Variante B */}
<div className="bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800 rounded-2xl p-8">
<h3 className="text-lg font-semibold mb-6 text-white/80">B: Glassmorphism Pro</h3>
<div className="flex items-center gap-12">
<LogoGlassmorphism size="sm" />
<LogoGlassmorphism size="md" />
<LogoGlassmorphism size="lg" />
<LogoGlassmorphism size="xl" />
<div className="flex gap-4">
<BPIcon variant="glass" size={32} />
<BPIcon variant="glass" size={48} />
<BPIcon variant="glass" size={64} />
</div>
</div>
</div>
{/* Variante C */}
<div className="bg-black rounded-2xl p-8">
<h3 className="text-lg font-semibold mb-6 text-white/80">C: Bento Style</h3>
<div className="flex items-center gap-12">
<LogoBento size="sm" />
<LogoBento size="md" />
<LogoBento size="lg" />
<LogoBento size="xl" />
<div className="flex gap-4">
<BPIcon variant="bento" size={32} />
<BPIcon variant="bento" size={48} />
<BPIcon variant="bento" size={64} />
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,513 @@
'use client'
import { useState, useEffect } from 'react'
import { useLanguage } from '@/lib/LanguageContext'
import { useTheme } from '@/lib/ThemeContext'
import { GermanyMap, bundeslaender } from './GermanyMap'
import { CityMap } from './CityMap'
import { SchoolSearch } from './SchoolSearch'
import { BPIcon } from './Logo'
interface OnboardingWizardProps {
onComplete: (data: OnboardingData) => void
}
export interface OnboardingData {
bundesland: string
bundeslandName: string
city: string
cityLat?: number
cityLng?: number
schoolName: string
schoolType: string
}
// Schulformen mit Icons und Beschreibungen
const schulformen = [
// Allgemeinbildende Schulen
{
id: 'gymnasium',
name: 'Gymnasium',
icon: '🎓',
description: 'Allgemeinbildend bis Abitur',
category: 'allgemein'
},
{
id: 'gesamtschule',
name: 'Gesamtschule',
icon: '🏫',
description: 'Integriert/kooperativ',
category: 'allgemein'
},
{
id: 'realschule',
name: 'Realschule',
icon: '📚',
description: 'Mittlerer Abschluss',
category: 'allgemein'
},
{
id: 'hauptschule',
name: 'Hauptschule',
icon: '📖',
description: 'Erster Abschluss',
category: 'allgemein'
},
{
id: 'mittelschule',
name: 'Mittelschule',
icon: '📝',
description: 'Bayern/Sachsen',
category: 'allgemein'
},
{
id: 'oberschule',
name: 'Oberschule',
icon: '🏛️',
description: 'Sachsen/Brandenburg',
category: 'allgemein'
},
{
id: 'stadtteilschule',
name: 'Stadtteilschule',
icon: '🌆',
description: 'Hamburg',
category: 'allgemein'
},
{
id: 'gemeinschaftsschule',
name: 'Gemeinschaftsschule',
icon: '🤲',
description: 'BW/SH/TH/SL/BE',
category: 'allgemein'
},
// Berufliche Schulen
{
id: 'berufsschule',
name: 'Berufsschule',
icon: '🔧',
description: 'Duale Ausbildung',
category: 'beruflich'
},
{
id: 'berufliches_gymnasium',
name: 'Berufl. Gymnasium',
icon: '💼',
description: 'Fachgebundenes Abitur',
category: 'beruflich'
},
{
id: 'fachoberschule',
name: 'Fachoberschule',
icon: '📊',
description: 'Fachhochschulreife',
category: 'beruflich'
},
{
id: 'berufsfachschule',
name: 'Berufsfachschule',
icon: '🛠️',
description: 'Vollzeitberufliche Bildung',
category: 'beruflich'
},
// Sonder- und Förderschulen
{
id: 'foerderschule',
name: 'Förderschule',
icon: '🤝',
description: 'Sonderpädagogisch',
category: 'foerder'
},
{
id: 'foerderzentrum',
name: 'Förderzentrum',
icon: '💚',
description: 'Inklusiv/integriert',
category: 'foerder'
},
// Privatschulen & Besondere Formen
{
id: 'privatschule',
name: 'Privatschule',
icon: '🏰',
description: 'Freier Träger',
category: 'privat'
},
{
id: 'internat',
name: 'Internat',
icon: '🛏️',
description: 'Mit Unterbringung',
category: 'privat'
},
{
id: 'waldorfschule',
name: 'Waldorfschule',
icon: '🌿',
description: 'Anthroposophisch',
category: 'alternativ'
},
{
id: 'montessori',
name: 'Montessori-Schule',
icon: '🧒',
description: 'Montessori-Pädagogik',
category: 'alternativ'
},
// Grundschulen
{
id: 'grundschule',
name: 'Grundschule',
icon: '🏠',
description: 'Klasse 1-4',
category: 'grund'
},
// Internationale
{
id: 'internationale_schule',
name: 'Internationale Schule',
icon: '🌍',
description: 'IB/Cambridge',
category: 'international'
},
{
id: 'europaeische_schule',
name: 'Europäische Schule',
icon: '🇪🇺',
description: 'EU-Curriculum',
category: 'international'
},
]
// Kategorien für die Anzeige
const schulformKategorien = [
{ id: 'allgemein', name: 'Allgemeinbildend', icon: '📚' },
{ id: 'beruflich', name: 'Berufsbildend', icon: '💼' },
{ id: 'foerder', name: 'Förderschulen', icon: '💚' },
{ id: 'privat', name: 'Privat & Internat', icon: '🏰' },
{ id: 'alternativ', name: 'Alternative Pädagogik', icon: '🌿' },
{ id: 'grund', name: 'Primarstufe', icon: '🏠' },
{ id: 'international', name: 'International', icon: '🌍' },
]
export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
const { t } = useLanguage()
const { isDark } = useTheme()
const [step, setStep] = useState(1)
const [data, setData] = useState<Partial<OnboardingData>>({})
const [citySearch, setCitySearch] = useState('')
const [schoolSearch, setSchoolSearch] = useState('')
const totalSteps = 4
const handleNext = () => {
if (step < totalSteps) {
setStep(step + 1)
} else {
// Abschluss
onComplete(data as OnboardingData)
}
}
const handleBack = () => {
if (step > 1) {
setStep(step - 1)
}
}
const canProceed = () => {
switch (step) {
case 1:
return !!data.bundesland
case 2:
return !!data.city && data.city.trim().length > 0
case 3:
return !!data.schoolName && data.schoolName.trim().length > 0
case 4:
return !!data.schoolType
default:
return false
}
}
return (
<div className={`min-h-screen flex flex-col ${
isDark
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
}`}>
{/* Animated Background */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className={`absolute -top-40 -right-40 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-pulse ${
isDark ? 'bg-purple-500 opacity-50' : 'bg-purple-300 opacity-30'
}`} />
<div className={`absolute -bottom-40 -left-40 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-pulse ${
isDark ? 'bg-blue-500 opacity-50' : 'bg-blue-300 opacity-30'
}`} style={{ animationDelay: '1s' }} />
</div>
<div className="relative z-10 flex-1 flex flex-col items-center justify-center p-8">
{/* Logo & Willkommen */}
<div className="text-center mb-8">
<div className="flex items-center justify-center gap-3 mb-4">
<BPIcon variant="cupertino" size={56} />
<div className="text-left">
<h1 className={`text-2xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
BreakPilot Studio
</h1>
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
Willkommen! Lassen Sie uns loslegen.
</p>
</div>
</div>
</div>
{/* Progress Bar */}
<div className="w-full max-w-2xl mb-8">
<div className="flex items-center justify-between mb-2">
{[1, 2, 3, 4].map((s) => (
<div
key={s}
className={`w-10 h-10 rounded-full flex items-center justify-center font-medium transition-all ${
s === step
? 'bg-gradient-to-br from-blue-500 to-purple-500 text-white scale-110 shadow-lg'
: s < step
? isDark
? 'bg-green-500/30 text-green-300'
: 'bg-green-100 text-green-700'
: isDark
? 'bg-white/10 text-white/40'
: 'bg-slate-200 text-slate-400'
}`}
>
{s < step ? '✓' : s}
</div>
))}
</div>
<div className={`h-2 rounded-full overflow-hidden ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}>
<div
className="h-full bg-gradient-to-r from-blue-500 to-purple-500 transition-all duration-500"
style={{ width: `${((step - 1) / (totalSteps - 1)) * 100}%` }}
/>
</div>
</div>
{/* Main Card */}
<div className={`w-full max-w-2xl backdrop-blur-xl border rounded-3xl p-8 ${
isDark
? 'bg-white/10 border-white/20'
: 'bg-white/80 border-black/10 shadow-xl'
}`}>
{/* Step 1: Bundesland */}
{step === 1 && (
<div className="text-center">
<h2 className={`text-2xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
In welchem Bundesland unterrichten Sie?
</h2>
<p className={`mb-6 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
Klicken Sie auf Ihr Bundesland in der Karte
</p>
<GermanyMap
selectedState={data.bundesland || null}
suggestedState="HH"
onSelectState={(stateId) => setData({
...data,
bundesland: stateId,
bundeslandName: bundeslaender[stateId as keyof typeof bundeslaender]
})}
className="mx-auto max-w-md"
/>
</div>
)}
{/* Step 2: Stadt */}
{step === 2 && (
<div className="text-center">
<h2 className={`text-2xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
In welcher Stadt arbeiten Sie?
</h2>
<p className={`mb-4 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
Suchen Sie Ihre Stadt oder klicken Sie auf die Karte
</p>
{/* Info Box - Bundesland */}
<div className={`inline-flex items-center gap-2 px-4 py-2 rounded-full mb-4 ${
isDark ? 'bg-white/10' : 'bg-slate-100'
}`}>
<span className="text-lg">📍</span>
<span className={`text-sm font-medium ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
{data.bundeslandName}
</span>
</div>
{/* CityMap Komponente */}
<CityMap
bundesland={data.bundesland || 'HH'}
bundeslandName={data.bundeslandName || 'Hamburg'}
selectedCity={data.city || ''}
onSelectCity={(city, lat, lng) => setData({
...data,
city,
cityLat: lat,
cityLng: lng
})}
className="max-w-lg mx-auto"
/>
</div>
)}
{/* Step 3: Schule */}
{step === 3 && (
<div className="text-center">
<h2 className={`text-2xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Wie heißt Ihre Schule?
</h2>
<p className={`mb-4 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
Suchen Sie Ihre Schule oder geben Sie den Namen ein
</p>
{/* SchoolSearch Komponente mit Autocomplete */}
<SchoolSearch
city={data.city || ''}
bundesland={data.bundesland || 'HH'}
bundeslandName={data.bundeslandName || 'Hamburg'}
selectedSchool={data.schoolName || ''}
onSelectSchool={(schoolName, schoolId) => setData({
...data,
schoolName
})}
className="max-w-lg mx-auto"
/>
</div>
)}
{/* Step 4: Schulform */}
{step === 4 && (
<div className="text-center">
<h2 className={`text-2xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Welche Schulform ist es?
</h2>
<p className={`mb-6 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
Wählen Sie die passende Schulform
</p>
{/* Scrollbarer Bereich mit Kategorien */}
<div className="max-h-[400px] overflow-y-auto pr-2 space-y-6">
{schulformKategorien.map((kategorie) => {
const formenInKategorie = schulformen.filter(f => f.category === kategorie.id)
if (formenInKategorie.length === 0) return null
return (
<div key={kategorie.id}>
{/* Kategorie-Header */}
<div className={`flex items-center gap-2 mb-3 ${isDark ? 'text-white/70' : 'text-slate-600'}`}>
<span>{kategorie.icon}</span>
<span className="text-sm font-medium">{kategorie.name}</span>
<div className={`flex-1 h-px ${isDark ? 'bg-white/10' : 'bg-slate-200'}`} />
</div>
{/* Schulformen in dieser Kategorie */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{formenInKategorie.map((form) => {
const isSelected = data.schoolType === form.id
return (
<button
key={form.id}
onClick={() => setData({ ...data, schoolType: form.id })}
className={`p-3 rounded-xl border-2 transition-all hover:scale-105 text-left ${
isSelected
? 'border-blue-500 bg-blue-500/20 shadow-lg'
: isDark
? 'border-white/10 bg-white/5 hover:bg-white/10 hover:border-white/20'
: 'border-slate-200 bg-white hover:bg-slate-50 hover:border-slate-300'
}`}
>
<div className="flex items-center gap-2">
<span className="text-xl">{form.icon}</span>
<div className="flex-1 min-w-0">
<p className={`font-medium text-sm truncate ${
isSelected
? isDark ? 'text-blue-300' : 'text-blue-700'
: isDark ? 'text-white' : 'text-slate-900'
}`}>
{form.name}
</p>
<p className={`text-xs truncate ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
{form.description}
</p>
</div>
</div>
</button>
)
})}
</div>
</div>
)
})}
</div>
{/* Summary */}
{data.schoolType && (
<div className={`mt-6 p-4 rounded-xl ${
isDark ? 'bg-white/5' : 'bg-slate-50'
}`}>
<p className={`text-sm ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
<strong>{data.schoolName}</strong>
</p>
<p className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
{schulformen.find(f => f.id === data.schoolType)?.name} in {data.city}, {data.bundeslandName}
</p>
</div>
)}
</div>
)}
</div>
{/* Navigation Buttons */}
<div className="flex items-center gap-4 mt-8">
{step > 1 && (
<button
onClick={handleBack}
className={`px-6 py-3 rounded-xl font-medium transition-all ${
isDark
? 'bg-white/10 text-white hover:bg-white/20'
: 'bg-slate-200 text-slate-700 hover:bg-slate-300'
}`}
>
Zurück
</button>
)}
<button
onClick={handleNext}
disabled={!canProceed()}
className={`px-8 py-3 rounded-xl font-medium transition-all ${
canProceed()
? 'bg-gradient-to-r from-blue-500 to-purple-500 text-white hover:shadow-xl hover:shadow-purple-500/30 hover:scale-105'
: isDark
? 'bg-white/10 text-white/30 cursor-not-allowed'
: 'bg-slate-200 text-slate-400 cursor-not-allowed'
}`}
>
{step === totalSteps ? 'Los geht\'s! →' : 'Weiter →'}
</button>
</div>
{/* Skip Option */}
<button
onClick={() => onComplete({
bundesland: data.bundesland || 'NI',
bundeslandName: data.bundeslandName || 'Niedersachsen',
city: data.city || 'Unbekannt',
schoolName: data.schoolName || 'Meine Schule',
schoolType: data.schoolType || 'gymnasium'
})}
className={`mt-4 text-sm ${isDark ? 'text-white/40 hover:text-white/60' : 'text-slate-400 hover:text-slate-600'}`}
>
Überspringen (später einrichten)
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,334 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useTheme } from '@/lib/ThemeContext'
interface UploadedFile {
id: string
sessionId: string
name: string
type: string
size: number
uploadedAt: string
dataUrl: string
}
interface QRCodeUploadProps {
sessionId?: string
onClose?: () => void
onFileUploaded?: (file: UploadedFile) => void
onFilesChanged?: (files: UploadedFile[]) => void
className?: string
}
export function QRCodeUpload({
sessionId,
onClose,
onFileUploaded,
onFilesChanged,
className = ''
}: QRCodeUploadProps) {
const { isDark } = useTheme()
const [qrCodeUrl, setQrCodeUrl] = useState<string | null>(null)
const [uploadUrl, setUploadUrl] = useState<string>('')
const [isLoading, setIsLoading] = useState(true)
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
const [isPolling, setIsPolling] = useState(false)
// Format file size
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
// Fetch uploads for this session
const fetchUploads = useCallback(async () => {
if (!sessionId) return
try {
const response = await fetch(`/api/uploads?sessionId=${sessionId}`)
if (response.ok) {
const data = await response.json()
const newFiles = data.uploads || []
// Check if there are new files
if (newFiles.length > uploadedFiles.length) {
const newlyAdded = newFiles.slice(uploadedFiles.length)
newlyAdded.forEach((file: UploadedFile) => {
if (onFileUploaded) {
onFileUploaded(file)
}
})
}
setUploadedFiles(newFiles)
if (onFilesChanged) {
onFilesChanged(newFiles)
}
}
} catch (error) {
console.error('Failed to fetch uploads:', error)
}
}, [sessionId, uploadedFiles.length, onFileUploaded, onFilesChanged])
// Initialize QR code and start polling
useEffect(() => {
// Generate Upload-URL
let baseUrl = typeof window !== 'undefined' ? window.location.origin : ''
// Hostname to IP mapping for local network
const hostnameToIP: Record<string, string> = {
'macmini': '192.168.178.100',
'macmini.local': '192.168.178.100',
}
// Replace known hostnames with IP addresses
Object.entries(hostnameToIP).forEach(([hostname, ip]) => {
if (baseUrl.includes(hostname)) {
baseUrl = baseUrl.replace(hostname, ip)
}
})
const uploadPath = `/upload/${sessionId || 'new'}`
const fullUrl = `${baseUrl}${uploadPath}`
setUploadUrl(fullUrl)
// Generate QR code via external API
const qrApiUrl = `https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=${encodeURIComponent(fullUrl)}`
setQrCodeUrl(qrApiUrl)
setIsLoading(false)
// Initial fetch
fetchUploads()
// Start polling for new uploads every 3 seconds
setIsPolling(true)
const pollInterval = setInterval(() => {
fetchUploads()
}, 3000)
return () => {
clearInterval(pollInterval)
setIsPolling(false)
}
}, [sessionId]) // Note: fetchUploads is intentionally not in deps to avoid re-creating interval
// Separate effect for fetching when uploadedFiles changes
useEffect(() => {
// This is just for the callback effect, actual polling is in the other useEffect
}, [uploadedFiles, onFilesChanged])
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(uploadUrl)
alert('Link kopiert!')
} catch (err) {
console.error('Kopieren fehlgeschlagen:', err)
}
}
const deleteUpload = async (id: string) => {
try {
const response = await fetch(`/api/uploads?id=${id}`, { method: 'DELETE' })
if (response.ok) {
const newFiles = uploadedFiles.filter(f => f.id !== id)
setUploadedFiles(newFiles)
if (onFilesChanged) {
onFilesChanged(newFiles)
}
}
} catch (error) {
console.error('Failed to delete upload:', error)
}
}
return (
<div className={`${className}`}>
<div className={`rounded-3xl border p-6 ${
isDark ? 'bg-white/10 border-white/20' : 'bg-white border-slate-200 shadow-lg'
}`}>
{/* 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'
}`}>
<span className="text-xl">📱</span>
</div>
<div>
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Mit Mobiltelefon hochladen
</h3>
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
QR-Code scannen oder Link teilen
</p>
</div>
</div>
{onClose && (
<button
onClick={onClose}
className={`p-2 rounded-lg transition-colors ${
isDark ? 'hover:bg-white/10 text-white/60' : 'hover:bg-slate-100 text-slate-400'
}`}
>
<svg className="w-5 h-5" 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>
{/* QR Code */}
<div className="flex flex-col items-center">
<div className={`p-4 rounded-2xl ${isDark ? 'bg-white' : 'bg-slate-50'}`}>
{isLoading ? (
<div className="w-[200px] h-[200px] flex items-center justify-center">
<div className="w-8 h-8 border-2 border-purple-500 border-t-transparent rounded-full animate-spin" />
</div>
) : qrCodeUrl ? (
<img
src={qrCodeUrl}
alt="QR Code zum Hochladen"
className="w-[200px] h-[200px]"
/>
) : (
<div className="w-[200px] h-[200px] flex items-center justify-center text-slate-400">
QR-Code nicht verfuegbar
</div>
)}
</div>
<p className={`mt-4 text-center text-sm ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
Scannen Sie diesen Code mit Ihrem Handy,<br />
um Dokumente direkt hochzuladen.
</p>
{/* Polling indicator */}
{isPolling && (
<div className={`mt-2 flex items-center gap-2 text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
Warte auf Uploads...
</div>
)}
</div>
{/* Uploaded Files List */}
{uploadedFiles.length > 0 && (
<div className={`mt-6 p-4 rounded-xl ${
isDark ? 'bg-green-500/10 border border-green-500/20' : 'bg-green-50 border border-green-200'
}`}>
<div className="flex items-center justify-between mb-3">
<p className={`text-sm font-medium ${isDark ? 'text-green-300' : 'text-green-700'}`}>
{uploadedFiles.length} Datei{uploadedFiles.length !== 1 ? 'en' : ''} empfangen
</p>
</div>
<div className="space-y-2 max-h-40 overflow-y-auto">
{uploadedFiles.map((file) => (
<div
key={file.id}
className={`flex items-center gap-3 p-2 rounded-lg ${
isDark ? 'bg-white/5' : 'bg-white'
}`}
>
<span className="text-lg">
{file.type.startsWith('image/') ? '🖼️' : '📄'}
</span>
<div className="flex-1 min-w-0">
<p className={`text-sm font-medium truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>
{file.name}
</p>
<p className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
{formatFileSize(file.size)}
</p>
</div>
<button
onClick={() => deleteUpload(file.id)}
className={`p-1 rounded transition-colors ${
isDark ? 'hover:bg-red-500/20 text-red-400' : 'hover:bg-red-100 text-red-500'
}`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
))}
</div>
</div>
)}
{/* Link teilen */}
<div className="mt-6">
<p className={`text-xs mb-2 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
Oder Link teilen:
</p>
<div className="flex items-center gap-2">
<input
type="text"
value={uploadUrl}
readOnly
className={`flex-1 px-3 py-2 rounded-xl text-sm border ${
isDark
? 'bg-white/5 border-white/10 text-white/80'
: 'bg-slate-50 border-slate-200 text-slate-700'
}`}
/>
<button
onClick={copyToClipboard}
className={`px-4 py-2 rounded-xl text-sm font-medium transition-colors ${
isDark
? 'bg-white/10 text-white hover:bg-white/20'
: 'bg-slate-200 text-slate-700 hover:bg-slate-300'
}`}
>
Kopieren
</button>
</div>
</div>
{/* Network hint - only show if no files uploaded yet */}
{uploadedFiles.length === 0 && (
<div className={`mt-6 p-4 rounded-xl ${
isDark ? 'bg-amber-500/10 border border-amber-500/20' : 'bg-amber-50 border border-amber-200'
}`}>
<div className="flex items-start gap-3">
<span className="text-lg"></span>
<div>
<p className={`text-sm font-medium ${isDark ? 'text-amber-300' : 'text-amber-900'}`}>
Nur im lokalen Netzwerk
</p>
<p className={`text-xs mt-1 ${isDark ? 'text-amber-300/70' : 'text-amber-700'}`}>
Ihr Mobiltelefon muss mit dem gleichen Netzwerk verbunden sein.
</p>
</div>
</div>
</div>
)}
{/* Tip */}
<div className={`mt-4 p-4 rounded-xl ${
isDark ? 'bg-blue-500/10 border border-blue-500/20' : 'bg-blue-50 border border-blue-200'
}`}>
<div className="flex items-start gap-3">
<span className="text-lg">💡</span>
<div>
<p className={`text-sm font-medium ${isDark ? 'text-blue-300' : 'text-blue-900'}`}>
Tipp: Mehrere Seiten scannen
</p>
<p className={`text-xs mt-1 ${isDark ? 'text-blue-300/70' : 'text-blue-700'}`}>
Sie koennen beliebig viele Fotos hochladen.
</p>
</div>
</div>
</div>
</div>
</div>
)
}
// Export the UploadedFile type for use in other components
export type { UploadedFile }

View File

@@ -0,0 +1,339 @@
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { useTheme } from '@/lib/ThemeContext'
interface SchoolSearchProps {
city: string
bundesland: string
bundeslandName: string
selectedSchool: string
onSelectSchool: (schoolName: string, schoolId?: string) => void
className?: string
}
interface SchoolSuggestion {
id: string
name: string
type: string
address?: string
city?: string
source: 'api' | 'mock'
}
// Edu-Search API URL - uses HTTPS proxy on port 8089 (edu-search runs on 8088)
const getApiUrl = () => {
if (typeof window === 'undefined') return 'http://localhost:8088'
const { hostname, protocol } = window.location
// localhost: direct HTTP to 8088, macmini: HTTPS via nginx proxy on 8089
return hostname === 'localhost' ? 'http://localhost:8088' : `${protocol}//${hostname}:8089`
}
// Attribution data for Open Data sources by Bundesland (CTRL-SRC-001)
const BUNDESLAND_ATTRIBUTION: Record<string, { source: string; license: string; licenseUrl: string }> = {
BW: { source: 'Open Data Baden-Wuerttemberg', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
BY: { source: 'Open Data Bayern', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
BE: { source: 'Datenportal Berlin', license: 'CC-BY', licenseUrl: 'https://creativecommons.org/licenses/by/4.0/' },
BB: { source: 'Daten Brandenburg', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
HB: { source: 'Open Data Bremen', license: 'CC-BY', licenseUrl: 'https://creativecommons.org/licenses/by/4.0/' },
HH: { source: 'Transparenzportal Hamburg', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
HE: { source: 'Open Data Hessen', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
MV: { source: 'Open Data MV', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
NI: { source: 'Open Data Niedersachsen', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
NW: { source: 'Open.NRW', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
RP: { source: 'Open Data Rheinland-Pfalz', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
SL: { source: 'Open Data Saarland', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
SN: { source: 'Datenportal Sachsen', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
ST: { source: 'Open Data Sachsen-Anhalt', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
SH: { source: 'Open Data Schleswig-Holstein', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
TH: { source: 'Open Data Thueringen', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
}
// Category to display type mapping
const getCategoryDisplayType = (category?: string): string => {
switch (category) {
case 'primary': return 'Grundschule'
case 'secondary': return 'Sekundarschule'
case 'vocational': return 'Berufsschule'
case 'special': return 'Foerderschule'
default: return 'Schule'
}
}
export function SchoolSearch({
city,
bundesland,
bundeslandName,
selectedSchool,
onSelectSchool,
className = ''
}: SchoolSearchProps) {
const { isDark } = useTheme()
const [searchQuery, setSearchQuery] = useState(selectedSchool || '')
const [suggestions, setSuggestions] = useState<SchoolSuggestion[]>([])
const [isSearching, setIsSearching] = useState(false)
const [showSuggestions, setShowSuggestions] = useState(false)
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
// Suche nach Schulen (echte API)
const searchSchools = useCallback(async (query: string) => {
if (query.length < 2) {
setSuggestions([])
return
}
setIsSearching(true)
try {
// Real API call to edu-search-service
const params = new URLSearchParams({
q: query,
limit: '10',
})
if (city) params.append('city', city)
if (bundesland) params.append('state', bundesland)
const response = await fetch(`${getApiUrl()}/api/v1/schools/search?${params}`)
if (response.ok) {
const data = await response.json()
const apiSchools: SchoolSuggestion[] = (data.schools || []).map((school: {
id: string
name: string
school_type_name?: string
school_category?: string
street?: string
city?: string
postal_code?: string
}) => ({
id: school.id,
name: school.name,
type: school.school_type_name || getCategoryDisplayType(school.school_category),
address: school.street ? `${school.street}, ${school.postal_code || ''} ${school.city || ''}`.trim() : undefined,
city: school.city,
source: 'api' as const,
}))
setSuggestions(apiSchools)
} else {
console.error('School search API error:', response.status)
setSuggestions([])
}
} catch (error) {
console.error('School search error:', error)
setSuggestions([])
} finally {
setIsSearching(false)
}
}, [city, bundesland])
// Debounced Search
useEffect(() => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current)
}
searchTimeoutRef.current = setTimeout(() => {
searchSchools(searchQuery)
}, 300)
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current)
}
}
}, [searchQuery, searchSchools])
// Klick ausserhalb schließt Dropdown
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setShowSuggestions(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
// Schule auswaehlen
const handleSelectSchool = (school: SchoolSuggestion) => {
setSearchQuery(school.name)
onSelectSchool(school.name, school.id)
setSuggestions([])
setShowSuggestions(false)
}
// Manuelle Eingabe bestaetigen
const handleManualInput = () => {
if (searchQuery.trim()) {
onSelectSchool(searchQuery.trim())
setShowSuggestions(false)
}
}
// Schultyp-Icon
const getSchoolTypeIcon = (type: string) => {
switch (type.toLowerCase()) {
case 'gymnasium': return '🎓'
case 'gesamtschule':
case 'stadtteilschule': return '🏫'
case 'realschule': return '📚'
case 'hauptschule':
case 'mittelschule': return '📖'
case 'grundschule': return '🏠'
case 'berufsschule': return '🔧'
case 'foerderschule': return '🤝'
case 'privatschule': return '🏰'
case 'waldorfschule': return '🌿'
default: return '🏫'
}
}
return (
<div className={`space-y-4 ${className}`} ref={containerRef}>
{/* Suchfeld */}
<div className="relative">
<div className="relative">
<input
type="text"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value)
setShowSuggestions(true)
}}
onFocus={() => setShowSuggestions(true)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleManualInput()
}
}}
placeholder={`Schule in ${city} suchen...`}
className={`w-full px-6 py-4 pl-14 text-lg rounded-2xl border transition-all ${
isDark
? 'bg-white/10 border-white/20 text-white placeholder-white/40 focus:border-blue-400'
: 'bg-white border-slate-300 text-slate-900 placeholder-slate-400 focus:border-blue-500'
} focus:outline-none focus:ring-2 focus:ring-blue-500/30`}
autoFocus
/>
<svg
className={`absolute left-5 top-1/2 -translate-y-1/2 w-5 h-5 ${
isDark ? 'text-white/40' : 'text-slate-400'
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
{isSearching && (
<div className={`absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 border-2 border-t-transparent rounded-full animate-spin ${
isDark ? 'border-white/40' : 'border-slate-400'
}`} />
)}
</div>
{/* Vorschlaege Dropdown */}
{showSuggestions && suggestions.length > 0 && (
<div className={`absolute top-full left-0 right-0 mt-2 rounded-xl border shadow-xl z-50 max-h-80 overflow-y-auto ${
isDark
? 'bg-slate-800/95 border-white/20 backdrop-blur-xl'
: 'bg-white/95 border-slate-200 backdrop-blur-xl'
}`}>
{suggestions.map((school, index) => (
<button
key={school.id}
onClick={() => handleSelectSchool(school)}
className={`w-full px-4 py-3 text-left transition-colors flex items-center gap-3 ${
isDark
? 'hover:bg-white/10 text-white/90'
: 'hover:bg-slate-100 text-slate-800'
} ${index > 0 ? (isDark ? 'border-t border-white/10' : 'border-t border-slate-100') : ''}`}
>
<span className="text-xl flex-shrink-0">{getSchoolTypeIcon(school.type)}</span>
<div className="min-w-0 flex-1">
<p className="font-medium truncate">{school.name}</p>
<div className="flex items-center gap-2 flex-wrap">
<span className={`text-xs px-2 py-0.5 rounded-full ${
isDark ? 'bg-white/10 text-white/60' : 'bg-slate-100 text-slate-500'
}`}>
{school.type}
</span>
{school.address && (
<span className={`text-xs truncate ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
{school.address}
</span>
)}
</div>
</div>
{school.source === 'api' && (
<span className={`text-xs px-2 py-0.5 rounded ${
isDark ? 'bg-green-500/20 text-green-300' : 'bg-green-100 text-green-700'
}`}>
verifiziert
</span>
)}
</button>
))}
{/* Attribution Footer (CTRL-SRC-001) */}
{(() => {
const attr = BUNDESLAND_ATTRIBUTION[bundesland]
return attr ? (
<div className={`px-4 py-2 text-xs border-t ${
isDark ? 'bg-slate-900/50 border-white/10 text-white/40' : 'bg-slate-50 border-slate-200 text-slate-500'
}`}>
<div className="flex items-center gap-1 flex-wrap">
<span>Quelle:</span>
<span className="font-medium">{attr.source}</span>
<span></span>
<a
href={attr.licenseUrl}
target="_blank"
rel="noopener noreferrer"
className={`underline hover:no-underline ${isDark ? 'text-blue-400' : 'text-blue-600'}`}
onClick={(e) => e.stopPropagation()}
>
{attr.license}
</a>
</div>
</div>
) : null
})()}
</div>
)}
{/* Keine Ergebnisse Info */}
{showSuggestions && searchQuery.length >= 2 && suggestions.length === 0 && !isSearching && (
<div className={`absolute top-full left-0 right-0 mt-2 rounded-xl border p-4 ${
isDark
? 'bg-slate-800/95 border-white/20 backdrop-blur-xl'
: 'bg-white/95 border-slate-200 backdrop-blur-xl'
}`}>
<p className={`text-sm text-center ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
Keine Schulen gefunden. Tippen Sie den Namen ein und druecken Sie Enter.
</p>
</div>
)}
</div>
{/* Standort Info */}
<div className={`p-4 rounded-xl ${isDark ? 'bg-white/5' : 'bg-slate-50'}`}>
<div className="flex items-start gap-3">
<span className="text-2xl">📍</span>
<div className="text-left flex-1">
<p className={`text-sm ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
<strong>{city}</strong>, {bundeslandName}
</p>
<p className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
Ihr Standort
</p>
</div>
</div>
</div>
{/* Hinweis */}
<p className={`text-xs text-center ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
Waehlen Sie aus den Vorschlaegen oder geben Sie den Namen manuell ein
</p>
</div>
)
}

View File

@@ -0,0 +1,230 @@
'use client'
import { useState } from 'react'
import { useRouter, usePathname } from 'next/navigation'
import { BPIcon } from '@/components/Logo'
import { useLanguage } from '@/lib/LanguageContext'
import { useTheme } from '@/lib/ThemeContext'
import { useAlerts } from '@/lib/AlertsContext'
import { useAlertsB2B } from '@/lib/AlertsB2BContext'
import { useMessages } from '@/lib/MessagesContext'
import { UserMenu } from '@/components/UserMenu'
interface SidebarProps {
selectedTab?: string
onTabChange?: (tab: string) => void
}
export function Sidebar({ selectedTab = 'dashboard', onTabChange }: SidebarProps) {
const [sidebarHovered, setSidebarHovered] = useState(false)
const { t } = useLanguage()
const { isDark } = useTheme()
const { unreadCount } = useAlerts()
const { unreadCount: b2bUnreadCount } = useAlertsB2B()
const { unreadCount: messagesUnreadCount } = useMessages()
const router = useRouter()
const pathname = usePathname()
const navItems = [
{ id: 'dashboard', labelKey: 'nav_dashboard', href: '/', 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 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
)},
{ id: 'dokumente', labelKey: 'nav_dokumente', href: '/', 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 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
)},
{ id: 'klausuren', labelKey: 'nav_klausuren', href: '/', 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="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>
)},
{ id: 'analytics', labelKey: 'nav_analytics', href: '/', 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="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
)},
{ id: 'alerts', labelKey: 'nav_alerts', href: '/alerts', 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 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
), showBadge: true },
{ id: 'alerts-b2b', labelKey: 'nav_alerts_b2b', href: '/alerts-b2b', 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="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
), showB2BBadge: true },
{ id: 'messages', labelKey: 'nav_messages', href: '/messages', 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="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
), showMessagesBadge: true },
{ id: 'vokabeln', labelKey: 'nav_vokabeln', href: '/vocab-worksheet', 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="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>
)},
{ id: 'worksheet-editor', labelKey: 'nav_worksheet_editor', href: '/worksheet-editor', 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>
)},
{ id: 'worksheet-cleanup', labelKey: 'nav_worksheet_cleanup', href: '/worksheet-cleanup', 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="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>
)},
{ id: 'korrektur', labelKey: 'nav_korrektur', href: '/korrektur', 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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)},
{ id: 'meet', labelKey: 'nav_meet', href: '/meet', 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 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)},
]
const handleNavClick = (item: typeof navItems[0]) => {
// Check if this is an external page (has a specific href)
if (item.href !== '/') {
router.push(item.href)
} else if (item.id === 'vokabeln') {
router.push('/vocab-worksheet')
} else if (item.id === 'meet') {
router.push('/meet')
} else if (item.id === 'alerts') {
router.push('/alerts')
} else {
// For dashboard tabs, either navigate or call the callback
if (pathname !== '/') {
router.push('/')
}
if (onTabChange) {
onTabChange(item.id)
}
}
}
// Determine active item based on pathname or selectedTab
const getActiveItem = () => {
if (pathname === '/meet') return 'meet'
if (pathname === '/vocab-worksheet') return 'vokabeln'
if (pathname === '/worksheet-editor') return 'worksheet-editor'
if (pathname === '/worksheet-cleanup') return 'worksheet-cleanup'
if (pathname === '/magic-help') return 'magic-help'
if (pathname === '/alerts') return 'alerts'
if (pathname === '/alerts-b2b') return 'alerts-b2b'
if (pathname === '/messages') return 'messages'
if (pathname?.startsWith('/korrektur')) return 'korrektur'
return selectedTab
}
const activeItem = getActiveItem()
return (
<aside
className="flex-shrink-0 w-[72px] hover:w-[200px] transition-all duration-300 group"
onMouseEnter={() => setSidebarHovered(true)}
onMouseLeave={() => setSidebarHovered(false)}
>
<div className={`sticky top-4 h-[calc(100vh-32px)] backdrop-blur-2xl rounded-3xl border flex flex-col p-3 overflow-hidden ${
isDark
? 'bg-white/10 border-white/20'
: 'bg-white/70 border-black/10 shadow-xl'
}`}>
{/* Logo - Cupertino Clean */}
<div className="mb-8">
<div className="flex items-center gap-4">
<BPIcon variant="cupertino" size={48} className="flex-shrink-0" />
<div className="opacity-0 group-hover:opacity-100 transition-opacity duration-300 whitespace-nowrap">
<span className={`font-semibold text-lg ${isDark ? 'text-white' : 'text-slate-900'}`}>BreakPilot</span>
<span className={`block text-xs ${isDark ? 'text-white/60' : 'text-slate-500'}`}>Studio v2</span>
</div>
</div>
</div>
{/* Navigation */}
<nav className="flex-1 space-y-2">
{navItems.map((item) => (
<button
key={item.id}
onClick={() => handleNavClick(item)}
className={`relative w-full flex items-center gap-4 p-3 rounded-2xl transition-all ${
activeItem === item.id
? isDark
? 'bg-white/20 text-white shadow-lg'
: 'bg-indigo-100 text-indigo-900 shadow-lg'
: item.id === 'alerts' && unreadCount > 0
? isDark
? 'text-amber-400 hover:bg-amber-500/10'
: 'text-amber-600 hover:bg-amber-50'
: item.id === 'alerts-b2b' && b2bUnreadCount > 0
? isDark
? 'text-blue-400 hover:bg-blue-500/10'
: 'text-blue-600 hover:bg-blue-50'
: item.id === 'messages' && messagesUnreadCount > 0
? isDark
? 'text-green-400 hover:bg-green-500/10'
: 'text-green-600 hover:bg-green-50'
: isDark
? 'text-white/60 hover:bg-white/10 hover:text-white'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
}`}
>
<span className="relative flex-shrink-0">
{item.icon}
{item.id === 'alerts' && unreadCount > 0 && (
<span className="absolute -top-1 -right-1 w-4 h-4 bg-red-500 rounded-full text-[10px] text-white flex items-center justify-center font-medium">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
{item.id === 'alerts-b2b' && b2bUnreadCount > 0 && (
<span className="absolute -top-1 -right-1 w-4 h-4 bg-blue-500 rounded-full text-[10px] text-white flex items-center justify-center font-medium">
{b2bUnreadCount > 9 ? '9+' : b2bUnreadCount}
</span>
)}
{item.id === 'messages' && messagesUnreadCount > 0 && (
<span className="absolute -top-1 -right-1 w-4 h-4 bg-green-500 rounded-full text-[10px] text-white flex items-center justify-center font-medium">
{messagesUnreadCount > 9 ? '9+' : messagesUnreadCount}
</span>
)}
</span>
<span className="font-medium opacity-0 group-hover:opacity-100 transition-opacity duration-300 whitespace-nowrap flex items-center gap-2">
{t(item.labelKey)}
{item.id === 'alerts' && unreadCount > 0 && (
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-amber-500/20 text-amber-500">
{unreadCount}
</span>
)}
{item.id === 'alerts-b2b' && b2bUnreadCount > 0 && (
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-blue-500/20 text-blue-500">
{b2bUnreadCount}
</span>
)}
{item.id === 'messages' && messagesUnreadCount > 0 && (
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-green-500/20 text-green-500">
{messagesUnreadCount}
</span>
)}
</span>
</button>
))}
</nav>
{/* User Menu */}
<div className={`pt-4 border-t ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
<UserMenu
userName="Lehrer Max"
userEmail="max@schule.de"
userInitials="LM"
isExpanded={sidebarHovered}
/>
</div>
</div>
</aside>
)
}

View File

@@ -0,0 +1,46 @@
'use client'
import { useTheme } from '@/lib/ThemeContext'
interface ThemeToggleProps {
className?: string
}
export function ThemeToggle({ className = '' }: ThemeToggleProps) {
const { toggleTheme, isDark } = useTheme()
return (
<button
onClick={toggleTheme}
className={`p-3 backdrop-blur-xl border rounded-2xl transition-all ${
isDark
? 'bg-white/10 border-white/20 text-white hover:bg-white/20'
: 'bg-black/5 border-black/10 text-slate-700 hover:bg-black/10'
} ${className}`}
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
title={isDark ? 'Hell' : 'Dunkel'}
>
{isDark ? (
// Sun icon for switching to light mode
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
) : (
// Moon icon for switching to dark mode
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
)}
</button>
)
}

View File

@@ -0,0 +1,163 @@
'use client'
import { useState, useRef, useEffect } from 'react'
import { useLanguage } from '@/lib/LanguageContext'
import { useTheme } from '@/lib/ThemeContext'
interface UserMenuProps {
userName: string
userEmail: string
userInitials: string
isExpanded?: boolean
className?: string
}
export function UserMenu({
userName,
userEmail,
userInitials,
isExpanded = false,
className = ''
}: UserMenuProps) {
const { t } = useLanguage()
const { isDark } = useTheme()
const [isOpen, setIsOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
// Schliessen bei Klick ausserhalb
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
// Schliessen bei Escape
useEffect(() => {
function handleEscape(event: KeyboardEvent) {
if (event.key === 'Escape') setIsOpen(false)
}
document.addEventListener('keydown', handleEscape)
return () => document.removeEventListener('keydown', handleEscape)
}, [])
const menuItems = [
{
id: 'settings',
labelKey: 'nav_settings',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
onClick: () => {
console.log('Settings clicked')
setIsOpen(false)
}
},
{
id: 'logout',
labelKey: 'logout',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
),
onClick: () => {
console.log('Logout clicked')
setIsOpen(false)
},
danger: true
}
]
return (
<div ref={menuRef} className={`relative ${className}`}>
{/* User Button - Trigger */}
<button
onClick={() => setIsOpen(!isOpen)}
className={`w-full flex items-center gap-3 p-2 rounded-2xl transition-all ${
isOpen
? isDark
? 'bg-white/20'
: 'bg-slate-200'
: isDark
? 'hover:bg-white/10'
: 'hover:bg-slate-100'
}`}
>
{/* Avatar */}
<div className="w-10 h-10 bg-gradient-to-br from-cyan-400 to-blue-500 rounded-full flex items-center justify-center text-white font-medium flex-shrink-0">
{userInitials}
</div>
{/* Name & Email - nur sichtbar wenn Sidebar expandiert */}
<div className={`flex-1 text-left ${isExpanded ? 'opacity-100' : 'opacity-0'} transition-opacity duration-300`}>
<p className={`text-sm font-medium whitespace-nowrap ${isDark ? 'text-white' : 'text-slate-900'}`}>
{userName}
</p>
<p className={`text-xs whitespace-nowrap ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
{userEmail}
</p>
</div>
{/* Chevron - nur sichtbar wenn Sidebar expandiert */}
<svg
className={`w-4 h-4 transition-all ${isExpanded ? 'opacity-100' : 'opacity-0'} ${isOpen ? 'rotate-180' : ''} ${
isDark ? 'text-white/60' : 'text-slate-500'
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
</svg>
</button>
{/* Popup Menu - erscheint oberhalb */}
{isOpen && (
<div className={`absolute bottom-full left-0 right-0 mb-2 backdrop-blur-2xl border rounded-2xl shadow-xl overflow-hidden z-50 ${
isDark
? 'bg-slate-900/95 border-white/20'
: 'bg-white/95 border-black/10'
}`}>
{/* User Info Header */}
<div className={`px-4 py-3 border-b ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
<p className={`text-sm font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
{userName}
</p>
<p className={`text-xs ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
{userEmail}
</p>
</div>
{/* Menu Items */}
<div className="py-1">
{menuItems.map((item) => (
<button
key={item.id}
onClick={item.onClick}
className={`w-full flex items-center gap-3 px-4 py-3 transition-all ${
item.danger
? isDark
? 'text-red-400 hover:bg-red-500/20'
: 'text-red-600 hover:bg-red-50'
: isDark
? 'text-white/80 hover:bg-white/10 hover:text-white'
: 'text-slate-700 hover:bg-slate-100 hover:text-slate-900'
}`}
>
<span className="flex-shrink-0">{item.icon}</span>
<span className="text-sm font-medium">{t(item.labelKey)}</span>
</button>
))}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,438 @@
'use client'
import { useEffect, useRef, useState, useCallback } from 'react'
import { GeoJSONPolygon } from '@/app/geo-lernwelt/types'
// MapLibre GL JS types (imported dynamically)
type MapInstance = {
on: (event: string, callback: (...args: unknown[]) => void) => void
addSource: (id: string, source: object) => void
addLayer: (layer: object) => void
getSource: (id: string) => { setData: (data: object) => void } | undefined
removeLayer: (id: string) => void
removeSource: (id: string) => void
getCanvas: () => { style: { cursor: string } }
remove: () => void
fitBounds: (bounds: [[number, number], [number, number]], options?: object) => void
}
interface AOISelectorProps {
onPolygonDrawn: (polygon: GeoJSONPolygon) => void
initialPolygon?: GeoJSONPolygon | null
maxAreaKm2?: number
geoServiceUrl: string
}
// Germany bounds
const GERMANY_BOUNDS: [[number, number], [number, number]] = [
[5.87, 47.27],
[15.04, 55.06],
]
// Default center (Germany)
const DEFAULT_CENTER: [number, number] = [10.45, 51.16]
const DEFAULT_ZOOM = 6
export default function AOISelector({
onPolygonDrawn,
initialPolygon,
maxAreaKm2 = 4,
geoServiceUrl,
}: AOISelectorProps) {
const mapContainerRef = useRef<HTMLDivElement>(null)
const mapRef = useRef<MapInstance | null>(null)
const [isDrawing, setIsDrawing] = useState(false)
const [drawPoints, setDrawPoints] = useState<[number, number][]>([])
const [mapReady, setMapReady] = useState(false)
const [areaKm2, setAreaKm2] = useState<number | null>(null)
const [validationError, setValidationError] = useState<string | null>(null)
// Initialize map
useEffect(() => {
if (!mapContainerRef.current) return
const initMap = async () => {
const maplibregl = await import('maplibre-gl')
// CSS is loaded via CDN in head to avoid Next.js dynamic import issues
if (!document.querySelector('link[href*="maplibre-gl.css"]')) {
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = 'https://unpkg.com/maplibre-gl@5.0.1/dist/maplibre-gl.css'
document.head.appendChild(link)
}
const map = new maplibregl.Map({
container: mapContainerRef.current!,
style: {
version: 8,
name: 'GeoEdu Map',
sources: {
osm: {
type: 'raster',
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '© OpenStreetMap contributors',
},
},
layers: [
{
id: 'osm-tiles',
type: 'raster',
source: 'osm',
minzoom: 0,
maxzoom: 19,
},
],
},
center: DEFAULT_CENTER,
zoom: DEFAULT_ZOOM,
maxBounds: [
[GERMANY_BOUNDS[0][0] - 1, GERMANY_BOUNDS[0][1] - 1],
[GERMANY_BOUNDS[1][0] + 1, GERMANY_BOUNDS[1][1] + 1],
],
}) as unknown as MapInstance
map.on('load', () => {
// Add drawing layer sources
map.addSource('draw-polygon', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: [],
},
})
map.addSource('draw-points', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: [],
},
})
// Add polygon fill layer
map.addLayer({
id: 'draw-polygon-fill',
type: 'fill',
source: 'draw-polygon',
paint: {
'fill-color': '#3b82f6',
'fill-opacity': 0.3,
},
})
// Add polygon outline layer
map.addLayer({
id: 'draw-polygon-outline',
type: 'line',
source: 'draw-polygon',
paint: {
'line-color': '#3b82f6',
'line-width': 2,
},
})
// Add points layer
map.addLayer({
id: 'draw-points-layer',
type: 'circle',
source: 'draw-points',
paint: {
'circle-radius': 6,
'circle-color': '#3b82f6',
'circle-stroke-color': '#ffffff',
'circle-stroke-width': 2,
},
})
mapRef.current = map
setMapReady(true)
// Load initial polygon if provided
if (initialPolygon) {
loadPolygon(map, initialPolygon)
}
})
}
initMap()
return () => {
if (mapRef.current) {
mapRef.current.remove()
}
}
}, [])
// Update polygon when initialPolygon changes
useEffect(() => {
if (mapReady && mapRef.current && initialPolygon) {
loadPolygon(mapRef.current, initialPolygon)
}
}, [initialPolygon, mapReady])
const loadPolygon = (map: MapInstance, polygon: GeoJSONPolygon) => {
const source = map.getSource('draw-polygon')
if (source) {
source.setData({
type: 'Feature',
geometry: polygon,
properties: {},
})
}
// Calculate and validate area
validatePolygon(polygon)
// Fit bounds to polygon
const coords = polygon.coordinates[0]
const bounds = coords.reduce<[[number, number], [number, number]]>(
(acc, coord) => [
[Math.min(acc[0][0], coord[0]), Math.min(acc[0][1], coord[1])],
[Math.max(acc[1][0], coord[0]), Math.max(acc[1][1], coord[1])],
],
[
[Infinity, Infinity],
[-Infinity, -Infinity],
]
)
map.fitBounds(bounds, { padding: 50 })
}
const validatePolygon = async (polygon: GeoJSONPolygon) => {
try {
const res = await fetch(`${geoServiceUrl}/api/v1/aoi/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(polygon),
})
if (res.ok) {
const data = await res.json()
setAreaKm2(data.area_km2)
if (!data.valid) {
setValidationError(data.error || 'Ungueltiges Polygon')
} else if (!data.within_germany) {
setValidationError('Gebiet muss innerhalb Deutschlands liegen')
} else if (!data.within_size_limit) {
setValidationError(`Gebiet zu gross (max. ${maxAreaKm2} km²)`)
} else {
setValidationError(null)
}
}
} catch (e) {
// Fallback to client-side calculation
console.error('Validation error:', e)
}
}
const handleMapClick = useCallback(
(e: { lngLat: { lng: number; lat: number } }) => {
if (!isDrawing || !mapRef.current) return
const newPoint: [number, number] = [e.lngLat.lng, e.lngLat.lat]
const newPoints = [...drawPoints, newPoint]
setDrawPoints(newPoints)
// Update points layer
const pointsSource = mapRef.current.getSource('draw-points')
if (pointsSource) {
pointsSource.setData({
type: 'FeatureCollection',
features: newPoints.map((pt) => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: pt },
properties: {},
})),
})
}
// Update polygon preview if we have at least 3 points
if (newPoints.length >= 3) {
const polygonCoords = [...newPoints, newPoints[0]]
const polygonSource = mapRef.current.getSource('draw-polygon')
if (polygonSource) {
polygonSource.setData({
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [polygonCoords],
},
properties: {},
})
}
}
},
[isDrawing, drawPoints]
)
// Attach click handler when drawing
useEffect(() => {
if (!mapReady || !mapRef.current) return
const map = mapRef.current
if (isDrawing) {
map.getCanvas().style.cursor = 'crosshair'
map.on('click', handleMapClick as (...args: unknown[]) => void)
} else {
map.getCanvas().style.cursor = ''
}
return () => {
// Note: maplibre doesn't have off() in the same way, handled by cleanup
}
}, [isDrawing, mapReady, handleMapClick])
const startDrawing = () => {
setIsDrawing(true)
setDrawPoints([])
setAreaKm2(null)
setValidationError(null)
// Clear existing polygon
if (mapRef.current) {
const polygonSource = mapRef.current.getSource('draw-polygon')
const pointsSource = mapRef.current.getSource('draw-points')
if (polygonSource) {
polygonSource.setData({ type: 'FeatureCollection', features: [] })
}
if (pointsSource) {
pointsSource.setData({ type: 'FeatureCollection', features: [] })
}
}
}
const finishDrawing = () => {
if (drawPoints.length < 3) {
setValidationError('Mindestens 3 Punkte erforderlich')
return
}
setIsDrawing(false)
// Close the polygon
const closedCoords = [...drawPoints, drawPoints[0]]
const polygon: GeoJSONPolygon = {
type: 'Polygon',
coordinates: [closedCoords],
}
// Clear points layer
if (mapRef.current) {
const pointsSource = mapRef.current.getSource('draw-points')
if (pointsSource) {
pointsSource.setData({ type: 'FeatureCollection', features: [] })
}
}
// Validate and callback
validatePolygon(polygon)
onPolygonDrawn(polygon)
}
const cancelDrawing = () => {
setIsDrawing(false)
setDrawPoints([])
// Clear layers
if (mapRef.current) {
const polygonSource = mapRef.current.getSource('draw-polygon')
const pointsSource = mapRef.current.getSource('draw-points')
if (polygonSource) {
polygonSource.setData({ type: 'FeatureCollection', features: [] })
}
if (pointsSource) {
pointsSource.setData({ type: 'FeatureCollection', features: [] })
}
}
}
return (
<div className="relative w-full h-full">
{/* Map Container */}
<div ref={mapContainerRef} className="w-full h-full" />
{/* Drawing Toolbar */}
<div className="absolute top-4 left-4 flex gap-2">
{!isDrawing ? (
<button
onClick={startDrawing}
className="px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg shadow-lg transition-colors 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="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
/>
</svg>
Gebiet zeichnen
</button>
) : (
<>
<button
onClick={finishDrawing}
disabled={drawPoints.length < 3}
className="px-4 py-2 bg-green-500 hover:bg-green-600 disabled:bg-gray-500 text-white rounded-lg shadow-lg transition-colors"
>
Fertig ({drawPoints.length} Punkte)
</button>
<button
onClick={cancelDrawing}
className="px-4 py-2 bg-red-500 hover:bg-red-600 text-white rounded-lg shadow-lg transition-colors"
>
Abbrechen
</button>
</>
)}
</div>
{/* Drawing Instructions */}
{isDrawing && (
<div className="absolute top-4 right-4 bg-black/70 text-white px-4 py-2 rounded-lg text-sm max-w-xs">
<p>Klicke auf die Karte um Punkte zu setzen.</p>
<p className="text-white/60 mt-1">Mindestens 3 Punkte erforderlich.</p>
</div>
)}
{/* Area Info */}
{areaKm2 !== null && (
<div className="absolute bottom-4 left-4 bg-black/70 text-white px-4 py-2 rounded-lg">
<div className="text-sm">
<span className="text-white/60">Flaeche: </span>
<span className={areaKm2 > maxAreaKm2 ? 'text-red-400' : 'text-green-400'}>
{areaKm2.toFixed(2)} km²
</span>
<span className="text-white/40"> / {maxAreaKm2} km² max</span>
</div>
</div>
)}
{/* Validation Error */}
{validationError && (
<div className="absolute bottom-4 right-4 bg-red-500/90 text-white px-4 py-2 rounded-lg text-sm max-w-xs">
{validationError}
</div>
)}
{/* Loading Overlay */}
{!mapReady && (
<div className="absolute inset-0 bg-slate-800 flex items-center justify-center">
<div className="text-white/60 flex flex-col items-center gap-2">
<div className="w-8 h-8 border-2 border-white/30 border-t-white rounded-full animate-spin" />
<span>Karte wird geladen...</span>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,329 @@
'use client'
import { useEffect, useRef, useState, useCallback } from 'react'
import { LearningNode } from '@/app/geo-lernwelt/types'
interface UnityViewerProps {
aoiId: string
manifestUrl?: string
learningNodes: LearningNode[]
geoServiceUrl: string
}
interface UnityInstance {
SendMessage: (objectName: string, methodName: string, value?: string | number) => void
}
export default function UnityViewer({
aoiId,
manifestUrl,
learningNodes,
geoServiceUrl,
}: UnityViewerProps) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const [unityInstance, setUnityInstance] = useState<UnityInstance | null>(null)
const [loadingProgress, setLoadingProgress] = useState(0)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [selectedNode, setSelectedNode] = useState<LearningNode | null>(null)
const [showNodePanel, setShowNodePanel] = useState(false)
// Placeholder mode when Unity build is not available
const [placeholderMode, setPlaceholderMode] = useState(true)
useEffect(() => {
// Check if Unity build exists
// For now, always use placeholder mode since Unity build needs to be created separately
setPlaceholderMode(true)
setIsLoading(false)
}, [])
// Unity message handler (for when Unity build exists)
useEffect(() => {
if (typeof window !== 'undefined') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).handleUnityMessage = (message: string) => {
try {
const data = JSON.parse(message)
switch (data.type) {
case 'nodeSelected':
const node = learningNodes.find((n) => n.id === data.nodeId)
if (node) {
setSelectedNode(node)
setShowNodePanel(true)
}
break
case 'terrainLoaded':
console.log('Unity terrain loaded')
break
case 'error':
setError(data.message)
break
}
} catch (e) {
console.error('Error parsing Unity message:', e)
}
}
}
return () => {
if (typeof window !== 'undefined') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (window as any).handleUnityMessage
}
}
}, [learningNodes])
const sendToUnity = useCallback(
(objectName: string, methodName: string, value?: string | number) => {
if (unityInstance) {
unityInstance.SendMessage(objectName, methodName, value)
}
},
[unityInstance]
)
// Send AOI data to Unity when ready
useEffect(() => {
if (unityInstance && manifestUrl) {
sendToUnity('TerrainManager', 'LoadManifest', manifestUrl)
// Send learning nodes
const nodesJson = JSON.stringify(learningNodes)
sendToUnity('LearningNodeManager', 'LoadNodes', nodesJson)
}
}, [unityInstance, manifestUrl, learningNodes, sendToUnity])
const handleNodeClick = (node: LearningNode) => {
setSelectedNode(node)
setShowNodePanel(true)
if (unityInstance) {
sendToUnity('CameraController', 'FocusOnNode', node.id)
}
}
const handleCloseNodePanel = () => {
setShowNodePanel(false)
setSelectedNode(null)
}
// Placeholder 3D view (when Unity is not available)
const PlaceholderView = () => (
<div className="w-full h-full bg-gradient-to-b from-sky-400 to-sky-200 relative overflow-hidden">
{/* Sky */}
<div className="absolute inset-0 bg-gradient-to-b from-sky-500 via-sky-400 to-sky-300" />
{/* Sun */}
<div className="absolute top-8 right-12 w-16 h-16 bg-yellow-300 rounded-full shadow-lg shadow-yellow-400/50" />
{/* Clouds */}
<div className="absolute top-12 left-8 w-24 h-8 bg-white/80 rounded-full blur-sm" />
<div className="absolute top-20 left-20 w-16 h-6 bg-white/70 rounded-full blur-sm" />
<div className="absolute top-16 right-32 w-20 h-7 bg-white/75 rounded-full blur-sm" />
{/* Mountains */}
<svg
className="absolute bottom-0 left-0 w-full h-1/2"
viewBox="0 0 1200 400"
preserveAspectRatio="none"
>
{/* Background mountains */}
<path d="M0,400 L200,150 L400,300 L600,100 L800,250 L1000,80 L1200,200 L1200,400 Z" fill="#4ade80" fillOpacity="0.5" />
{/* Foreground mountains */}
<path d="M0,400 L150,200 L300,350 L500,150 L700,300 L900,120 L1100,280 L1200,180 L1200,400 Z" fill="#22c55e" />
{/* Ground */}
<path d="M0,400 L0,350 Q300,320 600,350 Q900,380 1200,340 L1200,400 Z" fill="#15803d" />
</svg>
{/* Learning Nodes as Markers */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="relative w-full h-full">
{learningNodes.map((node, idx) => {
// Position nodes across the view
const positions = [
{ left: '20%', top: '55%' },
{ left: '40%', top: '45%' },
{ left: '60%', top: '50%' },
{ left: '75%', top: '55%' },
{ left: '30%', top: '60%' },
]
const pos = positions[idx % positions.length]
return (
<button
key={node.id}
onClick={() => handleNodeClick(node)}
style={{ left: pos.left, top: pos.top }}
className="absolute transform -translate-x-1/2 -translate-y-1/2 group"
>
<div className="relative">
{/* Marker pin */}
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white font-bold shadow-lg group-hover:bg-blue-600 transition-colors animate-bounce">
{idx + 1}
</div>
{/* Pulse effect */}
<div className="absolute inset-0 w-8 h-8 bg-blue-500 rounded-full animate-ping opacity-25" />
{/* Label */}
<div className="absolute top-10 left-1/2 transform -translate-x-1/2 bg-black/70 text-white text-xs px-2 py-1 rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity">
{node.title}
</div>
</div>
</button>
)
})}
</div>
</div>
{/* 3D View Notice */}
<div className="absolute bottom-4 left-4 bg-black/50 text-white px-4 py-2 rounded-lg text-sm">
<p className="font-medium">Vorschau-Modus</p>
<p className="text-white/70 text-xs">
Unity WebGL-Build wird fuer die volle 3D-Ansicht benoetigt
</p>
</div>
{/* Controls hint */}
<div className="absolute bottom-4 right-4 bg-black/50 text-white px-4 py-2 rounded-lg text-sm">
<p className="text-white/70">Klicke auf die Marker um Lernstationen anzuzeigen</p>
</div>
</div>
)
return (
<div className="relative w-full h-full bg-slate-900">
{/* Loading overlay */}
{isLoading && (
<div className="absolute inset-0 bg-slate-900 flex flex-col items-center justify-center z-10">
<div className="w-64 mb-4">
<div className="h-2 bg-slate-700 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 transition-all duration-300"
style={{ width: `${loadingProgress}%` }}
/>
</div>
</div>
<p className="text-white/60 text-sm">Lade 3D-Lernwelt... {loadingProgress}%</p>
</div>
)}
{/* Error display */}
{error && (
<div className="absolute inset-0 bg-slate-900 flex items-center justify-center z-10">
<div className="text-center p-8">
<div className="text-red-400 text-6xl mb-4"></div>
<p className="text-white text-lg mb-2">Fehler beim Laden</p>
<p className="text-white/60 text-sm max-w-md">{error}</p>
<button
onClick={() => {
setError(null)
setIsLoading(true)
setLoadingProgress(0)
}}
className="mt-4 px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg"
>
Erneut versuchen
</button>
</div>
</div>
)}
{/* Unity Canvas or Placeholder */}
{placeholderMode ? (
<PlaceholderView />
) : (
<canvas
ref={canvasRef}
id="unity-canvas"
className="w-full h-full"
tabIndex={-1}
/>
)}
{/* Learning Node Panel */}
{showNodePanel && selectedNode && (
<div className="absolute top-4 right-4 w-80 bg-white/95 dark:bg-slate-800/95 backdrop-blur-lg rounded-2xl shadow-2xl overflow-hidden z-20">
{/* Header */}
<div className="bg-gradient-to-r from-blue-500 to-purple-500 p-4 text-white">
<div className="flex items-center justify-between">
<span className="text-sm opacity-80">Station {learningNodes.indexOf(selectedNode) + 1}</span>
<button
onClick={handleCloseNodePanel}
className="w-6 h-6 rounded-full bg-white/20 hover:bg-white/30 flex items-center justify-center"
>
</button>
</div>
<h3 className="font-semibold text-lg mt-1">{selectedNode.title}</h3>
</div>
{/* Content */}
<div className="p-4 space-y-4">
{/* Question */}
<div>
<h4 className="text-sm font-medium text-slate-500 dark:text-slate-400 mb-1">Aufgabe</h4>
<p className="text-slate-800 dark:text-white">{selectedNode.question}</p>
</div>
{/* Hints */}
{selectedNode.hints.length > 0 && (
<div>
<h4 className="text-sm font-medium text-slate-500 dark:text-slate-400 mb-1">Hinweise</h4>
<ul className="list-disc list-inside text-sm text-slate-600 dark:text-slate-300 space-y-1">
{selectedNode.hints.map((hint, idx) => (
<li key={idx}>{hint}</li>
))}
</ul>
</div>
)}
{/* Answer (collapsible) */}
<details className="bg-green-50 dark:bg-green-900/20 rounded-lg">
<summary className="p-3 cursor-pointer text-green-700 dark:text-green-400 font-medium text-sm">
Loesung anzeigen
</summary>
<div className="px-3 pb-3">
<p className="text-green-800 dark:text-green-300 text-sm">{selectedNode.answer}</p>
{selectedNode.explanation && (
<p className="text-green-600 dark:text-green-400 text-xs mt-2 italic">
{selectedNode.explanation}
</p>
)}
</div>
</details>
{/* Points */}
<div className="flex items-center justify-between pt-2 border-t border-slate-200 dark:border-slate-700">
<span className="text-sm text-slate-500 dark:text-slate-400">Punkte</span>
<span className="font-bold text-blue-500">{selectedNode.points}</span>
</div>
</div>
</div>
)}
{/* Node List (minimized) */}
{!showNodePanel && learningNodes.length > 0 && (
<div className="absolute top-4 right-4 bg-black/70 rounded-xl p-3 z-20 max-h-64 overflow-y-auto">
<h4 className="text-white text-sm font-medium mb-2">Lernstationen</h4>
<div className="space-y-1">
{learningNodes.map((node, idx) => (
<button
key={node.id}
onClick={() => handleNodeClick(node)}
className="w-full text-left px-3 py-2 rounded-lg bg-white/10 hover:bg-white/20 transition-colors text-white text-sm flex items-center gap-2"
>
<span className="w-5 h-5 bg-blue-500 rounded-full flex items-center justify-center text-xs">
{idx + 1}
</span>
<span className="truncate">{node.title}</span>
</button>
))}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,7 @@
/**
* GeoEdu Components
* Exports all geo-lernwelt components
*/
export { default as AOISelector } from './AOISelector'
export { default as UnityViewer } from './UnityViewer'

View File

@@ -0,0 +1,310 @@
'use client'
import { useState, useRef, useCallback } from 'react'
import type { Annotation, AnnotationType, AnnotationPosition } from '@/app/korrektur/types'
import { ANNOTATION_COLORS } from '@/app/korrektur/types'
interface AnnotationLayerProps {
annotations: Annotation[]
selectedAnnotation: string | null
currentTool: AnnotationType | null
onAnnotationCreate: (position: AnnotationPosition, type: AnnotationType) => void
onAnnotationSelect: (id: string | null) => void
onAnnotationDelete: (id: string) => void
isEditable?: boolean
className?: string
}
export function AnnotationLayer({
annotations,
selectedAnnotation,
currentTool,
onAnnotationCreate,
onAnnotationSelect,
onAnnotationDelete,
isEditable = true,
className = '',
}: AnnotationLayerProps) {
const containerRef = useRef<HTMLDivElement>(null)
const [isDrawing, setIsDrawing] = useState(false)
const [drawStart, setDrawStart] = useState<{ x: number; y: number } | null>(null)
const [drawEnd, setDrawEnd] = useState<{ x: number; y: number } | null>(null)
const getRelativePosition = useCallback(
(e: React.MouseEvent | MouseEvent): { x: number; y: number } => {
if (!containerRef.current) return { x: 0, y: 0 }
const rect = containerRef.current.getBoundingClientRect()
return {
x: ((e.clientX - rect.left) / rect.width) * 100,
y: ((e.clientY - rect.top) / rect.height) * 100,
}
},
[]
)
const handleMouseDown = (e: React.MouseEvent) => {
if (!isEditable || !currentTool) return
e.preventDefault()
e.stopPropagation()
const pos = getRelativePosition(e)
setIsDrawing(true)
setDrawStart(pos)
setDrawEnd(pos)
onAnnotationSelect(null)
}
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
if (!isDrawing || !drawStart) return
const pos = getRelativePosition(e)
setDrawEnd(pos)
},
[isDrawing, drawStart, getRelativePosition]
)
const handleMouseUp = useCallback(
(e: React.MouseEvent) => {
if (!isDrawing || !drawStart || !drawEnd || !currentTool) {
setIsDrawing(false)
return
}
// Calculate rectangle bounds
const minX = Math.min(drawStart.x, drawEnd.x)
const minY = Math.min(drawStart.y, drawEnd.y)
const maxX = Math.max(drawStart.x, drawEnd.x)
const maxY = Math.max(drawStart.y, drawEnd.y)
// Minimum size check (at least 2% width/height)
const width = maxX - minX
const height = maxY - minY
if (width >= 1 && height >= 1) {
const position: AnnotationPosition = {
x: minX,
y: minY,
width: width,
height: height,
}
onAnnotationCreate(position, currentTool)
}
setIsDrawing(false)
setDrawStart(null)
setDrawEnd(null)
},
[isDrawing, drawStart, drawEnd, currentTool, onAnnotationCreate]
)
const handleAnnotationClick = (e: React.MouseEvent, id: string) => {
e.stopPropagation()
onAnnotationSelect(selectedAnnotation === id ? null : id)
}
const handleBackgroundClick = () => {
onAnnotationSelect(null)
}
// Drawing preview rectangle
const drawingRect =
isDrawing && drawStart && drawEnd
? {
x: Math.min(drawStart.x, drawEnd.x),
y: Math.min(drawStart.y, drawEnd.y),
width: Math.abs(drawEnd.x - drawStart.x),
height: Math.abs(drawEnd.y - drawStart.y),
}
: null
return (
<div
ref={containerRef}
className={`absolute inset-0 ${className}`}
style={{
pointerEvents: isEditable && currentTool ? 'auto' : 'none',
cursor: currentTool ? 'crosshair' : 'default',
}}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onClick={handleBackgroundClick}
>
<svg
className="absolute inset-0 w-full h-full"
style={{ pointerEvents: 'none' }}
>
{/* Existing Annotations */}
{annotations.map((annotation) => {
const color = ANNOTATION_COLORS[annotation.type] || '#6b7280'
const isSelected = selectedAnnotation === annotation.id
return (
<g
key={annotation.id}
style={{ pointerEvents: 'auto', cursor: 'pointer' }}
onClick={(e) => handleAnnotationClick(e, annotation.id)}
>
{/* Highlight Rectangle */}
<rect
x={`${annotation.position.x}%`}
y={`${annotation.position.y}%`}
width={`${annotation.position.width}%`}
height={`${annotation.position.height}%`}
fill={`${color}20`}
stroke={color}
strokeWidth={isSelected ? 3 : 2}
strokeDasharray={annotation.type === 'comment' ? '4 2' : 'none'}
rx="4"
ry="4"
className="transition-all"
/>
{/* Type Indicator */}
<circle
cx={`${annotation.position.x}%`}
cy={`${annotation.position.y}%`}
r="8"
fill={color}
className="drop-shadow-sm"
/>
{/* Severity Indicator */}
{annotation.severity === 'critical' && (
<circle
cx={`${annotation.position.x + annotation.position.width}%`}
cy={`${annotation.position.y}%`}
r="6"
fill="#ef4444"
className="animate-pulse"
/>
)}
</g>
)
})}
{/* Drawing Preview */}
{drawingRect && currentTool && (
<rect
x={`${drawingRect.x}%`}
y={`${drawingRect.y}%`}
width={`${drawingRect.width}%`}
height={`${drawingRect.height}%`}
fill={`${ANNOTATION_COLORS[currentTool]}30`}
stroke={ANNOTATION_COLORS[currentTool]}
strokeWidth={2}
strokeDasharray="4 2"
rx="4"
ry="4"
/>
)}
</svg>
{/* Selected Annotation Popup */}
{selectedAnnotation && (
<AnnotationPopup
annotation={annotations.find((a) => a.id === selectedAnnotation)!}
onDelete={() => onAnnotationDelete(selectedAnnotation)}
onClose={() => onAnnotationSelect(null)}
/>
)}
</div>
)
}
// =============================================================================
// ANNOTATION POPUP
// =============================================================================
interface AnnotationPopupProps {
annotation: Annotation
onDelete: () => void
onClose: () => void
}
function AnnotationPopup({ annotation, onDelete, onClose }: AnnotationPopupProps) {
const color = ANNOTATION_COLORS[annotation.type] || '#6b7280'
const typeLabels: Record<AnnotationType, string> = {
rechtschreibung: 'Rechtschreibung',
grammatik: 'Grammatik',
inhalt: 'Inhalt',
struktur: 'Struktur',
stil: 'Stil',
comment: 'Kommentar',
highlight: 'Markierung',
}
return (
<div
className="absolute z-10 w-64 rounded-xl bg-slate-800/95 backdrop-blur-sm border border-white/10 shadow-xl overflow-hidden"
style={{
left: `${Math.min(annotation.position.x + annotation.position.width, 70)}%`,
top: `${annotation.position.y}%`,
transform: 'translateY(-50%)',
}}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div
className="px-3 py-2 flex items-center justify-between"
style={{ backgroundColor: `${color}30` }}
>
<span className="text-white font-medium text-sm" style={{ color }}>
{typeLabels[annotation.type]}
</span>
<button
onClick={onClose}
className="p-1 rounded hover:bg-white/10 text-white/60"
>
<svg className="w-4 h-4" 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="p-3 space-y-2">
{annotation.text && (
<p className="text-white/80 text-sm">{annotation.text}</p>
)}
{annotation.suggestion && (
<div className="p-2 rounded-lg bg-green-500/10 border border-green-500/20">
<p className="text-green-400 text-xs font-medium mb-1">Vorschlag:</p>
<p className="text-white/70 text-sm">{annotation.suggestion}</p>
</div>
)}
{/* Severity Badge */}
<div className="flex items-center gap-2">
<span
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
annotation.severity === 'critical'
? 'bg-red-500/20 text-red-400'
: annotation.severity === 'major'
? 'bg-orange-500/20 text-orange-400'
: 'bg-gray-500/20 text-gray-400'
}`}
>
{annotation.severity === 'critical'
? 'Kritisch'
: annotation.severity === 'major'
? 'Wichtig'
: 'Hinweis'}
</span>
</div>
</div>
{/* Actions */}
<div className="px-3 py-2 border-t border-white/10 flex gap-2">
<button
onClick={onDelete}
className="flex-1 px-3 py-1.5 rounded-lg bg-red-500/20 text-red-400 text-sm hover:bg-red-500/30 transition-colors"
>
Loeschen
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,159 @@
'use client'
import type { AnnotationType } from '@/app/korrektur/types'
import { ANNOTATION_COLORS } from '@/app/korrektur/types'
interface AnnotationToolbarProps {
selectedTool: AnnotationType | null
onToolSelect: (tool: AnnotationType | null) => void
className?: string
}
const tools: Array<{
type: AnnotationType
label: string
shortcut: string
icon: React.ReactNode
}> = [
{
type: 'rechtschreibung',
label: 'Rechtschreibung',
shortcut: 'R',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
),
},
{
type: 'grammatik',
label: 'Grammatik',
shortcut: 'G',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
</svg>
),
},
{
type: 'inhalt',
label: 'Inhalt',
shortcut: 'I',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
),
},
{
type: 'struktur',
label: 'Struktur',
shortcut: 'S',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
),
},
{
type: 'stil',
label: 'Stil',
shortcut: 'T',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
),
},
{
type: 'comment',
label: 'Kommentar',
shortcut: 'K',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
</svg>
),
},
]
export function AnnotationToolbar({
selectedTool,
onToolSelect,
className = '',
}: AnnotationToolbarProps) {
return (
<div className={`flex items-center gap-1 p-2 bg-white/5 rounded-xl ${className}`}>
{tools.map((tool) => {
const isSelected = selectedTool === tool.type
const color = ANNOTATION_COLORS[tool.type]
return (
<button
key={tool.type}
onClick={() => onToolSelect(isSelected ? null : tool.type)}
className={`relative p-2 rounded-lg transition-all ${
isSelected
? 'bg-white/20 shadow-lg'
: 'hover:bg-white/10'
}`}
style={{
color: isSelected ? color : 'rgba(255, 255, 255, 0.6)',
}}
title={`${tool.label} (${tool.shortcut})`}
>
{tool.icon}
{/* Shortcut Badge */}
<span
className={`absolute -bottom-1 -right-1 w-4 h-4 rounded text-[10px] font-bold flex items-center justify-center ${
isSelected ? 'bg-white/20' : 'bg-white/10'
}`}
style={{ color: isSelected ? color : 'rgba(255, 255, 255, 0.4)' }}
>
{tool.shortcut}
</span>
</button>
)
})}
{/* Divider */}
<div className="w-px h-8 bg-white/10 mx-2" />
{/* Clear Tool Button */}
<button
onClick={() => onToolSelect(null)}
className={`p-2 rounded-lg transition-all ${
selectedTool === null
? 'bg-white/20 text-white'
: 'hover:bg-white/10 text-white/60'
}`}
title="Auswahl (Esc)"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
</button>
</div>
)
}
// =============================================================================
// ANNOTATION LEGEND
// =============================================================================
export function AnnotationLegend({ className = '' }: { className?: string }) {
return (
<div className={`flex flex-wrap gap-3 text-xs ${className}`}>
{tools.map((tool) => (
<div key={tool.type} className="flex items-center gap-1.5">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: ANNOTATION_COLORS[tool.type] }}
/>
<span className="text-white/60">{tool.label}</span>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,258 @@
'use client'
import { useMemo } from 'react'
import type { CriteriaScores, Annotation } from '@/app/korrektur/types'
import { DEFAULT_CRITERIA, ANNOTATION_COLORS, calculateGrade, getGradeLabel } from '@/app/korrektur/types'
interface CriteriaPanelProps {
scores: CriteriaScores
annotations: Annotation[]
onScoreChange: (criterion: string, value: number) => void
onLoadEHSuggestions?: (criterion: string) => void
isLoading?: boolean
className?: string
}
export function CriteriaPanel({
scores,
annotations,
onScoreChange,
onLoadEHSuggestions,
isLoading = false,
className = '',
}: CriteriaPanelProps) {
// Count annotations per criterion
const annotationCounts = useMemo(() => {
const counts: Record<string, number> = {}
for (const annotation of annotations) {
const type = annotation.linked_criterion || annotation.type
counts[type] = (counts[type] || 0) + 1
}
return counts
}, [annotations])
// Calculate total grade
const { totalWeightedScore, totalWeight, gradePoints, gradeLabel } = useMemo(() => {
let weightedScore = 0
let weight = 0
for (const [criterion, config] of Object.entries(DEFAULT_CRITERIA)) {
const score = scores[criterion]
if (score !== undefined) {
weightedScore += score * config.weight
weight += config.weight
}
}
const percentage = weight > 0 ? weightedScore / weight : 0
const grade = calculateGrade(percentage)
const label = getGradeLabel(grade)
return {
totalWeightedScore: weightedScore,
totalWeight: weight,
gradePoints: grade,
gradeLabel: label,
}
}, [scores])
return (
<div className={`space-y-4 ${className}`}>
{/* Criteria List */}
{Object.entries(DEFAULT_CRITERIA).map(([criterion, config]) => {
const score = scores[criterion] || 0
const annotationCount = annotationCounts[criterion] || 0
const color = ANNOTATION_COLORS[criterion as keyof typeof ANNOTATION_COLORS] || '#6b7280'
return (
<CriterionCard
key={criterion}
id={criterion}
name={config.name}
weight={config.weight}
score={score}
annotationCount={annotationCount}
color={color}
onScoreChange={(value) => onScoreChange(criterion, value)}
onLoadSuggestions={
onLoadEHSuggestions
? () => onLoadEHSuggestions(criterion)
: undefined
}
/>
)
})}
{/* Total Score */}
<div className="p-4 rounded-2xl bg-gradient-to-r from-purple-500/20 to-pink-500/20 border border-purple-500/30">
<div className="flex items-center justify-between mb-2">
<span className="text-white/60 text-sm">Gesamtnote</span>
<span className="text-2xl font-bold text-white">
{gradePoints} Punkte
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-white/40 text-xs">
{Math.round(totalWeightedScore / totalWeight)}% gewichtet
</span>
<span className="text-lg font-semibold text-purple-300">
({gradeLabel})
</span>
</div>
</div>
</div>
)
}
// =============================================================================
// CRITERION CARD
// =============================================================================
interface CriterionCardProps {
id: string
name: string
weight: number
score: number
annotationCount: number
color: string
onScoreChange: (value: number) => void
onLoadSuggestions?: () => void
}
function CriterionCard({
id,
name,
weight,
score,
annotationCount,
color,
onScoreChange,
onLoadSuggestions,
}: CriterionCardProps) {
const gradePoints = calculateGrade(score)
const gradeLabel = getGradeLabel(gradePoints)
return (
<div className="p-4 rounded-2xl bg-white/5 border border-white/10">
{/* Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: color }}
/>
<span className="text-white font-medium">{name}</span>
<span className="text-white/40 text-xs">({weight}%)</span>
</div>
<span className="text-white/60 text-sm">
{gradePoints} P ({gradeLabel})
</span>
</div>
{/* Slider */}
<div className="relative mb-3">
<input
type="range"
min="0"
max="100"
value={score}
onChange={(e) => onScoreChange(Number(e.target.value))}
className="w-full h-2 bg-white/10 rounded-full appearance-none cursor-pointer"
style={{
background: `linear-gradient(to right, ${color} ${score}%, rgba(255,255,255,0.1) ${score}%)`,
}}
/>
<div className="flex justify-between mt-1 text-xs text-white/30">
<span>0</span>
<span>25</span>
<span>50</span>
<span>75</span>
<span>100</span>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{annotationCount > 0 && (
<span
className="px-2 py-0.5 rounded-full text-xs font-medium"
style={{ backgroundColor: `${color}20`, color }}
>
{annotationCount} Anmerkung{annotationCount !== 1 ? 'en' : ''}
</span>
)}
</div>
{onLoadSuggestions && (id === 'inhalt' || id === 'struktur') && (
<button
onClick={onLoadSuggestions}
className="text-xs text-purple-400 hover:text-purple-300 transition-colors flex items-center gap-1"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
EH-Vorschlaege
</button>
)}
</div>
</div>
)
}
// =============================================================================
// COMPACT CRITERIA SUMMARY
// =============================================================================
interface CriteriaSummaryProps {
scores: CriteriaScores
className?: string
}
export function CriteriaSummary({ scores, className = '' }: CriteriaSummaryProps) {
const { gradePoints, gradeLabel } = useMemo(() => {
let weightedScore = 0
let weight = 0
for (const [criterion, config] of Object.entries(DEFAULT_CRITERIA)) {
const score = scores[criterion]
if (score !== undefined) {
weightedScore += score * config.weight
weight += config.weight
}
}
const percentage = weight > 0 ? weightedScore / weight : 0
const grade = calculateGrade(percentage)
const label = getGradeLabel(grade)
return { gradePoints: grade, gradeLabel: label }
}, [scores])
return (
<div className={`flex items-center gap-3 ${className}`}>
{Object.entries(DEFAULT_CRITERIA).map(([criterion, config]) => {
const score = scores[criterion] || 0
const color = ANNOTATION_COLORS[criterion as keyof typeof ANNOTATION_COLORS] || '#6b7280'
return (
<div
key={criterion}
className="flex items-center gap-1"
title={`${config.name}: ${score}%`}
>
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: color }}
/>
<span className="text-white/60 text-xs">{score}</span>
</div>
)
})}
<div className="w-px h-4 bg-white/20" />
<span className="text-white font-medium text-sm">
{gradePoints} ({gradeLabel})
</span>
</div>
)
}

View File

@@ -0,0 +1,221 @@
'use client'
import { useState, useRef, useCallback, useEffect } from 'react'
interface DocumentViewerProps {
fileUrl: string
fileType: 'pdf' | 'image'
currentPage: number
totalPages: number
onPageChange: (page: number) => void
children?: React.ReactNode // For annotation overlay
className?: string
}
export function DocumentViewer({
fileUrl,
fileType,
currentPage,
totalPages,
onPageChange,
children,
className = '',
}: DocumentViewerProps) {
const [zoom, setZoom] = useState(1)
const [isDragging, setIsDragging] = useState(false)
const [position, setPosition] = useState({ x: 0, y: 0 })
const [startPos, setStartPos] = useState({ x: 0, y: 0 })
const containerRef = useRef<HTMLDivElement>(null)
const handleZoomIn = () => setZoom((prev) => Math.min(prev + 0.25, 3))
const handleZoomOut = () => setZoom((prev) => Math.max(prev - 0.25, 0.5))
const handleFit = () => {
setZoom(1)
setPosition({ x: 0, y: 0 })
}
const handleMouseDown = (e: React.MouseEvent) => {
if (e.button !== 1 && !e.ctrlKey) return // Middle click or Ctrl+click for pan
setIsDragging(true)
setStartPos({ x: e.clientX - position.x, y: e.clientY - position.y })
}
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!isDragging) return
setPosition({
x: e.clientX - startPos.x,
y: e.clientY - startPos.y,
})
},
[isDragging, startPos]
)
const handleMouseUp = useCallback(() => {
setIsDragging(false)
}, [])
useEffect(() => {
if (isDragging) {
window.addEventListener('mousemove', handleMouseMove)
window.addEventListener('mouseup', handleMouseUp)
}
return () => {
window.removeEventListener('mousemove', handleMouseMove)
window.removeEventListener('mouseup', handleMouseUp)
}
}, [isDragging, handleMouseMove, handleMouseUp])
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === '+' || e.key === '=') {
e.preventDefault()
handleZoomIn()
} else if (e.key === '-') {
e.preventDefault()
handleZoomOut()
} else if (e.key === '0') {
e.preventDefault()
handleFit()
} else if (e.key === 'ArrowLeft' && currentPage > 1) {
e.preventDefault()
onPageChange(currentPage - 1)
} else if (e.key === 'ArrowRight' && currentPage < totalPages) {
e.preventDefault()
onPageChange(currentPage + 1)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [currentPage, totalPages, onPageChange])
return (
<div className={`flex flex-col h-full ${className}`}>
{/* Toolbar */}
<div className="flex items-center justify-between px-4 py-2 bg-white/5 border-b border-white/10">
<div className="flex items-center gap-2">
<button
onClick={handleZoomOut}
className="p-2 rounded-lg hover:bg-white/10 text-white/60 hover:text-white transition-colors"
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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM13 10H7" />
</svg>
</button>
<span className="text-white/60 text-sm min-w-[60px] text-center">
{Math.round(zoom * 100)}%
</span>
<button
onClick={handleZoomIn}
className="p-2 rounded-lg hover:bg-white/10 text-white/60 hover:text-white transition-colors"
title="Vergroessern (+)"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7" />
</svg>
</button>
<button
onClick={handleFit}
className="p-2 rounded-lg hover:bg-white/10 text-white/60 hover:text-white transition-colors"
title="Einpassen (0)"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
</svg>
</button>
</div>
{/* Page Navigation */}
{totalPages > 1 && (
<div className="flex items-center gap-2">
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage <= 1}
className="p-2 rounded-lg hover:bg-white/10 text-white/60 hover:text-white transition-colors disabled:opacity-30"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<span className="text-white/60 text-sm">
{currentPage} / {totalPages}
</span>
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage >= totalPages}
className="p-2 rounded-lg hover:bg-white/10 text-white/60 hover:text-white transition-colors disabled:opacity-30"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
)}
</div>
{/* Document Area */}
<div
ref={containerRef}
className="flex-1 overflow-hidden bg-slate-800/50 relative"
onMouseDown={handleMouseDown}
style={{ cursor: isDragging ? 'grabbing' : 'default' }}
>
<div
className="absolute inset-0 flex items-center justify-center"
style={{
transform: `translate(${position.x}px, ${position.y}px) scale(${zoom})`,
transformOrigin: 'center center',
transition: isDragging ? 'none' : 'transform 0.2s ease-out',
}}
>
{/* Document Image/PDF */}
<div className="relative">
{fileType === 'image' ? (
<img
src={fileUrl}
alt="Schuelerarbeit"
className="max-w-full max-h-full object-contain shadow-2xl"
draggable={false}
/>
) : (
<iframe
src={`${fileUrl}#page=${currentPage}`}
className="w-[800px] h-[1000px] bg-white shadow-2xl"
title="PDF Dokument"
/>
)}
{/* Annotation Overlay */}
{children && (
<div className="absolute inset-0 pointer-events-none">
{children}
</div>
)}
</div>
</div>
</div>
{/* Page Thumbnails (for multi-page) */}
{totalPages > 1 && (
<div className="flex gap-2 p-3 bg-white/5 border-t border-white/10 overflow-x-auto">
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<button
key={page}
onClick={() => onPageChange(page)}
className={`flex-shrink-0 w-12 h-16 rounded-lg border-2 flex items-center justify-center text-sm font-medium transition-all ${
page === currentPage
? 'border-purple-500 bg-purple-500/20 text-white'
: 'border-white/10 bg-white/5 text-white/60 hover:border-white/30'
}`}
>
{page}
</button>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,293 @@
'use client'
import { useState } from 'react'
import type { EHSuggestion } from '@/app/korrektur/types'
import { DEFAULT_CRITERIA, ANNOTATION_COLORS, NIBIS_ATTRIBUTION } from '@/app/korrektur/types'
interface EHSuggestionPanelProps {
suggestions: EHSuggestion[]
isLoading: boolean
onLoadSuggestions: (criterion?: string) => void
onInsertSuggestion?: (text: string) => void
className?: string
}
export function EHSuggestionPanel({
suggestions,
isLoading,
onLoadSuggestions,
onInsertSuggestion,
className = '',
}: EHSuggestionPanelProps) {
const [selectedCriterion, setSelectedCriterion] = useState<string | undefined>(undefined)
const [expandedSuggestion, setExpandedSuggestion] = useState<string | null>(null)
const handleLoadSuggestions = () => {
onLoadSuggestions(selectedCriterion)
}
// Group suggestions by criterion
const groupedSuggestions = suggestions.reduce((acc, suggestion) => {
if (!acc[suggestion.criterion]) {
acc[suggestion.criterion] = []
}
acc[suggestion.criterion].push(suggestion)
return acc
}, {} as Record<string, EHSuggestion[]>)
return (
<div className={`space-y-4 ${className}`}>
{/* Header with Attribution (CTRL-SRC-002) */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-white font-semibold">EH-Vorschlaege</h3>
<p className="text-white/40 text-xs">Aus 500+ NiBiS Dokumenten</p>
</div>
</div>
{/* Attribution Notice */}
<div className="p-2 rounded-lg bg-white/5 border border-white/10">
<div className="flex items-center gap-2 text-xs text-white/50">
<svg className="w-3 h-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>
Quelle: {NIBIS_ATTRIBUTION.publisher} {' '}
<a
href={NIBIS_ATTRIBUTION.license_url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:underline"
>
{NIBIS_ATTRIBUTION.license}
</a>
</span>
</div>
</div>
{/* Criterion Filter */}
<div className="flex flex-wrap gap-2">
<button
onClick={() => setSelectedCriterion(undefined)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
selectedCriterion === undefined
? 'bg-purple-500 text-white'
: 'bg-white/10 text-white/60 hover:bg-white/20'
}`}
>
Alle
</button>
{Object.entries(DEFAULT_CRITERIA).map(([id, config]) => {
const color = ANNOTATION_COLORS[id as keyof typeof ANNOTATION_COLORS] || '#6b7280'
return (
<button
key={id}
onClick={() => setSelectedCriterion(id)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
selectedCriterion === id
? 'text-white'
: 'text-white/60 hover:bg-white/20'
}`}
style={{
backgroundColor: selectedCriterion === id ? color : 'rgba(255,255,255,0.1)',
}}
>
{config.name}
</button>
)
})}
</div>
{/* Load Button */}
<button
onClick={handleLoadSuggestions}
disabled={isLoading}
className="w-full px-4 py-3 rounded-xl bg-gradient-to-r from-blue-500 to-cyan-500 text-white font-medium hover:shadow-lg hover:shadow-blue-500/30 transition-all disabled:opacity-50 flex items-center justify-center gap-2"
>
{isLoading ? (
<>
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
RAG-Suche laeuft...
</>
) : (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
EH-Vorschlaege laden
</>
)}
</button>
{/* Suggestions List */}
{suggestions.length > 0 && (
<div className="space-y-3 max-h-96 overflow-y-auto">
{Object.entries(groupedSuggestions).map(([criterion, criterionSuggestions]) => {
const config = DEFAULT_CRITERIA[criterion]
const color = ANNOTATION_COLORS[criterion as keyof typeof ANNOTATION_COLORS] || '#6b7280'
return (
<div key={criterion} className="space-y-2">
{/* Criterion Header */}
<div className="flex items-center gap-2 sticky top-0 bg-slate-900/95 backdrop-blur-sm py-1">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: color }}
/>
<span className="text-white/60 text-xs font-medium">
{config?.name || criterion}
</span>
<span className="text-white/30 text-xs">
({criterionSuggestions.length})
</span>
</div>
{/* Suggestions */}
{criterionSuggestions.map((suggestion, index) => (
<SuggestionCard
key={`${criterion}-${index}`}
suggestion={suggestion}
color={color}
isExpanded={expandedSuggestion === `${criterion}-${index}`}
onToggle={() =>
setExpandedSuggestion(
expandedSuggestion === `${criterion}-${index}`
? null
: `${criterion}-${index}`
)
}
onInsert={onInsertSuggestion}
/>
))}
</div>
)
})}
</div>
)}
{/* Empty State */}
{!isLoading && suggestions.length === 0 && (
<div className="p-6 rounded-xl bg-white/5 border border-white/10 text-center">
<div className="w-12 h-12 rounded-full bg-white/10 flex items-center justify-center mx-auto mb-3">
<svg className="w-6 h-6 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
</div>
<p className="text-white/60 text-sm">
Klicken Sie auf "EH-Vorschlaege laden" um<br />
relevante Bewertungskriterien zu finden.
</p>
</div>
)}
</div>
)
}
// =============================================================================
// SUGGESTION CARD
// =============================================================================
interface SuggestionCardProps {
suggestion: EHSuggestion
color: string
isExpanded: boolean
onToggle: () => void
onInsert?: (text: string) => void
}
function SuggestionCard({
suggestion,
color,
isExpanded,
onToggle,
onInsert,
}: SuggestionCardProps) {
const relevancePercent = Math.round(suggestion.relevance_score * 100)
return (
<div
className="rounded-xl bg-white/5 border border-white/10 overflow-hidden transition-all"
style={{ borderLeftColor: color, borderLeftWidth: '3px' }}
>
{/* Header */}
<button
onClick={onToggle}
className="w-full px-3 py-2 flex items-center justify-between hover:bg-white/5 transition-colors"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<div
className="flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center text-xs font-bold"
style={{
backgroundColor: `${color}20`,
color: color,
}}
>
{relevancePercent}%
</div>
<p className="text-white/70 text-sm truncate text-left">
{suggestion.excerpt.slice(0, 60)}...
</p>
</div>
<svg
className={`w-4 h-4 text-white/40 transition-transform flex-shrink-0 ml-2 ${
isExpanded ? 'rotate-180' : ''
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Expanded Content */}
{isExpanded && (
<div className="px-3 pb-3 space-y-2">
<p className="text-white/60 text-sm whitespace-pre-wrap">
{suggestion.excerpt}
</p>
{/* Source Attribution */}
<div className="flex items-center gap-2 text-xs text-white/40 pt-1 border-t border-white/10">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
<span>
{suggestion.source_document || 'NiBiS Kerncurriculum'} ({NIBIS_ATTRIBUTION.license})
</span>
</div>
{/* Actions */}
{onInsert && (
<div className="flex gap-2">
<button
onClick={() => onInsert(suggestion.excerpt)}
className="flex-1 px-3 py-2 rounded-lg bg-white/10 text-white/70 text-xs hover:bg-white/20 hover:text-white transition-colors flex items-center justify-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="M12 4v16m8-8H4" />
</svg>
Einfuegen
</button>
<button
onClick={() => {
// Insert with citation (CTRL-SRC-002)
const citation = `\n\n[Quelle: ${suggestion.source_document || 'NiBiS Kerncurriculum'}, ${NIBIS_ATTRIBUTION.publisher}, ${NIBIS_ATTRIBUTION.license}]`
onInsert(suggestion.excerpt + citation)
}}
className="px-3 py-2 rounded-lg bg-blue-500/20 text-blue-300 text-xs hover:bg-blue-500/30 transition-colors flex items-center justify-center gap-1"
title="Mit Quellenangabe einfuegen"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.172 13.828a4 4 0 015.656 0l4-4a4 4 0 00-5.656-5.656l-1.102 1.101" />
</svg>
+ Zitat
</button>
</div>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,194 @@
'use client'
import { useState, useEffect, useRef } from 'react'
interface GutachtenEditorProps {
value: string
onChange: (value: string) => void
onGenerate?: () => void
isGenerating?: boolean
placeholder?: string
className?: string
}
export function GutachtenEditor({
value,
onChange,
onGenerate,
isGenerating = false,
placeholder = 'Gutachten hier eingeben oder generieren lassen...',
className = '',
}: GutachtenEditorProps) {
const [isFocused, setIsFocused] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
// Auto-resize textarea
useEffect(() => {
const textarea = textareaRef.current
if (textarea) {
textarea.style.height = 'auto'
textarea.style.height = `${Math.max(200, textarea.scrollHeight)}px`
}
}, [value])
// Word count
const wordCount = value.trim() ? value.trim().split(/\s+/).length : 0
const charCount = value.length
return (
<div className={`space-y-3 ${className}`}>
{/* Header */}
<div className="flex items-center justify-between">
<h3 className="text-white font-semibold">Gutachten</h3>
<div className="flex items-center gap-2">
<span className="text-white/40 text-xs">
{wordCount} Woerter / {charCount} Zeichen
</span>
{onGenerate && (
<button
onClick={onGenerate}
disabled={isGenerating}
className="px-3 py-1.5 rounded-lg bg-gradient-to-r from-purple-500 to-pink-500 text-white text-sm font-medium hover:shadow-lg hover:shadow-purple-500/30 transition-all disabled:opacity-50 flex items-center gap-2"
>
{isGenerating ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Generiere...
</>
) : (
<>
<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>
KI Generieren
</>
)}
</button>
)}
</div>
</div>
{/* Editor */}
<div
className={`relative rounded-2xl transition-all ${
isFocused
? 'ring-2 ring-purple-500 ring-offset-2 ring-offset-slate-900'
: ''
}`}
>
<textarea
ref={textareaRef}
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
placeholder={placeholder}
className="w-full min-h-[200px] p-4 rounded-2xl bg-white/5 border border-white/10 text-white placeholder-white/30 resize-none focus:outline-none"
/>
{/* Loading Overlay */}
{isGenerating && (
<div className="absolute inset-0 bg-slate-900/80 backdrop-blur-sm rounded-2xl flex items-center justify-center">
<div className="text-center">
<div className="w-12 h-12 border-4 border-purple-500 border-t-transparent rounded-full animate-spin mx-auto mb-3" />
<p className="text-white/60 text-sm">Gutachten wird generiert...</p>
<p className="text-white/40 text-xs mt-1">Nutzt 500+ NiBiS Dokumente</p>
</div>
</div>
)}
</div>
{/* Quick Insert Buttons */}
<div className="flex flex-wrap gap-2">
<QuickInsertButton
label="Einleitung"
onClick={() => onChange(value + '\n\nEinleitung:\n')}
/>
<QuickInsertButton
label="Staerken"
onClick={() => onChange(value + '\n\nStaerken der Arbeit:\n- ')}
/>
<QuickInsertButton
label="Schwaechen"
onClick={() => onChange(value + '\n\nVerbesserungsmoeglichkeiten:\n- ')}
/>
<QuickInsertButton
label="Fazit"
onClick={() => onChange(value + '\n\nGesamteindruck:\n')}
/>
</div>
</div>
)
}
// =============================================================================
// QUICK INSERT BUTTON
// =============================================================================
interface QuickInsertButtonProps {
label: string
onClick: () => void
}
function QuickInsertButton({ label, onClick }: QuickInsertButtonProps) {
return (
<button
onClick={onClick}
className="px-3 py-1 rounded-lg bg-white/5 border border-white/10 text-white/60 text-xs hover:bg-white/10 hover:text-white transition-colors"
>
+ {label}
</button>
)
}
// =============================================================================
// GUTACHTEN PREVIEW (Read-only)
// =============================================================================
interface GutachtenPreviewProps {
value: string
className?: string
}
export function GutachtenPreview({ value, className = '' }: GutachtenPreviewProps) {
if (!value) {
return (
<div className={`p-4 rounded-2xl bg-white/5 border border-white/10 text-white/40 text-center ${className}`}>
Kein Gutachten vorhanden
</div>
)
}
// Split into paragraphs for better rendering
const paragraphs = value.split('\n\n').filter(Boolean)
return (
<div className={`p-4 rounded-2xl bg-white/5 border border-white/10 space-y-4 ${className}`}>
{paragraphs.map((paragraph, index) => {
// Check if it's a heading (ends with :)
const lines = paragraph.split('\n')
const firstLine = lines[0]
const isHeading = firstLine.endsWith(':')
if (isHeading) {
return (
<div key={index}>
<h4 className="text-white font-semibold mb-2">{firstLine}</h4>
{lines.slice(1).map((line, lineIndex) => (
<p key={lineIndex} className="text-white/70 text-sm">
{line}
</p>
))}
</div>
)
}
return (
<p key={index} className="text-white/70 text-sm whitespace-pre-wrap">
{paragraph}
</p>
)
})}
</div>
)
}

View File

@@ -0,0 +1,6 @@
export { DocumentViewer } from './DocumentViewer'
export { AnnotationLayer } from './AnnotationLayer'
export { AnnotationToolbar, AnnotationLegend } from './AnnotationToolbar'
export { CriteriaPanel, CriteriaSummary } from './CriteriaPanel'
export { GutachtenEditor, GutachtenPreview } from './GutachtenEditor'
export { EHSuggestionPanel } from './EHSuggestionPanel'

View File

@@ -0,0 +1,496 @@
'use client'
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { MOTION, SHADOWS, SHADOWS_DARK, LAYERS } from '@/lib/spatial-ui/depth-system'
import { usePerformance } from '@/lib/spatial-ui/PerformanceContext'
/**
* FloatingMessage - Cinematic message notification overlay
*
* Features:
* - Slides in from right with spring animation
* - Typewriter effect for message text
* - Glassmorphism with depth
* - Auto-dismiss with progress indicator
* - Quick reply without leaving context
* - Queue system for multiple messages
*/
export interface FloatingMessageData {
id: string
senderName: string
senderInitials: string
senderAvatar?: string
content: string
timestamp: Date
conversationId: string
isGroup?: boolean
priority?: 'normal' | 'high' | 'urgent'
}
interface FloatingMessageProps {
/** Auto-dismiss after X ms (0 = manual only) */
autoDismissMs?: number
/** Max messages in queue */
maxQueue?: number
/** Position on screen */
position?: 'top-right' | 'bottom-right' | 'top-left' | 'bottom-left'
/** Offset from edge */
offset?: { x: number; y: number }
/** Callback when message is opened */
onOpen?: (message: FloatingMessageData) => void
/** Callback when reply is sent */
onReply?: (message: FloatingMessageData, replyText: string) => void
/** Callback when dismissed */
onDismiss?: (message: FloatingMessageData) => void
}
// Demo messages for testing
const DEMO_MESSAGES: Omit<FloatingMessageData, 'id' | 'timestamp'>[] = [
{
senderName: 'Familie Mueller',
senderInitials: 'FM',
content: 'Guten Tag! Lisa hatte heute leider Fieber und konnte nicht zur Schule kommen. Koennten Sie uns bitte die Hausaufgaben mitteilen?',
conversationId: 'conv1',
isGroup: false,
priority: 'normal',
},
{
senderName: 'Kollegium 7a',
senderInitials: 'K7',
content: 'Erinnerung: Morgen findet die Klassenkonferenz um 14:00 Uhr statt. Bitte alle Notenlisten vorbereiten.',
conversationId: 'conv2',
isGroup: true,
priority: 'high',
},
{
senderName: 'Schulleitung',
senderInitials: 'SL',
content: 'Wichtig: Die Abiturklausuren muessen bis Freitag korrigiert sein. Bei Fragen wenden Sie sich an das Sekretariat.',
conversationId: 'conv3',
isGroup: false,
priority: 'urgent',
},
]
export function FloatingMessage({
autoDismissMs = 8000,
maxQueue = 5,
position = 'top-right',
offset = { x: 24, y: 80 },
onOpen,
onReply,
onDismiss,
}: FloatingMessageProps) {
const { isDark } = useTheme()
const { settings, reportAnimationStart, reportAnimationEnd } = usePerformance()
const [messageQueue, setMessageQueue] = useState<FloatingMessageData[]>([])
const [currentMessage, setCurrentMessage] = useState<FloatingMessageData | null>(null)
const [isVisible, setIsVisible] = useState(false)
const [isExiting, setIsExiting] = useState(false)
const [displayedText, setDisplayedText] = useState('')
const [isTyping, setIsTyping] = useState(false)
const [isReplying, setIsReplying] = useState(false)
const [replyText, setReplyText] = useState('')
const [dismissProgress, setDismissProgress] = useState(0)
const typewriterRef = useRef<NodeJS.Timeout | null>(null)
const dismissTimerRef = useRef<NodeJS.Timeout | null>(null)
const progressRef = useRef<NodeJS.Timeout | null>(null)
// Demo: Add messages periodically
useEffect(() => {
const addDemoMessage = (index: number) => {
const demo = DEMO_MESSAGES[index % DEMO_MESSAGES.length]
const message: FloatingMessageData = {
...demo,
id: `msg-${Date.now()}-${index}`,
timestamp: new Date(),
}
addToQueue(message)
}
// First message after 3 seconds
const timer1 = setTimeout(() => addDemoMessage(0), 3000)
// Second message after 15 seconds
const timer2 = setTimeout(() => addDemoMessage(1), 15000)
// Third message after 30 seconds
const timer3 = setTimeout(() => addDemoMessage(2), 30000)
return () => {
clearTimeout(timer1)
clearTimeout(timer2)
clearTimeout(timer3)
}
}, [])
// Add message to queue
const addToQueue = useCallback(
(message: FloatingMessageData) => {
setMessageQueue((prev) => {
if (prev.length >= maxQueue) {
return [...prev.slice(1), message]
}
return [...prev, message]
})
},
[maxQueue]
)
// Process queue
useEffect(() => {
if (!currentMessage && messageQueue.length > 0 && !isExiting) {
const next = messageQueue[0]
setMessageQueue((prev) => prev.slice(1))
setCurrentMessage(next)
setIsVisible(true)
setDisplayedText('')
setIsTyping(true)
setDismissProgress(0)
reportAnimationStart()
}
}, [currentMessage, messageQueue, isExiting, reportAnimationStart])
// Typewriter effect
useEffect(() => {
if (!currentMessage || !isTyping) return
const fullText = currentMessage.content
let charIndex = 0
if (settings.enableTypewriter) {
const speed = Math.round(25 * settings.animationSpeed)
typewriterRef.current = setInterval(() => {
charIndex++
setDisplayedText(fullText.slice(0, charIndex))
if (charIndex >= fullText.length) {
if (typewriterRef.current) clearInterval(typewriterRef.current)
setIsTyping(false)
}
}, speed)
} else {
setDisplayedText(fullText)
setIsTyping(false)
}
return () => {
if (typewriterRef.current) clearInterval(typewriterRef.current)
}
}, [currentMessage, isTyping, settings.enableTypewriter, settings.animationSpeed])
// Auto-dismiss timer with progress
useEffect(() => {
if (!currentMessage || autoDismissMs <= 0 || isTyping || isReplying) return
const startTime = Date.now()
const updateProgress = () => {
const elapsed = Date.now() - startTime
const progress = Math.min(100, (elapsed / autoDismissMs) * 100)
setDismissProgress(progress)
if (progress >= 100) {
handleDismiss()
}
}
progressRef.current = setInterval(updateProgress, 50)
dismissTimerRef.current = setTimeout(handleDismiss, autoDismissMs)
return () => {
if (progressRef.current) clearInterval(progressRef.current)
if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current)
}
}, [currentMessage, autoDismissMs, isTyping, isReplying])
// Dismiss handler
const handleDismiss = useCallback(() => {
if (progressRef.current) clearInterval(progressRef.current)
if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current)
setIsExiting(true)
const exitDuration = Math.round(MOTION.accelerate.duration * settings.animationSpeed)
setTimeout(() => {
if (currentMessage) {
onDismiss?.(currentMessage)
}
setCurrentMessage(null)
setIsVisible(false)
setIsExiting(false)
setDisplayedText('')
setReplyText('')
setIsReplying(false)
setDismissProgress(0)
reportAnimationEnd()
}, exitDuration)
}, [currentMessage, onDismiss, reportAnimationEnd, settings.animationSpeed])
// Open conversation
const handleOpen = useCallback(() => {
if (currentMessage) {
onOpen?.(currentMessage)
handleDismiss()
}
}, [currentMessage, onOpen, handleDismiss])
// Start reply
const handleReplyClick = useCallback(() => {
setIsReplying(true)
// Cancel auto-dismiss while replying
if (progressRef.current) clearInterval(progressRef.current)
if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current)
}, [])
// Send reply
const handleSendReply = useCallback(() => {
if (!replyText.trim() || !currentMessage) return
onReply?.(currentMessage, replyText)
handleDismiss()
}, [replyText, currentMessage, onReply, handleDismiss])
// Cancel reply
const handleCancelReply = useCallback(() => {
setIsReplying(false)
setReplyText('')
}, [])
// Keyboard handler
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSendReply()
}
if (e.key === 'Escape') {
if (isReplying) {
handleCancelReply()
} else {
handleDismiss()
}
}
},
[handleSendReply, isReplying, handleCancelReply, handleDismiss]
)
if (!isVisible) return null
// Position styles
const positionStyles: React.CSSProperties = {
position: 'fixed',
zIndex: LAYERS.overlay.zIndex,
...(position.includes('top') ? { top: offset.y } : { bottom: offset.y }),
...(position.includes('right') ? { right: offset.x } : { left: offset.x }),
}
// Animation styles
const animationDuration = Math.round(MOTION.decelerate.duration * settings.animationSpeed)
const exitDuration = Math.round(MOTION.accelerate.duration * settings.animationSpeed)
const transformOrigin = position.includes('right') ? 'right' : 'left'
const slideDirection = position.includes('right') ? 'translateX(120%)' : 'translateX(-120%)'
// Priority color
const priorityColor =
currentMessage?.priority === 'urgent'
? 'from-red-500 to-orange-500'
: currentMessage?.priority === 'high'
? 'from-amber-500 to-yellow-500'
: 'from-purple-500 to-pink-500'
// Material styles - Ultra transparent for floating effect (4%)
const cardBg = isDark
? 'rgba(255, 255, 255, 0.04)'
: 'rgba(255, 255, 255, 0.08)'
const shadow = '0 8px 32px rgba(0, 0, 0, 0.3)'
const blur = settings.enableBlur ? `blur(${Math.round(24 * settings.blurIntensity)}px) saturate(180%)` : 'none'
return (
<div
style={{
...positionStyles,
transform: isExiting ? slideDirection : 'translateX(0)',
opacity: isExiting ? 0 : 1,
transition: `
transform ${isExiting ? exitDuration : animationDuration}ms ${
isExiting ? MOTION.accelerate.easing : MOTION.spring.easing
},
opacity ${isExiting ? exitDuration : animationDuration}ms ${MOTION.standard.easing}
`,
transformOrigin,
}}
>
<div
className="w-96 max-w-[calc(100vw-3rem)] rounded-3xl overflow-hidden"
style={{
background: cardBg,
backdropFilter: blur,
WebkitBackdropFilter: blur,
boxShadow: shadow,
border: '1px solid rgba(255, 255, 255, 0.12)',
}}
>
{/* Progress bar */}
{autoDismissMs > 0 && !isReplying && (
<div className="h-1 w-full bg-black/5 dark:bg-white/5">
<div
className={`h-full bg-gradient-to-r ${priorityColor} transition-all duration-100`}
style={{ width: `${100 - dismissProgress}%` }}
/>
</div>
)}
<div className="p-5">
{/* Header */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
{/* Avatar */}
<div
className={`w-12 h-12 rounded-2xl flex items-center justify-center text-lg font-semibold text-white bg-gradient-to-br ${priorityColor}`}
style={{
boxShadow: isDark
? '0 4px 12px rgba(0,0,0,0.3)'
: '0 4px 12px rgba(0,0,0,0.15)',
}}
>
{currentMessage?.senderInitials}
</div>
<div>
<h3 className="font-semibold text-white">
{currentMessage?.senderName}
</h3>
<p className="text-xs text-white/50">
{currentMessage?.isGroup ? 'Gruppenchat' : 'Direktnachricht'} &bull; Jetzt
</p>
</div>
</div>
{/* Close button */}
<button
onClick={handleDismiss}
className="p-2 rounded-xl transition-all hover:bg-white/10 text-white/50"
>
<svg className="w-5 h-5" 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>
{/* Message content */}
<div
className="mb-4 p-4 rounded-2xl"
style={{ background: 'rgba(255, 255, 255, 0.06)' }}
>
<p className="text-sm leading-relaxed text-white/90">
{displayedText}
{isTyping && (
<span
className={`inline-block w-0.5 h-4 ml-0.5 animate-pulse ${
isDark ? 'bg-purple-400' : 'bg-purple-600'
}`}
/>
)}
</p>
</div>
{/* Reply input */}
{isReplying && (
<div className="mb-4">
<textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Antwort schreiben..."
autoFocus
rows={2}
className="w-full px-4 py-3 rounded-xl text-sm resize-none transition-all focus:outline-none focus:ring-2 bg-white/10 border border-white/20 text-white placeholder-white/40 focus:ring-purple-500/50"
/>
<p className="text-xs mt-1.5 text-white/40">
Enter zum Senden &bull; Esc zum Abbrechen
</p>
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2">
{isReplying ? (
<>
<button
onClick={handleSendReply}
disabled={!replyText.trim()}
className={`flex-1 py-2.5 rounded-xl font-medium transition-all disabled:opacity-50 bg-gradient-to-r ${priorityColor} text-white hover:shadow-lg`}
>
<span className="flex items-center justify-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="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
/>
</svg>
Senden
</span>
</button>
<button
onClick={handleCancelReply}
className="px-4 py-2.5 rounded-xl font-medium transition-all bg-white/10 text-white/80 hover:bg-white/20"
>
Abbrechen
</button>
</>
) : (
<>
<button
onClick={handleReplyClick}
className={`flex-1 py-2.5 rounded-xl font-medium transition-all bg-gradient-to-r ${priorityColor} text-white hover:shadow-lg`}
>
<span className="flex items-center justify-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="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
/>
</svg>
Antworten
</span>
</button>
<button
onClick={handleOpen}
className="px-4 py-2.5 rounded-xl font-medium transition-all bg-white/10 text-white/80 hover:bg-white/20"
>
Oeffnen
</button>
<button
onClick={handleDismiss}
className="px-4 py-2.5 rounded-xl font-medium transition-all bg-white/10 text-white/80 hover:bg-white/20"
>
Spaeter
</button>
</>
)}
</div>
</div>
{/* Queue indicator */}
{messageQueue.length > 0 && (
<div
className="px-5 py-3 text-center text-sm font-medium border-t bg-white/5 text-purple-300 border-white/10"
>
+{messageQueue.length} weitere Nachricht{messageQueue.length > 1 ? 'en' : ''}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,314 @@
'use client'
import React, { useState, useRef, useCallback, useMemo } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import {
SHADOWS,
SHADOWS_DARK,
MOTION,
PARALLAX,
MATERIALS,
calculateParallax,
} from '@/lib/spatial-ui/depth-system'
import { usePerformance } from '@/lib/spatial-ui/PerformanceContext'
/**
* SpatialCard - A card component with depth-aware interactions
*
* Features:
* - Dynamic shadows that respond to hover/active states
* - Subtle parallax effect based on cursor position
* - Material-based styling (glass, solid, acrylic)
* - Elevation changes with physics-based motion
* - Performance-adaptive (degrades gracefully)
*/
export type CardMaterial = 'solid' | 'glass' | 'thinGlass' | 'thickGlass' | 'acrylic'
export type CardElevation = 'flat' | 'raised' | 'floating'
interface SpatialCardProps {
children: React.ReactNode
/** Material style */
material?: CardMaterial
/** Base elevation level */
elevation?: CardElevation
/** Enable hover lift effect */
hoverLift?: boolean
/** Enable subtle parallax on hover */
parallax?: boolean
/** Parallax intensity (uses PARALLAX constants) */
parallaxIntensity?: number
/** Click handler */
onClick?: () => void
/** Additional CSS classes */
className?: string
/** Custom padding */
padding?: 'none' | 'sm' | 'md' | 'lg'
/** Border radius */
rounded?: 'none' | 'md' | 'lg' | 'xl' | '2xl' | '3xl'
/** Glow color on hover (RGB values) */
glowColor?: string
/** Disable all effects */
static?: boolean
}
const PADDING_MAP = {
none: '',
sm: 'p-3',
md: 'p-4',
lg: 'p-6',
}
const ROUNDED_MAP = {
none: 'rounded-none',
md: 'rounded-md',
lg: 'rounded-lg',
xl: 'rounded-xl',
'2xl': 'rounded-2xl',
'3xl': 'rounded-3xl',
}
const ELEVATION_SHADOWS = {
flat: { rest: 'none', hover: 'sm', active: 'xs' },
raised: { rest: 'sm', hover: 'lg', active: 'md' },
floating: { rest: 'lg', hover: 'xl', active: 'lg' },
}
export function SpatialCard({
children,
material = 'glass',
elevation = 'raised',
hoverLift = true,
parallax = false,
parallaxIntensity = PARALLAX.subtle,
onClick,
className = '',
padding = 'md',
rounded = '2xl',
glowColor,
static: isStatic = false,
}: SpatialCardProps) {
const { isDark } = useTheme()
const { settings, reportAnimationStart, reportAnimationEnd, canStartAnimation } = usePerformance()
const [isHovered, setIsHovered] = useState(false)
const [isActive, setIsActive] = useState(false)
const [parallaxOffset, setParallaxOffset] = useState({ x: 0, y: 0 })
const cardRef = useRef<HTMLDivElement>(null)
const animatingRef = useRef(false)
// Determine if effects should be enabled
const effectsEnabled = !isStatic && settings.enableShadows
const parallaxEnabled = parallax && settings.enableParallax && !isStatic
// Shadow key type (excludes glow function)
type ShadowKey = 'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
// Get current shadow based on state
const currentShadow = useMemo((): string => {
if (!effectsEnabled) return 'none'
const shadows = ELEVATION_SHADOWS[elevation]
const shadowKey = (isActive ? shadows.active : isHovered ? shadows.hover : shadows.rest) as ShadowKey
const shadowSet = isDark ? SHADOWS_DARK : SHADOWS
// Get base shadow as string (not the glow function)
const baseShadow = shadowSet[shadowKey] as string
// Add glow effect on hover if specified
if (isHovered && glowColor && settings.enableShadows) {
const glowFn = isDark ? SHADOWS_DARK.glow : SHADOWS.glow
return `${baseShadow}, ${glowFn(glowColor, 0.2)}`
}
return baseShadow
}, [effectsEnabled, elevation, isActive, isHovered, isDark, glowColor, settings.enableShadows])
// Get material styles
const materialStyles = useMemo(() => {
const mat = MATERIALS[material]
const bg = isDark ? mat.backgroundDark : mat.background
const border = isDark ? mat.borderDark : mat.border
const blur = settings.enableBlur ? mat.blur * settings.blurIntensity : 0
return {
background: bg,
backdropFilter: blur > 0 ? `blur(${blur}px)` : 'none',
WebkitBackdropFilter: blur > 0 ? `blur(${blur}px)` : 'none',
borderColor: border,
}
}, [material, isDark, settings.enableBlur, settings.blurIntensity])
// Calculate transform based on state
const transform = useMemo(() => {
if (isStatic || !settings.enableSpringAnimations) {
return 'translateY(0) scale(1)'
}
let y = 0
let scale = 1
if (isActive) {
y = 1
scale = 0.98
} else if (isHovered && hoverLift) {
y = -3
scale = 1.01
}
// Add parallax offset
const px = parallaxEnabled ? parallaxOffset.x : 0
const py = parallaxEnabled ? parallaxOffset.y : 0
return `translateY(${y}px) translateX(${px}px) translateZ(${py}px) scale(${scale})`
}, [isStatic, settings.enableSpringAnimations, isActive, isHovered, hoverLift, parallaxEnabled, parallaxOffset])
// Get transition timing
const transitionDuration = useMemo(() => {
const base = isActive ? MOTION.micro.duration : MOTION.standard.duration
return Math.round(base * settings.animationSpeed)
}, [isActive, settings.animationSpeed])
// Handlers
const handleMouseEnter = useCallback(() => {
if (isStatic) return
if (canStartAnimation() && !animatingRef.current) {
animatingRef.current = true
reportAnimationStart()
}
setIsHovered(true)
}, [isStatic, canStartAnimation, reportAnimationStart])
const handleMouseLeave = useCallback(() => {
setIsHovered(false)
setParallaxOffset({ x: 0, y: 0 })
if (animatingRef.current) {
animatingRef.current = false
reportAnimationEnd()
}
}, [reportAnimationEnd])
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
if (!parallaxEnabled || !cardRef.current) return
const rect = cardRef.current.getBoundingClientRect()
const offset = calculateParallax(
e.clientX,
e.clientY,
rect,
parallaxIntensity * settings.parallaxIntensity
)
setParallaxOffset(offset)
},
[parallaxEnabled, parallaxIntensity, settings.parallaxIntensity]
)
const handleMouseDown = useCallback(() => {
if (!isStatic) setIsActive(true)
}, [isStatic])
const handleMouseUp = useCallback(() => {
setIsActive(false)
}, [])
const handleClick = useCallback(() => {
onClick?.()
}, [onClick])
return (
<div
ref={cardRef}
className={`
border
${PADDING_MAP[padding]}
${ROUNDED_MAP[rounded]}
${onClick ? 'cursor-pointer' : ''}
${className}
`}
style={{
...materialStyles,
boxShadow: currentShadow,
transform,
transition: `
box-shadow ${transitionDuration}ms ${MOTION.standard.easing},
transform ${transitionDuration}ms ${settings.enableSpringAnimations ? MOTION.spring.easing : MOTION.standard.easing},
background ${transitionDuration}ms ${MOTION.standard.easing}
`,
willChange: effectsEnabled ? 'transform, box-shadow' : 'auto',
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onMouseMove={handleMouseMove}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onClick={handleClick}
>
{children}
</div>
)
}
/**
* SpatialCardHeader - Header section for SpatialCard
*/
export function SpatialCardHeader({
children,
className = '',
}: {
children: React.ReactNode
className?: string
}) {
const { isDark } = useTheme()
return (
<div
className={`
flex items-center justify-between mb-4
${isDark ? 'text-white' : 'text-slate-900'}
${className}
`}
>
{children}
</div>
)
}
/**
* SpatialCardContent - Main content area
*/
export function SpatialCardContent({
children,
className = '',
}: {
children: React.ReactNode
className?: string
}) {
return <div className={className}>{children}</div>
}
/**
* SpatialCardFooter - Footer section
*/
export function SpatialCardFooter({
children,
className = '',
}: {
children: React.ReactNode
className?: string
}) {
const { isDark } = useTheme()
return (
<div
className={`
mt-4 pt-4 border-t
${isDark ? 'border-white/10' : 'border-slate-200'}
${className}
`}
>
{children}
</div>
)
}

View File

@@ -0,0 +1,13 @@
/**
* Spatial UI Components
*
* A collection of components built on the Spatial UI design system.
* These components implement depth-aware interactions, adaptive quality,
* and cinematic visual effects.
*/
export { SpatialCard, SpatialCardHeader, SpatialCardContent, SpatialCardFooter } from './SpatialCard'
export type { CardMaterial, CardElevation } from './SpatialCard'
export { FloatingMessage } from './FloatingMessage'
export type { FloatingMessageData } from './FloatingMessage'

View File

@@ -0,0 +1,244 @@
'use client'
import { useEffect, useRef, useState, useCallback } from 'react'
import { VoiceAPI, VoiceMessage, VoiceTask } from '@/lib/voice/voice-api'
import { VoiceIndicator } from './VoiceIndicator'
interface VoiceCaptureProps {
onTranscript?: (text: string, isFinal: boolean) => void
onIntent?: (intent: string, parameters: Record<string, unknown>) => void
onResponse?: (text: string) => void
onTaskCreated?: (task: VoiceTask) => void
onError?: (error: Error) => void
className?: string
}
/**
* Voice capture component with microphone button
* Handles WebSocket connection and audio streaming
*/
export function VoiceCapture({
onTranscript,
onIntent,
onResponse,
onTaskCreated,
onError,
className = '',
}: VoiceCaptureProps) {
const voiceApiRef = useRef<VoiceAPI | null>(null)
const [isInitialized, setIsInitialized] = useState(false)
const [isConnected, setIsConnected] = useState(false)
const [isListening, setIsListening] = useState(false)
const [status, setStatus] = useState<string>('idle')
const [audioLevel, setAudioLevel] = useState(0)
const [transcript, setTranscript] = useState<string>('')
const [error, setError] = useState<string | null>(null)
// Initialize voice API
useEffect(() => {
const init = async () => {
try {
const api = new VoiceAPI()
await api.initialize()
voiceApiRef.current = api
// Set up event handlers
api.setOnMessage(handleMessage)
api.setOnError(handleError)
api.setOnStatusChange(handleStatusChange)
setIsInitialized(true)
} catch (e) {
console.error('Failed to initialize voice API:', e)
setError('Sprachdienst konnte nicht initialisiert werden')
}
}
init()
return () => {
voiceApiRef.current?.disconnect()
}
}, [])
const handleMessage = useCallback(
(message: VoiceMessage) => {
switch (message.type) {
case 'transcript':
setTranscript(message.text)
onTranscript?.(message.text, message.final)
break
case 'intent':
onIntent?.(message.intent, message.parameters)
break
case 'response':
onResponse?.(message.text)
break
case 'task_created':
onTaskCreated?.({
id: message.task_id,
session_id: '',
type: message.task_type,
state: message.state,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
result_available: false,
})
break
case 'error':
setError(message.message)
onError?.(new Error(message.message))
break
}
},
[onTranscript, onIntent, onResponse, onTaskCreated, onError]
)
const handleError = useCallback(
(error: Error) => {
setError(error.message)
setIsListening(false)
onError?.(error)
},
[onError]
)
const handleStatusChange = useCallback((newStatus: string) => {
setStatus(newStatus)
if (newStatus === 'connected') {
setIsConnected(true)
} else if (newStatus === 'disconnected') {
setIsConnected(false)
setIsListening(false)
} else if (newStatus === 'listening') {
setIsListening(true)
} else if (newStatus === 'processing') {
setIsListening(false)
}
}, [])
const toggleListening = async () => {
if (!voiceApiRef.current) return
try {
setError(null)
if (isListening) {
// Stop listening
voiceApiRef.current.stopCapture()
setIsListening(false)
} else {
// Start listening
if (!isConnected) {
await voiceApiRef.current.connect()
}
await voiceApiRef.current.startCapture()
setIsListening(true)
}
} catch (e) {
console.error('Failed to toggle listening:', e)
setError('Mikrofon konnte nicht aktiviert werden')
}
}
const interrupt = () => {
voiceApiRef.current?.interrupt()
}
if (!isInitialized) {
return (
<div className={`flex items-center gap-2 ${className}`}>
<div className="animate-spin w-6 h-6 border-2 border-gray-300 border-t-blue-500 rounded-full" />
<span className="text-sm text-gray-500">Initialisiere...</span>
</div>
)
}
return (
<div className={`flex flex-col gap-4 ${className}`}>
{/* Error display */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-2 rounded-lg text-sm">
{error}
</div>
)}
{/* Main controls */}
<div className="flex items-center gap-4">
{/* Microphone button */}
<button
onClick={toggleListening}
disabled={status === 'processing'}
className={`
relative w-16 h-16 rounded-full flex items-center justify-center
transition-all duration-200 focus:outline-none focus:ring-4
${
isListening
? 'bg-red-500 hover:bg-red-600 focus:ring-red-200'
: 'bg-blue-500 hover:bg-blue-600 focus:ring-blue-200'
}
${status === 'processing' ? 'opacity-50 cursor-not-allowed' : ''}
`}
>
{/* Microphone icon */}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="white"
className="w-8 h-8"
>
{isListening ? (
// Stop icon
<path d="M6 6h12v12H6z" />
) : (
// Microphone icon
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1 1.93c-3.94-.49-7-3.85-7-7.93h2c0 2.76 2.24 5 5 5s5-2.24 5-5h2c0 4.08-3.06 7.44-7 7.93V18h4v2H8v-2h4v-2.07z" />
)}
</svg>
{/* Pulsing ring when listening */}
{isListening && (
<span className="absolute inset-0 rounded-full animate-ping bg-red-400 opacity-25" />
)}
</button>
{/* Status indicator */}
<VoiceIndicator
isListening={isListening}
audioLevel={audioLevel}
status={status}
/>
{/* Interrupt button (when responding) */}
{status === 'responding' && (
<button
onClick={interrupt}
className="px-4 py-2 text-sm bg-gray-200 hover:bg-gray-300 rounded-lg"
>
Unterbrechen
</button>
)}
</div>
{/* Transcript display */}
{transcript && (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<p className="text-sm text-gray-500 mb-1">Erkannt:</p>
<p className="text-gray-800">{transcript}</p>
</div>
)}
{/* Instructions */}
<p className="text-xs text-gray-400">
{isListening
? 'Sprechen Sie jetzt... Klicken Sie erneut zum Beenden.'
: 'Klicken Sie auf das Mikrofon und sprechen Sie Ihren Befehl.'}
</p>
</div>
)
}

View File

@@ -0,0 +1,337 @@
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { VoiceAPI, VoiceMessage, VoiceTask } from '@/lib/voice/voice-api'
import { VoiceIndicator } from './VoiceIndicator'
interface Message {
id: string
role: 'user' | 'assistant'
content: string
timestamp: Date
intent?: string
task?: VoiceTask
}
interface VoiceCommandBarProps {
onTaskCreated?: (task: VoiceTask) => void
onTaskApproved?: (taskId: string) => void
className?: string
}
/**
* Full voice command bar with conversation history
* Shows transcript, responses, and pending tasks
*/
export function VoiceCommandBar({
onTaskCreated,
onTaskApproved,
className = '',
}: VoiceCommandBarProps) {
const voiceApiRef = useRef<VoiceAPI | null>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
const [isInitialized, setIsInitialized] = useState(false)
const [isConnected, setIsConnected] = useState(false)
const [isListening, setIsListening] = useState(false)
const [status, setStatus] = useState<string>('idle')
const [messages, setMessages] = useState<Message[]>([])
const [pendingTasks, setPendingTasks] = useState<VoiceTask[]>([])
const [error, setError] = useState<string | null>(null)
// Auto-scroll to bottom
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
// Initialize voice API
useEffect(() => {
const init = async () => {
try {
const api = new VoiceAPI()
await api.initialize()
voiceApiRef.current = api
api.setOnMessage(handleMessage)
api.setOnError(handleError)
api.setOnStatusChange(handleStatusChange)
setIsInitialized(true)
} catch (e) {
console.error('Failed to initialize:', e)
setError('Sprachdienst konnte nicht initialisiert werden')
}
}
init()
return () => {
voiceApiRef.current?.disconnect()
}
}, [])
const handleMessage = useCallback(
(message: VoiceMessage) => {
switch (message.type) {
case 'transcript':
if (message.final) {
setMessages((prev) => [
...prev,
{
id: `msg-${Date.now()}`,
role: 'user',
content: message.text,
timestamp: new Date(),
},
])
}
break
case 'intent':
// Update last user message with intent
setMessages((prev) => {
const updated = [...prev]
const lastUserMsg = [...updated].reverse().find((m) => m.role === 'user')
if (lastUserMsg) {
lastUserMsg.intent = message.intent
}
return updated
})
break
case 'response':
setMessages((prev) => [
...prev,
{
id: `msg-${Date.now()}`,
role: 'assistant',
content: message.text,
timestamp: new Date(),
},
])
break
case 'task_created':
const task: VoiceTask = {
id: message.task_id,
session_id: '',
type: message.task_type,
state: message.state,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
result_available: false,
}
setPendingTasks((prev) => [...prev, task])
onTaskCreated?.(task)
// Update last assistant message with task
setMessages((prev) => {
const updated = [...prev]
const lastAssistantMsg = [...updated].reverse().find((m) => m.role === 'assistant')
if (lastAssistantMsg) {
lastAssistantMsg.task = task
}
return updated
})
break
case 'error':
setError(message.message)
break
}
},
[onTaskCreated]
)
const handleError = useCallback((error: Error) => {
setError(error.message)
setIsListening(false)
}, [])
const handleStatusChange = useCallback((newStatus: string) => {
setStatus(newStatus)
setIsConnected(newStatus !== 'idle' && newStatus !== 'disconnected')
setIsListening(newStatus === 'listening')
}, [])
const toggleListening = async () => {
if (!voiceApiRef.current) return
try {
setError(null)
if (isListening) {
voiceApiRef.current.stopCapture()
} else {
if (!isConnected) {
await voiceApiRef.current.connect()
}
await voiceApiRef.current.startCapture()
}
} catch (e) {
console.error('Failed to toggle listening:', e)
setError('Mikrofon konnte nicht aktiviert werden')
}
}
const approveTask = async (taskId: string) => {
try {
await voiceApiRef.current?.approveTask(taskId)
setPendingTasks((prev) => prev.filter((t) => t.id !== taskId))
onTaskApproved?.(taskId)
} catch (e) {
console.error('Failed to approve task:', e)
}
}
const rejectTask = async (taskId: string) => {
try {
await voiceApiRef.current?.rejectTask(taskId)
setPendingTasks((prev) => prev.filter((t) => t.id !== taskId))
} catch (e) {
console.error('Failed to reject task:', e)
}
}
if (!isInitialized) {
return (
<div className={`flex items-center justify-center p-8 ${className}`}>
<div className="animate-spin w-8 h-8 border-2 border-gray-300 border-t-blue-500 rounded-full" />
</div>
)
}
return (
<div
className={`flex flex-col bg-white rounded-xl shadow-lg overflow-hidden ${className}`}
>
{/* Header */}
<div className="flex items-center justify-between p-4 bg-gray-50 border-b">
<div className="flex items-center gap-3">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-6 h-6 text-blue-500"
>
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
</svg>
<span className="font-medium text-gray-800">Breakpilot Voice</span>
</div>
<VoiceIndicator isListening={isListening} status={status} />
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4 min-h-[200px] max-h-[400px]">
{messages.length === 0 ? (
<div className="text-center text-gray-400 py-8">
<p className="mb-2">Willkommen bei Breakpilot Voice!</p>
<p className="text-sm">
Klicken Sie auf das Mikrofon und sprechen Sie Ihren Befehl.
</p>
</div>
) : (
messages.map((msg) => (
<div
key={msg.id}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[80%] rounded-lg px-4 py-2 ${
msg.role === 'user'
? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-800'
}`}
>
<p>{msg.content}</p>
{msg.intent && (
<p className="text-xs mt-1 opacity-70">
Intent: {msg.intent}
</p>
)}
{msg.task && msg.task.state === 'ready' && (
<div className="flex gap-2 mt-2">
<button
onClick={() => approveTask(msg.task!.id)}
className="px-2 py-1 text-xs bg-green-500 text-white rounded hover:bg-green-600"
>
Bestaetigen
</button>
<button
onClick={() => rejectTask(msg.task!.id)}
className="px-2 py-1 text-xs bg-gray-300 text-gray-700 rounded hover:bg-gray-400"
>
Abbrechen
</button>
</div>
)}
</div>
</div>
))
)}
<div ref={messagesEndRef} />
</div>
{/* Error */}
{error && (
<div className="px-4 py-2 bg-red-50 border-t border-red-200 text-red-700 text-sm">
{error}
</div>
)}
{/* Input area */}
<div className="p-4 bg-gray-50 border-t">
<div className="flex items-center gap-4">
{/* Microphone button */}
<button
onClick={toggleListening}
disabled={status === 'processing'}
className={`
w-12 h-12 rounded-full flex items-center justify-center
transition-all duration-200 focus:outline-none focus:ring-4
${
isListening
? 'bg-red-500 hover:bg-red-600 focus:ring-red-200'
: 'bg-blue-500 hover:bg-blue-600 focus:ring-blue-200'
}
${status === 'processing' ? 'opacity-50 cursor-not-allowed' : ''}
`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="white"
className="w-6 h-6"
>
{isListening ? (
<path d="M6 6h12v12H6z" />
) : (
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1 1.93c-3.94-.49-7-3.85-7-7.93h2c0 2.76 2.24 5 5 5s5-2.24 5-5h2c0 4.08-3.06 7.44-7 7.93V18h4v2H8v-2h4v-2.07z" />
)}
</svg>
</button>
{/* Text hint */}
<div className="flex-1 text-sm text-gray-500">
{isListening
? 'Ich hoere zu... Sprechen Sie jetzt.'
: status === 'processing'
? 'Verarbeite...'
: 'Tippen Sie auf das Mikrofon um zu sprechen'}
</div>
{/* Pending tasks indicator */}
{pendingTasks.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">
{pendingTasks.length} Aufgabe(n)
</span>
<span className="w-2 h-2 bg-yellow-400 rounded-full animate-pulse" />
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,90 @@
'use client'
import { useEffect, useState } from 'react'
interface VoiceIndicatorProps {
isListening: boolean
audioLevel?: number // 0-100
status?: string
}
/**
* Visual indicator for voice activity
* Shows audio level and status
*/
export function VoiceIndicator({
isListening,
audioLevel = 0,
status = 'idle',
}: VoiceIndicatorProps) {
const [bars, setBars] = useState<number[]>([0, 0, 0, 0, 0])
// Animate bars based on audio level
useEffect(() => {
if (!isListening) {
setBars([0, 0, 0, 0, 0])
return
}
const interval = setInterval(() => {
setBars((prev) =>
prev.map(() => {
const base = audioLevel / 100
const variance = Math.random() * 0.4
return Math.min(1, base + variance)
})
)
}, 100)
return () => clearInterval(interval)
}, [isListening, audioLevel])
const statusColors: Record<string, string> = {
idle: 'bg-gray-400',
connected: 'bg-blue-500',
listening: 'bg-green-500',
processing: 'bg-yellow-500',
responding: 'bg-purple-500',
error: 'bg-red-500',
}
const statusLabels: Record<string, string> = {
idle: 'Bereit',
connected: 'Verbunden',
listening: 'Hoert zu...',
processing: 'Verarbeitet...',
responding: 'Antwortet...',
error: 'Fehler',
}
return (
<div className="flex items-center gap-3">
{/* Status dot */}
<div
className={`w-3 h-3 rounded-full ${statusColors[status] || statusColors.idle} ${
isListening ? 'animate-pulse' : ''
}`}
/>
{/* Audio level bars */}
<div className="flex items-end gap-0.5 h-6">
{bars.map((level, i) => (
<div
key={i}
className={`w-1 rounded-full transition-all duration-100 ${
isListening ? 'bg-green-500' : 'bg-gray-300'
}`}
style={{
height: `${Math.max(4, level * 24)}px`,
}}
/>
))}
</div>
{/* Status text */}
<span className="text-sm text-gray-600">
{statusLabels[status] || status}
</span>
</div>
)
}

View File

@@ -0,0 +1,6 @@
/**
* Voice Components
*/
export { VoiceCapture } from './VoiceCapture'
export { VoiceIndicator } from './VoiceIndicator'
export { VoiceCommandBar } from './VoiceCommandBar'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,312 @@
'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
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, 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>
)}
<div className={`border-t ${dividerStyle}`} />
{/* Table Tool */}
<ToolButton
tool="table"
isActive={activeTool === 'table'}
onClick={() => handleToolClick('table')}
isDark={isDark}
label="Tabelle"
icon={
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 10h18M3 14h18m-9-4v8m-7 0h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
}
/>
<div className={`border-t ${dividerStyle} mt-auto`} />
{/* Undo/Redo */}
<button
onClick={undo}
disabled={!canUndo}
title="Rückgängig (Ctrl+Z)"
className={`w-12 h-12 flex items-center justify-center rounded-xl transition-all ${
canUndo
? isDark
? 'text-white/70 hover:bg-white/10 hover:text-white'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
: isDark
? 'text-white/30 cursor-not-allowed'
: 'text-slate-300 cursor-not-allowed'
}`}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
</svg>
</button>
<button
onClick={redo}
disabled={!canRedo}
title="Wiederholen (Ctrl+Y)"
className={`w-12 h-12 flex items-center justify-center rounded-xl transition-all ${
canRedo
? isDark
? 'text-white/70 hover:bg-white/10 hover:text-white'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
: isDark
? 'text-white/30 cursor-not-allowed'
: 'text-slate-300 cursor-not-allowed'
}`}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 10h-10a8 8 0 00-8 8v2M21 10l-6 6m6-6l-6-6" />
</svg>
</button>
</div>
)
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,15 @@
/**
* 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'