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:
112
studio-v2/app/agb/page.tsx
Normal file
112
studio-v2/app/agb/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1019
studio-v2/app/alerts-b2b/page.tsx
Normal file
1019
studio-v2/app/alerts-b2b/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
466
studio-v2/app/alerts/page.tsx
Normal file
466
studio-v2/app/alerts/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
129
studio-v2/app/api/companion/feedback/route.ts
Normal file
129
studio-v2/app/api/companion/feedback/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
194
studio-v2/app/api/companion/lesson/route.ts
Normal file
194
studio-v2/app/api/companion/lesson/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
137
studio-v2/app/api/companion/settings/route.ts
Normal file
137
studio-v2/app/api/companion/settings/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
96
studio-v2/app/api/meetings/[...path]/route.ts
Normal file
96
studio-v2/app/api/meetings/[...path]/route.ts
Normal 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)
|
||||
}
|
||||
106
studio-v2/app/api/recordings/[...path]/route.ts
Normal file
106
studio-v2/app/api/recordings/[...path]/route.ts
Normal 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)
|
||||
}
|
||||
34
studio-v2/app/api/recordings/route.ts
Normal file
34
studio-v2/app/api/recordings/route.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* Proxy for /api/recordings base endpoint
|
||||
*/
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8000'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const url = `${BACKEND_URL}/api/recordings`
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
const data = await response.text()
|
||||
return new NextResponse(data, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'Content-Type': response.headers.get('content-type') || 'application/json',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`Failed to proxy GET ${url}:`, error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend nicht erreichbar', details: error instanceof Error ? error.message : 'Unknown error' },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
}
|
||||
115
studio-v2/app/api/uploads/route.ts
Normal file
115
studio-v2/app/api/uploads/route.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { writeFile, readFile, mkdir } from 'fs/promises'
|
||||
import { existsSync } from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
// Speicherort fuer Uploads
|
||||
const UPLOADS_DIR = '/tmp/breakpilot-uploads'
|
||||
const METADATA_FILE = path.join(UPLOADS_DIR, 'metadata.json')
|
||||
|
||||
interface UploadedFile {
|
||||
id: string
|
||||
sessionId: string
|
||||
name: string
|
||||
type: string
|
||||
size: number
|
||||
uploadedAt: string
|
||||
dataUrl: string // Base64 data URL
|
||||
}
|
||||
|
||||
// Stelle sicher, dass das Upload-Verzeichnis existiert
|
||||
async function ensureUploadsDir() {
|
||||
if (!existsSync(UPLOADS_DIR)) {
|
||||
await mkdir(UPLOADS_DIR, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
// Lade Metadaten
|
||||
async function loadMetadata(): Promise<UploadedFile[]> {
|
||||
try {
|
||||
await ensureUploadsDir()
|
||||
if (existsSync(METADATA_FILE)) {
|
||||
const data = await readFile(METADATA_FILE, 'utf-8')
|
||||
return JSON.parse(data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading metadata:', error)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// Speichere Metadaten
|
||||
async function saveMetadata(uploads: UploadedFile[]) {
|
||||
await ensureUploadsDir()
|
||||
await writeFile(METADATA_FILE, JSON.stringify(uploads, null, 2))
|
||||
}
|
||||
|
||||
// GET: Liste alle Uploads fuer eine Session
|
||||
export async function GET(request: NextRequest) {
|
||||
const sessionId = request.nextUrl.searchParams.get('sessionId')
|
||||
|
||||
const uploads = await loadMetadata()
|
||||
|
||||
if (sessionId) {
|
||||
const filtered = uploads.filter(u => u.sessionId === sessionId)
|
||||
return NextResponse.json({ uploads: filtered })
|
||||
}
|
||||
|
||||
// Alle Uploads (fuer Dashboard)
|
||||
return NextResponse.json({ uploads })
|
||||
}
|
||||
|
||||
// POST: Neuen Upload hinzufuegen
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { sessionId, name, type, size, dataUrl } = body
|
||||
|
||||
if (!sessionId || !name || !dataUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const upload: UploadedFile = {
|
||||
id: `upload-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
sessionId,
|
||||
name,
|
||||
type: type || 'application/octet-stream',
|
||||
size: size || 0,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
dataUrl
|
||||
}
|
||||
|
||||
const uploads = await loadMetadata()
|
||||
uploads.push(upload)
|
||||
await saveMetadata(uploads)
|
||||
|
||||
return NextResponse.json({ success: true, upload })
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Upload failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE: Upload loeschen
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const id = request.nextUrl.searchParams.get('id')
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing upload id' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const uploads = await loadMetadata()
|
||||
const filtered = uploads.filter(u => u.id !== id)
|
||||
await saveMetadata(filtered)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
51
studio-v2/app/companion/page.tsx
Normal file
51
studio-v2/app/companion/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
739
studio-v2/app/dashboard-experimental/page.tsx
Normal file
739
studio-v2/app/dashboard-experimental/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
116
studio-v2/app/datenschutz/page.tsx
Normal file
116
studio-v2/app/datenschutz/page.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useLanguage } from '@/lib/LanguageContext'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { Footer } from '@/components/Footer'
|
||||
|
||||
export default function DatenschutzPage() {
|
||||
const { t } = useLanguage()
|
||||
const { isDark } = useTheme()
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen flex flex-col ${
|
||||
isDark
|
||||
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
|
||||
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
|
||||
}`}>
|
||||
<div className="flex-1 p-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Back Link */}
|
||||
<Link
|
||||
href="/"
|
||||
className={`inline-flex items-center gap-2 mb-8 transition-colors ${
|
||||
isDark ? 'text-white/60 hover:text-white' : 'text-slate-600 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
{t('back_to_selection')}
|
||||
</Link>
|
||||
|
||||
{/* Content Card */}
|
||||
<div className={`backdrop-blur-xl border rounded-3xl p-8 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20'
|
||||
: 'bg-white/70 border-black/10 shadow-lg'
|
||||
}`}>
|
||||
<h1 className={`text-3xl font-bold mb-8 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{t('privacy')}
|
||||
</h1>
|
||||
|
||||
<div className={`space-y-6 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
<section>
|
||||
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
1. Datenschutz auf einen Blick
|
||||
</h2>
|
||||
<h3 className={`text-lg font-medium mb-2 ${isDark ? 'text-white/90' : 'text-slate-800'}`}>
|
||||
Allgemeine Hinweise
|
||||
</h3>
|
||||
<p>
|
||||
Die folgenden Hinweise geben einen einfachen Überblick darüber, was mit Ihren personenbezogenen
|
||||
Daten passiert, wenn Sie diese Website besuchen. Personenbezogene Daten sind alle Daten, mit denen
|
||||
Sie persönlich identifiziert werden können.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
2. Datenerfassung auf dieser Website
|
||||
</h2>
|
||||
<h3 className={`text-lg font-medium mb-2 ${isDark ? 'text-white/90' : 'text-slate-800'}`}>
|
||||
Wer ist verantwortlich für die Datenerfassung?
|
||||
</h3>
|
||||
<p className="mb-4">
|
||||
Die Datenverarbeitung auf dieser Website erfolgt durch den Websitebetreiber.
|
||||
Dessen Kontaktdaten können Sie dem Impressum dieser Website entnehmen.
|
||||
</p>
|
||||
|
||||
<h3 className={`text-lg font-medium mb-2 ${isDark ? 'text-white/90' : 'text-slate-800'}`}>
|
||||
Wie erfassen wir Ihre Daten?
|
||||
</h3>
|
||||
<p>
|
||||
Ihre Daten werden zum einen dadurch erhoben, dass Sie uns diese mitteilen.
|
||||
Andere Daten werden automatisch beim Besuch der Website durch unsere IT-Systeme erfasst.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
3. Ihre Rechte
|
||||
</h2>
|
||||
<p>
|
||||
Sie haben jederzeit das Recht auf unentgeltliche Auskunft über Herkunft, Empfänger und Zweck
|
||||
Ihrer gespeicherten personenbezogenen Daten. Sie haben außerdem ein Recht, die Berichtigung
|
||||
oder Löschung dieser Daten zu verlangen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
4. Cookies
|
||||
</h2>
|
||||
<p>
|
||||
Diese Website verwendet Cookies. Sie können Ihre Cookie-Einstellungen jederzeit über den
|
||||
Link "Cookie-Einstellungen" im Footer dieser Seite anpassen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className={`mt-8 p-4 rounded-2xl ${
|
||||
isDark ? 'bg-yellow-500/20 border border-yellow-500/30' : 'bg-yellow-50 border border-yellow-200'
|
||||
}`}>
|
||||
<p className={`text-sm ${isDark ? 'text-yellow-200' : 'text-yellow-800'}`}>
|
||||
Hinweis: Dies ist ein Platzhalter. Bitte ersetzen Sie diesen Text durch Ihre vollständige
|
||||
Datenschutzerklärung gemäß DSGVO.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
501
studio-v2/app/geo-lernwelt/page.tsx
Normal file
501
studio-v2/app/geo-lernwelt/page.tsx
Normal 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 "Lernwelt erstellen" 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 "Gebiet waehlen"
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Learning Nodes List */}
|
||||
{learningNodes.length > 0 && (
|
||||
<div className="bg-white/5 backdrop-blur-lg rounded-2xl border border-white/10 p-4">
|
||||
<h3 className="text-white font-medium mb-3">Lernstationen</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{learningNodes.map((node, idx) => (
|
||||
<div
|
||||
key={node.id}
|
||||
className="p-3 bg-white/5 rounded-xl border border-white/10"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="w-6 h-6 bg-blue-500/30 rounded-full flex items-center justify-center text-xs text-white">
|
||||
{idx + 1}
|
||||
</span>
|
||||
<span className="text-white font-medium text-sm">{node.title}</span>
|
||||
</div>
|
||||
<p className="text-xs text-white/60 line-clamp-2">{node.question}</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className="text-xs bg-white/10 px-2 py-0.5 rounded text-white/60">
|
||||
{node.points} Punkte
|
||||
</span>
|
||||
{node.approved && (
|
||||
<span className="text-xs text-green-400">✓ Freigegeben</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
282
studio-v2/app/geo-lernwelt/types.ts
Normal file
282
studio-v2/app/geo-lernwelt/types.ts
Normal 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
37
studio-v2/app/globals.css
Normal file
@@ -0,0 +1,37 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* BreakPilot Studio v2 - Base Styles */
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #f8fafc;
|
||||
color: #1e293b;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Scrollbar Styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
109
studio-v2/app/impressum/page.tsx
Normal file
109
studio-v2/app/impressum/page.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useLanguage } from '@/lib/LanguageContext'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { Footer } from '@/components/Footer'
|
||||
|
||||
export default function ImpressumPage() {
|
||||
const { t } = useLanguage()
|
||||
const { isDark } = useTheme()
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen flex flex-col ${
|
||||
isDark
|
||||
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
|
||||
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
|
||||
}`}>
|
||||
<div className="flex-1 p-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Back Link */}
|
||||
<Link
|
||||
href="/"
|
||||
className={`inline-flex items-center gap-2 mb-8 transition-colors ${
|
||||
isDark ? 'text-white/60 hover:text-white' : 'text-slate-600 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
{t('back_to_selection')}
|
||||
</Link>
|
||||
|
||||
{/* Content Card */}
|
||||
<div className={`backdrop-blur-xl border rounded-3xl p-8 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20'
|
||||
: 'bg-white/70 border-black/10 shadow-lg'
|
||||
}`}>
|
||||
<h1 className={`text-3xl font-bold mb-8 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{t('imprint')}
|
||||
</h1>
|
||||
|
||||
<div className={`space-y-6 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
<section>
|
||||
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Angaben gemäß § 5 TMG
|
||||
</h2>
|
||||
<p>
|
||||
BreakPilot GmbH<br />
|
||||
Musterstraße 123<br />
|
||||
12345 Musterstadt<br />
|
||||
Deutschland
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Kontakt
|
||||
</h2>
|
||||
<p>
|
||||
Telefon: +49 (0) 123 456789<br />
|
||||
E-Mail: info@breakpilot.de
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Vertretungsberechtigte Geschäftsführer
|
||||
</h2>
|
||||
<p>Max Mustermann</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Registereintrag
|
||||
</h2>
|
||||
<p>
|
||||
Eintragung im Handelsregister<br />
|
||||
Registergericht: Amtsgericht Musterstadt<br />
|
||||
Registernummer: HRB 12345
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Umsatzsteuer-ID
|
||||
</h2>
|
||||
<p>
|
||||
Umsatzsteuer-Identifikationsnummer gemäß § 27 a Umsatzsteuergesetz:<br />
|
||||
DE 123456789
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className={`mt-8 p-4 rounded-2xl ${
|
||||
isDark ? 'bg-yellow-500/20 border border-yellow-500/30' : 'bg-yellow-50 border border-yellow-200'
|
||||
}`}>
|
||||
<p className={`text-sm ${isDark ? 'text-yellow-200' : 'text-yellow-800'}`}>
|
||||
Hinweis: Dies ist ein Platzhalter. Bitte ersetzen Sie diese Angaben durch Ihre tatsächlichen Unternehmensdaten.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
165
studio-v2/app/kontakt/page.tsx
Normal file
165
studio-v2/app/kontakt/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
492
studio-v2/app/korrektur/[klausurId]/[studentId]/page.tsx
Normal file
492
studio-v2/app/korrektur/[klausurId]/[studentId]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
569
studio-v2/app/korrektur/[klausurId]/fairness/page.tsx
Normal file
569
studio-v2/app/korrektur/[klausurId]/fairness/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
578
studio-v2/app/korrektur/[klausurId]/page.tsx
Normal file
578
studio-v2/app/korrektur/[klausurId]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1001
studio-v2/app/korrektur/archiv/page.tsx
Normal file
1001
studio-v2/app/korrektur/archiv/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
914
studio-v2/app/korrektur/page.tsx
Normal file
914
studio-v2/app/korrektur/page.tsx
Normal file
@@ -0,0 +1,914 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { useLanguage } from '@/lib/LanguageContext'
|
||||
import { Sidebar } from '@/components/Sidebar'
|
||||
import { ThemeToggle } from '@/components/ThemeToggle'
|
||||
import { LanguageDropdown } from '@/components/LanguageDropdown'
|
||||
import { QRCodeUpload, UploadedFile } from '@/components/QRCodeUpload'
|
||||
import {
|
||||
korrekturApi,
|
||||
getKorrekturStats,
|
||||
type KorrekturStats,
|
||||
} from '@/lib/korrektur/api'
|
||||
import type { Klausur, CreateKlausurData } from './types'
|
||||
|
||||
// LocalStorage Key for upload session
|
||||
const SESSION_ID_KEY = 'bp_korrektur_session'
|
||||
|
||||
// =============================================================================
|
||||
// GLASS CARD - Ultra Transparent (Apple Weather Style)
|
||||
// =============================================================================
|
||||
|
||||
interface GlassCardProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
delay?: number
|
||||
}
|
||||
|
||||
function GlassCard({ children, className = '', onClick, size = 'md', delay = 0, isDark = true }: GlassCardProps & { isDark?: boolean }) {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setIsVisible(true), delay)
|
||||
return () => clearTimeout(timer)
|
||||
}, [delay])
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'p-4',
|
||||
md: 'p-5',
|
||||
lg: 'p-6',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
rounded-3xl
|
||||
${sizeClasses[size]}
|
||||
${onClick ? 'cursor-pointer' : ''}
|
||||
${className}
|
||||
`}
|
||||
style={{
|
||||
background: isDark
|
||||
? (isHovered ? 'rgba(255, 255, 255, 0.12)' : 'rgba(255, 255, 255, 0.08)')
|
||||
: (isHovered ? 'rgba(255, 255, 255, 0.9)' : 'rgba(255, 255, 255, 0.7)'),
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: isDark ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.1)',
|
||||
boxShadow: isDark
|
||||
? '0 4px 24px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.05)'
|
||||
: '0 4px 24px rgba(0, 0, 0, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.5)',
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible
|
||||
? `translateY(0) scale(${isHovered ? 1.01 : 1})`
|
||||
: 'translateY(20px)',
|
||||
transition: 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STAT CARD
|
||||
// =============================================================================
|
||||
|
||||
interface StatCardProps {
|
||||
label: string
|
||||
value: string | number
|
||||
icon: React.ReactNode
|
||||
color?: string
|
||||
delay?: number
|
||||
isDark?: boolean
|
||||
}
|
||||
|
||||
function StatCard({ label, value, icon, color = '#a78bfa', delay = 0, isDark = true }: StatCardProps) {
|
||||
return (
|
||||
<GlassCard size="sm" delay={delay} isDark={isDark}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className="w-12 h-12 rounded-2xl flex items-center justify-center"
|
||||
style={{ backgroundColor: `${color}20` }}
|
||||
>
|
||||
<span style={{ color }}>{icon}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{label}</p>
|
||||
<p className={`text-2xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// KLAUSUR CARD
|
||||
// =============================================================================
|
||||
|
||||
interface KlausurCardProps {
|
||||
klausur: Klausur
|
||||
onClick: () => void
|
||||
delay?: number
|
||||
isDark?: boolean
|
||||
}
|
||||
|
||||
function KlausurCard({ klausur, onClick, delay = 0, isDark = true }: KlausurCardProps) {
|
||||
const progress = klausur.student_count
|
||||
? Math.round(((klausur.completed_count || 0) / klausur.student_count) * 100)
|
||||
: 0
|
||||
|
||||
const statusColor = klausur.status === 'completed'
|
||||
? '#22c55e'
|
||||
: klausur.status === 'in_progress'
|
||||
? '#f97316'
|
||||
: '#6b7280'
|
||||
|
||||
return (
|
||||
<GlassCard onClick={onClick} delay={delay} className="min-h-[180px]" isDark={isDark}>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<h3 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>{klausur.title}</h3>
|
||||
<span
|
||||
className="px-2 py-1 rounded-full text-xs font-medium"
|
||||
style={{ backgroundColor: `${statusColor}20`, color: statusColor }}
|
||||
>
|
||||
{klausur.status === 'completed' ? 'Fertig' : klausur.status === 'in_progress' ? 'In Arbeit' : 'Entwurf'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className={`text-sm mb-4 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
{klausur.subject} {klausur.semester} {klausur.year}
|
||||
</p>
|
||||
|
||||
<div className="mt-auto">
|
||||
<div className="flex justify-between text-sm mb-2">
|
||||
<span className={isDark ? 'text-white/50' : 'text-slate-500'}>{klausur.student_count || 0} Arbeiten</span>
|
||||
<span className={isDark ? 'text-white' : 'text-slate-900'}>{progress}%</span>
|
||||
</div>
|
||||
|
||||
<div className={`h-2 rounded-full overflow-hidden ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}>
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${progress}%`,
|
||||
background: `linear-gradient(90deg, ${statusColor}, ${statusColor}80)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CREATE KLAUSUR MODAL
|
||||
// =============================================================================
|
||||
|
||||
interface CreateKlausurModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (data: CreateKlausurData) => void
|
||||
isLoading: boolean
|
||||
isDark?: boolean
|
||||
}
|
||||
|
||||
function CreateKlausurModal({ isOpen, onClose, onSubmit, isLoading, isDark = true }: CreateKlausurModalProps) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [subject, setSubject] = useState('Deutsch')
|
||||
const [year, setYear] = useState(new Date().getFullYear())
|
||||
const [semester, setSemester] = useState('Abitur')
|
||||
const [modus, setModus] = useState<'landes_abitur' | 'vorabitur'>('landes_abitur')
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onSubmit({ title, subject, year, semester, modus })
|
||||
}
|
||||
|
||||
const inputClasses = isDark
|
||||
? 'bg-white/10 border-white/20 text-white placeholder-white/40'
|
||||
: 'bg-slate-100 border-slate-300 text-slate-900 placeholder-slate-400'
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
|
||||
<GlassCard className="relative w-full max-w-md" size="lg" delay={0} isDark={isDark}>
|
||||
<h2 className={`text-xl font-bold mb-6 ${isDark ? 'text-white' : 'text-slate-900'}`}>Neue Klausur erstellen</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="klausur-titel" className={`block text-sm mb-2 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>Titel</label>
|
||||
<input
|
||||
id="klausur-titel"
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="z.B. Deutsch LK Q4"
|
||||
className={`w-full p-3 rounded-xl border focus:ring-2 focus:ring-purple-500 focus:border-transparent ${inputClasses}`}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="klausur-fach" className={`block text-sm mb-2 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>Fach</label>
|
||||
<select
|
||||
id="klausur-fach"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
className={`w-full p-3 rounded-xl border focus:ring-2 focus:ring-purple-500 ${inputClasses}`}
|
||||
>
|
||||
<option value="Deutsch">Deutsch</option>
|
||||
<option value="Englisch">Englisch</option>
|
||||
<option value="Mathematik">Mathematik</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="klausur-jahr" className={`block text-sm mb-2 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>Jahr</label>
|
||||
<input
|
||||
id="klausur-jahr"
|
||||
type="number"
|
||||
value={year}
|
||||
onChange={(e) => setYear(Number(e.target.value))}
|
||||
className={`w-full p-3 rounded-xl border focus:ring-2 focus:ring-purple-500 ${inputClasses}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="klausur-semester" className={`block text-sm mb-2 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>Semester</label>
|
||||
<select
|
||||
id="klausur-semester"
|
||||
value={semester}
|
||||
onChange={(e) => setSemester(e.target.value)}
|
||||
className={`w-full p-3 rounded-xl border focus:ring-2 focus:ring-purple-500 ${inputClasses}`}
|
||||
>
|
||||
<option value="Abitur">Abitur</option>
|
||||
<option value="Q1">Q1</option>
|
||||
<option value="Q2">Q2</option>
|
||||
<option value="Q3">Q3</option>
|
||||
<option value="Q4">Q4</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="klausur-modus" className={`block text-sm mb-2 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>Modus</label>
|
||||
<select
|
||||
id="klausur-modus"
|
||||
value={modus}
|
||||
onChange={(e) => setModus(e.target.value as 'landes_abitur' | 'vorabitur')}
|
||||
className={`w-full p-3 rounded-xl border focus:ring-2 focus:ring-purple-500 ${inputClasses}`}
|
||||
>
|
||||
<option value="landes_abitur">Landes-Abitur (NiBiS EH)</option>
|
||||
<option value="vorabitur">Vorabitur (Eigener EH)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={`flex-1 px-4 py-3 rounded-xl transition-colors ${isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-200 text-slate-700 hover:bg-slate-300'}`}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !title.trim()}
|
||||
className="flex-1 px-4 py-3 rounded-xl bg-gradient-to-r from-purple-500 to-pink-500 text-white font-semibold hover:shadow-lg hover:shadow-purple-500/30 transition-all disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Erstelle...' : 'Erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</GlassCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function KorrekturPage() {
|
||||
const { isDark } = useTheme()
|
||||
const { t } = useLanguage()
|
||||
const router = useRouter()
|
||||
|
||||
// State
|
||||
const [klausuren, setKlausuren] = useState<Klausur[]>([])
|
||||
const [stats, setStats] = useState<KorrekturStats | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Modal states
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [showQRModal, setShowQRModal] = useState(false)
|
||||
const [uploadSessionId, setUploadSessionId] = useState('')
|
||||
const [showDirectUpload, setShowDirectUpload] = useState(false)
|
||||
const [showEHUpload, setShowEHUpload] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [uploadedFiles, setUploadedFiles] = useState<File[]>([])
|
||||
const [ehFile, setEhFile] = useState<File | null>(null)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
|
||||
// Initialize session ID
|
||||
useEffect(() => {
|
||||
let storedSessionId = localStorage.getItem(SESSION_ID_KEY)
|
||||
if (!storedSessionId) {
|
||||
storedSessionId = `korrektur-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
localStorage.setItem(SESSION_ID_KEY, storedSessionId)
|
||||
}
|
||||
setUploadSessionId(storedSessionId)
|
||||
}, [])
|
||||
|
||||
// Load data
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const [klausurenData, statsData] = await Promise.all([
|
||||
korrekturApi.getKlausuren(),
|
||||
getKorrekturStats(),
|
||||
])
|
||||
setKlausuren(klausurenData)
|
||||
setStats(statsData)
|
||||
} catch (err) {
|
||||
console.error('Failed to load data:', err)
|
||||
setError(err instanceof Error ? err.message : 'Laden fehlgeschlagen')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [loadData])
|
||||
|
||||
// Create klausur
|
||||
const handleCreateKlausur = async (data: CreateKlausurData) => {
|
||||
setIsCreating(true)
|
||||
try {
|
||||
const newKlausur = await korrekturApi.createKlausur(data)
|
||||
setKlausuren((prev) => [newKlausur, ...prev])
|
||||
setShowCreateModal(false)
|
||||
// Navigate to the new klausur
|
||||
router.push(`/korrektur/${newKlausur.id}`)
|
||||
} catch (err) {
|
||||
console.error('Failed to create klausur:', err)
|
||||
setError(err instanceof Error ? err.message : 'Erstellung fehlgeschlagen')
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle QR uploaded files
|
||||
const handleMobileFileSelect = async (uploadedFile: UploadedFile) => {
|
||||
// For now, just close the modal - in production this would create a quick-start klausur
|
||||
setShowQRModal(false)
|
||||
// Could auto-create a klausur and navigate
|
||||
}
|
||||
|
||||
// Handle direct file upload with drag & drop
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent, isEH = false) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
const files = Array.from(e.dataTransfer.files).filter(
|
||||
f => f.type === 'application/pdf' || f.type.startsWith('image/')
|
||||
)
|
||||
if (isEH && files.length > 0) {
|
||||
setEhFile(files[0])
|
||||
} else {
|
||||
setUploadedFiles(prev => [...prev, ...files])
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>, isEH = false) => {
|
||||
if (!e.target.files) return
|
||||
const files = Array.from(e.target.files)
|
||||
if (isEH && files.length > 0) {
|
||||
setEhFile(files[0])
|
||||
} else {
|
||||
setUploadedFiles(prev => [...prev, ...files])
|
||||
}
|
||||
}
|
||||
|
||||
const handleDirectUpload = async () => {
|
||||
if (uploadedFiles.length === 0) return
|
||||
setIsUploading(true)
|
||||
try {
|
||||
// Create a quick-start klausur
|
||||
const newKlausur = await korrekturApi.createKlausur({
|
||||
title: `Schnellstart ${new Date().toLocaleDateString('de-DE')}`,
|
||||
subject: 'Deutsch',
|
||||
year: new Date().getFullYear(),
|
||||
semester: 'Abitur',
|
||||
modus: 'landes_abitur'
|
||||
})
|
||||
|
||||
// Upload each file
|
||||
for (let i = 0; i < uploadedFiles.length; i++) {
|
||||
await korrekturApi.uploadStudentWork(newKlausur.id, uploadedFiles[i], `Arbeit-${i + 1}`)
|
||||
}
|
||||
|
||||
setShowDirectUpload(false)
|
||||
setUploadedFiles([])
|
||||
router.push(`/korrektur/${newKlausur.id}`)
|
||||
} catch (err) {
|
||||
console.error('Upload failed:', err)
|
||||
setError(err instanceof Error ? err.message : 'Upload fehlgeschlagen')
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEHUpload = async () => {
|
||||
if (!ehFile) return
|
||||
setIsUploading(true)
|
||||
try {
|
||||
// Upload EH to backend
|
||||
await korrekturApi.uploadEH(ehFile)
|
||||
setShowEHUpload(false)
|
||||
setEhFile(null)
|
||||
loadData() // Refresh to show new EH
|
||||
} catch (err) {
|
||||
console.error('EH Upload failed:', err)
|
||||
setError(err instanceof Error ? err.message : 'EH Upload fehlgeschlagen')
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen flex relative overflow-hidden ${
|
||||
isDark
|
||||
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
|
||||
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
|
||||
}`}>
|
||||
{/* Animated Background Blobs */}
|
||||
<div className={`absolute -top-40 -right-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob ${isDark ? 'bg-purple-500 opacity-30' : 'bg-purple-300 opacity-40'}`} />
|
||||
<div className={`absolute top-1/2 -left-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-2000 ${isDark ? 'bg-pink-500 opacity-30' : 'bg-pink-300 opacity-40'}`} />
|
||||
<div className={`absolute -bottom-40 right-1/3 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-4000 ${isDark ? 'bg-blue-500 opacity-30' : 'bg-blue-300 opacity-40'}`} />
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="relative z-10 p-4">
|
||||
<Sidebar />
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col relative z-10 p-6 overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className={`text-3xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>Korrekturplattform</h1>
|
||||
<p className={isDark ? 'text-white/50' : 'text-slate-500'}>KI-gestuetzte Abiturklausur-Korrektur</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<ThemeToggle />
|
||||
<LanguageDropdown />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<StatCard
|
||||
label="Offene Korrekturen"
|
||||
value={stats.openCorrections}
|
||||
icon={
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
}
|
||||
color="#f97316"
|
||||
delay={100}
|
||||
isDark={isDark}
|
||||
/>
|
||||
<StatCard
|
||||
label="Erledigt (Woche)"
|
||||
value={stats.completedThisWeek}
|
||||
icon={
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
}
|
||||
color="#22c55e"
|
||||
delay={200}
|
||||
isDark={isDark}
|
||||
/>
|
||||
<StatCard
|
||||
label="Durchschnitt"
|
||||
value={stats.averageGrade > 0 ? `${stats.averageGrade} P` : '-'}
|
||||
icon={
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
}
|
||||
color="#3b82f6"
|
||||
delay={300}
|
||||
isDark={isDark}
|
||||
/>
|
||||
<StatCard
|
||||
label="Zeit gespart"
|
||||
value={`${stats.timeSavedHours}h`}
|
||||
icon={
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
}
|
||||
color="#a78bfa"
|
||||
delay={400}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<GlassCard className="mb-6" size="sm" isDark={isDark}>
|
||||
<div className="flex items-center gap-3 text-red-400">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className={`ml-auto px-3 py-1 rounded-lg transition-colors text-sm ${isDark ? 'bg-white/10 hover:bg-white/20' : 'bg-slate-200 hover:bg-slate-300'}`}
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* Loading */}
|
||||
{isLoading && (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="w-12 h-12 border-4 border-purple-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Klausuren Grid */}
|
||||
{!isLoading && (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>Klausuren</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
|
||||
{klausuren.map((klausur, index) => (
|
||||
<KlausurCard
|
||||
key={klausur.id}
|
||||
klausur={klausur}
|
||||
onClick={() => router.push(`/korrektur/${klausur.id}`)}
|
||||
delay={500 + index * 50}
|
||||
isDark={isDark}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* New Klausur Card */}
|
||||
<GlassCard
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
delay={500 + klausuren.length * 50}
|
||||
className={`min-h-[180px] border-2 border-dashed ${isDark ? 'border-white/20 hover:border-purple-400/50' : 'border-slate-300 hover:border-purple-400'}`}
|
||||
isDark={isDark}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<div className="w-16 h-16 rounded-2xl bg-purple-500/20 flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>Neue Klausur</p>
|
||||
<p className={`text-sm mt-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Klausur erstellen</p>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<h2 className={`text-xl font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>Schnellaktionen</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<GlassCard
|
||||
onClick={() => setShowQRModal(true)}
|
||||
delay={700}
|
||||
className="cursor-pointer"
|
||||
isDark={isDark}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-2xl bg-blue-500/20 flex items-center justify-center">
|
||||
<span className="text-2xl">📱</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>QR Upload</p>
|
||||
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Mit Handy scannen</p>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard
|
||||
onClick={() => setShowDirectUpload(true)}
|
||||
delay={750}
|
||||
className="cursor-pointer"
|
||||
isDark={isDark}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-2xl bg-green-500/20 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>Direkt hochladen</p>
|
||||
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Drag & Drop</p>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
delay={800}
|
||||
className="cursor-pointer"
|
||||
isDark={isDark}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-2xl bg-purple-500/20 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>Schnellstart</p>
|
||||
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Direkt loslegen</p>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard
|
||||
onClick={() => setShowEHUpload(true)}
|
||||
delay={850}
|
||||
className="cursor-pointer"
|
||||
isDark={isDark}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-2xl bg-orange-500/20 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>EH hochladen</p>
|
||||
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Erwartungshorizont</p>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard
|
||||
onClick={() => router.push('/korrektur/archiv')}
|
||||
delay={900}
|
||||
className="cursor-pointer"
|
||||
isDark={isDark}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-2xl bg-indigo-500/20 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>Abitur-Archiv</p>
|
||||
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>EH durchsuchen</p>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Klausur Modal */}
|
||||
<CreateKlausurModal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onSubmit={handleCreateKlausur}
|
||||
isLoading={isCreating}
|
||||
isDark={isDark}
|
||||
/>
|
||||
|
||||
{/* QR Code Modal */}
|
||||
{showQRModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setShowQRModal(false)} />
|
||||
<div className={`relative w-full max-w-md rounded-3xl ${isDark ? 'bg-slate-900' : 'bg-white'}`}>
|
||||
<QRCodeUpload
|
||||
sessionId={uploadSessionId}
|
||||
onClose={() => setShowQRModal(false)}
|
||||
onFileUploaded={handleMobileFileSelect}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Direct Upload Modal */}
|
||||
{showDirectUpload && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setShowDirectUpload(false)} />
|
||||
<GlassCard className="relative w-full max-w-lg" size="lg" delay={0} isDark={isDark}>
|
||||
<h2 className={`text-xl font-bold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>Arbeiten hochladen</h2>
|
||||
<p className={`mb-4 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
Ziehen Sie eingescannte Klausuren hierher oder klicken Sie zum Auswaehlen.
|
||||
</p>
|
||||
|
||||
{/* Error Display in Modal */}
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-red-500/20 border border-red-500/30 text-red-300 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Drag & Drop Zone */}
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, false)}
|
||||
className={`relative p-8 rounded-2xl border-2 border-dashed transition-colors ${
|
||||
isDragging
|
||||
? 'border-purple-400 bg-purple-500/10'
|
||||
: isDark
|
||||
? 'border-white/20 hover:border-white/40'
|
||||
: 'border-slate-300 hover:border-slate-400'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf,image/*"
|
||||
multiple
|
||||
onChange={(e) => handleFileSelect(e, false)}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
<div className="text-center">
|
||||
<svg className={`w-12 h-12 mx-auto mb-4 ${isDark ? 'text-white/40' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{isDragging ? 'Dateien hier ablegen' : 'Dateien hierher ziehen'}
|
||||
</p>
|
||||
<p className={`text-sm mt-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
PDF oder Bilder (JPG, PNG)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Uploaded Files List */}
|
||||
{uploadedFiles.length > 0 && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className={`text-sm font-medium ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
{uploadedFiles.length} Datei(en) ausgewaehlt:
|
||||
</p>
|
||||
<div className="max-h-32 overflow-y-auto space-y-1">
|
||||
{uploadedFiles.map((file, idx) => (
|
||||
<div key={idx} className={`flex items-center justify-between p-2 rounded-lg ${isDark ? 'bg-white/5' : 'bg-slate-100'}`}>
|
||||
<span className={`text-sm truncate ${isDark ? 'text-white/80' : 'text-slate-700'}`}>{file.name}</span>
|
||||
<button
|
||||
onClick={() => setUploadedFiles(prev => prev.filter((_, i) => i !== idx))}
|
||||
className="text-red-400 hover:text-red-300"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button
|
||||
onClick={() => { setShowDirectUpload(false); setUploadedFiles([]) }}
|
||||
className={`flex-1 px-4 py-3 rounded-xl transition-colors ${isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-200 text-slate-700 hover:bg-slate-300'}`}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDirectUpload}
|
||||
disabled={uploadedFiles.length === 0 || isUploading}
|
||||
className="flex-1 px-4 py-3 rounded-xl bg-gradient-to-r from-purple-500 to-pink-500 text-white font-semibold hover:shadow-lg hover:shadow-purple-500/30 transition-all disabled:opacity-50"
|
||||
>
|
||||
{isUploading ? 'Hochladen...' : `${uploadedFiles.length} Arbeiten hochladen`}
|
||||
</button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* EH Upload Modal */}
|
||||
{showEHUpload && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setShowEHUpload(false)} />
|
||||
<GlassCard className="relative w-full max-w-lg" size="lg" delay={0} isDark={isDark}>
|
||||
<h2 className={`text-xl font-bold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>Erwartungshorizont hochladen</h2>
|
||||
<p className={`mb-6 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
Laden Sie einen eigenen Erwartungshorizont fuer Vorabitur-Klausuren hoch.
|
||||
</p>
|
||||
|
||||
{/* Drag & Drop Zone */}
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, true)}
|
||||
className={`relative p-8 rounded-2xl border-2 border-dashed transition-colors ${
|
||||
isDragging
|
||||
? 'border-orange-400 bg-orange-500/10'
|
||||
: isDark
|
||||
? 'border-white/20 hover:border-white/40'
|
||||
: 'border-slate-300 hover:border-slate-400'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf,.docx,.doc"
|
||||
onChange={(e) => handleFileSelect(e, true)}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
<div className="text-center">
|
||||
<svg className={`w-12 h-12 mx-auto mb-4 ${isDark ? 'text-white/40' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{ehFile ? ehFile.name : 'EH-Datei hierher ziehen'}
|
||||
</p>
|
||||
<p className={`text-sm mt-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
PDF oder Word-Dokument
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected File */}
|
||||
{ehFile && (
|
||||
<div className={`mt-4 flex items-center justify-between p-3 rounded-lg ${isDark ? 'bg-white/5' : 'bg-slate-100'}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="w-6 h-6 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<span className={`text-sm truncate ${isDark ? 'text-white/80' : 'text-slate-700'}`}>{ehFile.name}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setEhFile(null)}
|
||||
className="text-red-400 hover:text-red-300"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button
|
||||
onClick={() => { setShowEHUpload(false); setEhFile(null) }}
|
||||
className={`flex-1 px-4 py-3 rounded-xl transition-colors ${isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-200 text-slate-700 hover:bg-slate-300'}`}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleEHUpload}
|
||||
disabled={!ehFile || isUploading}
|
||||
className="flex-1 px-4 py-3 rounded-xl bg-gradient-to-r from-orange-500 to-red-500 text-white font-semibold hover:shadow-lg hover:shadow-orange-500/30 transition-all disabled:opacity-50"
|
||||
>
|
||||
{isUploading ? 'Hochladen...' : 'EH hochladen'}
|
||||
</button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
257
studio-v2/app/korrektur/types.ts
Normal file
257
studio-v2/app/korrektur/types.ts
Normal 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
39
studio-v2/app/layout.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { Metadata } from 'next'
|
||||
import './globals.css'
|
||||
import { LanguageProvider } from '@/lib/LanguageContext'
|
||||
import { ThemeProvider } from '@/lib/ThemeContext'
|
||||
import { AlertsProvider } from '@/lib/AlertsContext'
|
||||
import { AlertsB2BProvider } from '@/lib/AlertsB2BContext'
|
||||
import { MessagesProvider } from '@/lib/MessagesContext'
|
||||
import { ActivityProvider } from '@/lib/ActivityContext'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'BreakPilot Studio v2',
|
||||
description: 'Lehrer-Plattform für Korrektur, Arbeitsblaetter und mehr',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="de">
|
||||
<body>
|
||||
<ThemeProvider>
|
||||
<LanguageProvider>
|
||||
<AlertsProvider>
|
||||
<AlertsB2BProvider>
|
||||
<MessagesProvider>
|
||||
<ActivityProvider>
|
||||
{children}
|
||||
</ActivityProvider>
|
||||
</MessagesProvider>
|
||||
</AlertsB2BProvider>
|
||||
</AlertsProvider>
|
||||
</LanguageProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
9
studio-v2/app/magic-help/layout.tsx
Normal file
9
studio-v2/app/magic-help/layout.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import Layout from '@/components/Layout'
|
||||
|
||||
export default function MagicHelpLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return <Layout>{children}</Layout>
|
||||
}
|
||||
266
studio-v2/app/magic-help/page.tsx
Normal file
266
studio-v2/app/magic-help/page.tsx
Normal 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
1481
studio-v2/app/meet/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1166
studio-v2/app/messages/page.tsx
Normal file
1166
studio-v2/app/messages/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
934
studio-v2/app/page-original.tsx
Normal file
934
studio-v2/app/page-original.tsx
Normal 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
946
studio-v2/app/page.tsx
Normal file
@@ -0,0 +1,946 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { BPIcon } from '@/components/Logo'
|
||||
import { useLanguage } from '@/lib/LanguageContext'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { useAlerts, getImportanceColor, getRelativeTime } from '@/lib/AlertsContext'
|
||||
import { useMessages, formatMessageTime, getContactInitials } from '@/lib/MessagesContext'
|
||||
import { useActivity, formatDurationCompact } from '@/lib/ActivityContext'
|
||||
import { LanguageDropdown } from '@/components/LanguageDropdown'
|
||||
import { ThemeToggle } from '@/components/ThemeToggle'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { Sidebar } from '@/components/Sidebar'
|
||||
import { OnboardingWizard, OnboardingData } from '@/components/OnboardingWizard'
|
||||
import { DocumentUpload } from '@/components/DocumentUpload'
|
||||
import { QRCodeUpload } from '@/components/QRCodeUpload'
|
||||
import { DocumentSpace } from '@/components/DocumentSpace'
|
||||
import { ChatOverlay } from '@/components/ChatOverlay'
|
||||
import { AiPrompt } from '@/components/AiPrompt'
|
||||
|
||||
// LocalStorage Keys
|
||||
const ONBOARDING_KEY = 'bp_onboarding_complete'
|
||||
const USER_DATA_KEY = 'bp_user_data'
|
||||
const DOCUMENTS_KEY = 'bp_documents'
|
||||
const FIRST_VISIT_KEY = 'bp_first_dashboard_visit'
|
||||
const SESSION_ID_KEY = 'bp_session_id'
|
||||
|
||||
// BreakPilot Studio v2 - Glassmorphism Design
|
||||
|
||||
interface StoredDocument {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
size: number
|
||||
uploadedAt: Date
|
||||
url?: string
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const router = useRouter()
|
||||
const [selectedTab, setSelectedTab] = useState('dashboard')
|
||||
const [showOnboarding, setShowOnboarding] = useState<boolean | null>(null)
|
||||
const [userData, setUserData] = useState<OnboardingData | null>(null)
|
||||
const [documents, setDocuments] = useState<StoredDocument[]>([])
|
||||
const [showUploadModal, setShowUploadModal] = useState(false)
|
||||
const [showQRModal, setShowQRModal] = useState(false)
|
||||
const [isFirstVisit, setIsFirstVisit] = useState(false)
|
||||
const [sessionId, setSessionId] = useState<string>('')
|
||||
const [showAlertsDropdown, setShowAlertsDropdown] = useState(false)
|
||||
const { t } = useLanguage()
|
||||
const { isDark } = useTheme()
|
||||
const { alerts, unreadCount, markAsRead } = useAlerts()
|
||||
const { conversations, unreadCount: messagesUnreadCount, contacts, markAsRead: markMessageAsRead } = useMessages()
|
||||
const { stats: activityStats } = useActivity()
|
||||
|
||||
// Funktion zum Laden von Uploads aus der API
|
||||
const fetchUploadsFromAPI = useCallback(async (sid: string) => {
|
||||
if (!sid) return
|
||||
try {
|
||||
const response = await fetch(`/api/uploads?sessionId=${encodeURIComponent(sid)}`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
if (data.uploads && data.uploads.length > 0) {
|
||||
// Konvertiere API-Uploads zu StoredDocument Format
|
||||
const apiDocs: StoredDocument[] = data.uploads.map((u: any) => ({
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
type: u.type,
|
||||
size: u.size,
|
||||
uploadedAt: new Date(u.uploadedAt),
|
||||
url: u.dataUrl // Data URL direkt verwenden
|
||||
}))
|
||||
// Merge mit existierenden Dokumenten (ohne Duplikate)
|
||||
setDocuments(prev => {
|
||||
const existingIds = new Set(prev.map(d => d.id))
|
||||
const newDocs = apiDocs.filter(d => !existingIds.has(d.id))
|
||||
if (newDocs.length > 0) {
|
||||
return [...prev, ...newDocs]
|
||||
}
|
||||
return prev
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching uploads:', error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Prüfe beim Laden, ob Onboarding abgeschlossen ist
|
||||
useEffect(() => {
|
||||
const onboardingComplete = localStorage.getItem(ONBOARDING_KEY)
|
||||
const storedUserData = localStorage.getItem(USER_DATA_KEY)
|
||||
const storedDocs = localStorage.getItem(DOCUMENTS_KEY)
|
||||
const firstVisit = localStorage.getItem(FIRST_VISIT_KEY)
|
||||
let storedSessionId = localStorage.getItem(SESSION_ID_KEY)
|
||||
|
||||
// Session ID generieren falls nicht vorhanden
|
||||
if (!storedSessionId) {
|
||||
storedSessionId = `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
localStorage.setItem(SESSION_ID_KEY, storedSessionId)
|
||||
}
|
||||
setSessionId(storedSessionId)
|
||||
|
||||
if (onboardingComplete === 'true' && storedUserData) {
|
||||
setUserData(JSON.parse(storedUserData))
|
||||
setShowOnboarding(false)
|
||||
|
||||
// Dokumente laden
|
||||
if (storedDocs) {
|
||||
setDocuments(JSON.parse(storedDocs))
|
||||
}
|
||||
|
||||
// Erster Dashboard-Besuch nach Onboarding?
|
||||
if (!firstVisit) {
|
||||
setIsFirstVisit(true)
|
||||
localStorage.setItem(FIRST_VISIT_KEY, 'true')
|
||||
}
|
||||
|
||||
// Initialer Fetch von der API
|
||||
fetchUploadsFromAPI(storedSessionId)
|
||||
} else {
|
||||
setShowOnboarding(true)
|
||||
}
|
||||
}, [fetchUploadsFromAPI])
|
||||
|
||||
// Polling fuer neue Uploads von der API (alle 3 Sekunden)
|
||||
useEffect(() => {
|
||||
if (!sessionId || showOnboarding) return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
fetchUploadsFromAPI(sessionId)
|
||||
}, 3000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [sessionId, showOnboarding, fetchUploadsFromAPI])
|
||||
|
||||
// Dokumente in localStorage speichern
|
||||
useEffect(() => {
|
||||
if (documents.length > 0) {
|
||||
localStorage.setItem(DOCUMENTS_KEY, JSON.stringify(documents))
|
||||
}
|
||||
}, [documents])
|
||||
|
||||
// Handler fuer neue Uploads
|
||||
const handleUploadComplete = (uploadedDocs: any[]) => {
|
||||
const newDocs: StoredDocument[] = uploadedDocs.map(d => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
type: d.type,
|
||||
size: d.size,
|
||||
uploadedAt: d.uploadedAt,
|
||||
url: d.url
|
||||
}))
|
||||
setDocuments(prev => [...prev, ...newDocs])
|
||||
setIsFirstVisit(false)
|
||||
}
|
||||
|
||||
// Dokument loeschen (aus State und API)
|
||||
const handleDeleteDocument = async (id: string) => {
|
||||
setDocuments(prev => prev.filter(d => d.id !== id))
|
||||
// Auch aus API loeschen
|
||||
try {
|
||||
await fetch(`/api/uploads?id=${encodeURIComponent(id)}`, { method: 'DELETE' })
|
||||
} catch (error) {
|
||||
console.error('Error deleting from API:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Dokument umbenennen
|
||||
const handleRenameDocument = (id: string, newName: string) => {
|
||||
setDocuments(prev => prev.map(d => d.id === id ? { ...d, name: newName } : d))
|
||||
}
|
||||
|
||||
// Onboarding abschließen
|
||||
const handleOnboardingComplete = (data: OnboardingData) => {
|
||||
localStorage.setItem(ONBOARDING_KEY, 'true')
|
||||
localStorage.setItem(USER_DATA_KEY, JSON.stringify(data))
|
||||
setUserData(data)
|
||||
setShowOnboarding(false)
|
||||
}
|
||||
|
||||
// Zeige Ladebildschirm während der Prüfung
|
||||
if (showOnboarding === null) {
|
||||
return (
|
||||
<div className={`min-h-screen flex items-center justify-center ${
|
||||
isDark
|
||||
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
|
||||
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
|
||||
}`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<BPIcon variant="cupertino" size={48} className="animate-pulse" />
|
||||
<span className={`text-xl font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Laden...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Zeige Onboarding falls noch nicht abgeschlossen
|
||||
if (showOnboarding) {
|
||||
return <OnboardingWizard onComplete={handleOnboardingComplete} />
|
||||
}
|
||||
|
||||
// Ab hier: Dashboard (bestehender Code)
|
||||
|
||||
// Calculate time saved from activity tracking
|
||||
const timeSaved = formatDurationCompact(activityStats.weekSavedSeconds)
|
||||
const timeSavedDisplay = activityStats.weekSavedSeconds > 0
|
||||
? `${timeSaved.value}${timeSaved.unit}`
|
||||
: '0min'
|
||||
|
||||
const stats = [
|
||||
{ labelKey: 'stat_open_corrections', value: '12', icon: '📋', color: 'from-blue-400 to-blue-600' },
|
||||
{ labelKey: 'stat_completed_week', value: String(activityStats.activityCount), icon: '✅', color: 'from-green-400 to-green-600' },
|
||||
{ labelKey: 'stat_average', value: '2.3', icon: '📈', color: 'from-purple-400 to-purple-600' },
|
||||
{ labelKey: 'stat_time_saved', value: timeSavedDisplay, icon: '⏱', color: 'from-orange-400 to-orange-600' },
|
||||
]
|
||||
|
||||
const recentKlausuren = [
|
||||
{ id: 1, title: 'Deutsch LK - Textanalyse', students: 24, completed: 18, statusKey: 'status_in_progress' },
|
||||
{ id: 2, title: 'Deutsch GK - Erörterung', students: 28, completed: 28, statusKey: 'status_completed' },
|
||||
{ id: 3, title: 'Vorabitur - Gedichtanalyse', students: 22, completed: 10, statusKey: 'status_in_progress' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen relative overflow-hidden flex flex-col ${
|
||||
isDark
|
||||
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
|
||||
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
|
||||
}`}>
|
||||
{/* Animated Background Blobs */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className={`absolute -top-40 -right-40 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-blob ${
|
||||
isDark ? 'bg-purple-500 opacity-70' : 'bg-purple-300 opacity-50'
|
||||
}`} />
|
||||
<div className={`absolute -bottom-40 -left-40 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-2000 ${
|
||||
isDark ? 'bg-blue-500 opacity-70' : 'bg-blue-300 opacity-50'
|
||||
}`} />
|
||||
<div className={`absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-4000 ${
|
||||
isDark ? 'bg-pink-500 opacity-70' : 'bg-pink-300 opacity-50'
|
||||
}`} />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 flex min-h-screen gap-6 p-4">
|
||||
{/* Sidebar */}
|
||||
<Sidebar selectedTab={selectedTab} onTabChange={setSelectedTab} />
|
||||
|
||||
{/* ============================================
|
||||
ARBEITSFLAECHE (Main Content)
|
||||
============================================ */}
|
||||
<main className="flex-1">
|
||||
{/* Kopfleiste (Header) */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className={`text-4xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>{t('dashboard')}</h1>
|
||||
<p className={isDark ? 'text-white/60' : 'text-slate-600'}>{t('dashboard_subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{/* Search, Language & Actions */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('search_placeholder')}
|
||||
className={`backdrop-blur-xl border rounded-2xl px-5 py-3 pl-12 focus:outline-none focus:ring-2 w-64 transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white placeholder-white/40 focus:ring-white/30'
|
||||
: 'bg-white/70 border-black/10 text-slate-900 placeholder-slate-400 focus:ring-indigo-300'
|
||||
}`}
|
||||
/>
|
||||
<svg className={`absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 ${isDark ? 'text-white/40' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Language Dropdown */}
|
||||
<LanguageDropdown />
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<ThemeToggle />
|
||||
|
||||
{/* Notifications Bell with Glow Effect */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowAlertsDropdown(!showAlertsDropdown)}
|
||||
className={`relative p-3 backdrop-blur-xl border rounded-2xl transition-all ${
|
||||
unreadCount > 0
|
||||
? 'animate-pulse bg-gradient-to-r from-amber-500/20 to-orange-500/20 border-amber-500/30 shadow-lg shadow-amber-500/30'
|
||||
: isDark
|
||||
? 'bg-white/10 border-white/20 hover:bg-white/20'
|
||||
: 'bg-black/5 border-black/10 hover:bg-black/10'
|
||||
} ${isDark ? 'text-white' : 'text-slate-700'}`}
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
||||
</svg>
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 rounded-full text-xs text-white flex items-center justify-center font-medium">
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Alerts Dropdown */}
|
||||
{showAlertsDropdown && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowAlertsDropdown(false)} />
|
||||
<div className={`absolute right-0 mt-2 w-80 rounded-2xl border shadow-xl z-50 ${
|
||||
isDark
|
||||
? 'bg-slate-900 border-white/20'
|
||||
: 'bg-white border-slate-200'
|
||||
}`}>
|
||||
<div className={`p-4 border-b ${isDark ? 'border-white/10' : 'border-slate-100'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Aktuelle Alerts
|
||||
</h3>
|
||||
{unreadCount > 0 && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-amber-500/20 text-amber-500">
|
||||
{unreadCount} neu
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{alerts.slice(0, 5).map(alert => (
|
||||
<button
|
||||
key={alert.id}
|
||||
onClick={() => {
|
||||
markAsRead(alert.id)
|
||||
setShowAlertsDropdown(false)
|
||||
router.push('/alerts')
|
||||
}}
|
||||
className={`w-full text-left p-4 transition-all ${
|
||||
isDark
|
||||
? `hover:bg-white/5 ${!alert.isRead ? 'bg-amber-500/5 border-l-2 border-amber-500' : ''}`
|
||||
: `hover:bg-slate-50 ${!alert.isRead ? 'bg-amber-50 border-l-2 border-amber-500' : ''}`
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${getImportanceColor(alert.importance, isDark)}`}>
|
||||
{alert.importance.slice(0, 4)}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm font-medium truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{alert.title}
|
||||
</p>
|
||||
<p className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
{getRelativeTime(alert.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{alerts.length === 0 && (
|
||||
<div className={`p-8 text-center ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
<span className="text-2xl block mb-2">📭</span>
|
||||
<p className="text-sm">Keine Alerts</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`p-3 border-t ${isDark ? 'border-white/10' : 'border-slate-100'}`}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAlertsDropdown(false)
|
||||
router.push('/alerts')
|
||||
}}
|
||||
className={`w-full py-2 text-sm font-medium rounded-lg transition-all ${
|
||||
isDark
|
||||
? 'text-amber-400 hover:bg-amber-500/10'
|
||||
: 'text-amber-600 hover:bg-amber-50'
|
||||
}`}
|
||||
>
|
||||
Alle Alerts anzeigen →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Willkommensnachricht fuer ersten Besuch */}
|
||||
{isFirstVisit && documents.length === 0 && (
|
||||
<div className={`mb-8 p-6 rounded-3xl border backdrop-blur-xl ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-purple-500/20 to-pink-500/20 border-purple-500/30'
|
||||
: 'bg-gradient-to-r from-purple-50 to-pink-50 border-purple-200'
|
||||
}`}>
|
||||
<div className="flex items-start gap-6">
|
||||
<div className={`w-16 h-16 rounded-2xl flex items-center justify-center text-3xl ${
|
||||
isDark ? 'bg-white/20' : 'bg-white shadow-lg'
|
||||
}`}>
|
||||
🎉
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className={`text-xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Willkommen bei BreakPilot Studio!
|
||||
</h2>
|
||||
<p className={`mb-4 ${isDark ? 'text-white/70' : 'text-slate-600'}`}>
|
||||
Grossartig, dass Sie hier sind! Laden Sie jetzt Ihr erstes Dokument hoch,
|
||||
um die KI-gestuetzte Korrektur zu erleben. Sie koennen Dateien von Ihrem
|
||||
Computer oder Mobiltelefon hochladen.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
className="px-6 py-3 bg-gradient-to-r from-purple-500 to-pink-500 text-white rounded-xl font-medium hover:shadow-lg hover:shadow-purple-500/30 transition-all hover:scale-105"
|
||||
>
|
||||
Dokument hochladen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowQRModal(true)}
|
||||
className={`px-6 py-3 rounded-xl font-medium transition-all ${
|
||||
isDark
|
||||
? 'bg-white/20 text-white hover:bg-white/30'
|
||||
: 'bg-white text-slate-700 hover:bg-slate-50 shadow'
|
||||
}`}
|
||||
>
|
||||
Mit Mobiltelefon hochladen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsFirstVisit(false)}
|
||||
className={`p-2 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-white'}`}
|
||||
>
|
||||
<svg className={`w-5 h-5 ${isDark ? 'text-white/60' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* KI-Assistent */}
|
||||
<AiPrompt />
|
||||
|
||||
{/* Stats Kacheln */}
|
||||
<div className="grid grid-cols-4 gap-6 mb-8">
|
||||
{stats.map((stat, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`backdrop-blur-xl border rounded-3xl p-6 transition-all hover:scale-105 hover:shadow-xl cursor-pointer ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 hover:bg-white/15'
|
||||
: 'bg-white/70 border-black/10 hover:bg-white/90 shadow-lg'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className={`w-12 h-12 bg-gradient-to-br ${stat.color} rounded-2xl flex items-center justify-center text-2xl shadow-lg`}>
|
||||
{stat.icon}
|
||||
</div>
|
||||
<svg className={`w-5 h-5 ${isDark ? 'text-white/40' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className={`text-sm mb-1 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>{t(stat.labelKey)}</p>
|
||||
<p className={`text-3xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>{stat.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab-Content */}
|
||||
{selectedTab === 'dokumente' ? (
|
||||
/* Dokumente-Tab */
|
||||
<div className="space-y-6">
|
||||
{/* Upload-Optionen */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<button
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
className={`p-6 rounded-3xl border backdrop-blur-xl text-left transition-all hover:scale-105 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 hover:bg-white/15'
|
||||
: 'bg-white/70 border-black/10 hover:bg-white/90 shadow-lg'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center text-3xl mb-4 ${
|
||||
isDark ? 'bg-blue-500/20' : 'bg-blue-100'
|
||||
}`}>
|
||||
📤
|
||||
</div>
|
||||
<h3 className={`text-lg font-semibold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Direkt hochladen
|
||||
</h3>
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
Ziehen Sie Dateien hierher oder klicken Sie zum Auswaehlen
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowQRModal(true)}
|
||||
className={`p-6 rounded-3xl border backdrop-blur-xl text-left transition-all hover:scale-105 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 hover:bg-white/15'
|
||||
: 'bg-white/70 border-black/10 hover:bg-white/90 shadow-lg'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center text-3xl mb-4 ${
|
||||
isDark ? 'bg-purple-500/20' : 'bg-purple-100'
|
||||
}`}>
|
||||
📱
|
||||
</div>
|
||||
<h3 className={`text-lg font-semibold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Mit Mobiltelefon hochladen
|
||||
</h3>
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
QR-Code scannen (nur im lokalen Netzwerk)
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Document Space */}
|
||||
<div className={`rounded-3xl border backdrop-blur-xl p-6 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20'
|
||||
: 'bg-white/70 border-black/10 shadow-lg'
|
||||
}`}>
|
||||
<h2 className={`text-xl font-semibold mb-6 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Meine Dokumente
|
||||
</h2>
|
||||
<DocumentSpace
|
||||
documents={documents}
|
||||
onDelete={handleDeleteDocument}
|
||||
onRename={handleRenameDocument}
|
||||
onOpen={(doc) => doc.url && window.open(doc.url, '_blank')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Dashboard-Tab (Standard) */
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
{/* Aktuelle Klausuren Kachel */}
|
||||
<div className={`col-span-2 backdrop-blur-xl border rounded-3xl p-6 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20'
|
||||
: 'bg-white/70 border-black/10 shadow-lg'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>{t('recent_klausuren')}</h2>
|
||||
<button className={`text-sm transition-colors ${
|
||||
isDark ? 'text-white/60 hover:text-white' : 'text-slate-500 hover:text-slate-900'
|
||||
}`}>
|
||||
{t('show_all')} →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{recentKlausuren.map((klausur) => (
|
||||
<div
|
||||
key={klausur.id}
|
||||
className={`flex items-center gap-4 p-4 rounded-2xl transition-all cursor-pointer group ${
|
||||
isDark
|
||||
? 'bg-white/5 hover:bg-white/10'
|
||||
: 'bg-slate-50 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
<div className="w-14 h-14 bg-gradient-to-br from-blue-400 to-purple-500 rounded-2xl flex items-center justify-center">
|
||||
<span className="text-2xl">📝</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>{klausur.title}</h3>
|
||||
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{klausur.students} {t('students')}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${
|
||||
klausur.statusKey === 'status_completed'
|
||||
? isDark ? 'bg-green-500/20 text-green-300' : 'bg-green-100 text-green-700'
|
||||
: isDark ? 'bg-yellow-500/20 text-yellow-300' : 'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{t(klausur.statusKey)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<div className={`w-24 h-1.5 rounded-full overflow-hidden ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}>
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-cyan-400 to-blue-500 rounded-full"
|
||||
style={{ width: `${(klausur.completed / klausur.students) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{klausur.completed}/{klausur.students}</span>
|
||||
</div>
|
||||
</div>
|
||||
<svg className={`w-5 h-5 transition-colors ${
|
||||
isDark ? 'text-white/30 group-hover:text-white/60' : 'text-slate-300 group-hover:text-slate-600'
|
||||
}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schnellaktionen Kachel */}
|
||||
<div className={`backdrop-blur-xl border rounded-3xl p-6 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20'
|
||||
: 'bg-white/70 border-black/10 shadow-lg'
|
||||
}`}>
|
||||
<h2 className={`text-xl font-semibold mb-6 ${isDark ? 'text-white' : 'text-slate-900'}`}>{t('quick_actions')}</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button className="w-full flex items-center gap-4 p-4 bg-gradient-to-r from-blue-500 to-purple-500 rounded-2xl text-white hover:shadow-xl hover:shadow-purple-500/30 transition-all hover:scale-105">
|
||||
<span className="text-2xl">➕</span>
|
||||
<span className="font-medium">{t('create_klausur')}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
className={`w-full flex items-center gap-4 p-4 rounded-2xl transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white hover:bg-white/20'
|
||||
: 'bg-slate-100 text-slate-800 hover:bg-slate-200'
|
||||
}`}>
|
||||
<span className="text-2xl">📤</span>
|
||||
<span className="font-medium">{t('upload_work')}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setSelectedTab('dokumente')}
|
||||
className={`w-full flex items-center justify-between p-4 rounded-2xl transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white hover:bg-white/20'
|
||||
: 'bg-slate-100 text-slate-800 hover:bg-slate-200'
|
||||
}`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-2xl">📁</span>
|
||||
<span className="font-medium">{t('nav_dokumente')}</span>
|
||||
</div>
|
||||
{documents.length > 0 && (
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded-full ${
|
||||
isDark ? 'bg-white/20' : 'bg-slate-200'
|
||||
}`}>
|
||||
{documents.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => router.push('/worksheet-editor')}
|
||||
className={`w-full flex items-center gap-4 p-4 rounded-2xl transition-all ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-purple-500/20 to-pink-500/20 text-white hover:from-purple-500/30 hover:to-pink-500/30 border border-purple-500/30'
|
||||
: 'bg-gradient-to-r from-purple-50 to-pink-50 text-slate-800 hover:from-purple-100 hover:to-pink-100 border border-purple-200'
|
||||
}`}>
|
||||
<span className="text-2xl">🎨</span>
|
||||
<span className="font-medium">{t('nav_worksheet_editor')}</span>
|
||||
</button>
|
||||
|
||||
<button className={`w-full flex items-center gap-4 p-4 rounded-2xl transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white hover:bg-white/20'
|
||||
: 'bg-slate-100 text-slate-800 hover:bg-slate-200'
|
||||
}`}>
|
||||
<span className="text-2xl">✨</span>
|
||||
<span className="font-medium">{t('magic_help')}</span>
|
||||
</button>
|
||||
|
||||
<button className={`w-full flex items-center gap-4 p-4 rounded-2xl transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white hover:bg-white/20'
|
||||
: 'bg-slate-100 text-slate-800 hover:bg-slate-200'
|
||||
}`}>
|
||||
<span className="text-2xl">📊</span>
|
||||
<span className="font-medium">{t('fairness_check')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* AI Insight mini */}
|
||||
<div className={`mt-6 p-4 rounded-2xl border ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-pink-500/20 to-purple-500/20 border-pink-500/30'
|
||||
: 'bg-gradient-to-r from-pink-50 to-purple-50 border-pink-200'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="text-lg">🤖</span>
|
||||
<span className={`text-sm font-medium ${isDark ? 'text-white/80' : 'text-slate-700'}`}>{t('ai_tip')}</span>
|
||||
</div>
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
{t('ai_tip_text')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Alerts Kachel */}
|
||||
<div className={`mt-6 backdrop-blur-xl border rounded-2xl p-4 ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-amber-500/10 to-orange-500/10 border-amber-500/30'
|
||||
: 'bg-gradient-to-r from-amber-50 to-orange-50 border-amber-200'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className={`font-semibold flex items-center gap-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
<span>🔔</span> Aktuelle Alerts
|
||||
</h3>
|
||||
{unreadCount > 0 && (
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-amber-500/20 text-amber-500 font-medium">
|
||||
{unreadCount} neu
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Headlines Liste */}
|
||||
<div className="space-y-2">
|
||||
{alerts.slice(0, 3).map(alert => (
|
||||
<button
|
||||
key={alert.id}
|
||||
onClick={() => {
|
||||
markAsRead(alert.id)
|
||||
router.push('/alerts')
|
||||
}}
|
||||
className={`w-full text-left p-2 rounded-lg transition-all text-sm ${
|
||||
isDark
|
||||
? `hover:bg-white/10 ${!alert.isRead ? 'bg-white/5' : ''}`
|
||||
: `hover:bg-white ${!alert.isRead ? 'bg-white/50' : ''}`
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{!alert.isRead && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 mt-1.5 flex-shrink-0" />
|
||||
)}
|
||||
<p className={`truncate ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
{alert.title}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{alerts.length === 0 && (
|
||||
<p className={`text-sm ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
Keine Alerts vorhanden
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mehr anzeigen */}
|
||||
<button
|
||||
onClick={() => router.push('/alerts')}
|
||||
className={`w-full mt-3 text-sm font-medium ${
|
||||
isDark ? 'text-amber-400 hover:text-amber-300' : 'text-amber-600 hover:text-amber-700'
|
||||
}`}
|
||||
>
|
||||
Alle Alerts anzeigen →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Nachrichten Kachel */}
|
||||
<div className={`mt-6 backdrop-blur-xl border rounded-2xl p-4 ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-green-500/10 to-emerald-500/10 border-green-500/30'
|
||||
: 'bg-gradient-to-r from-green-50 to-emerald-50 border-green-200'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className={`font-semibold flex items-center gap-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
<span>💬</span> {t('nav_messages')}
|
||||
</h3>
|
||||
{messagesUnreadCount > 0 && (
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-green-500/20 text-green-500 font-medium">
|
||||
{messagesUnreadCount} neu
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Conversations Liste */}
|
||||
<div className="space-y-2">
|
||||
{conversations.slice(0, 3).map(conv => {
|
||||
const contact = contacts.find(c => conv.participant_ids.includes(c.id))
|
||||
return (
|
||||
<button
|
||||
key={conv.id}
|
||||
onClick={() => {
|
||||
if (conv.unread_count > 0) {
|
||||
markMessageAsRead(conv.id)
|
||||
}
|
||||
router.push('/messages')
|
||||
}}
|
||||
className={`w-full text-left p-2 rounded-lg transition-all text-sm ${
|
||||
isDark
|
||||
? `hover:bg-white/10 ${conv.unread_count > 0 ? 'bg-white/5' : ''}`
|
||||
: `hover:bg-white ${conv.unread_count > 0 ? 'bg-white/50' : ''}`
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Avatar */}
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium flex-shrink-0 ${
|
||||
contact?.online
|
||||
? isDark
|
||||
? 'bg-green-500/30 text-green-300'
|
||||
: 'bg-green-200 text-green-700'
|
||||
: isDark
|
||||
? 'bg-slate-600 text-slate-300'
|
||||
: 'bg-slate-200 text-slate-600'
|
||||
}`}>
|
||||
{conv.title ? getContactInitials(conv.title) : '?'}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{conv.unread_count > 0 && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-500 flex-shrink-0" />
|
||||
)}
|
||||
<span className={`font-medium truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{conv.title || 'Unbenannt'}
|
||||
</span>
|
||||
</div>
|
||||
{conv.last_message && (
|
||||
<p className={`text-xs truncate ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
{conv.last_message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{conv.last_message_time && (
|
||||
<span className={`text-xs flex-shrink-0 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
{formatMessageTime(conv.last_message_time)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{conversations.length === 0 && (
|
||||
<p className={`text-sm ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
Keine Nachrichten vorhanden
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mehr anzeigen */}
|
||||
<button
|
||||
onClick={() => router.push('/messages')}
|
||||
className={`w-full mt-3 text-sm font-medium ${
|
||||
isDark ? 'text-green-400 hover:text-green-300' : 'text-green-600 hover:text-green-700'
|
||||
}`}
|
||||
>
|
||||
Alle Nachrichten anzeigen →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Upload Modal */}
|
||||
{showUploadModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setShowUploadModal(false)} />
|
||||
<div className={`relative w-full max-w-2xl rounded-3xl border p-6 max-h-[90vh] overflow-y-auto ${
|
||||
isDark
|
||||
? 'bg-slate-900 border-white/20'
|
||||
: 'bg-white border-slate-200 shadow-2xl'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Dokumente hochladen
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowUploadModal(false)}
|
||||
className={`p-2 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'}`}
|
||||
>
|
||||
<svg className={`w-5 h-5 ${isDark ? 'text-white/60' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<DocumentUpload
|
||||
onUploadComplete={(docs) => {
|
||||
handleUploadComplete(docs)
|
||||
}}
|
||||
/>
|
||||
{/* Aktions-Buttons */}
|
||||
<div className={`mt-6 pt-6 border-t flex justify-between items-center ${
|
||||
isDark ? 'border-white/10' : 'border-slate-200'
|
||||
}`}>
|
||||
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
{documents.length > 0 ? `${documents.length} Dokument${documents.length !== 1 ? 'e' : ''} gespeichert` : 'Noch keine Dokumente'}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowUploadModal(false)}
|
||||
className={`px-4 py-2 rounded-xl text-sm font-medium ${
|
||||
isDark ? 'text-white/60 hover:text-white' : 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
Schliessen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowUploadModal(false)
|
||||
setSelectedTab('dokumente')
|
||||
}}
|
||||
className="px-4 py-2 bg-gradient-to-r from-purple-500 to-pink-500 text-white rounded-xl text-sm font-medium hover:shadow-lg transition-all"
|
||||
>
|
||||
Zu meinen Dokumenten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* QR Code Modal */}
|
||||
{showQRModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setShowQRModal(false)} />
|
||||
<div className={`relative w-full max-w-md rounded-3xl ${
|
||||
isDark ? 'bg-slate-900' : 'bg-white'
|
||||
}`}>
|
||||
<QRCodeUpload
|
||||
sessionId={sessionId}
|
||||
onClose={() => setShowQRModal(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Diegetic Chat Overlay - Cinematic message notifications */}
|
||||
<ChatOverlay
|
||||
typewriterEnabled={true}
|
||||
typewriterSpeed={25}
|
||||
autoDismissMs={0}
|
||||
maxQueue={5}
|
||||
/>
|
||||
|
||||
{/* Footer */}
|
||||
<Footer />
|
||||
|
||||
{/* Blob Animation Styles */}
|
||||
<style jsx>{`
|
||||
@keyframes blob {
|
||||
0% { transform: translate(0px, 0px) scale(1); }
|
||||
33% { transform: translate(30px, -50px) scale(1.1); }
|
||||
66% { transform: translate(-20px, 20px) scale(0.9); }
|
||||
100% { transform: translate(0px, 0px) scale(1); }
|
||||
}
|
||||
.animate-blob {
|
||||
animation: blob 7s infinite;
|
||||
}
|
||||
.animation-delay-2000 {
|
||||
animation-delay: 2s;
|
||||
}
|
||||
.animation-delay-4000 {
|
||||
animation-delay: 4s;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
235
studio-v2/app/upload/[sessionId]/page.tsx
Normal file
235
studio-v2/app/upload/[sessionId]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
2047
studio-v2/app/vocab-worksheet/page.tsx
Normal file
2047
studio-v2/app/vocab-worksheet/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
235
studio-v2/app/voice-test/page.tsx
Normal file
235
studio-v2/app/voice-test/page.tsx
Normal 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>"Notiz zu Max: heute wiederholt gestoert"</li>
|
||||
<li>"Erinner mich morgen an Hausaufgabenkontrolle"</li>
|
||||
<li>"Erstelle Arbeitsblatt mit 3 Lueckentexten"</li>
|
||||
<li>"Elternbrief wegen wiederholter Stoerungen"</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>• "Notiz zu [Name]: [Beobachtung]"</li>
|
||||
<li>• "[Name] braucht extra Uebung"</li>
|
||||
<li>• "Hausaufgabe kontrollieren"</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>• "Arbeitsblatt erstellen"</li>
|
||||
<li>• "Quiz mit 10 Fragen"</li>
|
||||
<li>• "Elternbrief wegen..."</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>• "Erinner mich morgen..."</li>
|
||||
<li>• "Nachricht an Klasse 8a"</li>
|
||||
<li>• "Offene Aufgaben zeigen"</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Privacy note */}
|
||||
<div className="mt-6 text-center text-sm text-gray-400">
|
||||
<p>
|
||||
DSGVO-konform: Audio wird nur im Arbeitsspeicher verarbeitet und
|
||||
nie gespeichert.
|
||||
</p>
|
||||
<p>
|
||||
Alle personenbezogenen Daten werden verschluesselt gespeichert -
|
||||
der Schluessel bleibt auf Ihrem Geraet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
899
studio-v2/app/worksheet-cleanup/page.tsx
Normal file
899
studio-v2/app/worksheet-cleanup/page.tsx
Normal file
@@ -0,0 +1,899 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { Sidebar } from '@/components/Sidebar'
|
||||
import { ThemeToggle } from '@/components/ThemeToggle'
|
||||
import { LanguageDropdown } from '@/components/LanguageDropdown'
|
||||
import { QRCodeUpload, UploadedFile } from '@/components/QRCodeUpload'
|
||||
|
||||
// LocalStorage Key for upload session
|
||||
const SESSION_ID_KEY = 'bp_cleanup_session'
|
||||
|
||||
/**
|
||||
* Worksheet Cleanup Page - Apple Weather Dashboard Style
|
||||
*
|
||||
* Design principles:
|
||||
* - Dark gradient background
|
||||
* - Ultra-translucent glass cards (~8% opacity)
|
||||
* - White text, monochrome palette
|
||||
* - Step-by-step cleanup wizard
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// GLASS CARD - Ultra Transparent
|
||||
// =============================================================================
|
||||
|
||||
interface GlassCardProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
delay?: number
|
||||
}
|
||||
|
||||
function GlassCard({ children, className = '', onClick, size = 'md', delay = 0, isDark = true }: GlassCardProps & { isDark?: boolean }) {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setIsVisible(true), delay)
|
||||
return () => clearTimeout(timer)
|
||||
}, [delay])
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'p-4',
|
||||
md: 'p-5',
|
||||
lg: 'p-6',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
rounded-3xl
|
||||
${sizeClasses[size]}
|
||||
${onClick ? 'cursor-pointer' : ''}
|
||||
${className}
|
||||
`}
|
||||
style={{
|
||||
background: isDark
|
||||
? (isHovered ? 'rgba(255, 255, 255, 0.12)' : 'rgba(255, 255, 255, 0.08)')
|
||||
: (isHovered ? 'rgba(255, 255, 255, 0.9)' : 'rgba(255, 255, 255, 0.7)'),
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: isDark ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.1)',
|
||||
boxShadow: isDark
|
||||
? '0 4px 24px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.05)'
|
||||
: '0 4px 24px rgba(0, 0, 0, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.5)',
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible
|
||||
? `translateY(0) scale(${isHovered ? 1.01 : 1})`
|
||||
: 'translateY(20px)',
|
||||
transition: 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PROGRESS RING
|
||||
// =============================================================================
|
||||
|
||||
interface ProgressRingProps {
|
||||
progress: number
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
label: string
|
||||
value: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
function ProgressRing({
|
||||
progress,
|
||||
size = 80,
|
||||
strokeWidth = 6,
|
||||
label,
|
||||
value,
|
||||
color = '#a78bfa'
|
||||
}: ProgressRingProps) {
|
||||
const radius = (size - strokeWidth) / 2
|
||||
const circumference = radius * 2 * Math.PI
|
||||
const offset = circumference - (progress / 100) * circumference
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="relative" style={{ width: size, height: size }}>
|
||||
<svg className="transform -rotate-90" width={size} height={size}>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="rgba(255, 255, 255, 0.1)"
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
style={{ transition: 'stroke-dashoffset 0.5s ease' }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-lg font-bold text-white">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="mt-2 text-xs text-white/50">{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface PreviewResult {
|
||||
has_handwriting: boolean
|
||||
confidence: number
|
||||
handwriting_ratio: number
|
||||
image_width: number
|
||||
image_height: number
|
||||
estimated_times_ms: {
|
||||
detection: number
|
||||
inpainting: number
|
||||
reconstruction: number
|
||||
total: number
|
||||
}
|
||||
}
|
||||
|
||||
interface PipelineResult {
|
||||
success: boolean
|
||||
handwriting_detected: boolean
|
||||
handwriting_removed: boolean
|
||||
layout_reconstructed: boolean
|
||||
cleaned_image_base64?: string
|
||||
fabric_json?: any
|
||||
metadata: any
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function WorksheetCleanupPage() {
|
||||
const { isDark } = useTheme()
|
||||
const router = useRouter()
|
||||
|
||||
// File state
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||
const [cleanedUrl, setCleanedUrl] = useState<string | null>(null)
|
||||
const [maskUrl, setMaskUrl] = useState<string | null>(null)
|
||||
|
||||
// Loading states
|
||||
const [isPreviewing, setIsPreviewing] = useState(false)
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Results
|
||||
const [previewResult, setPreviewResult] = useState<PreviewResult | null>(null)
|
||||
const [pipelineResult, setPipelineResult] = useState<PipelineResult | null>(null)
|
||||
|
||||
// Options
|
||||
const [removeHandwriting, setRemoveHandwriting] = useState(true)
|
||||
const [reconstructLayout, setReconstructLayout] = useState(true)
|
||||
const [inpaintingMethod, setInpaintingMethod] = useState<string>('auto')
|
||||
|
||||
// Step tracking
|
||||
const [currentStep, setCurrentStep] = useState<'upload' | 'preview' | 'processing' | 'result'>('upload')
|
||||
|
||||
// QR Code Upload
|
||||
const [showQRModal, setShowQRModal] = useState(false)
|
||||
const [uploadSessionId, setUploadSessionId] = useState('')
|
||||
const [mobileUploadedFiles, setMobileUploadedFiles] = useState<UploadedFile[]>([])
|
||||
|
||||
// Format file size
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
// Initialize upload session ID
|
||||
useEffect(() => {
|
||||
let storedSessionId = localStorage.getItem(SESSION_ID_KEY)
|
||||
if (!storedSessionId) {
|
||||
storedSessionId = `cleanup-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
localStorage.setItem(SESSION_ID_KEY, storedSessionId)
|
||||
}
|
||||
setUploadSessionId(storedSessionId)
|
||||
}, [])
|
||||
|
||||
const getApiUrl = useCallback(() => {
|
||||
if (typeof window === 'undefined') return 'http://localhost:8086'
|
||||
const { hostname, protocol } = window.location
|
||||
return hostname === 'localhost' ? 'http://localhost:8086' : `${protocol}//${hostname}:8086`
|
||||
}, [])
|
||||
|
||||
// Handle file selection
|
||||
const handleFileSelect = useCallback((selectedFile: File) => {
|
||||
setFile(selectedFile)
|
||||
setError(null)
|
||||
setPreviewResult(null)
|
||||
setPipelineResult(null)
|
||||
setCleanedUrl(null)
|
||||
setMaskUrl(null)
|
||||
|
||||
const url = URL.createObjectURL(selectedFile)
|
||||
setPreviewUrl(url)
|
||||
setCurrentStep('upload')
|
||||
}, [])
|
||||
|
||||
// Handle mobile file selection - convert to File and trigger handleFileSelect
|
||||
const handleMobileFileSelect = useCallback(async (uploadedFile: UploadedFile) => {
|
||||
try {
|
||||
const base64Data = uploadedFile.dataUrl.split(',')[1]
|
||||
const byteCharacters = atob(base64Data)
|
||||
const byteNumbers = new Array(byteCharacters.length)
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i)
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers)
|
||||
const blob = new Blob([byteArray], { type: uploadedFile.type })
|
||||
const file = new File([blob], uploadedFile.name, { type: uploadedFile.type })
|
||||
handleFileSelect(file)
|
||||
setShowQRModal(false)
|
||||
} catch (error) {
|
||||
console.error('Failed to convert mobile file:', error)
|
||||
setError('Fehler beim Laden der Datei vom Handy')
|
||||
}
|
||||
}, [handleFileSelect])
|
||||
|
||||
// Handle drop
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
const droppedFile = e.dataTransfer.files[0]
|
||||
if (droppedFile && droppedFile.type.startsWith('image/')) {
|
||||
handleFileSelect(droppedFile)
|
||||
}
|
||||
}, [handleFileSelect])
|
||||
|
||||
// Preview cleanup
|
||||
const handlePreview = useCallback(async () => {
|
||||
if (!file) return
|
||||
|
||||
setIsPreviewing(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('image', file)
|
||||
|
||||
const response = await fetch(`${getApiUrl()}/api/v1/worksheet/preview-cleanup`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
setPreviewResult(result)
|
||||
setCurrentStep('preview')
|
||||
} catch (err) {
|
||||
console.error('Preview failed:', err)
|
||||
setError(err instanceof Error ? err.message : 'Vorschau fehlgeschlagen')
|
||||
} finally {
|
||||
setIsPreviewing(false)
|
||||
}
|
||||
}, [file, getApiUrl])
|
||||
|
||||
// Run full cleanup pipeline
|
||||
const handleCleanup = useCallback(async () => {
|
||||
if (!file) return
|
||||
|
||||
setIsProcessing(true)
|
||||
setCurrentStep('processing')
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('image', file)
|
||||
formData.append('remove_handwriting', String(removeHandwriting))
|
||||
formData.append('reconstruct', String(reconstructLayout))
|
||||
formData.append('inpainting_method', inpaintingMethod)
|
||||
|
||||
const response = await fetch(`${getApiUrl()}/api/v1/worksheet/cleanup-pipeline`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }))
|
||||
throw new Error(errorData.detail || `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const result: PipelineResult = await response.json()
|
||||
setPipelineResult(result)
|
||||
|
||||
// Create cleaned image URL
|
||||
if (result.cleaned_image_base64) {
|
||||
const cleanedBlob = await fetch(`data:image/png;base64,${result.cleaned_image_base64}`).then(r => r.blob())
|
||||
setCleanedUrl(URL.createObjectURL(cleanedBlob))
|
||||
}
|
||||
|
||||
setCurrentStep('result')
|
||||
} catch (err) {
|
||||
console.error('Cleanup failed:', err)
|
||||
setError(err instanceof Error ? err.message : 'Bereinigung fehlgeschlagen')
|
||||
setCurrentStep('preview')
|
||||
} finally {
|
||||
setIsProcessing(false)
|
||||
}
|
||||
}, [file, removeHandwriting, reconstructLayout, inpaintingMethod, getApiUrl])
|
||||
|
||||
// Get detection mask
|
||||
const handleGetMask = useCallback(async () => {
|
||||
if (!file) return
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('image', file)
|
||||
|
||||
const response = await fetch(`${getApiUrl()}/api/v1/worksheet/detect-handwriting/mask`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const blob = await response.blob()
|
||||
setMaskUrl(URL.createObjectURL(blob))
|
||||
} catch (err) {
|
||||
console.error('Mask fetch failed:', err)
|
||||
}
|
||||
}, [file, getApiUrl])
|
||||
|
||||
// Open in worksheet editor
|
||||
const handleOpenInEditor = useCallback(() => {
|
||||
if (pipelineResult?.fabric_json) {
|
||||
// Store the fabric JSON in sessionStorage
|
||||
sessionStorage.setItem('worksheetCleanupResult', JSON.stringify(pipelineResult.fabric_json))
|
||||
router.push('/worksheet-editor')
|
||||
}
|
||||
}, [pipelineResult, router])
|
||||
|
||||
// Reset to start
|
||||
const handleReset = useCallback(() => {
|
||||
setFile(null)
|
||||
setPreviewUrl(null)
|
||||
setCleanedUrl(null)
|
||||
setMaskUrl(null)
|
||||
setPreviewResult(null)
|
||||
setPipelineResult(null)
|
||||
setError(null)
|
||||
setCurrentStep('upload')
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen flex relative overflow-hidden ${
|
||||
isDark
|
||||
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
|
||||
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
|
||||
}`}>
|
||||
{/* Animated Background Blobs */}
|
||||
<div className={`absolute -top-40 -right-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob ${isDark ? 'bg-purple-500 opacity-30' : 'bg-purple-300 opacity-40'}`} />
|
||||
<div className={`absolute top-1/2 -left-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-2000 ${isDark ? 'bg-pink-500 opacity-30' : 'bg-pink-300 opacity-40'}`} />
|
||||
<div className={`absolute -bottom-40 right-1/3 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-4000 ${isDark ? 'bg-blue-500 opacity-30' : 'bg-blue-300 opacity-40'}`} />
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="relative z-10 p-4">
|
||||
<Sidebar />
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col relative z-10 p-6 overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className={`text-3xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>Arbeitsblatt bereinigen</h1>
|
||||
<p className={isDark ? 'text-white/50' : 'text-slate-500'}>Handschrift entfernen und Layout rekonstruieren</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<ThemeToggle />
|
||||
<LanguageDropdown />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step Indicator */}
|
||||
<div className="flex items-center justify-center gap-4 mb-8">
|
||||
{['upload', 'preview', 'processing', 'result'].map((step, idx) => (
|
||||
<div key={step} className="flex items-center">
|
||||
<div className={`
|
||||
w-10 h-10 rounded-full flex items-center justify-center font-medium transition-all
|
||||
${currentStep === step
|
||||
? 'bg-purple-500 text-white shadow-lg shadow-purple-500/50'
|
||||
: ['upload', 'preview', 'processing', 'result'].indexOf(currentStep) > idx
|
||||
? 'bg-green-500 text-white'
|
||||
: isDark ? 'bg-white/10 text-white/40' : 'bg-slate-200 text-slate-400'
|
||||
}
|
||||
`}>
|
||||
{['upload', 'preview', 'processing', 'result'].indexOf(currentStep) > idx ? (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
idx + 1
|
||||
)}
|
||||
</div>
|
||||
{idx < 3 && (
|
||||
<div className={`w-16 h-0.5 mx-2 ${
|
||||
['upload', 'preview', 'processing', 'result'].indexOf(currentStep) > idx
|
||||
? 'bg-green-500'
|
||||
: isDark ? 'bg-white/20' : 'bg-slate-300'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<GlassCard className="mb-6" size="sm" isDark={isDark}>
|
||||
<div className="flex items-center gap-3 text-red-400">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* Content based on step */}
|
||||
<div className="flex-1">
|
||||
{/* Step 1: Upload */}
|
||||
{currentStep === 'upload' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Upload Options - File and QR Code side by side */}
|
||||
<GlassCard className="col-span-1" delay={100}>
|
||||
<div
|
||||
className="border-2 border-dashed border-white/20 rounded-2xl p-8 text-center cursor-pointer transition-all hover:border-purple-400/50 hover:bg-white/5 min-h-[280px] flex flex-col items-center justify-center"
|
||||
onDrop={handleDrop}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onClick={() => document.getElementById('file-input')?.click()}
|
||||
>
|
||||
<input
|
||||
id="file-input"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => e.target.files?.[0] && handleFileSelect(e.target.files[0])}
|
||||
className="hidden"
|
||||
/>
|
||||
{previewUrl ? (
|
||||
<div className="space-y-4">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Preview"
|
||||
className="max-h-40 mx-auto rounded-xl shadow-2xl"
|
||||
/>
|
||||
<p className="text-white font-medium text-sm">{file?.name}</p>
|
||||
<p className="text-white/50 text-xs">Klicke zum Ändern</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-16 h-16 mx-auto mb-4 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<p className="text-xl font-semibold text-white mb-2">Datei auswählen</p>
|
||||
<p className="text-white/50 text-sm mb-2">Ziehe ein Bild hierher oder klicke</p>
|
||||
<p className="text-white/30 text-xs">PNG, JPG, JPEG</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* QR Code Upload */}
|
||||
<GlassCard className="col-span-1" delay={150}>
|
||||
<div
|
||||
className="border-2 border-dashed border-white/20 rounded-2xl p-8 text-center cursor-pointer transition-all hover:border-purple-400/50 hover:bg-white/5 min-h-[280px] flex flex-col items-center justify-center"
|
||||
onClick={() => setShowQRModal(true)}
|
||||
>
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-xl bg-purple-500/20 flex items-center justify-center">
|
||||
<span className="text-3xl">📱</span>
|
||||
</div>
|
||||
<p className="text-xl font-semibold text-white mb-2">Mit Handy scannen</p>
|
||||
<p className="text-white/50 text-sm mb-2">QR-Code scannen um Foto hochzuladen</p>
|
||||
<p className="text-white/30 text-xs">Im lokalen Netzwerk</p>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* Options */}
|
||||
{file && (
|
||||
<>
|
||||
<GlassCard delay={200}>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Optionen</h3>
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-4 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={removeHandwriting}
|
||||
onChange={(e) => setRemoveHandwriting(e.target.checked)}
|
||||
className="w-5 h-5 rounded bg-white/10 border-white/20 text-purple-500 focus:ring-purple-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-white font-medium group-hover:text-purple-300 transition-colors">
|
||||
Handschrift entfernen
|
||||
</span>
|
||||
<p className="text-white/40 text-sm">Erkennt und entfernt handgeschriebene Inhalte</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-4 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={reconstructLayout}
|
||||
onChange={(e) => setReconstructLayout(e.target.checked)}
|
||||
className="w-5 h-5 rounded bg-white/10 border-white/20 text-purple-500 focus:ring-purple-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-white font-medium group-hover:text-purple-300 transition-colors">
|
||||
Layout rekonstruieren
|
||||
</span>
|
||||
<p className="text-white/40 text-sm">Erstellt bearbeitbare Textblöcke</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard delay={300}>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Methode</h3>
|
||||
<select
|
||||
value={inpaintingMethod}
|
||||
onChange={(e) => setInpaintingMethod(e.target.value)}
|
||||
className="w-full p-3 rounded-xl bg-white/10 border border-white/20 text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
<option value="auto">Automatisch (empfohlen)</option>
|
||||
<option value="opencv_telea">OpenCV Telea (schnell)</option>
|
||||
<option value="opencv_ns">OpenCV NS (glatter)</option>
|
||||
</select>
|
||||
<p className="text-white/40 text-sm mt-3">
|
||||
Die automatische Methode wählt die beste Option basierend auf dem Bildinhalt.
|
||||
</p>
|
||||
</GlassCard>
|
||||
|
||||
{/* Action Button */}
|
||||
<div className="col-span-1 lg:col-span-2 flex justify-center">
|
||||
<button
|
||||
onClick={handlePreview}
|
||||
disabled={isPreviewing}
|
||||
className="px-8 py-4 rounded-2xl font-semibold text-lg bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:shadow-lg hover:shadow-purple-500/30 transition-all disabled:opacity-50 flex items-center gap-3"
|
||||
>
|
||||
{isPreviewing ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Analysiere...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
Vorschau
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Preview */}
|
||||
{currentStep === 'preview' && previewResult && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Stats */}
|
||||
<GlassCard delay={100}>
|
||||
<h3 className="text-lg font-semibold text-white mb-6">Analyse</h3>
|
||||
<div className="flex justify-around">
|
||||
<ProgressRing
|
||||
progress={previewResult.confidence * 100}
|
||||
label="Konfidenz"
|
||||
value={`${Math.round(previewResult.confidence * 100)}%`}
|
||||
color={previewResult.has_handwriting ? '#f97316' : '#22c55e'}
|
||||
/>
|
||||
<ProgressRing
|
||||
progress={previewResult.handwriting_ratio * 100 * 10}
|
||||
label="Handschrift"
|
||||
value={`${(previewResult.handwriting_ratio * 100).toFixed(1)}%`}
|
||||
color="#a78bfa"
|
||||
/>
|
||||
</div>
|
||||
<div className={`mt-6 p-4 rounded-xl text-center ${
|
||||
previewResult.has_handwriting
|
||||
? 'bg-orange-500/20 text-orange-300'
|
||||
: 'bg-green-500/20 text-green-300'
|
||||
}`}>
|
||||
{previewResult.has_handwriting
|
||||
? 'Handschrift erkannt'
|
||||
: 'Keine Handschrift gefunden'}
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* Time Estimates */}
|
||||
<GlassCard delay={200}>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Geschätzte Zeit</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between text-white/70">
|
||||
<span>Erkennung</span>
|
||||
<span className="text-white">~{Math.round(previewResult.estimated_times_ms.detection / 1000)}s</span>
|
||||
</div>
|
||||
{removeHandwriting && previewResult.has_handwriting && (
|
||||
<div className="flex justify-between text-white/70">
|
||||
<span>Bereinigung</span>
|
||||
<span className="text-white">~{Math.round(previewResult.estimated_times_ms.inpainting / 1000)}s</span>
|
||||
</div>
|
||||
)}
|
||||
{reconstructLayout && (
|
||||
<div className="flex justify-between text-white/70">
|
||||
<span>Layout</span>
|
||||
<span className="text-white">~{Math.round(previewResult.estimated_times_ms.reconstruction / 1000)}s</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between pt-3 border-t border-white/10 font-medium">
|
||||
<span className="text-white">Gesamt</span>
|
||||
<span className="text-purple-300">~{Math.round(previewResult.estimated_times_ms.total / 1000)}s</span>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* Image Info */}
|
||||
<GlassCard delay={300}>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Bild-Info</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between text-white/70">
|
||||
<span>Breite</span>
|
||||
<span className="text-white">{previewResult.image_width}px</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-white/70">
|
||||
<span>Höhe</span>
|
||||
<span className="text-white">{previewResult.image_height}px</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-white/70">
|
||||
<span>Pixel</span>
|
||||
<span className="text-white">{(previewResult.image_width * previewResult.image_height / 1000000).toFixed(1)}MP</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleGetMask}
|
||||
className="w-full mt-4 px-4 py-2 rounded-xl bg-white/10 text-white/70 hover:bg-white/20 hover:text-white transition-all text-sm"
|
||||
>
|
||||
Maske anzeigen
|
||||
</button>
|
||||
</GlassCard>
|
||||
|
||||
{/* Preview Images */}
|
||||
<GlassCard className="col-span-1 lg:col-span-2" delay={400}>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Original</h3>
|
||||
{previewUrl && (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Original"
|
||||
className="w-full max-h-96 object-contain rounded-xl"
|
||||
/>
|
||||
)}
|
||||
</GlassCard>
|
||||
|
||||
{maskUrl && (
|
||||
<GlassCard delay={500}>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Maske</h3>
|
||||
<img
|
||||
src={maskUrl}
|
||||
alt="Mask"
|
||||
className="w-full max-h-96 object-contain rounded-xl"
|
||||
/>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="col-span-1 lg:col-span-3 flex justify-center gap-4">
|
||||
<button
|
||||
onClick={() => setCurrentStep('upload')}
|
||||
className="px-6 py-3 rounded-xl bg-white/10 text-white hover:bg-white/20 transition-all flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Zurück
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCleanup}
|
||||
disabled={isProcessing}
|
||||
className="px-8 py-3 rounded-xl font-semibold bg-gradient-to-r from-orange-500 to-red-500 text-white hover:shadow-lg hover:shadow-orange-500/30 transition-all disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
||||
</svg>
|
||||
Bereinigen starten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Processing */}
|
||||
{currentStep === 'processing' && (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<GlassCard className="text-center max-w-md" delay={0}>
|
||||
<div className="w-20 h-20 mx-auto mb-6 relative">
|
||||
<div className="absolute inset-0 rounded-full border-4 border-white/10"></div>
|
||||
<div className="absolute inset-0 rounded-full border-4 border-purple-500 border-t-transparent animate-spin"></div>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-white mb-2">Verarbeite Bild...</h3>
|
||||
<p className="text-white/50">
|
||||
{removeHandwriting ? 'Handschrift wird erkannt und entfernt' : 'Bild wird analysiert'}
|
||||
</p>
|
||||
</GlassCard>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: Result */}
|
||||
{currentStep === 'result' && pipelineResult && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Status */}
|
||||
<GlassCard className="col-span-1 lg:col-span-2" delay={100}>
|
||||
<div className={`flex items-center gap-4 ${
|
||||
pipelineResult.success ? 'text-green-300' : 'text-red-300'
|
||||
}`}>
|
||||
<div className={`w-12 h-12 rounded-full flex items-center justify-center ${
|
||||
pipelineResult.success ? 'bg-green-500/20' : 'bg-red-500/20'
|
||||
}`}>
|
||||
{pipelineResult.success ? (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold">
|
||||
{pipelineResult.success ? 'Erfolgreich bereinigt!' : 'Verarbeitung fehlgeschlagen'}
|
||||
</h3>
|
||||
<p className="text-white/50">
|
||||
{pipelineResult.handwriting_removed
|
||||
? `Handschrift wurde entfernt. ${pipelineResult.metadata?.layout?.element_count || 0} Elemente erkannt.`
|
||||
: pipelineResult.handwriting_detected
|
||||
? 'Handschrift erkannt, aber nicht entfernt'
|
||||
: 'Keine Handschrift im Bild gefunden'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* Original */}
|
||||
<GlassCard delay={200}>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Original</h3>
|
||||
{previewUrl && (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Original"
|
||||
className="w-full rounded-xl"
|
||||
/>
|
||||
)}
|
||||
</GlassCard>
|
||||
|
||||
{/* Cleaned */}
|
||||
<GlassCard delay={300}>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Bereinigt</h3>
|
||||
{cleanedUrl ? (
|
||||
<img
|
||||
src={cleanedUrl}
|
||||
alt="Cleaned"
|
||||
className="w-full rounded-xl"
|
||||
/>
|
||||
) : (
|
||||
<div className="aspect-video rounded-xl bg-white/5 flex items-center justify-center text-white/40">
|
||||
Kein Bild
|
||||
</div>
|
||||
)}
|
||||
</GlassCard>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="col-span-1 lg:col-span-2 flex justify-center gap-4">
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="px-6 py-3 rounded-xl bg-white/10 text-white hover:bg-white/20 transition-all flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Neues Bild
|
||||
</button>
|
||||
{cleanedUrl && (
|
||||
<a
|
||||
href={cleanedUrl}
|
||||
download="bereinigt.png"
|
||||
className="px-6 py-3 rounded-xl bg-white/10 text-white hover:bg-white/20 transition-all flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Download
|
||||
</a>
|
||||
)}
|
||||
{pipelineResult.layout_reconstructed && pipelineResult.fabric_json && (
|
||||
<button
|
||||
onClick={handleOpenInEditor}
|
||||
className="px-8 py-3 rounded-xl font-semibold bg-gradient-to-r from-green-500 to-emerald-500 text-white hover:shadow-lg hover:shadow-green-500/30 transition-all flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
Im Editor öffnen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* QR Code Modal */}
|
||||
{showQRModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setShowQRModal(false)} />
|
||||
<div className="relative w-full max-w-md rounded-3xl bg-slate-900">
|
||||
<QRCodeUpload
|
||||
sessionId={uploadSessionId}
|
||||
onClose={() => setShowQRModal(false)}
|
||||
onFilesChanged={(files) => {
|
||||
setMobileUploadedFiles(files)
|
||||
}}
|
||||
/>
|
||||
{/* Select button for mobile files */}
|
||||
{mobileUploadedFiles.length > 0 && (
|
||||
<div className="p-4 border-t border-white/10">
|
||||
<p className="text-white/60 text-sm mb-3">Datei auswählen:</p>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||
{mobileUploadedFiles.map((file) => (
|
||||
<button
|
||||
key={file.id}
|
||||
onClick={() => handleMobileFileSelect(file)}
|
||||
className="w-full flex items-center gap-3 p-3 rounded-xl text-left transition-all bg-white/5 hover:bg-white/10 border border-white/10"
|
||||
>
|
||||
<span className="text-xl">{file.type.startsWith('image/') ? '🖼️' : '📄'}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white font-medium truncate">{file.name}</p>
|
||||
<p className="text-white/50 text-xs">{formatFileSize(file.size)}</p>
|
||||
</div>
|
||||
<span className="text-purple-400 text-sm">Verwenden →</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
492
studio-v2/app/worksheet-editor/page.tsx
Normal file
492
studio-v2/app/worksheet-editor/page.tsx
Normal file
@@ -0,0 +1,492 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { useLanguage } from '@/lib/LanguageContext'
|
||||
import { WorksheetProvider, useWorksheet } from '@/lib/worksheet-editor/WorksheetContext'
|
||||
import { Sidebar } from '@/components/Sidebar'
|
||||
import { EditorToolbar } from '@/components/worksheet-editor/EditorToolbar'
|
||||
import { PropertiesPanel } from '@/components/worksheet-editor/PropertiesPanel'
|
||||
import { CanvasControls } from '@/components/worksheet-editor/CanvasControls'
|
||||
import { PageNavigator } from '@/components/worksheet-editor/PageNavigator'
|
||||
import { AIImageGenerator } from '@/components/worksheet-editor/AIImageGenerator'
|
||||
import { ExportPanel } from '@/components/worksheet-editor/ExportPanel'
|
||||
import { AIPromptBar } from '@/components/worksheet-editor/AIPromptBar'
|
||||
import { DocumentImporter } from '@/components/worksheet-editor/DocumentImporter'
|
||||
import { CleanupPanel } from '@/components/worksheet-editor/CleanupPanel'
|
||||
import { OCRImportPanel } from '@/components/worksheet-editor/OCRImportPanel'
|
||||
import { ThemeToggle } from '@/components/ThemeToggle'
|
||||
import { LanguageDropdown } from '@/components/LanguageDropdown'
|
||||
|
||||
// Dynamic import to prevent SSR issues with Fabric.js
|
||||
const FabricCanvas = dynamic(
|
||||
() => import('@/components/worksheet-editor/FabricCanvas').then(mod => mod.FabricCanvas),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-purple-500 border-t-transparent rounded-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
// Storage key for saved worksheets
|
||||
const WORKSHEETS_KEY = 'bp_worksheets'
|
||||
|
||||
interface SavedWorksheet {
|
||||
id: string
|
||||
title: string
|
||||
updatedAt: string
|
||||
thumbnail?: string
|
||||
}
|
||||
|
||||
function WorksheetEditorContent() {
|
||||
const { isDark } = useTheme()
|
||||
const { t } = useLanguage()
|
||||
const { document, setDocument, isDirty, setIsDirty, saveDocument, loadDocument, canvas } = useWorksheet()
|
||||
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [isAIGeneratorOpen, setIsAIGeneratorOpen] = useState(false)
|
||||
const [isExportPanelOpen, setIsExportPanelOpen] = useState(false)
|
||||
const [isDocumentImporterOpen, setIsDocumentImporterOpen] = useState(false)
|
||||
const [isCleanupPanelOpen, setIsCleanupPanelOpen] = useState(false)
|
||||
const [isOCRImportOpen, setIsOCRImportOpen] = useState(false)
|
||||
const [isDocumentListOpen, setIsDocumentListOpen] = useState(false)
|
||||
const [savedWorksheets, setSavedWorksheets] = useState<SavedWorksheet[]>([])
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [title, setTitle] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
loadSavedWorksheets()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (document) {
|
||||
setTitle(document.title)
|
||||
}
|
||||
}, [document])
|
||||
|
||||
// Load saved worksheets from localStorage
|
||||
const loadSavedWorksheets = useCallback(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(WORKSHEETS_KEY)
|
||||
if (stored) {
|
||||
setSavedWorksheets(JSON.parse(stored))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load worksheets:', e)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Save current worksheet
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!document) return
|
||||
setIsSaving(true)
|
||||
|
||||
try {
|
||||
// Save to context (which saves to API or localStorage)
|
||||
await saveDocument()
|
||||
|
||||
// Update worksheets list
|
||||
const worksheetEntry: SavedWorksheet = {
|
||||
id: document.id,
|
||||
title: document.title,
|
||||
updatedAt: new Date().toISOString(),
|
||||
thumbnail: canvas?.toDataURL({ format: 'png', multiplier: 0.1 })
|
||||
}
|
||||
|
||||
setSavedWorksheets(prev => {
|
||||
const filtered = prev.filter(w => w.id !== document.id)
|
||||
const updated = [worksheetEntry, ...filtered]
|
||||
localStorage.setItem(WORKSHEETS_KEY, JSON.stringify(updated))
|
||||
return updated
|
||||
})
|
||||
|
||||
setIsDirty(false)
|
||||
} catch (e) {
|
||||
console.error('Save failed:', e)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}, [document, saveDocument, canvas, setIsDirty])
|
||||
|
||||
// Load a saved worksheet
|
||||
const handleLoadWorksheet = useCallback(async (id: string) => {
|
||||
try {
|
||||
await loadDocument(id)
|
||||
setIsDocumentListOpen(false)
|
||||
} catch (e) {
|
||||
console.error('Failed to load worksheet:', e)
|
||||
}
|
||||
}, [loadDocument])
|
||||
|
||||
// Delete a saved worksheet
|
||||
const handleDeleteWorksheet = useCallback((id: string) => {
|
||||
setSavedWorksheets(prev => {
|
||||
const updated = prev.filter(w => w.id !== id)
|
||||
localStorage.setItem(WORKSHEETS_KEY, JSON.stringify(updated))
|
||||
localStorage.removeItem(`worksheet_${id}`)
|
||||
return updated
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Create new worksheet
|
||||
const handleNewWorksheet = useCallback(() => {
|
||||
const newDoc = {
|
||||
id: `ws_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
title: 'Neues Arbeitsblatt',
|
||||
pages: [{
|
||||
id: `page_${Date.now()}`,
|
||||
index: 0,
|
||||
canvasJSON: ''
|
||||
}],
|
||||
pageFormat: {
|
||||
width: 210,
|
||||
height: 297,
|
||||
orientation: 'portrait' as const,
|
||||
margins: { top: 15, right: 15, bottom: 15, left: 15 }
|
||||
},
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
setDocument(newDoc)
|
||||
setIsDocumentListOpen(false)
|
||||
if (canvas) {
|
||||
canvas.clear()
|
||||
canvas.backgroundColor = '#ffffff'
|
||||
canvas.renderAll()
|
||||
}
|
||||
}, [setDocument, canvas])
|
||||
|
||||
const handleTitleChange = (newTitle: string) => {
|
||||
setTitle(newTitle)
|
||||
if (document) {
|
||||
setDocument({
|
||||
...document,
|
||||
title: newTitle,
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className={`min-h-screen flex items-center justify-center ${
|
||||
isDark ? 'bg-slate-900' : 'bg-slate-100'
|
||||
}`}>
|
||||
<div className="animate-spin w-8 h-8 border-4 border-purple-500 border-t-transparent rounded-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen flex relative overflow-hidden ${
|
||||
isDark
|
||||
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
|
||||
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
|
||||
}`}>
|
||||
{/* Animated Background Blobs */}
|
||||
<div className={`absolute -top-40 -right-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob ${
|
||||
isDark ? 'bg-purple-500 opacity-30' : 'bg-purple-300 opacity-40'
|
||||
}`} />
|
||||
<div className={`absolute top-1/2 -left-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-2000 ${
|
||||
isDark ? 'bg-pink-500 opacity-30' : 'bg-pink-300 opacity-40'
|
||||
}`} />
|
||||
<div className={`absolute -bottom-40 right-1/3 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-4000 ${
|
||||
isDark ? 'bg-blue-500 opacity-30' : 'bg-blue-300 opacity-40'
|
||||
}`} />
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="relative z-10 p-4">
|
||||
<Sidebar />
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col relative z-10 p-4 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div>
|
||||
<h1 className={`text-2xl font-bold mb-1 ${isDark ? 'text-white' : 'text-slate-900'}`}>Arbeitsblatt-Editor</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => handleTitleChange(e.target.value)}
|
||||
placeholder="Arbeitsblatt-Titel..."
|
||||
className={`text-sm px-3 py-1.5 rounded-lg border transition-all w-56 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white placeholder-white/40 focus:border-purple-400'
|
||||
: 'bg-white/50 border-slate-300 text-slate-900 placeholder-slate-400 focus:border-purple-500'
|
||||
}`}
|
||||
/>
|
||||
{isDirty && (
|
||||
<span className={`px-2 py-1 rounded-lg text-xs font-medium ${
|
||||
isDark ? 'bg-yellow-500/20 text-yellow-300' : 'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
Ungespeichert
|
||||
</span>
|
||||
)}
|
||||
{/* Save Button */}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !isDirty}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-all ${
|
||||
isDirty
|
||||
? 'bg-green-500 text-white hover:bg-green-600'
|
||||
: isDark
|
||||
? 'bg-white/10 text-white/40 cursor-not-allowed'
|
||||
: 'bg-slate-200 text-slate-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{isSaving ? (
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
|
||||
</svg>
|
||||
)}
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Document List Button */}
|
||||
<button
|
||||
onClick={() => setIsDocumentListOpen(true)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-xl font-medium transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white hover:bg-white/20'
|
||||
: 'bg-white text-slate-700 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Meine Arbeitsblätter
|
||||
</button>
|
||||
|
||||
{/* Export Button */}
|
||||
<button
|
||||
onClick={() => setIsExportPanelOpen(true)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-xl font-medium transition-all ${
|
||||
isDark
|
||||
? 'bg-purple-500/30 text-purple-300 hover:bg-purple-500/40'
|
||||
: 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Exportieren
|
||||
</button>
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<ThemeToggle />
|
||||
|
||||
{/* Language Dropdown */}
|
||||
<LanguageDropdown />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor Area - New Layout */}
|
||||
<div className="flex-1 flex gap-4 overflow-hidden">
|
||||
{/* Left Toolbar */}
|
||||
<div className="flex-shrink-0">
|
||||
<EditorToolbar
|
||||
onOpenAIGenerator={() => setIsAIGeneratorOpen(true)}
|
||||
onOpenDocumentImporter={() => setIsDocumentImporterOpen(true)}
|
||||
onOpenCleanupPanel={() => setIsCleanupPanelOpen(true)}
|
||||
onOpenOCRImport={() => setIsOCRImportOpen(true)}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Canvas Area - takes remaining space */}
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
{/* Canvas with fixed aspect ratio container */}
|
||||
<div className={`flex-1 overflow-auto rounded-xl ${
|
||||
isDark ? 'bg-slate-800/50' : 'bg-slate-200/50'
|
||||
}`}>
|
||||
<FabricCanvas className="h-full" />
|
||||
</div>
|
||||
|
||||
{/* Bottom Controls */}
|
||||
<div className="flex items-center justify-between gap-4 py-2">
|
||||
<PageNavigator />
|
||||
<CanvasControls />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Panel - AI Prompt + Properties */}
|
||||
<div className="w-80 flex-shrink-0 flex flex-col gap-4 overflow-hidden">
|
||||
{/* AI Prompt Bar */}
|
||||
<div className="flex-shrink-0">
|
||||
<AIPromptBar />
|
||||
</div>
|
||||
|
||||
{/* Properties Panel */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<PropertiesPanel className="h-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Document List Modal */}
|
||||
{isDocumentListOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setIsDocumentListOpen(false)} />
|
||||
<div className={`relative w-full max-w-2xl rounded-3xl p-6 ${
|
||||
isDark ? 'bg-slate-900/95' : 'bg-white/95'
|
||||
} backdrop-blur-xl border ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className={`text-xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Meine Arbeitsblätter
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setIsDocumentListOpen(false)}
|
||||
className={`p-2 rounded-lg transition-colors ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'}`}
|
||||
>
|
||||
<svg className={`w-5 h-5 ${isDark ? 'text-white' : 'text-slate-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* New Worksheet Button */}
|
||||
<button
|
||||
onClick={handleNewWorksheet}
|
||||
className={`w-full mb-4 p-4 rounded-xl border-2 border-dashed transition-all flex items-center justify-center gap-2 ${
|
||||
isDark
|
||||
? 'border-white/20 text-white/60 hover:border-purple-400 hover:text-purple-300 hover:bg-purple-500/10'
|
||||
: 'border-slate-300 text-slate-500 hover:border-purple-500 hover:text-purple-600 hover:bg-purple-50'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Neues Arbeitsblatt erstellen
|
||||
</button>
|
||||
|
||||
{/* Worksheets List */}
|
||||
<div className="max-h-96 overflow-y-auto space-y-2">
|
||||
{savedWorksheets.length === 0 ? (
|
||||
<div className={`text-center py-8 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
<svg className="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p>Noch keine Arbeitsblätter gespeichert</p>
|
||||
</div>
|
||||
) : (
|
||||
savedWorksheets.map((worksheet) => (
|
||||
<div
|
||||
key={worksheet.id}
|
||||
className={`flex items-center gap-4 p-4 rounded-xl transition-all cursor-pointer ${
|
||||
isDark
|
||||
? 'bg-white/5 hover:bg-white/10'
|
||||
: 'bg-slate-50 hover:bg-slate-100'
|
||||
} ${document?.id === worksheet.id ? (isDark ? 'ring-2 ring-purple-500' : 'ring-2 ring-purple-500') : ''}`}
|
||||
onClick={() => handleLoadWorksheet(worksheet.id)}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div className={`w-16 h-20 rounded-lg flex-shrink-0 overflow-hidden ${
|
||||
isDark ? 'bg-white/10' : 'bg-slate-200'
|
||||
}`}>
|
||||
{worksheet.thumbnail ? (
|
||||
<img src={worksheet.thumbnail} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<svg className={`w-6 h-6 ${isDark ? 'text-white/30' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className={`font-medium truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{worksheet.title}
|
||||
</h3>
|
||||
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
{new Date(worksheet.updatedAt).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</p>
|
||||
{document?.id === worksheet.id && (
|
||||
<span className="inline-block mt-1 px-2 py-0.5 rounded text-xs bg-purple-500/20 text-purple-400">
|
||||
Aktuell geöffnet
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete Button */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (confirm('Arbeitsblatt wirklich löschen?')) {
|
||||
handleDeleteWorksheet(worksheet.id)
|
||||
}
|
||||
}}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
isDark ? 'hover:bg-red-500/20 text-white/50 hover:text-red-400' : 'hover:bg-red-50 text-slate-400 hover:text-red-500'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
<AIImageGenerator
|
||||
isOpen={isAIGeneratorOpen}
|
||||
onClose={() => setIsAIGeneratorOpen(false)}
|
||||
/>
|
||||
|
||||
<ExportPanel
|
||||
isOpen={isExportPanelOpen}
|
||||
onClose={() => setIsExportPanelOpen(false)}
|
||||
/>
|
||||
|
||||
<DocumentImporter
|
||||
isOpen={isDocumentImporterOpen}
|
||||
onClose={() => setIsDocumentImporterOpen(false)}
|
||||
/>
|
||||
|
||||
<CleanupPanel
|
||||
isOpen={isCleanupPanelOpen}
|
||||
onClose={() => setIsCleanupPanelOpen(false)}
|
||||
/>
|
||||
|
||||
<OCRImportPanel
|
||||
isOpen={isOCRImportOpen}
|
||||
onClose={() => setIsOCRImportOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function WorksheetEditorPage() {
|
||||
return (
|
||||
<WorksheetProvider>
|
||||
<WorksheetEditorContent />
|
||||
</WorksheetProvider>
|
||||
)
|
||||
}
|
||||
237
studio-v2/app/worksheet-editor/types.ts
Normal file
237
studio-v2/app/worksheet-editor/types.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user