[split-required] Split 500-1000 LOC files across all services

backend-lehrer (5 files):
- alerts_agent/db/repository.py (992 → 5), abitur_docs_api.py (956 → 3)
- teacher_dashboard_api.py (951 → 3), services/pdf_service.py (916 → 3)
- mail/mail_db.py (987 → 6)

klausur-service (5 files):
- legal_templates_ingestion.py (942 → 3), ocr_pipeline_postprocess.py (929 → 4)
- ocr_pipeline_words.py (876 → 3), ocr_pipeline_ocr_merge.py (616 → 2)
- KorrekturPage.tsx (956 → 6)

website (5 pages):
- mail (985 → 9), edu-search (958 → 8), mac-mini (950 → 7)
- ocr-labeling (946 → 7), audit-workspace (871 → 4)

studio-v2 (5 files + 1 deleted):
- page.tsx (946 → 5), MessagesContext.tsx (925 → 4)
- korrektur (914 → 6), worksheet-cleanup (899 → 6)
- useVocabWorksheet.ts (888 → 3)
- Deleted dead page-original.tsx (934 LOC)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-24 23:35:37 +02:00
parent 6811264756
commit b6983ab1dc
99 changed files with 13484 additions and 16106 deletions

View File

@@ -0,0 +1,41 @@
'use client'
interface BackgroundBlobsProps {
isDark: boolean
}
export function BackgroundBlobs({ isDark }: BackgroundBlobsProps) {
return (
<>
<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>
<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>
</>
)
}

View File

