[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:
41
studio-v2/app/_components/BackgroundBlobs.tsx
Normal file
41
studio-v2/app/_components/BackgroundBlobs.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
385
studio-v2/app/_components/DashboardContent.tsx
Normal file
385
studio-v2/app/_components/DashboardContent.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
91
studio-v2/app/_components/DocumentsTab.tsx
Normal file
91
studio-v2/app/_components/DocumentsTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
150
studio-v2/app/_components/HeaderBar.tsx
Normal file
150
studio-v2/app/_components/HeaderBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
102
studio-v2/app/_components/UploadModals.tsx
Normal file
102
studio-v2/app/_components/UploadModals.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
106
studio-v2/app/korrektur/_components/CreateKlausurModal.tsx
Normal file
106
studio-v2/app/korrektur/_components/CreateKlausurModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
49
studio-v2/app/korrektur/_components/GlassCard.tsx
Normal file
49
studio-v2/app/korrektur/_components/GlassCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
60
studio-v2/app/korrektur/_components/KlausurCard.tsx
Normal file
60
studio-v2/app/korrektur/_components/KlausurCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
31
studio-v2/app/korrektur/_components/StatCard.tsx
Normal file
31
studio-v2/app/korrektur/_components/StatCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
199
studio-v2/app/korrektur/_components/UploadModals.tsx
Normal file
199
studio-v2/app/korrektur/_components/UploadModals.tsx
Normal 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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
97
studio-v2/app/vocab-worksheet/usePageProcessing.ts
Normal file
97
studio-v2/app/vocab-worksheet/usePageProcessing.ts
Normal 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 }
|
||||
}
|
||||
156
studio-v2/app/vocab-worksheet/useSessionHandlers.ts
Normal file
156
studio-v2/app/vocab-worksheet/useSessionHandlers.ts
Normal 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('')
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
49
studio-v2/app/worksheet-cleanup/_components/GlassCard.tsx
Normal file
49
studio-v2/app/worksheet-cleanup/_components/GlassCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
127
studio-v2/app/worksheet-cleanup/_components/PreviewStep.tsx
Normal file
127
studio-v2/app/worksheet-cleanup/_components/PreviewStep.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
47
studio-v2/app/worksheet-cleanup/_components/ProgressRing.tsx
Normal file
47
studio-v2/app/worksheet-cleanup/_components/ProgressRing.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
87
studio-v2/app/worksheet-cleanup/_components/ResultStep.tsx
Normal file
87
studio-v2/app/worksheet-cleanup/_components/ResultStep.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
125
studio-v2/app/worksheet-cleanup/_components/UploadStep.tsx
Normal file
125
studio-v2/app/worksheet-cleanup/_components/UploadStep.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -1,733 +1,80 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'
|
||||
import type { Contact, Conversation, Message, MessageTemplate, MessagesStats, MessagesContextType } from './messages/types'
|
||||
import { mockContacts, mockConversations, mockMessages, mockTemplates } from './messages/mock-data'
|
||||
|
||||
// ============================================
|
||||
// TYPES
|
||||
// ============================================
|
||||
|
||||
export interface Contact {
|
||||
id: string
|
||||
name: string
|
||||
email?: string
|
||||
phone?: string
|
||||
role: 'parent' | 'teacher' | 'staff' | 'student'
|
||||
student_name?: string
|
||||
class_name?: string
|
||||
notes?: string
|
||||
tags: string[]
|
||||
avatar_url?: string
|
||||
preferred_channel: 'email' | 'matrix' | 'pwa'
|
||||
online: boolean
|
||||
last_seen?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string
|
||||
conversation_id: string
|
||||
sender_id: string // "self" for own messages
|
||||
content: string
|
||||
content_type: 'text' | 'file' | 'image' | 'voice'
|
||||
file_url?: string
|
||||
file_name?: string
|
||||
timestamp: string
|
||||
read: boolean
|
||||
read_at?: string
|
||||
delivered: boolean
|
||||
send_email: boolean
|
||||
email_sent: boolean
|
||||
email_sent_at?: string
|
||||
email_error?: string
|
||||
reply_to?: string // ID of message being replied to
|
||||
reactions?: { emoji: string; user_id: string }[]
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
id: string
|
||||
participant_ids: string[]
|
||||
group_id?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
last_message?: string
|
||||
last_message_time?: string
|
||||
unread_count: number
|
||||
is_group: boolean
|
||||
title?: string
|
||||
typing?: boolean // Someone is typing
|
||||
pinned?: boolean
|
||||
muted?: boolean
|
||||
archived?: boolean
|
||||
}
|
||||
|
||||
export interface MessageTemplate {
|
||||
id: string
|
||||
name: string
|
||||
content: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface MessagesStats {
|
||||
total_contacts: number
|
||||
total_conversations: number
|
||||
total_messages: number
|
||||
unread_messages: number
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CONTEXT INTERFACE
|
||||
// ============================================
|
||||
|
||||
interface MessagesContextType {
|
||||
// Data
|
||||
contacts: Contact[]
|
||||
conversations: Conversation[]
|
||||
messages: Record<string, Message[]> // conversationId -> messages
|
||||
templates: MessageTemplate[]
|
||||
stats: MessagesStats
|
||||
|
||||
// Computed
|
||||
unreadCount: number
|
||||
recentConversations: Conversation[]
|
||||
|
||||
// Actions
|
||||
fetchContacts: () => Promise<void>
|
||||
fetchConversations: () => Promise<void>
|
||||
fetchMessages: (conversationId: string) => Promise<Message[]>
|
||||
sendMessage: (conversationId: string, content: string, sendEmail?: boolean, replyTo?: string) => Promise<Message | null>
|
||||
markAsRead: (conversationId: string) => Promise<void>
|
||||
createConversation: (contactId: string) => Promise<Conversation | null>
|
||||
addReaction: (messageId: string, emoji: string) => void
|
||||
deleteMessage: (conversationId: string, messageId: string) => void
|
||||
pinConversation: (conversationId: string) => void
|
||||
muteConversation: (conversationId: string) => void
|
||||
|
||||
// State
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
currentConversationId: string | null
|
||||
setCurrentConversationId: (id: string | null) => void
|
||||
}
|
||||
// Re-export types and helpers for backward compatibility
|
||||
export type { Contact, Conversation, Message, MessageTemplate, MessagesStats } from './messages/types'
|
||||
export { formatMessageTime, formatMessageDate, getContactInitials, getRoleLabel, getRoleColor, emojiCategories } from './messages/helpers'
|
||||
|
||||
const MessagesContext = createContext<MessagesContextType | null>(null)
|
||||
|
||||
// ============================================
|
||||
// MOCK DATA - Realistic German school context
|
||||
// ============================================
|
||||
|
||||
const mockContacts: Contact[] = [
|
||||
{
|
||||
id: 'contact_mueller',
|
||||
name: 'Familie Mueller',
|
||||
email: 'familie.mueller@gmail.com',
|
||||
phone: '+49 170 1234567',
|
||||
role: 'parent',
|
||||
student_name: 'Max Mueller',
|
||||
class_name: '10a',
|
||||
notes: 'Bevorzugt Kommunikation per E-Mail',
|
||||
tags: ['aktiv', 'Elternbeirat'],
|
||||
preferred_channel: 'email',
|
||||
online: false,
|
||||
last_seen: new Date(Date.now() - 1800000).toISOString(),
|
||||
created_at: new Date(Date.now() - 86400000 * 30).toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: 'contact_schmidt',
|
||||
name: 'Petra Schmidt',
|
||||
email: 'p.schmidt@web.de',
|
||||
phone: '+49 171 9876543',
|
||||
role: 'parent',
|
||||
student_name: 'Lisa Schmidt',
|
||||
class_name: '10a',
|
||||
tags: ['responsive'],
|
||||
preferred_channel: 'pwa',
|
||||
online: true,
|
||||
created_at: new Date(Date.now() - 86400000 * 60).toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: 'contact_weber',
|
||||
name: 'Sabine Weber',
|
||||
email: 's.weber@schule-musterstadt.de',
|
||||
role: 'teacher',
|
||||
tags: ['Fachschaft Deutsch', 'Klassenleitung 9b'],
|
||||
preferred_channel: 'pwa',
|
||||
online: true,
|
||||
last_seen: new Date().toISOString(),
|
||||
created_at: new Date(Date.now() - 86400000 * 90).toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: 'contact_hoffmann',
|
||||
name: 'Thomas Hoffmann',
|
||||
email: 't.hoffmann@schule-musterstadt.de',
|
||||
role: 'teacher',
|
||||
tags: ['Fachschaft Mathe', 'Oberstufenkoordinator'],
|
||||
preferred_channel: 'pwa',
|
||||
online: false,
|
||||
last_seen: new Date(Date.now() - 3600000 * 2).toISOString(),
|
||||
created_at: new Date(Date.now() - 86400000 * 120).toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: 'contact_becker',
|
||||
name: 'Familie Becker',
|
||||
email: 'becker.familie@gmx.de',
|
||||
phone: '+49 172 5551234',
|
||||
role: 'parent',
|
||||
student_name: 'Tim Becker',
|
||||
class_name: '10a',
|
||||
tags: [],
|
||||
preferred_channel: 'email',
|
||||
online: false,
|
||||
last_seen: new Date(Date.now() - 86400000).toISOString(),
|
||||
created_at: new Date(Date.now() - 86400000 * 45).toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: 'contact_klein',
|
||||
name: 'Monika Klein',
|
||||
email: 'm.klein@schule-musterstadt.de',
|
||||
role: 'staff',
|
||||
tags: ['Sekretariat'],
|
||||
preferred_channel: 'pwa',
|
||||
online: true,
|
||||
created_at: new Date(Date.now() - 86400000 * 180).toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: 'contact_fischer',
|
||||
name: 'Familie Fischer',
|
||||
email: 'fischer@t-online.de',
|
||||
phone: '+49 173 4445566',
|
||||
role: 'parent',
|
||||
student_name: 'Anna Fischer',
|
||||
class_name: '11b',
|
||||
tags: ['Foerderverein'],
|
||||
preferred_channel: 'pwa',
|
||||
online: false,
|
||||
last_seen: new Date(Date.now() - 7200000).toISOString(),
|
||||
created_at: new Date(Date.now() - 86400000 * 75).toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: 'contact_meyer',
|
||||
name: 'Dr. Hans Meyer',
|
||||
email: 'h.meyer@schule-musterstadt.de',
|
||||
role: 'teacher',
|
||||
tags: ['Schulleitung', 'Stellvertretender Schulleiter'],
|
||||
preferred_channel: 'email',
|
||||
online: false,
|
||||
last_seen: new Date(Date.now() - 3600000).toISOString(),
|
||||
created_at: new Date(Date.now() - 86400000 * 365).toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
]
|
||||
|
||||
const mockConversations: Conversation[] = [
|
||||
{
|
||||
id: 'conv_mueller',
|
||||
participant_ids: ['contact_mueller'],
|
||||
created_at: new Date(Date.now() - 86400000 * 7).toISOString(),
|
||||
updated_at: new Date(Date.now() - 300000).toISOString(),
|
||||
last_message: 'Vielen Dank fuer die Info! Max freut sich schon auf die Klassenfahrt 🎉',
|
||||
last_message_time: new Date(Date.now() - 300000).toISOString(),
|
||||
unread_count: 2,
|
||||
is_group: false,
|
||||
title: 'Familie Mueller',
|
||||
pinned: true
|
||||
},
|
||||
{
|
||||
id: 'conv_schmidt',
|
||||
participant_ids: ['contact_schmidt'],
|
||||
created_at: new Date(Date.now() - 86400000 * 14).toISOString(),
|
||||
updated_at: new Date(Date.now() - 3600000).toISOString(),
|
||||
last_message: 'Lisa war heute krank, sie kommt morgen wieder.',
|
||||
last_message_time: new Date(Date.now() - 3600000).toISOString(),
|
||||
unread_count: 0,
|
||||
is_group: false,
|
||||
title: 'Petra Schmidt'
|
||||
},
|
||||
{
|
||||
id: 'conv_weber',
|
||||
participant_ids: ['contact_weber'],
|
||||
created_at: new Date(Date.now() - 86400000 * 30).toISOString(),
|
||||
updated_at: new Date(Date.now() - 7200000).toISOString(),
|
||||
last_message: 'Koenntest du mir die Klausuraufgaben bis Freitag schicken? 📝',
|
||||
last_message_time: new Date(Date.now() - 7200000).toISOString(),
|
||||
unread_count: 1,
|
||||
is_group: false,
|
||||
title: 'Sabine Weber',
|
||||
typing: true
|
||||
},
|
||||
{
|
||||
id: 'conv_hoffmann',
|
||||
participant_ids: ['contact_hoffmann'],
|
||||
created_at: new Date(Date.now() - 86400000 * 5).toISOString(),
|
||||
updated_at: new Date(Date.now() - 86400000).toISOString(),
|
||||
last_message: 'Die Notenkonferenz ist am 15.02. um 14:00 Uhr.',
|
||||
last_message_time: new Date(Date.now() - 86400000).toISOString(),
|
||||
unread_count: 0,
|
||||
is_group: false,
|
||||
title: 'Thomas Hoffmann'
|
||||
},
|
||||
{
|
||||
id: 'conv_becker',
|
||||
participant_ids: ['contact_becker'],
|
||||
created_at: new Date(Date.now() - 86400000 * 3).toISOString(),
|
||||
updated_at: new Date(Date.now() - 172800000).toISOString(),
|
||||
last_message: 'Wir haben die Einverstaendniserklaerung unterschrieben.',
|
||||
last_message_time: new Date(Date.now() - 172800000).toISOString(),
|
||||
unread_count: 0,
|
||||
is_group: false,
|
||||
title: 'Familie Becker',
|
||||
muted: true
|
||||
},
|
||||
{
|
||||
id: 'conv_fachschaft',
|
||||
participant_ids: ['contact_weber', 'contact_hoffmann', 'contact_meyer'],
|
||||
created_at: new Date(Date.now() - 86400000 * 60).toISOString(),
|
||||
updated_at: new Date(Date.now() - 14400000).toISOString(),
|
||||
last_message: 'Sabine: Hat jemand die neuen Lehrplaene schon gelesen?',
|
||||
last_message_time: new Date(Date.now() - 14400000).toISOString(),
|
||||
unread_count: 3,
|
||||
is_group: true,
|
||||
title: 'Fachschaft Deutsch 📚'
|
||||
}
|
||||
]
|
||||
|
||||
const mockMessages: Record<string, Message[]> = {
|
||||
'conv_mueller': [
|
||||
{
|
||||
id: 'msg_m1',
|
||||
conversation_id: 'conv_mueller',
|
||||
sender_id: 'self',
|
||||
content: 'Guten Tag Frau Mueller,\n\nich moechte Sie ueber die anstehende Klassenfahrt nach Berlin informieren. Die Reise findet vom 15.-19. April statt.',
|
||||
content_type: 'text',
|
||||
timestamp: new Date(Date.now() - 86400000).toISOString(),
|
||||
read: true,
|
||||
delivered: true,
|
||||
send_email: true,
|
||||
email_sent: true,
|
||||
email_sent_at: new Date(Date.now() - 86400000).toISOString()
|
||||
},
|
||||
{
|
||||
id: 'msg_m2',
|
||||
conversation_id: 'conv_mueller',
|
||||
sender_id: 'self',
|
||||
content: 'Die Kosten belaufen sich auf 280 Euro pro Schueler. Bitte ueberweisen Sie den Betrag bis zum 01.03. auf das Schulkonto.',
|
||||
content_type: 'text',
|
||||
timestamp: new Date(Date.now() - 86400000 + 60000).toISOString(),
|
||||
read: true,
|
||||
delivered: true,
|
||||
send_email: false,
|
||||
email_sent: false
|
||||
},
|
||||
{
|
||||
id: 'msg_m3',
|
||||
conversation_id: 'conv_mueller',
|
||||
sender_id: 'contact_mueller',
|
||||
content: 'Vielen Dank fuer die Information! Wir werden den Betrag diese Woche ueberweisen.',
|
||||
content_type: 'text',
|
||||
timestamp: new Date(Date.now() - 3600000).toISOString(),
|
||||
read: false,
|
||||
delivered: true,
|
||||
send_email: false,
|
||||
email_sent: false,
|
||||
reactions: [{ emoji: '👍', user_id: 'self' }]
|
||||
},
|
||||
{
|
||||
id: 'msg_m4',
|
||||
conversation_id: 'conv_mueller',
|
||||
sender_id: 'contact_mueller',
|
||||
content: 'Vielen Dank fuer die Info! Max freut sich schon auf die Klassenfahrt 🎉',
|
||||
content_type: 'text',
|
||||
timestamp: new Date(Date.now() - 300000).toISOString(),
|
||||
read: false,
|
||||
delivered: true,
|
||||
send_email: false,
|
||||
email_sent: false
|
||||
}
|
||||
],
|
||||
'conv_schmidt': [
|
||||
{
|
||||
id: 'msg_s1',
|
||||
conversation_id: 'conv_schmidt',
|
||||
sender_id: 'contact_schmidt',
|
||||
content: 'Guten Morgen! Lisa ist heute leider krank und kann nicht zur Schule kommen.',
|
||||
content_type: 'text',
|
||||
timestamp: new Date(Date.now() - 86400000 * 2).toISOString(),
|
||||
read: true,
|
||||
delivered: true,
|
||||
send_email: false,
|
||||
email_sent: false
|
||||
},
|
||||
{
|
||||
id: 'msg_s2',
|
||||
conversation_id: 'conv_schmidt',
|
||||
sender_id: 'self',
|
||||
content: 'Gute Besserung an Lisa! 🤒 Soll ich ihr die Hausaufgaben zukommen lassen?',
|
||||
content_type: 'text',
|
||||
timestamp: new Date(Date.now() - 86400000 * 2 + 1800000).toISOString(),
|
||||
read: true,
|
||||
delivered: true,
|
||||
send_email: false,
|
||||
email_sent: false
|
||||
},
|
||||
{
|
||||
id: 'msg_s3',
|
||||
conversation_id: 'conv_schmidt',
|
||||
sender_id: 'contact_schmidt',
|
||||
content: 'Das waere sehr nett, vielen Dank! 🙏',
|
||||
content_type: 'text',
|
||||
timestamp: new Date(Date.now() - 86400000 * 2 + 3600000).toISOString(),
|
||||
read: true,
|
||||
delivered: true,
|
||||
send_email: false,
|
||||
email_sent: false
|
||||
},
|
||||
{
|
||||
id: 'msg_s4',
|
||||
conversation_id: 'conv_schmidt',
|
||||
sender_id: 'self',
|
||||
content: 'Hier sind die Hausaufgaben fuer diese Woche:\n\n📖 Deutsch: Seite 45-48 lesen\n📝 Mathe: Aufgaben 1-5 auf Seite 112\n🔬 Bio: Referat vorbereiten',
|
||||
content_type: 'text',
|
||||
timestamp: new Date(Date.now() - 86400000).toISOString(),
|
||||
read: true,
|
||||
delivered: true,
|
||||
send_email: true,
|
||||
email_sent: true
|
||||
},
|
||||
{
|
||||
id: 'msg_s5',
|
||||
conversation_id: 'conv_schmidt',
|
||||
sender_id: 'contact_schmidt',
|
||||
content: 'Lisa war heute krank, sie kommt morgen wieder.',
|
||||
content_type: 'text',
|
||||
timestamp: new Date(Date.now() - 3600000).toISOString(),
|
||||
read: true,
|
||||
delivered: true,
|
||||
send_email: false,
|
||||
email_sent: false
|
||||
}
|
||||
],
|
||||
'conv_weber': [
|
||||
{
|
||||
id: 'msg_w1',
|
||||
conversation_id: 'conv_weber',
|
||||
sender_id: 'contact_weber',
|
||||
content: 'Hi! Hast du schon die neuen Abi-Themen gesehen?',
|
||||
content_type: 'text',
|
||||
timestamp: new Date(Date.now() - 86400000 * 3).toISOString(),
|
||||
read: true,
|
||||
delivered: true,
|
||||
send_email: false,
|
||||
email_sent: false
|
||||
},
|
||||
{
|
||||
id: 'msg_w2',
|
||||
conversation_id: 'conv_weber',
|
||||
sender_id: 'self',
|
||||
content: 'Ja, habe ich! Finde ich ganz gut machbar dieses Jahr. 📚',
|
||||
content_type: 'text',
|
||||
timestamp: new Date(Date.now() - 86400000 * 3 + 1800000).toISOString(),
|
||||
read: true,
|
||||
delivered: true,
|
||||
send_email: false,
|
||||
email_sent: false
|
||||
},
|
||||
{
|
||||
id: 'msg_w3',
|
||||
conversation_id: 'conv_weber',
|
||||
sender_id: 'contact_weber',
|
||||
content: 'Koenntest du mir die Klausuraufgaben bis Freitag schicken? 📝',
|
||||
content_type: 'text',
|
||||
timestamp: new Date(Date.now() - 7200000).toISOString(),
|
||||
read: false,
|
||||
delivered: true,
|
||||
send_email: false,
|
||||
email_sent: false
|
||||
}
|
||||
],
|
||||
'conv_hoffmann': [
|
||||
{
|
||||
id: 'msg_h1',
|
||||
conversation_id: 'conv_hoffmann',
|
||||
sender_id: 'contact_hoffmann',
|
||||
content: 'Kurze Info: Die Notenkonferenz ist am 15.02. um 14:00 Uhr.',
|
||||
content_type: 'text',
|
||||
timestamp: new Date(Date.now() - 86400000 * 2).toISOString(),
|
||||
read: true,
|
||||
delivered: true,
|
||||
send_email: false,
|
||||
email_sent: false
|
||||
},
|
||||
{
|
||||
id: 'msg_h2',
|
||||
conversation_id: 'conv_hoffmann',
|
||||
sender_id: 'self',
|
||||
content: 'Danke fuer die Info! Bin dabei. 👍',
|
||||
content_type: 'text',
|
||||
timestamp: new Date(Date.now() - 86400000 * 2 + 3600000).toISOString(),
|
||||
read: true,
|
||||
delivered: true,
|
||||
send_email: false,
|
||||
email_sent: false
|
||||
},
|
||||
{
|
||||
id: 'msg_h3',
|
||||
conversation_id: 'conv_hoffmann',
|
||||
sender_id: 'contact_hoffmann',
|
||||
content: 'Die Notenkonferenz ist am 15.02. um 14:00 Uhr.',
|
||||
content_type: 'text',
|
||||
timestamp: new Date(Date.now() - 86400000).toISOString(),
|
||||
read: true,
|
||||
delivered: true,
|
||||
send_email: false,
|
||||
email_sent: false
|
||||
}
|
||||
],
|
||||
'conv_becker': [
|
||||
{
|
||||
id: 'msg_b1',
|
||||
conversation_id: 'conv_becker',
|
||||
sender_id: 'self',
|
||||
content: 'Guten Tag Familie Becker,\n\nbitte vergessen Sie nicht, die Einverstaendniserklaerung fuer den Schwimmunterricht zu unterschreiben.',
|
||||
content_type: 'text',
|
||||
timestamp: new Date(Date.now() - 86400000 * 4).toISOString(),
|
||||
read: true,
|
||||
delivered: true,
|
||||
send_email: true,
|
||||
email_sent: true
|
||||
},
|
||||
{
|
||||
id: 'msg_b2',
|
||||
conversation_id: 'conv_becker',
|
||||
sender_id: 'contact_becker',
|
||||
content: 'Wir haben die Einverstaendniserklaerung unterschrieben.',
|
||||
content_type: 'text',
|
||||
timestamp: new Date(Date.now() - 172800000).toISOString(),
|
||||
read: true,
|
||||
delivered: true,
|
||||
send_email: false,
|
||||
email_sent: false
|
||||
}
|
||||
],
|
||||
'conv_fachschaft': [
|
||||
{
|
||||
id: 'msg_f1',
|
||||
conversation_id: 'conv_fachschaft',
|
||||
sender_id: 'contact_meyer',
|
||||
content: 'Liebe Kolleginnen und Kollegen,\n\ndie neuen Lehrplaene sind jetzt online verfuegbar.',
|
||||
content_type: 'text',
|
||||
timestamp: new Date(Date.now() - 86400000).toISOString(),
|
||||
read: true,
|
||||
delivered: true,
|
||||
send_email: false,
|
||||
email_sent: false
|
||||
},
|
||||
{
|
||||
id: 'msg_f2',
|
||||
conversation_id: 'conv_fachschaft',
|
||||
sender_id: 'contact_hoffmann',
|
||||
content: 'Danke fuer die Info! Werde ich mir heute Abend anschauen.',
|
||||
content_type: 'text',
|
||||
timestamp: new Date(Date.now() - 72000000).toISOString(),
|
||||
read: true,
|
||||
delivered: true,
|
||||
send_email: false,
|
||||
email_sent: false
|
||||
},
|
||||
{
|
||||
id: 'msg_f3',
|
||||
conversation_id: 'conv_fachschaft',
|
||||
sender_id: 'contact_weber',
|
||||
content: 'Hat jemand die neuen Lehrplaene schon gelesen?',
|
||||
content_type: 'text',
|
||||
timestamp: new Date(Date.now() - 14400000).toISOString(),
|
||||
read: false,
|
||||
delivered: true,
|
||||
send_email: false,
|
||||
email_sent: false
|
||||
},
|
||||
{
|
||||
id: 'msg_f4',
|
||||
conversation_id: 'conv_fachschaft',
|
||||
sender_id: 'contact_hoffmann',
|
||||
content: 'Noch nicht komplett, aber sieht interessant aus! 📖',
|
||||
content_type: 'text',
|
||||
timestamp: new Date(Date.now() - 10800000).toISOString(),
|
||||
read: false,
|
||||
delivered: true,
|
||||
send_email: false,
|
||||
email_sent: false
|
||||
},
|
||||
{
|
||||
id: 'msg_f5',
|
||||
conversation_id: 'conv_fachschaft',
|
||||
sender_id: 'contact_meyer',
|
||||
content: 'Wir sollten naechste Woche eine Besprechung ansetzen.',
|
||||
content_type: 'text',
|
||||
timestamp: new Date(Date.now() - 7200000).toISOString(),
|
||||
read: false,
|
||||
delivered: true,
|
||||
send_email: false,
|
||||
email_sent: false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const mockTemplates: MessageTemplate[] = [
|
||||
{
|
||||
id: 'tpl_1',
|
||||
name: 'Krankmeldung bestaetigen',
|
||||
content: 'Vielen Dank fuer die Krankmeldung. Gute Besserung! 🤒',
|
||||
created_at: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: 'tpl_2',
|
||||
name: 'Hausaufgaben senden',
|
||||
content: 'Hier sind die Hausaufgaben fuer diese Woche:\n\n📖 Deutsch: \n📝 Mathe: \n🔬 Bio: ',
|
||||
created_at: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: 'tpl_3',
|
||||
name: 'Elterngespraech anfragen',
|
||||
content: 'Guten Tag,\n\nich wuerde gerne ein Elterngespraech mit Ihnen vereinbaren. Wann haetten Sie Zeit?',
|
||||
created_at: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: 'tpl_4',
|
||||
name: 'Termin bestaetigen',
|
||||
content: 'Vielen Dank, der Termin ist bestaetigt. Ich freue mich auf unser Gespraech! 📅',
|
||||
created_at: new Date().toISOString()
|
||||
}
|
||||
]
|
||||
|
||||
// ============================================
|
||||
// PROVIDER
|
||||
// ============================================
|
||||
|
||||
export function MessagesProvider({ children }: { children: ReactNode }) {
|
||||
const [contacts, setContacts] = useState<Contact[]>(mockContacts)
|
||||
const [conversations, setConversations] = useState<Conversation[]>(mockConversations)
|
||||
const [messages, setMessages] = useState<Record<string, Message[]>>(mockMessages)
|
||||
const [templates, setTemplates] = useState<MessageTemplate[]>(mockTemplates)
|
||||
const [stats, setStats] = useState<MessagesStats>({
|
||||
const [templates] = useState<MessageTemplate[]>(mockTemplates)
|
||||
const [stats] = useState<MessagesStats>({
|
||||
total_contacts: mockContacts.length,
|
||||
total_conversations: mockConversations.length,
|
||||
total_messages: Object.values(mockMessages).flat().length,
|
||||
unread_messages: mockConversations.reduce((sum, c) => sum + c.unread_count, 0)
|
||||
})
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isLoading] = useState(false)
|
||||
const [error] = useState<string | null>(null)
|
||||
const [currentConversationId, setCurrentConversationId] = useState<string | null>(null)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
// Initialize
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
useEffect(() => { setMounted(true) }, [])
|
||||
|
||||
// Computed: unread count
|
||||
const unreadCount = conversations.reduce((sum, c) => sum + c.unread_count, 0)
|
||||
|
||||
// Computed: recent conversations (sorted by last_message_time, pinned first)
|
||||
const recentConversations = [...conversations]
|
||||
.sort((a, b) => {
|
||||
// Pinned conversations first
|
||||
if (a.pinned && !b.pinned) return -1
|
||||
if (!a.pinned && b.pinned) return 1
|
||||
// Then by last_message_time
|
||||
const aTime = a.last_message_time ? new Date(a.last_message_time).getTime() : 0
|
||||
const bTime = b.last_message_time ? new Date(b.last_message_time).getTime() : 0
|
||||
return bTime - aTime
|
||||
})
|
||||
|
||||
// Actions
|
||||
const fetchContacts = useCallback(async () => {
|
||||
// Using mock data directly
|
||||
setContacts(mockContacts)
|
||||
}, [])
|
||||
|
||||
const fetchConversations = useCallback(async () => {
|
||||
// Using mock data directly
|
||||
setConversations(mockConversations)
|
||||
}, [])
|
||||
const recentConversations = [...conversations].sort((a, b) => {
|
||||
if (a.pinned && !b.pinned) return -1
|
||||
if (!a.pinned && b.pinned) return 1
|
||||
const aTime = a.last_message_time ? new Date(a.last_message_time).getTime() : 0
|
||||
const bTime = b.last_message_time ? new Date(b.last_message_time).getTime() : 0
|
||||
return bTime - aTime
|
||||
})
|
||||
|
||||
const fetchContacts = useCallback(async () => { setContacts(mockContacts) }, [])
|
||||
const fetchConversations = useCallback(async () => { setConversations(mockConversations) }, [])
|
||||
const fetchMessages = useCallback(async (conversationId: string): Promise<Message[]> => {
|
||||
return messages[conversationId] || []
|
||||
}, [messages])
|
||||
|
||||
const sendMessage = useCallback(async (
|
||||
conversationId: string,
|
||||
content: string,
|
||||
sendEmail: boolean = false,
|
||||
replyTo?: string
|
||||
conversationId: string, content: string, sendEmail = false, replyTo?: string
|
||||
): Promise<Message | null> => {
|
||||
const newMsg: Message = {
|
||||
id: `msg_${Date.now()}`,
|
||||
conversation_id: conversationId,
|
||||
sender_id: 'self',
|
||||
content,
|
||||
content_type: 'text',
|
||||
timestamp: new Date().toISOString(),
|
||||
read: true,
|
||||
delivered: true,
|
||||
send_email: sendEmail,
|
||||
email_sent: sendEmail,
|
||||
reply_to: replyTo
|
||||
id: `msg_${Date.now()}`, conversation_id: conversationId, sender_id: 'self',
|
||||
content, content_type: 'text', timestamp: new Date().toISOString(),
|
||||
read: true, delivered: true, send_email: sendEmail, email_sent: sendEmail, reply_to: replyTo
|
||||
}
|
||||
|
||||
setMessages(prev => ({
|
||||
...prev,
|
||||
[conversationId]: [...(prev[conversationId] || []), newMsg]
|
||||
}))
|
||||
|
||||
// Update conversation
|
||||
setMessages(prev => ({ ...prev, [conversationId]: [...(prev[conversationId] || []), newMsg] }))
|
||||
setConversations(prev => prev.map(c =>
|
||||
c.id === conversationId
|
||||
? {
|
||||
...c,
|
||||
last_message: content.length > 50 ? content.slice(0, 50) + '...' : content,
|
||||
last_message_time: newMsg.timestamp,
|
||||
updated_at: newMsg.timestamp
|
||||
}
|
||||
? { ...c, last_message: content.length > 50 ? content.slice(0, 50) + '...' : content,
|
||||
last_message_time: newMsg.timestamp, updated_at: newMsg.timestamp }
|
||||
: c
|
||||
))
|
||||
|
||||
return newMsg
|
||||
}, [])
|
||||
|
||||
const markAsRead = useCallback(async (conversationId: string) => {
|
||||
setMessages(prev => ({
|
||||
...prev,
|
||||
[conversationId]: (prev[conversationId] || []).map(m => ({ ...m, read: true }))
|
||||
}))
|
||||
setConversations(prev => prev.map(c =>
|
||||
c.id === conversationId ? { ...c, unread_count: 0 } : c
|
||||
))
|
||||
setMessages(prev => ({ ...prev, [conversationId]: (prev[conversationId] || []).map(m => ({ ...m, read: true })) }))
|
||||
setConversations(prev => prev.map(c => c.id === conversationId ? { ...c, unread_count: 0 } : c))
|
||||
}, [])
|
||||
|
||||
const createConversation = useCallback(async (contactId: string): Promise<Conversation | null> => {
|
||||
// Check if conversation exists
|
||||
const existing = conversations.find(c =>
|
||||
!c.is_group && c.participant_ids.includes(contactId)
|
||||
)
|
||||
const existing = conversations.find(c => !c.is_group && c.participant_ids.includes(contactId))
|
||||
if (existing) return existing
|
||||
|
||||
// Create new conversation
|
||||
const contact = contacts.find(c => c.id === contactId)
|
||||
const newConv: Conversation = {
|
||||
id: `conv_${Date.now()}`,
|
||||
participant_ids: [contactId],
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
unread_count: 0,
|
||||
is_group: false,
|
||||
title: contact?.name || 'Neue Konversation'
|
||||
id: `conv_${Date.now()}`, participant_ids: [contactId],
|
||||
created_at: new Date().toISOString(), updated_at: new Date().toISOString(),
|
||||
unread_count: 0, is_group: false, title: contact?.name || 'Neue Konversation'
|
||||
}
|
||||
setConversations(prev => [newConv, ...prev])
|
||||
setMessages(prev => ({ ...prev, [newConv.id]: [] }))
|
||||
@@ -739,22 +86,14 @@ export function MessagesProvider({ children }: { children: ReactNode }) {
|
||||
const newMessages = { ...prev }
|
||||
for (const convId of Object.keys(newMessages)) {
|
||||
newMessages[convId] = newMessages[convId].map(msg => {
|
||||
if (msg.id === messageId) {
|
||||
const reactions = msg.reactions || []
|
||||
const existingIndex = reactions.findIndex(r => r.user_id === 'self')
|
||||
if (existingIndex >= 0) {
|
||||
// Toggle or change reaction
|
||||
if (reactions[existingIndex].emoji === emoji) {
|
||||
reactions.splice(existingIndex, 1)
|
||||
} else {
|
||||
reactions[existingIndex].emoji = emoji
|
||||
}
|
||||
} else {
|
||||
reactions.push({ emoji, user_id: 'self' })
|
||||
}
|
||||
return { ...msg, reactions }
|
||||
}
|
||||
return msg
|
||||
if (msg.id !== messageId) return msg
|
||||
const reactions = [...(msg.reactions || [])]
|
||||
const existingIndex = reactions.findIndex(r => r.user_id === 'self')
|
||||
if (existingIndex >= 0) {
|
||||
if (reactions[existingIndex].emoji === emoji) { reactions.splice(existingIndex, 1) }
|
||||
else { reactions[existingIndex] = { ...reactions[existingIndex], emoji } }
|
||||
} else { reactions.push({ emoji, user_id: 'self' }) }
|
||||
return { ...msg, reactions }
|
||||
})
|
||||
}
|
||||
return newMessages
|
||||
@@ -762,86 +101,30 @@ export function MessagesProvider({ children }: { children: ReactNode }) {
|
||||
}, [])
|
||||
|
||||
const deleteMessage = useCallback((conversationId: string, messageId: string) => {
|
||||
setMessages(prev => ({
|
||||
...prev,
|
||||
[conversationId]: (prev[conversationId] || []).filter(m => m.id !== messageId)
|
||||
}))
|
||||
setMessages(prev => ({ ...prev, [conversationId]: (prev[conversationId] || []).filter(m => m.id !== messageId) }))
|
||||
}, [])
|
||||
|
||||
const pinConversation = useCallback((conversationId: string) => {
|
||||
setConversations(prev => prev.map(c =>
|
||||
c.id === conversationId ? { ...c, pinned: !c.pinned } : c
|
||||
))
|
||||
setConversations(prev => prev.map(c => c.id === conversationId ? { ...c, pinned: !c.pinned } : c))
|
||||
}, [])
|
||||
|
||||
const muteConversation = useCallback((conversationId: string) => {
|
||||
setConversations(prev => prev.map(c =>
|
||||
c.id === conversationId ? { ...c, muted: !c.muted } : c
|
||||
))
|
||||
setConversations(prev => prev.map(c => c.id === conversationId ? { ...c, muted: !c.muted } : c))
|
||||
}, [])
|
||||
|
||||
// SSR safety
|
||||
if (!mounted) {
|
||||
return (
|
||||
<MessagesContext.Provider
|
||||
value={{
|
||||
contacts: [],
|
||||
conversations: [],
|
||||
messages: {},
|
||||
templates: [],
|
||||
stats: { total_contacts: 0, total_conversations: 0, total_messages: 0, unread_messages: 0 },
|
||||
unreadCount: 0,
|
||||
recentConversations: [],
|
||||
fetchContacts: async () => {},
|
||||
fetchConversations: async () => {},
|
||||
fetchMessages: async () => [],
|
||||
sendMessage: async () => null,
|
||||
markAsRead: async () => {},
|
||||
createConversation: async () => null,
|
||||
addReaction: () => {},
|
||||
deleteMessage: () => {},
|
||||
pinConversation: () => {},
|
||||
muteConversation: () => {},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
currentConversationId: null,
|
||||
setCurrentConversationId: () => {}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MessagesContext.Provider>
|
||||
)
|
||||
const value: MessagesContextType = {
|
||||
contacts: mounted ? contacts : [], conversations: mounted ? conversations : [],
|
||||
messages: mounted ? messages : {}, templates: mounted ? templates : [],
|
||||
stats: mounted ? stats : { total_contacts: 0, total_conversations: 0, total_messages: 0, unread_messages: 0 },
|
||||
unreadCount: mounted ? unreadCount : 0,
|
||||
recentConversations: mounted ? recentConversations : [],
|
||||
fetchContacts, fetchConversations, fetchMessages, sendMessage, markAsRead,
|
||||
createConversation, addReaction, deleteMessage, pinConversation, muteConversation,
|
||||
isLoading, error, currentConversationId,
|
||||
setCurrentConversationId: mounted ? setCurrentConversationId : () => {}
|
||||
}
|
||||
|
||||
return (
|
||||
<MessagesContext.Provider
|
||||
value={{
|
||||
contacts,
|
||||
conversations,
|
||||
messages,
|
||||
templates,
|
||||
stats,
|
||||
unreadCount,
|
||||
recentConversations,
|
||||
fetchContacts,
|
||||
fetchConversations,
|
||||
fetchMessages,
|
||||
sendMessage,
|
||||
markAsRead,
|
||||
createConversation,
|
||||
addReaction,
|
||||
deleteMessage,
|
||||
pinConversation,
|
||||
muteConversation,
|
||||
isLoading,
|
||||
error,
|
||||
currentConversationId,
|
||||
setCurrentConversationId
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MessagesContext.Provider>
|
||||
)
|
||||
return <MessagesContext.Provider value={value}>{children}</MessagesContext.Provider>
|
||||
}
|
||||
|
||||
export function useMessages() {
|
||||
@@ -851,75 +134,3 @@ export function useMessages() {
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================
|
||||
|
||||
export function formatMessageTime(timestamp: string): string {
|
||||
const date = new Date(timestamp)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMs / 3600000)
|
||||
const diffDays = Math.floor(diffMs / 86400000)
|
||||
|
||||
if (diffMins < 1) return 'Gerade eben'
|
||||
if (diffMins < 60) return `${diffMins} Min.`
|
||||
if (diffHours < 24) return `${diffHours} Std.`
|
||||
if (diffDays === 1) return 'Gestern'
|
||||
if (diffDays < 7) return `${diffDays} Tage`
|
||||
|
||||
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })
|
||||
}
|
||||
|
||||
export function formatMessageDate(timestamp: string): string {
|
||||
const date = new Date(timestamp)
|
||||
const now = new Date()
|
||||
const diffDays = Math.floor((now.getTime() - date.getTime()) / 86400000)
|
||||
|
||||
if (diffDays === 0) return 'Heute'
|
||||
if (diffDays === 1) return 'Gestern'
|
||||
if (diffDays < 7) {
|
||||
return date.toLocaleDateString('de-DE', { weekday: 'long' })
|
||||
}
|
||||
|
||||
return date.toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' })
|
||||
}
|
||||
|
||||
export function getContactInitials(name: string): string {
|
||||
const parts = name.split(' ').filter(p => p.length > 0)
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
|
||||
}
|
||||
return name.slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
export function getRoleLabel(role: Contact['role']): string {
|
||||
const labels: Record<Contact['role'], string> = {
|
||||
parent: 'Eltern',
|
||||
teacher: 'Lehrkraft',
|
||||
staff: 'Verwaltung',
|
||||
student: 'Schueler/in'
|
||||
}
|
||||
return labels[role] || role
|
||||
}
|
||||
|
||||
export function getRoleColor(role: Contact['role'], isDark: boolean): string {
|
||||
const colors: Record<Contact['role'], { dark: string; light: string }> = {
|
||||
parent: { dark: 'bg-blue-500/20 text-blue-300', light: 'bg-blue-100 text-blue-700' },
|
||||
teacher: { dark: 'bg-purple-500/20 text-purple-300', light: 'bg-purple-100 text-purple-700' },
|
||||
staff: { dark: 'bg-amber-500/20 text-amber-300', light: 'bg-amber-100 text-amber-700' },
|
||||
student: { dark: 'bg-green-500/20 text-green-300', light: 'bg-green-100 text-green-700' }
|
||||
}
|
||||
return isDark ? colors[role].dark : colors[role].light
|
||||
}
|
||||
|
||||
// Emoji categories for picker
|
||||
export const emojiCategories = {
|
||||
'Häufig': ['👍', '❤️', '😊', '😂', '🙏', '👏', '🎉', '✅', '📝', '📚'],
|
||||
'Smileys': ['😀', '😃', '😄', '😁', '😅', '😂', '🤣', '😊', '😇', '🙂', '😉', '😌', '😍', '🥰', '😘'],
|
||||
'Gesten': ['👍', '👎', '👌', '✌️', '🤞', '🤝', '👏', '🙌', '👋', '✋', '🤚', '🖐️', '🙏'],
|
||||
'Symbole': ['❤️', '💙', '💚', '💛', '🧡', '💜', '✅', '❌', '⭐', '🌟', '💯', '📌', '📎'],
|
||||
'Schule': ['📚', '📖', '📝', '✏️', '📓', '📕', '📗', '📘', '🎓', '🏫', '📅', '⏰', '🔔']
|
||||
}
|
||||
|
||||
69
studio-v2/lib/messages/helpers.ts
Normal file
69
studio-v2/lib/messages/helpers.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { Contact } from './types'
|
||||
|
||||
export function formatMessageTime(timestamp: string): string {
|
||||
const date = new Date(timestamp)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMs / 3600000)
|
||||
const diffDays = Math.floor(diffMs / 86400000)
|
||||
|
||||
if (diffMins < 1) return 'Gerade eben'
|
||||
if (diffMins < 60) return `${diffMins} Min.`
|
||||
if (diffHours < 24) return `${diffHours} Std.`
|
||||
if (diffDays === 1) return 'Gestern'
|
||||
if (diffDays < 7) return `${diffDays} Tage`
|
||||
|
||||
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })
|
||||
}
|
||||
|
||||
export function formatMessageDate(timestamp: string): string {
|
||||
const date = new Date(timestamp)
|
||||
const now = new Date()
|
||||
const diffDays = Math.floor((now.getTime() - date.getTime()) / 86400000)
|
||||
|
||||
if (diffDays === 0) return 'Heute'
|
||||
if (diffDays === 1) return 'Gestern'
|
||||
if (diffDays < 7) {
|
||||
return date.toLocaleDateString('de-DE', { weekday: 'long' })
|
||||
}
|
||||
|
||||
return date.toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' })
|
||||
}
|
||||
|
||||
export function getContactInitials(name: string): string {
|
||||
const parts = name.split(' ').filter(p => p.length > 0)
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
|
||||
}
|
||||
return name.slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
export function getRoleLabel(role: Contact['role']): string {
|
||||
const labels: Record<Contact['role'], string> = {
|
||||
parent: 'Eltern',
|
||||
teacher: 'Lehrkraft',
|
||||
staff: 'Verwaltung',
|
||||
student: 'Schueler/in'
|
||||
}
|
||||
return labels[role] || role
|
||||
}
|
||||
|
||||
export function getRoleColor(role: Contact['role'], isDark: boolean): string {
|
||||
const colors: Record<Contact['role'], { dark: string; light: string }> = {
|
||||
parent: { dark: 'bg-blue-500/20 text-blue-300', light: 'bg-blue-100 text-blue-700' },
|
||||
teacher: { dark: 'bg-purple-500/20 text-purple-300', light: 'bg-purple-100 text-purple-700' },
|
||||
staff: { dark: 'bg-amber-500/20 text-amber-300', light: 'bg-amber-100 text-amber-700' },
|
||||
student: { dark: 'bg-green-500/20 text-green-300', light: 'bg-green-100 text-green-700' }
|
||||
}
|
||||
return isDark ? colors[role].dark : colors[role].light
|
||||
}
|
||||
|
||||
// Emoji categories for picker
|
||||
export const emojiCategories = {
|
||||
'Häufig': ['👍', '❤️', '😊', '😂', '🙏', '👏', '🎉', '✅', '📝', '📚'],
|
||||
'Smileys': ['😀', '😃', '😄', '😁', '😅', '😂', '🤣', '😊', '😇', '🙂', '😉', '😌', '😍', '🥰', '😘'],
|
||||
'Gesten': ['👍', '👎', '👌', '✌️', '🤞', '🤝', '👏', '🙌', '👋', '✋', '🤚', '🖐️', '🙏'],
|
||||
'Symbole': ['❤️', '💙', '💚', '💛', '🧡', '💜', '✅', '❌', '⭐', '🌟', '💯', '📌', '📎'],
|
||||
'Schule': ['📚', '📖', '📝', '✏️', '📓', '📕', '📗', '📘', '🎓', '🏫', '📅', '⏰', '🔔']
|
||||
}
|
||||
227
studio-v2/lib/messages/mock-data.ts
Normal file
227
studio-v2/lib/messages/mock-data.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import type { Contact, Conversation, Message, MessageTemplate } from './types'
|
||||
|
||||
export const mockContacts: Contact[] = [
|
||||
{
|
||||
id: 'contact_mueller',
|
||||
name: 'Familie Mueller',
|
||||
email: 'familie.mueller@gmail.com',
|
||||
phone: '+49 170 1234567',
|
||||
role: 'parent',
|
||||
student_name: 'Max Mueller',
|
||||
class_name: '10a',
|
||||
notes: 'Bevorzugt Kommunikation per E-Mail',
|
||||
tags: ['aktiv', 'Elternbeirat'],
|
||||
preferred_channel: 'email',
|
||||
online: false,
|
||||
last_seen: new Date(Date.now() - 1800000).toISOString(),
|
||||
created_at: new Date(Date.now() - 86400000 * 30).toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: 'contact_schmidt',
|
||||
name: 'Petra Schmidt',
|
||||
email: 'p.schmidt@web.de',
|
||||
phone: '+49 171 9876543',
|
||||
role: 'parent',
|
||||
student_name: 'Lisa Schmidt',
|
||||
class_name: '10a',
|
||||
tags: ['responsive'],
|
||||
preferred_channel: 'pwa',
|
||||
online: true,
|
||||
created_at: new Date(Date.now() - 86400000 * 60).toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: 'contact_weber',
|
||||
name: 'Sabine Weber',
|
||||
email: 's.weber@schule-musterstadt.de',
|
||||
role: 'teacher',
|
||||
tags: ['Fachschaft Deutsch', 'Klassenleitung 9b'],
|
||||
preferred_channel: 'pwa',
|
||||
online: true,
|
||||
last_seen: new Date().toISOString(),
|
||||
created_at: new Date(Date.now() - 86400000 * 90).toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: 'contact_hoffmann',
|
||||
name: 'Thomas Hoffmann',
|
||||
email: 't.hoffmann@schule-musterstadt.de',
|
||||
role: 'teacher',
|
||||
tags: ['Fachschaft Mathe', 'Oberstufenkoordinator'],
|
||||
preferred_channel: 'pwa',
|
||||
online: false,
|
||||
last_seen: new Date(Date.now() - 3600000 * 2).toISOString(),
|
||||
created_at: new Date(Date.now() - 86400000 * 120).toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: 'contact_becker',
|
||||
name: 'Familie Becker',
|
||||
email: 'becker.familie@gmx.de',
|
||||
phone: '+49 172 5551234',
|
||||
role: 'parent',
|
||||
student_name: 'Tim Becker',
|
||||
class_name: '10a',
|
||||
tags: [],
|
||||
preferred_channel: 'email',
|
||||
online: false,
|
||||
last_seen: new Date(Date.now() - 86400000).toISOString(),
|
||||
created_at: new Date(Date.now() - 86400000 * 45).toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: 'contact_klein',
|
||||
name: 'Monika Klein',
|
||||
email: 'm.klein@schule-musterstadt.de',
|
||||
role: 'staff',
|
||||
tags: ['Sekretariat'],
|
||||
preferred_channel: 'pwa',
|
||||
online: true,
|
||||
created_at: new Date(Date.now() - 86400000 * 180).toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: 'contact_fischer',
|
||||
name: 'Familie Fischer',
|
||||
email: 'fischer@t-online.de',
|
||||
phone: '+49 173 4445566',
|
||||
role: 'parent',
|
||||
student_name: 'Anna Fischer',
|
||||
class_name: '11b',
|
||||
tags: ['Foerderverein'],
|
||||
preferred_channel: 'pwa',
|
||||
online: false,
|
||||
last_seen: new Date(Date.now() - 7200000).toISOString(),
|
||||
created_at: new Date(Date.now() - 86400000 * 75).toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: 'contact_meyer',
|
||||
name: 'Dr. Hans Meyer',
|
||||
email: 'h.meyer@schule-musterstadt.de',
|
||||
role: 'teacher',
|
||||
tags: ['Schulleitung', 'Stellvertretender Schulleiter'],
|
||||
preferred_channel: 'email',
|
||||
online: false,
|
||||
last_seen: new Date(Date.now() - 3600000).toISOString(),
|
||||
created_at: new Date(Date.now() - 86400000 * 365).toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
]
|
||||
|
||||
export const mockConversations: Conversation[] = [
|
||||
{
|
||||
id: 'conv_mueller',
|
||||
participant_ids: ['contact_mueller'],
|
||||
created_at: new Date(Date.now() - 86400000 * 7).toISOString(),
|
||||
updated_at: new Date(Date.now() - 300000).toISOString(),
|
||||
last_message: 'Vielen Dank fuer die Info! Max freut sich schon auf die Klassenfahrt 🎉',
|
||||
last_message_time: new Date(Date.now() - 300000).toISOString(),
|
||||
unread_count: 2,
|
||||
is_group: false,
|
||||
title: 'Familie Mueller',
|
||||
pinned: true
|
||||
},
|
||||
{
|
||||
id: 'conv_schmidt',
|
||||
participant_ids: ['contact_schmidt'],
|
||||
created_at: new Date(Date.now() - 86400000 * 14).toISOString(),
|
||||
updated_at: new Date(Date.now() - 3600000).toISOString(),
|
||||
last_message: 'Lisa war heute krank, sie kommt morgen wieder.',
|
||||
last_message_time: new Date(Date.now() - 3600000).toISOString(),
|
||||
unread_count: 0,
|
||||
is_group: false,
|
||||
title: 'Petra Schmidt'
|
||||
},
|
||||
{
|
||||
id: 'conv_weber',
|
||||
participant_ids: ['contact_weber'],
|
||||
created_at: new Date(Date.now() - 86400000 * 30).toISOString(),
|
||||
updated_at: new Date(Date.now() - 7200000).toISOString(),
|
||||
last_message: 'Koenntest du mir die Klausuraufgaben bis Freitag schicken? 📝',
|
||||
last_message_time: new Date(Date.now() - 7200000).toISOString(),
|
||||
unread_count: 1,
|
||||
is_group: false,
|
||||
title: 'Sabine Weber',
|
||||
typing: true
|
||||
},
|
||||
{
|
||||
id: 'conv_hoffmann',
|
||||
participant_ids: ['contact_hoffmann'],
|
||||
created_at: new Date(Date.now() - 86400000 * 5).toISOString(),
|
||||
updated_at: new Date(Date.now() - 86400000).toISOString(),
|
||||
last_message: 'Die Notenkonferenz ist am 15.02. um 14:00 Uhr.',
|
||||
last_message_time: new Date(Date.now() - 86400000).toISOString(),
|
||||
unread_count: 0,
|
||||
is_group: false,
|
||||
title: 'Thomas Hoffmann'
|
||||
},
|
||||
{
|
||||
id: 'conv_becker',
|
||||
participant_ids: ['contact_becker'],
|
||||
created_at: new Date(Date.now() - 86400000 * 3).toISOString(),
|
||||
updated_at: new Date(Date.now() - 172800000).toISOString(),
|
||||
last_message: 'Wir haben die Einverstaendniserklaerung unterschrieben.',
|
||||
last_message_time: new Date(Date.now() - 172800000).toISOString(),
|
||||
unread_count: 0,
|
||||
is_group: false,
|
||||
title: 'Familie Becker',
|
||||
muted: true
|
||||
},
|
||||
{
|
||||
id: 'conv_fachschaft',
|
||||
participant_ids: ['contact_weber', 'contact_hoffmann', 'contact_meyer'],
|
||||
created_at: new Date(Date.now() - 86400000 * 60).toISOString(),
|
||||
updated_at: new Date(Date.now() - 14400000).toISOString(),
|
||||
last_message: 'Sabine: Hat jemand die neuen Lehrplaene schon gelesen?',
|
||||
last_message_time: new Date(Date.now() - 14400000).toISOString(),
|
||||
unread_count: 3,
|
||||
is_group: true,
|
||||
title: 'Fachschaft Deutsch 📚'
|
||||
}
|
||||
]
|
||||
|
||||
export const mockMessages: Record<string, Message[]> = {
|
||||
'conv_mueller': [
|
||||
{ id: 'msg_m1', conversation_id: 'conv_mueller', sender_id: 'self', content: 'Guten Tag Frau Mueller,\n\nich moechte Sie ueber die anstehende Klassenfahrt nach Berlin informieren. Die Reise findet vom 15.-19. April statt.', content_type: 'text', timestamp: new Date(Date.now() - 86400000).toISOString(), read: true, delivered: true, send_email: true, email_sent: true, email_sent_at: new Date(Date.now() - 86400000).toISOString() },
|
||||
{ id: 'msg_m2', conversation_id: 'conv_mueller', sender_id: 'self', content: 'Die Kosten belaufen sich auf 280 Euro pro Schueler. Bitte ueberweisen Sie den Betrag bis zum 01.03. auf das Schulkonto.', content_type: 'text', timestamp: new Date(Date.now() - 86400000 + 60000).toISOString(), read: true, delivered: true, send_email: false, email_sent: false },
|
||||
{ id: 'msg_m3', conversation_id: 'conv_mueller', sender_id: 'contact_mueller', content: 'Vielen Dank fuer die Information! Wir werden den Betrag diese Woche ueberweisen.', content_type: 'text', timestamp: new Date(Date.now() - 3600000).toISOString(), read: false, delivered: true, send_email: false, email_sent: false, reactions: [{ emoji: '👍', user_id: 'self' }] },
|
||||
{ id: 'msg_m4', conversation_id: 'conv_mueller', sender_id: 'contact_mueller', content: 'Vielen Dank fuer die Info! Max freut sich schon auf die Klassenfahrt 🎉', content_type: 'text', timestamp: new Date(Date.now() - 300000).toISOString(), read: false, delivered: true, send_email: false, email_sent: false },
|
||||
],
|
||||
'conv_schmidt': [
|
||||
{ id: 'msg_s1', conversation_id: 'conv_schmidt', sender_id: 'contact_schmidt', content: 'Guten Morgen! Lisa ist heute leider krank und kann nicht zur Schule kommen.', content_type: 'text', timestamp: new Date(Date.now() - 86400000 * 2).toISOString(), read: true, delivered: true, send_email: false, email_sent: false },
|
||||
{ id: 'msg_s2', conversation_id: 'conv_schmidt', sender_id: 'self', content: 'Gute Besserung an Lisa! 🤒 Soll ich ihr die Hausaufgaben zukommen lassen?', content_type: 'text', timestamp: new Date(Date.now() - 86400000 * 2 + 1800000).toISOString(), read: true, delivered: true, send_email: false, email_sent: false },
|
||||
{ id: 'msg_s3', conversation_id: 'conv_schmidt', sender_id: 'contact_schmidt', content: 'Das waere sehr nett, vielen Dank! 🙏', content_type: 'text', timestamp: new Date(Date.now() - 86400000 * 2 + 3600000).toISOString(), read: true, delivered: true, send_email: false, email_sent: false },
|
||||
{ id: 'msg_s4', conversation_id: 'conv_schmidt', sender_id: 'self', content: 'Hier sind die Hausaufgaben fuer diese Woche:\n\n📖 Deutsch: Seite 45-48 lesen\n📝 Mathe: Aufgaben 1-5 auf Seite 112\n🔬 Bio: Referat vorbereiten', content_type: 'text', timestamp: new Date(Date.now() - 86400000).toISOString(), read: true, delivered: true, send_email: true, email_sent: true },
|
||||
{ id: 'msg_s5', conversation_id: 'conv_schmidt', sender_id: 'contact_schmidt', content: 'Lisa war heute krank, sie kommt morgen wieder.', content_type: 'text', timestamp: new Date(Date.now() - 3600000).toISOString(), read: true, delivered: true, send_email: false, email_sent: false },
|
||||
],
|
||||
'conv_weber': [
|
||||
{ id: 'msg_w1', conversation_id: 'conv_weber', sender_id: 'contact_weber', content: 'Hi! Hast du schon die neuen Abi-Themen gesehen?', content_type: 'text', timestamp: new Date(Date.now() - 86400000 * 3).toISOString(), read: true, delivered: true, send_email: false, email_sent: false },
|
||||
{ id: 'msg_w2', conversation_id: 'conv_weber', sender_id: 'self', content: 'Ja, habe ich! Finde ich ganz gut machbar dieses Jahr. 📚', content_type: 'text', timestamp: new Date(Date.now() - 86400000 * 3 + 1800000).toISOString(), read: true, delivered: true, send_email: false, email_sent: false },
|
||||
{ id: 'msg_w3', conversation_id: 'conv_weber', sender_id: 'contact_weber', content: 'Koenntest du mir die Klausuraufgaben bis Freitag schicken? 📝', content_type: 'text', timestamp: new Date(Date.now() - 7200000).toISOString(), read: false, delivered: true, send_email: false, email_sent: false },
|
||||
],
|
||||
'conv_hoffmann': [
|
||||
{ id: 'msg_h1', conversation_id: 'conv_hoffmann', sender_id: 'contact_hoffmann', content: 'Kurze Info: Die Notenkonferenz ist am 15.02. um 14:00 Uhr.', content_type: 'text', timestamp: new Date(Date.now() - 86400000 * 2).toISOString(), read: true, delivered: true, send_email: false, email_sent: false },
|
||||
{ id: 'msg_h2', conversation_id: 'conv_hoffmann', sender_id: 'self', content: 'Danke fuer die Info! Bin dabei. 👍', content_type: 'text', timestamp: new Date(Date.now() - 86400000 * 2 + 3600000).toISOString(), read: true, delivered: true, send_email: false, email_sent: false },
|
||||
{ id: 'msg_h3', conversation_id: 'conv_hoffmann', sender_id: 'contact_hoffmann', content: 'Die Notenkonferenz ist am 15.02. um 14:00 Uhr.', content_type: 'text', timestamp: new Date(Date.now() - 86400000).toISOString(), read: true, delivered: true, send_email: false, email_sent: false },
|
||||
],
|
||||
'conv_becker': [
|
||||
{ id: 'msg_b1', conversation_id: 'conv_becker', sender_id: 'self', content: 'Guten Tag Familie Becker,\n\nbitte vergessen Sie nicht, die Einverstaendniserklaerung fuer den Schwimmunterricht zu unterschreiben.', content_type: 'text', timestamp: new Date(Date.now() - 86400000 * 4).toISOString(), read: true, delivered: true, send_email: true, email_sent: true },
|
||||
{ id: 'msg_b2', conversation_id: 'conv_becker', sender_id: 'contact_becker', content: 'Wir haben die Einverstaendniserklaerung unterschrieben.', content_type: 'text', timestamp: new Date(Date.now() - 172800000).toISOString(), read: true, delivered: true, send_email: false, email_sent: false },
|
||||
],
|
||||
'conv_fachschaft': [
|
||||
{ id: 'msg_f1', conversation_id: 'conv_fachschaft', sender_id: 'contact_meyer', content: 'Liebe Kolleginnen und Kollegen,\n\ndie neuen Lehrplaene sind jetzt online verfuegbar.', content_type: 'text', timestamp: new Date(Date.now() - 86400000).toISOString(), read: true, delivered: true, send_email: false, email_sent: false },
|
||||
{ id: 'msg_f2', conversation_id: 'conv_fachschaft', sender_id: 'contact_hoffmann', content: 'Danke fuer die Info! Werde ich mir heute Abend anschauen.', content_type: 'text', timestamp: new Date(Date.now() - 72000000).toISOString(), read: true, delivered: true, send_email: false, email_sent: false },
|
||||
{ id: 'msg_f3', conversation_id: 'conv_fachschaft', sender_id: 'contact_weber', content: 'Hat jemand die neuen Lehrplaene schon gelesen?', content_type: 'text', timestamp: new Date(Date.now() - 14400000).toISOString(), read: false, delivered: true, send_email: false, email_sent: false },
|
||||
{ id: 'msg_f4', conversation_id: 'conv_fachschaft', sender_id: 'contact_hoffmann', content: 'Noch nicht komplett, aber sieht interessant aus! 📖', content_type: 'text', timestamp: new Date(Date.now() - 10800000).toISOString(), read: false, delivered: true, send_email: false, email_sent: false },
|
||||
{ id: 'msg_f5', conversation_id: 'conv_fachschaft', sender_id: 'contact_meyer', content: 'Wir sollten naechste Woche eine Besprechung ansetzen.', content_type: 'text', timestamp: new Date(Date.now() - 7200000).toISOString(), read: false, delivered: true, send_email: false, email_sent: false },
|
||||
]
|
||||
}
|
||||
|
||||
export const mockTemplates: MessageTemplate[] = [
|
||||
{ id: 'tpl_1', name: 'Krankmeldung bestaetigen', content: 'Vielen Dank fuer die Krankmeldung. Gute Besserung! 🤒', created_at: new Date().toISOString() },
|
||||
{ id: 'tpl_2', name: 'Hausaufgaben senden', content: 'Hier sind die Hausaufgaben fuer diese Woche:\n\n📖 Deutsch: \n📝 Mathe: \n🔬 Bio: ', created_at: new Date().toISOString() },
|
||||
{ id: 'tpl_3', name: 'Elterngespraech anfragen', content: 'Guten Tag,\n\nich wuerde gerne ein Elterngespraech mit Ihnen vereinbaren. Wann haetten Sie Zeit?', created_at: new Date().toISOString() },
|
||||
{ id: 'tpl_4', name: 'Termin bestaetigen', content: 'Vielen Dank, der Termin ist bestaetigt. Ich freue mich auf unser Gespraech! 📅', created_at: new Date().toISOString() },
|
||||
]
|
||||
99
studio-v2/lib/messages/types.ts
Normal file
99
studio-v2/lib/messages/types.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
export interface Contact {
|
||||
id: string
|
||||
name: string
|
||||
email?: string
|
||||
phone?: string
|
||||
role: 'parent' | 'teacher' | 'staff' | 'student'
|
||||
student_name?: string
|
||||
class_name?: string
|
||||
notes?: string
|
||||
tags: string[]
|
||||
avatar_url?: string
|
||||
preferred_channel: 'email' | 'matrix' | 'pwa'
|
||||
online: boolean
|
||||
last_seen?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string
|
||||
conversation_id: string
|
||||
sender_id: string // "self" for own messages
|
||||
content: string
|
||||
content_type: 'text' | 'file' | 'image' | 'voice'
|
||||
file_url?: string
|
||||
file_name?: string
|
||||
timestamp: string
|
||||
read: boolean
|
||||
read_at?: string
|
||||
delivered: boolean
|
||||
send_email: boolean
|
||||
email_sent: boolean
|
||||
email_sent_at?: string
|
||||
email_error?: string
|
||||
reply_to?: string // ID of message being replied to
|
||||
reactions?: { emoji: string; user_id: string }[]
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
id: string
|
||||
participant_ids: string[]
|
||||
group_id?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
last_message?: string
|
||||
last_message_time?: string
|
||||
unread_count: number
|
||||
is_group: boolean
|
||||
title?: string
|
||||
typing?: boolean // Someone is typing
|
||||
pinned?: boolean
|
||||
muted?: boolean
|
||||
archived?: boolean
|
||||
}
|
||||
|
||||
export interface MessageTemplate {
|
||||
id: string
|
||||
name: string
|
||||
content: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface MessagesStats {
|
||||
total_contacts: number
|
||||
total_conversations: number
|
||||
total_messages: number
|
||||
unread_messages: number
|
||||
}
|
||||
|
||||
export interface MessagesContextType {
|
||||
// Data
|
||||
contacts: Contact[]
|
||||
conversations: Conversation[]
|
||||
messages: Record<string, Message[]> // conversationId -> messages
|
||||
templates: MessageTemplate[]
|
||||
stats: MessagesStats
|
||||
|
||||
// Computed
|
||||
unreadCount: number
|
||||
recentConversations: Conversation[]
|
||||
|
||||
// Actions
|
||||
fetchContacts: () => Promise<void>
|
||||
fetchConversations: () => Promise<void>
|
||||
fetchMessages: (conversationId: string) => Promise<Message[]>
|
||||
sendMessage: (conversationId: string, content: string, sendEmail?: boolean, replyTo?: string) => Promise<Message | null>
|
||||
markAsRead: (conversationId: string) => Promise<void>
|
||||
createConversation: (contactId: string) => Promise<Conversation | null>
|
||||
addReaction: (messageId: string, emoji: string) => void
|
||||
deleteMessage: (conversationId: string, messageId: string) => void
|
||||
pinConversation: (conversationId: string) => void
|
||||
muteConversation: (conversationId: string) => void
|
||||
|
||||
// State
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
currentConversationId: string | null
|
||||
setCurrentConversationId: (id: string | null) => void
|
||||
}
|
||||
Reference in New Issue
Block a user