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

112
studio-v2/app/agb/page.tsx Normal file
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

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>
)
}

View File

@@ -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 }
)
}
}

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 }
)
}
}

View File

@@ -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 }
)
}
}

View File

@@ -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)
}

View File

@@ -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)
}

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 }
)
}
}

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 })
}

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>
)
}

View File

@@ -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>
)
}

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>
)
}

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>
)
}

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
studio-v2/app/globals.css Normal file
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;
}

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>
)
}

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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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

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>
)
}

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
studio-v2/app/layout.tsx Normal file
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>
)
}

View File

@@ -0,0 +1,9 @@
import Layout from '@/components/Layout'
export default function MagicHelpLayout({
children,
}: {
children: React.ReactNode
}) {
return <Layout>{children}</Layout>
}

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>
)
}

1481
studio-v2/app/meet/page.tsx Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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
studio-v2/app/page.tsx Normal file
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>
)
}

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

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>
)
}

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>
)
}

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>
)
}

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
}