@@ -0,0 +1,385 @@
'use client'
import { useRouter } from 'next/navigation'
import { useLanguage } from '@/lib/LanguageContext'
import { useTheme } from '@/lib/ThemeContext'
import { useAlerts } from '@/lib/AlertsContext'
import { useMessages, formatMessageTime, getContactInitials } from '@/lib/MessagesContext'
import { useActivity, formatDurationCompact } from '@/lib/ActivityContext'
interface StatsItem {
labelKey: string
value: string
icon: string
color: string
}
interface RecentKlausur {
id: number
title: string
students: number
completed: number
statusKey: string
}
interface DashboardContentProps {
documents: { id: string }[]
setShowUploadModal: (show: boolean) => void
setSelectedTab: (tab: string) => void
}
export function DashboardContent({ documents, setShowUploadModal, setSelectedTab }: DashboardContentProps) {
const router = useRouter()
const { t } = useLanguage()
const { isDark } = useTheme()
const { alerts, unreadCount: alertsUnreadCount, markAsRead } = useAlerts()
const { conversations, unreadCount: messagesUnreadCount, contacts, markAsRead: markMessageAsRead } = useMessages()
const { stats: activityStats } = useActivity()
const timeSaved = formatDurationCompact(activityStats.weekSavedSeconds)
const timeSavedDisplay = activityStats.weekSavedSeconds > 0
? `${timeSaved.value}${timeSaved.unit}`
: '0min'
const stats: StatsItem[] = [
{ 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: RecentKlausur[] = [
{ 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 (
<>
{/* 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>
{/* Dashboard Grid */}
<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>
{alertsUnreadCount > 0 && (
<span className="text-xs px-2 py-1 rounded-full bg-amber-500/20 text-amber-500 font-medium">
{alertsUnreadCount} neu
</span>
)}
</div>
<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>
<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>
<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">
<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>
<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>
</>
)
}

View File

@@ -0,0 +1,91 @@
'use client'
import { useTheme } from '@/lib/ThemeContext'
import { DocumentSpace } from '@/components/DocumentSpace'
interface StoredDocument {
id: string
name: string
type: string
size: number
uploadedAt: Date
url?: string
}
interface DocumentsTabProps {
documents: StoredDocument[]
onDelete: (id: string) => void
onRename: (id: string, newName: string) => void
setShowUploadModal: (show: boolean) => void
setShowQRModal: (show: boolean) => void
}
export function DocumentsTab({ documents, onDelete, onRename, setShowUploadModal, setShowQRModal }: DocumentsTabProps) {
const { isDark } = useTheme()
return (
<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={onDelete}
onRename={onRename}
onOpen={(doc) => doc.url && window.open(doc.url, '_blank')}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,150 @@
'use client'
import { useRouter } from 'next/navigation'
import { useLanguage } from '@/lib/LanguageContext'
import { useTheme } from '@/lib/ThemeContext'
import { useAlerts, getImportanceColor, getRelativeTime } from '@/lib/AlertsContext'
import { LanguageDropdown } from '@/components/LanguageDropdown'
import { ThemeToggle } from '@/components/ThemeToggle'
interface HeaderBarProps {
showAlertsDropdown: boolean
setShowAlertsDropdown: (show: boolean) => void
}
export function HeaderBar({ showAlertsDropdown, setShowAlertsDropdown }: HeaderBarProps) {
const router = useRouter()
const { t } = useLanguage()
const { isDark } = useTheme()
const { alerts, unreadCount, markAsRead } = useAlerts()
return (
<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>
<LanguageDropdown />
<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>
)
}

View File

@@ -0,0 +1,102 @@
'use client'
import { useTheme } from '@/lib/ThemeContext'
import { DocumentUpload } from '@/components/DocumentUpload'
import { QRCodeUpload } from '@/components/QRCodeUpload'
interface StoredDocument {
id: string
name: string
type: string
size: number
uploadedAt: Date
url?: string
}
interface UploadModalProps {
documents: StoredDocument[]
onUploadComplete: (docs: any[]) => void
onClose: () => void
onGoToDocuments: () => void
}
export function UploadModal({ documents, onUploadComplete, onClose, onGoToDocuments }: UploadModalProps) {
const { isDark } = useTheme()
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-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={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>
<DocumentUpload
onUploadComplete={(docs) => {
onUploadComplete(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={onClose}
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={onGoToDocuments}
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>
)
}
interface QRModalProps {
sessionId: string
onClose: () => void
}
export function QRModal({ sessionId, onClose }: QRModalProps) {
const { isDark } = useTheme()
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-md rounded-3xl ${
isDark ? 'bg-slate-900' : 'bg-white'
}`}>
<QRCodeUpload
sessionId={sessionId}
onClose={onClose}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,106 @@
'use client'
import { useState } from 'react'
import type { CreateKlausurData } from '../types'
import { GlassCard } from './GlassCard'
interface CreateKlausurModalProps {
isOpen: boolean
onClose: () => void
onSubmit: (data: CreateKlausurData) => void
isLoading: boolean
isDark?: boolean
}
export 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>
)
}

View File

@@ -0,0 +1,49 @@
'use client'
import { useState, useEffect } from 'react'
interface GlassCardProps {
children: React.ReactNode
className?: string
onClick?: () => void
size?: 'sm' | 'md' | 'lg'
delay?: number
isDark?: boolean
}
export function GlassCard({ children, className = '', onClick, size = 'md', delay = 0, isDark = true }: GlassCardProps) {
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>
)
}

View File

@@ -0,0 +1,60 @@
'use client'
import type { Klausur } from '../types'
import { GlassCard } from './GlassCard'
interface KlausurCardProps {
klausur: Klausur
onClick: () => void
delay?: number
isDark?: boolean
}
export 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>
)
}

View File

@@ -0,0 +1,31 @@
'use client'
import { GlassCard } from './GlassCard'
interface StatCardProps {
label: string
value: string | number
icon: React.ReactNode
color?: string
delay?: number
isDark?: boolean
}
export 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>
)
}

View File

@@ -0,0 +1,199 @@
'use client'
import { QRCodeUpload, UploadedFile } from '@/components/QRCodeUpload'
import { GlassCard } from './GlassCard'
// =============================================================================
// Direct Upload Modal
// =============================================================================
interface DirectUploadModalProps {
isDark: boolean
isDragging: boolean
uploadedFiles: File[]
isUploading: boolean
error: string | null
onDragOver: (e: React.DragEvent) => void
onDragLeave: (e: React.DragEvent) => void
onDrop: (e: React.DragEvent) => void
onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void
onRemoveFile: (idx: number) => void
onUpload: () => void
onClose: () => void
}
export function DirectUploadModal({
isDark, isDragging, uploadedFiles, isUploading, error,
onDragOver, onDragLeave, onDrop, onFileSelect, onRemoveFile, onUpload, onClose
}: DirectUploadModalProps) {
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-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 && (
<div className="mb-4 p-3 rounded-lg bg-red-500/20 border border-red-500/30 text-red-300 text-sm">{error}</div>
)}
<div
onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onDrop}
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={onFileSelect}
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>
{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={() => onRemoveFile(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>
)}
<div className="flex gap-3 mt-6">
<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 onClick={onUpload} 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
// =============================================================================
interface EHUploadModalProps {
isDark: boolean
isDragging: boolean
ehFile: File | null
isUploading: boolean
onDragOver: (e: React.DragEvent) => void
onDragLeave: (e: React.DragEvent) => void
onDrop: (e: React.DragEvent) => void
onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void
onRemoveFile: () => void
onUpload: () => void
onClose: () => void
}
export function EHUploadModal({
isDark, isDragging, ehFile, isUploading,
onDragOver, onDragLeave, onDrop, onFileSelect, onRemoveFile, onUpload, onClose
}: EHUploadModalProps) {
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-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>
<div
onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onDrop}
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={onFileSelect}
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>
{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={onRemoveFile} 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 className="flex gap-3 mt-6">
<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 onClick={onUpload} 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>
)
}
// =============================================================================
// QR Code Modal
// =============================================================================
interface QRCodeModalProps {
isDark: boolean
sessionId: string
onClose: () => void
onFileUploaded?: (file: UploadedFile) => void
}
export function QRCodeModal({ isDark, sessionId, onClose, onFileUploaded }: QRCodeModalProps) {
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-md rounded-3xl ${isDark ? 'bg-slate-900' : 'bg-white'}`}>
<QRCodeUpload sessionId={sessionId} onClose={onClose} onFileUploaded={onFileUploaded} />
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,934 +0,0 @@
'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>
)
}

View File

@@ -1,23 +1,18 @@
'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'
import { Footer } from '@/components/Footer'
import { BackgroundBlobs } from './_components/BackgroundBlobs'
import { HeaderBar } from './_components/HeaderBar'
import { DashboardContent } from './_components/DashboardContent'
import { DocumentsTab } from './_components/DocumentsTab'
import { UploadModal, QRModal } from './_components/UploadModals'
// LocalStorage Keys
const ONBOARDING_KEY = 'bp_onboarding_complete'
@@ -26,8 +21,6 @@ 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
@@ -38,7 +31,6 @@ interface StoredDocument {
}
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)
@@ -48,11 +40,7 @@ export default function HomePage() {
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) => {
@@ -62,23 +50,14 @@ export default function HomePage() {
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
id: u.id, name: u.name, type: u.type, size: u.size,
uploadedAt: new Date(u.uploadedAt), url: u.dataUrl
}))
// 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
return newDocs.length > 0 ? [...prev, ...newDocs] : prev
})
}
}
@@ -95,7 +74,6 @@ export default function HomePage() {
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)
@@ -105,33 +83,21 @@ export default function HomePage() {
if (onboardingComplete === 'true' && storedUserData) {
setUserData(JSON.parse(storedUserData))
setShowOnboarding(false)
// Dokumente laden
if (storedDocs) {
setDocuments(JSON.parse(storedDocs))
}
// Erster Dashboard-Besuch nach Onboarding?
if (storedDocs) setDocuments(JSON.parse(storedDocs))
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)
// Polling fuer neue Uploads
useEffect(() => {
if (!sessionId || showOnboarding) return
const interval = setInterval(() => {
fetchUploadsFromAPI(sessionId)
}, 3000)
const interval = setInterval(() => fetchUploadsFromAPI(sessionId), 3000)
return () => clearInterval(interval)
}, [sessionId, showOnboarding, fetchUploadsFromAPI])
@@ -142,24 +108,17 @@ export default function HomePage() {
}
}, [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
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) {
@@ -167,12 +126,10 @@ export default function HomePage() {
}
}
// 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))
@@ -180,7 +137,7 @@ export default function HomePage() {
setShowOnboarding(false)
}
// Zeige Ladebildschirm während der Prüfung
// Loading screen
if (showOnboarding === null) {
return (
<div className={`min-h-screen flex items-center justify-center ${
@@ -190,200 +147,31 @@ export default function HomePage() {
}`}>
<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>
<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>
<BackgroundBlobs isDark={isDark} />
<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>
<HeaderBar showAlertsDropdown={showAlertsDropdown} setShowAlertsDropdown={setShowAlertsDropdown} />
{/* 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 */}
{/* Welcome message for first visit */}
{isFirstVisit && documents.length === 0 && (
<div className={`mb-8 p-6 rounded-3xl border backdrop-blur-xl ${
isDark
@@ -393,9 +181,7 @@ export default function HomePage() {
<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>
<div className="flex-1">
<h2 className={`text-xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Willkommen bei BreakPilot Studio!
@@ -406,28 +192,20 @@ export default function HomePage() {
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"
>
<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)}
<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'
}`}
>
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'}`}
>
<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>
@@ -436,511 +214,38 @@ export default function HomePage() {
</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>
<DocumentsTab
documents={documents}
onDelete={handleDeleteDocument}
onRename={handleRenameDocument}
setShowUploadModal={setShowUploadModal}
setShowQRModal={setShowQRModal}
/>
) : (
/* 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>
<DashboardContent
documents={documents}
setShowUploadModal={setShowUploadModal}
setSelectedTab={setSelectedTab}
/>
)}
</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>
<UploadModal
documents={documents}
onUploadComplete={handleUploadComplete}
onClose={() => setShowUploadModal(false)}
onGoToDocuments={() => { setShowUploadModal(false); setSelectedTab('dokumente') }}
/>
)}
{showQRModal && <QRModal sessionId={sessionId} onClose={() => setShowQRModal(false)} />}
{/* 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 */}
<ChatOverlay typewriterEnabled={true} typewriterSpeed={25} autoDismissMs={0} maxQueue={5} />
<Footer />
{/* Blob Animation Styles */}
<style jsx>{`
@keyframes blob {
0% { transform: translate(0px, 0px) scale(1); }
33% { transform: translate(30px, -50px) scale(1.1); }
66% { transform: translate(-20px, 20px) scale(0.9); }
100% { transform: translate(0px, 0px) scale(1); }
}
.animate-blob {
animation: blob 7s infinite;
}
.animation-delay-2000 {
animation-delay: 2s;
}
.animation-delay-4000 {
animation-delay: 4s;
}
`}</style>
</div>
)
}

View File

@@ -0,0 +1,97 @@
import type { VocabularyEntry, OcrPrompts, IpaMode, SyllableMode } from './types'
import { getApiBase } from './constants'
/**
* Process a single page and return vocabulary + optional scan quality info.
*/
export async function processSinglePage(
sessionId: string,
pageIndex: number,
ipa: IpaMode,
syllable: SyllableMode,
ocrPrompts: OcrPrompts,
ocrEnhance: boolean,
ocrMaxCols: number,
ocrMinConf: number,
): Promise<{ success: boolean; vocabulary: VocabularyEntry[]; error?: string; scanQuality?: any }> {
const API_BASE = getApiBase()
try {
const params = new URLSearchParams({
ipa_mode: ipa,
syllable_mode: syllable,
enhance: String(ocrEnhance),
max_cols: String(ocrMaxCols),
min_conf: String(ocrMinConf),
})
const res = await fetch(`${API_BASE}/api/v1/vocab/sessions/${sessionId}/process-single-page/${pageIndex}?${params}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ocr_prompts: ocrPrompts }),
})
if (!res.ok) {
const errBody = await res.json().catch(() => ({}))
const detail = errBody.detail || `HTTP ${res.status}`
return { success: false, vocabulary: [], error: `Seite ${pageIndex + 1}: ${detail}` }
}
const data = await res.json()
if (!data.success) {
return { success: false, vocabulary: [], error: data.error || `Seite ${pageIndex + 1}: Unbekannter Fehler` }
}
return { success: true, vocabulary: data.vocabulary || [], scanQuality: data.scan_quality }
} catch (e) {
return { success: false, vocabulary: [], error: `Seite ${pageIndex + 1}: ${e instanceof Error ? e.message : 'Netzwerkfehler'}` }
}
}
/**
* Reprocess pages with updated IPA/syllable settings.
* Returns the new vocabulary array.
*/
export async function reprocessPagesFlow(
sessionId: string,
pagesToReprocess: number[],
ipa: IpaMode,
syllable: SyllableMode,
ocrPrompts: OcrPrompts,
ocrEnhance: boolean,
ocrMaxCols: number,
ocrMinConf: number,
setExtractionStatus: (s: string) => void,
): Promise<{ vocabulary: VocabularyEntry[]; qualityInfo: string }> {
const API_BASE = getApiBase()
const allVocab: VocabularyEntry[] = []
let lastQuality: any = null
for (const pageIndex of pagesToReprocess) {
setExtractionStatus(`Verarbeite Seite ${pageIndex + 1}...`)
try {
const params = new URLSearchParams({
ipa_mode: ipa,
syllable_mode: syllable,
enhance: String(ocrEnhance),
max_cols: String(ocrMaxCols),
min_conf: String(ocrMinConf),
})
const res = await fetch(`${API_BASE}/api/v1/vocab/sessions/${sessionId}/process-single-page/${pageIndex}?${params}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ocr_prompts: ocrPrompts }),
})
if (res.ok) {
const data = await res.json()
if (data.vocabulary) allVocab.push(...data.vocabulary)
if (data.scan_quality) lastQuality = data.scan_quality
}
} catch { /* ignore individual page failures */ }
}
const qualityInfo = lastQuality
? ` | Qualitaet: ${lastQuality.quality_pct}%${lastQuality.is_degraded ? ' (degradiert!)' : ''} | Blur: ${lastQuality.blur_score} | Kontrast: ${lastQuality.contrast_score}`
: ''
return { vocabulary: allVocab, qualityInfo }
}

View File

