Initial commit: breakpilot-lehrer - Lehrer KI Platform

Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website,
Klausur-Service, School-Service, Voice-Service, Geo-Service,
BreakPilot Drive, Agent-Core

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Boenisch
2026-02-11 23:47:26 +01:00
commit 5a31f52310
1224 changed files with 425430 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
.next
.git
*.md
.env*.local
+55
View File
@@ -0,0 +1,55 @@
#!/bin/bash
#
# Git pre-push hook for studio-v2
# Runs Playwright E2E tests before pushing changes
#
# To install, run:
# chmod +x .git-hooks/pre-push
# git config core.hooksPath .git-hooks
#
# To skip tests (emergency only):
# git push --no-verify
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${YELLOW}Running E2E tests before push...${NC}"
echo ""
# Navigate to studio-v2 directory
cd "$(dirname "$0")/../.." || exit 1
# Check if dev server is running on port 3001
if ! curl -s http://localhost:3001 > /dev/null 2>&1; then
echo -e "${YELLOW}Dev server not running. Checking remote...${NC}"
# Check if macmini server is available
if curl -s https://macmini/korrektur > /dev/null 2>&1; then
echo -e "${GREEN}Using remote server at https://macmini${NC}"
export PLAYWRIGHT_BASE_URL="https://macmini"
else
echo -e "${RED}No server available. Start dev server with 'npm run dev' or deploy to macmini.${NC}"
echo -e "${YELLOW}Skipping tests...${NC}"
exit 0
fi
fi
# Run tests
echo -e "${YELLOW}Running Playwright tests...${NC}"
npm run test:e2e -- --reporter=list 2>&1
# Check test result
if [ $? -eq 0 ]; then
echo ""
echo -e "${GREEN}All tests passed! Pushing...${NC}"
exit 0
else
echo ""
echo -e "${RED}Tests failed! Push aborted.${NC}"
echo -e "${YELLOW}Fix the failing tests and try again.${NC}"
echo -e "${YELLOW}To push anyway (not recommended): git push --no-verify${NC}"
exit 1
fi
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
#
# Setup git hooks for studio-v2
#
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
echo "Setting up git hooks for studio-v2..."
# Make hooks executable
chmod +x "$SCRIPT_DIR/pre-push"
# Configure git to use custom hooks directory
git config core.hooksPath .git-hooks
echo "Git hooks installed successfully!"
echo ""
echo "Hooks enabled:"
echo " - pre-push: Runs E2E tests before pushing"
echo ""
echo "To disable hooks temporarily: git push --no-verify"
+49
View File
@@ -0,0 +1,49 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package.json package-lock.json* ./
# Install dependencies
RUN npm install
# Copy source files
COPY . .
# Build arguments for environment variables (needed at build time for Next.js)
ARG NEXT_PUBLIC_VOICE_SERVICE_URL
ARG NEXT_PUBLIC_KLAUSUR_SERVICE_URL
# Set environment variables for build
ENV NEXT_PUBLIC_VOICE_SERVICE_URL=$NEXT_PUBLIC_VOICE_SERVICE_URL
ENV NEXT_PUBLIC_KLAUSUR_SERVICE_URL=$NEXT_PUBLIC_KLAUSUR_SERVICE_URL
# Build the application
RUN npm run build
# Production stage
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# Create non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy built application
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3001
ENV PORT=3001
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
+112
View File
@@ -0,0 +1,112 @@
'use client'
import Link from 'next/link'
import { useLanguage } from '@/lib/LanguageContext'
import { useTheme } from '@/lib/ThemeContext'
import { Footer } from '@/components/Footer'
export default function AGBPage() {
const { t } = useLanguage()
const { isDark } = useTheme()
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'
}`}>
<div className="flex-1 p-8">
<div className="max-w-4xl mx-auto">
{/* Back Link */}
<Link
href="/"
className={`inline-flex items-center gap-2 mb-8 transition-colors ${
isDark ? 'text-white/60 hover:text-white' : 'text-slate-600 hover:text-slate-900'
}`}
>
<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>
{t('back_to_selection')}
</Link>
{/* Content Card */}
<div className={`backdrop-blur-xl border rounded-3xl p-8 ${
isDark
? 'bg-white/10 border-white/20'
: 'bg-white/70 border-black/10 shadow-lg'
}`}>
<h1 className={`text-3xl font-bold mb-8 ${isDark ? 'text-white' : 'text-slate-900'}`}>
{t('legal')}
</h1>
<div className={`space-y-6 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
<section>
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
§ 1 Geltungsbereich
</h2>
<p>
Diese Allgemeinen Geschäftsbedingungen gelten für alle Verträge zwischen BreakPilot GmbH
und dem Kunden über die Nutzung der BreakPilot Studio Plattform.
</p>
</section>
<section>
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
§ 2 Vertragsgegenstand
</h2>
<p>
Gegenstand des Vertrages ist die Bereitstellung der BreakPilot Studio Software als
webbasierte Anwendung (Software as a Service) zur Unterstützung von Lehrkräften
bei der Korrektur von Klausuren und Prüfungsarbeiten.
</p>
</section>
<section>
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
§ 3 Nutzungsrechte
</h2>
<p>
Der Kunde erhält das nicht-exklusive, nicht übertragbare Recht, die Software
während der Vertragslaufzeit bestimmungsgemäß zu nutzen.
</p>
</section>
<section>
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
§ 4 Datenschutz
</h2>
<p>
Die Verarbeitung personenbezogener Daten erfolgt gemäß unserer Datenschutzerklärung
und den Vorgaben der DSGVO. Für die Verarbeitung von Schülerdaten wird ein
Auftragsverarbeitungsvertrag (AVV) geschlossen.
</p>
</section>
<section>
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
§ 5 Haftung
</h2>
<p>
Die Haftung richtet sich nach den gesetzlichen Bestimmungen mit den in diesen AGB
enthaltenen Einschränkungen und Ergänzungen.
</p>
</section>
<div className={`mt-8 p-4 rounded-2xl ${
isDark ? 'bg-yellow-500/20 border border-yellow-500/30' : 'bg-yellow-50 border border-yellow-200'
}`}>
<p className={`text-sm ${isDark ? 'text-yellow-200' : 'text-yellow-800'}`}>
Hinweis: Dies ist ein Platzhalter. Bitte ersetzen Sie diesen Text durch Ihre vollständigen
Allgemeinen Geschäftsbedingungen.
</p>
</div>
</div>
</div>
</div>
</div>
<Footer />
</div>
)
}
File diff suppressed because it is too large Load Diff
+466
View File
@@ -0,0 +1,466 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useTheme } from '@/lib/ThemeContext'
import { useLanguage } from '@/lib/LanguageContext'
import { useAlerts, Alert, getImportanceColor, getRelativeTime } from '@/lib/AlertsContext'
import { Sidebar } from '@/components/Sidebar'
import { AlertsWizard } from '@/components/AlertsWizard'
import { InfoBox, TipBox } from '@/components/InfoBox'
import { Footer } from '@/components/Footer'
import { LanguageDropdown } from '@/components/LanguageDropdown'
import { ThemeToggle } from '@/components/ThemeToggle'
// Alert Detail Modal
function AlertDetailModal({
alert,
onClose,
onMarkRead
}: {
alert: Alert
onClose: () => void
onMarkRead: () => void
}) {
const { isDark } = useTheme()
useEffect(() => {
if (!alert.isRead) {
onMarkRead()
}
}, [alert, onMarkRead])
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
<div className={`relative w-full max-w-2xl rounded-3xl border p-8 max-h-[90vh] overflow-y-auto ${
isDark
? 'bg-slate-900 border-white/20'
: 'bg-white border-slate-200 shadow-2xl'
}`}>
{/* Header */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<span className={`px-3 py-1 rounded-full text-xs font-medium border ${getImportanceColor(alert.importance, isDark)}`}>
{alert.importance}
</span>
<span className={`text-xs ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
{getRelativeTime(alert.timestamp)}
</span>
</div>
<button
onClick={onClose}
className={`p-2 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'}`}
>
<svg className={`w-5 h-5 ${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>
{/* Title */}
<h2 className={`text-xl font-bold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>
{alert.title}
</h2>
{/* LLM Summary */}
<div className={`rounded-xl p-4 mb-6 ${isDark ? 'bg-white/5' : 'bg-slate-50'}`}>
<h4 className={`text-sm font-medium mb-2 flex items-center gap-2 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
<span>🤖</span> KI-Zusammenfassung
</h4>
<p className={isDark ? 'text-white/80' : 'text-slate-600'}>
{alert.summary}
</p>
</div>
{/* Sources */}
<div className="space-y-3">
<h4 className={`text-sm font-medium ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
Quellen:
</h4>
{alert.sources.map((source, idx) => (
<a
key={idx}
href={source.url}
target="_blank"
rel="noopener noreferrer"
className={`block p-3 rounded-lg transition-all ${
isDark
? 'bg-white/5 hover:bg-white/10'
: 'bg-slate-50 hover:bg-slate-100'
}`}
>
<p className={`text-sm font-medium ${isDark ? 'text-blue-400' : 'text-blue-600'}`}>
{source.title}
</p>
<p className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
{source.domain}
</p>
</a>
))}
</div>
{/* Original Source */}
<div className={`mt-6 pt-4 border-t ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
<p className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
Quelle: {alert.source}
</p>
</div>
</div>
</div>
)
}
// Alert Headline Component
function AlertHeadline({
alert,
onClick
}: {
alert: Alert
onClick: () => void
}) {
const { isDark } = useTheme()
return (
<button
onClick={onClick}
className={`w-full text-left p-4 rounded-xl transition-all group ${
isDark
? `bg-white/5 hover:bg-white/10 ${!alert.isRead ? 'border-l-4 border-amber-500' : ''}`
: `bg-slate-50 hover:bg-slate-100 ${!alert.isRead ? 'border-l-4 border-amber-500' : ''}`
}`}
>
<div className="flex items-start gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium border ${getImportanceColor(alert.importance, isDark)}`}>
{alert.importance}
</span>
<span className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
{getRelativeTime(alert.timestamp)}
</span>
{!alert.isRead && (
<span className="w-2 h-2 rounded-full bg-amber-500" />
)}
</div>
<h3 className={`font-medium truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>
{alert.title}
</h3>
<p className={`text-sm truncate ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
{alert.summary}
</p>
</div>
<svg className={`w-5 h-5 flex-shrink-0 transition-colors ${
isDark ? 'text-white/30 group-hover:text-white/60' : 'text-slate-300 group-hover:text-slate-600'
}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</button>
)
}
export default function AlertsPage() {
const router = useRouter()
const { isDark } = useTheme()
const { t } = useLanguage()
const {
alerts,
unreadCount,
isLoading,
topics,
settings,
markAsRead,
markAllAsRead,
updateSettings
} = useAlerts()
const [selectedAlert, setSelectedAlert] = useState<Alert | null>(null)
const [showWizard, setShowWizard] = useState(false)
const [filterImportance, setFilterImportance] = useState<string>('all')
const [viewMode, setViewMode] = useState<'simple' | 'expert'>('simple')
// Zeige Wizard wenn noch nicht abgeschlossen
useEffect(() => {
if (!settings.wizardCompleted && topics.length === 0) {
setShowWizard(true)
}
}, [settings.wizardCompleted, topics.length])
// Gefilterte Alerts
const filteredAlerts = alerts.filter(alert => {
if (filterImportance === 'all') return true
if (filterImportance === 'unread') return !alert.isRead
return alert.importance === filterImportance
})
// Wizard-Modus
if (showWizard) {
return (
<AlertsWizard
onComplete={() => setShowWizard(false)}
onSkip={() => {
updateSettings({ wizardCompleted: true })
setShowWizard(false)
}}
/>
)
}
return (
<div className={`min-h-screen relative overflow-hidden 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 */}
<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-amber-500 opacity-70' : 'bg-amber-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-orange-500 opacity-70' : 'bg-orange-300 opacity-50'
}`} />
</div>
<div className="relative z-10 flex min-h-screen gap-6 p-4">
{/* Sidebar */}
<Sidebar selectedTab="alerts" />
{/* Main Content */}
<main className="flex-1">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className={`text-4xl font-bold mb-2 flex items-center gap-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
<span>🔔</span> Alerts
{unreadCount > 0 && (
<span className="px-3 py-1 text-sm font-medium rounded-full bg-amber-500/20 text-amber-400 border border-amber-500/30">
{unreadCount} neu
</span>
)}
</h1>
<p className={isDark ? 'text-white/60' : 'text-slate-600'}>
Aktuelle Nachrichten zu Ihren Bildungsthemen
</p>
</div>
<div className="flex items-center gap-4">
{/* Mode Toggle */}
<div className={`flex rounded-xl overflow-hidden border ${isDark ? 'border-white/20' : 'border-slate-200'}`}>
<button
onClick={() => setViewMode('simple')}
className={`px-4 py-2 text-sm font-medium transition-all ${
viewMode === 'simple'
? 'bg-amber-500 text-white'
: isDark ? 'text-white/60 hover:text-white' : 'text-slate-500 hover:text-slate-900'
}`}
>
Einfach
</button>
<button
onClick={() => setViewMode('expert')}
className={`px-4 py-2 text-sm font-medium transition-all ${
viewMode === 'expert'
? 'bg-amber-500 text-white'
: isDark ? 'text-white/60 hover:text-white' : 'text-slate-500 hover:text-slate-900'
}`}
>
Experte
</button>
</div>
<LanguageDropdown />
<ThemeToggle />
<button
onClick={() => setShowWizard(true)}
className={`p-3 backdrop-blur-xl border rounded-2xl hover:bg-white/20 transition-all ${
isDark
? 'bg-white/10 border-white/20 text-white'
: 'bg-black/5 border-black/10 text-slate-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="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>
</button>
</div>
</div>
<div className="grid grid-cols-3 gap-6">
{/* Alerts Liste */}
<div className={`col-span-2 backdrop-blur-xl border rounded-3xl p-6 ${
isDark
? 'bg-white/10 border-white/20'
: 'bg-white/70 border-black/10 shadow-lg'
}`}>
{/* Filter Bar */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
{['all', 'unread', 'KRITISCH', 'DRINGEND', 'WICHTIG'].map((filter) => (
<button
key={filter}
onClick={() => setFilterImportance(filter)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-all ${
filterImportance === filter
? 'bg-amber-500/20 text-amber-400 border border-amber-500/30'
: isDark
? 'text-white/60 hover:text-white hover:bg-white/10'
: 'text-slate-500 hover:text-slate-900 hover:bg-slate-100'
}`}
>
{filter === 'all' ? 'Alle' : filter === 'unread' ? 'Ungelesen' : filter}
</button>
))}
</div>
{unreadCount > 0 && (
<button
onClick={markAllAsRead}
className={`text-sm ${isDark ? 'text-white/60 hover:text-white' : 'text-slate-500 hover:text-slate-900'}`}
>
Alle als gelesen markieren
</button>
)}
</div>
{/* Alerts */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-amber-500 border-t-transparent" />
</div>
) : filteredAlerts.length === 0 ? (
<div className={`text-center py-12 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
<span className="text-4xl block mb-4">📭</span>
<p>Keine Alerts gefunden</p>
</div>
) : (
<div className="space-y-3">
{filteredAlerts.map(alert => (
<AlertHeadline
key={alert.id}
alert={alert}
onClick={() => setSelectedAlert(alert)}
/>
))}
</div>
)}
</div>
{/* Right Sidebar */}
<div className="space-y-6">
{/* Topics */}
<div className={`backdrop-blur-xl border rounded-3xl p-6 ${
isDark
? 'bg-white/10 border-white/20'
: 'bg-white/70 border-black/10 shadow-lg'
}`}>
<h2 className={`text-lg font-semibold mb-4 flex items-center gap-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
<span>📋</span> Meine Themen
</h2>
{topics.length === 0 ? (
<p className={`text-sm ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
Noch keine Themen konfiguriert.
</p>
) : (
<div className="space-y-2">
{topics.map(topic => (
<div
key={topic.id}
className={`flex items-center gap-3 p-3 rounded-lg ${
isDark ? 'bg-white/5' : 'bg-slate-50'
}`}
>
<span className="text-lg">{topic.icon}</span>
<div className="flex-1 min-w-0">
<p className={`font-medium text-sm ${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, 3).join(', ')}
</p>
</div>
<div className={`w-2 h-2 rounded-full ${topic.isActive ? 'bg-green-500' : 'bg-slate-400'}`} />
</div>
))}
</div>
)}
<button
onClick={() => setShowWizard(true)}
className={`w-full mt-4 p-3 rounded-xl text-sm font-medium transition-all ${
isDark
? 'bg-white/10 text-white hover:bg-white/20'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
+ Thema hinzufuegen
</button>
</div>
{/* Stats */}
<div className={`backdrop-blur-xl border rounded-3xl p-6 ${
isDark
? 'bg-white/10 border-white/20'
: 'bg-white/70 border-black/10 shadow-lg'
}`}>
<h2 className={`text-lg font-semibold mb-4 flex items-center gap-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
<span>📊</span> Statistik
</h2>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>Gesamt</span>
<span className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>{alerts.length}</span>
</div>
<div className="flex items-center justify-between">
<span className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>Ungelesen</span>
<span className="font-medium text-amber-500">{unreadCount}</span>
</div>
<div className="flex items-center justify-between">
<span className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>Themen</span>
<span className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>{topics.length}</span>
</div>
</div>
</div>
{/* Info */}
<TipBox title="LLM-Zusammenfassungen" icon="🤖">
<p className="text-sm">
Alle Alerts werden automatisch mit KI zusammengefasst,
um Ihnen Zeit zu sparen.
</p>
</TipBox>
</div>
</div>
</main>
</div>
{/* Alert Detail Modal */}
{selectedAlert && (
<AlertDetailModal
alert={selectedAlert}
onClose={() => setSelectedAlert(null)}
onMarkRead={() => markAsRead(selectedAlert.id)}
/>
)}
<Footer />
{/* 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;
}
`}</style>
</div>
)
}
@@ -0,0 +1,129 @@
import { NextRequest, NextResponse } from 'next/server'
/**
* POST /api/companion/feedback
* Submit feedback (bug report, feature request, general feedback)
* Proxy to backend /api/feedback
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json()
// Validate required fields
if (!body.type || !body.title || !body.description) {
return NextResponse.json(
{
success: false,
error: 'Missing required fields: type, title, description',
},
{ status: 400 }
)
}
// Validate feedback type
const validTypes = ['bug', 'feature', 'feedback']
if (!validTypes.includes(body.type)) {
return NextResponse.json(
{
success: false,
error: 'Invalid feedback type. Must be: bug, feature, or feedback',
},
{ status: 400 }
)
}
// TODO: Replace with actual backend call
// const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
// const response = await fetch(`${backendUrl}/api/feedback`, {
// method: 'POST',
// headers: {
// 'Content-Type': 'application/json',
// // Add auth headers
// },
// body: JSON.stringify({
// type: body.type,
// title: body.title,
// description: body.description,
// screenshot: body.screenshot,
// sessionId: body.sessionId,
// metadata: {
// ...body.metadata,
// source: 'companion',
// timestamp: new Date().toISOString(),
// userAgent: request.headers.get('user-agent'),
// },
// }),
// })
//
// if (!response.ok) {
// throw new Error(`Backend responded with ${response.status}`)
// }
//
// const data = await response.json()
// return NextResponse.json(data)
// Mock response - just acknowledge the submission
const feedbackId = `fb-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
console.log('Feedback received:', {
id: feedbackId,
type: body.type,
title: body.title,
description: body.description.substring(0, 100) + '...',
hasScreenshot: !!body.screenshot,
sessionId: body.sessionId,
})
return NextResponse.json({
success: true,
message: 'Feedback submitted successfully',
data: {
feedbackId,
submittedAt: new Date().toISOString(),
},
})
} catch (error) {
console.error('Submit feedback error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}
/**
* GET /api/companion/feedback
* Get feedback history (admin only)
*/
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const type = searchParams.get('type')
const limit = parseInt(searchParams.get('limit') || '10')
// TODO: Replace with actual backend call
// Mock response - empty list for now
return NextResponse.json({
success: true,
data: {
feedback: [],
total: 0,
page: 1,
limit,
},
})
} catch (error) {
console.error('Get feedback error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}
+194
View File
@@ -0,0 +1,194 @@
import { NextRequest, NextResponse } from 'next/server'
/**
* POST /api/companion/lesson
* Start a new lesson session
* Proxy to backend /api/classroom/sessions
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
// TODO: Replace with actual backend call
// const response = await fetch(`${backendUrl}/api/classroom/sessions`, {
// method: 'POST',
// headers: {
// 'Content-Type': 'application/json',
// },
// body: JSON.stringify(body),
// })
//
// if (!response.ok) {
// throw new Error(`Backend responded with ${response.status}`)
// }
//
// const data = await response.json()
// return NextResponse.json(data)
// Mock response - create a new session
const sessionId = `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
const mockSession = {
success: true,
data: {
sessionId,
classId: body.classId,
className: body.className || body.classId,
subject: body.subject,
topic: body.topic,
startTime: new Date().toISOString(),
phases: [
{ phase: 'einstieg', duration: 8, status: 'active', actualTime: 0 },
{ phase: 'erarbeitung', duration: 20, status: 'planned', actualTime: 0 },
{ phase: 'sicherung', duration: 10, status: 'planned', actualTime: 0 },
{ phase: 'transfer', duration: 7, status: 'planned', actualTime: 0 },
{ phase: 'reflexion', duration: 5, status: 'planned', actualTime: 0 },
],
totalPlannedDuration: 50,
currentPhaseIndex: 0,
elapsedTime: 0,
isPaused: false,
pauseDuration: 0,
overtimeMinutes: 0,
status: 'in_progress',
homeworkList: [],
materials: [],
},
}
return NextResponse.json(mockSession)
} catch (error) {
console.error('Start lesson error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}
/**
* GET /api/companion/lesson
* Get current lesson session or list of recent sessions
*/
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const sessionId = searchParams.get('sessionId')
// TODO: Replace with actual backend call
// const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
// const url = sessionId
// ? `${backendUrl}/api/classroom/sessions/${sessionId}`
// : `${backendUrl}/api/classroom/sessions`
//
// const response = await fetch(url)
// const data = await response.json()
// return NextResponse.json(data)
// Mock response
if (sessionId) {
return NextResponse.json({
success: true,
data: null, // No active session stored on server in mock
})
}
return NextResponse.json({
success: true,
data: {
sessions: [], // Empty list for now
},
})
} catch (error) {
console.error('Get lesson error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}
/**
* PATCH /api/companion/lesson
* Update lesson session (timer state, phase changes, etc.)
*/
export async function PATCH(request: NextRequest) {
try {
const body = await request.json()
const { sessionId, ...updates } = body
if (!sessionId) {
return NextResponse.json(
{ success: false, error: 'Session ID required' },
{ status: 400 }
)
}
// TODO: Replace with actual backend call
// const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
// const response = await fetch(`${backendUrl}/api/classroom/sessions/${sessionId}`, {
// method: 'PATCH',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify(updates),
// })
//
// const data = await response.json()
// return NextResponse.json(data)
// Mock response - just acknowledge the update
return NextResponse.json({
success: true,
message: 'Session updated',
})
} catch (error) {
console.error('Update lesson error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}
/**
* DELETE /api/companion/lesson
* End/delete a lesson session
*/
export async function DELETE(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const sessionId = searchParams.get('sessionId')
if (!sessionId) {
return NextResponse.json(
{ success: false, error: 'Session ID required' },
{ status: 400 }
)
}
// TODO: Replace with actual backend call
return NextResponse.json({
success: true,
message: 'Session ended',
})
} catch (error) {
console.error('End lesson error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}
@@ -0,0 +1,137 @@
import { NextRequest, NextResponse } from 'next/server'
const DEFAULT_SETTINGS = {
defaultPhaseDurations: {
einstieg: 8,
erarbeitung: 20,
sicherung: 10,
transfer: 7,
reflexion: 5,
},
preferredLessonLength: 45,
autoAdvancePhases: true,
soundNotifications: true,
showKeyboardShortcuts: true,
highContrastMode: false,
onboardingCompleted: false,
}
/**
* GET /api/companion/settings
* Get teacher settings
* Proxy to backend /api/teacher/settings
*/
export async function GET() {
try {
// TODO: Replace with actual backend call
// const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
// const response = await fetch(`${backendUrl}/api/teacher/settings`, {
// method: 'GET',
// headers: {
// 'Content-Type': 'application/json',
// // Add auth headers
// },
// })
//
// if (!response.ok) {
// throw new Error(`Backend responded with ${response.status}`)
// }
//
// const data = await response.json()
// return NextResponse.json(data)
// Mock response - return default settings
return NextResponse.json({
success: true,
data: DEFAULT_SETTINGS,
})
} catch (error) {
console.error('Get settings error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}
/**
* PUT /api/companion/settings
* Update teacher settings
*/
export async function PUT(request: NextRequest) {
try {
const body = await request.json()
// Validate the settings structure
if (!body || typeof body !== 'object') {
return NextResponse.json(
{ success: false, error: 'Invalid settings data' },
{ status: 400 }
)
}
// TODO: Replace with actual backend call
// const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
// const response = await fetch(`${backendUrl}/api/teacher/settings`, {
// method: 'PUT',
// headers: {
// 'Content-Type': 'application/json',
// // Add auth headers
// },
// body: JSON.stringify(body),
// })
//
// if (!response.ok) {
// throw new Error(`Backend responded with ${response.status}`)
// }
//
// const data = await response.json()
// return NextResponse.json(data)
// Mock response - just acknowledge the save
return NextResponse.json({
success: true,
message: 'Settings saved',
data: body,
})
} catch (error) {
console.error('Save settings error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}
/**
* PATCH /api/companion/settings
* Partially update teacher settings
*/
export async function PATCH(request: NextRequest) {
try {
const body = await request.json()
// TODO: Replace with actual backend call
return NextResponse.json({
success: true,
message: 'Settings updated',
data: body,
})
} catch (error) {
console.error('Update settings error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}
@@ -0,0 +1,96 @@
import { NextRequest, NextResponse } from 'next/server'
/**
* Proxy for meetings API endpoints
* Routes requests to the backend service to avoid mixed-content/CORS issues
*/
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8000'
async function proxyRequest(
request: NextRequest,
params: { path: string[] }
): Promise<NextResponse> {
const path = params.path.join('/')
const url = `${BACKEND_URL}/api/meetings/${path}`
try {
const headers: HeadersInit = {
'Content-Type': 'application/json',
}
// Forward authorization header if present
const authHeader = request.headers.get('authorization')
if (authHeader) {
headers['Authorization'] = authHeader
}
const fetchOptions: RequestInit = {
method: request.method,
headers,
}
// Add body for POST/PUT/PATCH requests
if (['POST', 'PUT', 'PATCH'].includes(request.method)) {
fetchOptions.body = await request.text()
}
const response = await fetch(url, fetchOptions)
// Get response data
const contentType = response.headers.get('content-type')
let data: string | ArrayBuffer
if (contentType?.includes('application/json')) {
data = await response.text()
} else {
data = await response.arrayBuffer()
}
// Return proxied response
return new NextResponse(data, {
status: response.status,
headers: {
'Content-Type': contentType || 'application/json',
},
})
} catch (error) {
console.error(`Failed to proxy ${request.method} ${url}:`, error)
return NextResponse.json(
{ error: 'Backend nicht erreichbar', details: error instanceof Error ? error.message : 'Unknown error' },
{ status: 502 }
)
}
}
export async function GET(
request: NextRequest,
context: { params: Promise<{ path: string[] }> }
) {
const params = await context.params
return proxyRequest(request, params)
}
export async function POST(
request: NextRequest,
context: { params: Promise<{ path: string[] }> }
) {
const params = await context.params
return proxyRequest(request, params)
}
export async function PUT(
request: NextRequest,
context: { params: Promise<{ path: string[] }> }
) {
const params = await context.params
return proxyRequest(request, params)
}
export async function DELETE(
request: NextRequest,
context: { params: Promise<{ path: string[] }> }
) {
const params = await context.params
return proxyRequest(request, params)
}
@@ -0,0 +1,106 @@
import { NextRequest, NextResponse } from 'next/server'
/**
* Proxy for recordings API endpoints
* Routes requests to the backend service to avoid mixed-content/CORS issues
*/
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8000'
async function proxyRequest(
request: NextRequest,
params: { path: string[] }
): Promise<NextResponse> {
const path = params.path.join('/')
const url = `${BACKEND_URL}/api/recordings/${path}`
try {
const headers: HeadersInit = {}
// Forward content-type if present
const contentType = request.headers.get('content-type')
if (contentType) {
headers['Content-Type'] = contentType
}
// Forward authorization header if present
const authHeader = request.headers.get('authorization')
if (authHeader) {
headers['Authorization'] = authHeader
}
const fetchOptions: RequestInit = {
method: request.method,
headers,
}
// Add body for POST/PUT/PATCH requests
if (['POST', 'PUT', 'PATCH'].includes(request.method)) {
fetchOptions.body = await request.text()
}
const response = await fetch(url, fetchOptions)
// Get response data
const responseContentType = response.headers.get('content-type')
// Handle binary data (like video files)
if (responseContentType?.includes('video') || responseContentType?.includes('octet-stream')) {
const data = await response.arrayBuffer()
return new NextResponse(data, {
status: response.status,
headers: {
'Content-Type': responseContentType,
'Content-Disposition': response.headers.get('content-disposition') || '',
},
})
}
// Handle JSON and text
const data = await response.text()
return new NextResponse(data, {
status: response.status,
headers: {
'Content-Type': responseContentType || 'application/json',
},
})
} catch (error) {
console.error(`Failed to proxy ${request.method} ${url}:`, error)
return NextResponse.json(
{ error: 'Backend nicht erreichbar', details: error instanceof Error ? error.message : 'Unknown error' },
{ status: 502 }
)
}
}
export async function GET(
request: NextRequest,
context: { params: Promise<{ path: string[] }> }
) {
const params = await context.params
return proxyRequest(request, params)
}
export async function POST(
request: NextRequest,
context: { params: Promise<{ path: string[] }> }
) {
const params = await context.params
return proxyRequest(request, params)
}
export async function PUT(
request: NextRequest,
context: { params: Promise<{ path: string[] }> }
) {
const params = await context.params
return proxyRequest(request, params)
}
export async function DELETE(
request: NextRequest,
context: { params: Promise<{ path: string[] }> }
) {
const params = await context.params
return proxyRequest(request, params)
}
+34
View File
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server'
/**
* Proxy for /api/recordings base endpoint
*/
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8000'
export async function GET(request: NextRequest) {
const url = `${BACKEND_URL}/api/recordings`
try {
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
const data = await response.text()
return new NextResponse(data, {
status: response.status,
headers: {
'Content-Type': response.headers.get('content-type') || 'application/json',
},
})
} catch (error) {
console.error(`Failed to proxy GET ${url}:`, error)
return NextResponse.json(
{ error: 'Backend nicht erreichbar', details: error instanceof Error ? error.message : 'Unknown error' },
{ status: 502 }
)
}
}
+115
View File
@@ -0,0 +1,115 @@
import { NextRequest, NextResponse } from 'next/server'
import { writeFile, readFile, mkdir } from 'fs/promises'
import { existsSync } from 'fs'
import path from 'path'
// Speicherort fuer Uploads
const UPLOADS_DIR = '/tmp/breakpilot-uploads'
const METADATA_FILE = path.join(UPLOADS_DIR, 'metadata.json')
interface UploadedFile {
id: string
sessionId: string
name: string
type: string
size: number
uploadedAt: string
dataUrl: string // Base64 data URL
}
// Stelle sicher, dass das Upload-Verzeichnis existiert
async function ensureUploadsDir() {
if (!existsSync(UPLOADS_DIR)) {
await mkdir(UPLOADS_DIR, { recursive: true })
}
}
// Lade Metadaten
async function loadMetadata(): Promise<UploadedFile[]> {
try {
await ensureUploadsDir()
if (existsSync(METADATA_FILE)) {
const data = await readFile(METADATA_FILE, 'utf-8')
return JSON.parse(data)
}
} catch (error) {
console.error('Error loading metadata:', error)
}
return []
}
// Speichere Metadaten
async function saveMetadata(uploads: UploadedFile[]) {
await ensureUploadsDir()
await writeFile(METADATA_FILE, JSON.stringify(uploads, null, 2))
}
// GET: Liste alle Uploads fuer eine Session
export async function GET(request: NextRequest) {
const sessionId = request.nextUrl.searchParams.get('sessionId')
const uploads = await loadMetadata()
if (sessionId) {
const filtered = uploads.filter(u => u.sessionId === sessionId)
return NextResponse.json({ uploads: filtered })
}
// Alle Uploads (fuer Dashboard)
return NextResponse.json({ uploads })
}
// POST: Neuen Upload hinzufuegen
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { sessionId, name, type, size, dataUrl } = body
if (!sessionId || !name || !dataUrl) {
return NextResponse.json(
{ error: 'Missing required fields' },
{ status: 400 }
)
}
const upload: UploadedFile = {
id: `upload-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
sessionId,
name,
type: type || 'application/octet-stream',
size: size || 0,
uploadedAt: new Date().toISOString(),
dataUrl
}
const uploads = await loadMetadata()
uploads.push(upload)
await saveMetadata(uploads)
return NextResponse.json({ success: true, upload })
} catch (error) {
console.error('Upload error:', error)
return NextResponse.json(
{ error: 'Upload failed' },
{ status: 500 }
)
}
}
// DELETE: Upload loeschen
export async function DELETE(request: NextRequest) {
const id = request.nextUrl.searchParams.get('id')
if (!id) {
return NextResponse.json(
{ error: 'Missing upload id' },
{ status: 400 }
)
}
const uploads = await loadMetadata()
const filtered = uploads.filter(u => u.id !== id)
await saveMetadata(filtered)
return NextResponse.json({ success: true })
}
+51
View File
@@ -0,0 +1,51 @@
'use client'
import { useTheme } from '@/lib/ThemeContext'
import { Sidebar } from '@/components/Sidebar'
import { CompanionDashboard } from '@/components/companion/CompanionDashboard'
import { ThemeToggle } from '@/components/ThemeToggle'
import { LanguageDropdown } from '@/components/LanguageDropdown'
export default function CompanionPage() {
const { isDark } = useTheme()
return (
<div className={`min-h-screen flex relative overflow-hidden ${
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 */}
<div className={`absolute -top-40 -right-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob ${
isDark ? 'bg-purple-500 opacity-30' : 'bg-purple-300 opacity-40'
}`} />
<div className={`absolute top-1/2 -left-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-2000 ${
isDark ? 'bg-pink-500 opacity-30' : 'bg-pink-300 opacity-40'
}`} />
{/* Sidebar */}
<div className="relative z-10 p-4">
<Sidebar />
</div>
{/* Main Content */}
<div className="flex-1 flex flex-col relative z-10 p-4 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h1 className={`text-2xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Companion
</h1>
<div className="flex items-center gap-2">
<ThemeToggle />
<LanguageDropdown />
</div>
</div>
{/* Companion Dashboard */}
<div className="flex-1 overflow-auto">
<CompanionDashboard />
</div>
</div>
</div>
)
}
@@ -0,0 +1,739 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
// Spatial UI System
import { PerformanceProvider, usePerformance } from '@/lib/spatial-ui/PerformanceContext'
import { FocusProvider } from '@/lib/spatial-ui/FocusContext'
import { FloatingMessage } from '@/components/spatial-ui/FloatingMessage'
/**
* Apple Weather Style Dashboard - Refined Version
*
* Design principles:
* - Photo/gradient background that sets the mood
* - Ultra-translucent cards (~8% opacity)
* - Cards blend INTO the background
* - White text, monochrome palette
* - Subtle blur, minimal shadows
* - Useful info: time, weather, compass
*/
// =============================================================================
// GLASS CARD - Ultra Transparent
// =============================================================================
interface GlassCardProps {
children: React.ReactNode
className?: string
onClick?: () => void
size?: 'sm' | 'md' | 'lg'
delay?: number
}
function GlassCard({ children, className = '', onClick, size = 'md', delay = 0 }: GlassCardProps) {
const { settings } = usePerformance()
const [isVisible, setIsVisible] = useState(false)
const [isHovered, setIsHovered] = useState(false)
useEffect(() => {
const timer = setTimeout(() => setIsVisible(true), delay)
return () => clearTimeout(timer)
}, [delay])
const sizeClasses = {
sm: 'p-4',
md: 'p-5',
lg: 'p-6',
}
const blur = settings.enableBlur ? 24 * settings.blurIntensity : 0
return (
<div
className={`
rounded-3xl
${sizeClasses[size]}
${onClick ? 'cursor-pointer' : ''}
${className}
`}
style={{
background: isHovered
? 'rgba(255, 255, 255, 0.12)'
: 'rgba(255, 255, 255, 0.08)',
backdropFilter: blur > 0 ? `blur(${blur}px) saturate(180%)` : 'none',
WebkitBackdropFilter: blur > 0 ? `blur(${blur}px) saturate(180%)` : 'none',
border: '1px solid rgba(255, 255, 255, 0.1)',
boxShadow: '0 4px 24px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.05)',
opacity: isVisible ? 1 : 0,
transform: isVisible
? `translateY(0) scale(${isHovered ? 1.01 : 1})`
: 'translateY(20px)',
transition: 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={onClick}
>
{children}
</div>
)
}
// =============================================================================
// ANALOG CLOCK - Apple Style
// =============================================================================
function AnalogClock() {
const [time, setTime] = useState(new Date())
useEffect(() => {
const timer = setInterval(() => setTime(new Date()), 1000)
return () => clearInterval(timer)
}, [])
const hours = time.getHours() % 12
const minutes = time.getMinutes()
const seconds = time.getSeconds()
const hourDeg = (hours * 30) + (minutes * 0.5)
const minuteDeg = minutes * 6
const secondDeg = seconds * 6
return (
<div className="relative w-32 h-32">
{/* Clock face */}
<div
className="absolute inset-0 rounded-full"
style={{
background: 'rgba(255, 255, 255, 0.05)',
border: '2px solid rgba(255, 255, 255, 0.15)',
}}
>
{/* Hour markers */}
{[...Array(12)].map((_, i) => (
<div
key={i}
className="absolute w-1 h-3 bg-white/40 rounded-full"
style={{
left: '50%',
top: '8px',
transform: `translateX(-50%) rotate(${i * 30}deg)`,
transformOrigin: '50% 56px',
}}
/>
))}
{/* Hour hand */}
<div
className="absolute w-1.5 h-10 bg-white rounded-full"
style={{
left: '50%',
bottom: '50%',
transform: `translateX(-50%) rotate(${hourDeg}deg)`,
transformOrigin: 'bottom center',
}}
/>
{/* Minute hand */}
<div
className="absolute w-1 h-14 bg-white/80 rounded-full"
style={{
left: '50%',
bottom: '50%',
transform: `translateX(-50%) rotate(${minuteDeg}deg)`,
transformOrigin: 'bottom center',
}}
/>
{/* Second hand */}
<div
className="absolute w-0.5 h-14 bg-orange-400 rounded-full"
style={{
left: '50%',
bottom: '50%',
transform: `translateX(-50%) rotate(${secondDeg}deg)`,
transformOrigin: 'bottom center',
transition: 'transform 0.1s ease-out',
}}
/>
{/* Center dot */}
<div className="absolute w-3 h-3 bg-white rounded-full left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
</div>
</div>
)
}
// =============================================================================
// COMPASS - Apple Weather Style
// =============================================================================
function Compass({ direction = 225 }: { direction?: number }) {
return (
<div className="relative w-24 h-24">
{/* Compass face */}
<div
className="absolute inset-0 rounded-full"
style={{
background: 'rgba(255, 255, 255, 0.05)',
border: '2px solid rgba(255, 255, 255, 0.15)',
}}
>
{/* Cardinal directions */}
<span className="absolute top-2 left-1/2 -translate-x-1/2 text-xs font-bold text-red-400">N</span>
<span className="absolute bottom-2 left-1/2 -translate-x-1/2 text-xs font-medium text-white/50">S</span>
<span className="absolute left-2 top-1/2 -translate-y-1/2 text-xs font-medium text-white/50">W</span>
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs font-medium text-white/50">O</span>
{/* Needle */}
<div
className="absolute inset-4"
style={{
transform: `rotate(${direction}deg)`,
transition: 'transform 0.5s ease-out',
}}
>
{/* North (red) */}
<div
className="absolute w-1.5 h-8 bg-gradient-to-t from-red-500 to-red-400 rounded-full"
style={{
left: '50%',
bottom: '50%',
transform: 'translateX(-50%)',
transformOrigin: 'bottom center',
}}
/>
{/* South (white) */}
<div
className="absolute w-1.5 h-8 bg-gradient-to-b from-white/80 to-white/40 rounded-full"
style={{
left: '50%',
top: '50%',
transform: 'translateX(-50%)',
transformOrigin: 'top center',
}}
/>
</div>
{/* Center */}
<div className="absolute w-3 h-3 bg-white rounded-full left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
</div>
</div>
)
}
// =============================================================================
// BAR CHART - Apple Weather Hourly Style
// =============================================================================
interface BarChartProps {
data: { label: string; value: number; highlight?: boolean }[]
maxValue?: number
}
function BarChart({ data, maxValue }: BarChartProps) {
const max = maxValue || Math.max(...data.map((d) => d.value))
return (
<div className="flex items-end justify-between gap-2 h-32">
{data.map((item, index) => {
const height = (item.value / max) * 100
return (
<div key={index} className="flex flex-col items-center gap-2 flex-1">
<span className="text-xs text-white/60 font-medium">{item.value}</span>
<div
className="w-full rounded-lg transition-all duration-500"
style={{
height: `${height}%`,
minHeight: 8,
background: item.highlight
? 'linear-gradient(to top, rgba(96, 165, 250, 0.6), rgba(167, 139, 250, 0.6))'
: 'rgba(255, 255, 255, 0.2)',
boxShadow: item.highlight ? '0 0 20px rgba(139, 92, 246, 0.3)' : 'none',
}}
/>
<span className="text-xs text-white/40">{item.label}</span>
</div>
)
})}
</div>
)
}
// =============================================================================
// TEMPERATURE DISPLAY
// =============================================================================
function TemperatureDisplay({ temp, condition }: { temp: number; condition: string }) {
const conditionIcons: Record<string, string> = {
sunny: '☀️',
cloudy: '☁️',
rainy: '🌧️',
snowy: '🌨️',
partly_cloudy: '⛅',
}
return (
<div className="text-center">
<div className="text-6xl mb-2">{conditionIcons[condition] || '☀️'}</div>
<div className="flex items-start justify-center">
<span className="text-6xl font-extralight text-white">{temp}</span>
<span className="text-2xl text-white/60 mt-2">°C</span>
</div>
<p className="text-white/50 text-sm mt-1 capitalize">
{condition.replace('_', ' ')}
</p>
</div>
)
}
// =============================================================================
// PROGRESS RING
// =============================================================================
interface ProgressRingProps {
progress: number
size?: number
strokeWidth?: number
label: string
value: string
color?: string
}
function ProgressRing({ progress, size = 80, strokeWidth = 6, label, value, color = '#a78bfa' }: ProgressRingProps) {
const radius = (size - strokeWidth) / 2
const circumference = radius * 2 * Math.PI
const offset = circumference - (progress / 100) * circumference
return (
<div className="flex flex-col items-center">
<div className="relative" style={{ width: size, height: size }}>
<svg width={size} height={size} className="transform -rotate-90">
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="rgba(255, 255, 255, 0.1)"
strokeWidth={strokeWidth}
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={offset}
style={{ transition: 'stroke-dashoffset 1s ease-out' }}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-lg font-light text-white">{value}</span>
</div>
</div>
<p className="text-white/40 text-xs mt-2 font-medium uppercase tracking-wide">{label}</p>
</div>
)
}
// =============================================================================
// STAT DISPLAY
// =============================================================================
function StatDisplay({ value, unit, label, icon }: { value: string; unit?: string; label: string; icon?: string }) {
return (
<div className="text-center">
{icon && <div className="text-2xl mb-2 opacity-80">{icon}</div>}
<div className="flex items-baseline justify-center gap-1">
<span className="text-4xl font-light text-white">{value}</span>
{unit && <span className="text-lg text-white/50 font-light">{unit}</span>}
</div>
<p className="text-white/40 text-xs mt-1 font-medium uppercase tracking-wide">{label}</p>
</div>
)
}
// =============================================================================
// LIST ITEM
// =============================================================================
function ListItem({ icon, title, subtitle, value, delay = 0 }: {
icon: string; title: string; subtitle?: string; value?: string; delay?: number
}) {
const [isVisible, setIsVisible] = useState(false)
const [isHovered, setIsHovered] = useState(false)
useEffect(() => {
const timer = setTimeout(() => setIsVisible(true), delay)
return () => clearTimeout(timer)
}, [delay])
return (
<div
className="flex items-center gap-4 p-3 rounded-2xl cursor-pointer transition-all"
style={{
background: isHovered ? 'rgba(255, 255, 255, 0.06)' : 'transparent',
opacity: isVisible ? 1 : 0,
transform: isVisible ? 'translateX(0)' : 'translateX(-10px)',
transition: 'all 0.3s ease-out',
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="w-10 h-10 rounded-xl bg-white/8 flex items-center justify-center text-xl"
style={{ background: 'rgba(255,255,255,0.08)' }}>
{icon}
</div>
<div className="flex-1">
<p className="text-white font-medium">{title}</p>
{subtitle && <p className="text-white/40 text-sm">{subtitle}</p>}
</div>
{value && <span className="text-white/50 font-medium">{value}</span>}
<svg className="w-4 h-4 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24"
style={{ transform: isHovered ? 'translateX(2px)' : 'translateX(0)', transition: 'transform 0.2s' }}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
)
}
// =============================================================================
// ACTION BUTTON
// =============================================================================
function ActionButton({ icon, label, primary = false, onClick, delay = 0 }: {
icon: string; label: string; primary?: boolean; onClick?: () => void; delay?: number
}) {
const [isVisible, setIsVisible] = useState(false)
const [isPressed, setIsPressed] = useState(false)
useEffect(() => {
const timer = setTimeout(() => setIsVisible(true), delay)
return () => clearTimeout(timer)
}, [delay])
return (
<button
className="w-full flex items-center justify-center gap-3 p-4 rounded-2xl font-medium transition-all"
style={{
background: primary
? 'linear-gradient(135deg, rgba(96, 165, 250, 0.3), rgba(167, 139, 250, 0.3))'
: 'rgba(255, 255, 255, 0.06)',
border: '1px solid rgba(255, 255, 255, 0.08)',
color: 'white',
opacity: isVisible ? 1 : 0,
transform: isVisible ? `translateY(0) scale(${isPressed ? 0.97 : 1})` : 'translateY(10px)',
transition: 'all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)',
}}
onMouseDown={() => setIsPressed(true)}
onMouseUp={() => setIsPressed(false)}
onMouseLeave={() => setIsPressed(false)}
onClick={onClick}
>
<span className="text-xl">{icon}</span>
<span>{label}</span>
</button>
)
}
// =============================================================================
// QUALITY INDICATOR
// =============================================================================
function QualityIndicator() {
const { metrics, settings, forceQuality } = usePerformance()
const [isExpanded, setIsExpanded] = useState(false)
return (
<div
className="fixed bottom-6 left-6 z-50"
style={{
background: 'rgba(0, 0, 0, 0.3)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
border: '1px solid rgba(255, 255, 255, 0.08)',
borderRadius: 16,
padding: isExpanded ? 16 : 12,
minWidth: isExpanded ? 200 : 'auto',
transition: 'all 0.3s ease-out',
}}
>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-3 text-white/70 text-sm"
>
<span className={`w-2 h-2 rounded-full ${
metrics.qualityLevel === 'high' ? 'bg-green-400' :
metrics.qualityLevel === 'medium' ? 'bg-yellow-400' : 'bg-red-400'
}`} />
<span className="font-mono">{metrics.fps} FPS</span>
<span className="text-white/30">|</span>
<span className="uppercase text-xs tracking-wide">{metrics.qualityLevel}</span>
</button>
{isExpanded && (
<div className="mt-4 pt-4 border-t border-white/10 space-y-2">
<div className="flex gap-1">
{(['high', 'medium', 'low'] as const).map((level) => (
<button
key={level}
onClick={() => forceQuality(level)}
className={`flex-1 py-1.5 rounded-lg text-xs font-medium transition-all ${
metrics.qualityLevel === level
? 'bg-white/15 text-white'
: 'bg-white/5 text-white/40 hover:bg-white/10'
}`}
>
{level[0].toUpperCase()}
</button>
))}
</div>
</div>
)}
</div>
)
}
// =============================================================================
// MAIN DASHBOARD
// =============================================================================
function DashboardContent() {
const router = useRouter()
const { settings } = usePerformance()
const [mousePos, setMousePos] = useState({ x: 0, y: 0 })
const [time, setTime] = useState(new Date())
useEffect(() => {
const timer = setInterval(() => setTime(new Date()), 1000)
return () => clearInterval(timer)
}, [])
useEffect(() => {
if (!settings.enableParallax) return
const handleMouseMove = (e: MouseEvent) => setMousePos({ x: e.clientX, y: e.clientY })
window.addEventListener('mousemove', handleMouseMove)
return () => window.removeEventListener('mousemove', handleMouseMove)
}, [settings.enableParallax])
const windowWidth = typeof window !== 'undefined' ? window.innerWidth : 1920
const windowHeight = typeof window !== 'undefined' ? window.innerHeight : 1080
const parallax = settings.enableParallax
? { x: (mousePos.x / windowWidth - 0.5) * 15, y: (mousePos.y / windowHeight - 0.5) * 15 }
: { x: 0, y: 0 }
const greeting = time.getHours() < 12 ? 'Guten Morgen' : time.getHours() < 18 ? 'Guten Tag' : 'Guten Abend'
// Weekly correction data
const weeklyData = [
{ label: 'Mo', value: 4, highlight: false },
{ label: 'Di', value: 7, highlight: false },
{ label: 'Mi', value: 3, highlight: false },
{ label: 'Do', value: 8, highlight: false },
{ label: 'Fr', value: 6, highlight: true },
{ label: 'Sa', value: 2, highlight: false },
{ label: 'So', value: 0, highlight: false },
]
return (
<div className="min-h-screen relative overflow-hidden">
{/* Background */}
<div
className="absolute inset-0 bg-gradient-to-br from-slate-900 via-indigo-950 to-slate-900"
style={{
transform: `translate(${parallax.x * 0.5}px, ${parallax.y * 0.5}px) scale(1.05)`,
transition: 'transform 0.3s ease-out',
}}
>
{/* Stars */}
<div className="absolute inset-0 opacity-30"
style={{
backgroundImage: `radial-gradient(2px 2px at 20px 30px, white, transparent),
radial-gradient(2px 2px at 40px 70px, rgba(255,255,255,0.8), transparent),
radial-gradient(1px 1px at 90px 40px, white, transparent),
radial-gradient(2px 2px at 160px 120px, rgba(255,255,255,0.9), transparent),
radial-gradient(1px 1px at 230px 80px, white, transparent),
radial-gradient(2px 2px at 300px 150px, rgba(255,255,255,0.7), transparent)`,
backgroundSize: '400px 200px',
}}
/>
{/* Ambient glows */}
<div className="absolute w-[500px] h-[500px] rounded-full opacity-20"
style={{
background: 'radial-gradient(circle, rgba(99, 102, 241, 0.5) 0%, transparent 70%)',
left: '10%', top: '20%',
transform: `translate(${parallax.x}px, ${parallax.y}px)`,
transition: 'transform 0.5s ease-out',
}}
/>
<div className="absolute w-[400px] h-[400px] rounded-full opacity-15"
style={{
background: 'radial-gradient(circle, rgba(167, 139, 250, 0.5) 0%, transparent 70%)',
right: '5%', bottom: '10%',
transform: `translate(${-parallax.x * 0.8}px, ${-parallax.y * 0.8}px)`,
transition: 'transform 0.5s ease-out',
}}
/>
</div>
{/* Content */}
<div className="relative z-10 min-h-screen p-6">
{/* Header */}
<header className="flex items-start justify-between mb-8">
<div>
<p className="text-white/40 text-sm font-medium tracking-wide uppercase mb-1">
{time.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long' })}
</p>
<h1 className="text-4xl font-light text-white tracking-tight">{greeting}</h1>
</div>
<div className="flex items-center gap-3">
<GlassCard size="sm" className="!p-3">
<div className="flex items-center gap-2">
<span className="text-lg">🔔</span>
<span className="text-white font-medium text-sm">3</span>
</div>
</GlassCard>
<GlassCard size="sm" className="!p-3">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-blue-400 to-purple-500" />
</GlassCard>
</div>
</header>
{/* Main Grid */}
<div className="grid grid-cols-12 gap-4 max-w-7xl mx-auto">
{/* Clock & Weather Row */}
<div className="col-span-3">
<GlassCard size="lg" delay={50}>
<div className="flex flex-col items-center">
<AnalogClock />
<p className="text-white text-2xl font-light mt-4">
{time.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
</p>
</div>
</GlassCard>
</div>
<div className="col-span-3">
<GlassCard size="lg" delay={100}>
<TemperatureDisplay temp={8} condition="partly_cloudy" />
</GlassCard>
</div>
<div className="col-span-3">
<GlassCard size="lg" delay={150}>
<div className="flex flex-col items-center">
<Compass direction={225} />
<p className="text-white/50 text-sm mt-3">SW Wind</p>
<p className="text-white text-lg font-light">12 km/h</p>
</div>
</GlassCard>
</div>
<div className="col-span-3">
<GlassCard size="lg" delay={200}>
<StatDisplay icon="📋" value="12" label="Offene Korrekturen" />
<div className="mt-4 pt-4 border-t border-white/10 flex justify-around">
<div className="text-center">
<p className="text-xl font-light text-white">28</p>
<p className="text-white/40 text-xs">Diese Woche</p>
</div>
<div className="text-center">
<p className="text-xl font-light text-white">156</p>
<p className="text-white/40 text-xs">Gesamt</p>
</div>
</div>
</GlassCard>
</div>
{/* Bar Chart */}
<div className="col-span-6">
<GlassCard size="lg" delay={250}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-white text-sm font-medium uppercase tracking-wide opacity-60">Korrekturen diese Woche</h2>
<span className="text-white/40 text-sm">30 gesamt</span>
</div>
<BarChart data={weeklyData} maxValue={10} />
</GlassCard>
</div>
{/* Progress Rings */}
<div className="col-span-3">
<GlassCard size="lg" delay={300}>
<div className="flex justify-around">
<ProgressRing progress={75} label="Fortschritt" value="75%" color="#60a5fa" />
<ProgressRing progress={92} label="Qualitaet" value="92%" color="#a78bfa" />
</div>
</GlassCard>
</div>
{/* Time Saved */}
<div className="col-span-3">
<GlassCard size="lg" delay={350}>
<StatDisplay icon="⏱" value="4.2" unit="h" label="Zeit gespart" />
<p className="text-center text-white/30 text-xs mt-3">durch KI-Unterstuetzung</p>
</GlassCard>
</div>
{/* Klausuren List */}
<div className="col-span-8">
<GlassCard size="lg" delay={400}>
<div className="flex items-center justify-between mb-3">
<h2 className="text-white text-sm font-medium uppercase tracking-wide opacity-60">Aktuelle Klausuren</h2>
<button className="text-white/40 text-xs hover:text-white transition-colors">Alle anzeigen</button>
</div>
<div className="space-y-1">
<ListItem icon="📝" title="Deutsch LK - Textanalyse" subtitle="24 Schueler" value="18/24" delay={450} />
<ListItem icon="✅" title="Deutsch GK - Eroerterung" subtitle="Abgeschlossen" value="28/28" delay={500} />
<ListItem icon="📝" title="Vorabitur - Gedichtanalyse" subtitle="22 Schueler" value="10/22" delay={550} />
</div>
</GlassCard>
</div>
{/* Quick Actions */}
<div className="col-span-4">
<GlassCard size="lg" delay={450}>
<h2 className="text-white text-sm font-medium uppercase tracking-wide opacity-60 mb-4">Schnellaktionen</h2>
<div className="space-y-2">
<ActionButton icon="" label="Neue Klausur" primary delay={500} />
<ActionButton icon="📤" label="Arbeiten hochladen" delay={550} />
<ActionButton icon="🎨" label="Worksheet Editor" onClick={() => router.push('/worksheet-editor')} delay={600} />
</div>
</GlassCard>
</div>
</div>
</div>
{/* Floating Messages */}
<FloatingMessage
autoDismissMs={12000}
maxQueue={3}
position="top-right"
offset={{ x: 24, y: 24 }}
/>
{/* Quality Indicator */}
<QualityIndicator />
</div>
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
export default function ExperimentalDashboard() {
return (
<PerformanceProvider>
<FocusProvider>
<DashboardContent />
</FocusProvider>
</PerformanceProvider>
)
}
+116
View File
@@ -0,0 +1,116 @@
'use client'
import Link from 'next/link'
import { useLanguage } from '@/lib/LanguageContext'
import { useTheme } from '@/lib/ThemeContext'
import { Footer } from '@/components/Footer'
export default function DatenschutzPage() {
const { t } = useLanguage()
const { isDark } = useTheme()
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'
}`}>
<div className="flex-1 p-8">
<div className="max-w-4xl mx-auto">
{/* Back Link */}
<Link
href="/"
className={`inline-flex items-center gap-2 mb-8 transition-colors ${
isDark ? 'text-white/60 hover:text-white' : 'text-slate-600 hover:text-slate-900'
}`}
>
<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>
{t('back_to_selection')}
</Link>
{/* Content Card */}
<div className={`backdrop-blur-xl border rounded-3xl p-8 ${
isDark
? 'bg-white/10 border-white/20'
: 'bg-white/70 border-black/10 shadow-lg'
}`}>
<h1 className={`text-3xl font-bold mb-8 ${isDark ? 'text-white' : 'text-slate-900'}`}>
{t('privacy')}
</h1>
<div className={`space-y-6 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
<section>
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
1. Datenschutz auf einen Blick
</h2>
<h3 className={`text-lg font-medium mb-2 ${isDark ? 'text-white/90' : 'text-slate-800'}`}>
Allgemeine Hinweise
</h3>
<p>
Die folgenden Hinweise geben einen einfachen Überblick darüber, was mit Ihren personenbezogenen
Daten passiert, wenn Sie diese Website besuchen. Personenbezogene Daten sind alle Daten, mit denen
Sie persönlich identifiziert werden können.
</p>
</section>
<section>
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
2. Datenerfassung auf dieser Website
</h2>
<h3 className={`text-lg font-medium mb-2 ${isDark ? 'text-white/90' : 'text-slate-800'}`}>
Wer ist verantwortlich für die Datenerfassung?
</h3>
<p className="mb-4">
Die Datenverarbeitung auf dieser Website erfolgt durch den Websitebetreiber.
Dessen Kontaktdaten können Sie dem Impressum dieser Website entnehmen.
</p>
<h3 className={`text-lg font-medium mb-2 ${isDark ? 'text-white/90' : 'text-slate-800'}`}>
Wie erfassen wir Ihre Daten?
</h3>
<p>
Ihre Daten werden zum einen dadurch erhoben, dass Sie uns diese mitteilen.
Andere Daten werden automatisch beim Besuch der Website durch unsere IT-Systeme erfasst.
</p>
</section>
<section>
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
3. Ihre Rechte
</h2>
<p>
Sie haben jederzeit das Recht auf unentgeltliche Auskunft über Herkunft, Empfänger und Zweck
Ihrer gespeicherten personenbezogenen Daten. Sie haben außerdem ein Recht, die Berichtigung
oder Löschung dieser Daten zu verlangen.
</p>
</section>
<section>
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
4. Cookies
</h2>
<p>
Diese Website verwendet Cookies. Sie können Ihre Cookie-Einstellungen jederzeit über den
Link "Cookie-Einstellungen" im Footer dieser Seite anpassen.
</p>
</section>
<div className={`mt-8 p-4 rounded-2xl ${
isDark ? 'bg-yellow-500/20 border border-yellow-500/30' : 'bg-yellow-50 border border-yellow-200'
}`}>
<p className={`text-sm ${isDark ? 'text-yellow-200' : 'text-yellow-800'}`}>
Hinweis: Dies ist ein Platzhalter. Bitte ersetzen Sie diesen Text durch Ihre vollständige
Datenschutzerklärung gemäß DSGVO.
</p>
</div>
</div>
</div>
</div>
</div>
<Footer />
</div>
)
}
+501
View File
@@ -0,0 +1,501 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import dynamic from 'next/dynamic'
import {
AOIResponse,
AOITheme,
AOIQuality,
Difficulty,
GeoJSONPolygon,
LearningNode,
GeoServiceHealth,
DemoTemplate,
} from './types'
// Dynamic imports for map components (no SSR)
const AOISelector = dynamic(
() => import('@/components/geo-lernwelt/AOISelector'),
{ ssr: false, loading: () => <MapLoadingPlaceholder /> }
)
const UnityViewer = dynamic(
() => import('@/components/geo-lernwelt/UnityViewer'),
{ ssr: false }
)
// API base URL
const GEO_SERVICE_URL = process.env.NEXT_PUBLIC_GEO_SERVICE_URL || 'http://localhost:8088'
// Loading placeholder for map
function MapLoadingPlaceholder() {
return (
<div className="w-full h-[400px] bg-slate-800 rounded-xl 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>
)
}
// Theme icons and colors
const THEME_CONFIG: Record<AOITheme, { icon: string; color: string; label: string }> = {
topographie: { icon: '🏔️', color: 'bg-amber-500', label: 'Topographie' },
landnutzung: { icon: '🏘️', color: 'bg-green-500', label: 'Landnutzung' },
orientierung: { icon: '🧭', color: 'bg-blue-500', label: 'Orientierung' },
geologie: { icon: '🪨', color: 'bg-stone-500', label: 'Geologie' },
hydrologie: { icon: '💧', color: 'bg-cyan-500', label: 'Hydrologie' },
vegetation: { icon: '🌲', color: 'bg-emerald-500', label: 'Vegetation' },
}
export default function GeoLernweltPage() {
// State
const [serviceHealth, setServiceHealth] = useState<GeoServiceHealth | null>(null)
const [currentAOI, setCurrentAOI] = useState<AOIResponse | null>(null)
const [drawnPolygon, setDrawnPolygon] = useState<GeoJSONPolygon | null>(null)
const [selectedTheme, setSelectedTheme] = useState<AOITheme>('topographie')
const [quality, setQuality] = useState<AOIQuality>('medium')
const [difficulty, setDifficulty] = useState<Difficulty>('mittel')
const [learningNodes, setLearningNodes] = useState<LearningNode[]>([])
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<'map' | 'unity'>('map')
const [demoTemplate, setDemoTemplate] = useState<DemoTemplate | null>(null)
// Check service health on mount
useEffect(() => {
checkServiceHealth()
loadMainauTemplate()
}, [])
const checkServiceHealth = async () => {
try {
const res = await fetch(`${GEO_SERVICE_URL}/health`)
if (res.ok) {
const health = await res.json()
setServiceHealth(health)
}
} catch (e) {
console.error('Service health check failed:', e)
setServiceHealth(null)
}
}
const loadMainauTemplate = async () => {
try {
const res = await fetch(`${GEO_SERVICE_URL}/api/v1/aoi/templates/mainau`)
if (res.ok) {
const template = await res.json()
setDemoTemplate(template)
}
} catch (e) {
console.error('Failed to load Mainau template:', e)
}
}
const handlePolygonDrawn = useCallback((polygon: GeoJSONPolygon) => {
setDrawnPolygon(polygon)
setError(null)
}, [])
const handleCreateAOI = async () => {
if (!drawnPolygon) {
setError('Bitte zeichne zuerst ein Gebiet auf der Karte.')
return
}
setIsLoading(true)
setError(null)
try {
const res = await fetch(`${GEO_SERVICE_URL}/api/v1/aoi`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
polygon: drawnPolygon,
theme: selectedTheme,
quality,
}),
})
if (!res.ok) {
const errorData = await res.json()
throw new Error(errorData.detail || 'Fehler beim Erstellen des Gebiets')
}
const aoi = await res.json()
setCurrentAOI(aoi)
// Poll for completion
pollAOIStatus(aoi.aoi_id)
} catch (e) {
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
} finally {
setIsLoading(false)
}
}
const pollAOIStatus = async (aoiId: string) => {
const poll = async () => {
try {
const res = await fetch(`${GEO_SERVICE_URL}/api/v1/aoi/${aoiId}`)
if (res.ok) {
const aoi = await res.json()
setCurrentAOI(aoi)
if (aoi.status === 'completed') {
// Load learning nodes
generateLearningNodes(aoiId)
} else if (aoi.status === 'failed') {
setError('Verarbeitung fehlgeschlagen')
} else {
// Continue polling
setTimeout(poll, 2000)
}
}
} catch (e) {
console.error('Polling error:', e)
}
}
poll()
}
const generateLearningNodes = async (aoiId: string) => {
try {
const res = await fetch(`${GEO_SERVICE_URL}/api/v1/learning/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
aoi_id: aoiId,
theme: selectedTheme,
difficulty,
node_count: 5,
language: 'de',
}),
})
if (res.ok) {
const data = await res.json()
setLearningNodes(data.nodes)
}
} catch (e) {
console.error('Failed to generate learning nodes:', e)
}
}
const handleLoadDemo = () => {
if (demoTemplate) {
setDrawnPolygon(demoTemplate.polygon)
setSelectedTheme(demoTemplate.suggested_themes[0] || 'topographie')
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-indigo-950 to-slate-900">
{/* Header */}
<header className="border-b border-white/10 bg-black/20 backdrop-blur-lg">
<div className="max-w-7xl mx-auto px-4 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-3xl">🌍</span>
<div>
<h1 className="text-xl font-semibold text-white">Geo-Lernwelt</h1>
<p className="text-sm text-white/60">Interaktive Erdkunde-Lernplattform</p>
</div>
</div>
{/* Service Status */}
<div className="flex items-center gap-2">
<div
className={`w-2 h-2 rounded-full ${
serviceHealth?.status === 'healthy'
? 'bg-green-500'
: 'bg-yellow-500 animate-pulse'
}`}
/>
<span className="text-sm text-white/60">
{serviceHealth?.status === 'healthy' ? 'Verbunden' : 'Verbinde...'}
</span>
</div>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 py-6">
{/* Tab Navigation */}
<div className="flex gap-2 mb-6">
<button
onClick={() => setActiveTab('map')}
className={`px-4 py-2 rounded-lg font-medium transition-all ${
activeTab === 'map'
? 'bg-white/10 text-white'
: 'text-white/60 hover:text-white hover:bg-white/5'
}`}
>
🗺 Gebiet waehlen
</button>
<button
onClick={() => setActiveTab('unity')}
disabled={!currentAOI || currentAOI.status !== 'completed'}
className={`px-4 py-2 rounded-lg font-medium transition-all ${
activeTab === 'unity'
? 'bg-white/10 text-white'
: 'text-white/60 hover:text-white hover:bg-white/5'
} disabled:opacity-40 disabled:cursor-not-allowed`}
>
🎮 3D-Lernwelt
</button>
</div>
{/* Error Message */}
{error && (
<div className="mb-6 p-4 bg-red-500/20 border border-red-500/50 rounded-xl text-red-200">
{error}
</div>
)}
{activeTab === 'map' ? (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Map Section (2/3) */}
<div className="lg:col-span-2 space-y-4">
{/* Map Card */}
<div className="bg-white/5 backdrop-blur-lg rounded-2xl border border-white/10 overflow-hidden">
<div className="p-4 border-b border-white/10">
<div className="flex items-center justify-between">
<h2 className="text-lg font-medium text-white">Gebiet auf der Karte waehlen</h2>
{demoTemplate && (
<button
onClick={handleLoadDemo}
className="px-3 py-1.5 text-sm bg-blue-500/20 hover:bg-blue-500/30 text-blue-300 rounded-lg transition-colors"
>
📍 Demo: Insel Mainau
</button>
)}
</div>
<p className="text-sm text-white/60 mt-1">
Zeichne ein Polygon (max. 4 km²) um das gewuenschte Lerngebiet
</p>
</div>
<div className="h-[500px]">
<AOISelector
onPolygonDrawn={handlePolygonDrawn}
initialPolygon={drawnPolygon}
maxAreaKm2={4}
geoServiceUrl={GEO_SERVICE_URL}
/>
</div>
</div>
{/* Attribution */}
<div className="text-xs text-white/40 text-center">
Kartendaten: © OpenStreetMap contributors (ODbL) | Hoehenmodell: © Copernicus DEM
</div>
</div>
{/* Settings Panel (1/3) */}
<div className="space-y-4">
{/* Theme Selection */}
<div className="bg-white/5 backdrop-blur-lg rounded-2xl border border-white/10 p-4">
<h3 className="text-white font-medium mb-3">Lernthema</h3>
<div className="grid grid-cols-2 gap-2">
{(Object.keys(THEME_CONFIG) as AOITheme[]).map((theme) => {
const config = THEME_CONFIG[theme]
return (
<button
key={theme}
onClick={() => setSelectedTheme(theme)}
className={`p-3 rounded-xl text-left transition-all ${
selectedTheme === theme
? 'bg-white/15 border border-white/30'
: 'bg-white/5 border border-transparent hover:bg-white/10'
}`}
>
<span className="text-2xl">{config.icon}</span>
<div className="text-sm text-white mt-1">{config.label}</div>
</button>
)
})}
</div>
</div>
{/* Quality Selection */}
<div className="bg-white/5 backdrop-blur-lg rounded-2xl border border-white/10 p-4">
<h3 className="text-white font-medium mb-3">Qualitaet</h3>
<div className="flex gap-2">
{(['low', 'medium', 'high'] as AOIQuality[]).map((q) => (
<button
key={q}
onClick={() => setQuality(q)}
className={`flex-1 py-2 rounded-lg text-sm transition-all ${
quality === q
? 'bg-white/15 text-white border border-white/30'
: 'bg-white/5 text-white/60 hover:bg-white/10'
}`}
>
{q === 'low' ? 'Schnell' : q === 'medium' ? 'Standard' : 'Hoch'}
</button>
))}
</div>
</div>
{/* Difficulty Selection */}
<div className="bg-white/5 backdrop-blur-lg rounded-2xl border border-white/10 p-4">
<h3 className="text-white font-medium mb-3">Schwierigkeitsgrad</h3>
<div className="flex gap-2">
{(['leicht', 'mittel', 'schwer'] as Difficulty[]).map((d) => (
<button
key={d}
onClick={() => setDifficulty(d)}
className={`flex-1 py-2 rounded-lg text-sm capitalize transition-all ${
difficulty === d
? 'bg-white/15 text-white border border-white/30'
: 'bg-white/5 text-white/60 hover:bg-white/10'
}`}
>
{d}
</button>
))}
</div>
</div>
{/* Area Info */}
{drawnPolygon && (
<div className="bg-white/5 backdrop-blur-lg rounded-2xl border border-white/10 p-4">
<h3 className="text-white font-medium mb-2">Ausgewaehltes Gebiet</h3>
<div className="text-sm text-white/60">
<p>Polygon gezeichnet </p>
<p className="text-white/40 text-xs mt-1">
Klicke &quot;Lernwelt erstellen&quot; um fortzufahren
</p>
</div>
</div>
)}
{/* Create Button */}
<button
onClick={handleCreateAOI}
disabled={!drawnPolygon || isLoading}
className={`w-full py-4 rounded-xl font-medium text-lg transition-all ${
drawnPolygon && !isLoading
? 'bg-gradient-to-r from-blue-500 to-purple-500 text-white hover:from-blue-600 hover:to-purple-600'
: 'bg-white/10 text-white/40 cursor-not-allowed'
}`}
>
{isLoading ? (
<span className="flex items-center justify-center gap-2">
<span className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Wird erstellt...
</span>
) : (
'🚀 Lernwelt erstellen'
)}
</button>
{/* AOI Status */}
{currentAOI && (
<div className="bg-white/5 backdrop-blur-lg rounded-2xl border border-white/10 p-4">
<h3 className="text-white font-medium mb-2">Status</h3>
<div className="space-y-2">
<div className="flex items-center gap-2">
<div
className={`w-2 h-2 rounded-full ${
currentAOI.status === 'completed'
? 'bg-green-500'
: currentAOI.status === 'failed'
? 'bg-red-500'
: 'bg-yellow-500 animate-pulse'
}`}
/>
<span className="text-sm text-white/80 capitalize">
{currentAOI.status === 'queued'
? 'In Warteschlange...'
: currentAOI.status === 'processing'
? 'Wird verarbeitet...'
: currentAOI.status === 'completed'
? 'Fertig!'
: 'Fehlgeschlagen'}
</span>
</div>
{currentAOI.area_km2 > 0 && (
<p className="text-xs text-white/50">
Flaeche: {currentAOI.area_km2.toFixed(2)} km²
</p>
)}
</div>
</div>
)}
</div>
</div>
) : (
/* Unity 3D Viewer Tab */
<div className="space-y-4">
<div className="bg-white/5 backdrop-blur-lg rounded-2xl border border-white/10 overflow-hidden">
<div className="p-4 border-b border-white/10 flex items-center justify-between">
<div>
<h2 className="text-lg font-medium text-white">3D-Lernwelt</h2>
<p className="text-sm text-white/60">
Erkunde das Gebiet und bearbeite die Lernstationen
</p>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-white/60">
{learningNodes.length} Lernstationen
</span>
</div>
</div>
<div className="h-[600px]">
{currentAOI && currentAOI.status === 'completed' ? (
<UnityViewer
aoiId={currentAOI.aoi_id}
manifestUrl={currentAOI.manifest_url}
learningNodes={learningNodes}
geoServiceUrl={GEO_SERVICE_URL}
/>
) : (
<div className="h-full flex items-center justify-center text-white/60">
Erstelle zuerst ein Lerngebiet im Tab &quot;Gebiet waehlen&quot;
</div>
)}
</div>
</div>
{/* Learning Nodes List */}
{learningNodes.length > 0 && (
<div className="bg-white/5 backdrop-blur-lg rounded-2xl border border-white/10 p-4">
<h3 className="text-white font-medium mb-3">Lernstationen</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{learningNodes.map((node, idx) => (
<div
key={node.id}
className="p-3 bg-white/5 rounded-xl border border-white/10"
>
<div className="flex items-center gap-2 mb-2">
<span className="w-6 h-6 bg-blue-500/30 rounded-full flex items-center justify-center text-xs text-white">
{idx + 1}
</span>
<span className="text-white font-medium text-sm">{node.title}</span>
</div>
<p className="text-xs text-white/60 line-clamp-2">{node.question}</p>
<div className="mt-2 flex items-center gap-2">
<span className="text-xs bg-white/10 px-2 py-0.5 rounded text-white/60">
{node.points} Punkte
</span>
{node.approved && (
<span className="text-xs text-green-400"> Freigegeben</span>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
</main>
</div>
)
}
+282
View File
@@ -0,0 +1,282 @@
/**
* GeoEdu Service - TypeScript Types
* Types for the geography learning platform
*/
// Geographic types
export interface Position {
latitude: number
longitude: number
altitude?: number
}
export interface Bounds {
west: number
south: number
east: number
north: number
}
export interface GeoJSONPolygon {
type: 'Polygon'
coordinates: number[][][]
}
// AOI (Area of Interest) types
export type AOIStatus = 'queued' | 'processing' | 'completed' | 'failed'
export type AOIQuality = 'low' | 'medium' | 'high'
export type AOITheme =
| 'topographie'
| 'landnutzung'
| 'orientierung'
| 'geologie'
| 'hydrologie'
| 'vegetation'
export interface AOIRequest {
polygon: GeoJSONPolygon
theme: AOITheme
quality: AOIQuality
}
export interface AOIResponse {
aoi_id: string
status: AOIStatus
area_km2: number
estimated_size_mb: number
message?: string
download_url?: string
manifest_url?: string
created_at?: string
completed_at?: string
}
export interface AOIManifest {
version: string
aoi_id: string
created_at: string
bounds: Bounds
center: Position
area_km2: number
theme: AOITheme
quality: AOIQuality
assets: {
terrain: { file: string; config: string }
osm_features: { file: string }
learning_positions: { file: string }
attribution: { file: string }
}
unity: {
coordinate_system: string
scale: number
terrain_resolution: number
}
}
// Learning Node types
export type NodeType = 'question' | 'observation' | 'exploration'
export type Difficulty = 'leicht' | 'mittel' | 'schwer'
export interface LearningNode {
id: string
aoi_id: string
title: string
theme: AOITheme
position: Position
question: string
hints: string[]
answer: string
explanation: string
node_type: NodeType
points: number
approved: boolean
media?: {
type: 'image' | 'audio' | 'video'
url: string
}[]
tags?: string[]
difficulty?: Difficulty
grade_level?: string
}
export interface LearningNodeRequest {
aoi_id: string
theme: AOITheme
difficulty: Difficulty
node_count: number
grade_level?: string
language?: string
}
export interface LearningNodeResponse {
aoi_id: string
theme: string
nodes: LearningNode[]
total_count: number
generation_model: string
}
// Theme template types
export interface ThemeTemplate {
id: AOITheme
name: string
description: string
icon: string
grade_levels: string[]
example_questions: string[]
keywords: string[]
}
export interface LearningTemplates {
themes: ThemeTemplate[]
difficulties: {
id: Difficulty
name: string
description: string
}[]
supported_languages: string[]
}
// Attribution types
export interface AttributionSource {
name: string
license: string
url: string
attribution: string
required: boolean
logo_url?: string
}
export interface Attribution {
sources: AttributionSource[]
generated_at: string
notice: string
}
// Tile metadata types
export interface TileMetadata {
name: string
description: string
format: string
scheme: string
minzoom: number
maxzoom: number
bounds: [number, number, number, number]
center: [number, number, number]
attribution: string
data_available: boolean
last_updated?: string
}
export interface DEMMetadata {
name: string
description: string
resolution_m: number
coverage: string
bounds: [number, number, number, number]
vertical_datum: string
horizontal_datum: string
license: string
attribution: string
data_available: boolean
tiles_generated: number
}
// Service health status
export interface GeoServiceHealth {
status: 'healthy' | 'degraded' | 'unhealthy'
service: string
version: string
environment: string
data_status: {
pmtiles_available: boolean
dem_available: boolean
tile_cache_dir: boolean
bundle_dir: boolean
}
config: {
max_aoi_size_km2: number
supported_themes: AOITheme[]
}
}
// Map style types (for MapLibre)
export interface MapStyle {
version: number
name: string
metadata: {
description: string
attribution: string
}
sources: {
[key: string]: {
type: string
tiles?: string[]
url?: string
minzoom?: number
maxzoom?: number
attribution?: string
tileSize?: number
}
}
layers: MapLayer[]
terrain?: {
source: string
exaggeration: number
}
}
export interface MapLayer {
id: string
type: string
source?: string // Optional for background layers
'source-layer'?: string
minzoom?: number
maxzoom?: number
filter?: unknown[] // MapLibre filter expressions can have mixed types
layout?: Record<string, unknown>
paint?: Record<string, unknown>
}
// UI State types
export interface GeoLernweltState {
// Current AOI
currentAOI: AOIResponse | null
drawnPolygon: GeoJSONPolygon | null
// Selected theme and settings
selectedTheme: AOITheme
quality: AOIQuality
difficulty: Difficulty
// Learning nodes
learningNodes: LearningNode[]
selectedNode: LearningNode | null
// UI state
isDrawing: boolean
isLoading: boolean
error: string | null
// Unity viewer state
unityReady: boolean
unityProgress: number
}
// API response wrapper
export interface ApiResponse<T> {
data?: T
error?: string
message?: string
}
// Demo templates
export interface DemoTemplate {
name: string
description: string
polygon: GeoJSONPolygon
center: [number, number]
area_km2: number
suggested_themes: AOITheme[]
features: string[]
}
+37
View File
@@ -0,0 +1,37 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* BreakPilot Studio v2 - Base Styles */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f8fafc;
color: #1e293b;
min-height: 100vh;
}
/* Scrollbar Styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
+109
View File
@@ -0,0 +1,109 @@
'use client'
import Link from 'next/link'
import { useLanguage } from '@/lib/LanguageContext'
import { useTheme } from '@/lib/ThemeContext'
import { Footer } from '@/components/Footer'
export default function ImpressumPage() {
const { t } = useLanguage()
const { isDark } = useTheme()
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'
}`}>
<div className="flex-1 p-8">
<div className="max-w-4xl mx-auto">
{/* Back Link */}
<Link
href="/"
className={`inline-flex items-center gap-2 mb-8 transition-colors ${
isDark ? 'text-white/60 hover:text-white' : 'text-slate-600 hover:text-slate-900'
}`}
>
<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>
{t('back_to_selection')}
</Link>
{/* Content Card */}
<div className={`backdrop-blur-xl border rounded-3xl p-8 ${
isDark
? 'bg-white/10 border-white/20'
: 'bg-white/70 border-black/10 shadow-lg'
}`}>
<h1 className={`text-3xl font-bold mb-8 ${isDark ? 'text-white' : 'text-slate-900'}`}>
{t('imprint')}
</h1>
<div className={`space-y-6 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
<section>
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Angaben gemäß § 5 TMG
</h2>
<p>
BreakPilot GmbH<br />
Musterstraße 123<br />
12345 Musterstadt<br />
Deutschland
</p>
</section>
<section>
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Kontakt
</h2>
<p>
Telefon: +49 (0) 123 456789<br />
E-Mail: info@breakpilot.de
</p>
</section>
<section>
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Vertretungsberechtigte Geschäftsführer
</h2>
<p>Max Mustermann</p>
</section>
<section>
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Registereintrag
</h2>
<p>
Eintragung im Handelsregister<br />
Registergericht: Amtsgericht Musterstadt<br />
Registernummer: HRB 12345
</p>
</section>
<section>
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Umsatzsteuer-ID
</h2>
<p>
Umsatzsteuer-Identifikationsnummer gemäß § 27 a Umsatzsteuergesetz:<br />
DE 123456789
</p>
</section>
<div className={`mt-8 p-4 rounded-2xl ${
isDark ? 'bg-yellow-500/20 border border-yellow-500/30' : 'bg-yellow-50 border border-yellow-200'
}`}>
<p className={`text-sm ${isDark ? 'text-yellow-200' : 'text-yellow-800'}`}>
Hinweis: Dies ist ein Platzhalter. Bitte ersetzen Sie diese Angaben durch Ihre tatsächlichen Unternehmensdaten.
</p>
</div>
</div>
</div>
</div>
</div>
<Footer />
</div>
)
}
+165
View File
@@ -0,0 +1,165 @@
'use client'
import Link from 'next/link'
import { useLanguage } from '@/lib/LanguageContext'
import { useTheme } from '@/lib/ThemeContext'
import { Footer } from '@/components/Footer'
export default function KontaktPage() {
const { t } = useLanguage()
const { isDark } = useTheme()
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'
}`}>
<div className="flex-1 p-8">
<div className="max-w-4xl mx-auto">
{/* Back Link */}
<Link
href="/"
className={`inline-flex items-center gap-2 mb-8 transition-colors ${
isDark ? 'text-white/60 hover:text-white' : 'text-slate-600 hover:text-slate-900'
}`}
>
<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>
{t('back_to_selection')}
</Link>
{/* Content Card */}
<div className={`backdrop-blur-xl border rounded-3xl p-8 ${
isDark
? 'bg-white/10 border-white/20'
: 'bg-white/70 border-black/10 shadow-lg'
}`}>
<h1 className={`text-3xl font-bold mb-8 ${isDark ? 'text-white' : 'text-slate-900'}`}>
{t('contact')}
</h1>
<div className={`space-y-8 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
{/* Contact Info */}
<div className="grid md:grid-cols-2 gap-8">
<section>
<h2 className={`text-xl font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Kontaktdaten
</h2>
<div className="space-y-3">
<div className="flex items-center gap-3">
<svg className={`w-5 h-5 ${isDark ? 'text-white/60' : 'text-slate-500'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<span>info@breakpilot.de</span>
</div>
<div className="flex items-center gap-3">
<svg className={`w-5 h-5 ${isDark ? 'text-white/60' : 'text-slate-500'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
<span>+49 (0) 123 456789</span>
</div>
<div className="flex items-start gap-3">
<svg className={`w-5 h-5 mt-0.5 ${isDark ? 'text-white/60' : 'text-slate-500'}`} 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" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span>
BreakPilot GmbH<br />
Musterstraße 123<br />
12345 Musterstadt
</span>
</div>
</div>
</section>
<section>
<h2 className={`text-xl font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Support
</h2>
<p className="mb-4">
Unser Support-Team ist für Sie da:
</p>
<div className="space-y-2">
<p>Mo - Fr: 9:00 - 17:00 Uhr</p>
<p>E-Mail: support@breakpilot.de</p>
</div>
</section>
</div>
{/* Contact Form Placeholder */}
<section>
<h2 className={`text-xl font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Nachricht senden
</h2>
<div className={`p-6 rounded-2xl border ${
isDark ? 'bg-white/5 border-white/10' : 'bg-slate-50 border-slate-200'
}`}>
<div className="space-y-4">
<div>
<label className={`block text-sm font-medium mb-2 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
Name
</label>
<input
type="text"
className={`w-full px-4 py-3 rounded-xl border transition-colors ${
isDark
? 'bg-white/10 border-white/20 text-white placeholder-white/40 focus:border-white/40'
: 'bg-white border-slate-300 text-slate-900 placeholder-slate-400 focus:border-indigo-500'
} focus:outline-none`}
placeholder="Ihr Name"
/>
</div>
<div>
<label className={`block text-sm font-medium mb-2 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
E-Mail
</label>
<input
type="email"
className={`w-full px-4 py-3 rounded-xl border transition-colors ${
isDark
? 'bg-white/10 border-white/20 text-white placeholder-white/40 focus:border-white/40'
: 'bg-white border-slate-300 text-slate-900 placeholder-slate-400 focus:border-indigo-500'
} focus:outline-none`}
placeholder="ihre@email.de"
/>
</div>
<div>
<label className={`block text-sm font-medium mb-2 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
Nachricht
</label>
<textarea
rows={4}
className={`w-full px-4 py-3 rounded-xl border transition-colors resize-none ${
isDark
? 'bg-white/10 border-white/20 text-white placeholder-white/40 focus:border-white/40'
: 'bg-white border-slate-300 text-slate-900 placeholder-slate-400 focus:border-indigo-500'
} focus:outline-none`}
placeholder="Ihre Nachricht..."
/>
</div>
<button className="w-full py-3 bg-gradient-to-r from-blue-500 to-purple-500 text-white font-medium rounded-xl hover:shadow-lg hover:shadow-purple-500/30 transition-all">
Nachricht senden
</button>
</div>
</div>
</section>
<div className={`mt-8 p-4 rounded-2xl ${
isDark ? 'bg-yellow-500/20 border border-yellow-500/30' : 'bg-yellow-50 border border-yellow-200'
}`}>
<p className={`text-sm ${isDark ? 'text-yellow-200' : 'text-yellow-800'}`}>
Hinweis: Das Kontaktformular ist noch nicht funktionsfähig. Bitte nutzen Sie vorerst
die angegebene E-Mail-Adresse für Anfragen.
</p>
</div>
</div>
</div>
</div>
</div>
<Footer />
</div>
)
}
@@ -0,0 +1,492 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { useTheme } from '@/lib/ThemeContext'
import { Sidebar } from '@/components/Sidebar'
import { ThemeToggle } from '@/components/ThemeToggle'
import { LanguageDropdown } from '@/components/LanguageDropdown'
import {
DocumentViewer,
AnnotationLayer,
AnnotationToolbar,
AnnotationLegend,
CriteriaPanel,
GutachtenEditor,
EHSuggestionPanel,
} from '@/components/korrektur'
import { korrekturApi } from '@/lib/korrektur/api'
import type {
Klausur,
StudentWork,
Annotation,
AnnotationType,
AnnotationPosition,
CriteriaScores,
EHSuggestion,
} from '../../types'
// =============================================================================
// GLASS CARD
// =============================================================================
interface GlassCardProps {
children: React.ReactNode
className?: string
}
function GlassCard({ children, className = '' }: GlassCardProps) {
return (
<div
className={`rounded-3xl p-4 ${className}`}
style={{
background: 'rgba(255, 255, 255, 0.08)',
backdropFilter: 'blur(24px) saturate(180%)',
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
border: '1px solid rgba(255, 255, 255, 0.1)',
boxShadow: '0 4px 24px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.05)',
}}
>
{children}
</div>
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
export default function StudentWorkspacePage() {
const { isDark } = useTheme()
const router = useRouter()
const params = useParams()
const klausurId = params.klausurId as string
const studentId = params.studentId as string
// State
const [klausur, setKlausur] = useState<Klausur | null>(null)
const [student, setStudent] = useState<StudentWork | null>(null)
const [students, setStudents] = useState<StudentWork[]>([])
const [annotations, setAnnotations] = useState<Annotation[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Editor state
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [selectedTool, setSelectedTool] = useState<AnnotationType | null>(null)
const [selectedAnnotation, setSelectedAnnotation] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<'kriterien' | 'gutachten' | 'eh'>('kriterien')
// Criteria and Gutachten state
const [criteriaScores, setCriteriaScores] = useState<CriteriaScores>({})
const [gutachten, setGutachten] = useState('')
const [isGeneratingGutachten, setIsGeneratingGutachten] = useState(false)
// EH Suggestions state
const [ehSuggestions, setEhSuggestions] = useState<EHSuggestion[]>([])
const [isLoadingEH, setIsLoadingEH] = useState(false)
// Saving state
const [isSaving, setIsSaving] = useState(false)
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
// Load data
const loadData = useCallback(async () => {
if (!klausurId || !studentId) return
setIsLoading(true)
setError(null)
try {
const [klausurData, studentData, studentsData, annotationsData] = await Promise.all([
korrekturApi.getKlausur(klausurId),
korrekturApi.getStudent(studentId),
korrekturApi.getStudents(klausurId),
korrekturApi.getAnnotations(studentId),
])
setKlausur(klausurData)
setStudent(studentData)
setStudents(studentsData)
setAnnotations(annotationsData)
// Initialize editor state from student data
setCriteriaScores(studentData.criteria_scores || {})
setGutachten(studentData.gutachten || '')
// Estimate total pages (for images, usually 1; for PDFs, would need backend info)
setTotalPages(studentData.file_type === 'pdf' ? 5 : 1)
} catch (err) {
console.error('Failed to load data:', err)
setError(err instanceof Error ? err.message : 'Laden fehlgeschlagen')
} finally {
setIsLoading(false)
}
}, [klausurId, studentId])
useEffect(() => {
loadData()
}, [loadData])
// Get current student index
const currentIndex = students.findIndex((s) => s.id === studentId)
const prevStudent = currentIndex > 0 ? students[currentIndex - 1] : null
const nextStudent = currentIndex < students.length - 1 ? students[currentIndex + 1] : null
// Navigation
const goToStudent = (id: string) => {
if (hasUnsavedChanges) {
if (!confirm('Sie haben ungespeicherte Aenderungen. Trotzdem wechseln?')) {
return
}
}
router.push(`/korrektur/${klausurId}/${id}`)
}
// Handle criteria change
const handleCriteriaChange = (criterion: string, value: number) => {
setCriteriaScores((prev) => ({
...prev,
[criterion]: value,
}))
setHasUnsavedChanges(true)
}
// Handle gutachten change
const handleGutachtenChange = (value: string) => {
setGutachten(value)
setHasUnsavedChanges(true)
}
// Generate gutachten
const handleGenerateGutachten = async () => {
setIsGeneratingGutachten(true)
try {
const result = await korrekturApi.generateGutachten(studentId)
setGutachten(result.gutachten)
setHasUnsavedChanges(true)
} catch (err) {
console.error('Failed to generate gutachten:', err)
setError('Gutachten-Generierung fehlgeschlagen')
} finally {
setIsGeneratingGutachten(false)
}
}
// Load EH suggestions
const handleLoadEHSuggestions = async (criterion?: string) => {
setIsLoadingEH(true)
try {
const suggestions = await korrekturApi.getEHSuggestions(studentId, criterion)
setEhSuggestions(suggestions)
} catch (err) {
console.error('Failed to load EH suggestions:', err)
setError('EH-Vorschlaege konnten nicht geladen werden')
} finally {
setIsLoadingEH(false)
}
}
// Create annotation
const handleAnnotationCreate = async (position: AnnotationPosition, type: AnnotationType) => {
try {
const newAnnotation = await korrekturApi.createAnnotation(studentId, {
page: currentPage,
position,
type,
text: '',
severity: 'minor',
})
setAnnotations((prev) => [...prev, newAnnotation])
setSelectedAnnotation(newAnnotation.id)
setSelectedTool(null)
} catch (err) {
console.error('Failed to create annotation:', err)
}
}
// Delete annotation
const handleAnnotationDelete = async (id: string) => {
try {
await korrekturApi.deleteAnnotation(id)
setAnnotations((prev) => prev.filter((a) => a.id !== id))
setSelectedAnnotation(null)
} catch (err) {
console.error('Failed to delete annotation:', err)
}
}
// Save all changes
const handleSave = async () => {
setIsSaving(true)
try {
await Promise.all([
korrekturApi.updateCriteria(studentId, criteriaScores),
korrekturApi.updateGutachten(studentId, gutachten),
])
setHasUnsavedChanges(false)
} catch (err) {
console.error('Failed to save:', err)
setError('Speichern fehlgeschlagen')
} finally {
setIsSaving(false)
}
}
// Insert EH suggestion into gutachten
const handleInsertSuggestion = (text: string) => {
setGutachten((prev) => prev + '\n\n' + text)
setHasUnsavedChanges(true)
setActiveTab('gutachten')
}
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Don't trigger shortcuts when typing in inputs
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return
}
if (e.key === 'Escape') {
setSelectedTool(null)
setSelectedAnnotation(null)
} else if (e.key === 'r' || e.key === 'R') {
setSelectedTool('rechtschreibung')
} else if (e.key === 'g' || e.key === 'G') {
setSelectedTool('grammatik')
} else if (e.key === 'i' || e.key === 'I') {
setSelectedTool('inhalt')
} else if (e.key === 's' && e.metaKey) {
e.preventDefault()
handleSave()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [criteriaScores, gutachten])
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-purple-900/30 to-slate-900">
<div className="w-12 h-12 border-4 border-purple-500 border-t-transparent rounded-full animate-spin" />
</div>
)
}
return (
<div className="min-h-screen flex relative overflow-hidden bg-gradient-to-br from-slate-900 via-purple-900/30 to-slate-900">
{/* Animated Background Blobs */}
<div className="absolute -top-40 -right-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob bg-purple-500 opacity-30" />
<div className="absolute top-1/2 -left-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-2000 bg-blue-500 opacity-30" />
{/* Sidebar */}
<div className="relative z-10 p-4">
<Sidebar />
</div>
{/* Main Content */}
<div className="flex-1 flex flex-col relative z-10 p-4 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-4">
<button
onClick={() => router.push(`/korrektur/${klausurId}`)}
className="p-2 rounded-xl bg-white/10 hover:bg-white/20 text-white transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
</button>
<div>
<h1 className="text-xl font-bold text-white">
{student?.anonym_id || 'Student'}
</h1>
<p className="text-white/50 text-sm">{klausur?.title}</p>
</div>
</div>
{/* Navigation */}
<div className="flex items-center gap-2">
<button
onClick={() => prevStudent && goToStudent(prevStudent.id)}
disabled={!prevStudent}
className="p-2 rounded-xl bg-white/10 hover:bg-white/20 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 px-3">
{currentIndex + 1} / {students.length}
</span>
<button
onClick={() => nextStudent && goToStudent(nextStudent.id)}
disabled={!nextStudent}
className="p-2 rounded-xl bg-white/10 hover:bg-white/20 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 className="flex items-center gap-3">
{hasUnsavedChanges && (
<span className="text-amber-400 text-sm">Ungespeicherte Aenderungen</span>
)}
<button
onClick={handleSave}
disabled={isSaving || !hasUnsavedChanges}
className="px-4 py-2 rounded-xl bg-gradient-to-r from-green-500 to-emerald-500 text-white font-medium hover:shadow-lg hover:shadow-green-500/30 transition-all disabled:opacity-50 flex items-center gap-2"
>
{isSaving ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Speichern...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Speichern
</>
)}
</button>
<ThemeToggle />
<LanguageDropdown />
</div>
</div>
{/* Error Display */}
{error && (
<GlassCard className="mb-4">
<div className="flex items-center gap-3 text-red-400">
<svg className="w-5 h-5" 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>
<span className="text-sm">{error}</span>
<button
onClick={() => setError(null)}
className="ml-auto text-white/60 hover:text-white"
>
<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>
</GlassCard>
)}
{/* Main Workspace - 2/3 - 1/3 Layout */}
<div className="flex-1 flex gap-4 overflow-hidden">
{/* Left: Document Viewer (2/3) */}
<div className="w-2/3 flex flex-col">
<GlassCard className="flex-1 flex flex-col overflow-hidden">
<DocumentViewer
fileUrl={korrekturApi.getStudentFileUrl(studentId)}
fileType={student?.file_type || 'image'}
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
>
<AnnotationLayer
annotations={annotations.filter((a) => a.page === currentPage)}
selectedAnnotation={selectedAnnotation}
currentTool={selectedTool}
onAnnotationCreate={handleAnnotationCreate}
onAnnotationSelect={setSelectedAnnotation}
onAnnotationDelete={handleAnnotationDelete}
/>
</DocumentViewer>
</GlassCard>
{/* Annotation Toolbar */}
<div className="mt-3 flex items-center justify-between">
<AnnotationToolbar
selectedTool={selectedTool}
onToolSelect={setSelectedTool}
/>
<AnnotationLegend className="hidden lg:flex" />
</div>
</div>
{/* Right: Criteria/Gutachten Panel (1/3) */}
<div className="w-1/3 flex flex-col">
<GlassCard className="flex-1 flex flex-col overflow-hidden">
{/* Tabs */}
<div className="flex border-b border-white/10 mb-4">
<button
onClick={() => setActiveTab('kriterien')}
className={`flex-1 py-2 text-sm font-medium transition-colors ${
activeTab === 'kriterien'
? 'text-white border-b-2 border-purple-500'
: 'text-white/50 hover:text-white'
}`}
>
Kriterien
</button>
<button
onClick={() => setActiveTab('gutachten')}
className={`flex-1 py-2 text-sm font-medium transition-colors ${
activeTab === 'gutachten'
? 'text-white border-b-2 border-purple-500'
: 'text-white/50 hover:text-white'
}`}
>
Gutachten
</button>
<button
onClick={() => setActiveTab('eh')}
className={`flex-1 py-2 text-sm font-medium transition-colors ${
activeTab === 'eh'
? 'text-white border-b-2 border-purple-500'
: 'text-white/50 hover:text-white'
}`}
>
EH
</button>
</div>
{/* Tab Content */}
<div className="flex-1 overflow-y-auto">
{activeTab === 'kriterien' && (
<CriteriaPanel
scores={criteriaScores}
annotations={annotations}
onScoreChange={handleCriteriaChange}
onLoadEHSuggestions={(criterion) => {
handleLoadEHSuggestions(criterion)
setActiveTab('eh')
}}
/>
)}
{activeTab === 'gutachten' && (
<GutachtenEditor
value={gutachten}
onChange={handleGutachtenChange}
onGenerate={handleGenerateGutachten}
isGenerating={isGeneratingGutachten}
/>
)}
{activeTab === 'eh' && (
<EHSuggestionPanel
suggestions={ehSuggestions}
isLoading={isLoadingEH}
onLoadSuggestions={handleLoadEHSuggestions}
onInsertSuggestion={handleInsertSuggestion}
/>
)}
</div>
</GlassCard>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,569 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { useTheme } from '@/lib/ThemeContext'
import { Sidebar } from '@/components/Sidebar'
import { ThemeToggle } from '@/components/ThemeToggle'
import { LanguageDropdown } from '@/components/LanguageDropdown'
import { korrekturApi } from '@/lib/korrektur/api'
import type { Klausur, StudentWork, FairnessAnalysis } from '../../types'
import { DEFAULT_CRITERIA, ANNOTATION_COLORS, getGradeLabel } from '../../types'
// =============================================================================
// GLASS CARD
// =============================================================================
interface GlassCardProps {
children: React.ReactNode
className?: string
delay?: number
isDark?: boolean
}
function GlassCard({ children, className = '', delay = 0, isDark = true }: GlassCardProps) {
const [isVisible, setIsVisible] = useState(false)
useEffect(() => {
const timer = setTimeout(() => setIsVisible(true), delay)
return () => clearTimeout(timer)
}, [delay])
return (
<div
className={`rounded-3xl p-5 ${className}`}
style={{
background: isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(255, 255, 255, 0.7)',
backdropFilter: 'blur(24px) saturate(180%)',
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
border: isDark ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.1)',
boxShadow: isDark
? '0 4px 24px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.05)'
: '0 4px 24px rgba(0, 0, 0, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.5)',
opacity: isVisible ? 1 : 0,
transform: isVisible ? 'translateY(0)' : 'translateY(20px)',
transition: 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
}}
>
{children}
</div>
)
}
// =============================================================================
// HISTOGRAM
// =============================================================================
interface HistogramProps {
students: StudentWork[]
className?: string
isDark?: boolean
}
function Histogram({ students, className = '', isDark = true }: HistogramProps) {
// Group students by grade points (0-15)
const distribution = useMemo(() => {
const counts: Record<number, number> = {}
for (let i = 0; i <= 15; i++) {
counts[i] = 0
}
for (const student of students) {
if (student.grade_points !== undefined) {
counts[student.grade_points] = (counts[student.grade_points] || 0) + 1
}
}
return counts
}, [students])
const maxCount = Math.max(...Object.values(distribution), 1)
return (
<div className={className}>
<h3 className={`font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>Notenverteilung</h3>
<div className="flex items-end gap-1 h-40">
{Array.from({ length: 16 }, (_, i) => 15 - i).map((grade) => {
const count = distribution[grade] || 0
const height = (count / maxCount) * 100
// Color based on grade
let color = '#22c55e' // Green for good grades
if (grade <= 4) color = '#ef4444' // Red for poor grades
else if (grade <= 9) color = '#f97316' // Orange for medium grades
return (
<div
key={grade}
className="flex-1 flex flex-col items-center gap-1"
>
<span className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>{count || ''}</span>
<div
className="w-full rounded-t transition-all hover:opacity-80"
style={{
height: `${height}%`,
minHeight: count > 0 ? '8px' : '0',
backgroundColor: color,
}}
title={`${grade} Punkte (${getGradeLabel(grade)}): ${count} Schueler`}
/>
<span className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{grade}</span>
</div>
)
})}
</div>
<p className={`text-xs text-center mt-2 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>Punkte</p>
</div>
)
}
// =============================================================================
// CRITERIA HEATMAP
// =============================================================================
interface CriteriaHeatmapProps {
students: StudentWork[]
className?: string
isDark?: boolean
}
function CriteriaHeatmap({ students, className = '', isDark = true }: CriteriaHeatmapProps) {
// Calculate average for each criterion
const criteriaAverages = useMemo(() => {
const sums: Record<string, { sum: number; count: number }> = {}
for (const criterion of Object.keys(DEFAULT_CRITERIA)) {
sums[criterion] = { sum: 0, count: 0 }
}
for (const student of students) {
if (student.criteria_scores) {
for (const [criterion, score] of Object.entries(student.criteria_scores)) {
if (score !== undefined && sums[criterion]) {
sums[criterion].sum += score
sums[criterion].count += 1
}
}
}
}
const averages: Record<string, number> = {}
for (const [criterion, data] of Object.entries(sums)) {
averages[criterion] = data.count > 0 ? Math.round(data.sum / data.count) : 0
}
return averages
}, [students])
return (
<div className={className}>
<h3 className={`font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>Kriterien-Durchschnitt</h3>
<div className="space-y-3">
{Object.entries(DEFAULT_CRITERIA).map(([criterion, config]) => {
const average = criteriaAverages[criterion] || 0
const color = ANNOTATION_COLORS[criterion as keyof typeof ANNOTATION_COLORS] || '#6b7280'
return (
<div key={criterion} className="space-y-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: color }}
/>
<span className={`text-sm ${isDark ? 'text-white/70' : 'text-slate-600'}`}>{config.name}</span>
</div>
<span className={`text-sm font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>{average}%</span>
</div>
<div className={`h-2 rounded-full overflow-hidden ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}>
<div
className="h-full rounded-full transition-all"
style={{
width: `${average}%`,
backgroundColor: color,
}}
/>
</div>
</div>
)
})}
</div>
</div>
)
}
// =============================================================================
// OUTLIER LIST
// =============================================================================
interface OutlierListProps {
fairness: FairnessAnalysis | null
onStudentClick: (studentId: string) => void
className?: string
isDark?: boolean
}
function OutlierList({ fairness, onStudentClick, className = '', isDark = true }: OutlierListProps) {
if (!fairness || fairness.outliers.length === 0) {
return (
<div className={`text-center py-8 ${className}`}>
<div className="w-12 h-12 rounded-full bg-green-500/20 flex items-center justify-center mx-auto mb-3">
<svg className="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-600'}`}>Keine Ausreisser erkannt</p>
<p className={`text-xs mt-1 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>Alle Bewertungen sind konsistent</p>
</div>
)
}
return (
<div className={className}>
<h3 className={`font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Ausreisser ({fairness.outliers.length})
</h3>
<div className="space-y-2 max-h-64 overflow-y-auto">
{fairness.outliers.map((outlier) => (
<button
key={outlier.student_id}
onClick={() => onStudentClick(outlier.student_id)}
className={`w-full p-3 rounded-xl border transition-colors text-left ${isDark ? 'bg-white/5 border-white/10 hover:bg-white/10' : 'bg-slate-100 border-slate-200 hover:bg-slate-200'}`}
>
<div className="flex items-center justify-between mb-1">
<span className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>{outlier.anonym_id}</span>
<span
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
outlier.deviation > 0
? 'bg-green-500/20 text-green-400'
: 'bg-red-500/20 text-red-400'
}`}
>
{outlier.deviation > 0 ? '+' : ''}{outlier.deviation.toFixed(1)} Punkte
</span>
</div>
<div className="flex items-center justify-between">
<span className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
{outlier.grade_points} Punkte ({getGradeLabel(outlier.grade_points)})
</span>
<span className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>{outlier.reason}</span>
</div>
</button>
))}
</div>
</div>
)
}
// =============================================================================
// FAIRNESS SCORE
// =============================================================================
interface FairnessScoreProps {
fairness: FairnessAnalysis | null
className?: string
isDark?: boolean
}
function FairnessScore({ fairness, className = '', isDark = true }: FairnessScoreProps) {
const score = fairness?.fairness_score || 0
const percentage = Math.round(score * 100)
let color = '#22c55e' // Green
let label = 'Ausgezeichnet'
if (percentage < 70) {
color = '#ef4444'
label = 'Ueberpruefung empfohlen'
} else if (percentage < 85) {
color = '#f97316'
label = 'Akzeptabel'
} else if (percentage < 95) {
color = '#22c55e'
label = 'Gut'
}
// SVG ring
const size = 120
const strokeWidth = 10
const radius = (size - strokeWidth) / 2
const circumference = radius * 2 * Math.PI
const offset = circumference - (percentage / 100) * circumference
return (
<div className={`text-center ${className}`}>
<div className="relative inline-block" style={{ width: size, height: size }}>
<svg className="transform -rotate-90" width={size} height={size}>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'}
strokeWidth={strokeWidth}
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
style={{ transition: 'stroke-dashoffset 1s ease-out' }}
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className={`text-3xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>{percentage}</span>
<span className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>%</span>
</div>
</div>
<p className={`font-medium mt-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>{label}</p>
<p className={`text-xs mt-1 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>Fairness-Score</p>
</div>
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
export default function FairnessPage() {
const { isDark } = useTheme()
const router = useRouter()
const params = useParams()
const klausurId = params.klausurId as string
// State
const [klausur, setKlausur] = useState<Klausur | null>(null)
const [students, setStudents] = useState<StudentWork[]>([])
const [fairness, setFairness] = useState<FairnessAnalysis | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Load data
const loadData = useCallback(async () => {
if (!klausurId) return
setIsLoading(true)
setError(null)
try {
const [klausurData, studentsData, fairnessData] = await Promise.all([
korrekturApi.getKlausur(klausurId),
korrekturApi.getStudents(klausurId),
korrekturApi.getFairnessAnalysis(klausurId),
])
setKlausur(klausurData)
setStudents(studentsData)
setFairness(fairnessData)
} catch (err) {
console.error('Failed to load data:', err)
setError(err instanceof Error ? err.message : 'Laden fehlgeschlagen')
} finally {
setIsLoading(false)
}
}, [klausurId])
useEffect(() => {
loadData()
}, [loadData])
// Calculated stats
const stats = useMemo(() => {
if (!fairness) return null
return {
studentCount: fairness.student_count,
average: fairness.average_grade,
stdDev: fairness.std_deviation,
spread: fairness.spread,
outlierCount: fairness.outliers.length,
warningCount: fairness.warnings.length,
}
}, [fairness])
return (
<div className={`min-h-screen flex relative overflow-hidden ${
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 */}
<div className={`absolute -top-40 -right-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob ${isDark ? 'bg-purple-500 opacity-30' : 'bg-purple-300 opacity-40'}`} />
<div className={`absolute top-1/2 -left-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-2000 ${isDark ? 'bg-pink-500 opacity-30' : 'bg-pink-300 opacity-40'}`} />
<div className={`absolute -bottom-40 right-1/3 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-4000 ${isDark ? 'bg-blue-500 opacity-30' : 'bg-blue-300 opacity-40'}`} />
{/* Sidebar */}
<div className="relative z-10 p-4">
<Sidebar />
</div>
{/* Main Content */}
<div className="flex-1 flex flex-col relative z-10 p-6 overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-4">
<button
onClick={() => router.push(`/korrektur/${klausurId}`)}
className={`p-2 rounded-xl transition-colors ${isDark ? 'bg-white/10 hover:bg-white/20 text-white' : 'bg-slate-200 hover:bg-slate-300 text-slate-700'}`}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
</button>
<div>
<h1 className={`text-3xl font-bold mb-1 ${isDark ? 'text-white' : 'text-slate-900'}`}>Fairness-Analyse</h1>
<p className={isDark ? 'text-white/50' : 'text-slate-500'}>{klausur?.title}</p>
</div>
</div>
<div className="flex items-center gap-3">
<a
href={korrekturApi.getOverviewExportUrl(klausurId)}
target="_blank"
rel="noopener noreferrer"
className={`px-4 py-2 rounded-xl transition-colors flex items-center gap-2 ${isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-200 text-slate-700 hover:bg-slate-300'}`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
PDF Export
</a>
<ThemeToggle />
<LanguageDropdown />
</div>
</div>
{/* Error Display */}
{error && (
<GlassCard className="mb-6" isDark={isDark}>
<div className="flex items-center gap-3 text-red-400">
<svg className="w-6 h-6" 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>
<span>{error}</span>
<button
onClick={loadData}
className={`ml-auto px-3 py-1 rounded-lg transition-colors text-sm ${isDark ? 'bg-white/10 hover:bg-white/20' : 'bg-slate-200 hover:bg-slate-300'}`}
>
Erneut versuchen
</button>
</div>
</GlassCard>
)}
{/* Loading */}
{isLoading && (
<div className="flex-1 flex items-center justify-center">
<div className="w-12 h-12 border-4 border-purple-500 border-t-transparent rounded-full animate-spin" />
</div>
)}
{/* Content */}
{!isLoading && fairness && (
<>
{/* Stats Row */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 mb-6">
<GlassCard delay={100} isDark={isDark}>
<p className={`text-xs mb-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Arbeiten</p>
<p className={`text-2xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>{stats?.studentCount}</p>
</GlassCard>
<GlassCard delay={150} isDark={isDark}>
<p className={`text-xs mb-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Durchschnitt</p>
<p className={`text-2xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
{stats?.average.toFixed(1)} P
</p>
</GlassCard>
<GlassCard delay={200} isDark={isDark}>
<p className={`text-xs mb-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Standardabw.</p>
<p className={`text-2xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
{stats?.stdDev.toFixed(2)}
</p>
</GlassCard>
<GlassCard delay={250} isDark={isDark}>
<p className={`text-xs mb-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Spannweite</p>
<p className={`text-2xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>{stats?.spread} P</p>
</GlassCard>
<GlassCard delay={300} isDark={isDark}>
<p className={`text-xs mb-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Ausreisser</p>
<p className={`text-2xl font-bold ${stats?.outlierCount ? 'text-amber-400' : 'text-green-400'}`}>
{stats?.outlierCount}
</p>
</GlassCard>
<GlassCard delay={350} isDark={isDark}>
<p className={`text-xs mb-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Warnungen</p>
<p className={`text-2xl font-bold ${stats?.warningCount ? 'text-red-400' : 'text-green-400'}`}>
{stats?.warningCount}
</p>
</GlassCard>
</div>
{/* Warnings */}
{fairness.warnings.length > 0 && (
<GlassCard className="mb-6" delay={400} isDark={isDark}>
<h3 className={`font-semibold mb-3 flex items-center gap-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
<svg className="w-5 h-5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
Warnungen
</h3>
<ul className="space-y-2">
{fairness.warnings.map((warning, index) => (
<li key={index} className={`text-sm flex items-start gap-2 ${isDark ? 'text-white/70' : 'text-slate-600'}`}>
<span className="text-amber-400 mt-1">-</span>
{warning}
</li>
))}
</ul>
</GlassCard>
)}
{/* Main Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Fairness Score */}
<GlassCard delay={450} isDark={isDark}>
<FairnessScore fairness={fairness} isDark={isDark} />
</GlassCard>
{/* Histogram */}
<GlassCard className="lg:col-span-2" delay={500} isDark={isDark}>
<Histogram students={students} isDark={isDark} />
</GlassCard>
{/* Criteria Heatmap */}
<GlassCard delay={550} isDark={isDark}>
<CriteriaHeatmap students={students} isDark={isDark} />
</GlassCard>
{/* Outlier List */}
<GlassCard className="lg:col-span-2" delay={600} isDark={isDark}>
<OutlierList
fairness={fairness}
onStudentClick={(studentId) =>
router.push(`/korrektur/${klausurId}/${studentId}`)
}
isDark={isDark}
/>
</GlassCard>
</div>
</>
)}
{/* No Data */}
{!isLoading && !fairness && !error && (
<GlassCard className="text-center py-12" isDark={isDark}>
<div className={`w-16 h-16 rounded-2xl flex items-center justify-center mx-auto mb-4 ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}>
<svg className={`w-8 h-8 ${isDark ? 'text-white/30' : 'text-slate-400'}`} 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>
</div>
<h3 className={`text-xl font-semibold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>Keine Daten verfuegbar</h3>
<p className={isDark ? 'text-white/50' : 'text-slate-500'}>
Die Fairness-Analyse erfordert korrigierte Arbeiten.
</p>
</GlassCard>
)}
</div>
</div>
)
}
@@ -0,0 +1,578 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { useTheme } from '@/lib/ThemeContext'
import { Sidebar } from '@/components/Sidebar'
import { ThemeToggle } from '@/components/ThemeToggle'
import { LanguageDropdown } from '@/components/LanguageDropdown'
import { QRCodeUpload, UploadedFile } from '@/components/QRCodeUpload'
import { korrekturApi } from '@/lib/korrektur/api'
import type { Klausur, StudentWork, StudentStatus } from '../types'
import { STATUS_COLORS, STATUS_LABELS, getGradeLabel } from '../types'
// LocalStorage Key for upload session
const SESSION_ID_KEY = 'bp_korrektur_student_session'
// =============================================================================
// GLASS CARD
// =============================================================================
interface GlassCardProps {
children: React.ReactNode
className?: string
onClick?: () => void
size?: 'sm' | 'md' | 'lg'
delay?: number
}
function GlassCard({ children, className = '', onClick, size = 'md', delay = 0, isDark = true }: GlassCardProps & { isDark?: boolean }) {
const [isVisible, setIsVisible] = useState(false)
const [isHovered, setIsHovered] = useState(false)
useEffect(() => {
const timer = setTimeout(() => setIsVisible(true), delay)
return () => clearTimeout(timer)
}, [delay])
const sizeClasses = {
sm: 'p-4',
md: 'p-5',
lg: 'p-6',
}
return (
<div
className={`
rounded-3xl
${sizeClasses[size]}
${onClick ? 'cursor-pointer' : ''}
${className}
`}
style={{
background: isDark
? (isHovered ? 'rgba(255, 255, 255, 0.12)' : 'rgba(255, 255, 255, 0.08)')
: (isHovered ? 'rgba(255, 255, 255, 0.9)' : 'rgba(255, 255, 255, 0.7)'),
backdropFilter: 'blur(24px) saturate(180%)',
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
border: isDark ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.1)',
boxShadow: isDark
? '0 4px 24px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.05)'
: '0 4px 24px rgba(0, 0, 0, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.5)',
opacity: isVisible ? 1 : 0,
transform: isVisible
? `translateY(0) scale(${isHovered ? 1.01 : 1})`
: 'translateY(20px)',
transition: 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={onClick}
>
{children}
</div>
)
}
// =============================================================================
// STUDENT CARD
// =============================================================================
interface StudentCardProps {
student: StudentWork
index: number
onClick: () => void
delay?: number
isDark?: boolean
}
function StudentCard({ student, index, onClick, delay = 0, isDark = true }: StudentCardProps) {
const statusColor = STATUS_COLORS[student.status] || '#6b7280'
const statusLabel = STATUS_LABELS[student.status] || student.status
const hasGrade = student.status === 'COMPLETED' || student.status === 'FIRST_EXAMINER' || student.status === 'SECOND_EXAMINER'
return (
<GlassCard onClick={onClick} delay={delay} size="sm" isDark={isDark}>
<div className="flex items-center gap-4">
{/* Index/Number */}
<div className={`w-10 h-10 rounded-xl flex items-center justify-center font-medium ${isDark ? 'bg-white/10 text-white/60' : 'bg-slate-200 text-slate-600'}`}>
{index + 1}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<p className={`font-medium truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>{student.anonym_id}</p>
<div className="flex items-center gap-2 mt-1">
<span
className="px-2 py-0.5 rounded-full text-xs font-medium"
style={{ backgroundColor: `${statusColor}20`, color: statusColor }}
>
{statusLabel}
</span>
{hasGrade && student.grade_points > 0 && (
<span className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
{student.grade_points} P ({getGradeLabel(student.grade_points)})
</span>
)}
</div>
</div>
{/* Arrow */}
<svg className={`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="M9 5l7 7-7 7" />
</svg>
</div>
</GlassCard>
)
}
// =============================================================================
// UPLOAD MODAL
// =============================================================================
interface UploadModalProps {
isOpen: boolean
onClose: () => void
onUpload: (files: File[], anonymIds: string[]) => void
isUploading: boolean
}
function UploadModal({ isOpen, onClose, onUpload, isUploading }: UploadModalProps) {
const [files, setFiles] = useState<File[]>([])
const [anonymIds, setAnonymIds] = useState<string[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
if (!isOpen) return null
const handleFileSelect = (selectedFiles: FileList | null) => {
if (!selectedFiles) return
const newFiles = Array.from(selectedFiles)
setFiles((prev) => [...prev, ...newFiles])
// Generate default anonym IDs
setAnonymIds((prev) => [
...prev,
...newFiles.map((_, i) => `Arbeit-${prev.length + i + 1}`),
])
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
handleFileSelect(e.dataTransfer.files)
}
const removeFile = (index: number) => {
setFiles((prev) => prev.filter((_, i) => i !== index))
setAnonymIds((prev) => prev.filter((_, i) => i !== index))
}
const updateAnonymId = (index: number, value: string) => {
setAnonymIds((prev) => {
const updated = [...prev]
updated[index] = value
return updated
})
}
const handleSubmit = () => {
if (files.length > 0) {
onUpload(files, anonymIds)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
<GlassCard className="relative w-full max-w-2xl max-h-[80vh] overflow-hidden" size="lg" delay={0}>
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-white">Arbeiten hochladen</h2>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-white/10 text-white/60 transition-colors"
>
<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>
{/* Drop Zone */}
<div
className="border-2 border-dashed border-white/20 rounded-2xl p-8 text-center cursor-pointer transition-all hover:border-purple-400/50 hover:bg-white/5 mb-6"
onDrop={handleDrop}
onDragOver={(e) => e.preventDefault()}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept="image/*,.pdf"
multiple
onChange={(e) => handleFileSelect(e.target.files)}
className="hidden"
/>
<svg className="w-12 h-12 mx-auto mb-3 text-white/30" 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-white font-medium">Dateien hierher ziehen</p>
<p className="text-white/50 text-sm mt-1">oder klicken zum Auswaehlen</p>
</div>
{/* File List */}
{files.length > 0 && (
<div className="max-h-64 overflow-y-auto space-y-2 mb-6">
{files.map((file, index) => (
<div
key={index}
className="flex items-center gap-3 p-3 rounded-xl bg-white/5"
>
<span className="text-lg">
{file.type.startsWith('image/') ? '🖼️' : '📄'}
</span>
<div className="flex-1 min-w-0">
<p className="text-white text-sm truncate">{file.name}</p>
<input
type="text"
value={anonymIds[index] || ''}
onChange={(e) => updateAnonymId(index, e.target.value)}
placeholder="Anonym-ID"
className="mt-1 w-full px-2 py-1 rounded bg-white/10 border border-white/10 text-white text-sm placeholder-white/40 focus:outline-none focus:border-purple-500"
/>
</div>
<button
onClick={() => removeFile(index)}
className="p-2 rounded-lg hover:bg-red-500/20 text-red-400 transition-colors"
>
<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>
)}
{/* Actions */}
<div className="flex gap-3">
<button
onClick={onClose}
className="flex-1 px-4 py-3 rounded-xl bg-white/10 text-white hover:bg-white/20 transition-colors"
>
Abbrechen
</button>
<button
onClick={handleSubmit}
disabled={isUploading || files.length === 0}
className="flex-1 px-4 py-3 rounded-xl bg-gradient-to-r from-purple-500 to-pink-500 text-white font-semibold hover:shadow-lg hover:shadow-purple-500/30 transition-all disabled:opacity-50 flex items-center justify-center gap-2"
>
{isUploading ? (
<>
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
Hochladen...
</>
) : (
<>
<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>
{files.length} Dateien hochladen
</>
)}
</button>
</div>
</GlassCard>
</div>
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
export default function KlausurDetailPage() {
const { isDark } = useTheme()
const router = useRouter()
const params = useParams()
const klausurId = params.klausurId as string
// State
const [klausur, setKlausur] = useState<Klausur | null>(null)
const [students, setStudents] = useState<StudentWork[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Modal states
const [showUploadModal, setShowUploadModal] = useState(false)
const [showQRModal, setShowQRModal] = useState(false)
const [isUploading, setIsUploading] = useState(false)
const [uploadSessionId, setUploadSessionId] = useState('')
// Initialize session ID
useEffect(() => {
let storedSessionId = localStorage.getItem(SESSION_ID_KEY)
if (!storedSessionId) {
storedSessionId = `student-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
localStorage.setItem(SESSION_ID_KEY, storedSessionId)
}
setUploadSessionId(storedSessionId)
}, [])
// Load data
const loadData = useCallback(async () => {
if (!klausurId) return
setIsLoading(true)
setError(null)
try {
const [klausurData, studentsData] = await Promise.all([
korrekturApi.getKlausur(klausurId),
korrekturApi.getStudents(klausurId),
])
setKlausur(klausurData)
setStudents(studentsData)
} catch (err) {
console.error('Failed to load data:', err)
setError(err instanceof Error ? err.message : 'Laden fehlgeschlagen')
} finally {
setIsLoading(false)
}
}, [klausurId])
useEffect(() => {
loadData()
}, [loadData])
// Handle upload
const handleUpload = async (files: File[], anonymIds: string[]) => {
setIsUploading(true)
try {
for (let i = 0; i < files.length; i++) {
await korrekturApi.uploadStudentWork(klausurId, files[i], anonymIds[i])
}
setShowUploadModal(false)
loadData() // Refresh the list
} catch (err) {
console.error('Upload failed:', err)
setError(err instanceof Error ? err.message : 'Upload fehlgeschlagen')
} finally {
setIsUploading(false)
}
}
// Calculate progress
const completedCount = students.filter(s => s.status === 'COMPLETED').length
const progress = students.length > 0 ? Math.round((completedCount / students.length) * 100) : 0
return (
<div className={`min-h-screen flex relative overflow-hidden ${
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 */}
<div className={`absolute -top-40 -right-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob ${isDark ? 'bg-purple-500 opacity-30' : 'bg-purple-300 opacity-40'}`} />
<div className={`absolute top-1/2 -left-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-2000 ${isDark ? 'bg-pink-500 opacity-30' : 'bg-pink-300 opacity-40'}`} />
<div className={`absolute -bottom-40 right-1/3 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-4000 ${isDark ? 'bg-blue-500 opacity-30' : 'bg-blue-300 opacity-40'}`} />
{/* Sidebar */}
<div className="relative z-10 p-4">
<Sidebar />
</div>
{/* Main Content */}
<div className="flex-1 flex flex-col relative z-10 p-6 overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-4">
<button
onClick={() => router.push('/korrektur')}
className={`p-2 rounded-xl transition-colors ${isDark ? 'bg-white/10 hover:bg-white/20 text-white' : 'bg-slate-200 hover:bg-slate-300 text-slate-700'}`}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
</button>
<div>
<h1 className={`text-3xl font-bold mb-1 ${isDark ? 'text-white' : 'text-slate-900'}`}>
{klausur?.title || 'Klausur'}
</h1>
<p className={isDark ? 'text-white/50' : 'text-slate-500'}>
{klausur ? `${klausur.subject} ${klausur.semester} ${klausur.year}` : ''}
</p>
</div>
</div>
<div className="flex items-center gap-3">
<ThemeToggle />
<LanguageDropdown />
</div>
</div>
{/* Stats Row */}
{!isLoading && klausur && (
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-8">
<GlassCard size="sm" delay={100} isDark={isDark}>
<div className="text-center">
<p className={`text-3xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>{students.length}</p>
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Arbeiten</p>
</div>
</GlassCard>
<GlassCard size="sm" delay={150} isDark={isDark}>
<div className="text-center">
<p className="text-3xl font-bold text-green-400">{completedCount}</p>
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Abgeschlossen</p>
</div>
</GlassCard>
<GlassCard size="sm" delay={200} isDark={isDark}>
<div className="text-center">
<p className="text-3xl font-bold text-orange-400">{students.length - completedCount}</p>
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Offen</p>
</div>
</GlassCard>
<GlassCard size="sm" delay={250} isDark={isDark}>
<div className="text-center">
<p className="text-3xl font-bold text-purple-400">{progress}%</p>
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Fortschritt</p>
</div>
</GlassCard>
</div>
)}
{/* Progress Bar */}
{!isLoading && students.length > 0 && (
<GlassCard size="sm" className="mb-6" delay={300} isDark={isDark}>
<div className="flex items-center justify-between text-sm mb-2">
<span className={isDark ? 'text-white/50' : 'text-slate-500'}>Gesamtfortschritt</span>
<span className={isDark ? 'text-white' : 'text-slate-900'}>{completedCount}/{students.length} korrigiert</span>
</div>
<div className={`h-3 rounded-full overflow-hidden ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}>
<div
className="h-full rounded-full transition-all duration-500 bg-gradient-to-r from-green-500 to-emerald-400"
style={{ width: `${progress}%` }}
/>
</div>
</GlassCard>
)}
{/* Error Display */}
{error && (
<GlassCard className="mb-6" size="sm" isDark={isDark}>
<div className="flex items-center gap-3 text-red-400">
<svg className="w-6 h-6" 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>
<span>{error}</span>
<button
onClick={loadData}
className={`ml-auto px-3 py-1 rounded-lg transition-colors text-sm ${isDark ? 'bg-white/10 hover:bg-white/20' : 'bg-slate-200 hover:bg-slate-300'}`}
>
Erneut versuchen
</button>
</div>
</GlassCard>
)}
{/* Loading */}
{isLoading && (
<div className="flex-1 flex items-center justify-center">
<div className="w-12 h-12 border-4 border-purple-500 border-t-transparent rounded-full animate-spin" />
</div>
)}
{/* Action Buttons */}
{!isLoading && (
<div className="flex gap-3 mb-6">
<button
onClick={() => setShowUploadModal(true)}
className="px-6 py-3 rounded-xl bg-gradient-to-r from-purple-500 to-pink-500 text-white font-semibold hover:shadow-lg hover:shadow-purple-500/30 transition-all flex items-center gap-2"
>
<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>
Arbeiten hochladen
</button>
<button
onClick={() => setShowQRModal(true)}
className={`px-6 py-3 rounded-xl transition-colors flex items-center gap-2 ${isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-200 text-slate-700 hover:bg-slate-300'}`}
>
<span className="text-xl">📱</span>
QR Upload
</button>
{students.length > 0 && (
<button
onClick={() => router.push(`/korrektur/${klausurId}/fairness`)}
className={`px-6 py-3 rounded-xl transition-colors flex items-center gap-2 ${isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-200 text-slate-700 hover:bg-slate-300'}`}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
Fairness-Analyse
</button>
)}
</div>
)}
{/* Students List */}
{!isLoading && students.length === 0 && (
<GlassCard className="text-center py-12" delay={350} isDark={isDark}>
<div className={`w-20 h-20 mx-auto mb-4 rounded-2xl flex items-center justify-center ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}>
<svg className={`w-10 h-10 ${isDark ? 'text-white/30' : 'text-slate-400'}`} 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>
<h3 className={`text-xl font-semibold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>Keine Arbeiten vorhanden</h3>
<p className={`mb-6 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Laden Sie Schuelerarbeiten hoch, um mit der Korrektur zu beginnen.</p>
<button
onClick={() => setShowUploadModal(true)}
className="px-6 py-3 rounded-xl bg-gradient-to-r from-purple-500 to-pink-500 text-white font-semibold hover:shadow-lg hover:shadow-purple-500/30 transition-all"
>
Arbeiten hochladen
</button>
</GlassCard>
)}
{!isLoading && students.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{students.map((student, index) => (
<StudentCard
key={student.id}
student={student}
index={index}
onClick={() => router.push(`/korrektur/${klausurId}/${student.id}`)}
delay={350 + index * 30}
isDark={isDark}
/>
))}
</div>
)}
</div>
{/* Upload Modal */}
<UploadModal
isOpen={showUploadModal}
onClose={() => setShowUploadModal(false)}
onUpload={handleUpload}
isUploading={isUploading}
/>
{/* QR Code Modal */}
{showQRModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setShowQRModal(false)} />
<div className={`relative w-full max-w-md rounded-3xl ${isDark ? 'bg-slate-900' : 'bg-white'}`}>
<QRCodeUpload
sessionId={uploadSessionId}
onClose={() => setShowQRModal(false)}
onFilesChanged={(files) => {
// Handle mobile uploaded files
if (files.length > 0) {
// Could auto-process the files here
}
}}
/>
</div>
</div>
)}
</div>
)
}
File diff suppressed because it is too large Load Diff
+914
View File
@@ -0,0 +1,914 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { useTheme } from '@/lib/ThemeContext'
import { useLanguage } from '@/lib/LanguageContext'
import { Sidebar } from '@/components/Sidebar'
import { ThemeToggle } from '@/components/ThemeToggle'
import { LanguageDropdown } from '@/components/LanguageDropdown'
import { QRCodeUpload, UploadedFile } from '@/components/QRCodeUpload'
import {
korrekturApi,
getKorrekturStats,
type KorrekturStats,
} from '@/lib/korrektur/api'
import type { Klausur, CreateKlausurData } from './types'
// LocalStorage Key for upload session
const SESSION_ID_KEY = 'bp_korrektur_session'
// =============================================================================
// GLASS CARD - Ultra Transparent (Apple Weather Style)
// =============================================================================
interface GlassCardProps {
children: React.ReactNode
className?: string
onClick?: () => void
size?: 'sm' | 'md' | 'lg'
delay?: number
}
function GlassCard({ children, className = '', onClick, size = 'md', delay = 0, isDark = true }: GlassCardProps & { isDark?: boolean }) {
const [isVisible, setIsVisible] = useState(false)
const [isHovered, setIsHovered] = useState(false)
useEffect(() => {
const timer = setTimeout(() => setIsVisible(true), delay)
return () => clearTimeout(timer)
}, [delay])
const sizeClasses = {
sm: 'p-4',
md: 'p-5',
lg: 'p-6',
}
return (
<div
className={`
rounded-3xl
${sizeClasses[size]}
${onClick ? 'cursor-pointer' : ''}
${className}
`}
style={{
background: isDark
? (isHovered ? 'rgba(255, 255, 255, 0.12)' : 'rgba(255, 255, 255, 0.08)')
: (isHovered ? 'rgba(255, 255, 255, 0.9)' : 'rgba(255, 255, 255, 0.7)'),
backdropFilter: 'blur(24px) saturate(180%)',
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
border: isDark ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.1)',
boxShadow: isDark
? '0 4px 24px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.05)'
: '0 4px 24px rgba(0, 0, 0, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.5)',
opacity: isVisible ? 1 : 0,
transform: isVisible
? `translateY(0) scale(${isHovered ? 1.01 : 1})`
: 'translateY(20px)',
transition: 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={onClick}
>
{children}
</div>
)
}
// =============================================================================
// STAT CARD
// =============================================================================
interface StatCardProps {
label: string
value: string | number
icon: React.ReactNode
color?: string
delay?: number
isDark?: boolean
}
function StatCard({ label, value, icon, color = '#a78bfa', delay = 0, isDark = true }: StatCardProps) {
return (
<GlassCard size="sm" delay={delay} isDark={isDark}>
<div className="flex items-center gap-4">
<div
className="w-12 h-12 rounded-2xl flex items-center justify-center"
style={{ backgroundColor: `${color}20` }}
>
<span style={{ color }}>{icon}</span>
</div>
<div>
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{label}</p>
<p className={`text-2xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>{value}</p>
</div>
</div>
</GlassCard>
)
}
// =============================================================================
// KLAUSUR CARD
// =============================================================================
interface KlausurCardProps {
klausur: Klausur
onClick: () => void
delay?: number
isDark?: boolean
}
function KlausurCard({ klausur, onClick, delay = 0, isDark = true }: KlausurCardProps) {
const progress = klausur.student_count
? Math.round(((klausur.completed_count || 0) / klausur.student_count) * 100)
: 0
const statusColor = klausur.status === 'completed'
? '#22c55e'
: klausur.status === 'in_progress'
? '#f97316'
: '#6b7280'
return (
<GlassCard onClick={onClick} delay={delay} className="min-h-[180px]" isDark={isDark}>
<div className="flex flex-col h-full">
<div className="flex items-start justify-between mb-3">
<h3 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>{klausur.title}</h3>
<span
className="px-2 py-1 rounded-full text-xs font-medium"
style={{ backgroundColor: `${statusColor}20`, color: statusColor }}
>
{klausur.status === 'completed' ? 'Fertig' : klausur.status === 'in_progress' ? 'In Arbeit' : 'Entwurf'}
</span>
</div>
<p className={`text-sm mb-4 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
{klausur.subject} {klausur.semester} {klausur.year}
</p>
<div className="mt-auto">
<div className="flex justify-between text-sm mb-2">
<span className={isDark ? 'text-white/50' : 'text-slate-500'}>{klausur.student_count || 0} Arbeiten</span>
<span className={isDark ? 'text-white' : 'text-slate-900'}>{progress}%</span>
</div>
<div className={`h-2 rounded-full overflow-hidden ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}>
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${progress}%`,
background: `linear-gradient(90deg, ${statusColor}, ${statusColor}80)`,
}}
/>
</div>
</div>
</div>
</GlassCard>
)
}
// =============================================================================
// CREATE KLAUSUR MODAL
// =============================================================================
interface CreateKlausurModalProps {
isOpen: boolean
onClose: () => void
onSubmit: (data: CreateKlausurData) => void
isLoading: boolean
isDark?: boolean
}
function CreateKlausurModal({ isOpen, onClose, onSubmit, isLoading, isDark = true }: CreateKlausurModalProps) {
const [title, setTitle] = useState('')
const [subject, setSubject] = useState('Deutsch')
const [year, setYear] = useState(new Date().getFullYear())
const [semester, setSemester] = useState('Abitur')
const [modus, setModus] = useState<'landes_abitur' | 'vorabitur'>('landes_abitur')
if (!isOpen) return null
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onSubmit({ title, subject, year, semester, modus })
}
const inputClasses = isDark
? 'bg-white/10 border-white/20 text-white placeholder-white/40'
: 'bg-slate-100 border-slate-300 text-slate-900 placeholder-slate-400'
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
<GlassCard className="relative w-full max-w-md" size="lg" delay={0} isDark={isDark}>
<h2 className={`text-xl font-bold mb-6 ${isDark ? 'text-white' : 'text-slate-900'}`}>Neue Klausur erstellen</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="klausur-titel" className={`block text-sm mb-2 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>Titel</label>
<input
id="klausur-titel"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="z.B. Deutsch LK Q4"
className={`w-full p-3 rounded-xl border focus:ring-2 focus:ring-purple-500 focus:border-transparent ${inputClasses}`}
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="klausur-fach" className={`block text-sm mb-2 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>Fach</label>
<select
id="klausur-fach"
value={subject}
onChange={(e) => setSubject(e.target.value)}
className={`w-full p-3 rounded-xl border focus:ring-2 focus:ring-purple-500 ${inputClasses}`}
>
<option value="Deutsch">Deutsch</option>
<option value="Englisch">Englisch</option>
<option value="Mathematik">Mathematik</option>
</select>
</div>
<div>
<label htmlFor="klausur-jahr" className={`block text-sm mb-2 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>Jahr</label>
<input
id="klausur-jahr"
type="number"
value={year}
onChange={(e) => setYear(Number(e.target.value))}
className={`w-full p-3 rounded-xl border focus:ring-2 focus:ring-purple-500 ${inputClasses}`}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="klausur-semester" className={`block text-sm mb-2 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>Semester</label>
<select
id="klausur-semester"
value={semester}
onChange={(e) => setSemester(e.target.value)}
className={`w-full p-3 rounded-xl border focus:ring-2 focus:ring-purple-500 ${inputClasses}`}
>
<option value="Abitur">Abitur</option>
<option value="Q1">Q1</option>
<option value="Q2">Q2</option>
<option value="Q3">Q3</option>
<option value="Q4">Q4</option>
</select>
</div>
<div>
<label htmlFor="klausur-modus" className={`block text-sm mb-2 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>Modus</label>
<select
id="klausur-modus"
value={modus}
onChange={(e) => setModus(e.target.value as 'landes_abitur' | 'vorabitur')}
className={`w-full p-3 rounded-xl border focus:ring-2 focus:ring-purple-500 ${inputClasses}`}
>
<option value="landes_abitur">Landes-Abitur (NiBiS EH)</option>
<option value="vorabitur">Vorabitur (Eigener EH)</option>
</select>
</div>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={onClose}
className={`flex-1 px-4 py-3 rounded-xl transition-colors ${isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-200 text-slate-700 hover:bg-slate-300'}`}
>
Abbrechen
</button>
<button
type="submit"
disabled={isLoading || !title.trim()}
className="flex-1 px-4 py-3 rounded-xl bg-gradient-to-r from-purple-500 to-pink-500 text-white font-semibold hover:shadow-lg hover:shadow-purple-500/30 transition-all disabled:opacity-50"
>
{isLoading ? 'Erstelle...' : 'Erstellen'}
</button>
</div>
</form>
</GlassCard>
</div>
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
export default function KorrekturPage() {
const { isDark } = useTheme()
const { t } = useLanguage()
const router = useRouter()
// State
const [klausuren, setKlausuren] = useState<Klausur[]>([])
const [stats, setStats] = useState<KorrekturStats | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Modal states
const [showCreateModal, setShowCreateModal] = useState(false)
const [isCreating, setIsCreating] = useState(false)
const [showQRModal, setShowQRModal] = useState(false)
const [uploadSessionId, setUploadSessionId] = useState('')
const [showDirectUpload, setShowDirectUpload] = useState(false)
const [showEHUpload, setShowEHUpload] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const [uploadedFiles, setUploadedFiles] = useState<File[]>([])
const [ehFile, setEhFile] = useState<File | null>(null)
const [isUploading, setIsUploading] = useState(false)
// Initialize session ID
useEffect(() => {
let storedSessionId = localStorage.getItem(SESSION_ID_KEY)
if (!storedSessionId) {
storedSessionId = `korrektur-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
localStorage.setItem(SESSION_ID_KEY, storedSessionId)
}
setUploadSessionId(storedSessionId)
}, [])
// Load data
const loadData = useCallback(async () => {
setIsLoading(true)
setError(null)
try {
const [klausurenData, statsData] = await Promise.all([
korrekturApi.getKlausuren(),
getKorrekturStats(),
])
setKlausuren(klausurenData)
setStats(statsData)
} catch (err) {
console.error('Failed to load data:', err)
setError(err instanceof Error ? err.message : 'Laden fehlgeschlagen')
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => {
loadData()
}, [loadData])
// Create klausur
const handleCreateKlausur = async (data: CreateKlausurData) => {
setIsCreating(true)
try {
const newKlausur = await korrekturApi.createKlausur(data)
setKlausuren((prev) => [newKlausur, ...prev])
setShowCreateModal(false)
// Navigate to the new klausur
router.push(`/korrektur/${newKlausur.id}`)
} catch (err) {
console.error('Failed to create klausur:', err)
setError(err instanceof Error ? err.message : 'Erstellung fehlgeschlagen')
} finally {
setIsCreating(false)
}
}
// Handle QR uploaded files
const handleMobileFileSelect = async (uploadedFile: UploadedFile) => {
// For now, just close the modal - in production this would create a quick-start klausur
setShowQRModal(false)
// Could auto-create a klausur and navigate
}
// Handle direct file upload with drag & drop
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
setIsDragging(true)
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
}
const handleDrop = (e: React.DragEvent, isEH = false) => {
e.preventDefault()
setIsDragging(false)
const files = Array.from(e.dataTransfer.files).filter(
f => f.type === 'application/pdf' || f.type.startsWith('image/')
)
if (isEH && files.length > 0) {
setEhFile(files[0])
} else {
setUploadedFiles(prev => [...prev, ...files])
}
}
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>, isEH = false) => {
if (!e.target.files) return
const files = Array.from(e.target.files)
if (isEH && files.length > 0) {
setEhFile(files[0])
} else {
setUploadedFiles(prev => [...prev, ...files])
}
}
const handleDirectUpload = async () => {
if (uploadedFiles.length === 0) return
setIsUploading(true)
try {
// Create a quick-start klausur
const newKlausur = await korrekturApi.createKlausur({
title: `Schnellstart ${new Date().toLocaleDateString('de-DE')}`,
subject: 'Deutsch',
year: new Date().getFullYear(),
semester: 'Abitur',
modus: 'landes_abitur'
})
// Upload each file
for (let i = 0; i < uploadedFiles.length; i++) {
await korrekturApi.uploadStudentWork(newKlausur.id, uploadedFiles[i], `Arbeit-${i + 1}`)
}
setShowDirectUpload(false)
setUploadedFiles([])
router.push(`/korrektur/${newKlausur.id}`)
} catch (err) {
console.error('Upload failed:', err)
setError(err instanceof Error ? err.message : 'Upload fehlgeschlagen')
} finally {
setIsUploading(false)
}
}
const handleEHUpload = async () => {
if (!ehFile) return
setIsUploading(true)
try {
// Upload EH to backend
await korrekturApi.uploadEH(ehFile)
setShowEHUpload(false)
setEhFile(null)
loadData() // Refresh to show new EH
} catch (err) {
console.error('EH Upload failed:', err)
setError(err instanceof Error ? err.message : 'EH Upload fehlgeschlagen')
} finally {
setIsUploading(false)
}
}
return (
<div className={`min-h-screen flex relative overflow-hidden ${
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 */}
<div className={`absolute -top-40 -right-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob ${isDark ? 'bg-purple-500 opacity-30' : 'bg-purple-300 opacity-40'}`} />
<div className={`absolute top-1/2 -left-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-2000 ${isDark ? 'bg-pink-500 opacity-30' : 'bg-pink-300 opacity-40'}`} />
<div className={`absolute -bottom-40 right-1/3 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-4000 ${isDark ? 'bg-blue-500 opacity-30' : 'bg-blue-300 opacity-40'}`} />
{/* Sidebar */}
<div className="relative z-10 p-4">
<Sidebar />
</div>
{/* Main Content */}
<div className="flex-1 flex flex-col relative z-10 p-6 overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className={`text-3xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>Korrekturplattform</h1>
<p className={isDark ? 'text-white/50' : 'text-slate-500'}>KI-gestuetzte Abiturklausur-Korrektur</p>
</div>
<div className="flex items-center gap-3">
<ThemeToggle />
<LanguageDropdown />
</div>
</div>
{/* Stats Cards */}
{stats && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<StatCard
label="Offene Korrekturen"
value={stats.openCorrections}
icon={
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
}
color="#f97316"
delay={100}
isDark={isDark}
/>
<StatCard
label="Erledigt (Woche)"
value={stats.completedThisWeek}
icon={
<svg className="w-6 h-6" 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>
}
color="#22c55e"
delay={200}
isDark={isDark}
/>
<StatCard
label="Durchschnitt"
value={stats.averageGrade > 0 ? `${stats.averageGrade} P` : '-'}
icon={
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
}
color="#3b82f6"
delay={300}
isDark={isDark}
/>
<StatCard
label="Zeit gespart"
value={`${stats.timeSavedHours}h`}
icon={
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
}
color="#a78bfa"
delay={400}
isDark={isDark}
/>
</div>
)}
{/* Error Display */}
{error && (
<GlassCard className="mb-6" size="sm" isDark={isDark}>
<div className="flex items-center gap-3 text-red-400">
<svg className="w-6 h-6" 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>
<span>{error}</span>
<button
onClick={loadData}
className={`ml-auto px-3 py-1 rounded-lg transition-colors text-sm ${isDark ? 'bg-white/10 hover:bg-white/20' : 'bg-slate-200 hover:bg-slate-300'}`}
>
Erneut versuchen
</button>
</div>
</GlassCard>
)}
{/* Loading */}
{isLoading && (
<div className="flex-1 flex items-center justify-center">
<div className="w-12 h-12 border-4 border-purple-500 border-t-transparent rounded-full animate-spin" />
</div>
)}
{/* Klausuren Grid */}
{!isLoading && (
<>
<div className="flex items-center justify-between mb-4">
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>Klausuren</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
{klausuren.map((klausur, index) => (
<KlausurCard
key={klausur.id}
klausur={klausur}
onClick={() => router.push(`/korrektur/${klausur.id}`)}
delay={500 + index * 50}
isDark={isDark}
/>
))}
{/* New Klausur Card */}
<GlassCard
onClick={() => setShowCreateModal(true)}
delay={500 + klausuren.length * 50}
className={`min-h-[180px] border-2 border-dashed ${isDark ? 'border-white/20 hover:border-purple-400/50' : 'border-slate-300 hover:border-purple-400'}`}
isDark={isDark}
>
<div className="flex flex-col items-center justify-center h-full text-center">
<div className="w-16 h-16 rounded-2xl bg-purple-500/20 flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</div>
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>Neue Klausur</p>
<p className={`text-sm mt-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Klausur erstellen</p>
</div>
</GlassCard>
</div>
{/* Quick Actions */}
<h2 className={`text-xl font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>Schnellaktionen</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<GlassCard
onClick={() => setShowQRModal(true)}
delay={700}
className="cursor-pointer"
isDark={isDark}
>
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-blue-500/20 flex items-center justify-center">
<span className="text-2xl">📱</span>
</div>
<div>
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>QR Upload</p>
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Mit Handy scannen</p>
</div>
</div>
</GlassCard>
<GlassCard
onClick={() => setShowDirectUpload(true)}
delay={750}
className="cursor-pointer"
isDark={isDark}
>
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-green-500/20 flex items-center justify-center">
<svg className="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>Direkt hochladen</p>
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Drag & Drop</p>
</div>
</div>
</GlassCard>
<GlassCard
onClick={() => setShowCreateModal(true)}
delay={800}
className="cursor-pointer"
isDark={isDark}
>
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-purple-500/20 flex items-center justify-center">
<svg className="w-6 h-6 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div>
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>Schnellstart</p>
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Direkt loslegen</p>
</div>
</div>
</GlassCard>
<GlassCard
onClick={() => setShowEHUpload(true)}
delay={850}
className="cursor-pointer"
isDark={isDark}
>
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-orange-500/20 flex items-center justify-center">
<svg className="w-6 h-6 text-orange-400" 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>
</div>
<div>
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>EH hochladen</p>
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Erwartungshorizont</p>
</div>
</div>
</GlassCard>
<GlassCard
onClick={() => router.push('/korrektur/archiv')}
delay={900}
className="cursor-pointer"
isDark={isDark}
>
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-indigo-500/20 flex items-center justify-center">
<svg className="w-6 h-6 text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<div>
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>Abitur-Archiv</p>
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>EH durchsuchen</p>
</div>
</div>
</GlassCard>
</div>
</>
)}
</div>
{/* Create Klausur Modal */}
<CreateKlausurModal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
onSubmit={handleCreateKlausur}
isLoading={isCreating}
isDark={isDark}
/>
{/* QR Code Modal */}
{showQRModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setShowQRModal(false)} />
<div className={`relative w-full max-w-md rounded-3xl ${isDark ? 'bg-slate-900' : 'bg-white'}`}>
<QRCodeUpload
sessionId={uploadSessionId}
onClose={() => setShowQRModal(false)}
onFileUploaded={handleMobileFileSelect}
/>
</div>
</div>
)}
{/* Direct Upload Modal */}
{showDirectUpload && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setShowDirectUpload(false)} />
<GlassCard className="relative w-full max-w-lg" size="lg" delay={0} isDark={isDark}>
<h2 className={`text-xl font-bold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>Arbeiten hochladen</h2>
<p className={`mb-4 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
Ziehen Sie eingescannte Klausuren hierher oder klicken Sie zum Auswaehlen.
</p>
{/* Error Display in Modal */}
{error && (
<div className="mb-4 p-3 rounded-lg bg-red-500/20 border border-red-500/30 text-red-300 text-sm">
{error}
</div>
)}
{/* Drag & Drop Zone */}
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, false)}
className={`relative p-8 rounded-2xl border-2 border-dashed transition-colors ${
isDragging
? 'border-purple-400 bg-purple-500/10'
: isDark
? 'border-white/20 hover:border-white/40'
: 'border-slate-300 hover:border-slate-400'
}`}
>
<input
type="file"
accept=".pdf,image/*"
multiple
onChange={(e) => handleFileSelect(e, false)}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
<div className="text-center">
<svg className={`w-12 h-12 mx-auto mb-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="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={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
{isDragging ? 'Dateien hier ablegen' : 'Dateien hierher ziehen'}
</p>
<p className={`text-sm mt-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
PDF oder Bilder (JPG, PNG)
</p>
</div>
</div>
{/* Uploaded Files List */}
{uploadedFiles.length > 0 && (
<div className="mt-4 space-y-2">
<p className={`text-sm font-medium ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
{uploadedFiles.length} Datei(en) ausgewaehlt:
</p>
<div className="max-h-32 overflow-y-auto space-y-1">
{uploadedFiles.map((file, idx) => (
<div key={idx} className={`flex items-center justify-between p-2 rounded-lg ${isDark ? 'bg-white/5' : 'bg-slate-100'}`}>
<span className={`text-sm truncate ${isDark ? 'text-white/80' : 'text-slate-700'}`}>{file.name}</span>
<button
onClick={() => setUploadedFiles(prev => prev.filter((_, i) => i !== idx))}
className="text-red-400 hover:text-red-300"
>
<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>
))}
</div>
</div>
)}
{/* Actions */}
<div className="flex gap-3 mt-6">
<button
onClick={() => { setShowDirectUpload(false); setUploadedFiles([]) }}
className={`flex-1 px-4 py-3 rounded-xl transition-colors ${isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-200 text-slate-700 hover:bg-slate-300'}`}
>
Abbrechen
</button>
<button
onClick={handleDirectUpload}
disabled={uploadedFiles.length === 0 || isUploading}
className="flex-1 px-4 py-3 rounded-xl bg-gradient-to-r from-purple-500 to-pink-500 text-white font-semibold hover:shadow-lg hover:shadow-purple-500/30 transition-all disabled:opacity-50"
>
{isUploading ? 'Hochladen...' : `${uploadedFiles.length} Arbeiten hochladen`}
</button>
</div>
</GlassCard>
</div>
)}
{/* EH Upload Modal */}
{showEHUpload && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setShowEHUpload(false)} />
<GlassCard className="relative w-full max-w-lg" size="lg" delay={0} isDark={isDark}>
<h2 className={`text-xl font-bold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>Erwartungshorizont hochladen</h2>
<p className={`mb-6 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
Laden Sie einen eigenen Erwartungshorizont fuer Vorabitur-Klausuren hoch.
</p>
{/* Drag & Drop Zone */}
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, true)}
className={`relative p-8 rounded-2xl border-2 border-dashed transition-colors ${
isDragging
? 'border-orange-400 bg-orange-500/10'
: isDark
? 'border-white/20 hover:border-white/40'
: 'border-slate-300 hover:border-slate-400'
}`}
>
<input
type="file"
accept=".pdf,.docx,.doc"
onChange={(e) => handleFileSelect(e, true)}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
<div className="text-center">
<svg className={`w-12 h-12 mx-auto mb-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="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={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
{ehFile ? ehFile.name : 'EH-Datei hierher ziehen'}
</p>
<p className={`text-sm mt-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
PDF oder Word-Dokument
</p>
</div>
</div>
{/* Selected File */}
{ehFile && (
<div className={`mt-4 flex items-center justify-between p-3 rounded-lg ${isDark ? 'bg-white/5' : 'bg-slate-100'}`}>
<div className="flex items-center gap-3">
<svg className="w-6 h-6 text-orange-400" 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 className={`text-sm truncate ${isDark ? 'text-white/80' : 'text-slate-700'}`}>{ehFile.name}</span>
</div>
<button
onClick={() => setEhFile(null)}
className="text-red-400 hover:text-red-300"
>
<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>
)}
{/* Actions */}
<div className="flex gap-3 mt-6">
<button
onClick={() => { setShowEHUpload(false); setEhFile(null) }}
className={`flex-1 px-4 py-3 rounded-xl transition-colors ${isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-200 text-slate-700 hover:bg-slate-300'}`}
>
Abbrechen
</button>
<button
onClick={handleEHUpload}
disabled={!ehFile || isUploading}
className="flex-1 px-4 py-3 rounded-xl bg-gradient-to-r from-orange-500 to-red-500 text-white font-semibold hover:shadow-lg hover:shadow-orange-500/30 transition-all disabled:opacity-50"
>
{isUploading ? 'Hochladen...' : 'EH hochladen'}
</button>
</div>
</GlassCard>
</div>
)}
</div>
)
}
+257
View File
@@ -0,0 +1,257 @@
// TypeScript Interfaces fuer Korrekturplattform (Studio v2)
export interface Klausur {
id: string
title: string
subject: string
year: number
semester: string
modus: 'landes_abitur' | 'vorabitur'
eh_id?: string
created_at: string
student_count?: number
completed_count?: number
status?: 'draft' | 'in_progress' | 'completed'
}
export interface StudentWork {
id: string
klausur_id: string
anonym_id: string
file_path: string
file_type: 'pdf' | 'image'
ocr_text: string
criteria_scores: CriteriaScores
gutachten: string
status: StudentStatus
raw_points: number
grade_points: number
grade_label?: string
created_at: string
examiner_id?: string
second_examiner_id?: string
second_examiner_grade?: number
}
export type StudentStatus =
| 'UPLOADED'
| 'OCR_PROCESSING'
| 'OCR_COMPLETE'
| 'ANALYZING'
| 'FIRST_EXAMINER'
| 'SECOND_EXAMINER'
| 'COMPLETED'
| 'ERROR'
export interface CriteriaScores {
rechtschreibung?: number
grammatik?: number
inhalt?: number
struktur?: number
stil?: number
[key: string]: number | undefined
}
export interface Criterion {
id: string
name: string
weight: number
description?: string
}
export interface GradeInfo {
thresholds: Record<number, number>
labels: Record<number, string>
criteria: Record<string, Criterion>
}
export interface Annotation {
id: string
student_work_id: string
page: number
position: AnnotationPosition
type: AnnotationType
text: string
severity: 'minor' | 'major' | 'critical'
suggestion?: string
created_by: string
created_at: string
role: 'first_examiner' | 'second_examiner'
linked_criterion?: string
}
export interface AnnotationPosition {
x: number // Prozent (0-100)
y: number // Prozent (0-100)
width: number // Prozent (0-100)
height: number // Prozent (0-100)
}
export type AnnotationType =
| 'rechtschreibung'
| 'grammatik'
| 'inhalt'
| 'struktur'
| 'stil'
| 'comment'
| 'highlight'
export interface FairnessAnalysis {
klausur_id: string
student_count: number
average_grade: number
std_deviation: number
spread: number
outliers: OutlierInfo[]
criteria_analysis: Record<string, CriteriaStats>
fairness_score: number
warnings: string[]
}
export interface OutlierInfo {
student_id: string
anonym_id: string
grade_points: number
deviation: number
reason: string
}
export interface CriteriaStats {
min: number
max: number
average: number
std_deviation: number
}
export interface EHSuggestion {
criterion: string
excerpt: string
relevance_score: number
source_chunk_id: string
// Attribution fields (CTRL-SRC-002)
source_document?: string
source_url?: string
license?: string
license_url?: string
publisher?: string
}
// Default Attribution for NiBiS documents (CTRL-SRC-002)
export const NIBIS_ATTRIBUTION = {
publisher: 'Niedersaechsischer Bildungsserver (NiBiS)',
license: 'DL-DE-BY-2.0',
license_url: 'https://www.govdata.de/dl-de/by-2-0',
source_url: 'https://nibis.de',
}
export interface GutachtenSection {
title: string
content: string
evidence_links?: string[]
}
export interface Gutachten {
einleitung: string
hauptteil: string
fazit: string
staerken: string[]
schwaechen: string[]
generated_at?: string
}
// API Response Types
export interface KlausurenResponse {
klausuren: Klausur[]
total: number
}
export interface StudentsResponse {
students: StudentWork[]
total: number
}
export interface AnnotationsResponse {
annotations: Annotation[]
}
// Create/Update Types
export interface CreateKlausurData {
title: string
subject?: string
year?: number
semester?: string
modus?: 'landes_abitur' | 'vorabitur'
}
// Color mapping for annotation types
export const ANNOTATION_COLORS: Record<AnnotationType, string> = {
rechtschreibung: '#dc2626', // Red
grammatik: '#2563eb', // Blue
inhalt: '#16a34a', // Green
struktur: '#9333ea', // Purple
stil: '#ea580c', // Orange
comment: '#6b7280', // Gray
highlight: '#eab308', // Yellow
}
// Status colors
export const STATUS_COLORS: Record<StudentStatus, string> = {
UPLOADED: '#6b7280',
OCR_PROCESSING: '#eab308',
OCR_COMPLETE: '#3b82f6',
ANALYZING: '#8b5cf6',
FIRST_EXAMINER: '#f97316',
SECOND_EXAMINER: '#06b6d4',
COMPLETED: '#22c55e',
ERROR: '#ef4444',
}
export const STATUS_LABELS: Record<StudentStatus, string> = {
UPLOADED: 'Hochgeladen',
OCR_PROCESSING: 'OCR laeuft',
OCR_COMPLETE: 'OCR fertig',
ANALYZING: 'Analyse laeuft',
FIRST_EXAMINER: 'Erstkorrektur',
SECOND_EXAMINER: 'Zweitkorrektur',
COMPLETED: 'Abgeschlossen',
ERROR: 'Fehler',
}
// Default criteria with weights (NI standard)
export const DEFAULT_CRITERIA: Record<string, { name: string; weight: number }> = {
rechtschreibung: { name: 'Rechtschreibung', weight: 15 },
grammatik: { name: 'Grammatik', weight: 15 },
inhalt: { name: 'Inhalt', weight: 40 },
struktur: { name: 'Struktur', weight: 15 },
stil: { name: 'Stil', weight: 15 },
}
// Grade thresholds (15-point system)
export const GRADE_THRESHOLDS: Record<number, number> = {
15: 95, 14: 90, 13: 85, 12: 80, 11: 75,
10: 70, 9: 65, 8: 60, 7: 55, 6: 50,
5: 45, 4: 40, 3: 33, 2: 27, 1: 20, 0: 0
}
// Helper function to calculate grade from percentage
export function calculateGrade(percentage: number): number {
for (const [grade, threshold] of Object.entries(GRADE_THRESHOLDS).sort((a, b) => Number(b[0]) - Number(a[0]))) {
if (percentage >= threshold) {
return Number(grade)
}
}
return 0
}
// Helper function to get grade label
export function getGradeLabel(points: number): string {
const labels: Record<number, string> = {
15: '1+', 14: '1', 13: '1-',
12: '2+', 11: '2', 10: '2-',
9: '3+', 8: '3', 7: '3-',
6: '4+', 5: '4', 4: '4-',
3: '5+', 2: '5', 1: '5-',
0: '6'
}
return labels[points] || String(points)
}
+39
View File
@@ -0,0 +1,39 @@
import type { Metadata } from 'next'
import './globals.css'
import { LanguageProvider } from '@/lib/LanguageContext'
import { ThemeProvider } from '@/lib/ThemeContext'
import { AlertsProvider } from '@/lib/AlertsContext'
import { AlertsB2BProvider } from '@/lib/AlertsB2BContext'
import { MessagesProvider } from '@/lib/MessagesContext'
import { ActivityProvider } from '@/lib/ActivityContext'
export const metadata: Metadata = {
title: 'BreakPilot Studio v2',
description: 'Lehrer-Plattform für Korrektur, Arbeitsblaetter und mehr',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="de">
<body>
<ThemeProvider>
<LanguageProvider>
<AlertsProvider>
<AlertsB2BProvider>
<MessagesProvider>
<ActivityProvider>
{children}
</ActivityProvider>
</MessagesProvider>
</AlertsB2BProvider>
</AlertsProvider>
</LanguageProvider>
</ThemeProvider>
</body>
</html>
)
}
+9
View File
@@ -0,0 +1,9 @@
import Layout from '@/components/Layout'
export default function MagicHelpLayout({
children,
}: {
children: React.ReactNode
}) {
return <Layout>{children}</Layout>
}
+266
View File
@@ -0,0 +1,266 @@
'use client'
/**
* Magic Help - Handschrift-OCR
*
* Ermöglicht das Erkennen von Handschrift in Bildern.
* Backend: POST /api/klausur/trocr/recognize
*/
import { useState, useCallback } from 'react'
// Backend URL - dynamisch basierend auf Protokoll
const getBackendUrl = () => {
if (typeof window === 'undefined') return 'http://localhost:8000'
const { hostname, protocol } = window.location
return hostname === 'localhost' ? 'http://localhost:8000' : `${protocol}//${hostname}:8000`
}
interface OCRResult {
text: string
confidence: number
processing_time_ms: number
model: string
}
export default function MagicHelpPage() {
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const [ocrResult, setOcrResult] = useState<OCRResult | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Datei auswählen
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
setSelectedFile(file)
setPreviewUrl(URL.createObjectURL(file))
setOcrResult(null)
setError(null)
}
}, [])
// Drag & Drop
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
const file = e.dataTransfer.files[0]
if (file && file.type.startsWith('image/')) {
setSelectedFile(file)
setPreviewUrl(URL.createObjectURL(file))
setOcrResult(null)
setError(null)
}
}, [])
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
}, [])
// OCR ausführen
const runOCR = useCallback(async () => {
if (!selectedFile) return
setLoading(true)
setError(null)
try {
const formData = new FormData()
formData.append('file', selectedFile)
const res = await fetch(`${getBackendUrl()}/api/klausur/trocr/recognize`, {
method: 'POST',
body: formData,
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
}
const data = await res.json()
setOcrResult(data)
} catch (err) {
setError(err instanceof Error ? err.message : 'Fehler bei der OCR-Erkennung')
} finally {
setLoading(false)
}
}, [selectedFile])
// Reset
const handleReset = useCallback(() => {
setSelectedFile(null)
setPreviewUrl(null)
setOcrResult(null)
setError(null)
}, [])
return (
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-slate-800">Magic Help</h1>
<p className="text-slate-500 mt-1">Handschrift-Erkennung mit TrOCR</p>
</div>
{/* Upload Area */}
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-6 mb-6">
<h2 className="font-semibold text-slate-700 mb-4">Bild hochladen</h2>
{!previewUrl ? (
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
className="border-2 border-dashed border-slate-300 rounded-lg p-12 text-center hover:border-primary-500 transition-colors cursor-pointer"
>
<input
type="file"
accept="image/*"
onChange={handleFileSelect}
className="hidden"
id="file-upload"
/>
<label htmlFor="file-upload" className="cursor-pointer">
<svg
className="w-12 h-12 mx-auto text-slate-400 mb-4"
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>
<p className="text-slate-600 font-medium">Bild hier ablegen oder klicken</p>
<p className="text-sm text-slate-400 mt-1">PNG, JPG, JPEG bis 10MB</p>
</label>
</div>
) : (
<div className="space-y-4">
{/* Preview */}
<div className="relative bg-slate-100 rounded-lg overflow-hidden">
<img
src={previewUrl}
alt="Preview"
className="max-h-96 mx-auto object-contain"
/>
</div>
{/* Actions */}
<div className="flex gap-3">
<button
onClick={runOCR}
disabled={loading}
className="flex-1 bg-primary-500 text-white px-6 py-3 rounded-lg font-medium hover:bg-primary-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{loading ? (
<>
<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>
Wird erkannt...
</>
) : (
<>
<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>
Text erkennen
</>
)}
</button>
<button
onClick={handleReset}
className="px-6 py-3 rounded-lg font-medium border border-slate-300 text-slate-600 hover:bg-slate-50 transition-colors"
>
Zurücksetzen
</button>
</div>
</div>
)}
</div>
{/* Error */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-6">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-red-500 mt-0.5" 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>
<p className="font-medium text-red-800">Fehler</p>
<p className="text-sm text-red-600 mt-1">{error}</p>
</div>
</div>
</div>
)}
{/* Result */}
{ocrResult && (
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-6">
<h2 className="font-semibold text-slate-700 mb-4">Erkannter Text</h2>
{/* Text Output */}
<div className="bg-slate-50 rounded-lg p-4 mb-4 font-mono text-slate-800 whitespace-pre-wrap">
{ocrResult.text || '(Kein Text erkannt)'}
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-4 text-sm">
<div className="bg-slate-50 rounded-lg p-3">
<div className="text-slate-500">Konfidenz</div>
<div className="font-semibold text-slate-800">
{(ocrResult.confidence * 100).toFixed(1)}%
</div>
</div>
<div className="bg-slate-50 rounded-lg p-3">
<div className="text-slate-500">Dauer</div>
<div className="font-semibold text-slate-800">
{ocrResult.processing_time_ms}ms
</div>
</div>
<div className="bg-slate-50 rounded-lg p-3">
<div className="text-slate-500">Modell</div>
<div className="font-semibold text-slate-800 truncate">
{ocrResult.model}
</div>
</div>
</div>
{/* Copy Button */}
<button
onClick={() => navigator.clipboard.writeText(ocrResult.text)}
className="mt-4 px-4 py-2 rounded-lg font-medium border border-slate-300 text-slate-600 hover:bg-slate-50 transition-colors text-sm 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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
Text kopieren
</button>
</div>
)}
{/* Info Box */}
{!previewUrl && !ocrResult && (
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-blue-500 mt-0.5" 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>
<div>
<p className="font-medium text-blue-800">Tipp</p>
<p className="text-sm text-blue-600 mt-1">
Laden Sie ein Bild mit handgeschriebenem Text hoch. Der TrOCR-Dienst erkennt
deutsche Handschrift und gibt den Text zurück.
</p>
</div>
</div>
</div>
)}
</div>
)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+934
View File
@@ -0,0 +1,934 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { BPIcon } from '@/components/Logo'
import { useLanguage } from '@/lib/LanguageContext'
import { useTheme } from '@/lib/ThemeContext'
import { useAlerts, getImportanceColor, getRelativeTime } from '@/lib/AlertsContext'
import { useMessages, formatMessageTime, getContactInitials } from '@/lib/MessagesContext'
import { LanguageDropdown } from '@/components/LanguageDropdown'
import { ThemeToggle } from '@/components/ThemeToggle'
import { Footer } from '@/components/Footer'
import { Sidebar } from '@/components/Sidebar'
import { OnboardingWizard, OnboardingData } from '@/components/OnboardingWizard'
import { DocumentUpload } from '@/components/DocumentUpload'
import { QRCodeUpload } from '@/components/QRCodeUpload'
import { DocumentSpace } from '@/components/DocumentSpace'
import { ChatOverlay } from '@/components/ChatOverlay'
// LocalStorage Keys
const ONBOARDING_KEY = 'bp_onboarding_complete'
const USER_DATA_KEY = 'bp_user_data'
const DOCUMENTS_KEY = 'bp_documents'
const FIRST_VISIT_KEY = 'bp_first_dashboard_visit'
const SESSION_ID_KEY = 'bp_session_id'
// BreakPilot Studio v2 - Glassmorphism Design
interface StoredDocument {
id: string
name: string
type: string
size: number
uploadedAt: Date
url?: string
}
export default function HomePage() {
const router = useRouter()
const [selectedTab, setSelectedTab] = useState('dashboard')
const [showOnboarding, setShowOnboarding] = useState<boolean | null>(null)
const [userData, setUserData] = useState<OnboardingData | null>(null)
const [documents, setDocuments] = useState<StoredDocument[]>([])
const [showUploadModal, setShowUploadModal] = useState(false)
const [showQRModal, setShowQRModal] = useState(false)
const [isFirstVisit, setIsFirstVisit] = useState(false)
const [sessionId, setSessionId] = useState<string>('')
const [showAlertsDropdown, setShowAlertsDropdown] = useState(false)
const { t } = useLanguage()
const { isDark } = useTheme()
const { alerts, unreadCount, markAsRead } = useAlerts()
const { conversations, unreadCount: messagesUnreadCount, contacts, markAsRead: markMessageAsRead } = useMessages()
// Funktion zum Laden von Uploads aus der API
const fetchUploadsFromAPI = useCallback(async (sid: string) => {
if (!sid) return
try {
const response = await fetch(`/api/uploads?sessionId=${encodeURIComponent(sid)}`)
if (response.ok) {
const data = await response.json()
if (data.uploads && data.uploads.length > 0) {
// Konvertiere API-Uploads zu StoredDocument Format
const apiDocs: StoredDocument[] = data.uploads.map((u: any) => ({
id: u.id,
name: u.name,
type: u.type,
size: u.size,
uploadedAt: new Date(u.uploadedAt),
url: u.dataUrl // Data URL direkt verwenden
}))
// Merge mit existierenden Dokumenten (ohne Duplikate)
setDocuments(prev => {
const existingIds = new Set(prev.map(d => d.id))
const newDocs = apiDocs.filter(d => !existingIds.has(d.id))
if (newDocs.length > 0) {
return [...prev, ...newDocs]
}
return prev
})
}
}
} catch (error) {
console.error('Error fetching uploads:', error)
}
}, [])
// Prüfe beim Laden, ob Onboarding abgeschlossen ist
useEffect(() => {
const onboardingComplete = localStorage.getItem(ONBOARDING_KEY)
const storedUserData = localStorage.getItem(USER_DATA_KEY)
const storedDocs = localStorage.getItem(DOCUMENTS_KEY)
const firstVisit = localStorage.getItem(FIRST_VISIT_KEY)
let storedSessionId = localStorage.getItem(SESSION_ID_KEY)
// Session ID generieren falls nicht vorhanden
if (!storedSessionId) {
storedSessionId = `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
localStorage.setItem(SESSION_ID_KEY, storedSessionId)
}
setSessionId(storedSessionId)
if (onboardingComplete === 'true' && storedUserData) {
setUserData(JSON.parse(storedUserData))
setShowOnboarding(false)
// Dokumente laden
if (storedDocs) {
setDocuments(JSON.parse(storedDocs))
}
// Erster Dashboard-Besuch nach Onboarding?
if (!firstVisit) {
setIsFirstVisit(true)
localStorage.setItem(FIRST_VISIT_KEY, 'true')
}
// Initialer Fetch von der API
fetchUploadsFromAPI(storedSessionId)
} else {
setShowOnboarding(true)
}
}, [fetchUploadsFromAPI])
// Polling fuer neue Uploads von der API (alle 3 Sekunden)
useEffect(() => {
if (!sessionId || showOnboarding) return
const interval = setInterval(() => {
fetchUploadsFromAPI(sessionId)
}, 3000)
return () => clearInterval(interval)
}, [sessionId, showOnboarding, fetchUploadsFromAPI])
// Dokumente in localStorage speichern
useEffect(() => {
if (documents.length > 0) {
localStorage.setItem(DOCUMENTS_KEY, JSON.stringify(documents))
}
}, [documents])
// Handler fuer neue Uploads
const handleUploadComplete = (uploadedDocs: any[]) => {
const newDocs: StoredDocument[] = uploadedDocs.map(d => ({
id: d.id,
name: d.name,
type: d.type,
size: d.size,
uploadedAt: d.uploadedAt,
url: d.url
}))
setDocuments(prev => [...prev, ...newDocs])
setIsFirstVisit(false)
}
// Dokument loeschen (aus State und API)
const handleDeleteDocument = async (id: string) => {
setDocuments(prev => prev.filter(d => d.id !== id))
// Auch aus API loeschen
try {
await fetch(`/api/uploads?id=${encodeURIComponent(id)}`, { method: 'DELETE' })
} catch (error) {
console.error('Error deleting from API:', error)
}
}
// Dokument umbenennen
const handleRenameDocument = (id: string, newName: string) => {
setDocuments(prev => prev.map(d => d.id === id ? { ...d, name: newName } : d))
}
// Onboarding abschließen
const handleOnboardingComplete = (data: OnboardingData) => {
localStorage.setItem(ONBOARDING_KEY, 'true')
localStorage.setItem(USER_DATA_KEY, JSON.stringify(data))
setUserData(data)
setShowOnboarding(false)
}
// Zeige Ladebildschirm während der Prüfung
if (showOnboarding === null) {
return (
<div className={`min-h-screen flex items-center justify-center ${
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'
}`}>
<div className="flex items-center gap-4">
<BPIcon variant="cupertino" size={48} className="animate-pulse" />
<span className={`text-xl font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
Laden...
</span>
</div>
</div>
)
}
// Zeige Onboarding falls noch nicht abgeschlossen
if (showOnboarding) {
return <OnboardingWizard onComplete={handleOnboardingComplete} />
}
// Ab hier: Dashboard (bestehender Code)
const stats = [
{ labelKey: 'stat_open_corrections', value: '12', icon: '📋', color: 'from-blue-400 to-blue-600' },
{ labelKey: 'stat_completed_week', value: '28', icon: '✅', color: 'from-green-400 to-green-600' },
{ labelKey: 'stat_average', value: '2.3', icon: '📈', color: 'from-purple-400 to-purple-600' },
{ labelKey: 'stat_time_saved', value: '4.2h', icon: '⏱', color: 'from-orange-400 to-orange-600' },
]
const recentKlausuren = [
{ id: 1, title: 'Deutsch LK - Textanalyse', students: 24, completed: 18, statusKey: 'status_in_progress' },
{ id: 2, title: 'Deutsch GK - Erörterung', students: 28, completed: 28, statusKey: 'status_completed' },
{ id: 3, title: 'Vorabitur - Gedichtanalyse', students: 22, completed: 10, statusKey: 'status_in_progress' },
]
return (
<div className={`min-h-screen relative overflow-hidden 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 */}
<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>
<div className="relative z-10 flex min-h-screen gap-6 p-4">
{/* Sidebar */}
<Sidebar selectedTab={selectedTab} onTabChange={setSelectedTab} />
{/* ============================================
ARBEITSFLAECHE (Main Content)
============================================ */}
<main className="flex-1">
{/* Kopfleiste (Header) */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className={`text-4xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>{t('dashboard')}</h1>
<p className={isDark ? 'text-white/60' : 'text-slate-600'}>{t('dashboard_subtitle')}</p>
</div>
{/* Search, Language & Actions */}
<div className="flex items-center gap-4">
<div className="relative">
<input
type="text"
placeholder={t('search_placeholder')}
className={`backdrop-blur-xl border rounded-2xl px-5 py-3 pl-12 focus:outline-none focus:ring-2 w-64 transition-all ${
isDark
? 'bg-white/10 border-white/20 text-white placeholder-white/40 focus:ring-white/30'
: 'bg-white/70 border-black/10 text-slate-900 placeholder-slate-400 focus:ring-indigo-300'
}`}
/>
<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>
</div>
{/* Language Dropdown */}
<LanguageDropdown />
{/* Theme Toggle */}
<ThemeToggle />
{/* Notifications Bell with Glow Effect */}
<div className="relative">
<button
onClick={() => setShowAlertsDropdown(!showAlertsDropdown)}
className={`relative p-3 backdrop-blur-xl border rounded-2xl transition-all ${
unreadCount > 0
? 'animate-pulse bg-gradient-to-r from-amber-500/20 to-orange-500/20 border-amber-500/30 shadow-lg shadow-amber-500/30'
: isDark
? 'bg-white/10 border-white/20 hover:bg-white/20'
: 'bg-black/5 border-black/10 hover:bg-black/10'
} ${isDark ? 'text-white' : 'text-slate-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="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>
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 rounded-full text-xs text-white flex items-center justify-center font-medium">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</button>
{/* Alerts Dropdown */}
{showAlertsDropdown && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowAlertsDropdown(false)} />
<div className={`absolute right-0 mt-2 w-80 rounded-2xl border shadow-xl z-50 ${
isDark
? 'bg-slate-900 border-white/20'
: 'bg-white border-slate-200'
}`}>
<div className={`p-4 border-b ${isDark ? 'border-white/10' : 'border-slate-100'}`}>
<div className="flex items-center justify-between">
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Aktuelle Alerts
</h3>
{unreadCount > 0 && (
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-amber-500/20 text-amber-500">
{unreadCount} neu
</span>
)}
</div>
</div>
<div className="max-h-80 overflow-y-auto">
{alerts.slice(0, 5).map(alert => (
<button
key={alert.id}
onClick={() => {
markAsRead(alert.id)
setShowAlertsDropdown(false)
router.push('/alerts')
}}
className={`w-full text-left p-4 transition-all ${
isDark
? `hover:bg-white/5 ${!alert.isRead ? 'bg-amber-500/5 border-l-2 border-amber-500' : ''}`
: `hover:bg-slate-50 ${!alert.isRead ? 'bg-amber-50 border-l-2 border-amber-500' : ''}`
}`}
>
<div className="flex items-start gap-3">
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${getImportanceColor(alert.importance, isDark)}`}>
{alert.importance.slice(0, 4)}
</span>
<div className="flex-1 min-w-0">
<p className={`text-sm font-medium truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>
{alert.title}
</p>
<p className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
{getRelativeTime(alert.timestamp)}
</p>
</div>
</div>
</button>
))}
{alerts.length === 0 && (
<div className={`p-8 text-center ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
<span className="text-2xl block mb-2">📭</span>
<p className="text-sm">Keine Alerts</p>
</div>
)}
</div>
<div className={`p-3 border-t ${isDark ? 'border-white/10' : 'border-slate-100'}`}>
<button
onClick={() => {
setShowAlertsDropdown(false)
router.push('/alerts')
}}
className={`w-full py-2 text-sm font-medium rounded-lg transition-all ${
isDark
? 'text-amber-400 hover:bg-amber-500/10'
: 'text-amber-600 hover:bg-amber-50'
}`}
>
Alle Alerts anzeigen
</button>
</div>
</div>
</>
)}
</div>
</div>
</div>
{/* Willkommensnachricht fuer ersten Besuch */}
{isFirstVisit && documents.length === 0 && (
<div className={`mb-8 p-6 rounded-3xl border backdrop-blur-xl ${
isDark
? 'bg-gradient-to-r from-purple-500/20 to-pink-500/20 border-purple-500/30'
: 'bg-gradient-to-r from-purple-50 to-pink-50 border-purple-200'
}`}>
<div className="flex items-start gap-6">
<div className={`w-16 h-16 rounded-2xl flex items-center justify-center text-3xl ${
isDark ? 'bg-white/20' : 'bg-white shadow-lg'
}`}>
🎉
</div>
<div className="flex-1">
<h2 className={`text-xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Willkommen bei BreakPilot Studio!
</h2>
<p className={`mb-4 ${isDark ? 'text-white/70' : 'text-slate-600'}`}>
Grossartig, dass Sie hier sind! Laden Sie jetzt Ihr erstes Dokument hoch,
um die KI-gestuetzte Korrektur zu erleben. Sie koennen Dateien von Ihrem
Computer oder Mobiltelefon hochladen.
</p>
<div className="flex gap-3">
<button
onClick={() => setShowUploadModal(true)}
className="px-6 py-3 bg-gradient-to-r from-purple-500 to-pink-500 text-white rounded-xl font-medium hover:shadow-lg hover:shadow-purple-500/30 transition-all hover:scale-105"
>
Dokument hochladen
</button>
<button
onClick={() => setShowQRModal(true)}
className={`px-6 py-3 rounded-xl font-medium transition-all ${
isDark
? 'bg-white/20 text-white hover:bg-white/30'
: 'bg-white text-slate-700 hover:bg-slate-50 shadow'
}`}
>
Mit Mobiltelefon hochladen
</button>
</div>
</div>
<button
onClick={() => setIsFirstVisit(false)}
className={`p-2 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-white'}`}
>
<svg className={`w-5 h-5 ${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>
</div>
)}
{/* Stats Kacheln */}
<div className="grid grid-cols-4 gap-6 mb-8">
{stats.map((stat, index) => (
<div
key={index}
className={`backdrop-blur-xl border rounded-3xl p-6 transition-all hover:scale-105 hover:shadow-xl cursor-pointer ${
isDark
? 'bg-white/10 border-white/20 hover:bg-white/15'
: 'bg-white/70 border-black/10 hover:bg-white/90 shadow-lg'
}`}
>
<div className="flex items-center justify-between mb-4">
<div className={`w-12 h-12 bg-gradient-to-br ${stat.color} rounded-2xl flex items-center justify-center text-2xl shadow-lg`}>
{stat.icon}
</div>
<svg className={`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="M9 5l7 7-7 7" />
</svg>
</div>
<p className={`text-sm mb-1 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>{t(stat.labelKey)}</p>
<p className={`text-3xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>{stat.value}</p>
</div>
))}
</div>
{/* Tab-Content */}
{selectedTab === 'dokumente' ? (
/* Dokumente-Tab */
<div className="space-y-6">
{/* Upload-Optionen */}
<div className="grid grid-cols-2 gap-6">
<button
onClick={() => setShowUploadModal(true)}
className={`p-6 rounded-3xl border backdrop-blur-xl text-left transition-all hover:scale-105 ${
isDark
? 'bg-white/10 border-white/20 hover:bg-white/15'
: 'bg-white/70 border-black/10 hover:bg-white/90 shadow-lg'
}`}
>
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center text-3xl mb-4 ${
isDark ? 'bg-blue-500/20' : 'bg-blue-100'
}`}>
📤
</div>
<h3 className={`text-lg font-semibold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Direkt hochladen
</h3>
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
Ziehen Sie Dateien hierher oder klicken Sie zum Auswaehlen
</p>
</button>
<button
onClick={() => setShowQRModal(true)}
className={`p-6 rounded-3xl border backdrop-blur-xl text-left transition-all hover:scale-105 ${
isDark
? 'bg-white/10 border-white/20 hover:bg-white/15'
: 'bg-white/70 border-black/10 hover:bg-white/90 shadow-lg'
}`}
>
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center text-3xl mb-4 ${
isDark ? 'bg-purple-500/20' : 'bg-purple-100'
}`}>
📱
</div>
<h3 className={`text-lg font-semibold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Mit Mobiltelefon hochladen
</h3>
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
QR-Code scannen (nur im lokalen Netzwerk)
</p>
</button>
</div>
{/* Document Space */}
<div className={`rounded-3xl border backdrop-blur-xl p-6 ${
isDark
? 'bg-white/10 border-white/20'
: 'bg-white/70 border-black/10 shadow-lg'
}`}>
<h2 className={`text-xl font-semibold mb-6 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Meine Dokumente
</h2>
<DocumentSpace
documents={documents}
onDelete={handleDeleteDocument}
onRename={handleRenameDocument}
onOpen={(doc) => doc.url && window.open(doc.url, '_blank')}
/>
</div>
</div>
) : (
/* Dashboard-Tab (Standard) */
<div className="grid grid-cols-3 gap-6">
{/* Aktuelle Klausuren Kachel */}
<div className={`col-span-2 backdrop-blur-xl border rounded-3xl p-6 ${
isDark
? 'bg-white/10 border-white/20'
: 'bg-white/70 border-black/10 shadow-lg'
}`}>
<div className="flex items-center justify-between mb-6">
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>{t('recent_klausuren')}</h2>
<button className={`text-sm transition-colors ${
isDark ? 'text-white/60 hover:text-white' : 'text-slate-500 hover:text-slate-900'
}`}>
{t('show_all')}
</button>
</div>
<div className="space-y-4">
{recentKlausuren.map((klausur) => (
<div
key={klausur.id}
className={`flex items-center gap-4 p-4 rounded-2xl transition-all cursor-pointer group ${
isDark
? 'bg-white/5 hover:bg-white/10'
: 'bg-slate-50 hover:bg-slate-100'
}`}
>
<div className="w-14 h-14 bg-gradient-to-br from-blue-400 to-purple-500 rounded-2xl flex items-center justify-center">
<span className="text-2xl">📝</span>
</div>
<div className="flex-1">
<h3 className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>{klausur.title}</h3>
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{klausur.students} {t('students')}</p>
</div>
<div className="text-right">
<span className={`px-3 py-1 rounded-full text-xs font-medium ${
klausur.statusKey === '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'
}`}>
{t(klausur.statusKey)}
</span>
<div className="flex items-center gap-2 mt-2">
<div className={`w-24 h-1.5 rounded-full overflow-hidden ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}>
<div
className="h-full bg-gradient-to-r from-cyan-400 to-blue-500 rounded-full"
style={{ width: `${(klausur.completed / klausur.students) * 100}%` }}
/>
</div>
<span className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{klausur.completed}/{klausur.students}</span>
</div>
</div>
<svg className={`w-5 h-5 transition-colors ${
isDark ? 'text-white/30 group-hover:text-white/60' : 'text-slate-300 group-hover:text-slate-600'
}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
))}
</div>
</div>
{/* Schnellaktionen Kachel */}
<div className={`backdrop-blur-xl border rounded-3xl p-6 ${
isDark
? 'bg-white/10 border-white/20'
: 'bg-white/70 border-black/10 shadow-lg'
}`}>
<h2 className={`text-xl font-semibold mb-6 ${isDark ? 'text-white' : 'text-slate-900'}`}>{t('quick_actions')}</h2>
<div className="space-y-3">
<button className="w-full flex items-center gap-4 p-4 bg-gradient-to-r from-blue-500 to-purple-500 rounded-2xl text-white hover:shadow-xl hover:shadow-purple-500/30 transition-all hover:scale-105">
<span className="text-2xl"></span>
<span className="font-medium">{t('create_klausur')}</span>
</button>
<button
onClick={() => setShowUploadModal(true)}
className={`w-full flex items-center gap-4 p-4 rounded-2xl transition-all ${
isDark
? 'bg-white/10 text-white hover:bg-white/20'
: 'bg-slate-100 text-slate-800 hover:bg-slate-200'
}`}>
<span className="text-2xl">📤</span>
<span className="font-medium">{t('upload_work')}</span>
</button>
<button
onClick={() => setSelectedTab('dokumente')}
className={`w-full flex items-center justify-between p-4 rounded-2xl transition-all ${
isDark
? 'bg-white/10 text-white hover:bg-white/20'
: 'bg-slate-100 text-slate-800 hover:bg-slate-200'
}`}>
<div className="flex items-center gap-4">
<span className="text-2xl">📁</span>
<span className="font-medium">{t('nav_dokumente')}</span>
</div>
{documents.length > 0 && (
<span className={`px-2 py-1 text-xs font-medium rounded-full ${
isDark ? 'bg-white/20' : 'bg-slate-200'
}`}>
{documents.length}
</span>
)}
</button>
<button
onClick={() => router.push('/worksheet-editor')}
className={`w-full flex items-center gap-4 p-4 rounded-2xl transition-all ${
isDark
? 'bg-gradient-to-r from-purple-500/20 to-pink-500/20 text-white hover:from-purple-500/30 hover:to-pink-500/30 border border-purple-500/30'
: 'bg-gradient-to-r from-purple-50 to-pink-50 text-slate-800 hover:from-purple-100 hover:to-pink-100 border border-purple-200'
}`}>
<span className="text-2xl">🎨</span>
<span className="font-medium">{t('nav_worksheet_editor')}</span>
</button>
<button className={`w-full flex items-center gap-4 p-4 rounded-2xl transition-all ${
isDark
? 'bg-white/10 text-white hover:bg-white/20'
: 'bg-slate-100 text-slate-800 hover:bg-slate-200'
}`}>
<span className="text-2xl"></span>
<span className="font-medium">{t('magic_help')}</span>
</button>
<button className={`w-full flex items-center gap-4 p-4 rounded-2xl transition-all ${
isDark
? 'bg-white/10 text-white hover:bg-white/20'
: 'bg-slate-100 text-slate-800 hover:bg-slate-200'
}`}>
<span className="text-2xl">📊</span>
<span className="font-medium">{t('fairness_check')}</span>
</button>
</div>
{/* AI Insight mini */}
<div className={`mt-6 p-4 rounded-2xl border ${
isDark
? 'bg-gradient-to-r from-pink-500/20 to-purple-500/20 border-pink-500/30'
: 'bg-gradient-to-r from-pink-50 to-purple-50 border-pink-200'
}`}>
<div className="flex items-center gap-3 mb-2">
<span className="text-lg">🤖</span>
<span className={`text-sm font-medium ${isDark ? 'text-white/80' : 'text-slate-700'}`}>{t('ai_tip')}</span>
</div>
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
{t('ai_tip_text')}
</p>
</div>
{/* Alerts Kachel */}
<div className={`mt-6 backdrop-blur-xl border rounded-2xl p-4 ${
isDark
? 'bg-gradient-to-r from-amber-500/10 to-orange-500/10 border-amber-500/30'
: 'bg-gradient-to-r from-amber-50 to-orange-50 border-amber-200'
}`}>
<div className="flex items-center justify-between mb-3">
<h3 className={`font-semibold flex items-center gap-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
<span>🔔</span> Aktuelle Alerts
</h3>
{unreadCount > 0 && (
<span className="text-xs px-2 py-1 rounded-full bg-amber-500/20 text-amber-500 font-medium">
{unreadCount} neu
</span>
)}
</div>
{/* Headlines Liste */}
<div className="space-y-2">
{alerts.slice(0, 3).map(alert => (
<button
key={alert.id}
onClick={() => {
markAsRead(alert.id)
router.push('/alerts')
}}
className={`w-full text-left p-2 rounded-lg transition-all text-sm ${
isDark
? `hover:bg-white/10 ${!alert.isRead ? 'bg-white/5' : ''}`
: `hover:bg-white ${!alert.isRead ? 'bg-white/50' : ''}`
}`}
>
<div className="flex items-start gap-2">
{!alert.isRead && (
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 mt-1.5 flex-shrink-0" />
)}
<p className={`truncate ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
{alert.title}
</p>
</div>
</button>
))}
{alerts.length === 0 && (
<p className={`text-sm ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
Keine Alerts vorhanden
</p>
)}
</div>
{/* Mehr anzeigen */}
<button
onClick={() => router.push('/alerts')}
className={`w-full mt-3 text-sm font-medium ${
isDark ? 'text-amber-400 hover:text-amber-300' : 'text-amber-600 hover:text-amber-700'
}`}
>
Alle Alerts anzeigen
</button>
</div>
{/* Nachrichten Kachel */}
<div className={`mt-6 backdrop-blur-xl border rounded-2xl p-4 ${
isDark
? 'bg-gradient-to-r from-green-500/10 to-emerald-500/10 border-green-500/30'
: 'bg-gradient-to-r from-green-50 to-emerald-50 border-green-200'
}`}>
<div className="flex items-center justify-between mb-3">
<h3 className={`font-semibold flex items-center gap-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
<span>💬</span> {t('nav_messages')}
</h3>
{messagesUnreadCount > 0 && (
<span className="text-xs px-2 py-1 rounded-full bg-green-500/20 text-green-500 font-medium">
{messagesUnreadCount} neu
</span>
)}
</div>
{/* Conversations Liste */}
<div className="space-y-2">
{conversations.slice(0, 3).map(conv => {
const contact = contacts.find(c => conv.participant_ids.includes(c.id))
return (
<button
key={conv.id}
onClick={() => {
if (conv.unread_count > 0) {
markMessageAsRead(conv.id)
}
router.push('/messages')
}}
className={`w-full text-left p-2 rounded-lg transition-all text-sm ${
isDark
? `hover:bg-white/10 ${conv.unread_count > 0 ? 'bg-white/5' : ''}`
: `hover:bg-white ${conv.unread_count > 0 ? 'bg-white/50' : ''}`
}`}
>
<div className="flex items-center gap-2">
{/* Avatar */}
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium flex-shrink-0 ${
contact?.online
? isDark
? 'bg-green-500/30 text-green-300'
: 'bg-green-200 text-green-700'
: isDark
? 'bg-slate-600 text-slate-300'
: 'bg-slate-200 text-slate-600'
}`}>
{conv.title ? getContactInitials(conv.title) : '?'}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
{conv.unread_count > 0 && (
<span className="w-1.5 h-1.5 rounded-full bg-green-500 flex-shrink-0" />
)}
<span className={`font-medium truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>
{conv.title || 'Unbenannt'}
</span>
</div>
{conv.last_message && (
<p className={`text-xs truncate ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
{conv.last_message}
</p>
)}
</div>
{conv.last_message_time && (
<span className={`text-xs flex-shrink-0 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
{formatMessageTime(conv.last_message_time)}
</span>
)}
</div>
</button>
)
})}
{conversations.length === 0 && (
<p className={`text-sm ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
Keine Nachrichten vorhanden
</p>
)}
</div>
{/* Mehr anzeigen */}
<button
onClick={() => router.push('/messages')}
className={`w-full mt-3 text-sm font-medium ${
isDark ? 'text-green-400 hover:text-green-300' : 'text-green-600 hover:text-green-700'
}`}
>
Alle Nachrichten anzeigen
</button>
</div>
</div>
</div>
)}
</main>
</div>
{/* Upload Modal */}
{showUploadModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setShowUploadModal(false)} />
<div className={`relative w-full max-w-2xl rounded-3xl border p-6 max-h-[90vh] overflow-y-auto ${
isDark
? 'bg-slate-900 border-white/20'
: 'bg-white border-slate-200 shadow-2xl'
}`}>
<div className="flex items-center justify-between mb-6">
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Dokumente hochladen
</h2>
<button
onClick={() => setShowUploadModal(false)}
className={`p-2 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'}`}
>
<svg className={`w-5 h-5 ${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>
<DocumentUpload
onUploadComplete={(docs) => {
handleUploadComplete(docs)
}}
/>
{/* Aktions-Buttons */}
<div className={`mt-6 pt-6 border-t flex justify-between items-center ${
isDark ? 'border-white/10' : 'border-slate-200'
}`}>
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
{documents.length > 0 ? `${documents.length} Dokument${documents.length !== 1 ? 'e' : ''} gespeichert` : 'Noch keine Dokumente'}
</p>
<div className="flex gap-3">
<button
onClick={() => setShowUploadModal(false)}
className={`px-4 py-2 rounded-xl text-sm font-medium ${
isDark ? 'text-white/60 hover:text-white' : 'text-slate-500 hover:text-slate-700'
}`}
>
Schliessen
</button>
<button
onClick={() => {
setShowUploadModal(false)
setSelectedTab('dokumente')
}}
className="px-4 py-2 bg-gradient-to-r from-purple-500 to-pink-500 text-white rounded-xl text-sm font-medium hover:shadow-lg transition-all"
>
Zu meinen Dokumenten
</button>
</div>
</div>
</div>
</div>
)}
{/* QR Code Modal */}
{showQRModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setShowQRModal(false)} />
<div className={`relative w-full max-w-md rounded-3xl ${
isDark ? 'bg-slate-900' : 'bg-white'
}`}>
<QRCodeUpload
sessionId={sessionId}
onClose={() => setShowQRModal(false)}
/>
</div>
</div>
)}
{/* Diegetic Chat Overlay - Cinematic message notifications */}
<ChatOverlay
typewriterEnabled={true}
typewriterSpeed={25}
autoDismissMs={0}
maxQueue={5}
/>
{/* Footer */}
<Footer />
{/* 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>
)
}
+946
View File
@@ -0,0 +1,946 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { BPIcon } from '@/components/Logo'
import { useLanguage } from '@/lib/LanguageContext'
import { useTheme } from '@/lib/ThemeContext'
import { useAlerts, getImportanceColor, getRelativeTime } from '@/lib/AlertsContext'
import { useMessages, formatMessageTime, getContactInitials } from '@/lib/MessagesContext'
import { useActivity, formatDurationCompact } from '@/lib/ActivityContext'
import { LanguageDropdown } from '@/components/LanguageDropdown'
import { ThemeToggle } from '@/components/ThemeToggle'
import { Footer } from '@/components/Footer'
import { Sidebar } from '@/components/Sidebar'
import { OnboardingWizard, OnboardingData } from '@/components/OnboardingWizard'
import { DocumentUpload } from '@/components/DocumentUpload'
import { QRCodeUpload } from '@/components/QRCodeUpload'
import { DocumentSpace } from '@/components/DocumentSpace'
import { ChatOverlay } from '@/components/ChatOverlay'
import { AiPrompt } from '@/components/AiPrompt'
// LocalStorage Keys
const ONBOARDING_KEY = 'bp_onboarding_complete'
const USER_DATA_KEY = 'bp_user_data'
const DOCUMENTS_KEY = 'bp_documents'
const FIRST_VISIT_KEY = 'bp_first_dashboard_visit'
const SESSION_ID_KEY = 'bp_session_id'
// BreakPilot Studio v2 - Glassmorphism Design
interface StoredDocument {
id: string
name: string
type: string
size: number
uploadedAt: Date
url?: string
}
export default function HomePage() {
const router = useRouter()
const [selectedTab, setSelectedTab] = useState('dashboard')
const [showOnboarding, setShowOnboarding] = useState<boolean | null>(null)
const [userData, setUserData] = useState<OnboardingData | null>(null)
const [documents, setDocuments] = useState<StoredDocument[]>([])
const [showUploadModal, setShowUploadModal] = useState(false)
const [showQRModal, setShowQRModal] = useState(false)
const [isFirstVisit, setIsFirstVisit] = useState(false)
const [sessionId, setSessionId] = useState<string>('')
const [showAlertsDropdown, setShowAlertsDropdown] = useState(false)
const { t } = useLanguage()
const { isDark } = useTheme()
const { alerts, unreadCount, markAsRead } = useAlerts()
const { conversations, unreadCount: messagesUnreadCount, contacts, markAsRead: markMessageAsRead } = useMessages()
const { stats: activityStats } = useActivity()
// Funktion zum Laden von Uploads aus der API
const fetchUploadsFromAPI = useCallback(async (sid: string) => {
if (!sid) return
try {
const response = await fetch(`/api/uploads?sessionId=${encodeURIComponent(sid)}`)
if (response.ok) {
const data = await response.json()
if (data.uploads && data.uploads.length > 0) {
// Konvertiere API-Uploads zu StoredDocument Format
const apiDocs: StoredDocument[] = data.uploads.map((u: any) => ({
id: u.id,
name: u.name,
type: u.type,
size: u.size,
uploadedAt: new Date(u.uploadedAt),
url: u.dataUrl // Data URL direkt verwenden
}))
// Merge mit existierenden Dokumenten (ohne Duplikate)
setDocuments(prev => {
const existingIds = new Set(prev.map(d => d.id))
const newDocs = apiDocs.filter(d => !existingIds.has(d.id))
if (newDocs.length > 0) {
return [...prev, ...newDocs]
}
return prev
})
}
}
} catch (error) {
console.error('Error fetching uploads:', error)
}
}, [])
// Prüfe beim Laden, ob Onboarding abgeschlossen ist
useEffect(() => {
const onboardingComplete = localStorage.getItem(ONBOARDING_KEY)
const storedUserData = localStorage.getItem(USER_DATA_KEY)
const storedDocs = localStorage.getItem(DOCUMENTS_KEY)
const firstVisit = localStorage.getItem(FIRST_VISIT_KEY)
let storedSessionId = localStorage.getItem(SESSION_ID_KEY)
// Session ID generieren falls nicht vorhanden
if (!storedSessionId) {
storedSessionId = `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
localStorage.setItem(SESSION_ID_KEY, storedSessionId)
}
setSessionId(storedSessionId)
if (onboardingComplete === 'true' && storedUserData) {
setUserData(JSON.parse(storedUserData))
setShowOnboarding(false)
// Dokumente laden
if (storedDocs) {
setDocuments(JSON.parse(storedDocs))
}
// Erster Dashboard-Besuch nach Onboarding?
if (!firstVisit) {
setIsFirstVisit(true)
localStorage.setItem(FIRST_VISIT_KEY, 'true')
}
// Initialer Fetch von der API
fetchUploadsFromAPI(storedSessionId)
} else {
setShowOnboarding(true)
}
}, [fetchUploadsFromAPI])
// Polling fuer neue Uploads von der API (alle 3 Sekunden)
useEffect(() => {
if (!sessionId || showOnboarding) return
const interval = setInterval(() => {
fetchUploadsFromAPI(sessionId)
}, 3000)
return () => clearInterval(interval)
}, [sessionId, showOnboarding, fetchUploadsFromAPI])
// Dokumente in localStorage speichern
useEffect(() => {
if (documents.length > 0) {
localStorage.setItem(DOCUMENTS_KEY, JSON.stringify(documents))
}
}, [documents])
// Handler fuer neue Uploads
const handleUploadComplete = (uploadedDocs: any[]) => {
const newDocs: StoredDocument[] = uploadedDocs.map(d => ({
id: d.id,
name: d.name,
type: d.type,
size: d.size,
uploadedAt: d.uploadedAt,
url: d.url
}))
setDocuments(prev => [...prev, ...newDocs])
setIsFirstVisit(false)
}
// Dokument loeschen (aus State und API)
const handleDeleteDocument = async (id: string) => {
setDocuments(prev => prev.filter(d => d.id !== id))
// Auch aus API loeschen
try {
await fetch(`/api/uploads?id=${encodeURIComponent(id)}`, { method: 'DELETE' })
} catch (error) {
console.error('Error deleting from API:', error)
}
}
// Dokument umbenennen
const handleRenameDocument = (id: string, newName: string) => {
setDocuments(prev => prev.map(d => d.id === id ? { ...d, name: newName } : d))
}
// Onboarding abschließen
const handleOnboardingComplete = (data: OnboardingData) => {
localStorage.setItem(ONBOARDING_KEY, 'true')
localStorage.setItem(USER_DATA_KEY, JSON.stringify(data))
setUserData(data)
setShowOnboarding(false)
}
// Zeige Ladebildschirm während der Prüfung
if (showOnboarding === null) {
return (
<div className={`min-h-screen flex items-center justify-center ${
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'
}`}>
<div className="flex items-center gap-4">
<BPIcon variant="cupertino" size={48} className="animate-pulse" />
<span className={`text-xl font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
Laden...
</span>
</div>
</div>
)
}
// Zeige Onboarding falls noch nicht abgeschlossen
if (showOnboarding) {
return <OnboardingWizard onComplete={handleOnboardingComplete} />
}
// Ab hier: Dashboard (bestehender Code)
// Calculate time saved from activity tracking
const timeSaved = formatDurationCompact(activityStats.weekSavedSeconds)
const timeSavedDisplay = activityStats.weekSavedSeconds > 0
? `${timeSaved.value}${timeSaved.unit}`
: '0min'
const stats = [
{ labelKey: 'stat_open_corrections', value: '12', icon: '📋', color: 'from-blue-400 to-blue-600' },
{ labelKey: 'stat_completed_week', value: String(activityStats.activityCount), icon: '✅', color: 'from-green-400 to-green-600' },
{ labelKey: 'stat_average', value: '2.3', icon: '📈', color: 'from-purple-400 to-purple-600' },
{ labelKey: 'stat_time_saved', value: timeSavedDisplay, icon: '⏱', color: 'from-orange-400 to-orange-600' },
]
const recentKlausuren = [
{ id: 1, title: 'Deutsch LK - Textanalyse', students: 24, completed: 18, statusKey: 'status_in_progress' },
{ id: 2, title: 'Deutsch GK - Erörterung', students: 28, completed: 28, statusKey: 'status_completed' },
{ id: 3, title: 'Vorabitur - Gedichtanalyse', students: 22, completed: 10, statusKey: 'status_in_progress' },
]
return (
<div className={`min-h-screen relative overflow-hidden 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 */}
<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>
<div className="relative z-10 flex min-h-screen gap-6 p-4">
{/* Sidebar */}
<Sidebar selectedTab={selectedTab} onTabChange={setSelectedTab} />
{/* ============================================
ARBEITSFLAECHE (Main Content)
============================================ */}
<main className="flex-1">
{/* Kopfleiste (Header) */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className={`text-4xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>{t('dashboard')}</h1>
<p className={isDark ? 'text-white/60' : 'text-slate-600'}>{t('dashboard_subtitle')}</p>
</div>
{/* Search, Language & Actions */}
<div className="flex items-center gap-4">
<div className="relative">
<input
type="text"
placeholder={t('search_placeholder')}
className={`backdrop-blur-xl border rounded-2xl px-5 py-3 pl-12 focus:outline-none focus:ring-2 w-64 transition-all ${
isDark
? 'bg-white/10 border-white/20 text-white placeholder-white/40 focus:ring-white/30'
: 'bg-white/70 border-black/10 text-slate-900 placeholder-slate-400 focus:ring-indigo-300'
}`}
/>
<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>
</div>
{/* Language Dropdown */}
<LanguageDropdown />
{/* Theme Toggle */}
<ThemeToggle />
{/* Notifications Bell with Glow Effect */}
<div className="relative">
<button
onClick={() => setShowAlertsDropdown(!showAlertsDropdown)}
className={`relative p-3 backdrop-blur-xl border rounded-2xl transition-all ${
unreadCount > 0
? 'animate-pulse bg-gradient-to-r from-amber-500/20 to-orange-500/20 border-amber-500/30 shadow-lg shadow-amber-500/30'
: isDark
? 'bg-white/10 border-white/20 hover:bg-white/20'
: 'bg-black/5 border-black/10 hover:bg-black/10'
} ${isDark ? 'text-white' : 'text-slate-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="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>
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 rounded-full text-xs text-white flex items-center justify-center font-medium">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</button>
{/* Alerts Dropdown */}
{showAlertsDropdown && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowAlertsDropdown(false)} />
<div className={`absolute right-0 mt-2 w-80 rounded-2xl border shadow-xl z-50 ${
isDark
? 'bg-slate-900 border-white/20'
: 'bg-white border-slate-200'
}`}>
<div className={`p-4 border-b ${isDark ? 'border-white/10' : 'border-slate-100'}`}>
<div className="flex items-center justify-between">
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Aktuelle Alerts
</h3>
{unreadCount > 0 && (
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-amber-500/20 text-amber-500">
{unreadCount} neu
</span>
)}
</div>
</div>
<div className="max-h-80 overflow-y-auto">
{alerts.slice(0, 5).map(alert => (
<button
key={alert.id}
onClick={() => {
markAsRead(alert.id)
setShowAlertsDropdown(false)
router.push('/alerts')
}}
className={`w-full text-left p-4 transition-all ${
isDark
? `hover:bg-white/5 ${!alert.isRead ? 'bg-amber-500/5 border-l-2 border-amber-500' : ''}`
: `hover:bg-slate-50 ${!alert.isRead ? 'bg-amber-50 border-l-2 border-amber-500' : ''}`
}`}
>
<div className="flex items-start gap-3">
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${getImportanceColor(alert.importance, isDark)}`}>
{alert.importance.slice(0, 4)}
</span>
<div className="flex-1 min-w-0">
<p className={`text-sm font-medium truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>
{alert.title}
</p>
<p className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
{getRelativeTime(alert.timestamp)}
</p>
</div>
</div>
</button>
))}
{alerts.length === 0 && (
<div className={`p-8 text-center ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
<span className="text-2xl block mb-2">📭</span>
<p className="text-sm">Keine Alerts</p>
</div>
)}
</div>
<div className={`p-3 border-t ${isDark ? 'border-white/10' : 'border-slate-100'}`}>
<button
onClick={() => {
setShowAlertsDropdown(false)
router.push('/alerts')
}}
className={`w-full py-2 text-sm font-medium rounded-lg transition-all ${
isDark
? 'text-amber-400 hover:bg-amber-500/10'
: 'text-amber-600 hover:bg-amber-50'
}`}
>
Alle Alerts anzeigen
</button>
</div>
</div>
</>
)}
</div>
</div>
</div>
{/* Willkommensnachricht fuer ersten Besuch */}
{isFirstVisit && documents.length === 0 && (
<div className={`mb-8 p-6 rounded-3xl border backdrop-blur-xl ${
isDark
? 'bg-gradient-to-r from-purple-500/20 to-pink-500/20 border-purple-500/30'
: 'bg-gradient-to-r from-purple-50 to-pink-50 border-purple-200'
}`}>
<div className="flex items-start gap-6">
<div className={`w-16 h-16 rounded-2xl flex items-center justify-center text-3xl ${
isDark ? 'bg-white/20' : 'bg-white shadow-lg'
}`}>
🎉
</div>
<div className="flex-1">
<h2 className={`text-xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Willkommen bei BreakPilot Studio!
</h2>
<p className={`mb-4 ${isDark ? 'text-white/70' : 'text-slate-600'}`}>
Grossartig, dass Sie hier sind! Laden Sie jetzt Ihr erstes Dokument hoch,
um die KI-gestuetzte Korrektur zu erleben. Sie koennen Dateien von Ihrem
Computer oder Mobiltelefon hochladen.
</p>
<div className="flex gap-3">
<button
onClick={() => setShowUploadModal(true)}
className="px-6 py-3 bg-gradient-to-r from-purple-500 to-pink-500 text-white rounded-xl font-medium hover:shadow-lg hover:shadow-purple-500/30 transition-all hover:scale-105"
>
Dokument hochladen
</button>
<button
onClick={() => setShowQRModal(true)}
className={`px-6 py-3 rounded-xl font-medium transition-all ${
isDark
? 'bg-white/20 text-white hover:bg-white/30'
: 'bg-white text-slate-700 hover:bg-slate-50 shadow'
}`}
>
Mit Mobiltelefon hochladen
</button>
</div>
</div>
<button
onClick={() => setIsFirstVisit(false)}
className={`p-2 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-white'}`}
>
<svg className={`w-5 h-5 ${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>
</div>
)}
{/* KI-Assistent */}
<AiPrompt />
{/* Stats Kacheln */}
<div className="grid grid-cols-4 gap-6 mb-8">
{stats.map((stat, index) => (
<div
key={index}
className={`backdrop-blur-xl border rounded-3xl p-6 transition-all hover:scale-105 hover:shadow-xl cursor-pointer ${
isDark
? 'bg-white/10 border-white/20 hover:bg-white/15'
: 'bg-white/70 border-black/10 hover:bg-white/90 shadow-lg'
}`}
>
<div className="flex items-center justify-between mb-4">
<div className={`w-12 h-12 bg-gradient-to-br ${stat.color} rounded-2xl flex items-center justify-center text-2xl shadow-lg`}>
{stat.icon}
</div>
<svg className={`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="M9 5l7 7-7 7" />
</svg>
</div>
<p className={`text-sm mb-1 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>{t(stat.labelKey)}</p>
<p className={`text-3xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>{stat.value}</p>
</div>
))}
</div>
{/* Tab-Content */}
{selectedTab === 'dokumente' ? (
/* Dokumente-Tab */
<div className="space-y-6">
{/* Upload-Optionen */}
<div className="grid grid-cols-2 gap-6">
<button
onClick={() => setShowUploadModal(true)}
className={`p-6 rounded-3xl border backdrop-blur-xl text-left transition-all hover:scale-105 ${
isDark
? 'bg-white/10 border-white/20 hover:bg-white/15'
: 'bg-white/70 border-black/10 hover:bg-white/90 shadow-lg'
}`}
>
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center text-3xl mb-4 ${
isDark ? 'bg-blue-500/20' : 'bg-blue-100'
}`}>
📤
</div>
<h3 className={`text-lg font-semibold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Direkt hochladen
</h3>
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
Ziehen Sie Dateien hierher oder klicken Sie zum Auswaehlen
</p>
</button>
<button
onClick={() => setShowQRModal(true)}
className={`p-6 rounded-3xl border backdrop-blur-xl text-left transition-all hover:scale-105 ${
isDark
? 'bg-white/10 border-white/20 hover:bg-white/15'
: 'bg-white/70 border-black/10 hover:bg-white/90 shadow-lg'
}`}
>
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center text-3xl mb-4 ${
isDark ? 'bg-purple-500/20' : 'bg-purple-100'
}`}>
📱
</div>
<h3 className={`text-lg font-semibold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Mit Mobiltelefon hochladen
</h3>
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
QR-Code scannen (nur im lokalen Netzwerk)
</p>
</button>
</div>
{/* Document Space */}
<div className={`rounded-3xl border backdrop-blur-xl p-6 ${
isDark
? 'bg-white/10 border-white/20'
: 'bg-white/70 border-black/10 shadow-lg'
}`}>
<h2 className={`text-xl font-semibold mb-6 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Meine Dokumente
</h2>
<DocumentSpace
documents={documents}
onDelete={handleDeleteDocument}
onRename={handleRenameDocument}
onOpen={(doc) => doc.url && window.open(doc.url, '_blank')}
/>
</div>
</div>
) : (
/* Dashboard-Tab (Standard) */
<div className="grid grid-cols-3 gap-6">
{/* Aktuelle Klausuren Kachel */}
<div className={`col-span-2 backdrop-blur-xl border rounded-3xl p-6 ${
isDark
? 'bg-white/10 border-white/20'
: 'bg-white/70 border-black/10 shadow-lg'
}`}>
<div className="flex items-center justify-between mb-6">
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>{t('recent_klausuren')}</h2>
<button className={`text-sm transition-colors ${
isDark ? 'text-white/60 hover:text-white' : 'text-slate-500 hover:text-slate-900'
}`}>
{t('show_all')}
</button>
</div>
<div className="space-y-4">
{recentKlausuren.map((klausur) => (
<div
key={klausur.id}
className={`flex items-center gap-4 p-4 rounded-2xl transition-all cursor-pointer group ${
isDark
? 'bg-white/5 hover:bg-white/10'
: 'bg-slate-50 hover:bg-slate-100'
}`}
>
<div className="w-14 h-14 bg-gradient-to-br from-blue-400 to-purple-500 rounded-2xl flex items-center justify-center">
<span className="text-2xl">📝</span>
</div>
<div className="flex-1">
<h3 className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>{klausur.title}</h3>
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{klausur.students} {t('students')}</p>
</div>
<div className="text-right">
<span className={`px-3 py-1 rounded-full text-xs font-medium ${
klausur.statusKey === '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'
}`}>
{t(klausur.statusKey)}
</span>
<div className="flex items-center gap-2 mt-2">
<div className={`w-24 h-1.5 rounded-full overflow-hidden ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}>
<div
className="h-full bg-gradient-to-r from-cyan-400 to-blue-500 rounded-full"
style={{ width: `${(klausur.completed / klausur.students) * 100}%` }}
/>
</div>
<span className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{klausur.completed}/{klausur.students}</span>
</div>
</div>
<svg className={`w-5 h-5 transition-colors ${
isDark ? 'text-white/30 group-hover:text-white/60' : 'text-slate-300 group-hover:text-slate-600'
}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
))}
</div>
</div>
{/* Schnellaktionen Kachel */}
<div className={`backdrop-blur-xl border rounded-3xl p-6 ${
isDark
? 'bg-white/10 border-white/20'
: 'bg-white/70 border-black/10 shadow-lg'
}`}>
<h2 className={`text-xl font-semibold mb-6 ${isDark ? 'text-white' : 'text-slate-900'}`}>{t('quick_actions')}</h2>
<div className="space-y-3">
<button className="w-full flex items-center gap-4 p-4 bg-gradient-to-r from-blue-500 to-purple-500 rounded-2xl text-white hover:shadow-xl hover:shadow-purple-500/30 transition-all hover:scale-105">
<span className="text-2xl"></span>
<span className="font-medium">{t('create_klausur')}</span>
</button>
<button
onClick={() => setShowUploadModal(true)}
className={`w-full flex items-center gap-4 p-4 rounded-2xl transition-all ${
isDark
? 'bg-white/10 text-white hover:bg-white/20'
: 'bg-slate-100 text-slate-800 hover:bg-slate-200'
}`}>
<span className="text-2xl">📤</span>
<span className="font-medium">{t('upload_work')}</span>
</button>
<button
onClick={() => setSelectedTab('dokumente')}
className={`w-full flex items-center justify-between p-4 rounded-2xl transition-all ${
isDark
? 'bg-white/10 text-white hover:bg-white/20'
: 'bg-slate-100 text-slate-800 hover:bg-slate-200'
}`}>
<div className="flex items-center gap-4">
<span className="text-2xl">📁</span>
<span className="font-medium">{t('nav_dokumente')}</span>
</div>
{documents.length > 0 && (
<span className={`px-2 py-1 text-xs font-medium rounded-full ${
isDark ? 'bg-white/20' : 'bg-slate-200'
}`}>
{documents.length}
</span>
)}
</button>
<button
onClick={() => router.push('/worksheet-editor')}
className={`w-full flex items-center gap-4 p-4 rounded-2xl transition-all ${
isDark
? 'bg-gradient-to-r from-purple-500/20 to-pink-500/20 text-white hover:from-purple-500/30 hover:to-pink-500/30 border border-purple-500/30'
: 'bg-gradient-to-r from-purple-50 to-pink-50 text-slate-800 hover:from-purple-100 hover:to-pink-100 border border-purple-200'
}`}>
<span className="text-2xl">🎨</span>
<span className="font-medium">{t('nav_worksheet_editor')}</span>
</button>
<button className={`w-full flex items-center gap-4 p-4 rounded-2xl transition-all ${
isDark
? 'bg-white/10 text-white hover:bg-white/20'
: 'bg-slate-100 text-slate-800 hover:bg-slate-200'
}`}>
<span className="text-2xl"></span>
<span className="font-medium">{t('magic_help')}</span>
</button>
<button className={`w-full flex items-center gap-4 p-4 rounded-2xl transition-all ${
isDark
? 'bg-white/10 text-white hover:bg-white/20'
: 'bg-slate-100 text-slate-800 hover:bg-slate-200'
}`}>
<span className="text-2xl">📊</span>
<span className="font-medium">{t('fairness_check')}</span>
</button>
</div>
{/* AI Insight mini */}
<div className={`mt-6 p-4 rounded-2xl border ${
isDark
? 'bg-gradient-to-r from-pink-500/20 to-purple-500/20 border-pink-500/30'
: 'bg-gradient-to-r from-pink-50 to-purple-50 border-pink-200'
}`}>
<div className="flex items-center gap-3 mb-2">
<span className="text-lg">🤖</span>
<span className={`text-sm font-medium ${isDark ? 'text-white/80' : 'text-slate-700'}`}>{t('ai_tip')}</span>
</div>
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
{t('ai_tip_text')}
</p>
</div>
{/* Alerts Kachel */}
<div className={`mt-6 backdrop-blur-xl border rounded-2xl p-4 ${
isDark
? 'bg-gradient-to-r from-amber-500/10 to-orange-500/10 border-amber-500/30'
: 'bg-gradient-to-r from-amber-50 to-orange-50 border-amber-200'
}`}>
<div className="flex items-center justify-between mb-3">
<h3 className={`font-semibold flex items-center gap-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
<span>🔔</span> Aktuelle Alerts
</h3>
{unreadCount > 0 && (
<span className="text-xs px-2 py-1 rounded-full bg-amber-500/20 text-amber-500 font-medium">
{unreadCount} neu
</span>
)}
</div>
{/* Headlines Liste */}
<div className="space-y-2">
{alerts.slice(0, 3).map(alert => (
<button
key={alert.id}
onClick={() => {
markAsRead(alert.id)
router.push('/alerts')
}}
className={`w-full text-left p-2 rounded-lg transition-all text-sm ${
isDark
? `hover:bg-white/10 ${!alert.isRead ? 'bg-white/5' : ''}`
: `hover:bg-white ${!alert.isRead ? 'bg-white/50' : ''}`
}`}
>
<div className="flex items-start gap-2">
{!alert.isRead && (
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 mt-1.5 flex-shrink-0" />
)}
<p className={`truncate ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
{alert.title}
</p>
</div>
</button>
))}
{alerts.length === 0 && (
<p className={`text-sm ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
Keine Alerts vorhanden
</p>
)}
</div>
{/* Mehr anzeigen */}
<button
onClick={() => router.push('/alerts')}
className={`w-full mt-3 text-sm font-medium ${
isDark ? 'text-amber-400 hover:text-amber-300' : 'text-amber-600 hover:text-amber-700'
}`}
>
Alle Alerts anzeigen
</button>
</div>
{/* Nachrichten Kachel */}
<div className={`mt-6 backdrop-blur-xl border rounded-2xl p-4 ${
isDark
? 'bg-gradient-to-r from-green-500/10 to-emerald-500/10 border-green-500/30'
: 'bg-gradient-to-r from-green-50 to-emerald-50 border-green-200'
}`}>
<div className="flex items-center justify-between mb-3">
<h3 className={`font-semibold flex items-center gap-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
<span>💬</span> {t('nav_messages')}
</h3>
{messagesUnreadCount > 0 && (
<span className="text-xs px-2 py-1 rounded-full bg-green-500/20 text-green-500 font-medium">
{messagesUnreadCount} neu
</span>
)}
</div>
{/* Conversations Liste */}
<div className="space-y-2">
{conversations.slice(0, 3).map(conv => {
const contact = contacts.find(c => conv.participant_ids.includes(c.id))
return (
<button
key={conv.id}
onClick={() => {
if (conv.unread_count > 0) {
markMessageAsRead(conv.id)
}
router.push('/messages')
}}
className={`w-full text-left p-2 rounded-lg transition-all text-sm ${
isDark
? `hover:bg-white/10 ${conv.unread_count > 0 ? 'bg-white/5' : ''}`
: `hover:bg-white ${conv.unread_count > 0 ? 'bg-white/50' : ''}`
}`}
>
<div className="flex items-center gap-2">
{/* Avatar */}
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium flex-shrink-0 ${
contact?.online
? isDark
? 'bg-green-500/30 text-green-300'
: 'bg-green-200 text-green-700'
: isDark
? 'bg-slate-600 text-slate-300'
: 'bg-slate-200 text-slate-600'
}`}>
{conv.title ? getContactInitials(conv.title) : '?'}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
{conv.unread_count > 0 && (
<span className="w-1.5 h-1.5 rounded-full bg-green-500 flex-shrink-0" />
)}
<span className={`font-medium truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>
{conv.title || 'Unbenannt'}
</span>
</div>
{conv.last_message && (
<p className={`text-xs truncate ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
{conv.last_message}
</p>
)}
</div>
{conv.last_message_time && (
<span className={`text-xs flex-shrink-0 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
{formatMessageTime(conv.last_message_time)}
</span>
)}
</div>
</button>
)
})}
{conversations.length === 0 && (
<p className={`text-sm ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
Keine Nachrichten vorhanden
</p>
)}
</div>
{/* Mehr anzeigen */}
<button
onClick={() => router.push('/messages')}
className={`w-full mt-3 text-sm font-medium ${
isDark ? 'text-green-400 hover:text-green-300' : 'text-green-600 hover:text-green-700'
}`}
>
Alle Nachrichten anzeigen
</button>
</div>
</div>
</div>
)}
</main>
</div>
{/* Upload Modal */}
{showUploadModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setShowUploadModal(false)} />
<div className={`relative w-full max-w-2xl rounded-3xl border p-6 max-h-[90vh] overflow-y-auto ${
isDark
? 'bg-slate-900 border-white/20'
: 'bg-white border-slate-200 shadow-2xl'
}`}>
<div className="flex items-center justify-between mb-6">
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Dokumente hochladen
</h2>
<button
onClick={() => setShowUploadModal(false)}
className={`p-2 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'}`}
>
<svg className={`w-5 h-5 ${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>
<DocumentUpload
onUploadComplete={(docs) => {
handleUploadComplete(docs)
}}
/>
{/* Aktions-Buttons */}
<div className={`mt-6 pt-6 border-t flex justify-between items-center ${
isDark ? 'border-white/10' : 'border-slate-200'
}`}>
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
{documents.length > 0 ? `${documents.length} Dokument${documents.length !== 1 ? 'e' : ''} gespeichert` : 'Noch keine Dokumente'}
</p>
<div className="flex gap-3">
<button
onClick={() => setShowUploadModal(false)}
className={`px-4 py-2 rounded-xl text-sm font-medium ${
isDark ? 'text-white/60 hover:text-white' : 'text-slate-500 hover:text-slate-700'
}`}
>
Schliessen
</button>
<button
onClick={() => {
setShowUploadModal(false)
setSelectedTab('dokumente')
}}
className="px-4 py-2 bg-gradient-to-r from-purple-500 to-pink-500 text-white rounded-xl text-sm font-medium hover:shadow-lg transition-all"
>
Zu meinen Dokumenten
</button>
</div>
</div>
</div>
</div>
)}
{/* QR Code Modal */}
{showQRModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setShowQRModal(false)} />
<div className={`relative w-full max-w-md rounded-3xl ${
isDark ? 'bg-slate-900' : 'bg-white'
}`}>
<QRCodeUpload
sessionId={sessionId}
onClose={() => setShowQRModal(false)}
/>
</div>
</div>
)}
{/* Diegetic Chat Overlay - Cinematic message notifications */}
<ChatOverlay
typewriterEnabled={true}
typewriterSpeed={25}
autoDismissMs={0}
maxQueue={5}
/>
{/* Footer */}
<Footer />
{/* 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>
)
}
+235
View File
@@ -0,0 +1,235 @@
'use client'
import { useState, useRef, useCallback } from 'react'
import { useParams } from 'next/navigation'
import { BPIcon } from '@/components/Logo'
interface UploadedFile {
id: string
name: string
size: number
status: 'uploading' | 'complete' | 'error'
progress: number
}
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 default function MobileUploadPage() {
const params = useParams()
const sessionId = params.sessionId as string
const [files, setFiles] = useState<UploadedFile[]>([])
const [isDragging, setIsDragging] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
// Echten Upload durchfuehren
const uploadFile = useCallback(async (file: File) => {
const localId = `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
const uploadFileState: UploadedFile = {
id: localId,
name: file.name,
size: file.size,
status: 'uploading',
progress: 0
}
setFiles(prev => [...prev, uploadFileState])
try {
// Fortschritt auf 30% setzen (Datei wird gelesen)
setFiles(prev => prev.map(f =>
f.id === localId ? { ...f, progress: 30 } : f
))
// Datei als Base64 Data URL konvertieren
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(file)
})
// Fortschritt auf 60% setzen (Upload wird gesendet)
setFiles(prev => prev.map(f =>
f.id === localId ? { ...f, progress: 60 } : f
))
// An API senden
const response = await fetch('/api/uploads', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId,
name: file.name,
type: file.type,
size: file.size,
dataUrl
})
})
if (!response.ok) {
throw new Error('Upload fehlgeschlagen')
}
// Upload erfolgreich
setFiles(prev => prev.map(f =>
f.id === localId ? { ...f, status: 'complete', progress: 100 } : f
))
} catch (error) {
console.error('Upload error:', error)
setFiles(prev => prev.map(f =>
f.id === localId ? { ...f, status: 'error', progress: 0 } : f
))
}
}, [sessionId])
const handleFiles = useCallback((fileList: FileList | null) => {
if (!fileList) return
Array.from(fileList).forEach(file => {
if (file.type === 'application/pdf' || file.type.startsWith('image/')) {
uploadFile(file)
}
})
}, [uploadFile])
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
setIsDragging(true)
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
handleFiles(e.dataTransfer.files)
}
const completedCount = files.filter(f => f.status === 'complete').length
return (
<div className="min-h-screen bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800 relative overflow-hidden">
{/* Animated Background Blobs */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute -top-40 -right-40 w-80 h-80 bg-purple-500 opacity-70 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" />
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-blue-500 opacity-70 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" style={{ animationDelay: '2s' }} />
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-80 h-80 bg-pink-500 opacity-70 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" style={{ animationDelay: '4s' }} />
</div>
<div className="relative z-10 min-h-screen flex flex-col p-4 safe-area-inset">
{/* Header */}
<div className="flex items-center justify-center gap-3 py-6">
<BPIcon variant="cupertino" size={40} />
<div>
<h1 className="text-xl font-bold text-white">BreakPilot</h1>
<p className="text-xs text-white/60">Mobiler Upload</p>
</div>
</div>
{/* Upload Area */}
<div className="flex-1 flex flex-col gap-4">
{/* Upload-Button */}
<button
onClick={() => fileInputRef.current?.click()}
className="w-full backdrop-blur-xl bg-gradient-to-r from-purple-500 to-pink-500 border border-white/20 rounded-3xl p-8 flex flex-col items-center justify-center text-center transition-all hover:shadow-xl hover:shadow-purple-500/30 active:scale-95"
>
<input
ref={fileInputRef}
type="file"
multiple
accept=".pdf,.jpg,.jpeg,.png,image/*,application/pdf"
onChange={(e) => handleFiles(e.target.files)}
className="hidden"
/>
<div className="w-20 h-20 bg-white/20 rounded-2xl flex items-center justify-center mb-4">
<svg className="w-10 h-10 text-white" 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>
<p className="text-xl font-semibold text-white">Dokument hochladen</p>
<p className="text-sm text-white/70 mt-2">Tippen um Foto oder Datei auszuwaehlen</p>
<p className="text-xs text-white/50 mt-1">PDF, JPG, PNG</p>
</button>
{/* Uploaded Files */}
{files.length > 0 && (
<div className="backdrop-blur-xl bg-white/10 border border-white/20 rounded-2xl overflow-hidden">
<div className="px-4 py-3 border-b border-white/10">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-white">
Hochgeladene Dateien
</span>
<span className="text-xs text-white/60">
{completedCount}/{files.length} fertig
</span>
</div>
</div>
<div className="divide-y divide-white/10 max-h-[40vh] overflow-y-auto">
{files.map((file) => (
<div key={file.id} className="p-4 flex items-center gap-3">
<div className={`w-10 h-10 rounded-xl flex items-center justify-center text-xl ${
file.status === 'complete' ? 'bg-green-500/20' : 'bg-blue-500/20'
}`}>
{file.status === 'complete' ? '✅' : '📄'}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">
{file.name}
</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-white/50">
{formatFileSize(file.size)}
</span>
{file.status === 'uploading' && (
<span className="text-xs text-blue-300">
{Math.round(file.progress)}%
</span>
)}
</div>
{file.status === 'uploading' && (
<div className="mt-2 h-1 bg-white/10 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-blue-500 to-purple-500 rounded-full transition-all"
style={{ width: `${file.progress}%` }}
/>
</div>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Success Message */}
{completedCount > 0 && (
<div className="backdrop-blur-xl bg-green-500/20 border border-green-500/30 rounded-2xl p-4 text-center">
<p className="text-green-300 font-medium">
{completedCount} Datei{completedCount !== 1 ? 'en' : ''} erfolgreich hochgeladen!
</p>
<p className="text-green-300/70 text-sm mt-1">
Sie koennen diese Seite jetzt schliessen.
</p>
</div>
)}
</div>
{/* Footer */}
<div className="py-4 text-center">
<p className="text-xs text-white/40">
Session: {sessionId}
</p>
</div>
</div>
</div>
)
}
File diff suppressed because it is too large Load Diff
+235
View File
@@ -0,0 +1,235 @@
'use client'
import { useState } from 'react'
import { VoiceCapture, VoiceCommandBar } from '@/components/voice'
import { VoiceTask } from '@/lib/voice/voice-api'
/**
* Voice Test Page
* For testing and demonstrating voice interface
*/
export default function VoiceTestPage() {
const [activeTab, setActiveTab] = useState<'simple' | 'full'>('full')
const [transcripts, setTranscripts] = useState<string[]>([])
const [intents, setIntents] = useState<{ intent: string; params: Record<string, unknown> }[]>([])
const [tasks, setTasks] = useState<VoiceTask[]>([])
const handleTranscript = (text: string, isFinal: boolean) => {
if (isFinal) {
setTranscripts((prev) => [...prev.slice(-9), text])
}
}
const handleIntent = (intent: string, params: Record<string, unknown>) => {
setIntents((prev) => [...prev.slice(-9), { intent, params }])
}
const handleTaskCreated = (task: VoiceTask) => {
setTasks((prev) => [...prev.slice(-9), task])
}
return (
<div className="min-h-screen bg-gradient-to-br from-gray-100 to-gray-200 p-8">
<div className="max-w-6xl mx-auto">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-800 mb-2">
Breakpilot Voice Test
</h1>
<p className="text-gray-600">
Testen Sie die Sprachsteuerung fuer Breakpilot. Sprechen Sie Befehle wie:
</p>
<ul className="mt-2 text-sm text-gray-500 list-disc list-inside">
<li>&quot;Notiz zu Max: heute wiederholt gestoert&quot;</li>
<li>&quot;Erinner mich morgen an Hausaufgabenkontrolle&quot;</li>
<li>&quot;Erstelle Arbeitsblatt mit 3 Lueckentexten&quot;</li>
<li>&quot;Elternbrief wegen wiederholter Stoerungen&quot;</li>
</ul>
</div>
{/* Tab switcher */}
<div className="flex gap-2 mb-6">
<button
onClick={() => setActiveTab('full')}
className={`px-4 py-2 rounded-lg transition-colors ${
activeTab === 'full'
? 'bg-blue-500 text-white'
: 'bg-white text-gray-700 hover:bg-gray-50'
}`}
>
Volle Ansicht
</button>
<button
onClick={() => setActiveTab('simple')}
className={`px-4 py-2 rounded-lg transition-colors ${
activeTab === 'simple'
? 'bg-blue-500 text-white'
: 'bg-white text-gray-700 hover:bg-gray-50'
}`}
>
Einfacher Modus
</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Voice Component */}
<div>
{activeTab === 'full' ? (
<VoiceCommandBar
onTaskCreated={handleTaskCreated}
className="h-[500px]"
/>
) : (
<div className="bg-white rounded-xl shadow-lg p-6">
<h2 className="text-lg font-semibold mb-4">Sprachaufnahme</h2>
<VoiceCapture
onTranscript={handleTranscript}
onIntent={handleIntent}
onTaskCreated={handleTaskCreated}
/>
</div>
)}
</div>
{/* Debug panel */}
<div className="space-y-6">
{/* Transcripts */}
<div className="bg-white rounded-xl shadow-lg p-6">
<h2 className="text-lg font-semibold mb-4">Erkannte Texte</h2>
<div className="space-y-2 max-h-[150px] overflow-y-auto">
{transcripts.length === 0 ? (
<p className="text-gray-400 text-sm">
Noch keine Transkripte...
</p>
) : (
transcripts.map((t, i) => (
<div
key={i}
className="bg-gray-50 rounded px-3 py-2 text-sm"
>
{t}
</div>
))
)}
</div>
</div>
{/* Intents */}
<div className="bg-white rounded-xl shadow-lg p-6">
<h2 className="text-lg font-semibold mb-4">Erkannte Absichten</h2>
<div className="space-y-2 max-h-[150px] overflow-y-auto">
{intents.length === 0 ? (
<p className="text-gray-400 text-sm">Noch keine Intents...</p>
) : (
intents.map((intent, i) => (
<div
key={i}
className="bg-blue-50 rounded px-3 py-2 text-sm"
>
<span className="font-medium text-blue-700">
{intent.intent}
</span>
{Object.keys(intent.params).length > 0 && (
<pre className="mt-1 text-xs text-gray-500">
{JSON.stringify(intent.params, null, 2)}
</pre>
)}
</div>
))
)}
</div>
</div>
{/* Tasks */}
<div className="bg-white rounded-xl shadow-lg p-6">
<h2 className="text-lg font-semibold mb-4">Erstellte Aufgaben</h2>
<div className="space-y-2 max-h-[150px] overflow-y-auto">
{tasks.length === 0 ? (
<p className="text-gray-400 text-sm">
Noch keine Aufgaben...
</p>
) : (
tasks.map((task, i) => (
<div
key={i}
className="bg-green-50 rounded px-3 py-2 text-sm"
>
<div className="flex justify-between items-center">
<span className="font-medium text-green-700">
{task.type}
</span>
<span
className={`px-2 py-0.5 rounded text-xs ${
task.state === 'completed'
? 'bg-green-200 text-green-800'
: task.state === 'ready'
? 'bg-yellow-200 text-yellow-800'
: 'bg-gray-200 text-gray-800'
}`}
>
{task.state}
</span>
</div>
<p className="text-xs text-gray-500 mt-1">
ID: {task.id.slice(0, 8)}...
</p>
</div>
))
)}
</div>
</div>
</div>
</div>
{/* Instructions */}
<div className="mt-8 bg-white rounded-xl shadow-lg p-6">
<h2 className="text-lg font-semibold mb-4">Anleitung</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 text-sm">
<div>
<h3 className="font-medium text-gray-700 mb-2">
1. Notizen & Beobachtungen
</h3>
<ul className="text-gray-500 space-y-1">
<li> &quot;Notiz zu [Name]: [Beobachtung]&quot;</li>
<li> &quot;[Name] braucht extra Uebung&quot;</li>
<li> &quot;Hausaufgabe kontrollieren&quot;</li>
</ul>
</div>
<div>
<h3 className="font-medium text-gray-700 mb-2">
2. Materialerstellung
</h3>
<ul className="text-gray-500 space-y-1">
<li> &quot;Arbeitsblatt erstellen&quot;</li>
<li> &quot;Quiz mit 10 Fragen&quot;</li>
<li> &quot;Elternbrief wegen...&quot;</li>
</ul>
</div>
<div>
<h3 className="font-medium text-gray-700 mb-2">
3. Organisation
</h3>
<ul className="text-gray-500 space-y-1">
<li> &quot;Erinner mich morgen...&quot;</li>
<li> &quot;Nachricht an Klasse 8a&quot;</li>
<li> &quot;Offene Aufgaben zeigen&quot;</li>
</ul>
</div>
</div>
</div>
{/* Privacy note */}
<div className="mt-6 text-center text-sm text-gray-400">
<p>
DSGVO-konform: Audio wird nur im Arbeitsspeicher verarbeitet und
nie gespeichert.
</p>
<p>
Alle personenbezogenen Daten werden verschluesselt gespeichert -
der Schluessel bleibt auf Ihrem Geraet.
</p>
</div>
</div>
</div>
)
}
+899
View File
@@ -0,0 +1,899 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { useTheme } from '@/lib/ThemeContext'
import { Sidebar } from '@/components/Sidebar'
import { ThemeToggle } from '@/components/ThemeToggle'
import { LanguageDropdown } from '@/components/LanguageDropdown'
import { QRCodeUpload, UploadedFile } from '@/components/QRCodeUpload'
// LocalStorage Key for upload session
const SESSION_ID_KEY = 'bp_cleanup_session'
/**
* Worksheet Cleanup Page - Apple Weather Dashboard Style
*
* Design principles:
* - Dark gradient background
* - Ultra-translucent glass cards (~8% opacity)
* - White text, monochrome palette
* - Step-by-step cleanup wizard
*/
// =============================================================================
// GLASS CARD - Ultra Transparent
// =============================================================================
interface GlassCardProps {
children: React.ReactNode
className?: string
onClick?: () => void
size?: 'sm' | 'md' | 'lg'
delay?: number
}
function GlassCard({ children, className = '', onClick, size = 'md', delay = 0, isDark = true }: GlassCardProps & { isDark?: boolean }) {
const [isVisible, setIsVisible] = useState(false)
const [isHovered, setIsHovered] = useState(false)
useEffect(() => {
const timer = setTimeout(() => setIsVisible(true), delay)
return () => clearTimeout(timer)
}, [delay])
const sizeClasses = {
sm: 'p-4',
md: 'p-5',
lg: 'p-6',
}
return (
<div
className={`
rounded-3xl
${sizeClasses[size]}
${onClick ? 'cursor-pointer' : ''}
${className}
`}
style={{
background: isDark
? (isHovered ? 'rgba(255, 255, 255, 0.12)' : 'rgba(255, 255, 255, 0.08)')
: (isHovered ? 'rgba(255, 255, 255, 0.9)' : 'rgba(255, 255, 255, 0.7)'),
backdropFilter: 'blur(24px) saturate(180%)',
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
border: isDark ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.1)',
boxShadow: isDark
? '0 4px 24px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.05)'
: '0 4px 24px rgba(0, 0, 0, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.5)',
opacity: isVisible ? 1 : 0,
transform: isVisible
? `translateY(0) scale(${isHovered ? 1.01 : 1})`
: 'translateY(20px)',
transition: 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={onClick}
>
{children}
</div>
)
}
// =============================================================================
// PROGRESS RING
// =============================================================================
interface ProgressRingProps {
progress: number
size?: number
strokeWidth?: number
label: string
value: string
color?: string
}
function ProgressRing({
progress,
size = 80,
strokeWidth = 6,
label,
value,
color = '#a78bfa'
}: ProgressRingProps) {
const radius = (size - strokeWidth) / 2
const circumference = radius * 2 * Math.PI
const offset = circumference - (progress / 100) * circumference
return (
<div className="flex flex-col items-center">
<div className="relative" style={{ width: size, height: size }}>
<svg className="transform -rotate-90" width={size} height={size}>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="rgba(255, 255, 255, 0.1)"
strokeWidth={strokeWidth}
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
style={{ transition: 'stroke-dashoffset 0.5s ease' }}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-lg font-bold text-white">{value}</span>
</div>
</div>
<span className="mt-2 text-xs text-white/50">{label}</span>
</div>
)
}
// =============================================================================
// TYPES
// =============================================================================
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
}
}
interface PipelineResult {
success: boolean
handwriting_detected: boolean
handwriting_removed: boolean
layout_reconstructed: boolean
cleaned_image_base64?: string
fabric_json?: any
metadata: any
}
// =============================================================================
// MAIN PAGE
// =============================================================================
export default function WorksheetCleanupPage() {
const { isDark } = useTheme()
const router = useRouter()
// File state
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)
// Loading states
const [isPreviewing, setIsPreviewing] = useState(false)
const [isProcessing, setIsProcessing] = useState(false)
const [error, setError] = useState<string | null>(null)
// Results
const [previewResult, setPreviewResult] = useState<PreviewResult | null>(null)
const [pipelineResult, setPipelineResult] = useState<PipelineResult | 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' | 'processing' | 'result'>('upload')
// QR Code Upload
const [showQRModal, setShowQRModal] = useState(false)
const [uploadSessionId, setUploadSessionId] = useState('')
const [mobileUploadedFiles, setMobileUploadedFiles] = useState<UploadedFile[]>([])
// 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]
}
// Initialize upload session ID
useEffect(() => {
let storedSessionId = localStorage.getItem(SESSION_ID_KEY)
if (!storedSessionId) {
storedSessionId = `cleanup-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
localStorage.setItem(SESSION_ID_KEY, storedSessionId)
}
setUploadSessionId(storedSessionId)
}, [])
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`
}, [])
// Handle file selection
const handleFileSelect = useCallback((selectedFile: File) => {
setFile(selectedFile)
setError(null)
setPreviewResult(null)
setPipelineResult(null)
setCleanedUrl(null)
setMaskUrl(null)
const url = URL.createObjectURL(selectedFile)
setPreviewUrl(url)
setCurrentStep('upload')
}, [])
// Handle mobile file selection - convert to File and trigger handleFileSelect
const handleMobileFileSelect = useCallback(async (uploadedFile: UploadedFile) => {
try {
const base64Data = uploadedFile.dataUrl.split(',')[1]
const byteCharacters = atob(base64Data)
const byteNumbers = new Array(byteCharacters.length)
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
const blob = new Blob([byteArray], { type: uploadedFile.type })
const file = new File([blob], uploadedFile.name, { type: uploadedFile.type })
handleFileSelect(file)
setShowQRModal(false)
} catch (error) {
console.error('Failed to convert mobile file:', error)
setError('Fehler beim Laden der Datei vom Handy')
}
}, [handleFileSelect])
// 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')
} catch (err) {
console.error('Preview failed:', err)
setError(err instanceof Error ? err.message : 'Vorschau fehlgeschlagen')
} finally {
setIsPreviewing(false)
}
}, [file, getApiUrl])
// Run full cleanup pipeline
const handleCleanup = useCallback(async () => {
if (!file) return
setIsProcessing(true)
setCurrentStep('processing')
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')
setCurrentStep('preview')
} finally {
setIsProcessing(false)
}
}, [file, removeHandwriting, reconstructLayout, inpaintingMethod, getApiUrl])
// 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])
// Open in worksheet editor
const handleOpenInEditor = useCallback(() => {
if (pipelineResult?.fabric_json) {
// Store the fabric JSON in sessionStorage
sessionStorage.setItem('worksheetCleanupResult', JSON.stringify(pipelineResult.fabric_json))
router.push('/worksheet-editor')
}
}, [pipelineResult, router])
// Reset to start
const handleReset = useCallback(() => {
setFile(null)
setPreviewUrl(null)
setCleanedUrl(null)
setMaskUrl(null)
setPreviewResult(null)
setPipelineResult(null)
setError(null)
setCurrentStep('upload')
}, [])
return (
<div className={`min-h-screen flex relative overflow-hidden ${
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 */}
<div className={`absolute -top-40 -right-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob ${isDark ? 'bg-purple-500 opacity-30' : 'bg-purple-300 opacity-40'}`} />
<div className={`absolute top-1/2 -left-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-2000 ${isDark ? 'bg-pink-500 opacity-30' : 'bg-pink-300 opacity-40'}`} />
<div className={`absolute -bottom-40 right-1/3 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-4000 ${isDark ? 'bg-blue-500 opacity-30' : 'bg-blue-300 opacity-40'}`} />
{/* Sidebar */}
<div className="relative z-10 p-4">
<Sidebar />
</div>
{/* Main Content */}
<div className="flex-1 flex flex-col relative z-10 p-6 overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className={`text-3xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>Arbeitsblatt bereinigen</h1>
<p className={isDark ? 'text-white/50' : 'text-slate-500'}>Handschrift entfernen und Layout rekonstruieren</p>
</div>
<div className="flex items-center gap-3">
<ThemeToggle />
<LanguageDropdown />
</div>
</div>
{/* Step Indicator */}
<div className="flex items-center justify-center gap-4 mb-8">
{['upload', 'preview', 'processing', 'result'].map((step, idx) => (
<div key={step} className="flex items-center">
<div className={`
w-10 h-10 rounded-full flex items-center justify-center font-medium transition-all
${currentStep === step
? 'bg-purple-500 text-white shadow-lg shadow-purple-500/50'
: ['upload', 'preview', 'processing', 'result'].indexOf(currentStep) > idx
? 'bg-green-500 text-white'
: isDark ? 'bg-white/10 text-white/40' : 'bg-slate-200 text-slate-400'
}
`}>
{['upload', 'preview', 'processing', 'result'].indexOf(currentStep) > idx ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
idx + 1
)}
</div>
{idx < 3 && (
<div className={`w-16 h-0.5 mx-2 ${
['upload', 'preview', 'processing', 'result'].indexOf(currentStep) > idx
? 'bg-green-500'
: isDark ? 'bg-white/20' : 'bg-slate-300'
}`} />
)}
</div>
))}
</div>
{/* Error Display */}
{error && (
<GlassCard className="mb-6" size="sm" isDark={isDark}>
<div className="flex items-center gap-3 text-red-400">
<svg className="w-6 h-6" 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>
<span>{error}</span>
</div>
</GlassCard>
)}
{/* Content based on step */}
<div className="flex-1">
{/* Step 1: Upload */}
{currentStep === 'upload' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Upload Options - File and QR Code side by side */}
<GlassCard className="col-span-1" delay={100}>
<div
className="border-2 border-dashed border-white/20 rounded-2xl p-8 text-center cursor-pointer transition-all hover:border-purple-400/50 hover:bg-white/5 min-h-[280px] flex flex-col items-center justify-center"
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-40 mx-auto rounded-xl shadow-2xl"
/>
<p className="text-white font-medium text-sm">{file?.name}</p>
<p className="text-white/50 text-xs">Klicke zum Ändern</p>
</div>
) : (
<>
<svg className="w-16 h-16 mx-auto mb-4 text-white/30" 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-xl font-semibold text-white mb-2">Datei auswählen</p>
<p className="text-white/50 text-sm mb-2">Ziehe ein Bild hierher oder klicke</p>
<p className="text-white/30 text-xs">PNG, JPG, JPEG</p>
</>
)}
</div>
</GlassCard>
{/* QR Code Upload */}
<GlassCard className="col-span-1" delay={150}>
<div
className="border-2 border-dashed border-white/20 rounded-2xl p-8 text-center cursor-pointer transition-all hover:border-purple-400/50 hover:bg-white/5 min-h-[280px] flex flex-col items-center justify-center"
onClick={() => setShowQRModal(true)}
>
<div className="w-16 h-16 mx-auto mb-4 rounded-xl bg-purple-500/20 flex items-center justify-center">
<span className="text-3xl">📱</span>
</div>
<p className="text-xl font-semibold text-white mb-2">Mit Handy scannen</p>
<p className="text-white/50 text-sm mb-2">QR-Code scannen um Foto hochzuladen</p>
<p className="text-white/30 text-xs">Im lokalen Netzwerk</p>
</div>
</GlassCard>
{/* Options */}
{file && (
<>
<GlassCard delay={200}>
<h3 className="text-lg font-semibold text-white mb-4">Optionen</h3>
<div className="space-y-4">
<label className="flex items-center gap-4 cursor-pointer group">
<input
type="checkbox"
checked={removeHandwriting}
onChange={(e) => setRemoveHandwriting(e.target.checked)}
className="w-5 h-5 rounded bg-white/10 border-white/20 text-purple-500 focus:ring-purple-500"
/>
<div>
<span className="text-white font-medium group-hover:text-purple-300 transition-colors">
Handschrift entfernen
</span>
<p className="text-white/40 text-sm">Erkennt und entfernt handgeschriebene Inhalte</p>
</div>
</label>
<label className="flex items-center gap-4 cursor-pointer group">
<input
type="checkbox"
checked={reconstructLayout}
onChange={(e) => setReconstructLayout(e.target.checked)}
className="w-5 h-5 rounded bg-white/10 border-white/20 text-purple-500 focus:ring-purple-500"
/>
<div>
<span className="text-white font-medium group-hover:text-purple-300 transition-colors">
Layout rekonstruieren
</span>
<p className="text-white/40 text-sm">Erstellt bearbeitbare Textblöcke</p>
</div>
</label>
</div>
</GlassCard>
<GlassCard delay={300}>
<h3 className="text-lg font-semibold text-white mb-4">Methode</h3>
<select
value={inpaintingMethod}
onChange={(e) => setInpaintingMethod(e.target.value)}
className="w-full p-3 rounded-xl bg-white/10 border border-white/20 text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="auto">Automatisch (empfohlen)</option>
<option value="opencv_telea">OpenCV Telea (schnell)</option>
<option value="opencv_ns">OpenCV NS (glatter)</option>
</select>
<p className="text-white/40 text-sm mt-3">
Die automatische Methode wählt die beste Option basierend auf dem Bildinhalt.
</p>
</GlassCard>
{/* Action Button */}
<div className="col-span-1 lg:col-span-2 flex justify-center">
<button
onClick={handlePreview}
disabled={isPreviewing}
className="px-8 py-4 rounded-2xl font-semibold text-lg bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:shadow-lg hover:shadow-purple-500/30 transition-all disabled:opacity-50 flex items-center gap-3"
>
{isPreviewing ? (
<>
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
Analysiere...
</>
) : (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
Vorschau
</>
)}
</button>
</div>
</>
)}
</div>
)}
{/* Step 2: Preview */}
{currentStep === 'preview' && previewResult && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Stats */}
<GlassCard delay={100}>
<h3 className="text-lg font-semibold text-white mb-6">Analyse</h3>
<div className="flex justify-around">
<ProgressRing
progress={previewResult.confidence * 100}
label="Konfidenz"
value={`${Math.round(previewResult.confidence * 100)}%`}
color={previewResult.has_handwriting ? '#f97316' : '#22c55e'}
/>
<ProgressRing
progress={previewResult.handwriting_ratio * 100 * 10}
label="Handschrift"
value={`${(previewResult.handwriting_ratio * 100).toFixed(1)}%`}
color="#a78bfa"
/>
</div>
<div className={`mt-6 p-4 rounded-xl text-center ${
previewResult.has_handwriting
? 'bg-orange-500/20 text-orange-300'
: 'bg-green-500/20 text-green-300'
}`}>
{previewResult.has_handwriting
? 'Handschrift erkannt'
: 'Keine Handschrift gefunden'}
</div>
</GlassCard>
{/* Time Estimates */}
<GlassCard delay={200}>
<h3 className="text-lg font-semibold text-white mb-4">Geschätzte Zeit</h3>
<div className="space-y-3">
<div className="flex justify-between text-white/70">
<span>Erkennung</span>
<span className="text-white">~{Math.round(previewResult.estimated_times_ms.detection / 1000)}s</span>
</div>
{removeHandwriting && previewResult.has_handwriting && (
<div className="flex justify-between text-white/70">
<span>Bereinigung</span>
<span className="text-white">~{Math.round(previewResult.estimated_times_ms.inpainting / 1000)}s</span>
</div>
)}
{reconstructLayout && (
<div className="flex justify-between text-white/70">
<span>Layout</span>
<span className="text-white">~{Math.round(previewResult.estimated_times_ms.reconstruction / 1000)}s</span>
</div>
)}
<div className="flex justify-between pt-3 border-t border-white/10 font-medium">
<span className="text-white">Gesamt</span>
<span className="text-purple-300">~{Math.round(previewResult.estimated_times_ms.total / 1000)}s</span>
</div>
</div>
</GlassCard>
{/* Image Info */}
<GlassCard delay={300}>
<h3 className="text-lg font-semibold text-white mb-4">Bild-Info</h3>
<div className="space-y-3">
<div className="flex justify-between text-white/70">
<span>Breite</span>
<span className="text-white">{previewResult.image_width}px</span>
</div>
<div className="flex justify-between text-white/70">
<span>Höhe</span>
<span className="text-white">{previewResult.image_height}px</span>
</div>
<div className="flex justify-between text-white/70">
<span>Pixel</span>
<span className="text-white">{(previewResult.image_width * previewResult.image_height / 1000000).toFixed(1)}MP</span>
</div>
</div>
<button
onClick={handleGetMask}
className="w-full mt-4 px-4 py-2 rounded-xl bg-white/10 text-white/70 hover:bg-white/20 hover:text-white transition-all text-sm"
>
Maske anzeigen
</button>
</GlassCard>
{/* Preview Images */}
<GlassCard className="col-span-1 lg:col-span-2" delay={400}>
<h3 className="text-lg font-semibold text-white mb-4">Original</h3>
{previewUrl && (
<img
src={previewUrl}
alt="Original"
className="w-full max-h-96 object-contain rounded-xl"
/>
)}
</GlassCard>
{maskUrl && (
<GlassCard delay={500}>
<h3 className="text-lg font-semibold text-white mb-4">Maske</h3>
<img
src={maskUrl}
alt="Mask"
className="w-full max-h-96 object-contain rounded-xl"
/>
</GlassCard>
)}
{/* Actions */}
<div className="col-span-1 lg:col-span-3 flex justify-center gap-4">
<button
onClick={() => setCurrentStep('upload')}
className="px-6 py-3 rounded-xl bg-white/10 text-white hover:bg-white/20 transition-all flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Zurück
</button>
<button
onClick={handleCleanup}
disabled={isProcessing}
className="px-8 py-3 rounded-xl font-semibold bg-gradient-to-r from-orange-500 to-red-500 text-white hover:shadow-lg hover:shadow-orange-500/30 transition-all disabled:opacity-50 flex items-center gap-2"
>
<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 starten
</button>
</div>
</div>
)}
{/* Step 3: Processing */}
{currentStep === 'processing' && (
<div className="flex flex-col items-center justify-center py-20">
<GlassCard className="text-center max-w-md" delay={0}>
<div className="w-20 h-20 mx-auto mb-6 relative">
<div className="absolute inset-0 rounded-full border-4 border-white/10"></div>
<div className="absolute inset-0 rounded-full border-4 border-purple-500 border-t-transparent animate-spin"></div>
</div>
<h3 className="text-xl font-semibold text-white mb-2">Verarbeite Bild...</h3>
<p className="text-white/50">
{removeHandwriting ? 'Handschrift wird erkannt und entfernt' : 'Bild wird analysiert'}
</p>
</GlassCard>
</div>
)}
{/* Step 4: Result */}
{currentStep === 'result' && pipelineResult && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Status */}
<GlassCard className="col-span-1 lg:col-span-2" delay={100}>
<div className={`flex items-center gap-4 ${
pipelineResult.success ? 'text-green-300' : 'text-red-300'
}`}>
<div className={`w-12 h-12 rounded-full flex items-center justify-center ${
pipelineResult.success ? 'bg-green-500/20' : 'bg-red-500/20'
}`}>
{pipelineResult.success ? (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<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>
)}
</div>
<div>
<h3 className="text-xl font-semibold">
{pipelineResult.success ? 'Erfolgreich bereinigt!' : 'Verarbeitung fehlgeschlagen'}
</h3>
<p className="text-white/50">
{pipelineResult.handwriting_removed
? `Handschrift wurde entfernt. ${pipelineResult.metadata?.layout?.element_count || 0} Elemente erkannt.`
: pipelineResult.handwriting_detected
? 'Handschrift erkannt, aber nicht entfernt'
: 'Keine Handschrift im Bild gefunden'}
</p>
</div>
</div>
</GlassCard>
{/* Original */}
<GlassCard delay={200}>
<h3 className="text-lg font-semibold text-white mb-4">Original</h3>
{previewUrl && (
<img
src={previewUrl}
alt="Original"
className="w-full rounded-xl"
/>
)}
</GlassCard>
{/* Cleaned */}
<GlassCard delay={300}>
<h3 className="text-lg font-semibold text-white mb-4">Bereinigt</h3>
{cleanedUrl ? (
<img
src={cleanedUrl}
alt="Cleaned"
className="w-full rounded-xl"
/>
) : (
<div className="aspect-video rounded-xl bg-white/5 flex items-center justify-center text-white/40">
Kein Bild
</div>
)}
</GlassCard>
{/* Actions */}
<div className="col-span-1 lg:col-span-2 flex justify-center gap-4">
<button
onClick={handleReset}
className="px-6 py-3 rounded-xl bg-white/10 text-white hover:bg-white/20 transition-all flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Neues Bild
</button>
{cleanedUrl && (
<a
href={cleanedUrl}
download="bereinigt.png"
className="px-6 py-3 rounded-xl bg-white/10 text-white hover:bg-white/20 transition-all flex items-center gap-2"
>
<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>
Download
</a>
)}
{pipelineResult.layout_reconstructed && pipelineResult.fabric_json && (
<button
onClick={handleOpenInEditor}
className="px-8 py-3 rounded-xl font-semibold bg-gradient-to-r from-green-500 to-emerald-500 text-white hover:shadow-lg hover:shadow-green-500/30 transition-all flex items-center gap-2"
>
<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>
Im Editor öffnen
</button>
)}
</div>
</div>
)}
</div>
</div>
{/* QR Code Modal */}
{showQRModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setShowQRModal(false)} />
<div className="relative w-full max-w-md rounded-3xl bg-slate-900">
<QRCodeUpload
sessionId={uploadSessionId}
onClose={() => setShowQRModal(false)}
onFilesChanged={(files) => {
setMobileUploadedFiles(files)
}}
/>
{/* Select button for mobile files */}
{mobileUploadedFiles.length > 0 && (
<div className="p-4 border-t border-white/10">
<p className="text-white/60 text-sm mb-3">Datei auswählen:</p>
<div className="space-y-2 max-h-40 overflow-y-auto">
{mobileUploadedFiles.map((file) => (
<button
key={file.id}
onClick={() => handleMobileFileSelect(file)}
className="w-full flex items-center gap-3 p-3 rounded-xl text-left transition-all bg-white/5 hover:bg-white/10 border border-white/10"
>
<span className="text-xl">{file.type.startsWith('image/') ? '🖼️' : '📄'}</span>
<div className="flex-1 min-w-0">
<p className="text-white font-medium truncate">{file.name}</p>
<p className="text-white/50 text-xs">{formatFileSize(file.size)}</p>
</div>
<span className="text-purple-400 text-sm">Verwenden </span>
</button>
))}
</div>
</div>
)}
</div>
</div>
)}
</div>
)
}
+492
View File
@@ -0,0 +1,492 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import dynamic from 'next/dynamic'
import { useTheme } from '@/lib/ThemeContext'
import { useLanguage } from '@/lib/LanguageContext'
import { WorksheetProvider, useWorksheet } from '@/lib/worksheet-editor/WorksheetContext'
import { Sidebar } from '@/components/Sidebar'
import { EditorToolbar } from '@/components/worksheet-editor/EditorToolbar'
import { PropertiesPanel } from '@/components/worksheet-editor/PropertiesPanel'
import { CanvasControls } from '@/components/worksheet-editor/CanvasControls'
import { PageNavigator } from '@/components/worksheet-editor/PageNavigator'
import { AIImageGenerator } from '@/components/worksheet-editor/AIImageGenerator'
import { ExportPanel } from '@/components/worksheet-editor/ExportPanel'
import { AIPromptBar } from '@/components/worksheet-editor/AIPromptBar'
import { DocumentImporter } from '@/components/worksheet-editor/DocumentImporter'
import { CleanupPanel } from '@/components/worksheet-editor/CleanupPanel'
import { OCRImportPanel } from '@/components/worksheet-editor/OCRImportPanel'
import { ThemeToggle } from '@/components/ThemeToggle'
import { LanguageDropdown } from '@/components/LanguageDropdown'
// Dynamic import to prevent SSR issues with Fabric.js
const FabricCanvas = dynamic(
() => import('@/components/worksheet-editor/FabricCanvas').then(mod => mod.FabricCanvas),
{
ssr: false,
loading: () => (
<div className="flex items-center justify-center h-full">
<div className="animate-spin w-8 h-8 border-4 border-purple-500 border-t-transparent rounded-full" />
</div>
)
}
)
// Storage key for saved worksheets
const WORKSHEETS_KEY = 'bp_worksheets'
interface SavedWorksheet {
id: string
title: string
updatedAt: string
thumbnail?: string
}
function WorksheetEditorContent() {
const { isDark } = useTheme()
const { t } = useLanguage()
const { document, setDocument, isDirty, setIsDirty, saveDocument, loadDocument, canvas } = useWorksheet()
const [mounted, setMounted] = useState(false)
const [isAIGeneratorOpen, setIsAIGeneratorOpen] = useState(false)
const [isExportPanelOpen, setIsExportPanelOpen] = useState(false)
const [isDocumentImporterOpen, setIsDocumentImporterOpen] = useState(false)
const [isCleanupPanelOpen, setIsCleanupPanelOpen] = useState(false)
const [isOCRImportOpen, setIsOCRImportOpen] = useState(false)
const [isDocumentListOpen, setIsDocumentListOpen] = useState(false)
const [savedWorksheets, setSavedWorksheets] = useState<SavedWorksheet[]>([])
const [isSaving, setIsSaving] = useState(false)
const [title, setTitle] = useState('')
useEffect(() => {
setMounted(true)
loadSavedWorksheets()
}, [])
useEffect(() => {
if (document) {
setTitle(document.title)
}
}, [document])
// Load saved worksheets from localStorage
const loadSavedWorksheets = useCallback(() => {
try {
const stored = localStorage.getItem(WORKSHEETS_KEY)
if (stored) {
setSavedWorksheets(JSON.parse(stored))
}
} catch (e) {
console.error('Failed to load worksheets:', e)
}
}, [])
// Save current worksheet
const handleSave = useCallback(async () => {
if (!document) return
setIsSaving(true)
try {
// Save to context (which saves to API or localStorage)
await saveDocument()
// Update worksheets list
const worksheetEntry: SavedWorksheet = {
id: document.id,
title: document.title,
updatedAt: new Date().toISOString(),
thumbnail: canvas?.toDataURL({ format: 'png', multiplier: 0.1 })
}
setSavedWorksheets(prev => {
const filtered = prev.filter(w => w.id !== document.id)
const updated = [worksheetEntry, ...filtered]
localStorage.setItem(WORKSHEETS_KEY, JSON.stringify(updated))
return updated
})
setIsDirty(false)
} catch (e) {
console.error('Save failed:', e)
} finally {
setIsSaving(false)
}
}, [document, saveDocument, canvas, setIsDirty])
// Load a saved worksheet
const handleLoadWorksheet = useCallback(async (id: string) => {
try {
await loadDocument(id)
setIsDocumentListOpen(false)
} catch (e) {
console.error('Failed to load worksheet:', e)
}
}, [loadDocument])
// Delete a saved worksheet
const handleDeleteWorksheet = useCallback((id: string) => {
setSavedWorksheets(prev => {
const updated = prev.filter(w => w.id !== id)
localStorage.setItem(WORKSHEETS_KEY, JSON.stringify(updated))
localStorage.removeItem(`worksheet_${id}`)
return updated
})
}, [])
// Create new worksheet
const handleNewWorksheet = useCallback(() => {
const newDoc = {
id: `ws_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
title: 'Neues Arbeitsblatt',
pages: [{
id: `page_${Date.now()}`,
index: 0,
canvasJSON: ''
}],
pageFormat: {
width: 210,
height: 297,
orientation: 'portrait' as const,
margins: { top: 15, right: 15, bottom: 15, left: 15 }
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
setDocument(newDoc)
setIsDocumentListOpen(false)
if (canvas) {
canvas.clear()
canvas.backgroundColor = '#ffffff'
canvas.renderAll()
}
}, [setDocument, canvas])
const handleTitleChange = (newTitle: string) => {
setTitle(newTitle)
if (document) {
setDocument({
...document,
title: newTitle,
updatedAt: new Date().toISOString()
})
}
}
if (!mounted) {
return (
<div className={`min-h-screen flex items-center justify-center ${
isDark ? 'bg-slate-900' : 'bg-slate-100'
}`}>
<div className="animate-spin w-8 h-8 border-4 border-purple-500 border-t-transparent rounded-full" />
</div>
)
}
return (
<div className={`min-h-screen flex relative overflow-hidden ${
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 */}
<div className={`absolute -top-40 -right-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob ${
isDark ? 'bg-purple-500 opacity-30' : 'bg-purple-300 opacity-40'
}`} />
<div className={`absolute top-1/2 -left-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-2000 ${
isDark ? 'bg-pink-500 opacity-30' : 'bg-pink-300 opacity-40'
}`} />
<div className={`absolute -bottom-40 right-1/3 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-4000 ${
isDark ? 'bg-blue-500 opacity-30' : 'bg-blue-300 opacity-40'
}`} />
{/* Sidebar */}
<div className="relative z-10 p-4">
<Sidebar />
</div>
{/* Main Content */}
<div className="flex-1 flex flex-col relative z-10 p-4 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-4">
<div>
<h1 className={`text-2xl font-bold mb-1 ${isDark ? 'text-white' : 'text-slate-900'}`}>Arbeitsblatt-Editor</h1>
<div className="flex items-center gap-2">
<input
type="text"
value={title}
onChange={(e) => handleTitleChange(e.target.value)}
placeholder="Arbeitsblatt-Titel..."
className={`text-sm px-3 py-1.5 rounded-lg border transition-all w-56 ${
isDark
? 'bg-white/10 border-white/20 text-white placeholder-white/40 focus:border-purple-400'
: 'bg-white/50 border-slate-300 text-slate-900 placeholder-slate-400 focus:border-purple-500'
}`}
/>
{isDirty && (
<span className={`px-2 py-1 rounded-lg text-xs font-medium ${
isDark ? 'bg-yellow-500/20 text-yellow-300' : 'bg-yellow-100 text-yellow-700'
}`}>
Ungespeichert
</span>
)}
{/* Save Button */}
<button
onClick={handleSave}
disabled={isSaving || !isDirty}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-all ${
isDirty
? 'bg-green-500 text-white hover:bg-green-600'
: isDark
? 'bg-white/10 text-white/40 cursor-not-allowed'
: 'bg-slate-200 text-slate-400 cursor-not-allowed'
}`}
>
{isSaving ? (
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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
</button>
</div>
</div>
</div>
<div className="flex items-center gap-2">
{/* Document List Button */}
<button
onClick={() => setIsDocumentListOpen(true)}
className={`flex items-center gap-2 px-3 py-2 rounded-xl font-medium transition-all ${
isDark
? 'bg-white/10 text-white hover:bg-white/20'
: 'bg-white text-slate-700 hover:bg-slate-100'
}`}
>
<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>
Meine Arbeitsblätter
</button>
{/* Export Button */}
<button
onClick={() => setIsExportPanelOpen(true)}
className={`flex items-center gap-2 px-3 py-2 rounded-xl font-medium transition-all ${
isDark
? 'bg-purple-500/30 text-purple-300 hover:bg-purple-500/40'
: 'bg-purple-600 text-white hover:bg-purple-700'
}`}
>
<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>
Exportieren
</button>
{/* Theme Toggle */}
<ThemeToggle />
{/* Language Dropdown */}
<LanguageDropdown />
</div>
</div>
{/* Editor Area - New Layout */}
<div className="flex-1 flex gap-4 overflow-hidden">
{/* Left Toolbar */}
<div className="flex-shrink-0">
<EditorToolbar
onOpenAIGenerator={() => setIsAIGeneratorOpen(true)}
onOpenDocumentImporter={() => setIsDocumentImporterOpen(true)}
onOpenCleanupPanel={() => setIsCleanupPanelOpen(true)}
onOpenOCRImport={() => setIsOCRImportOpen(true)}
className="h-full"
/>
</div>
{/* Canvas Area - takes remaining space */}
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
{/* Canvas with fixed aspect ratio container */}
<div className={`flex-1 overflow-auto rounded-xl ${
isDark ? 'bg-slate-800/50' : 'bg-slate-200/50'
}`}>
<FabricCanvas className="h-full" />
</div>
{/* Bottom Controls */}
<div className="flex items-center justify-between gap-4 py-2">
<PageNavigator />
<CanvasControls />
</div>
</div>
{/* Right Panel - AI Prompt + Properties */}
<div className="w-80 flex-shrink-0 flex flex-col gap-4 overflow-hidden">
{/* AI Prompt Bar */}
<div className="flex-shrink-0">
<AIPromptBar />
</div>
{/* Properties Panel */}
<div className="flex-1 overflow-hidden">
<PropertiesPanel className="h-full" />
</div>
</div>
</div>
</div>
{/* Document List Modal */}
{isDocumentListOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setIsDocumentListOpen(false)} />
<div className={`relative w-full max-w-2xl rounded-3xl p-6 ${
isDark ? 'bg-slate-900/95' : 'bg-white/95'
} backdrop-blur-xl border ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
<div className="flex items-center justify-between mb-6">
<h2 className={`text-xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Meine Arbeitsblätter
</h2>
<button
onClick={() => setIsDocumentListOpen(false)}
className={`p-2 rounded-lg transition-colors ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'}`}
>
<svg className={`w-5 h-5 ${isDark ? 'text-white' : 'text-slate-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* New Worksheet Button */}
<button
onClick={handleNewWorksheet}
className={`w-full mb-4 p-4 rounded-xl border-2 border-dashed transition-all flex items-center justify-center gap-2 ${
isDark
? 'border-white/20 text-white/60 hover:border-purple-400 hover:text-purple-300 hover:bg-purple-500/10'
: 'border-slate-300 text-slate-500 hover:border-purple-500 hover:text-purple-600 hover:bg-purple-50'
}`}
>
<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>
Neues Arbeitsblatt erstellen
</button>
{/* Worksheets List */}
<div className="max-h-96 overflow-y-auto space-y-2">
{savedWorksheets.length === 0 ? (
<div className={`text-center py-8 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
<svg className="w-12 h-12 mx-auto mb-3 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>Noch keine Arbeitsblätter gespeichert</p>
</div>
) : (
savedWorksheets.map((worksheet) => (
<div
key={worksheet.id}
className={`flex items-center gap-4 p-4 rounded-xl transition-all cursor-pointer ${
isDark
? 'bg-white/5 hover:bg-white/10'
: 'bg-slate-50 hover:bg-slate-100'
} ${document?.id === worksheet.id ? (isDark ? 'ring-2 ring-purple-500' : 'ring-2 ring-purple-500') : ''}`}
onClick={() => handleLoadWorksheet(worksheet.id)}
>
{/* Thumbnail */}
<div className={`w-16 h-20 rounded-lg flex-shrink-0 overflow-hidden ${
isDark ? 'bg-white/10' : 'bg-slate-200'
}`}>
{worksheet.thumbnail ? (
<img src={worksheet.thumbnail} alt="" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center">
<svg className={`w-6 h-6 ${isDark ? 'text-white/30' : 'text-slate-400'}`} 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>
{/* Info */}
<div className="flex-1 min-w-0">
<h3 className={`font-medium truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>
{worksheet.title}
</h3>
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
{new Date(worksheet.updatedAt).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</p>
{document?.id === worksheet.id && (
<span className="inline-block mt-1 px-2 py-0.5 rounded text-xs bg-purple-500/20 text-purple-400">
Aktuell geöffnet
</span>
)}
</div>
{/* Delete Button */}
<button
onClick={(e) => {
e.stopPropagation()
if (confirm('Arbeitsblatt wirklich löschen?')) {
handleDeleteWorksheet(worksheet.id)
}
}}
className={`p-2 rounded-lg transition-colors ${
isDark ? 'hover:bg-red-500/20 text-white/50 hover:text-red-400' : 'hover:bg-red-50 text-slate-400 hover:text-red-500'
}`}
>
<svg className="w-5 h-5" 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>
)}
{/* Modals */}
<AIImageGenerator
isOpen={isAIGeneratorOpen}
onClose={() => setIsAIGeneratorOpen(false)}
/>
<ExportPanel
isOpen={isExportPanelOpen}
onClose={() => setIsExportPanelOpen(false)}
/>
<DocumentImporter
isOpen={isDocumentImporterOpen}
onClose={() => setIsDocumentImporterOpen(false)}
/>
<CleanupPanel
isOpen={isCleanupPanelOpen}
onClose={() => setIsCleanupPanelOpen(false)}
/>
<OCRImportPanel
isOpen={isOCRImportOpen}
onClose={() => setIsOCRImportOpen(false)}
/>
</div>
)
}
export default function WorksheetEditorPage() {
return (
<WorksheetProvider>
<WorksheetEditorContent />
</WorksheetProvider>
)
}
+237
View File
@@ -0,0 +1,237 @@
/**
* Worksheet Editor - TypeScript Interfaces
*
* Types for the visual worksheet editor using Fabric.js
*/
import type { Canvas, Object as FabricObject } from 'fabric'
// Tool Types
export type EditorTool =
| 'select'
| 'text'
| 'rectangle'
| 'circle'
| 'line'
| 'arrow'
| 'image'
| 'ai-image'
| 'table'
// Text Alignment
export type TextAlign = 'left' | 'center' | 'right' | 'justify'
// Font Weight
export type FontWeight = 'normal' | 'bold'
// Font Style
export type FontStyle = 'normal' | 'italic'
// Object Type
export type WorksheetObjectType =
| 'text'
| 'image'
| 'rectangle'
| 'circle'
| 'line'
| 'arrow'
| 'table'
| 'ai-image'
// Base Object Properties
export interface BaseObjectProps {
id: string
type: WorksheetObjectType
left: number
top: number
width?: number
height?: number
angle: number
opacity: number
fill?: string
stroke?: string
strokeWidth?: number
locked?: boolean
}
// Text Object Properties
export interface TextObjectProps extends BaseObjectProps {
type: 'text'
text: string
fontFamily: string
fontSize: number
fontWeight: FontWeight
fontStyle: FontStyle
textAlign: TextAlign
lineHeight: number
charSpacing: number
underline?: boolean
linethrough?: boolean
}
// Image Object Properties
export interface ImageObjectProps extends BaseObjectProps {
type: 'image' | 'ai-image'
src: string
originalWidth: number
originalHeight: number
cropX?: number
cropY?: number
cropWidth?: number
cropHeight?: number
}
// Shape Object Properties
export interface ShapeObjectProps extends BaseObjectProps {
type: 'rectangle' | 'circle' | 'line' | 'arrow'
rx?: number // Corner radius for rectangles
ry?: number
}
// Table Object Properties
export interface TableObjectProps extends BaseObjectProps {
type: 'table'
rows: number
cols: number
cellWidth: number
cellHeight: number
cellData: string[][]
}
// Union type for all objects
export type WorksheetObject =
| TextObjectProps
| ImageObjectProps
| ShapeObjectProps
| TableObjectProps
// Page
export interface WorksheetPage {
id: string
index: number
canvasJSON: string // Serialized Fabric.js canvas state
thumbnail?: string
}
// Worksheet Document
export interface WorksheetDocument {
id: string
title: string
description?: string
pages: WorksheetPage[]
pageFormat: PageFormat
createdAt: string
updatedAt: string
}
// Page Format
export interface PageFormat {
width: number // in mm
height: number // in mm
orientation: 'portrait' | 'landscape'
margins: {
top: number
right: number
bottom: number
left: number
}
}
// Default A4 Format
export const DEFAULT_PAGE_FORMAT: PageFormat = {
width: 210,
height: 297,
orientation: 'portrait',
margins: {
top: 15,
right: 15,
bottom: 15,
left: 15
}
}
// Canvas Scale (mm to pixels at 96 DPI)
export const MM_TO_PX = 3.7795275591 // 1mm = 3.78px at 96 DPI
// AI Image Generation
export interface AIImageRequest {
prompt: string
style: AIImageStyle
width: number
height: number
}
export type AIImageStyle =
| 'realistic'
| 'cartoon'
| 'sketch'
| 'clipart'
| 'educational'
export interface AIImageResponse {
image_base64: string
prompt_used: string
error?: string
}
// Editor State
export interface EditorState {
activeTool: EditorTool
activeObject: FabricObject | null
selectedObjects: FabricObject[]
zoom: number
showGrid: boolean
snapToGrid: boolean
gridSize: number
currentPageIndex: number
}
// History Entry for Undo/Redo
export interface HistoryEntry {
canvasJSON: string
timestamp: number
action: string
}
// Typography Presets
export interface TypographyPreset {
id: string
name: string
fontFamily: string
fontSize: number
fontWeight: FontWeight
lineHeight: number
}
// Default Typography Presets
export const DEFAULT_TYPOGRAPHY_PRESETS: TypographyPreset[] = [
{ id: 'h1', name: 'Überschrift 1', fontFamily: 'Arial', fontSize: 32, fontWeight: 'bold', lineHeight: 1.2 },
{ id: 'h2', name: 'Überschrift 2', fontFamily: 'Arial', fontSize: 24, fontWeight: 'bold', lineHeight: 1.3 },
{ id: 'h3', name: 'Überschrift 3', fontFamily: 'Arial', fontSize: 18, fontWeight: 'bold', lineHeight: 1.4 },
{ id: 'body', name: 'Fließtext', fontFamily: 'Arial', fontSize: 12, fontWeight: 'normal', lineHeight: 1.5 },
{ id: 'small', name: 'Klein', fontFamily: 'Arial', fontSize: 10, fontWeight: 'normal', lineHeight: 1.4 },
{ id: 'caption', name: 'Bildunterschrift', fontFamily: 'Arial', fontSize: 9, fontWeight: 'normal', lineHeight: 1.3 },
]
// Available Fonts
export const AVAILABLE_FONTS = [
{ name: 'Arial', family: 'Arial, sans-serif' },
{ name: 'Times New Roman', family: 'Times New Roman, serif' },
{ name: 'Georgia', family: 'Georgia, serif' },
{ name: 'Verdana', family: 'Verdana, sans-serif' },
{ name: 'Comic Sans MS', family: 'Comic Sans MS, cursive' },
{ name: 'OpenDyslexic', family: 'OpenDyslexic, sans-serif' },
{ name: 'Schulschrift', family: 'Schulschrift, cursive' },
{ name: 'Courier New', family: 'Courier New, monospace' },
]
// Export Format
export type ExportFormat = 'pdf' | 'png' | 'jpg' | 'json'
// Export Options
export interface ExportOptions {
format: ExportFormat
quality?: number // 0-1 for images
includeBackground?: boolean
scale?: number
}
+261
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
+552
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>
)
}
+848
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>
)
}
+413
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
}
}
}
+266
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>
)
}
+77
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>
)
}
+405
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>
)
}
+363
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>
)
}
+90
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>
)
}
+214
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>
)
}
+212
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>
)
}
+113
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>
)
}
+193
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>
)
}
+288
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>
)
}
+513
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>
)
}
+334
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 }
+339
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>
)
}
+237
View File
@@ -0,0 +1,237 @@
'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: 'companion', labelKey: 'nav_companion', href: '/companion', icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9" strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6v6l4 2" />
</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 === '/companion') return 'companion'
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>
)
}
+46
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>
)
}
+163
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>
)
}
@@ -0,0 +1,222 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Settings, MessageSquare, HelpCircle, Timer } from 'lucide-react'
import { TeacherSettings, FeedbackType } from '@/lib/companion/types'
import { DEFAULT_TEACHER_SETTINGS, STORAGE_KEYS } from '@/lib/companion/constants'
// Components
import { LessonStartForm } from './lesson-mode/LessonStartForm'
import { LessonActiveView } from './lesson-mode/LessonActiveView'
import { LessonEndedView } from './lesson-mode/LessonEndedView'
import { SettingsModal } from './modals/SettingsModal'
import { FeedbackModal } from './modals/FeedbackModal'
import { OnboardingModal } from './modals/OnboardingModal'
// Hooks
import { useLessonSession } from '@/hooks/companion/useLessonSession'
import { useKeyboardShortcuts } from '@/hooks/companion/useKeyboardShortcuts'
export function CompanionDashboard() {
// Modal states
const [showSettings, setShowSettings] = useState(false)
const [showFeedback, setShowFeedback] = useState(false)
const [showOnboarding, setShowOnboarding] = useState(false)
// Settings
const [settings, setSettings] = useState<TeacherSettings>(DEFAULT_TEACHER_SETTINGS)
// Load settings from localStorage
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEYS.SETTINGS)
if (stored) {
try {
const parsed = JSON.parse(stored)
setSettings({ ...DEFAULT_TEACHER_SETTINGS, ...parsed })
} catch {
// Invalid stored settings
}
}
// Check if onboarding needed
const onboardingStored = localStorage.getItem(STORAGE_KEYS.ONBOARDING_STATE)
if (!onboardingStored) {
setShowOnboarding(true)
}
}, [])
// Lesson session hook
const {
session,
startLesson,
endLesson,
clearSession,
pauseLesson,
resumeLesson,
extendTime,
skipPhase,
saveReflection,
addHomework,
removeHomework,
isPaused,
} = useLessonSession({
onOvertimeStart: () => {
if (settings.soundNotifications) {
// TODO: Play notification sound
}
},
})
// Handle pause/resume toggle
const handlePauseToggle = useCallback(() => {
if (isPaused) {
resumeLesson()
} else {
pauseLesson()
}
}, [isPaused, pauseLesson, resumeLesson])
// Keyboard shortcuts
useKeyboardShortcuts({
onPauseResume: session ? handlePauseToggle : undefined,
onExtend: session && !isPaused ? () => extendTime(5) : undefined,
onNextPhase: session && !isPaused ? skipPhase : undefined,
onCloseModal: () => {
setShowSettings(false)
setShowFeedback(false)
setShowOnboarding(false)
},
enabled: settings.showKeyboardShortcuts,
})
// Handle settings save
const handleSaveSettings = (newSettings: TeacherSettings) => {
setSettings(newSettings)
localStorage.setItem(STORAGE_KEYS.SETTINGS, JSON.stringify(newSettings))
}
// Handle feedback submit
const handleFeedbackSubmit = async (type: FeedbackType, title: string, description: string) => {
const response = await fetch('/api/companion/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type,
title,
description,
sessionId: session?.sessionId,
}),
})
if (!response.ok) {
throw new Error('Failed to submit feedback')
}
}
// Handle onboarding complete
const handleOnboardingComplete = (data: { state?: string; schoolType?: string }) => {
localStorage.setItem(STORAGE_KEYS.ONBOARDING_STATE, JSON.stringify({
...data,
completed: true,
completedAt: new Date().toISOString(),
}))
setShowOnboarding(false)
setSettings({ ...settings, onboardingCompleted: true })
}
// Determine current view based on session status
const renderContent = () => {
if (!session) {
return <LessonStartForm onStart={startLesson} />
}
if (session.status === 'completed') {
return (
<LessonEndedView
session={session}
onSaveReflection={saveReflection}
onAddHomework={addHomework}
onRemoveHomework={removeHomework}
onStartNew={clearSession}
/>
)
}
// in_progress or paused
return (
<LessonActiveView
session={session}
onPauseToggle={handlePauseToggle}
onExtendTime={extendTime}
onSkipPhase={skipPhase}
onEndLesson={endLesson}
/>
)
}
return (
<div className={`min-h-[calc(100vh-200px)] ${settings.highContrastMode ? 'high-contrast' : ''}`}>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 rounded-lg">
<Timer className="w-5 h-5 text-blue-600" />
</div>
<h2 className="text-lg font-semibold text-slate-900">Unterrichtsstunde</h2>
</div>
<div className="flex items-center gap-2">
{/* Feedback Button */}
<button
onClick={() => setShowFeedback(true)}
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
title="Feedback"
>
<MessageSquare className="w-5 h-5" />
</button>
{/* Settings Button */}
<button
onClick={() => setShowSettings(true)}
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
title="Einstellungen"
>
<Settings className="w-5 h-5" />
</button>
{/* Help Button */}
<button
onClick={() => setShowOnboarding(true)}
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
title="Hilfe"
>
<HelpCircle className="w-5 h-5" />
</button>
</div>
</div>
{/* Main Content */}
{renderContent()}
{/* Modals */}
<SettingsModal
isOpen={showSettings}
onClose={() => setShowSettings(false)}
settings={settings}
onSave={handleSaveSettings}
/>
<FeedbackModal
isOpen={showFeedback}
onClose={() => setShowFeedback(false)}
onSubmit={handleFeedbackSubmit}
/>
<OnboardingModal
isOpen={showOnboarding}
onClose={() => setShowOnboarding(false)}
onComplete={handleOnboardingComplete}
/>
</div>
)
}
@@ -0,0 +1,153 @@
'use client'
import { useState } from 'react'
import { Plus, Trash2, BookOpen, Calendar } from 'lucide-react'
import { Homework } from '@/lib/companion/types'
interface HomeworkSectionProps {
homeworkList: Homework[]
onAdd: (title: string, dueDate: string) => void
onRemove: (id: string) => void
}
export function HomeworkSection({ homeworkList, onAdd, onRemove }: HomeworkSectionProps) {
const [newTitle, setNewTitle] = useState('')
const [newDueDate, setNewDueDate] = useState('')
const [isAdding, setIsAdding] = useState(false)
// Default due date to next week
const getDefaultDueDate = () => {
const date = new Date()
date.setDate(date.getDate() + 7)
return date.toISOString().split('T')[0]
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!newTitle.trim()) return
onAdd(newTitle.trim(), newDueDate || getDefaultDueDate())
setNewTitle('')
setNewDueDate('')
setIsAdding(false)
}
const formatDate = (dateStr: string) => {
const date = new Date(dateStr)
return date.toLocaleDateString('de-DE', {
weekday: 'short',
day: 'numeric',
month: 'short',
})
}
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
<BookOpen className="w-5 h-5 text-slate-400" />
Hausaufgaben
</h3>
{!isAdding && (
<button
onClick={() => setIsAdding(true)}
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
>
<Plus className="w-4 h-4" />
Hinzufuegen
</button>
)}
</div>
{/* Add Form */}
{isAdding && (
<form onSubmit={handleSubmit} className="mb-4 p-4 bg-blue-50 rounded-xl">
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Aufgabe
</label>
<input
type="text"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="z.B. Aufgabe 1-5 auf S. 42..."
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Faellig am
</label>
<input
type="date"
value={newDueDate}
onChange={(e) => setNewDueDate(e.target.value)}
min={new Date().toISOString().split('T')[0]}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div className="flex gap-2">
<button
type="submit"
disabled={!newTitle.trim()}
className="flex-1 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Speichern
</button>
<button
type="button"
onClick={() => {
setIsAdding(false)
setNewTitle('')
setNewDueDate('')
}}
className="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded-lg"
>
Abbrechen
</button>
</div>
</div>
</form>
)}
{/* Homework List */}
{homeworkList.length === 0 ? (
<div className="text-center py-8">
<BookOpen className="w-10 h-10 text-slate-300 mx-auto mb-2" />
<p className="text-slate-500">Keine Hausaufgaben eingetragen</p>
<p className="text-sm text-slate-400 mt-1">
Fuegen Sie Hausaufgaben hinzu, um sie zu dokumentieren
</p>
</div>
) : (
<div className="space-y-3">
{homeworkList.map((hw) => (
<div
key={hw.id}
className="flex items-start gap-3 p-4 bg-slate-50 rounded-xl group"
>
<div className="flex-1">
<p className="font-medium text-slate-900">{hw.title}</p>
<div className="flex items-center gap-2 mt-1 text-sm text-slate-500">
<Calendar className="w-3 h-3" />
<span>Faellig: {formatDate(hw.dueDate)}</span>
</div>
</div>
<button
onClick={() => onRemove(hw.id)}
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg opacity-0 group-hover:opacity-100 transition-all"
title="Entfernen"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,172 @@
'use client'
import { BookOpen, Clock, Users } from 'lucide-react'
import { LessonSession } from '@/lib/companion/types'
import { VisualPieTimer } from './VisualPieTimer'
import { QuickActionsBar } from './QuickActionsBar'
import { PhaseTimelineDetailed } from './PhaseTimeline'
import {
PHASE_COLORS,
PHASE_DISPLAY_NAMES,
formatTime,
getTimerColorStatus,
} from '@/lib/companion/constants'
interface LessonActiveViewProps {
session: LessonSession
onPauseToggle: () => void
onExtendTime: (minutes: number) => void
onSkipPhase: () => void
onEndLesson: () => void
}
export function LessonActiveView({
session,
onPauseToggle,
onExtendTime,
onSkipPhase,
onEndLesson,
}: LessonActiveViewProps) {
const currentPhase = session.phases[session.currentPhaseIndex]
const phaseId = currentPhase?.phase || 'einstieg'
const phaseColor = PHASE_COLORS[phaseId].hex
const phaseName = PHASE_DISPLAY_NAMES[phaseId]
// Calculate timer values
const phaseDurationSeconds = (currentPhase?.duration || 0) * 60
const elapsedInPhase = currentPhase?.actualTime || 0
const remainingSeconds = phaseDurationSeconds - elapsedInPhase
const progress = Math.min(elapsedInPhase / phaseDurationSeconds, 1)
const isOvertime = remainingSeconds < 0
const colorStatus = getTimerColorStatus(remainingSeconds, isOvertime)
const isLastPhase = session.currentPhaseIndex === session.phases.length - 1
// Calculate total elapsed
const totalElapsedMinutes = Math.floor(session.elapsedTime / 60)
return (
<div className="space-y-6">
{/* Header with Session Info */}
<div
className="bg-gradient-to-r rounded-xl p-6 text-white"
style={{
background: `linear-gradient(135deg, ${phaseColor}, ${phaseColor}dd)`,
}}
>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2 text-white/80 text-sm mb-1">
<Users className="w-4 h-4" />
<span>{session.className}</span>
<span className="mx-2">|</span>
<BookOpen className="w-4 h-4" />
<span>{session.subject}</span>
</div>
<h2 className="text-2xl font-bold">
{session.topic || phaseName}
</h2>
</div>
<div className="text-right">
<div className="text-white/80 text-sm">Gesamtzeit</div>
<div className="text-xl font-mono font-bold">
{formatTime(session.elapsedTime)}
</div>
</div>
</div>
</div>
{/* Main Timer Section */}
<div className="bg-white border border-slate-200 rounded-xl p-8">
<div className="flex flex-col items-center">
{/* Visual Pie Timer */}
<VisualPieTimer
progress={progress}
remainingSeconds={remainingSeconds}
totalSeconds={phaseDurationSeconds}
colorStatus={colorStatus}
isPaused={session.isPaused}
currentPhaseName={phaseName}
phaseColor={phaseColor}
onTogglePause={onPauseToggle}
size="lg"
/>
{/* Quick Actions */}
<div className="mt-8 w-full max-w-md">
<QuickActionsBar
onExtend={onExtendTime}
onPause={onPauseToggle}
onResume={onPauseToggle}
onSkip={onSkipPhase}
onEnd={onEndLesson}
isPaused={session.isPaused}
isLastPhase={isLastPhase}
/>
</div>
</div>
</div>
{/* Phase Timeline */}
<PhaseTimelineDetailed
phases={session.phases.map((p, i) => ({
id: p.phase,
shortName: p.phase[0].toUpperCase(),
displayName: PHASE_DISPLAY_NAMES[p.phase],
duration: p.duration,
status: p.status === 'active' ? 'active' : p.status === 'completed' ? 'completed' : 'planned',
actualTime: p.actualTime,
color: PHASE_COLORS[p.phase].hex,
}))}
currentPhaseIndex={session.currentPhaseIndex}
onPhaseClick={(index) => {
// Optional: Allow clicking to navigate to a phase
}}
/>
{/* Lesson Stats */}
<div className="grid grid-cols-3 gap-4">
<div className="bg-white border border-slate-200 rounded-xl p-4 text-center">
<Clock className="w-5 h-5 text-slate-400 mx-auto mb-2" />
<div className="text-2xl font-bold text-slate-900">{totalElapsedMinutes}</div>
<div className="text-sm text-slate-500">Minuten vergangen</div>
</div>
<div className="bg-white border border-slate-200 rounded-xl p-4 text-center">
<div
className="w-5 h-5 rounded-full mx-auto mb-2"
style={{ backgroundColor: phaseColor }}
/>
<div className="text-2xl font-bold text-slate-900">
{session.currentPhaseIndex + 1}/{session.phases.length}
</div>
<div className="text-sm text-slate-500">Phase</div>
</div>
<div className="bg-white border border-slate-200 rounded-xl p-4 text-center">
<Clock className="w-5 h-5 text-slate-400 mx-auto mb-2" />
<div className="text-2xl font-bold text-slate-900">
{session.totalPlannedDuration - totalElapsedMinutes}
</div>
<div className="text-sm text-slate-500">Minuten verbleibend</div>
</div>
</div>
{/* Keyboard Shortcuts Hint */}
<div className="text-center text-sm text-slate-400">
<span className="inline-flex items-center gap-4">
<span>
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs">Leertaste</kbd> Pause
</span>
<span>
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs">E</kbd> +5 Min
</span>
<span>
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs">N</kbd> Weiter
</span>
</span>
</div>
</div>
)
}
@@ -0,0 +1,209 @@
'use client'
import { useState } from 'react'
import { CheckCircle, Clock, BarChart3, Plus, RefreshCw } from 'lucide-react'
import { LessonSession } from '@/lib/companion/types'
import { HomeworkSection } from './HomeworkSection'
import { ReflectionSection } from './ReflectionSection'
import {
PHASE_COLORS,
PHASE_DISPLAY_NAMES,
formatTime,
formatMinutes,
} from '@/lib/companion/constants'
interface LessonEndedViewProps {
session: LessonSession
onSaveReflection: (rating: number, notes: string, nextSteps: string) => void
onAddHomework: (title: string, dueDate: string) => void
onRemoveHomework: (id: string) => void
onStartNew: () => void
}
export function LessonEndedView({
session,
onSaveReflection,
onAddHomework,
onRemoveHomework,
onStartNew,
}: LessonEndedViewProps) {
const [activeTab, setActiveTab] = useState<'summary' | 'homework' | 'reflection'>('summary')
// Calculate analytics
const totalPlannedSeconds = session.totalPlannedDuration * 60
const totalActualSeconds = session.elapsedTime
const timeDiff = totalActualSeconds - totalPlannedSeconds
const timeDiffMinutes = Math.round(timeDiff / 60)
const startTime = new Date(session.startTime)
const endTime = session.endTime ? new Date(session.endTime) : new Date()
return (
<div className="space-y-6">
{/* Success Header */}
<div className="bg-gradient-to-r from-green-500 to-green-600 rounded-xl p-6 text-white">
<div className="flex items-center gap-4">
<div className="p-3 bg-white/20 rounded-full">
<CheckCircle className="w-8 h-8" />
</div>
<div>
<h2 className="text-2xl font-bold">Stunde beendet!</h2>
<p className="text-green-100">
{session.className} - {session.subject}
{session.topic && ` - ${session.topic}`}
</p>
</div>
</div>
</div>
{/* Tab Navigation */}
<div className="bg-white border border-slate-200 rounded-xl p-1 flex">
{[
{ id: 'summary', label: 'Zusammenfassung', icon: BarChart3 },
{ id: 'homework', label: 'Hausaufgaben', icon: Plus },
{ id: 'reflection', label: 'Reflexion', icon: RefreshCw },
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as typeof activeTab)}
className={`
flex-1 flex items-center justify-center gap-2 py-3 px-4 rounded-lg
font-medium transition-all duration-200
${activeTab === tab.id
? 'bg-slate-900 text-white'
: 'text-slate-600 hover:bg-slate-100'
}
`}
>
<tab.icon className="w-4 h-4" />
{tab.label}
</button>
))}
</div>
{/* Tab Content */}
{activeTab === 'summary' && (
<div className="space-y-6">
{/* Time Overview */}
<div className="bg-white border border-slate-200 rounded-xl p-6">
<h3 className="font-semibold text-slate-900 mb-4 flex items-center gap-2">
<Clock className="w-5 h-5 text-slate-400" />
Zeitauswertung
</h3>
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="text-center p-4 bg-slate-50 rounded-xl">
<div className="text-2xl font-bold text-slate-900">
{formatTime(totalActualSeconds)}
</div>
<div className="text-sm text-slate-500">Tatsaechlich</div>
</div>
<div className="text-center p-4 bg-slate-50 rounded-xl">
<div className="text-2xl font-bold text-slate-900">
{formatMinutes(session.totalPlannedDuration)}
</div>
<div className="text-sm text-slate-500">Geplant</div>
</div>
<div className={`text-center p-4 rounded-xl ${timeDiff > 0 ? 'bg-amber-50' : 'bg-green-50'}`}>
<div className={`text-2xl font-bold ${timeDiff > 0 ? 'text-amber-600' : 'text-green-600'}`}>
{timeDiffMinutes > 0 ? '+' : ''}{timeDiffMinutes} Min
</div>
<div className={`text-sm ${timeDiff > 0 ? 'text-amber-500' : 'text-green-500'}`}>
Differenz
</div>
</div>
</div>
{/* Session Times */}
<div className="flex items-center justify-between text-sm text-slate-500 border-t border-slate-100 pt-4">
<span>Start: {startTime.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}</span>
<span>Ende: {endTime.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}</span>
</div>
</div>
{/* Phase Breakdown */}
<div className="bg-white border border-slate-200 rounded-xl p-6">
<h3 className="font-semibold text-slate-900 mb-4 flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-slate-400" />
Phasen-Analyse
</h3>
<div className="space-y-4">
{session.phases.map((phase) => {
const plannedSeconds = phase.duration * 60
const actualSeconds = phase.actualTime
const diff = actualSeconds - plannedSeconds
const diffMinutes = Math.round(diff / 60)
const percentage = Math.min((actualSeconds / plannedSeconds) * 100, 150)
return (
<div key={phase.phase} className="space-y-2">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: PHASE_COLORS[phase.phase].hex }}
/>
<span className="font-medium text-slate-700">
{PHASE_DISPLAY_NAMES[phase.phase]}
</span>
</div>
<div className="flex items-center gap-3 text-slate-500">
<span>{Math.round(actualSeconds / 60)} / {phase.duration} Min</span>
<span className={`
px-2 py-0.5 rounded text-xs font-medium
${diff > 60 ? 'bg-amber-100 text-amber-700' : diff < -60 ? 'bg-blue-100 text-blue-700' : 'bg-green-100 text-green-700'}
`}>
{diffMinutes > 0 ? '+' : ''}{diffMinutes} Min
</span>
</div>
</div>
{/* Progress Bar */}
<div className="h-3 bg-slate-100 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${Math.min(percentage, 100)}%`,
backgroundColor: percentage > 100
? '#f59e0b' // amber for overtime
: PHASE_COLORS[phase.phase].hex,
}}
/>
</div>
</div>
)
})}
</div>
</div>
</div>
)}
{activeTab === 'homework' && (
<HomeworkSection
homeworkList={session.homeworkList}
onAdd={onAddHomework}
onRemove={onRemoveHomework}
/>
)}
{activeTab === 'reflection' && (
<ReflectionSection
reflection={session.reflection}
onSave={onSaveReflection}
/>
)}
{/* Start New Lesson Button */}
<div className="pt-4">
<button
onClick={onStartNew}
className="w-full py-4 px-6 bg-slate-900 text-white rounded-xl font-semibold hover:bg-slate-800 transition-colors flex items-center justify-center gap-2"
>
<RefreshCw className="w-5 h-5" />
Neue Stunde starten
</button>
</div>
</div>
)
}
@@ -0,0 +1,269 @@
'use client'
import { useState } from 'react'
import { Play, Clock, BookOpen, Users, ChevronDown, Info } from 'lucide-react'
import { LessonTemplate, PhaseDurations, Class } from '@/lib/companion/types'
import {
SYSTEM_TEMPLATES,
DEFAULT_PHASE_DURATIONS,
PHASE_DISPLAY_NAMES,
PHASE_ORDER,
calculateTotalDuration,
formatMinutes,
} from '@/lib/companion/constants'
interface LessonStartFormProps {
onStart: (data: {
classId: string
subject: string
topic?: string
templateId?: string
}) => void
loading?: boolean
availableClasses?: Class[]
}
// Mock classes for development
const MOCK_CLASSES: Class[] = [
{ id: 'c1', name: '9a', grade: '9', studentCount: 28 },
{ id: 'c2', name: '9b', grade: '9', studentCount: 26 },
{ id: 'c3', name: '10a', grade: '10', studentCount: 24 },
{ id: 'c4', name: 'Deutsch LK', grade: 'Q1', studentCount: 18 },
{ id: 'c5', name: 'Mathe GK', grade: 'Q2', studentCount: 22 },
]
const SUBJECTS = [
'Deutsch',
'Mathematik',
'Englisch',
'Biologie',
'Physik',
'Chemie',
'Geschichte',
'Geographie',
'Politik',
'Kunst',
'Musik',
'Sport',
'Informatik',
'Sonstiges',
]
export function LessonStartForm({
onStart,
loading,
availableClasses = MOCK_CLASSES,
}: LessonStartFormProps) {
const [selectedClass, setSelectedClass] = useState('')
const [selectedSubject, setSelectedSubject] = useState('')
const [topic, setTopic] = useState('')
const [selectedTemplate, setSelectedTemplate] = useState<LessonTemplate | null>(
SYSTEM_TEMPLATES[0] as LessonTemplate
)
const [showTemplateDetails, setShowTemplateDetails] = useState(false)
const totalDuration = selectedTemplate
? calculateTotalDuration(selectedTemplate.durations)
: calculateTotalDuration(DEFAULT_PHASE_DURATIONS)
const canStart = selectedClass && selectedSubject
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!canStart) return
onStart({
classId: selectedClass,
subject: selectedSubject,
topic: topic || undefined,
templateId: selectedTemplate?.templateId,
})
}
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="flex items-center gap-3 mb-6">
<div className="p-3 bg-blue-100 rounded-xl">
<Play className="w-6 h-6 text-blue-600" />
</div>
<div>
<h2 className="text-xl font-semibold text-slate-900">Neue Stunde starten</h2>
<p className="text-sm text-slate-500">
Waehlen Sie Klasse, Fach und Template
</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Class Selection */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
<Users className="w-4 h-4 inline mr-2" />
Klasse *
</label>
<select
value={selectedClass}
onChange={(e) => setSelectedClass(e.target.value)}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
required
>
<option value="">Klasse auswaehlen...</option>
{availableClasses.map((cls) => (
<option key={cls.id} value={cls.id}>
{cls.name} ({cls.studentCount} Schueler)
</option>
))}
</select>
</div>
{/* Subject Selection */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
<BookOpen className="w-4 h-4 inline mr-2" />
Fach *
</label>
<select
value={selectedSubject}
onChange={(e) => setSelectedSubject(e.target.value)}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
required
>
<option value="">Fach auswaehlen...</option>
{SUBJECTS.map((subject) => (
<option key={subject} value={subject}>
{subject}
</option>
))}
</select>
</div>
{/* Topic (Optional) */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Thema (optional)
</label>
<input
type="text"
value={topic}
onChange={(e) => setTopic(e.target.value)}
placeholder="z.B. Quadratische Funktionen, Gedichtanalyse..."
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
{/* Template Selection */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
<Clock className="w-4 h-4 inline mr-2" />
Template
</label>
<div className="space-y-2">
{SYSTEM_TEMPLATES.map((template) => {
const tpl = template as LessonTemplate
const isSelected = selectedTemplate?.templateId === tpl.templateId
const total = calculateTotalDuration(tpl.durations)
return (
<button
key={tpl.templateId}
type="button"
onClick={() => setSelectedTemplate(tpl)}
className={`
w-full p-4 rounded-xl border text-left transition-all
${isSelected
? 'border-blue-500 bg-blue-50 ring-2 ring-blue-500/20'
: 'border-slate-200 hover:border-slate-300 hover:bg-slate-50'
}
`}
>
<div className="flex items-center justify-between">
<div>
<p className={`font-medium ${isSelected ? 'text-blue-900' : 'text-slate-900'}`}>
{tpl.name}
</p>
<p className="text-sm text-slate-500">{tpl.description}</p>
</div>
<span className={`text-sm font-medium ${isSelected ? 'text-blue-600' : 'text-slate-500'}`}>
{formatMinutes(total)}
</span>
</div>
</button>
)
})}
</div>
{/* Template Details Toggle */}
{selectedTemplate && (
<button
type="button"
onClick={() => setShowTemplateDetails(!showTemplateDetails)}
className="mt-3 flex items-center gap-2 text-sm text-slate-600 hover:text-slate-900"
>
<Info className="w-4 h-4" />
Phasendauern anzeigen
<ChevronDown className={`w-4 h-4 transition-transform ${showTemplateDetails ? 'rotate-180' : ''}`} />
</button>
)}
{/* Template Details */}
{showTemplateDetails && selectedTemplate && (
<div className="mt-3 p-4 bg-slate-50 rounded-xl">
<div className="grid grid-cols-5 gap-2">
{PHASE_ORDER.map((phaseId) => (
<div key={phaseId} className="text-center">
<p className="text-xs text-slate-500">{PHASE_DISPLAY_NAMES[phaseId]}</p>
<p className="font-medium text-slate-900">
{selectedTemplate.durations[phaseId]} Min
</p>
</div>
))}
</div>
</div>
)}
</div>
{/* Summary & Start Button */}
<div className="pt-4 border-t border-slate-200">
<div className="flex items-center justify-between mb-4">
<div className="text-sm text-slate-600">
Gesamtdauer: <span className="font-semibold">{formatMinutes(totalDuration)}</span>
</div>
{selectedClass && (
<div className="text-sm text-slate-600">
Klasse: <span className="font-semibold">
{availableClasses.find((c) => c.id === selectedClass)?.name}
</span>
</div>
)}
</div>
<button
type="submit"
disabled={!canStart || loading}
className={`
w-full py-4 px-6 rounded-xl font-semibold text-lg
flex items-center justify-center gap-3
transition-all duration-200
${canStart && !loading
? 'bg-blue-600 hover:bg-blue-700 text-white shadow-lg shadow-blue-500/25'
: 'bg-slate-200 text-slate-500 cursor-not-allowed'
}
`}
>
{loading ? (
<>
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Stunde wird gestartet...
</>
) : (
<>
<Play className="w-5 h-5" />
Stunde starten
</>
)}
</button>
</div>
</form>
</div>
)
}
@@ -0,0 +1,128 @@
'use client'
import { Check } from 'lucide-react'
import { formatMinutes } from '@/lib/companion/constants'
interface PhaseTimelinePhase {
id: string
shortName: string
displayName: string
duration: number
status: string
actualTime?: number
color: string
}
interface PhaseTimelineDetailedProps {
phases: PhaseTimelinePhase[]
currentPhaseIndex: number
onPhaseClick?: (index: number) => void
}
export function PhaseTimelineDetailed({
phases,
currentPhaseIndex,
onPhaseClick,
}: PhaseTimelineDetailedProps) {
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<h3 className="text-sm font-medium text-slate-500 mb-4">Unterrichtsphasen</h3>
<div className="flex items-start justify-between">
{phases.map((phase, index) => {
const isActive = index === currentPhaseIndex
const isCompleted = phase.status === 'completed'
const isPast = index < currentPhaseIndex
return (
<div key={phase.id} className="flex flex-col items-center flex-1">
{/* Top connector line */}
<div className="w-full flex items-center mb-2">
{index > 0 && (
<div
className="flex-1 h-1"
style={{
background: isPast || isCompleted
? phases[index - 1].color
: '#e2e8f0',
}}
/>
)}
{index === 0 && <div className="flex-1" />}
{/* Phase Circle */}
<button
onClick={() => onPhaseClick?.(index)}
disabled={!onPhaseClick}
className={`
relative w-12 h-12 rounded-full
flex items-center justify-center
font-bold text-lg
transition-all duration-300
${onPhaseClick ? 'cursor-pointer hover:scale-110' : 'cursor-default'}
${isActive ? 'ring-4 ring-offset-2 shadow-lg' : ''}
`}
style={{
backgroundColor: isActive || isCompleted || isPast ? phase.color : '#e2e8f0',
color: isActive || isCompleted || isPast ? 'white' : '#64748b',
'--tw-ring-color': isActive ? `${phase.color}40` : undefined,
} as React.CSSProperties}
>
{isCompleted ? (
<Check className="w-6 h-6" />
) : (
phase.shortName
)}
{isActive && (
<span
className="absolute inset-0 rounded-full animate-ping opacity-20"
style={{ backgroundColor: phase.color }}
/>
)}
</button>
{index < phases.length - 1 && (
<div
className="flex-1 h-1"
style={{
background: isCompleted ? phase.color : '#e2e8f0',
}}
/>
)}
{index === phases.length - 1 && <div className="flex-1" />}
</div>
{/* Phase Label */}
<span
className={`
text-sm font-medium mt-2
${isActive ? 'text-slate-900' : 'text-slate-500'}
`}
>
{phase.displayName}
</span>
{/* Duration */}
<span
className={`
text-xs mt-1
${isActive ? 'text-slate-700' : 'text-slate-400'}
`}
>
{formatMinutes(phase.duration)}
</span>
{/* Actual time if completed */}
{phase.actualTime !== undefined && phase.actualTime > 0 && (
<span className="text-xs text-slate-400 mt-0.5">
(tatsaechlich: {Math.round(phase.actualTime / 60)} Min)
</span>
)}
</div>
)
})}
</div>
</div>
)
}
@@ -0,0 +1,194 @@
'use client'
import { Plus, Pause, Play, SkipForward, Square, Clock } from 'lucide-react'
interface QuickActionsBarProps {
onExtend: (minutes: number) => void
onPause: () => void
onResume: () => void
onSkip: () => void
onEnd: () => void
isPaused: boolean
isLastPhase: boolean
disabled?: boolean
}
export function QuickActionsBar({
onExtend,
onPause,
onResume,
onSkip,
onEnd,
isPaused,
isLastPhase,
disabled,
}: QuickActionsBarProps) {
return (
<div
className="flex items-center justify-center gap-3 p-4 bg-white border border-slate-200 rounded-xl"
role="toolbar"
aria-label="Steuerung"
>
{/* Extend +5 Min */}
<button
onClick={() => onExtend(5)}
disabled={disabled || isPaused}
className={`
flex items-center gap-2 px-4 py-3 rounded-xl
font-medium transition-all duration-200
min-w-[52px] justify-center
${disabled || isPaused
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
: 'bg-blue-50 text-blue-700 hover:bg-blue-100 active:scale-95'
}
`}
title="+5 Minuten (E)"
aria-keyshortcuts="e"
aria-label="5 Minuten verlaengern"
>
<Plus className="w-5 h-5" />
<span>5 Min</span>
</button>
{/* Pause / Resume */}
<button
onClick={isPaused ? onResume : onPause}
disabled={disabled}
className={`
flex items-center gap-2 px-6 py-3 rounded-xl
font-semibold transition-all duration-200
min-w-[52px] min-h-[52px] justify-center
${disabled
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
: isPaused
? 'bg-green-600 text-white hover:bg-green-700 shadow-lg shadow-green-500/25 active:scale-95'
: 'bg-amber-500 text-white hover:bg-amber-600 shadow-lg shadow-amber-500/25 active:scale-95'
}
`}
title={isPaused ? 'Fortsetzen (Leertaste)' : 'Pausieren (Leertaste)'}
aria-keyshortcuts="Space"
aria-label={isPaused ? 'Stunde fortsetzen' : 'Stunde pausieren'}
>
{isPaused ? (
<>
<Play className="w-5 h-5" />
<span>Fortsetzen</span>
</>
) : (
<>
<Pause className="w-5 h-5" />
<span>Pause</span>
</>
)}
</button>
{/* Skip Phase / End Lesson */}
{isLastPhase ? (
<button
onClick={onEnd}
disabled={disabled}
className={`
flex items-center gap-2 px-4 py-3 rounded-xl
font-medium transition-all duration-200
min-w-[52px] justify-center
${disabled
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
: 'bg-red-50 text-red-700 hover:bg-red-100 active:scale-95'
}
`}
title="Stunde beenden"
aria-label="Stunde beenden"
>
<Square className="w-5 h-5" />
<span>Beenden</span>
</button>
) : (
<button
onClick={onSkip}
disabled={disabled || isPaused}
className={`
flex items-center gap-2 px-4 py-3 rounded-xl
font-medium transition-all duration-200
min-w-[52px] justify-center
${disabled || isPaused
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200 active:scale-95'
}
`}
title="Naechste Phase (N)"
aria-keyshortcuts="n"
aria-label="Zur naechsten Phase springen"
>
<SkipForward className="w-5 h-5" />
<span>Weiter</span>
</button>
)}
</div>
)
}
/**
* Compact version for mobile or sidebar
*/
export function QuickActionsCompact({
onExtend,
onPause,
onResume,
onSkip,
isPaused,
isLastPhase,
disabled,
}: Omit<QuickActionsBarProps, 'onEnd'>) {
return (
<div className="flex items-center gap-2">
<button
onClick={() => onExtend(5)}
disabled={disabled || isPaused}
className={`
p-2 rounded-lg transition-all
${disabled || isPaused
? 'text-slate-300'
: 'text-blue-600 hover:bg-blue-50'
}
`}
title="+5 Min"
>
<Clock className="w-5 h-5" />
</button>
<button
onClick={isPaused ? onResume : onPause}
disabled={disabled}
className={`
p-2 rounded-lg transition-all
${disabled
? 'text-slate-300'
: isPaused
? 'text-green-600 hover:bg-green-50'
: 'text-amber-600 hover:bg-amber-50'
}
`}
title={isPaused ? 'Fortsetzen' : 'Pausieren'}
>
{isPaused ? <Play className="w-5 h-5" /> : <Pause className="w-5 h-5" />}
</button>
{!isLastPhase && (
<button
onClick={onSkip}
disabled={disabled || isPaused}
className={`
p-2 rounded-lg transition-all
${disabled || isPaused
? 'text-slate-300'
: 'text-slate-600 hover:bg-slate-50'
}
`}
title="Naechste Phase"
>
<SkipForward className="w-5 h-5" />
</button>
)}
</div>
)
}
@@ -0,0 +1,146 @@
'use client'
import { useState, useEffect } from 'react'
import { Star, Save, CheckCircle } from 'lucide-react'
import { LessonReflection } from '@/lib/companion/types'
interface ReflectionSectionProps {
reflection?: LessonReflection
onSave: (rating: number, notes: string, nextSteps: string) => void
}
export function ReflectionSection({ reflection, onSave }: ReflectionSectionProps) {
const [rating, setRating] = useState(reflection?.rating || 0)
const [notes, setNotes] = useState(reflection?.notes || '')
const [nextSteps, setNextSteps] = useState(reflection?.nextSteps || '')
const [hoverRating, setHoverRating] = useState(0)
const [saved, setSaved] = useState(false)
useEffect(() => {
if (reflection) {
setRating(reflection.rating)
setNotes(reflection.notes)
setNextSteps(reflection.nextSteps)
}
}, [reflection])
const handleSave = () => {
if (rating === 0) return
onSave(rating, notes, nextSteps)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
const ratingLabels = [
'', // 0
'Verbesserungsbedarf',
'Okay',
'Gut',
'Sehr gut',
'Ausgezeichnet',
]
return (
<div className="bg-white border border-slate-200 rounded-xl p-6 space-y-6">
{/* Star Rating */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-3">
Wie lief die Stunde?
</label>
<div className="flex items-center gap-2">
{[1, 2, 3, 4, 5].map((star) => {
const isFilled = star <= (hoverRating || rating)
return (
<button
key={star}
type="button"
onClick={() => setRating(star)}
onMouseEnter={() => setHoverRating(star)}
onMouseLeave={() => setHoverRating(0)}
className="p-1 transition-transform hover:scale-110"
aria-label={`${star} Stern${star > 1 ? 'e' : ''}`}
>
<Star
className={`w-8 h-8 ${
isFilled
? 'fill-amber-400 text-amber-400'
: 'text-slate-300'
}`}
/>
</button>
)
})}
{(hoverRating || rating) > 0 && (
<span className="ml-3 text-sm text-slate-600">
{ratingLabels[hoverRating || rating]}
</span>
)}
</div>
</div>
{/* Notes */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Notizen zur Stunde
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Was lief gut? Was koennte besser laufen? Besondere Vorkommnisse..."
rows={4}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none"
/>
</div>
{/* Next Steps */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Naechste Schritte
</label>
<textarea
value={nextSteps}
onChange={(e) => setNextSteps(e.target.value)}
placeholder="Was muss fuer die naechste Stunde vorbereitet werden? Follow-ups..."
rows={3}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none"
/>
</div>
{/* Save Button */}
<button
onClick={handleSave}
disabled={rating === 0}
className={`
w-full py-3 px-6 rounded-xl font-semibold
flex items-center justify-center gap-2
transition-all duration-200
${saved
? 'bg-green-600 text-white'
: rating === 0
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700'
}
`}
>
{saved ? (
<>
<CheckCircle className="w-5 h-5" />
Gespeichert!
</>
) : (
<>
<Save className="w-5 h-5" />
Reflexion speichern
</>
)}
</button>
{/* Previous Reflection Info */}
{reflection?.savedAt && (
<p className="text-center text-sm text-slate-400">
Zuletzt gespeichert: {new Date(reflection.savedAt).toLocaleString('de-DE')}
</p>
)}
</div>
)
}
@@ -0,0 +1,220 @@
'use client'
import { Pause, Play } from 'lucide-react'
import { TimerColorStatus } from '@/lib/companion/types'
import {
PIE_TIMER_RADIUS,
PIE_TIMER_CIRCUMFERENCE,
PIE_TIMER_STROKE_WIDTH,
PIE_TIMER_SIZE,
TIMER_COLOR_CLASSES,
TIMER_BG_COLORS,
formatTime,
} from '@/lib/companion/constants'
interface VisualPieTimerProps {
progress: number // 0-1 (how much time has elapsed)
remainingSeconds: number
totalSeconds: number
colorStatus: TimerColorStatus
isPaused: boolean
currentPhaseName: string
phaseColor: string
onTogglePause?: () => void
size?: 'sm' | 'md' | 'lg'
}
const sizeConfig = {
sm: { outer: 120, viewBox: 100, radius: 38, stroke: 6, fontSize: 'text-lg' },
md: { outer: 180, viewBox: 100, radius: 40, stroke: 7, fontSize: 'text-2xl' },
lg: { outer: 240, viewBox: 100, radius: 42, stroke: 8, fontSize: 'text-4xl' },
}
export function VisualPieTimer({
progress,
remainingSeconds,
totalSeconds,
colorStatus,
isPaused,
currentPhaseName,
phaseColor,
onTogglePause,
size = 'lg',
}: VisualPieTimerProps) {
const config = sizeConfig[size]
const circumference = 2 * Math.PI * config.radius
// Calculate stroke-dashoffset for progress
// Progress goes from 0 (full) to 1 (empty), so offset decreases as time passes
const strokeDashoffset = circumference * (1 - progress)
// For overtime, show a pulsing full circle
const isOvertime = colorStatus === 'overtime'
const displayTime = formatTime(remainingSeconds)
// Get color classes based on status
const colorClasses = TIMER_COLOR_CLASSES[colorStatus]
const bgColorClass = TIMER_BG_COLORS[colorStatus]
return (
<div className="flex flex-col items-center">
{/* Timer Circle */}
<div
className={`relative ${bgColorClass} rounded-full p-4 transition-colors duration-300`}
style={{ width: config.outer, height: config.outer }}
>
<svg
width="100%"
height="100%"
viewBox={`0 0 ${config.viewBox} ${config.viewBox}`}
className="transform -rotate-90"
>
{/* Background circle */}
<circle
cx={config.viewBox / 2}
cy={config.viewBox / 2}
r={config.radius}
fill="none"
stroke="currentColor"
strokeWidth={config.stroke}
className="text-slate-200"
/>
{/* Progress circle */}
<circle
cx={config.viewBox / 2}
cy={config.viewBox / 2}
r={config.radius}
fill="none"
stroke={isOvertime ? '#dc2626' : phaseColor}
strokeWidth={config.stroke}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={isOvertime ? 0 : strokeDashoffset}
className={`transition-all duration-100 ${isOvertime ? 'animate-pulse' : ''}`}
/>
</svg>
{/* Center Content */}
<div className="absolute inset-0 flex flex-col items-center justify-center">
{/* Time Display */}
<span
className={`
font-mono font-bold ${config.fontSize}
${isOvertime ? 'text-red-600 animate-pulse' : colorStatus === 'critical' ? 'text-red-500' : colorStatus === 'warning' ? 'text-amber-500' : 'text-slate-900'}
`}
>
{displayTime}
</span>
{/* Phase Name */}
<span className="text-sm text-slate-500 mt-1">
{currentPhaseName}
</span>
{/* Paused Indicator */}
{isPaused && (
<span className="text-xs text-amber-600 font-medium mt-1 flex items-center gap-1">
<Pause className="w-3 h-3" />
Pausiert
</span>
)}
{/* Overtime Badge */}
{isOvertime && (
<span className="absolute -bottom-2 px-2 py-0.5 bg-red-600 text-white text-xs font-bold rounded-full">
+{Math.abs(Math.floor(remainingSeconds / 60))} Min
</span>
)}
</div>
{/* Pause/Play Button (overlay) */}
{onTogglePause && (
<button
onClick={onTogglePause}
className={`
absolute inset-0 rounded-full
flex items-center justify-center
opacity-0 hover:opacity-100
bg-black/20 backdrop-blur-sm
transition-opacity duration-200
`}
aria-label={isPaused ? 'Fortsetzen' : 'Pausieren'}
>
{isPaused ? (
<Play className="w-12 h-12 text-white" />
) : (
<Pause className="w-12 h-12 text-white" />
)}
</button>
)}
</div>
{/* Status Text */}
<div className="mt-4 text-center">
{isOvertime ? (
<p className="text-red-600 font-semibold animate-pulse">
Ueberzogen - Zeit fuer die naechste Phase!
</p>
) : colorStatus === 'critical' ? (
<p className="text-red-500 font-medium">
Weniger als 2 Minuten verbleibend
</p>
) : colorStatus === 'warning' ? (
<p className="text-amber-500">
Weniger als 5 Minuten verbleibend
</p>
) : null}
</div>
</div>
)
}
/**
* Compact timer for header/toolbar
*/
export function CompactTimer({
remainingSeconds,
colorStatus,
isPaused,
phaseName,
phaseColor,
}: {
remainingSeconds: number
colorStatus: TimerColorStatus
isPaused: boolean
phaseName: string
phaseColor: string
}) {
const isOvertime = colorStatus === 'overtime'
return (
<div className="flex items-center gap-3 px-4 py-2 bg-white border border-slate-200 rounded-xl">
{/* Phase indicator */}
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: phaseColor }}
/>
{/* Phase name */}
<span className="text-sm font-medium text-slate-600">{phaseName}</span>
{/* Time */}
<span
className={`
font-mono font-bold
${isOvertime ? 'text-red-600 animate-pulse' : colorStatus === 'critical' ? 'text-red-500' : colorStatus === 'warning' ? 'text-amber-500' : 'text-slate-900'}
`}
>
{formatTime(remainingSeconds)}
</span>
{/* Paused badge */}
{isPaused && (
<span className="px-2 py-0.5 bg-amber-100 text-amber-700 text-xs font-medium rounded">
Pausiert
</span>
)}
</div>
)
}
@@ -0,0 +1,201 @@
'use client'
import { useState } from 'react'
import { X, MessageSquare, Bug, Lightbulb, Send, CheckCircle } from 'lucide-react'
import { FeedbackType } from '@/lib/companion/types'
interface FeedbackModalProps {
isOpen: boolean
onClose: () => void
onSubmit: (type: FeedbackType, title: string, description: string) => Promise<void>
}
const feedbackTypes: { id: FeedbackType; label: string; icon: typeof Bug; color: string }[] = [
{ id: 'bug', label: 'Bug melden', icon: Bug, color: 'text-red-600 bg-red-50' },
{ id: 'feature', label: 'Feature-Wunsch', icon: Lightbulb, color: 'text-amber-600 bg-amber-50' },
{ id: 'feedback', label: 'Allgemeines Feedback', icon: MessageSquare, color: 'text-blue-600 bg-blue-50' },
]
export function FeedbackModal({ isOpen, onClose, onSubmit }: FeedbackModalProps) {
const [type, setType] = useState<FeedbackType>('feedback')
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
if (!isOpen) return null
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!title.trim() || !description.trim()) return
setIsSubmitting(true)
try {
await onSubmit(type, title.trim(), description.trim())
setIsSuccess(true)
setTimeout(() => {
setIsSuccess(false)
setTitle('')
setDescription('')
setType('feedback')
onClose()
}, 2000)
} catch (error) {
console.error('Failed to submit feedback:', error)
} finally {
setIsSubmitting(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-slate-200">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 rounded-xl">
<MessageSquare className="w-5 h-5 text-blue-600" />
</div>
<h2 className="text-xl font-semibold text-slate-900">Feedback senden</h2>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-slate-500" />
</button>
</div>
{/* Success State */}
{isSuccess ? (
<div className="p-12 text-center">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<CheckCircle className="w-8 h-8 text-green-600" />
</div>
<h3 className="text-xl font-semibold text-slate-900 mb-2">Vielen Dank!</h3>
<p className="text-slate-600">Ihr Feedback wurde erfolgreich gesendet.</p>
</div>
) : (
<form onSubmit={handleSubmit}>
{/* Content */}
<div className="p-6 space-y-6">
{/* Feedback Type */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-3">
Art des Feedbacks
</label>
<div className="grid grid-cols-3 gap-3">
{feedbackTypes.map((ft) => (
<button
key={ft.id}
type="button"
onClick={() => setType(ft.id)}
className={`
p-4 rounded-xl border-2 text-center transition-all
${type === ft.id
? 'border-blue-500 bg-blue-50'
: 'border-slate-200 hover:border-slate-300'
}
`}
>
<div className={`w-10 h-10 rounded-lg ${ft.color} flex items-center justify-center mx-auto mb-2`}>
<ft.icon className="w-5 h-5" />
</div>
<span className={`text-sm font-medium ${type === ft.id ? 'text-blue-700' : 'text-slate-700'}`}>
{ft.label}
</span>
</button>
))}
</div>
</div>
{/* Title */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Titel *
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={
type === 'bug'
? 'z.B. Timer stoppt nach Pause nicht mehr'
: type === 'feature'
? 'z.B. Materialien an Stunde anhaengen'
: 'z.B. Super nuetzliches Tool!'
}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required
/>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Beschreibung *
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={
type === 'bug'
? 'Bitte beschreiben Sie den Fehler moeglichst genau. Was haben Sie gemacht? Was ist passiert? Was haetten Sie erwartet?'
: type === 'feature'
? 'Beschreiben Sie die gewuenschte Funktion. Warum waere sie hilfreich?'
: 'Teilen Sie uns Ihre Gedanken mit...'
}
rows={5}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none"
required
/>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-6 border-t border-slate-200 bg-slate-50">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg transition-colors"
>
Abbrechen
</button>
<button
type="submit"
disabled={!title.trim() || !description.trim() || isSubmitting}
className={`
flex items-center gap-2 px-6 py-2 rounded-lg font-medium
transition-all duration-200
${!title.trim() || !description.trim() || isSubmitting
? 'bg-slate-200 text-slate-500 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700'
}
`}
>
{isSubmitting ? (
<>
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Senden...
</>
) : (
<>
<Send className="w-4 h-4" />
Absenden
</>
)}
</button>
</div>
</form>
)}
</div>
</div>
)
}
@@ -0,0 +1,280 @@
'use client'
import { useState } from 'react'
import { ChevronRight, ChevronLeft, Check, GraduationCap, Settings, Timer } from 'lucide-react'
interface OnboardingModalProps {
isOpen: boolean
onClose: () => void
onComplete: (data: { state?: string; schoolType?: string }) => void
}
const STATES = [
'Baden-Wuerttemberg',
'Bayern',
'Berlin',
'Brandenburg',
'Bremen',
'Hamburg',
'Hessen',
'Mecklenburg-Vorpommern',
'Niedersachsen',
'Nordrhein-Westfalen',
'Rheinland-Pfalz',
'Saarland',
'Sachsen',
'Sachsen-Anhalt',
'Schleswig-Holstein',
'Thueringen',
]
const SCHOOL_TYPES = [
'Grundschule',
'Hauptschule',
'Realschule',
'Gymnasium',
'Gesamtschule',
'Berufsschule',
'Foerderschule',
'Andere',
]
interface Step {
id: number
title: string
description: string
icon: typeof GraduationCap
}
const steps: Step[] = [
{
id: 1,
title: 'Willkommen',
description: 'Der Companion hilft Ihnen bei der Unterrichtsplanung und -durchfuehrung.',
icon: GraduationCap,
},
{
id: 2,
title: 'Ihre Schule',
description: 'Waehlen Sie Ihr Bundesland und Ihre Schulform.',
icon: Settings,
},
{
id: 3,
title: 'Bereit!',
description: 'Sie koennen jetzt mit dem Lesson-Modus starten.',
icon: Timer,
},
]
export function OnboardingModal({ isOpen, onClose, onComplete }: OnboardingModalProps) {
const [currentStep, setCurrentStep] = useState(1)
const [selectedState, setSelectedState] = useState('')
const [selectedSchoolType, setSelectedSchoolType] = useState('')
if (!isOpen) return null
const canProceed = () => {
if (currentStep === 2) {
return selectedState !== '' && selectedSchoolType !== ''
}
return true
}
const handleNext = () => {
if (currentStep < 3) {
setCurrentStep(currentStep + 1)
} else {
onComplete({
state: selectedState,
schoolType: selectedSchoolType,
})
}
}
const handleBack = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1)
}
}
const currentStepData = steps[currentStep - 1]
const Icon = currentStepData.icon
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
{/* Modal */}
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 overflow-hidden">
{/* Progress Bar */}
<div className="h-1 bg-slate-100">
<div
className="h-full bg-blue-600 transition-all duration-300"
style={{ width: `${(currentStep / 3) * 100}%` }}
/>
</div>
{/* Content */}
<div className="p-8">
{/* Step Indicator */}
<div className="flex items-center justify-center gap-2 mb-8">
{steps.map((step) => (
<div
key={step.id}
className={`
w-3 h-3 rounded-full transition-all
${step.id === currentStep
? 'bg-blue-600 scale-125'
: step.id < currentStep
? 'bg-blue-600'
: 'bg-slate-200'
}
`}
/>
))}
</div>
{/* Icon */}
<div className="w-16 h-16 bg-blue-100 rounded-2xl flex items-center justify-center mx-auto mb-6">
<Icon className="w-8 h-8 text-blue-600" />
</div>
{/* Title & Description */}
<h2 className="text-2xl font-bold text-slate-900 text-center mb-2">
{currentStepData.title}
</h2>
<p className="text-slate-600 text-center mb-8">
{currentStepData.description}
</p>
{/* Step Content */}
{currentStep === 1 && (
<div className="space-y-4 text-center">
<div className="grid grid-cols-3 gap-4">
<div className="p-4 bg-blue-50 rounded-xl">
<div className="text-2xl mb-1">5</div>
<div className="text-xs text-slate-600">Phasen</div>
</div>
<div className="p-4 bg-green-50 rounded-xl">
<div className="text-2xl mb-1">45</div>
<div className="text-xs text-slate-600">Minuten</div>
</div>
<div className="p-4 bg-purple-50 rounded-xl">
<div className="text-2xl mb-1"></div>
<div className="text-xs text-slate-600">Flexibel</div>
</div>
</div>
<p className="text-sm text-slate-500 mt-4">
Einstieg Erarbeitung Sicherung Transfer Reflexion
</p>
</div>
)}
{currentStep === 2 && (
<div className="space-y-4">
{/* State Selection */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Bundesland
</label>
<select
value={selectedState}
onChange={(e) => setSelectedState(e.target.value)}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
>
<option value="">Bitte waehlen...</option>
{STATES.map((state) => (
<option key={state} value={state}>
{state}
</option>
))}
</select>
</div>
{/* School Type Selection */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Schulform
</label>
<select
value={selectedSchoolType}
onChange={(e) => setSelectedSchoolType(e.target.value)}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
>
<option value="">Bitte waehlen...</option>
{SCHOOL_TYPES.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
</div>
</div>
)}
{currentStep === 3 && (
<div className="text-center space-y-4">
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto">
<Check className="w-10 h-10 text-green-600" />
</div>
<div className="p-4 bg-slate-50 rounded-xl">
<p className="text-sm text-slate-600">
<strong>Bundesland:</strong> {selectedState || 'Nicht angegeben'}
</p>
<p className="text-sm text-slate-600">
<strong>Schulform:</strong> {selectedSchoolType || 'Nicht angegeben'}
</p>
</div>
<p className="text-sm text-slate-500">
Sie koennen diese Einstellungen jederzeit aendern.
</p>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between p-6 border-t border-slate-200 bg-slate-50">
<button
onClick={currentStep === 1 ? onClose : handleBack}
className="flex items-center gap-2 px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg transition-colors"
>
{currentStep === 1 ? (
'Ueberspringen'
) : (
<>
<ChevronLeft className="w-4 h-4" />
Zurueck
</>
)}
</button>
<button
onClick={handleNext}
disabled={!canProceed()}
className={`
flex items-center gap-2 px-6 py-2 rounded-lg font-medium
transition-all duration-200
${canProceed()
? 'bg-blue-600 text-white hover:bg-blue-700'
: 'bg-slate-200 text-slate-500 cursor-not-allowed'
}
`}
>
{currentStep === 3 ? (
<>
<Check className="w-4 h-4" />
Fertig
</>
) : (
<>
Weiter
<ChevronRight className="w-4 h-4" />
</>
)}
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,248 @@
'use client'
import { useState, useEffect } from 'react'
import { X, Settings, Save, RotateCcw } from 'lucide-react'
import { TeacherSettings, PhaseDurations } from '@/lib/companion/types'
import {
DEFAULT_TEACHER_SETTINGS,
DEFAULT_PHASE_DURATIONS,
PHASE_ORDER,
PHASE_DISPLAY_NAMES,
PHASE_COLORS,
calculateTotalDuration,
} from '@/lib/companion/constants'
interface SettingsModalProps {
isOpen: boolean
onClose: () => void
settings: TeacherSettings
onSave: (settings: TeacherSettings) => void
}
export function SettingsModal({
isOpen,
onClose,
settings,
onSave,
}: SettingsModalProps) {
const [localSettings, setLocalSettings] = useState<TeacherSettings>(settings)
const [durations, setDurations] = useState<PhaseDurations>(settings.defaultPhaseDurations)
useEffect(() => {
setLocalSettings(settings)
setDurations(settings.defaultPhaseDurations)
}, [settings])
if (!isOpen) return null
const totalDuration = calculateTotalDuration(durations)
const handleDurationChange = (phase: keyof PhaseDurations, value: number) => {
const newDurations = { ...durations, [phase]: Math.max(1, Math.min(60, value)) }
setDurations(newDurations)
}
const handleReset = () => {
setDurations(DEFAULT_PHASE_DURATIONS)
setLocalSettings(DEFAULT_TEACHER_SETTINGS)
}
const handleSave = () => {
const newSettings: TeacherSettings = {
...localSettings,
defaultPhaseDurations: durations,
}
onSave(newSettings)
onClose()
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-slate-200">
<div className="flex items-center gap-3">
<div className="p-2 bg-slate-100 rounded-xl">
<Settings className="w-5 h-5 text-slate-600" />
</div>
<h2 className="text-xl font-semibold text-slate-900">Einstellungen</h2>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-slate-500" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-6 overflow-y-auto max-h-[60vh]">
{/* Phase Durations */}
<div>
<h3 className="text-sm font-medium text-slate-700 mb-4">
Standard-Phasendauern (Minuten)
</h3>
<div className="space-y-4">
{PHASE_ORDER.map((phase) => (
<div key={phase} className="flex items-center gap-4">
<div className="flex items-center gap-2 w-32">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: PHASE_COLORS[phase].hex }}
/>
<span className="text-sm text-slate-700">
{PHASE_DISPLAY_NAMES[phase]}
</span>
</div>
<input
type="number"
min={1}
max={60}
value={durations[phase]}
onChange={(e) => handleDurationChange(phase, parseInt(e.target.value) || 1)}
className="w-20 px-3 py-2 border border-slate-200 rounded-lg text-center focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<input
type="range"
min={1}
max={45}
value={durations[phase]}
onChange={(e) => handleDurationChange(phase, parseInt(e.target.value))}
className="flex-1"
style={{
accentColor: PHASE_COLORS[phase].hex,
}}
/>
</div>
))}
</div>
<div className="mt-4 p-3 bg-slate-50 rounded-xl flex items-center justify-between">
<span className="text-sm text-slate-600">Gesamtdauer:</span>
<span className="font-semibold text-slate-900">{totalDuration} Minuten</span>
</div>
</div>
{/* Other Settings */}
<div className="space-y-4 pt-4 border-t border-slate-200">
<h3 className="text-sm font-medium text-slate-700 mb-4">
Weitere Einstellungen
</h3>
{/* Auto Advance */}
<label className="flex items-center justify-between p-3 bg-slate-50 rounded-xl cursor-pointer">
<div>
<span className="text-sm font-medium text-slate-700">
Automatischer Phasenwechsel
</span>
<p className="text-xs text-slate-500">
Phasen automatisch wechseln wenn Zeit abgelaufen
</p>
</div>
<input
type="checkbox"
checked={localSettings.autoAdvancePhases}
onChange={(e) =>
setLocalSettings({ ...localSettings, autoAdvancePhases: e.target.checked })
}
className="w-5 h-5 text-blue-600 rounded focus:ring-blue-500"
/>
</label>
{/* Sound Notifications */}
<label className="flex items-center justify-between p-3 bg-slate-50 rounded-xl cursor-pointer">
<div>
<span className="text-sm font-medium text-slate-700">
Ton-Benachrichtigungen
</span>
<p className="text-xs text-slate-500">
Signalton bei Phasenende und Warnungen
</p>
</div>
<input
type="checkbox"
checked={localSettings.soundNotifications}
onChange={(e) =>
setLocalSettings({ ...localSettings, soundNotifications: e.target.checked })
}
className="w-5 h-5 text-blue-600 rounded focus:ring-blue-500"
/>
</label>
{/* Keyboard Shortcuts */}
<label className="flex items-center justify-between p-3 bg-slate-50 rounded-xl cursor-pointer">
<div>
<span className="text-sm font-medium text-slate-700">
Tastaturkuerzel anzeigen
</span>
<p className="text-xs text-slate-500">
Hinweise zu Tastaturkuerzeln einblenden
</p>
</div>
<input
type="checkbox"
checked={localSettings.showKeyboardShortcuts}
onChange={(e) =>
setLocalSettings({ ...localSettings, showKeyboardShortcuts: e.target.checked })
}
className="w-5 h-5 text-blue-600 rounded focus:ring-blue-500"
/>
</label>
{/* High Contrast */}
<label className="flex items-center justify-between p-3 bg-slate-50 rounded-xl cursor-pointer">
<div>
<span className="text-sm font-medium text-slate-700">
Hoher Kontrast
</span>
<p className="text-xs text-slate-500">
Bessere Sichtbarkeit durch erhoehten Kontrast
</p>
</div>
<input
type="checkbox"
checked={localSettings.highContrastMode}
onChange={(e) =>
setLocalSettings({ ...localSettings, highContrastMode: e.target.checked })
}
className="w-5 h-5 text-blue-600 rounded focus:ring-blue-500"
/>
</label>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between p-6 border-t border-slate-200 bg-slate-50">
<button
onClick={handleReset}
className="flex items-center gap-2 px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg transition-colors"
>
<RotateCcw className="w-4 h-4" />
Zuruecksetzen
</button>
<div className="flex items-center gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg transition-colors"
>
Abbrechen
</button>
<button
onClick={handleSave}
className="flex items-center gap-2 px-6 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
>
<Save className="w-4 h-4" />
Speichern
</button>
</div>
</div>
</div>
</div>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -0,0 +1,7 @@
/**
* GeoEdu Components
* Exports all geo-lernwelt components
*/
export { default as AOISelector } from './AOISelector'
export { default as UnityViewer } from './UnityViewer'
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
+6
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'
@@ -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>
)
}
@@ -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>
)
}
+13
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'
+244
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>
)
}
@@ -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>
)
}
@@ -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>
)
}
+6
View File
@@ -0,0 +1,6 @@
/**
* Voice Components
*/
export { VoiceCapture } from './VoiceCapture'
export { VoiceIndicator } from './VoiceIndicator'
export { VoiceCommandBar } from './VoiceCommandBar'
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -0,0 +1,330 @@
'use client'
import { useRef } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { useLanguage } from '@/lib/LanguageContext'
import { useWorksheet } from '@/lib/worksheet-editor/WorksheetContext'
import type { EditorTool } from '@/app/worksheet-editor/types'
interface EditorToolbarProps {
onOpenAIGenerator: () => void
onOpenDocumentImporter: () => void
onOpenCleanupPanel?: () => void
onOpenOCRImport?: () => void
className?: string
}
interface ToolButtonProps {
tool: EditorTool
icon: React.ReactNode
label: string
isActive: boolean
onClick: () => void
isDark: boolean
}
function ToolButton({ tool, icon, label, isActive, onClick, isDark }: ToolButtonProps) {
return (
<button
onClick={onClick}
title={label}
className={`w-12 h-12 flex items-center justify-center rounded-xl transition-all ${
isActive
? isDark
? 'bg-purple-500/30 text-purple-300 shadow-lg'
: 'bg-purple-100 text-purple-700 shadow-lg'
: isDark
? 'text-white/70 hover:bg-white/10 hover:text-white'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
}`}
>
{icon}
</button>
)
}
export function EditorToolbar({ onOpenAIGenerator, onOpenDocumentImporter, onOpenCleanupPanel, onOpenOCRImport, className = '' }: EditorToolbarProps) {
const { isDark } = useTheme()
const { t } = useLanguage()
const fileInputRef = useRef<HTMLInputElement>(null)
const {
activeTool,
setActiveTool,
canvas,
canUndo,
canRedo,
undo,
redo,
} = useWorksheet()
const handleToolClick = (tool: EditorTool) => {
setActiveTool(tool)
}
const handleImageUpload = () => {
fileInputRef.current?.click()
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file || !canvas) return
const reader = new FileReader()
reader.onload = (event) => {
const url = event.target?.result as string
if ((canvas as any).addImage) {
(canvas as any).addImage(url)
}
}
reader.readAsDataURL(file)
// Reset input
e.target.value = ''
}
// Glassmorphism styles
const toolbarStyle = isDark
? 'backdrop-blur-xl bg-white/10 border border-white/20'
: 'backdrop-blur-xl bg-white/70 border border-black/10 shadow-xl'
const dividerStyle = isDark
? 'border-white/20'
: 'border-slate-200'
return (
<div className={`flex flex-col gap-2 p-2 rounded-2xl ${toolbarStyle} ${className}`}>
{/* Selection Tool */}
<ToolButton
tool="select"
isActive={activeTool === 'select'}
onClick={() => handleToolClick('select')}
isDark={isDark}
label="Auswählen"
icon={
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122" />
</svg>
}
/>
<div className={`border-t ${dividerStyle}`} />
{/* Text Tool */}
<ToolButton
tool="text"
isActive={activeTool === 'text'}
onClick={() => handleToolClick('text')}
isDark={isDark}
label="Text"
icon={
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
}
/>
<div className={`border-t ${dividerStyle}`} />
{/* Shape Tools */}
<ToolButton
tool="rectangle"
isActive={activeTool === 'rectangle'}
onClick={() => handleToolClick('rectangle')}
isDark={isDark}
label="Rechteck"
icon={
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h16M4 18h16M4 6v12M20 6v12" />
</svg>
}
/>
<ToolButton
tool="circle"
isActive={activeTool === 'circle'}
onClick={() => handleToolClick('circle')}
isDark={isDark}
label="Kreis"
icon={
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9" strokeWidth={1.5} />
</svg>
}
/>
<ToolButton
tool="line"
isActive={activeTool === 'line'}
onClick={() => handleToolClick('line')}
isDark={isDark}
label="Linie"
icon={
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 20L20 4" />
</svg>
}
/>
<ToolButton
tool="arrow"
isActive={activeTool === 'arrow'}
onClick={() => handleToolClick('arrow')}
isDark={isDark}
label="Pfeil"
icon={
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
}
/>
<div className={`border-t ${dividerStyle}`} />
{/* Image Tools */}
<ToolButton
tool="image"
isActive={activeTool === 'image'}
onClick={handleImageUpload}
isDark={isDark}
label="Bild hochladen"
icon={
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
}
/>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileChange}
className="hidden"
/>
{/* AI Image Generator */}
<ToolButton
tool="ai-image"
isActive={activeTool === 'ai-image'}
onClick={onOpenAIGenerator}
isDark={isDark}
label="KI-Bild generieren"
icon={
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
</svg>
}
/>
<div className={`border-t ${dividerStyle}`} />
{/* Document Import */}
<button
onClick={onOpenDocumentImporter}
title="Dokument importieren"
className={`w-12 h-12 flex items-center justify-center rounded-xl transition-all ${
isDark
? 'text-blue-300 hover:bg-blue-500/20 hover:text-blue-200'
: 'text-blue-600 hover:bg-blue-100 hover:text-blue-700'
}`}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 3v6a1 1 0 001 1h6" />
</svg>
</button>
{/* Cleanup Panel - Handwriting Removal */}
{onOpenCleanupPanel && (
<button
onClick={onOpenCleanupPanel}
title="Arbeitsblatt bereinigen (Handschrift entfernen)"
className={`w-12 h-12 flex items-center justify-center rounded-xl transition-all ${
isDark
? 'text-orange-300 hover:bg-orange-500/20 hover:text-orange-200'
: 'text-orange-600 hover:bg-orange-100 hover:text-orange-700'
}`}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
</svg>
</button>
)}
{/* OCR Import */}
{onOpenOCRImport && (
<button
onClick={onOpenOCRImport}
title="OCR Daten importieren (aus Grid Analyse)"
className={`w-12 h-12 flex items-center justify-center rounded-xl transition-all ${
isDark
? 'text-green-300 hover:bg-green-500/20 hover:text-green-200'
: 'text-green-600 hover:bg-green-100 hover:text-green-700'
}`}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
</button>
)}
<div className={`border-t ${dividerStyle}`} />
{/* Table Tool */}
<ToolButton
tool="table"
isActive={activeTool === 'table'}
onClick={() => handleToolClick('table')}
isDark={isDark}
label="Tabelle"
icon={
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 10h18M3 14h18m-9-4v8m-7 0h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
}
/>
<div className={`border-t ${dividerStyle} mt-auto`} />
{/* Undo/Redo */}
<button
onClick={undo}
disabled={!canUndo}
title="Rückgängig (Ctrl+Z)"
className={`w-12 h-12 flex items-center justify-center rounded-xl transition-all ${
canUndo
? isDark
? 'text-white/70 hover:bg-white/10 hover:text-white'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
: isDark
? 'text-white/30 cursor-not-allowed'
: 'text-slate-300 cursor-not-allowed'
}`}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
</svg>
</button>
<button
onClick={redo}
disabled={!canRedo}
title="Wiederholen (Ctrl+Y)"
className={`w-12 h-12 flex items-center justify-center rounded-xl transition-all ${
canRedo
? isDark
? 'text-white/70 hover:bg-white/10 hover:text-white'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
: isDark
? 'text-white/30 cursor-not-allowed'
: 'text-slate-300 cursor-not-allowed'
}`}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 10h-10a8 8 0 00-8 8v2M21 10l-6 6m6-6l-6-6" />
</svg>
</button>
</div>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -0,0 +1,471 @@
'use client'
/**
* OCR Import Panel
*
* Loads OCR export data from the klausur-service API (shared between admin-v2 and studio-v2)
* and imports recognized words as editable text objects onto the Fabric.js canvas.
*/
import { useState, useEffect, useCallback } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { useWorksheet } from '@/lib/worksheet-editor/WorksheetContext'
import type { OCRExportData, OCRWord } from '@/lib/worksheet-editor/ocr-integration'
import { createTextProps, getColumnColor } from '@/lib/worksheet-editor/ocr-integration'
interface OCRImportPanelProps {
isOpen: boolean
onClose: () => void
}
export function OCRImportPanel({ isOpen, onClose }: OCRImportPanelProps) {
const { isDark } = useTheme()
const { canvas, saveToHistory } = useWorksheet()
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [ocrData, setOcrData] = useState<OCRExportData | null>(null)
const [selectedWords, setSelectedWords] = useState<Set<number>>(new Set())
const [importing, setImporting] = useState(false)
const [importSuccess, setImportSuccess] = useState(false)
// Load OCR data when panel opens
useEffect(() => {
if (!isOpen) return
loadOCRData()
}, [isOpen])
const loadOCRData = useCallback(async () => {
setLoading(true)
setError(null)
setOcrData(null)
setSelectedWords(new Set())
setImportSuccess(false)
try {
const res = await fetch('/klausur-api/api/v1/vocab/ocr-export/latest')
if (!res.ok) {
throw new Error('not_found')
}
const data: OCRExportData = await res.json()
setOcrData(data)
// Select all words by default
setSelectedWords(new Set(data.words.map((_, i) => i)))
} catch {
setError(
'Keine OCR-Daten gefunden. Bitte zuerst im OCR-Compare Tool "Zum Editor exportieren" klicken.'
)
} finally {
setLoading(false)
}
}, [])
const toggleWord = useCallback((index: number) => {
setSelectedWords(prev => {
const next = new Set(prev)
if (next.has(index)) {
next.delete(index)
} else {
next.add(index)
}
return next
})
}, [])
const selectAll = useCallback(() => {
if (!ocrData) return
setSelectedWords(new Set(ocrData.words.map((_, i) => i)))
}, [ocrData])
const deselectAll = useCallback(() => {
setSelectedWords(new Set())
}, [])
const handleImport = useCallback(async () => {
if (!ocrData || !canvas || selectedWords.size === 0) return
setImporting(true)
try {
// Dynamic import of fabric to avoid SSR issues
const { IText } = await import('fabric')
// Save current state for undo
if (saveToHistory) {
saveToHistory('OCR Import')
}
const wordsToImport = ocrData.words.filter((_, i) => selectedWords.has(i))
for (const word of wordsToImport) {
const props = createTextProps(word)
const textObj = new IText(props.text, {
left: props.left,
top: props.top,
fontSize: props.fontSize,
fontFamily: props.fontFamily,
fill: props.fill,
editable: true,
})
canvas.add(textObj)
}
canvas.renderAll()
setImportSuccess(true)
// Close after brief delay
setTimeout(() => {
onClose()
}, 1500)
} catch (e) {
console.error('Import failed:', e)
setError('Import fehlgeschlagen. Bitte erneut versuchen.')
} finally {
setImporting(false)
}
}, [ocrData, canvas, selectedWords, saveToHistory, onClose])
if (!isOpen) return null
// Count column types
const columnSummary = ocrData
? ocrData.words.reduce<Record<string, number>>((acc, w) => {
acc[w.column_type] = (acc[w.column_type] || 0) + 1
return acc
}, {})
: {}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Panel */}
<div
className={`relative w-full max-w-2xl max-h-[85vh] rounded-3xl overflow-hidden flex flex-col ${
isDark
? 'bg-slate-900/95 border border-white/10'
: 'bg-white/95 border border-slate-200'
} backdrop-blur-xl`}
>
{/* Header */}
<div
className={`flex items-center justify-between px-6 py-4 border-b ${
isDark ? 'border-white/10' : 'border-slate-200'
}`}
>
<div>
<h2
className={`text-xl font-bold ${
isDark ? 'text-white' : 'text-slate-900'
}`}
>
OCR Daten importieren
</h2>
<p
className={`text-sm mt-0.5 ${
isDark ? 'text-white/50' : 'text-slate-500'
}`}
>
Erkannte Texte aus der Grid-Analyse einfuegen
</p>
</div>
<button
onClick={onClose}
className={`p-2 rounded-lg transition-colors ${
isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'
}`}
>
<svg
className={`w-5 h-5 ${isDark ? 'text-white' : 'text-slate-600'}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{/* Loading */}
{loading && (
<div className="flex flex-col items-center justify-center py-12">
<div className="animate-spin w-8 h-8 border-4 border-green-500 border-t-transparent rounded-full" />
<p
className={`mt-3 text-sm ${
isDark ? 'text-white/50' : 'text-slate-500'
}`}
>
Lade OCR-Daten...
</p>
</div>
)}
{/* Error */}
{error && (
<div
className={`p-4 rounded-xl text-center ${
isDark
? 'bg-red-500/10 text-red-300'
: 'bg-red-50 text-red-700'
}`}
>
<svg
className="w-8 h-8 mx-auto mb-2 opacity-50"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
<p className="text-sm">{error}</p>
<button
onClick={loadOCRData}
className={`mt-3 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
isDark
? 'bg-white/10 text-white hover:bg-white/20'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
Erneut versuchen
</button>
</div>
)}
{/* Success State */}
{importSuccess && (
<div
className={`p-6 rounded-xl text-center ${
isDark
? 'bg-green-500/10 text-green-300'
: 'bg-green-50 text-green-700'
}`}
>
<svg
className="w-12 h-12 mx-auto mb-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
<p className="font-medium">
{selectedWords.size} Texte erfolgreich importiert!
</p>
</div>
)}
{/* OCR Data Display */}
{ocrData && !importSuccess && (
<>
{/* Summary */}
<div
className={`p-4 rounded-xl mb-4 ${
isDark ? 'bg-white/5' : 'bg-slate-50'
}`}
>
<div className="flex flex-wrap gap-3 mb-2">
<span
className={`px-3 py-1 rounded-lg text-sm font-medium ${
isDark
? 'bg-green-500/20 text-green-300'
: 'bg-green-100 text-green-700'
}`}
>
{ocrData.words.length} Woerter
</span>
{Object.entries(columnSummary).map(([type, count]) => (
<span
key={type}
className="px-3 py-1 rounded-lg text-sm font-medium"
style={{
backgroundColor: getColumnColor(type as any) + '15',
color: getColumnColor(type as any),
}}
>
{type === 'english'
? 'Englisch'
: type === 'german'
? 'Deutsch'
: type === 'example'
? 'Beispiel'
: 'Unbekannt'}
: {count}
</span>
))}
</div>
<p
className={`text-xs ${
isDark ? 'text-white/40' : 'text-slate-400'
}`}
>
Session: {ocrData.session_id.slice(0, 8)}... | Seite{' '}
{ocrData.page_number} | Exportiert:{' '}
{new Date(ocrData.exported_at).toLocaleString('de-DE')}
</p>
</div>
{/* Selection Controls */}
<div className="flex items-center justify-between mb-3">
<span
className={`text-sm font-medium ${
isDark ? 'text-white/70' : 'text-slate-600'
}`}
>
{selectedWords.size} von {ocrData.words.length} ausgewaehlt
</span>
<div className="flex gap-2">
<button
onClick={selectAll}
className={`px-3 py-1 rounded-lg text-xs font-medium transition-colors ${
isDark
? 'bg-white/10 text-white/70 hover:bg-white/20'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
Alle
</button>
<button
onClick={deselectAll}
className={`px-3 py-1 rounded-lg text-xs font-medium transition-colors ${
isDark
? 'bg-white/10 text-white/70 hover:bg-white/20'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
Keine
</button>
</div>
</div>
{/* Word List */}
<div className="space-y-1 max-h-[40vh] overflow-y-auto">
{ocrData.words.map((word, idx) => (
<label
key={idx}
className={`flex items-center gap-3 p-2 rounded-lg cursor-pointer transition-colors ${
selectedWords.has(idx)
? isDark
? 'bg-green-500/10'
: 'bg-green-50'
: isDark
? 'hover:bg-white/5'
: 'hover:bg-slate-50'
}`}
>
<input
type="checkbox"
checked={selectedWords.has(idx)}
onChange={() => toggleWord(idx)}
className="rounded border-slate-300"
/>
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{
backgroundColor: getColumnColor(word.column_type),
}}
/>
<span
className={`text-sm flex-1 ${
isDark ? 'text-white' : 'text-slate-900'
}`}
>
{word.text}
</span>
<span
className={`text-xs px-2 py-0.5 rounded ${
isDark ? 'bg-white/10 text-white/40' : 'bg-slate-100 text-slate-400'
}`}
>
{word.column_type === 'english'
? 'EN'
: word.column_type === 'german'
? 'DE'
: word.column_type === 'example'
? 'Ex'
: '?'}
</span>
</label>
))}
</div>
</>
)}
</div>
{/* Footer */}
{ocrData && !importSuccess && (
<div
className={`px-6 py-4 border-t ${
isDark ? 'border-white/10' : 'border-slate-200'
}`}
>
<div className="flex items-center justify-between">
<button
onClick={onClose}
className={`px-4 py-2 rounded-xl text-sm font-medium transition-colors ${
isDark
? 'text-white/60 hover:bg-white/10'
: 'text-slate-600 hover:bg-slate-100'
}`}
>
Abbrechen
</button>
<button
onClick={handleImport}
disabled={importing || selectedWords.size === 0}
className={`px-6 py-2.5 rounded-xl text-sm font-medium transition-colors flex items-center gap-2 ${
selectedWords.size > 0
? 'bg-green-600 text-white hover:bg-green-700'
: isDark
? 'bg-white/10 text-white/30 cursor-not-allowed'
: 'bg-slate-200 text-slate-400 cursor-not-allowed'
}`}
>
{importing ? (
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : (
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
)}
{selectedWords.size} Texte importieren
</button>
</div>
</div>
)}
</div>
</div>
)
}
@@ -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>
)
}

Some files were not shown because too many files have changed in this diff Show More