@@ -0,0 +1,156 @@
import type {
VocabularyEntry, Session, StoredDocument, OcrPrompts, IpaMode, SyllableMode,
} from './types'
import { getApiBase } from './constants'
/**
* Start a new session: create on server, upload document, process first page or PDF.
*/
export async function startSessionFlow(params: {
sessionName: string
selectedDocumentId: string | null
directFile: File | null
selectedMobileFile: { dataUrl: string; type: string; name: string } | null
storedDocuments: StoredDocument[]
ocrPrompts: OcrPrompts
startActivity: (type: string, meta: any) => void
setSession: (s: Session | null | ((prev: Session | null) => Session | null)) => void
setWorksheetTitle: (t: string) => void
setExtractionStatus: (s: string) => void
setPdfPageCount: (n: number) => void
setSelectedPages: (p: number[]) => void
setPagesThumbnails: (t: string[]) => void
setIsLoadingThumbnails: (l: boolean) => void
setVocabulary: (v: VocabularyEntry[]) => void
setActiveTab: (t: 'upload' | 'pages' | 'vocabulary' | 'spreadsheet' | 'worksheet' | 'export' | 'settings') => void
setError: (e: string | null) => void
}): Promise<Session | null> {
const {
sessionName, selectedDocumentId, directFile, selectedMobileFile, storedDocuments,
ocrPrompts, startActivity, setSession, setWorksheetTitle, setExtractionStatus,
setPdfPageCount, setSelectedPages, setPagesThumbnails, setIsLoadingThumbnails,
setVocabulary, setActiveTab, setError,
} = params
setError(null)
setExtractionStatus('Session wird erstellt...')
const API_BASE = getApiBase()
const sessionRes = await fetch(`${API_BASE}/api/v1/vocab/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: sessionName, ocr_prompts: ocrPrompts }),
})
if (!sessionRes.ok) throw new Error('Session konnte nicht erstellt werden')
const sessionData = await sessionRes.json()
setSession(sessionData)
setWorksheetTitle(sessionName)
startActivity('vocab_extraction', { description: sessionName })
let file: File
let isPdf = false
if (directFile) {
file = directFile
isPdf = directFile.type === 'application/pdf'
} else if (selectedMobileFile) {
isPdf = selectedMobileFile.type === 'application/pdf'
const base64Data = selectedMobileFile.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 blob = new Blob([new Uint8Array(byteNumbers)], { type: selectedMobileFile.type })
file = new File([blob], selectedMobileFile.name, { type: selectedMobileFile.type })
} else {
const selectedDoc = storedDocuments.find(d => d.id === selectedDocumentId)
if (!selectedDoc || !selectedDoc.url) throw new Error('Das ausgewaehlte Dokument ist nicht verfuegbar.')
isPdf = selectedDoc.type === 'application/pdf'
const base64Data = selectedDoc.url.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 blob = new Blob([new Uint8Array(byteNumbers)], { type: selectedDoc.type })
file = new File([blob], selectedDoc.name, { type: selectedDoc.type })
}
if (isPdf) {
setExtractionStatus('PDF wird hochgeladen...')
const formData = new FormData()
formData.append('file', file)
const pdfInfoRes = await fetch(`${API_BASE}/api/v1/vocab/sessions/${sessionData.id}/upload-pdf-info`, {
method: 'POST', body: formData,
})
if (!pdfInfoRes.ok) throw new Error('PDF konnte nicht verarbeitet werden')
const pdfInfo = await pdfInfoRes.json()
setPdfPageCount(pdfInfo.page_count)
setSelectedPages(Array.from({ length: pdfInfo.page_count }, (_, i) => i))
setActiveTab('pages')
setExtractionStatus(`${pdfInfo.page_count} Seiten erkannt. Vorschau wird geladen...`)
setIsLoadingThumbnails(true)
const thumbnails: string[] = []
for (let i = 0; i < pdfInfo.page_count; i++) {
try {
const thumbRes = await fetch(`${API_BASE}/api/v1/vocab/sessions/${sessionData.id}/pdf-thumbnail/${i}?hires=true`)
if (thumbRes.ok) { const blob = await thumbRes.blob(); thumbnails.push(URL.createObjectURL(blob)) }
} catch (e) { console.error(`Failed to load thumbnail for page ${i}`) }
}
setPagesThumbnails(thumbnails)
setIsLoadingThumbnails(false)
setExtractionStatus(`${pdfInfo.page_count} Seiten bereit. Waehlen Sie die zu verarbeitenden Seiten.`)
} else {
setExtractionStatus('KI analysiert das Bild... (kann 30-60 Sekunden dauern)')
const formData = new FormData()
formData.append('file', file)
const uploadRes = await fetch(`${API_BASE}/api/v1/vocab/sessions/${sessionData.id}/upload`, {
method: 'POST', body: formData,
})
if (!uploadRes.ok) throw new Error('Bild konnte nicht verarbeitet werden')
const uploadData = await uploadRes.json()
setSession(prev => prev ? { ...prev, status: 'extracted', vocabulary_count: uploadData.vocabulary_count } : null)
const vocabRes = await fetch(`${API_BASE}/api/v1/vocab/sessions/${sessionData.id}/vocabulary`)
if (vocabRes.ok) {
const vocabData = await vocabRes.json()
setVocabulary(vocabData.vocabulary || [])
setExtractionStatus(`${vocabData.vocabulary?.length || 0} Vokabeln gefunden!`)
}
await new Promise(r => setTimeout(r, 1000))
setActiveTab('vocabulary')
}
return sessionData
}
/**
* Resume an existing session from the API.
*/
export async function resumeSessionFlow(
existingSession: Session,
setSession: (s: Session) => void,
setWorksheetTitle: (t: string) => void,
setVocabulary: (v: VocabularyEntry[]) => void,
setActiveTab: (t: 'upload' | 'pages' | 'vocabulary' | 'spreadsheet' | 'worksheet' | 'export' | 'settings') => void,
setExtractionStatus: (s: string) => void,
): Promise<void> {
const API_BASE = getApiBase()
const sessionRes = await fetch(`${API_BASE}/api/v1/vocab/sessions/${existingSession.id}`)
if (!sessionRes.ok) throw new Error('Session nicht gefunden')
const sessionData = await sessionRes.json()
setSession(sessionData)
setWorksheetTitle(sessionData.name)
if (sessionData.status === 'extracted' || sessionData.status === 'completed') {
const vocabRes = await fetch(`${API_BASE}/api/v1/vocab/sessions/${existingSession.id}/vocabulary`)
if (vocabRes.ok) { const vd = await vocabRes.json(); setVocabulary(vd.vocabulary || []) }
setActiveTab('vocabulary')
setExtractionStatus('')
} else if (sessionData.status === 'pending') {
setActiveTab('upload')
setExtractionStatus('Diese Session hat noch keine Vokabeln. Bitte laden Sie ein Dokument hoch.')
} else {
setActiveTab('vocabulary')
setExtractionStatus('')
}
}

View File

@@ -3,7 +3,6 @@
import { useState, useRef, useEffect } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { useLanguage } from '@/lib/LanguageContext'
import { useRouter } from 'next/navigation'
import { useActivity } from '@/lib/ActivityContext'
import type { UploadedFile } from '@/components/QRCodeUpload'
@@ -16,11 +15,12 @@ import {
getApiBase, DOCUMENTS_KEY, OCR_PROMPTS_KEY, SESSION_ID_KEY,
defaultOcrPrompts, formatFileSize,
} from './constants'
import { startSessionFlow, resumeSessionFlow } from './useSessionHandlers'
import { processSinglePage, reprocessPagesFlow } from './usePageProcessing'
export function useVocabWorksheet(): VocabWorksheetHook {
const { isDark } = useTheme()
const { t } = useLanguage()
const router = useRouter()
const { startActivity, completeActivity } = useActivity()
const [mounted, setMounted] = useState(false)
@@ -34,39 +34,39 @@ export function useVocabWorksheet(): VocabWorksheetHook {
const [error, setError] = useState<string | null>(null)
const [extractionStatus, setExtractionStatus] = useState<string>('')
// Existing sessions list
// Existing sessions
const [existingSessions, setExistingSessions] = useState<Session[]>([])
const [isLoadingSessions, setIsLoadingSessions] = useState(true)
// Documents from storage
// Documents
const [storedDocuments, setStoredDocuments] = useState<StoredDocument[]>([])
const [selectedDocumentId, setSelectedDocumentId] = useState<string | null>(null)
// Direct file upload
// Direct file
const [directFile, setDirectFile] = useState<File | null>(null)
const [directFilePreview, setDirectFilePreview] = useState<string | null>(null)
const [showFullPreview, setShowFullPreview] = useState(false)
const directFileInputRef = useRef<HTMLInputElement>(null)
// PDF page selection state
// PDF pages
const [pdfPageCount, setPdfPageCount] = useState<number>(0)
const [selectedPages, setSelectedPages] = useState<number[]>([])
const [pagesThumbnails, setPagesThumbnails] = useState<string[]>([])
const [isLoadingThumbnails, setIsLoadingThumbnails] = useState(false)
const [excludedPages, setExcludedPages] = useState<number[]>([])
// Dynamic extra columns per source page
// Extra columns
const [pageExtraColumns, setPageExtraColumns] = useState<Record<number, ExtraColumn[]>>({})
// Upload state
// Upload
const [uploadedImage, setUploadedImage] = useState<string | null>(null)
const [isExtracting, setIsExtracting] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
// Vocabulary state
// Vocabulary
const [vocabulary, setVocabulary] = useState<VocabularyEntry[]>([])
// Worksheet state
// Worksheet
const [selectedTypes, setSelectedTypes] = useState<WorksheetType[]>(['en_to_de'])
const [worksheetTitle, setWorksheetTitle] = useState('')
const [includeSolutions, setIncludeSolutions] = useState(true)
@@ -75,27 +75,25 @@ export function useVocabWorksheet(): VocabWorksheetHook {
const [ipaMode, setIpaMode] = useState<IpaMode>('none')
const [syllableMode, setSyllableMode] = useState<SyllableMode>('none')
// Export state
// Export
const [worksheetId, setWorksheetId] = useState<string | null>(null)
const [isGenerating, setIsGenerating] = useState(false)
// Processing results
// Processing
const [processingErrors, setProcessingErrors] = useState<string[]>([])
const [successfulPages, setSuccessfulPages] = useState<number[]>([])
const [failedPages, setFailedPages] = useState<number[]>([])
const [currentlyProcessingPage, setCurrentlyProcessingPage] = useState<number | null>(null)
const [processingQueue, setProcessingQueue] = useState<number[]>([])
// OCR Prompts/Settings
// OCR Settings
const [ocrPrompts, setOcrPrompts] = useState<OcrPrompts>(defaultOcrPrompts)
const [showSettings, setShowSettings] = useState(false)
const [ocrEnhance, setOcrEnhance] = useState(true)
const [ocrMaxCols, setOcrMaxCols] = useState(3)
const [ocrMinConf, setOcrMinConf] = useState(0)
// OCR Quality Steps (toggle individually for A/B testing)
const [ocrEnhance, setOcrEnhance] = useState(true) // Step 3: CLAHE + denoise
const [ocrMaxCols, setOcrMaxCols] = useState(3) // Step 2: max columns (0=unlimited)
const [ocrMinConf, setOcrMinConf] = useState(0) // Step 1: 0=auto from quality score
// QR Code Upload
// QR
const [showQRModal, setShowQRModal] = useState(false)
const [uploadSessionId, setUploadSessionId] = useState('')
const [mobileUploadedFiles, setMobileUploadedFiles] = useState<UploadedFile[]>([])
@@ -109,772 +107,260 @@ export function useVocabWorksheet(): VocabWorksheetHook {
const [ocrCompareError, setOcrCompareError] = useState<string | null>(null)
// --- Effects ---
// SSR Safety
useEffect(() => {
setMounted(true)
let storedSessionId = localStorage.getItem(SESSION_ID_KEY)
if (!storedSessionId) {
storedSessionId = `vocab-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
localStorage.setItem(SESSION_ID_KEY, storedSessionId)
}
setUploadSessionId(storedSessionId)
let sid = localStorage.getItem(SESSION_ID_KEY)
if (!sid) { sid = `vocab-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; localStorage.setItem(SESSION_ID_KEY, sid) }
setUploadSessionId(sid)
}, [])
// Load OCR prompts from localStorage
useEffect(() => {
if (!mounted) return
const stored = localStorage.getItem(OCR_PROMPTS_KEY)
if (stored) {
try {
setOcrPrompts({ ...defaultOcrPrompts, ...JSON.parse(stored) })
} catch (e) {
console.error('Failed to parse OCR prompts:', e)
}
}
if (stored) { try { setOcrPrompts({ ...defaultOcrPrompts, ...JSON.parse(stored) }) } catch {} }
}, [mounted])
// Load documents from localStorage
useEffect(() => {
if (!mounted) return
const stored = localStorage.getItem(DOCUMENTS_KEY)
if (stored) {
try {
const docs = JSON.parse(stored)
const imagesDocs = docs.filter((d: StoredDocument) =>
d.type?.startsWith('image/') || d.type === 'application/pdf'
)
setStoredDocuments(imagesDocs)
} catch (e) {
console.error('Failed to parse stored documents:', e)
}
try { setStoredDocuments(JSON.parse(stored).filter((d: StoredDocument) => d.type?.startsWith('image/') || d.type === 'application/pdf')) } catch {}
}
}, [mounted])
// Load existing sessions from API
useEffect(() => {
if (!mounted) return
const loadSessions = async () => {
const API_BASE = getApiBase()
;(async () => {
try {
const res = await fetch(`${API_BASE}/api/v1/vocab/sessions`)
if (res.ok) {
const sessions = await res.json()
setExistingSessions(sessions)
}
} catch (e) {
console.error('Failed to load sessions:', e)
} finally {
setIsLoadingSessions(false)
}
}
loadSessions()
const res = await fetch(`${getApiBase()}/api/v1/vocab/sessions`)
if (res.ok) setExistingSessions(await res.json())
} catch {} finally { setIsLoadingSessions(false) }
})()
}, [mounted])
// --- Glassmorphism styles ---
const glassCard = isDark
? 'backdrop-blur-xl bg-white/10 border border-white/20'
: 'backdrop-blur-xl bg-white/70 border border-black/10'
const glassInput = isDark
? 'bg-white/10 border-white/20 text-white placeholder-white/40 focus:border-purple-400'
: 'bg-white/50 border-black/10 text-slate-900 placeholder-slate-400 focus:border-purple-500'
const glassCard = isDark ? 'backdrop-blur-xl bg-white/10 border border-white/20' : 'backdrop-blur-xl bg-white/70 border border-black/10'
const glassInput = isDark ? 'bg-white/10 border-white/20 text-white placeholder-white/40 focus:border-purple-400' : 'bg-white/50 border-black/10 text-slate-900 placeholder-slate-400 focus:border-purple-500'
// --- Handlers ---
const saveOcrPrompts = (prompts: OcrPrompts) => {
setOcrPrompts(prompts)
localStorage.setItem(OCR_PROMPTS_KEY, JSON.stringify(prompts))
}
const saveOcrPrompts = (prompts: OcrPrompts) => { setOcrPrompts(prompts); localStorage.setItem(OCR_PROMPTS_KEY, JSON.stringify(prompts)) }
const handleDirectFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setDirectFile(file)
setSelectedDocumentId(null)
setSelectedMobileFile(null)
const file = e.target.files?.[0]; if (!file) return
setDirectFile(file); setSelectedDocumentId(null); setSelectedMobileFile(null)
if (file.type.startsWith('image/')) {
const reader = new FileReader()
reader.onload = (ev) => {
setDirectFilePreview(ev.target?.result as string)
}
reader.readAsDataURL(file)
} else if (file.type === 'application/pdf') {
setDirectFilePreview(URL.createObjectURL(file))
} else {
setDirectFilePreview(null)
}
const reader = new FileReader(); reader.onload = (ev) => setDirectFilePreview(ev.target?.result as string); reader.readAsDataURL(file)
} else if (file.type === 'application/pdf') { setDirectFilePreview(URL.createObjectURL(file)) }
else { setDirectFilePreview(null) }
}
const startSession = async () => {
if (!sessionName.trim()) {
setError('Bitte geben Sie einen Namen fuer die Session ein.')
return
}
if (!selectedDocumentId && !directFile && !selectedMobileFile) {
setError('Bitte waehlen Sie ein Dokument aus oder laden Sie eine Datei hoch.')
return
}
setError(null)
if (!sessionName.trim()) { setError('Bitte geben Sie einen Namen fuer die Session ein.'); return }
if (!selectedDocumentId && !directFile && !selectedMobileFile) { setError('Bitte waehlen Sie ein Dokument aus oder laden Sie eine Datei hoch.'); return }
setIsCreatingSession(true)
setExtractionStatus('Session wird erstellt...')
const API_BASE = getApiBase()
try {
const sessionRes = await fetch(`${API_BASE}/api/v1/vocab/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: sessionName,
ocr_prompts: ocrPrompts
}),
await startSessionFlow({
sessionName, selectedDocumentId, directFile, selectedMobileFile, storedDocuments,
ocrPrompts, startActivity, setSession, setWorksheetTitle, setExtractionStatus,
setPdfPageCount, setSelectedPages, setPagesThumbnails, setIsLoadingThumbnails,
setVocabulary, setActiveTab, setError,
})
if (!sessionRes.ok) {
throw new Error('Session konnte nicht erstellt werden')
}
const sessionData = await sessionRes.json()
setSession(sessionData)
setWorksheetTitle(sessionName)
startActivity('vocab_extraction', { description: sessionName })
let file: File
let isPdf = false
if (directFile) {
file = directFile
isPdf = directFile.type === 'application/pdf'
} else if (selectedMobileFile) {
isPdf = selectedMobileFile.type === 'application/pdf'
const base64Data = selectedMobileFile.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: selectedMobileFile.type })
file = new File([blob], selectedMobileFile.name, { type: selectedMobileFile.type })
} else {
const selectedDoc = storedDocuments.find(d => d.id === selectedDocumentId)
if (!selectedDoc || !selectedDoc.url) {
throw new Error('Das ausgewaehlte Dokument ist nicht verfuegbar.')
}
isPdf = selectedDoc.type === 'application/pdf'
const base64Data = selectedDoc.url.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: selectedDoc.type })
file = new File([blob], selectedDoc.name, { type: selectedDoc.type })
}
if (isPdf) {
setExtractionStatus('PDF wird hochgeladen...')
const formData = new FormData()
formData.append('file', file)
const pdfInfoRes = await fetch(`${API_BASE}/api/v1/vocab/sessions/${sessionData.id}/upload-pdf-info`, {
method: 'POST',
body: formData,
})
if (!pdfInfoRes.ok) {
throw new Error('PDF konnte nicht verarbeitet werden')
}
const pdfInfo = await pdfInfoRes.json()
setPdfPageCount(pdfInfo.page_count)
setSelectedPages(Array.from({ length: pdfInfo.page_count }, (_, i) => i))
setActiveTab('pages')
setExtractionStatus(`${pdfInfo.page_count} Seiten erkannt. Vorschau wird geladen...`)
setIsLoadingThumbnails(true)
const thumbnails: string[] = []
for (let i = 0; i < pdfInfo.page_count; i++) {
try {
const thumbRes = await fetch(`${API_BASE}/api/v1/vocab/sessions/${sessionData.id}/pdf-thumbnail/${i}?hires=true`)
if (thumbRes.ok) {
const blob = await thumbRes.blob()
thumbnails.push(URL.createObjectURL(blob))
}
} catch (e) {
console.error(`Failed to load thumbnail for page ${i}`)
}
}
setPagesThumbnails(thumbnails)
setIsLoadingThumbnails(false)
setExtractionStatus(`${pdfInfo.page_count} Seiten bereit. Waehlen Sie die zu verarbeitenden Seiten.`)
} else {
setExtractionStatus('KI analysiert das Bild... (kann 30-60 Sekunden dauern)')
const formData = new FormData()
formData.append('file', file)
const uploadRes = await fetch(`${API_BASE}/api/v1/vocab/sessions/${sessionData.id}/upload`, {
method: 'POST',
body: formData,
})
if (!uploadRes.ok) {
throw new Error('Bild konnte nicht verarbeitet werden')
}
const uploadData = await uploadRes.json()
setSession(prev => prev ? { ...prev, status: 'extracted', vocabulary_count: uploadData.vocabulary_count } : null)
const vocabRes = await fetch(`${API_BASE}/api/v1/vocab/sessions/${sessionData.id}/vocabulary`)
if (vocabRes.ok) {
const vocabData = await vocabRes.json()
setVocabulary(vocabData.vocabulary || [])
setExtractionStatus(`${vocabData.vocabulary?.length || 0} Vokabeln gefunden!`)
}
await new Promise(r => setTimeout(r, 1000))
setActiveTab('vocabulary')
}
} catch (error) {
console.error('Session start failed:', error)
setError(error instanceof Error ? error.message : 'Ein Fehler ist aufgetreten')
setExtractionStatus('')
setSession(null)
} finally {
setIsCreatingSession(false)
}
}
const processSinglePage = async (pageIndex: number, ipa: IpaMode, syllable: SyllableMode): Promise<{ success: boolean; vocabulary: VocabularyEntry[]; error?: string; scanQuality?: any }> => {
const API_BASE = getApiBase()
try {
const params = new URLSearchParams({
ipa_mode: ipa,
syllable_mode: syllable,
enhance: String(ocrEnhance),
max_cols: String(ocrMaxCols),
min_conf: String(ocrMinConf),
})
const res = await fetch(`${API_BASE}/api/v1/vocab/sessions/${session!.id}/process-single-page/${pageIndex}?${params}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ocr_prompts: ocrPrompts }),
})
if (!res.ok) {
const errBody = await res.json().catch(() => ({}))
const detail = errBody.detail || `HTTP ${res.status}`
return { success: false, vocabulary: [], error: `Seite ${pageIndex + 1}: ${detail}` }
}
const data = await res.json()
if (!data.success) {
return { success: false, vocabulary: [], error: data.error || `Seite ${pageIndex + 1}: Unbekannter Fehler` }
}
return { success: true, vocabulary: data.vocabulary || [], scanQuality: data.scan_quality }
} catch (e) {
return { success: false, vocabulary: [], error: `Seite ${pageIndex + 1}: ${e instanceof Error ? e.message : 'Netzwerkfehler'}` }
}
setExtractionStatus(''); setSession(null)
} finally { setIsCreatingSession(false) }
}
const processSelectedPages = async () => {
if (!session || selectedPages.length === 0) return
const pagesToProcess = [...selectedPages].sort((a, b) => a - b)
setIsExtracting(true); setProcessingErrors([]); setSuccessfulPages([]); setFailedPages([])
setProcessingQueue(pagesToProcess); setVocabulary([]); setActiveTab('vocabulary')
setIsExtracting(true)
setProcessingErrors([])
setSuccessfulPages([])
setFailedPages([])
setProcessingQueue(pagesToProcess)
setVocabulary([])
setActiveTab('vocabulary')
const API_BASE = getApiBase()
const errors: string[] = []
const successful: number[] = []
const failed: number[] = []
const errors: string[] = []; const successful: number[] = []; const failed: number[] = []
for (let i = 0; i < pagesToProcess.length; i++) {
const pageIndex = pagesToProcess[i]
setCurrentlyProcessingPage(pageIndex + 1)
setExtractionStatus(`Verarbeite Seite ${pageIndex + 1} von ${pagesToProcess.length}... (kann 30-60 Sekunden dauern)`)
const result = await processSinglePage(pageIndex, ipaMode, syllableMode)
const result = await processSinglePage(session.id, pageIndex, ipaMode, syllableMode, ocrPrompts, ocrEnhance, ocrMaxCols, ocrMinConf)
if (result.success) {
successful.push(pageIndex + 1)
setSuccessfulPages([...successful])
setVocabulary(prev => [...prev, ...result.vocabulary])
const qualityInfo = result.scanQuality
? ` | Qualitaet: ${result.scanQuality.quality_pct}%${result.scanQuality.is_degraded ? ' (degradiert!)' : ''}`
: ''
setExtractionStatus(`Seite ${pageIndex + 1} fertig: ${result.vocabulary.length} Vokabeln gefunden${qualityInfo}`)
successful.push(pageIndex + 1); setSuccessfulPages([...successful]); setVocabulary(prev => [...prev, ...result.vocabulary])
const qi = result.scanQuality ? ` | Qualitaet: ${result.scanQuality.quality_pct}%${result.scanQuality.is_degraded ? ' (degradiert!)' : ''}` : ''
setExtractionStatus(`Seite ${pageIndex + 1} fertig: ${result.vocabulary.length} Vokabeln gefunden${qi}`)
} else {
failed.push(pageIndex + 1)
setFailedPages([...failed])
if (result.error) {
errors.push(result.error)
setProcessingErrors([...errors])
}
failed.push(pageIndex + 1); setFailedPages([...failed])
if (result.error) { errors.push(result.error); setProcessingErrors([...errors]) }
setExtractionStatus(`Seite ${pageIndex + 1} fehlgeschlagen`)
}
await new Promise(r => setTimeout(r, 500))
}
setCurrentlyProcessingPage(null)
setProcessingQueue([])
setIsExtracting(false)
setCurrentlyProcessingPage(null); setProcessingQueue([]); setIsExtracting(false)
if (successful.length === pagesToProcess.length) {
setExtractionStatus(`Fertig! Alle ${successful.length} Seiten verarbeitet.`)
} else if (successful.length > 0) {
setExtractionStatus(`${successful.length} von ${pagesToProcess.length} Seiten verarbeitet. ${failed.length} fehlgeschlagen.`)
} else {
setExtractionStatus(`Alle Seiten fehlgeschlagen.`)
}
if (successful.length === pagesToProcess.length) setExtractionStatus(`Fertig! Alle ${successful.length} Seiten verarbeitet.`)
else if (successful.length > 0) setExtractionStatus(`${successful.length} von ${pagesToProcess.length} Seiten verarbeitet. ${failed.length} fehlgeschlagen.`)
else setExtractionStatus(`Alle Seiten fehlgeschlagen.`)
// Reload thumbnails for processed pages (server may have rotated them)
// Reload thumbnails for processed pages
if (successful.length > 0 && session) {
const updatedThumbs = [...pagesThumbnails]
const API_BASE = getApiBase(); const updatedThumbs = [...pagesThumbnails]
for (const pageNum of successful) {
const idx = pageNum - 1
try {
const thumbRes = await fetch(`${API_BASE}/api/v1/vocab/sessions/${session.id}/pdf-thumbnail/${idx}?hires=true&t=${Date.now()}`)
if (thumbRes.ok) {
const blob = await thumbRes.blob()
if (updatedThumbs[idx]) URL.revokeObjectURL(updatedThumbs[idx])
updatedThumbs[idx] = URL.createObjectURL(blob)
}
} catch (e) {
console.error(`Failed to refresh thumbnail for page ${pageNum}`)
}
const res = await fetch(`${API_BASE}/api/v1/vocab/sessions/${session.id}/pdf-thumbnail/${idx}?hires=true&t=${Date.now()}`)
if (res.ok) { if (updatedThumbs[idx]) URL.revokeObjectURL(updatedThumbs[idx]); updatedThumbs[idx] = URL.createObjectURL(await res.blob()) }
} catch {}
}
setPagesThumbnails(updatedThumbs)
}
setSession(prev => prev ? { ...prev, status: 'extracted' } : null)
}
const togglePageSelection = (pageIndex: number) => {
setSelectedPages(prev =>
prev.includes(pageIndex)
? prev.filter(p => p !== pageIndex)
: [...prev, pageIndex].sort((a, b) => a - b)
)
}
const selectAllPages = () => setSelectedPages(
Array.from({ length: pdfPageCount }, (_, i) => i).filter(p => !excludedPages.includes(p))
)
const togglePageSelection = (i: number) => { setSelectedPages(p => p.includes(i) ? p.filter(x => x !== i) : [...p, i].sort((a, b) => a - b)) }
const selectAllPages = () => setSelectedPages(Array.from({ length: pdfPageCount }, (_, i) => i).filter(p => !excludedPages.includes(p)))
const selectNoPages = () => setSelectedPages([])
const excludePage = (pageIndex: number, e: React.MouseEvent) => {
e.stopPropagation()
setExcludedPages(prev => [...prev, pageIndex])
setSelectedPages(prev => prev.filter(p => p !== pageIndex))
}
const restoreExcludedPages = () => {
setExcludedPages([])
}
const excludePage = (i: number, e: React.MouseEvent) => { e.stopPropagation(); setExcludedPages(p => [...p, i]); setSelectedPages(p => p.filter(x => x !== i)) }
const restoreExcludedPages = () => setExcludedPages([])
const runOcrComparison = async (pageIndex: number) => {
if (!session) return
setOcrComparePageIndex(pageIndex)
setShowOcrComparison(true)
setIsComparingOcr(true)
setOcrCompareError(null)
setOcrCompareResult(null)
const API_BASE = getApiBase()
setOcrComparePageIndex(pageIndex); setShowOcrComparison(true); setIsComparingOcr(true); setOcrCompareError(null); setOcrCompareResult(null)
try {
const res = await fetch(`${API_BASE}/api/v1/vocab/sessions/${session.id}/compare-ocr/${pageIndex}`, {
method: 'POST',
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
const data = await res.json()
setOcrCompareResult(data)
} catch (e) {
setOcrCompareError(e instanceof Error ? e.message : 'Vergleich fehlgeschlagen')
} finally {
setIsComparingOcr(false)
}
const res = await fetch(`${getApiBase()}/api/v1/vocab/sessions/${session.id}/compare-ocr/${pageIndex}`, { method: 'POST' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
setOcrCompareResult(await res.json())
} catch (e) { setOcrCompareError(e instanceof Error ? e.message : 'Vergleich fehlgeschlagen') }
finally { setIsComparingOcr(false) }
}
const updateVocabularyEntry = (id: string, field: string, value: string) => {
setVocabulary(prev => prev.map(v => {
if (v.id !== id) return v
if (field === 'english' || field === 'german' || field === 'example_sentence' || field === 'word_type') {
return { ...v, [field]: value }
}
if (field === 'english' || field === 'german' || field === 'example_sentence' || field === 'word_type') return { ...v, [field]: value }
return { ...v, extras: { ...(v.extras || {}), [field]: value } }
}))
}
const addExtraColumn = (sourcePage: number) => {
const label = prompt('Spaltenname:')
if (!label || !label.trim()) return
const label = prompt('Spaltenname:'); if (!label || !label.trim()) return
const key = `extra_${Date.now()}`
setPageExtraColumns(prev => ({
...prev,
[sourcePage]: [...(prev[sourcePage] || []), { key, label: label.trim() }],
}))
setPageExtraColumns(prev => ({ ...prev, [sourcePage]: [...(prev[sourcePage] || []), { key, label: label.trim() }] }))
}
const removeExtraColumn = (sourcePage: number, key: string) => {
setPageExtraColumns(prev => ({
...prev,
[sourcePage]: (prev[sourcePage] || []).filter(c => c.key !== key),
}))
setVocabulary(prev => prev.map(v => {
if (!v.extras || !(key in v.extras)) return v
const { [key]: _, ...rest } = v.extras
return { ...v, extras: rest }
}))
setPageExtraColumns(prev => ({ ...prev, [sourcePage]: (prev[sourcePage] || []).filter(c => c.key !== key) }))
setVocabulary(prev => prev.map(v => { if (!v.extras || !(key in v.extras)) return v; const { [key]: _, ...rest } = v.extras; return { ...v, extras: rest } }))
}
const getExtraColumnsForPage = (sourcePage: number): ExtraColumn[] => {
const global = pageExtraColumns[0] || []
const pageSpecific = pageExtraColumns[sourcePage] || []
return [...global, ...pageSpecific]
}
const getExtraColumnsForPage = (sourcePage: number): ExtraColumn[] => [...(pageExtraColumns[0] || []), ...(pageExtraColumns[sourcePage] || [])]
const getAllExtraColumns = (): ExtraColumn[] => {
const seen = new Set<string>()
const result: ExtraColumn[] = []
for (const cols of Object.values(pageExtraColumns)) {
for (const col of cols) {
if (!seen.has(col.key)) {
seen.add(col.key)
result.push(col)
}
}
}
const seen = new Set<string>(); const result: ExtraColumn[] = []
for (const cols of Object.values(pageExtraColumns)) for (const col of cols) { if (!seen.has(col.key)) { seen.add(col.key); result.push(col) } }
return result
}
const deleteVocabularyEntry = (id: string) => {
setVocabulary(prev => prev.filter(v => v.id !== id))
}
const toggleVocabularySelection = (id: string) => {
setVocabulary(prev => prev.map(v =>
v.id === id ? { ...v, selected: !v.selected } : v
))
}
const toggleAllSelection = () => {
const allSelected = vocabulary.every(v => v.selected)
setVocabulary(prev => prev.map(v => ({ ...v, selected: !allSelected })))
}
const deleteVocabularyEntry = (id: string) => setVocabulary(prev => prev.filter(v => v.id !== id))
const toggleVocabularySelection = (id: string) => setVocabulary(prev => prev.map(v => v.id === id ? { ...v, selected: !v.selected } : v))
const toggleAllSelection = () => { const all = vocabulary.every(v => v.selected); setVocabulary(prev => prev.map(v => ({ ...v, selected: !all }))) }
const addVocabularyEntry = (atIndex?: number) => {
const newEntry: VocabularyEntry = {
id: `new-${Date.now()}`,
english: '',
german: '',
example_sentence: '',
selected: true
}
setVocabulary(prev => {
if (atIndex === undefined) {
return [...prev, newEntry]
}
const newList = [...prev]
newList.splice(atIndex, 0, newEntry)
return newList
})
const ne: VocabularyEntry = { id: `new-${Date.now()}`, english: '', german: '', example_sentence: '', selected: true }
setVocabulary(prev => { if (atIndex === undefined) return [...prev, ne]; const nl = [...prev]; nl.splice(atIndex, 0, ne); return nl })
}
const saveVocabulary = async () => {
if (!session) return
const API_BASE = getApiBase()
try {
await fetch(`${API_BASE}/api/v1/vocab/sessions/${session.id}/vocabulary`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ vocabulary }),
})
} catch (error) {
console.error('Failed to save vocabulary:', error)
}
try { await fetch(`${getApiBase()}/api/v1/vocab/sessions/${session.id}/vocabulary`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ vocabulary }) }) }
catch (e) { console.error('Failed to save vocabulary:', e) }
}
const generateWorksheet = async () => {
if (!session) return
if (selectedFormat === 'standard' && selectedTypes.length === 0) return
if (!session) return; if (selectedFormat === 'standard' && selectedTypes.length === 0) return
setIsGenerating(true)
const API_BASE = getApiBase()
try {
await saveVocabulary()
let res: Response
if (selectedFormat === 'nru') {
res = await fetch(`${API_BASE}/api/v1/vocab/sessions/${session.id}/generate-nru`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: worksheetTitle || session.name,
include_solutions: includeSolutions,
}),
})
} else {
res = await fetch(`${API_BASE}/api/v1/vocab/sessions/${session.id}/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
worksheet_types: selectedTypes,
title: worksheetTitle || session.name,
include_solutions: includeSolutions,
line_height: lineHeight,
}),
})
}
if (res.ok) {
const data = await res.json()
setWorksheetId(data.worksheet_id || data.id)
setActiveTab('export')
completeActivity({ vocabCount: vocabulary.length })
}
} catch (error) {
console.error('Failed to generate worksheet:', error)
} finally {
setIsGenerating(false)
}
const API_BASE = getApiBase()
const endpoint = selectedFormat === 'nru' ? 'generate-nru' : 'generate'
const body = selectedFormat === 'nru'
? { title: worksheetTitle || session.name, include_solutions: includeSolutions }
: { worksheet_types: selectedTypes, title: worksheetTitle || session.name, include_solutions: includeSolutions, line_height: lineHeight }
const res = await fetch(`${API_BASE}/api/v1/vocab/sessions/${session.id}/${endpoint}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
})
if (res.ok) { const data = await res.json(); setWorksheetId(data.worksheet_id || data.id); setActiveTab('export'); completeActivity({ vocabCount: vocabulary.length }) }
} catch (e) { console.error('Failed to generate worksheet:', e) }
finally { setIsGenerating(false) }
}
const downloadPDF = (type: 'worksheet' | 'solution') => {
if (!worksheetId) return
const API_BASE = getApiBase()
const endpoint = type === 'worksheet' ? 'pdf' : 'solution'
window.open(`${API_BASE}/api/v1/vocab/worksheets/${worksheetId}/${endpoint}`, '_blank')
window.open(`${getApiBase()}/api/v1/vocab/worksheets/${worksheetId}/${type === 'worksheet' ? 'pdf' : 'solution'}`, '_blank')
}
const toggleWorksheetType = (type: WorksheetType) => {
setSelectedTypes(prev =>
prev.includes(type) ? prev.filter(t => t !== type) : [...prev, type]
)
}
const toggleWorksheetType = (type: WorksheetType) => setSelectedTypes(prev => prev.includes(type) ? prev.filter(t => t !== type) : [...prev, type])
const resumeSession = async (existingSession: Session) => {
setError(null)
setExtractionStatus('Session wird geladen...')
const API_BASE = getApiBase()
try {
const sessionRes = await fetch(`${API_BASE}/api/v1/vocab/sessions/${existingSession.id}`)
if (!sessionRes.ok) throw new Error('Session nicht gefunden')
const sessionData = await sessionRes.json()
setSession(sessionData)
setWorksheetTitle(sessionData.name)
if (sessionData.status === 'extracted' || sessionData.status === 'completed') {
const vocabRes = await fetch(`${API_BASE}/api/v1/vocab/sessions/${existingSession.id}/vocabulary`)
if (vocabRes.ok) {
const vocabData = await vocabRes.json()
setVocabulary(vocabData.vocabulary || [])
}
setActiveTab('vocabulary')
setExtractionStatus('')
} else if (sessionData.status === 'pending') {
setActiveTab('upload')
setExtractionStatus('Diese Session hat noch keine Vokabeln. Bitte laden Sie ein Dokument hoch.')
} else {
setActiveTab('vocabulary')
setExtractionStatus('')
}
} catch (error) {
console.error('Failed to resume session:', error)
setError(error instanceof Error ? error.message : 'Fehler beim Laden der Session')
setExtractionStatus('')
}
setError(null); setExtractionStatus('Session wird geladen...')
try { await resumeSessionFlow(existingSession, setSession, setWorksheetTitle, setVocabulary, setActiveTab, setExtractionStatus) }
catch (e) { setError(e instanceof Error ? e.message : 'Fehler beim Laden der Session'); setExtractionStatus('') }
}
const resetSession = async () => {
setSession(null)
setSessionName('')
setVocabulary([])
setUploadedImage(null)
setWorksheetId(null)
setSelectedDocumentId(null)
setDirectFile(null)
setDirectFilePreview(null)
setShowFullPreview(false)
setPdfPageCount(0)
setSelectedPages([])
setPagesThumbnails([])
setExcludedPages([])
setActiveTab('upload')
setError(null)
setExtractionStatus('')
const API_BASE = getApiBase()
try {
const res = await fetch(`${API_BASE}/api/v1/vocab/sessions`)
if (res.ok) {
const sessions = await res.json()
setExistingSessions(sessions)
}
} catch (e) {
console.error('Failed to reload sessions:', e)
}
setSession(null); setSessionName(''); setVocabulary([]); setUploadedImage(null); setWorksheetId(null)
setSelectedDocumentId(null); setDirectFile(null); setDirectFilePreview(null); setShowFullPreview(false)
setPdfPageCount(0); setSelectedPages([]); setPagesThumbnails([]); setExcludedPages([])
setActiveTab('upload'); setError(null); setExtractionStatus('')
try { const res = await fetch(`${getApiBase()}/api/v1/vocab/sessions`); if (res.ok) setExistingSessions(await res.json()) } catch {}
}
const deleteSession = async (sessionId: string, e: React.MouseEvent) => {
e.stopPropagation()
if (!confirm('Session wirklich loeschen? Diese Aktion kann nicht rueckgaengig gemacht werden.')) {
return
}
const API_BASE = getApiBase()
try {
const res = await fetch(`${API_BASE}/api/v1/vocab/sessions/${sessionId}`, {
method: 'DELETE',
})
if (res.ok) {
setExistingSessions(prev => prev.filter(s => s.id !== sessionId))
}
} catch (e) {
console.error('Failed to delete session:', e)
}
if (!confirm('Session wirklich loeschen? Diese Aktion kann nicht rueckgaengig gemacht werden.')) return
try { const res = await fetch(`${getApiBase()}/api/v1/vocab/sessions/${sessionId}`, { method: 'DELETE' }); if (res.ok) setExistingSessions(prev => prev.filter(s => s.id !== sessionId)) } catch {}
}
// Reprocess all successful pages with new IPA/syllable modes
const reprocessPages = (ipa: IpaMode, syllable: SyllableMode) => {
if (!session) return
let pages: number[]
if (successfulPages.length > 0) pages = successfulPages.map(p => p - 1)
else if (vocabulary.length > 0) pages = [...new Set(vocabulary.map(v => (v.source_page || 1) - 1))].sort((a, b) => a - b)
else if (selectedPages.length > 0) pages = [...selectedPages]
else pages = [0]
if (pages.length === 0) return
// Determine pages to reprocess: use successfulPages if available,
// otherwise derive from vocabulary source_page or selectedPages
let pagesToReprocess: number[]
if (successfulPages.length > 0) {
pagesToReprocess = successfulPages.map(p => p - 1)
} else if (vocabulary.length > 0) {
// Derive from vocabulary entries' source_page (1-indexed → 0-indexed)
const pageSet = new Set(vocabulary.map(v => (v.source_page || 1) - 1))
pagesToReprocess = [...pageSet].sort((a, b) => a - b)
} else if (selectedPages.length > 0) {
pagesToReprocess = [...selectedPages]
} else {
// Fallback: try page 0
pagesToReprocess = [0]
}
if (pagesToReprocess.length === 0) return
setIsExtracting(true)
setExtractionStatus('Verarbeite mit neuen Einstellungen...')
const API_BASE = getApiBase()
setIsExtracting(true); setExtractionStatus('Verarbeite mit neuen Einstellungen...')
;(async () => {
const allVocab: VocabularyEntry[] = []
let lastQuality: any = null
for (const pageIndex of pagesToReprocess) {
setExtractionStatus(`Verarbeite Seite ${pageIndex + 1}...`)
try {
const params = new URLSearchParams({
ipa_mode: ipa,
syllable_mode: syllable,
enhance: String(ocrEnhance),
max_cols: String(ocrMaxCols),
min_conf: String(ocrMinConf),
})
const res = await fetch(`${API_BASE}/api/v1/vocab/sessions/${session.id}/process-single-page/${pageIndex}?${params}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ocr_prompts: ocrPrompts }),
})
if (res.ok) {
const data = await res.json()
if (data.vocabulary) allVocab.push(...data.vocabulary)
if (data.scan_quality) lastQuality = data.scan_quality
}
} catch {}
}
setVocabulary(allVocab)
setIsExtracting(false)
const qualityInfo = lastQuality
? ` | Qualitaet: ${lastQuality.quality_pct}%${lastQuality.is_degraded ? ' (degradiert!)' : ''} | Blur: ${lastQuality.blur_score} | Kontrast: ${lastQuality.contrast_score}`
: ''
const { vocabulary: allVocab, qualityInfo } = await reprocessPagesFlow(
session.id, pages, ipa, syllable, ocrPrompts, ocrEnhance, ocrMaxCols, ocrMinConf, setExtractionStatus
)
setVocabulary(allVocab); setIsExtracting(false)
setExtractionStatus(`${allVocab.length} Vokabeln mit neuen Einstellungen${qualityInfo}`)
})()
}
return {
// Mounted
mounted,
// Theme
isDark, glassCard, glassInput,
// Tab
mounted, isDark, glassCard, glassInput,
activeTab, setActiveTab,
// Session
session, sessionName, setSessionName, isCreatingSession, error, setError, extractionStatus,
// Existing sessions
existingSessions, isLoadingSessions,
// Documents
storedDocuments, selectedDocumentId, setSelectedDocumentId,
// Direct file
directFile, setDirectFile, directFilePreview, showFullPreview, setShowFullPreview, directFileInputRef,
// PDF pages
pdfPageCount, selectedPages, pagesThumbnails, isLoadingThumbnails, excludedPages,
// Extra columns
pageExtraColumns,
// Upload
uploadedImage, isExtracting,
// Vocabulary
vocabulary,
// Worksheet
selectedTypes, worksheetTitle, setWorksheetTitle,
includeSolutions, setIncludeSolutions,
lineHeight, setLineHeight,
selectedFormat, setSelectedFormat,
ipaMode, setIpaMode, syllableMode, setSyllableMode,
// Export
includeSolutions, setIncludeSolutions, lineHeight, setLineHeight,
selectedFormat, setSelectedFormat, ipaMode, setIpaMode, syllableMode, setSyllableMode,
worksheetId, isGenerating,
// Processing
processingErrors, successfulPages, failedPages, currentlyProcessingPage,
// OCR settings
ocrPrompts, showSettings, setShowSettings,
// QR
showQRModal, setShowQRModal, uploadSessionId,
mobileUploadedFiles, selectedMobileFile, setSelectedMobileFile, setMobileUploadedFiles,
// OCR Comparison
showOcrComparison, setShowOcrComparison,
ocrComparePageIndex, ocrCompareResult, isComparingOcr, ocrCompareError,
// Handlers
handleDirectFileSelect, startSession, processSelectedPages,
togglePageSelection, selectAllPages, selectNoPages, excludePage, restoreExcludedPages,
runOcrComparison,

View File

@@ -0,0 +1,49 @@
'use client'
import { useState, useEffect } from 'react'
interface GlassCardProps {
children: React.ReactNode
className?: string
onClick?: () => void
size?: 'sm' | 'md' | 'lg'
delay?: number
isDark?: boolean
}
export function GlassCard({ children, className = '', onClick, size = 'md', delay = 0, isDark = true }: GlassCardProps) {
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>
)
}

View File

@@ -0,0 +1,127 @@
'use client'
import { GlassCard } from './GlassCard'
import { ProgressRing } from './ProgressRing'
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 PreviewStepProps {
previewResult: PreviewResult
previewUrl: string | null
maskUrl: string | null
removeHandwriting: boolean
reconstructLayout: boolean
isProcessing: boolean
onBack: () => void
onCleanup: () => void
onGetMask: () => void
}
export function PreviewStep({
previewResult, previewUrl, maskUrl,
removeHandwriting, reconstructLayout, isProcessing,
onBack, onCleanup, onGetMask
}: PreviewStepProps) {
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<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>
<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>
<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={onGetMask}
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>
<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>
)}
<div className="col-span-1 lg:col-span-3 flex justify-center gap-4">
<button onClick={onBack}
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={onCleanup} 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>
)
}

View File

@@ -0,0 +1,47 @@
'use client'
interface ProgressRingProps {
progress: number
size?: number
strokeWidth?: number
label: string
value: string
color?: string
}
export 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>
)
}

View File

@@ -0,0 +1,87 @@
'use client'
import { GlassCard } from './GlassCard'
interface PipelineResult {
success: boolean
handwriting_detected: boolean
handwriting_removed: boolean
layout_reconstructed: boolean
cleaned_image_base64?: string
fabric_json?: any
metadata: any
}
interface ResultStepProps {
pipelineResult: PipelineResult
previewUrl: string | null
cleanedUrl: string | null
onReset: () => void
onOpenInEditor: () => void
}
export function ResultStep({ pipelineResult, previewUrl, cleanedUrl, onReset, onOpenInEditor }: ResultStepProps) {
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<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>
<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>
<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>
<div className="col-span-1 lg:col-span-2 flex justify-center gap-4">
<button onClick={onReset}
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={onOpenInEditor}
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>
)
}

View File

@@ -0,0 +1,125 @@
'use client'
import { GlassCard } from './GlassCard'
interface UploadStepProps {
isDark: boolean
previewUrl: string | null
file: File | null
removeHandwriting: boolean
setRemoveHandwriting: (v: boolean) => void
reconstructLayout: boolean
setReconstructLayout: (v: boolean) => void
inpaintingMethod: string
setInpaintingMethod: (v: string) => void
isPreviewing: boolean
onDrop: (e: React.DragEvent) => void
onFileSelect: (file: File) => void
onPreview: () => void
onQRClick: () => void
}
export function UploadStep({
isDark, previewUrl, file,
removeHandwriting, setRemoveHandwriting,
reconstructLayout, setReconstructLayout,
inpaintingMethod, setInpaintingMethod,
isPreviewing, onDrop, onFileSelect, onPreview, onQRClick
}: UploadStepProps) {
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<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={onDrop}
onDragOver={(e) => e.preventDefault()}
onClick={() => document.getElementById('file-input')?.click()}
>
<input id="file-input" type="file" accept="image/*"
onChange={(e) => e.target.files?.[0] && onFileSelect(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>
<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={onQRClick}
>
<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>
{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>
<div className="col-span-1 lg:col-span-2 flex justify-center">
<button onClick={onPreview} 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>
)
}

View File

@@ -7,220 +7,56 @@ import { Sidebar } from '@/components/Sidebar'
import { ThemeToggle } from '@/components/ThemeToggle'
import { LanguageDropdown } from '@/components/LanguageDropdown'
import { QRCodeUpload, UploadedFile } from '@/components/QRCodeUpload'
import { GlassCard } from './_components/GlassCard'
import { UploadStep } from './_components/UploadStep'
import { PreviewStep } from './_components/PreviewStep'
import { ResultStep } from './_components/ResultStep'
// 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
}
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
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 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)
let sid = localStorage.getItem(SESSION_ID_KEY)
if (!sid) { sid = `cleanup-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; localStorage.setItem(SESSION_ID_KEY, sid) }
setUploadSessionId(sid)
}, [])
const getApiUrl = useCallback(() => {
@@ -229,661 +65,182 @@ export default function WorksheetCleanupPage() {
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')
setFile(selectedFile); setError(null); setPreviewResult(null); setPipelineResult(null)
setCleanedUrl(null); setMaskUrl(null)
setPreviewUrl(URL.createObjectURL(selectedFile)); 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)
for (let i = 0; i < byteCharacters.length; i++) byteNumbers[i] = byteCharacters.charCodeAt(i)
const blob = new Blob([new Uint8Array(byteNumbers)], { type: uploadedFile.type })
handleFileSelect(new File([blob], uploadedFile.name, { type: uploadedFile.type }))
setShowQRModal(false)
} catch (error) {
console.error('Failed to convert mobile file:', error)
setError('Fehler beim Laden der Datei vom Handy')
}
} catch { 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)
}
const f = e.dataTransfer.files[0]
if (f && f.type.startsWith('image/')) handleFileSelect(f)
}, [handleFileSelect])
// Preview cleanup
const handlePreview = useCallback(async () => {
if (!file) return
setIsPreviewing(true)
setError(null)
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)
}
const fd = new FormData(); fd.append('image', file)
const res = await fetch(`${getApiUrl()}/api/v1/worksheet/preview-cleanup`, { method: 'POST', body: fd })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
setPreviewResult(await res.json()); setCurrentStep('preview')
} catch (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)
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
const fd = new FormData(); fd.append('image', file)
fd.append('remove_handwriting', String(removeHandwriting))
fd.append('reconstruct', String(reconstructLayout)); fd.append('inpainting_method', inpaintingMethod)
const res = await fetch(`${getApiUrl()}/api/v1/worksheet/cleanup-pipeline`, { method: 'POST', body: fd })
if (!res.ok) { const ed = await res.json().catch(() => ({ detail: 'Unknown error' })); throw new Error(ed.detail || `HTTP ${res.status}`) }
const result: PipelineResult = await res.json(); setPipelineResult(result)
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))
const blob = await fetch(`data:image/png;base64,${result.cleaned_image_base64}`).then(r => r.blob())
setCleanedUrl(URL.createObjectURL(blob))
}
setCurrentStep('result')
} catch (err) {
console.error('Cleanup failed:', err)
setError(err instanceof Error ? err.message : 'Bereinigung fehlgeschlagen')
setCurrentStep('preview')
} finally {
setIsProcessing(false)
}
} catch (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)
}
const fd = new FormData(); fd.append('image', file)
const res = await fetch(`${getApiUrl()}/api/v1/worksheet/detect-handwriting/mask`, { method: 'POST', body: fd })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
setMaskUrl(URL.createObjectURL(await res.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')
setFile(null); setPreviewUrl(null); setCleanedUrl(null); setMaskUrl(null)
setPreviewResult(null); setPipelineResult(null); setError(null); setCurrentStep('upload')
}, [])
const steps = ['upload', 'preview', 'processing', 'result'] as const
const currentStepIdx = steps.indexOf(currentStep)
return (
<div className={`min-h-screen flex relative overflow-hidden ${
isDark
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
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>
<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 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) => (
{steps.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 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'
: currentStepIdx > idx ? 'bg-green-500 text-white'
: isDark ? 'bg-white/10 text-white/40' : 'bg-slate-200 text-slate-400'
}`}>
{currentStepIdx > 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'
}`} />
)}
{idx < 3 && <div className={`w-16 h-0.5 mx-2 ${currentStepIdx > 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>
<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>
<UploadStep isDark={isDark} previewUrl={previewUrl} file={file}
removeHandwriting={removeHandwriting} setRemoveHandwriting={setRemoveHandwriting}
reconstructLayout={reconstructLayout} setReconstructLayout={setReconstructLayout}
inpaintingMethod={inpaintingMethod} setInpaintingMethod={setInpaintingMethod}
isPreviewing={isPreviewing} onDrop={handleDrop} onFileSelect={handleFileSelect}
onPreview={handlePreview} onQRClick={() => setShowQRModal(true)} />
)}
{/* 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>
<PreviewStep previewResult={previewResult} previewUrl={previewUrl} maskUrl={maskUrl}
removeHandwriting={removeHandwriting} reconstructLayout={reconstructLayout}
isProcessing={isProcessing} onBack={() => setCurrentStep('upload')}
onCleanup={handleCleanup} onGetMask={handleGetMask} />
)}
{/* 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 className="absolute inset-0 rounded-full border-4 border-white/10" />
<div className="absolute inset-0 rounded-full border-4 border-purple-500 border-t-transparent animate-spin" />
</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>
<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>
<ResultStep pipelineResult={pipelineResult} previewUrl={previewUrl} cleanedUrl={cleanedUrl}
onReset={handleReset} onOpenInEditor={handleOpenInEditor} />
)}
</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 */}
<QRCodeUpload sessionId={uploadSessionId} onClose={() => setShowQRModal(false)}
onFilesChanged={(files) => setMobileUploadedFiles(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>
{mobileUploadedFiles.map((f) => (
<button key={f.id} onClick={() => handleMobileFileSelect(f)}
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">{f.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>
<p className="text-white font-medium truncate">{f.name}</p>
<p className="text-white/50 text-xs">{formatFileSize(f.size)}</p>
</div>
<span className="text-purple-400 text-sm">Verwenden </span>
</button>