[split-required] Split website + studio-v2 monoliths (Phase 3 continued)
Website (14 monoliths split): - compliance/page.tsx (1,519 → 9), docs/audit (1,262 → 20) - quality (1,231 → 16), alerts (1,203 → 10), docs (1,202 → 11) - i18n.ts (1,173 → 8 language files) - unity-bridge (1,094 → 12), backlog (1,087 → 6) - training (1,066 → 8), rag (1,063 → 8) - Deleted index_original.ts (4,899 LOC dead backup) Studio-v2 (5 monoliths split): - meet/page.tsx (1,481 → 9), messages (1,166 → 9) - AlertsB2BContext.tsx (1,165 → 5 modules) - alerts-b2b/page.tsx (1,019 → 6), korrektur/archiv (1,001 → 6) All existing imports preserved. Zero new TypeScript errors. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
120
studio-v2/app/alerts-b2b/_components/DecisionTraceModal.tsx
Normal file
120
studio-v2/app/alerts-b2b/_components/DecisionTraceModal.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
'use client'
|
||||
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import type { B2BHit } from '@/lib/AlertsB2BContext'
|
||||
|
||||
export function DecisionTraceModal({
|
||||
hit,
|
||||
onClose
|
||||
}: {
|
||||
hit: B2BHit
|
||||
onClose: () => void
|
||||
}) {
|
||||
const { isDark } = useTheme()
|
||||
const trace = hit.decisionTrace
|
||||
|
||||
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-xl 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-4">
|
||||
<h3 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Decision Trace
|
||||
</h3>
|
||||
<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>
|
||||
|
||||
{trace ? (
|
||||
<div className="space-y-4">
|
||||
{/* Rules Triggered */}
|
||||
<div className={`p-4 rounded-xl ${isDark ? 'bg-white/5' : 'bg-slate-50'}`}>
|
||||
<h4 className={`text-sm font-medium mb-2 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
Regeln ausgeloest
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{trace.rulesTriggered.map((rule, idx) => (
|
||||
<span key={idx} className={`px-2 py-1 rounded-lg text-xs ${
|
||||
isDark ? 'bg-blue-500/20 text-blue-300' : 'bg-blue-100 text-blue-700'
|
||||
}`}>
|
||||
{rule}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* LLM Used */}
|
||||
<div className={`p-4 rounded-xl ${isDark ? 'bg-white/5' : 'bg-slate-50'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>LLM verwendet</span>
|
||||
<span className={`px-2 py-1 rounded-lg text-xs ${
|
||||
trace.llmUsed
|
||||
? isDark ? 'bg-purple-500/20 text-purple-300' : 'bg-purple-100 text-purple-700'
|
||||
: isDark ? 'bg-slate-500/20 text-slate-300' : 'bg-slate-100 text-slate-500'
|
||||
}`}>
|
||||
{trace.llmUsed ? `Ja (${Math.round((trace.llmConfidence || 0) * 100)}% Konfidenz)` : 'Nein'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Signals */}
|
||||
<div className="space-y-3">
|
||||
{trace.signals.procurementSignalsFound.length > 0 && (
|
||||
<div className={`p-4 rounded-xl ${isDark ? 'bg-green-500/10' : 'bg-green-50'}`}>
|
||||
<h4 className={`text-sm font-medium mb-2 ${isDark ? 'text-green-300' : 'text-green-700'}`}>
|
||||
Beschaffungs-Signale
|
||||
</h4>
|
||||
<p className={`text-sm ${isDark ? 'text-green-200/80' : 'text-green-600'}`}>
|
||||
{trace.signals.procurementSignalsFound.join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{trace.signals.publicBuyerSignalsFound.length > 0 && (
|
||||
<div className={`p-4 rounded-xl ${isDark ? 'bg-blue-500/10' : 'bg-blue-50'}`}>
|
||||
<h4 className={`text-sm font-medium mb-2 ${isDark ? 'text-blue-300' : 'text-blue-700'}`}>
|
||||
Oeffentliche Auftraggeber
|
||||
</h4>
|
||||
<p className={`text-sm ${isDark ? 'text-blue-200/80' : 'text-blue-600'}`}>
|
||||
{trace.signals.publicBuyerSignalsFound.join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{trace.signals.productSignalsFound.length > 0 && (
|
||||
<div className={`p-4 rounded-xl ${isDark ? 'bg-amber-500/10' : 'bg-amber-50'}`}>
|
||||
<h4 className={`text-sm font-medium mb-2 ${isDark ? 'text-amber-300' : 'text-amber-700'}`}>
|
||||
Produkt-Signale
|
||||
</h4>
|
||||
<p className={`text-sm ${isDark ? 'text-amber-200/80' : 'text-amber-600'}`}>
|
||||
{trace.signals.productSignalsFound.join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{trace.signals.negativesFound.length > 0 && (
|
||||
<div className={`p-4 rounded-xl ${isDark ? 'bg-red-500/10' : 'bg-red-50'}`}>
|
||||
<h4 className={`text-sm font-medium mb-2 ${isDark ? 'text-red-300' : 'text-red-700'}`}>
|
||||
Negative Signale
|
||||
</h4>
|
||||
<p className={`text-sm ${isDark ? 'text-red-200/80' : 'text-red-600'}`}>
|
||||
{trace.signals.negativesFound.join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className={`text-sm ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
Kein Decision Trace verfuegbar.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
49
studio-v2/app/alerts-b2b/_components/DigestView.tsx
Normal file
49
studio-v2/app/alerts-b2b/_components/DigestView.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
'use client'
|
||||
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import type { B2BHit } from '@/lib/AlertsB2BContext'
|
||||
import { HitCard } from './HitCard'
|
||||
|
||||
export function DigestView({
|
||||
hits,
|
||||
onHitClick
|
||||
}: {
|
||||
hits: B2BHit[]
|
||||
onHitClick: (hit: B2BHit) => void
|
||||
}) {
|
||||
const { isDark } = useTheme()
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="text-2xl">📬</span>
|
||||
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Tages-Digest (Top 10)
|
||||
</h3>
|
||||
</div>
|
||||
{hits.length === 0 ? (
|
||||
<div className={`text-center py-8 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
<span className="text-4xl block mb-2">🎉</span>
|
||||
<p>Keine relevanten Hits heute</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{hits.map((hit, idx) => (
|
||||
<div key={hit.id} className="flex items-start gap-3">
|
||||
<span className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium ${
|
||||
idx < 3
|
||||
? 'bg-amber-500/20 text-amber-400'
|
||||
: isDark ? 'bg-white/10 text-white/60' : 'bg-slate-100 text-slate-500'
|
||||
}`}>
|
||||
{idx + 1}
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<HitCard hit={hit} onClick={() => onHitClick(hit)} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
163
studio-v2/app/alerts-b2b/_components/EmailImportModal.tsx
Normal file
163
studio-v2/app/alerts-b2b/_components/EmailImportModal.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
|
||||
export function EmailImportModal({
|
||||
onClose,
|
||||
onImport
|
||||
}: {
|
||||
onClose: () => void
|
||||
onImport: (content: string, subject?: string) => void
|
||||
}) {
|
||||
const { isDark } = useTheme()
|
||||
const [emailSubject, setEmailSubject] = useState('')
|
||||
const [emailContent, setEmailContent] = useState('')
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!emailContent.trim()) return
|
||||
setIsProcessing(true)
|
||||
// Simulate processing delay
|
||||
await new Promise(resolve => setTimeout(resolve, 800))
|
||||
onImport(emailContent, emailSubject || undefined)
|
||||
setIsProcessing(false)
|
||||
onClose()
|
||||
}
|
||||
|
||||
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">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center text-xl">
|
||||
📧
|
||||
</div>
|
||||
<div>
|
||||
<h3 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
E-Mail manuell einfuegen
|
||||
</h3>
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
Fuegen Sie den Inhalt einer Google Alert E-Mail ein
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className={`p-2 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'}`}>
|
||||
<svg className={`w-5 h-5 ${isDark ? 'text-white/60' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Subject (optional) */}
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-2 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
Betreff (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="z.B. Google Alert - Parkscheinautomaten"
|
||||
value={emailSubject}
|
||||
onChange={(e) => setEmailSubject(e.target.value)}
|
||||
className={`w-full px-4 py-3 rounded-xl border ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white placeholder-white/40'
|
||||
: 'bg-white border-slate-200 text-slate-900 placeholder-slate-400'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Email Content */}
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-2 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
E-Mail-Inhalt *
|
||||
</label>
|
||||
<textarea
|
||||
placeholder="Fuegen Sie hier den kompletten E-Mail-Inhalt ein...
|
||||
|
||||
Beispiel:
|
||||
Google Alerts
|
||||
|
||||
Parkscheinautomaten
|
||||
Tagesaktuell | 23. Januar 2026
|
||||
|
||||
Stadt Muenchen schreibt neue Parkscheinautomaten aus
|
||||
www.muenchen.de - Die Landeshauptstadt Muenchen schreibt die Beschaffung von 150 neuen Parkscheinautomaten fuer das Stadtgebiet aus. Die Submission endet am 15.02.2026..."
|
||||
value={emailContent}
|
||||
onChange={(e) => setEmailContent(e.target.value)}
|
||||
rows={12}
|
||||
className={`w-full px-4 py-3 rounded-xl border resize-none font-mono text-sm ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white placeholder-white/30'
|
||||
: 'bg-white border-slate-200 text-slate-900 placeholder-slate-400'
|
||||
}`}
|
||||
/>
|
||||
<p className={`text-xs mt-2 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
Kopieren Sie den gesamten E-Mail-Text inkl. Links aus Ihrer Google Alert E-Mail
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className={`p-4 rounded-xl ${isDark ? 'bg-purple-500/10 border border-purple-500/30' : 'bg-purple-50 border border-purple-200'}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-xl">🤖</span>
|
||||
<div>
|
||||
<p className={`text-sm font-medium ${isDark ? 'text-purple-300' : 'text-purple-700'}`}>
|
||||
KI-Verarbeitung
|
||||
</p>
|
||||
<p className={`text-sm ${isDark ? 'text-purple-200/70' : 'text-purple-600'}`}>
|
||||
Die KI analysiert den Inhalt, erkennt Beschaffungs-Signale, identifiziert
|
||||
potenzielle Auftraggeber und bewertet die Relevanz fuer Ihr Unternehmen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-3 mt-6 pt-4 border-t border-white/10">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={`px-4 py-2 rounded-xl font-medium ${
|
||||
isDark
|
||||
? 'text-white/60 hover:text-white hover:bg-white/10'
|
||||
: 'text-slate-500 hover:text-slate-900 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={!emailContent.trim() || isProcessing}
|
||||
className={`px-6 py-2 rounded-xl font-medium transition-all flex items-center gap-2 ${
|
||||
emailContent.trim() && !isProcessing
|
||||
? 'bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:shadow-lg hover:shadow-purple-500/30'
|
||||
: isDark
|
||||
? 'bg-white/10 text-white/30 cursor-not-allowed'
|
||||
: 'bg-slate-200 text-slate-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Verarbeite...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>🔍</span>
|
||||
Analysieren & Importieren
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
66
studio-v2/app/alerts-b2b/_components/HitCard.tsx
Normal file
66
studio-v2/app/alerts-b2b/_components/HitCard.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
'use client'
|
||||
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import type { B2BHit } from '@/lib/AlertsB2BContext'
|
||||
import { getImportanceLabelColor, getDecisionLabelColor, formatDeadline } from '@/lib/AlertsB2BContext'
|
||||
|
||||
export function HitCard({
|
||||
hit,
|
||||
onClick
|
||||
}: {
|
||||
hit: B2BHit
|
||||
onClick: () => void
|
||||
}) {
|
||||
const { isDark } = useTheme()
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full text-left p-4 rounded-xl transition-all group ${
|
||||
isDark
|
||||
? `bg-white/5 hover:bg-white/10 ${!hit.isRead ? 'border-l-4 border-amber-500' : ''}`
|
||||
: `bg-slate-50 hover:bg-slate-100 ${!hit.isRead ? 'border-l-4 border-amber-500' : ''}`
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium border ${getImportanceLabelColor(hit.importanceLabel, isDark)}`}>
|
||||
{hit.importanceLabel}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 rounded-lg text-xs ${getDecisionLabelColor(hit.decisionLabel, isDark)}`}>
|
||||
{hit.decisionLabel === 'relevant' ? 'Relevant' : hit.decisionLabel === 'needs_review' ? 'Pruefung' : 'Info'}
|
||||
</span>
|
||||
{hit.deadlineGuess && (
|
||||
<span className={`text-xs ${
|
||||
formatDeadline(hit.deadlineGuess).includes('Heute') || formatDeadline(hit.deadlineGuess).includes('Morgen')
|
||||
? 'text-red-500 font-medium'
|
||||
: isDark ? 'text-white/40' : 'text-slate-400'
|
||||
}`}>
|
||||
📅 {formatDeadline(hit.deadlineGuess)}
|
||||
</span>
|
||||
)}
|
||||
{!hit.isRead && (
|
||||
<span className="w-2 h-2 rounded-full bg-amber-500" />
|
||||
)}
|
||||
</div>
|
||||
<h3 className={`font-medium text-sm mb-1 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{hit.title}
|
||||
</h3>
|
||||
<p className={`text-xs truncate ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
{hit.snippet}
|
||||
</p>
|
||||
<div className={`flex items-center gap-3 mt-2 text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
{hit.buyerGuess && <span>🏛️ {hit.buyerGuess}</span>}
|
||||
{hit.countryGuess && <span>🌍 {hit.countryGuess}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<svg className={`w-5 h-5 flex-shrink-0 transition-colors ${
|
||||
isDark ? 'text-white/30 group-hover:text-white/60' : 'text-slate-300 group-hover:text-slate-600'
|
||||
}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
148
studio-v2/app/alerts-b2b/_components/HitDetailModal.tsx
Normal file
148
studio-v2/app/alerts-b2b/_components/HitDetailModal.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import type { B2BHit } from '@/lib/AlertsB2BContext'
|
||||
import { getImportanceLabelColor, getDecisionLabelColor, formatDeadline } from '@/lib/AlertsB2BContext'
|
||||
import { DecisionTraceModal } from './DecisionTraceModal'
|
||||
|
||||
export function HitDetailModal({
|
||||
hit,
|
||||
onClose,
|
||||
onFeedback
|
||||
}: {
|
||||
hit: B2BHit
|
||||
onClose: () => void
|
||||
onFeedback: (feedback: 'relevant' | 'irrelevant') => void
|
||||
}) {
|
||||
const { isDark } = useTheme()
|
||||
const [showTrace, setShowTrace] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className={`relative w-full max-w-2xl rounded-3xl border p-8 max-h-[90vh] overflow-y-auto ${
|
||||
isDark ? 'bg-slate-900 border-white/20' : 'bg-white border-slate-200 shadow-2xl'
|
||||
}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium border ${getImportanceLabelColor(hit.importanceLabel, isDark)}`}>
|
||||
{hit.importanceLabel}
|
||||
</span>
|
||||
<span className={`px-2 py-1 rounded-lg text-xs ${getDecisionLabelColor(hit.decisionLabel, isDark)}`}>
|
||||
{hit.decisionLabel === 'relevant' ? 'Relevant' : hit.decisionLabel === 'needs_review' ? 'Pruefung noetig' : 'Irrelevant'}
|
||||
</span>
|
||||
<span className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
Score: {hit.importanceScore}
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={onClose} className={`p-2 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'}`}>
|
||||
<svg className={`w-5 h-5 ${isDark ? 'text-white/60' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h2 className={`text-xl font-bold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{hit.title}
|
||||
</h2>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className={`flex flex-wrap gap-4 mb-4 text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
{hit.buyerGuess && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span>🏛️</span> {hit.buyerGuess}
|
||||
</span>
|
||||
)}
|
||||
{hit.countryGuess && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span>🌍</span> {hit.countryGuess}
|
||||
</span>
|
||||
)}
|
||||
{hit.deadlineGuess && (
|
||||
<span className={`flex items-center gap-1 ${
|
||||
formatDeadline(hit.deadlineGuess).includes('Heute') || formatDeadline(hit.deadlineGuess).includes('Morgen')
|
||||
? 'text-red-500 font-medium'
|
||||
: ''
|
||||
}`}>
|
||||
<span>📅</span> Frist: {formatDeadline(hit.deadlineGuess)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Snippet */}
|
||||
<div className={`rounded-xl p-4 mb-6 ${isDark ? 'bg-white/5' : 'bg-slate-50'}`}>
|
||||
<p className={isDark ? 'text-white/80' : 'text-slate-600'}>
|
||||
{hit.snippet}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Source Info */}
|
||||
<div className={`flex items-center gap-4 mb-6 text-sm ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
<span className={`px-2 py-1 rounded ${isDark ? 'bg-white/10' : 'bg-slate-100'}`}>
|
||||
{hit.sourceType === 'email' ? '📧 Email' : '📡 RSS'}
|
||||
</span>
|
||||
<a
|
||||
href={hit.originalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`hover:underline ${isDark ? 'text-blue-400' : 'text-blue-600'}`}
|
||||
>
|
||||
Original ansehen →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between pt-4 border-t border-white/10">
|
||||
{/* Feedback */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>War das relevant?</span>
|
||||
<button
|
||||
onClick={() => onFeedback('relevant')}
|
||||
className={`p-2 rounded-lg transition-all ${
|
||||
hit.userFeedback === 'relevant'
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: isDark ? 'hover:bg-white/10 text-white/40' : 'hover:bg-slate-100 text-slate-400'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onFeedback('irrelevant')}
|
||||
className={`p-2 rounded-lg transition-all ${
|
||||
hit.userFeedback === 'irrelevant'
|
||||
? 'bg-red-500/20 text-red-400'
|
||||
: isDark ? 'hover:bg-white/10 text-white/40' : 'hover:bg-slate-100 text-slate-400'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Decision Trace */}
|
||||
<button
|
||||
onClick={() => setShowTrace(true)}
|
||||
className={`px-4 py-2 rounded-lg text-sm transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white/80 hover:bg-white/20'
|
||||
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
🔍 Decision Trace
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decision Trace Modal */}
|
||||
{showTrace && (
|
||||
<DecisionTraceModal hit={hit} onClose={() => setShowTrace(false)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,130 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { GlassCard } from './GlassCard'
|
||||
import type { AbiturDokument } from './DokumentCard'
|
||||
|
||||
interface CreateKlausurFromTemplateModalProps {
|
||||
template: AbiturDokument
|
||||
onClose: () => void
|
||||
onCreate: (title: string) => void
|
||||
onFallback: () => void
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
isDark: boolean
|
||||
}
|
||||
|
||||
export function CreateKlausurFromTemplateModal({
|
||||
template,
|
||||
onClose,
|
||||
onCreate,
|
||||
onFallback,
|
||||
isLoading,
|
||||
error,
|
||||
isDark,
|
||||
}: CreateKlausurFromTemplateModalProps) {
|
||||
const [title, setTitle] = useState(
|
||||
`${template.fach} ${template.aufgabentyp || ''} ${template.jahr}`.trim()
|
||||
)
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onCreate(title)
|
||||
}
|
||||
|
||||
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-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Klausur aus Vorlage erstellen
|
||||
</h2>
|
||||
<p className={`text-sm mb-6 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
Basierend auf: {template.thema || template.dateiname}
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-red-500/20 border border-red-500/30 text-sm">
|
||||
<p className="text-red-300 mb-2">{error}</p>
|
||||
<button
|
||||
onClick={onFallback}
|
||||
className="text-purple-400 hover:text-purple-300 underline text-xs"
|
||||
>
|
||||
Zur Korrektur-Uebersicht (ohne Klausur erstellen)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className={`block text-sm mb-2 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
Klausur-Titel
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="z.B. Deutsch LK Q4 - Kafka"
|
||||
className={`w-full p-3 rounded-xl border focus:ring-2 focus:ring-purple-500 focus:border-transparent ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white placeholder-white/40'
|
||||
: 'bg-slate-100 border-slate-300 text-slate-900 placeholder-slate-400'
|
||||
}`}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Template Info */}
|
||||
<div className={`p-4 rounded-xl ${isDark ? 'bg-white/5' : 'bg-slate-100'}`}>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
{[
|
||||
{ label: 'Fach', value: template.fach },
|
||||
{ label: 'Jahr', value: String(template.jahr) },
|
||||
{ label: 'Niveau', value: template.niveau },
|
||||
{ label: 'Typ', value: template.aufgabentyp || '-' },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label}>
|
||||
<span className={isDark ? 'text-white/50' : 'text-slate-500'}>{label}:</span>
|
||||
<span className={`ml-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
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'
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
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 ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Erstelle...
|
||||
</span>
|
||||
) : (
|
||||
'Klausur erstellen'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</GlassCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
79
studio-v2/app/korrektur/archiv/_components/DokumentCard.tsx
Normal file
79
studio-v2/app/korrektur/archiv/_components/DokumentCard.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
'use client'
|
||||
|
||||
import { GlassCard } from './GlassCard'
|
||||
|
||||
export interface AbiturDokument {
|
||||
id: string
|
||||
dateiname: string
|
||||
fach: string
|
||||
jahr: number
|
||||
bundesland: string
|
||||
niveau: string
|
||||
dokumenttyp: string
|
||||
aufgabentyp?: string
|
||||
thema?: string
|
||||
download_url: string
|
||||
preview_url?: string
|
||||
file_size?: number
|
||||
page_count?: number
|
||||
}
|
||||
|
||||
interface DokumentCardProps {
|
||||
dokument: AbiturDokument
|
||||
onPreview: () => void
|
||||
onUseAsTemplate: () => void
|
||||
delay?: number
|
||||
isDark: boolean
|
||||
}
|
||||
|
||||
export function DokumentCard({ dokument, onPreview, onUseAsTemplate, delay = 0, isDark }: DokumentCardProps) {
|
||||
const typeColor = dokument.dokumenttyp === 'Erwartungshorizont' ? '#22c55e' : '#3b82f6'
|
||||
|
||||
return (
|
||||
<GlassCard delay={delay} isDark={isDark}>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className={`font-semibold truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{dokument.fach} {dokument.jahr} {dokument.niveau}
|
||||
</h3>
|
||||
<p className={`text-sm truncate ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
{dokument.thema || dokument.aufgabentyp || dokument.dateiname}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className="px-2 py-1 rounded-full text-xs font-medium flex-shrink-0 ml-2"
|
||||
style={{ backgroundColor: `${typeColor}20`, color: typeColor }}
|
||||
>
|
||||
{dokument.dokumenttyp === 'Erwartungshorizont' ? 'EH' : 'Aufgabe'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Meta */}
|
||||
<div className={`flex items-center gap-3 text-xs mb-4 ${isDark ? 'text-white/40' : 'text-slate-500'}`}>
|
||||
<span>{dokument.bundesland}</span>
|
||||
{dokument.page_count && <span>{dokument.page_count} Seiten</span>}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 mt-auto">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onPreview() }}
|
||||
className={`flex-1 px-3 py-2 rounded-xl text-sm font-medium transition-colors ${
|
||||
isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Vorschau
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onUseAsTemplate() }}
|
||||
className="flex-1 px-3 py-2 rounded-xl text-sm font-medium bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:shadow-lg hover:shadow-purple-500/30 transition-all"
|
||||
>
|
||||
Verwenden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client'
|
||||
|
||||
interface FilterDropdownProps {
|
||||
label: string
|
||||
value: string
|
||||
options: string[]
|
||||
onChange: (value: string) => void
|
||||
isDark: boolean
|
||||
}
|
||||
|
||||
export function FilterDropdown({ label, value, options, onChange, isDark }: FilterDropdownProps) {
|
||||
const inputId = `filter-${label.toLowerCase().replace(/\s+/g, '-')}`
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor={inputId} className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{label}</label>
|
||||
<select
|
||||
id={inputId}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={`px-3 py-2 rounded-xl text-sm transition-colors ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white'
|
||||
: 'bg-white border-slate-200 text-slate-900'
|
||||
} border focus:ring-2 focus:ring-purple-500 focus:border-transparent`}
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<option key={opt} value={opt} className="bg-slate-800 text-white">
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
49
studio-v2/app/korrektur/archiv/_components/GlassCard.tsx
Normal file
49
studio-v2/app/korrektur/archiv/_components/GlassCard.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, ReactNode } from 'react'
|
||||
|
||||
interface GlassCardProps {
|
||||
children: 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>
|
||||
)
|
||||
}
|
||||
164
studio-v2/app/korrektur/archiv/_components/PreviewModal.tsx
Normal file
164
studio-v2/app/korrektur/archiv/_components/PreviewModal.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { GlassCard } from './GlassCard'
|
||||
import type { AbiturDokument } from './DokumentCard'
|
||||
|
||||
interface PreviewModalProps {
|
||||
dokument: AbiturDokument | null
|
||||
onClose: () => void
|
||||
onUseAsTemplate: () => void
|
||||
isDark: boolean
|
||||
}
|
||||
|
||||
export function PreviewModal({ dokument, onClose, onUseAsTemplate, isDark }: PreviewModalProps) {
|
||||
const [zoom, setZoom] = useState(100)
|
||||
|
||||
if (!dokument) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative flex-1 flex m-4 gap-4">
|
||||
{/* PDF Viewer */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
<GlassCard className="flex-1 flex flex-col overflow-hidden" size="sm" isDark={isDark}>
|
||||
{/* Toolbar */}
|
||||
<div className={`flex items-center justify-between p-3 border-b ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setZoom(z => Math.max(50, z - 25))}
|
||||
className={`p-2 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'}`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-600'}`}>{zoom}%</span>
|
||||
<button
|
||||
onClick={() => setZoom(z => Math.min(200, z + 25))}
|
||||
className={`p-2 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'}`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={`p-2 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'}`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* PDF Content */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
<div
|
||||
className="mx-auto bg-white rounded-lg shadow-xl"
|
||||
style={{ width: `${zoom}%`, minHeight: '800px' }}
|
||||
>
|
||||
{dokument.preview_url ? (
|
||||
<iframe
|
||||
src={dokument.preview_url}
|
||||
className="w-full h-full min-h-[800px]"
|
||||
title={dokument.dateiname}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-slate-400">
|
||||
<div className="text-center">
|
||||
<svg className="w-16 h-16 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p>Vorschau nicht verfuegbar</p>
|
||||
<a
|
||||
href={dokument.download_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-purple-400 hover:underline mt-2 inline-block"
|
||||
>
|
||||
PDF herunterladen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="w-80 flex flex-col gap-4">
|
||||
{/* Close Button - prominent */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={`flex items-center gap-2 px-4 py-3 rounded-2xl font-medium transition-colors ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white hover:bg-white/20'
|
||||
: 'bg-white/70 text-slate-700 hover:bg-white/90'
|
||||
}`}
|
||||
style={{
|
||||
backdropFilter: 'blur(24px)',
|
||||
border: isDark ? '1px solid rgba(255,255,255,0.1)' : '1px solid rgba(0,0,0,0.1)',
|
||||
}}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Zurueck zum Archiv
|
||||
</button>
|
||||
|
||||
<GlassCard size="md" isDark={isDark}>
|
||||
<h3 className={`font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>Details</h3>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ label: 'Fach', value: dokument.fach },
|
||||
{ label: 'Jahr', value: String(dokument.jahr) },
|
||||
{ label: 'Bundesland', value: dokument.bundesland },
|
||||
{ label: 'Niveau', value: dokument.niveau },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label}>
|
||||
<span className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{label}</span>
|
||||
<p className={isDark ? 'text-white' : 'text-slate-900'}>{value}</p>
|
||||
</div>
|
||||
))}
|
||||
{dokument.thema && (
|
||||
<div>
|
||||
<span className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Thema</span>
|
||||
<p className={isDark ? 'text-white' : 'text-slate-900'}>{dokument.thema}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard size="md" isDark={isDark}>
|
||||
<h3 className={`font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>Aktionen</h3>
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={onUseAsTemplate}
|
||||
className="w-full 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"
|
||||
>
|
||||
Als Vorlage verwenden
|
||||
</button>
|
||||
<a
|
||||
href={dokument.download_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`block w-full px-4 py-3 rounded-xl text-center font-medium transition-colors ${
|
||||
isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Herunterladen
|
||||
</a>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { useLanguage } from '@/lib/LanguageContext'
|
||||
@@ -8,507 +8,33 @@ import { Sidebar } from '@/components/Sidebar'
|
||||
import { ThemeToggle } from '@/components/ThemeToggle'
|
||||
import { LanguageDropdown } from '@/components/LanguageDropdown'
|
||||
import { korrekturApi } from '@/lib/korrektur/api'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface AbiturDokument {
|
||||
id: string
|
||||
dateiname: string
|
||||
fach: string
|
||||
jahr: number
|
||||
bundesland: string
|
||||
niveau: string
|
||||
dokumenttyp: string
|
||||
aufgabentyp?: string
|
||||
thema?: string
|
||||
download_url: string
|
||||
preview_url?: string
|
||||
file_size?: number
|
||||
page_count?: number
|
||||
}
|
||||
|
||||
interface ThemaSuggestion {
|
||||
label: string
|
||||
count: number
|
||||
aufgabentyp: string
|
||||
kategorie?: string
|
||||
}
|
||||
import { GlassCard } from './_components/GlassCard'
|
||||
import { FilterDropdown } from './_components/FilterDropdown'
|
||||
import { DokumentCard, type AbiturDokument } from './_components/DokumentCard'
|
||||
import { PreviewModal } from './_components/PreviewModal'
|
||||
import { CreateKlausurFromTemplateModal } from './_components/CreateKlausurFromTemplateModal'
|
||||
|
||||
// =============================================================================
|
||||
// CONSTANTS
|
||||
// =============================================================================
|
||||
|
||||
// Default filter options (will be updated from API)
|
||||
const DEFAULT_FAECHER = ['Alle', 'Deutsch', 'Englisch', 'Mathematik', 'Geschichte', 'Politik']
|
||||
const DEFAULT_JAHRE = ['Alle', '2025', '2024', '2023', '2022', '2021']
|
||||
const BUNDESLAENDER = ['Alle', 'Niedersachsen', 'NRW', 'Bayern', 'Baden-Wuerttemberg', 'Hessen']
|
||||
const DEFAULT_NIVEAUS = ['Alle', 'eA', 'gA']
|
||||
const DEFAULT_DOKUMENTTYPEN = ['Alle', 'Aufgabe', 'Erwartungshorizont', 'Loesungshinweise']
|
||||
|
||||
const POPULAR_THEMES = [
|
||||
'Textanalyse',
|
||||
'Gedichtanalyse',
|
||||
'Eroerterung',
|
||||
'Dramenanalyse',
|
||||
'Sprachreflexion',
|
||||
'Romantik',
|
||||
'Expressionismus',
|
||||
'Textanalyse', 'Gedichtanalyse', 'Eroerterung', 'Dramenanalyse',
|
||||
'Sprachreflexion', 'Romantik', 'Expressionismus',
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// GLASS CARD COMPONENT
|
||||
// =============================================================================
|
||||
const SAMPLE_PDF = 'https://mozilla.github.io/pdf.js/web/compressed.tracemonkey-pldi-09.pdf'
|
||||
|
||||
interface GlassCardProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
delay?: number
|
||||
isDark?: boolean
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FILTER DROPDOWN
|
||||
// =============================================================================
|
||||
|
||||
interface FilterDropdownProps {
|
||||
label: string
|
||||
value: string
|
||||
options: string[]
|
||||
onChange: (value: string) => void
|
||||
isDark: boolean
|
||||
}
|
||||
|
||||
function FilterDropdown({ label, value, options, onChange, isDark }: FilterDropdownProps) {
|
||||
const inputId = `filter-${label.toLowerCase().replace(/\s+/g, '-')}`
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor={inputId} className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{label}</label>
|
||||
<select
|
||||
id={inputId}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={`px-3 py-2 rounded-xl text-sm transition-colors ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white'
|
||||
: 'bg-white border-slate-200 text-slate-900'
|
||||
} border focus:ring-2 focus:ring-purple-500 focus:border-transparent`}
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<option key={opt} value={opt} className="bg-slate-800 text-white">
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DOCUMENT CARD
|
||||
// =============================================================================
|
||||
|
||||
interface DokumentCardProps {
|
||||
dokument: AbiturDokument
|
||||
onPreview: () => void
|
||||
onUseAsTemplate: () => void
|
||||
delay?: number
|
||||
isDark: boolean
|
||||
}
|
||||
|
||||
function DokumentCard({ dokument, onPreview, onUseAsTemplate, delay = 0, isDark }: DokumentCardProps) {
|
||||
const typeColor = dokument.dokumenttyp === 'Erwartungshorizont' ? '#22c55e' : '#3b82f6'
|
||||
|
||||
return (
|
||||
<GlassCard delay={delay} isDark={isDark}>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className={`font-semibold truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{dokument.fach} {dokument.jahr} {dokument.niveau}
|
||||
</h3>
|
||||
<p className={`text-sm truncate ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
{dokument.thema || dokument.aufgabentyp || dokument.dateiname}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className="px-2 py-1 rounded-full text-xs font-medium flex-shrink-0 ml-2"
|
||||
style={{ backgroundColor: `${typeColor}20`, color: typeColor }}
|
||||
>
|
||||
{dokument.dokumenttyp === 'Erwartungshorizont' ? 'EH' : 'Aufgabe'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Meta */}
|
||||
<div className={`flex items-center gap-3 text-xs mb-4 ${isDark ? 'text-white/40' : 'text-slate-500'}`}>
|
||||
<span>{dokument.bundesland}</span>
|
||||
{dokument.page_count && <span>{dokument.page_count} Seiten</span>}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 mt-auto">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onPreview() }}
|
||||
className={`flex-1 px-3 py-2 rounded-xl text-sm font-medium transition-colors ${
|
||||
isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Vorschau
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onUseAsTemplate() }}
|
||||
className="flex-1 px-3 py-2 rounded-xl text-sm font-medium bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:shadow-lg hover:shadow-purple-500/30 transition-all"
|
||||
>
|
||||
Verwenden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PREVIEW MODAL
|
||||
// =============================================================================
|
||||
|
||||
interface PreviewModalProps {
|
||||
dokument: AbiturDokument | null
|
||||
onClose: () => void
|
||||
onUseAsTemplate: () => void
|
||||
isDark: boolean
|
||||
}
|
||||
|
||||
function PreviewModal({ dokument, onClose, onUseAsTemplate, isDark }: PreviewModalProps) {
|
||||
const [zoom, setZoom] = useState(100)
|
||||
|
||||
if (!dokument) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative flex-1 flex m-4 gap-4">
|
||||
{/* PDF Viewer */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
<GlassCard className="flex-1 flex flex-col overflow-hidden" size="sm" isDark={isDark}>
|
||||
{/* Toolbar */}
|
||||
<div className={`flex items-center justify-between p-3 border-b ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setZoom(z => Math.max(50, z - 25))}
|
||||
className={`p-2 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'}`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-600'}`}>{zoom}%</span>
|
||||
<button
|
||||
onClick={() => setZoom(z => Math.min(200, z + 25))}
|
||||
className={`p-2 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'}`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={`p-2 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'}`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* PDF Content */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
<div
|
||||
className="mx-auto bg-white rounded-lg shadow-xl"
|
||||
style={{ width: `${zoom}%`, minHeight: '800px' }}
|
||||
>
|
||||
{dokument.preview_url ? (
|
||||
<iframe
|
||||
src={dokument.preview_url}
|
||||
className="w-full h-full min-h-[800px]"
|
||||
title={dokument.dateiname}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-slate-400">
|
||||
<div className="text-center">
|
||||
<svg className="w-16 h-16 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p>Vorschau nicht verfuegbar</p>
|
||||
<a
|
||||
href={dokument.download_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-purple-400 hover:underline mt-2 inline-block"
|
||||
>
|
||||
PDF herunterladen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="w-80 flex flex-col gap-4">
|
||||
{/* Close Button - prominent */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={`flex items-center gap-2 px-4 py-3 rounded-2xl font-medium transition-colors ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white hover:bg-white/20'
|
||||
: 'bg-white/70 text-slate-700 hover:bg-white/90'
|
||||
}`}
|
||||
style={{
|
||||
backdropFilter: 'blur(24px)',
|
||||
border: isDark ? '1px solid rgba(255,255,255,0.1)' : '1px solid rgba(0,0,0,0.1)',
|
||||
}}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Zurueck zum Archiv
|
||||
</button>
|
||||
|
||||
<GlassCard size="md" isDark={isDark}>
|
||||
<h3 className={`font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>Details</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<span className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Fach</span>
|
||||
<p className={isDark ? 'text-white' : 'text-slate-900'}>{dokument.fach}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Jahr</span>
|
||||
<p className={isDark ? 'text-white' : 'text-slate-900'}>{dokument.jahr}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Bundesland</span>
|
||||
<p className={isDark ? 'text-white' : 'text-slate-900'}>{dokument.bundesland}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Niveau</span>
|
||||
<p className={isDark ? 'text-white' : 'text-slate-900'}>{dokument.niveau}</p>
|
||||
</div>
|
||||
{dokument.thema && (
|
||||
<div>
|
||||
<span className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Thema</span>
|
||||
<p className={isDark ? 'text-white' : 'text-slate-900'}>{dokument.thema}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard size="md" isDark={isDark}>
|
||||
<h3 className={`font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>Aktionen</h3>
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={onUseAsTemplate}
|
||||
className="w-full 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"
|
||||
>
|
||||
Als Vorlage verwenden
|
||||
</button>
|
||||
<a
|
||||
href={dokument.download_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`block w-full px-4 py-3 rounded-xl text-center font-medium transition-colors ${
|
||||
isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Herunterladen
|
||||
</a>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CREATE KLAUSUR FROM TEMPLATE MODAL
|
||||
// =============================================================================
|
||||
|
||||
interface CreateKlausurFromTemplateModalProps {
|
||||
template: AbiturDokument
|
||||
onClose: () => void
|
||||
onCreate: (title: string) => void
|
||||
onFallback: () => void
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
isDark: boolean
|
||||
}
|
||||
|
||||
function CreateKlausurFromTemplateModal({
|
||||
template,
|
||||
onClose,
|
||||
onCreate,
|
||||
onFallback,
|
||||
isLoading,
|
||||
error,
|
||||
isDark,
|
||||
}: CreateKlausurFromTemplateModalProps) {
|
||||
const [title, setTitle] = useState(
|
||||
`${template.fach} ${template.aufgabentyp || ''} ${template.jahr}`.trim()
|
||||
)
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onCreate(title)
|
||||
}
|
||||
|
||||
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-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Klausur aus Vorlage erstellen
|
||||
</h2>
|
||||
<p className={`text-sm mb-6 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
Basierend auf: {template.thema || template.dateiname}
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-red-500/20 border border-red-500/30 text-sm">
|
||||
<p className="text-red-300 mb-2">{error}</p>
|
||||
<button
|
||||
onClick={onFallback}
|
||||
className="text-purple-400 hover:text-purple-300 underline text-xs"
|
||||
>
|
||||
Zur Korrektur-Uebersicht (ohne Klausur erstellen)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className={`block text-sm mb-2 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
Klausur-Titel
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="z.B. Deutsch LK Q4 - Kafka"
|
||||
className={`w-full p-3 rounded-xl border focus:ring-2 focus:ring-purple-500 focus:border-transparent ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white placeholder-white/40'
|
||||
: 'bg-slate-100 border-slate-300 text-slate-900 placeholder-slate-400'
|
||||
}`}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Template Info */}
|
||||
<div className={`p-4 rounded-xl ${isDark ? 'bg-white/5' : 'bg-slate-100'}`}>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<span className={isDark ? 'text-white/50' : 'text-slate-500'}>Fach:</span>
|
||||
<span className={`ml-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>{template.fach}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className={isDark ? 'text-white/50' : 'text-slate-500'}>Jahr:</span>
|
||||
<span className={`ml-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>{template.jahr}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className={isDark ? 'text-white/50' : 'text-slate-500'}>Niveau:</span>
|
||||
<span className={`ml-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>{template.niveau}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className={isDark ? 'text-white/50' : 'text-slate-500'}>Typ:</span>
|
||||
<span className={`ml-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>{template.aufgabentyp}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
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'
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
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 ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Erstelle...
|
||||
</span>
|
||||
) : (
|
||||
'Klausur erstellen'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</GlassCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const MOCK_DOKUMENTE: AbiturDokument[] = [
|
||||
{ id: '1', dateiname: 'Deutsch_eA_2024_Aufgabe1.pdf', fach: 'Deutsch', jahr: 2024, bundesland: 'Niedersachsen', niveau: 'eA', dokumenttyp: 'Aufgabe', aufgabentyp: 'Textanalyse', thema: 'Textanalyse: "Der Prozess" - Kafka', download_url: SAMPLE_PDF, preview_url: SAMPLE_PDF, page_count: 4 },
|
||||
{ id: '2', dateiname: 'Deutsch_eA_2024_EH1.pdf', fach: 'Deutsch', jahr: 2024, bundesland: 'Niedersachsen', niveau: 'eA', dokumenttyp: 'Erwartungshorizont', aufgabentyp: 'Textanalyse', thema: 'EH zu Kafka-Analyse', download_url: SAMPLE_PDF, preview_url: SAMPLE_PDF, page_count: 8 },
|
||||
{ id: '3', dateiname: 'Deutsch_gA_2024_Aufgabe2.pdf', fach: 'Deutsch', jahr: 2024, bundesland: 'Niedersachsen', niveau: 'gA', dokumenttyp: 'Aufgabe', aufgabentyp: 'Gedichtanalyse', thema: 'Gedichtvergleich Romantik', download_url: SAMPLE_PDF, preview_url: SAMPLE_PDF, page_count: 3 },
|
||||
{ id: '4', dateiname: 'Deutsch_eA_2023_Aufgabe1.pdf', fach: 'Deutsch', jahr: 2023, bundesland: 'Niedersachsen', niveau: 'eA', dokumenttyp: 'Aufgabe', aufgabentyp: 'Eroerterung', thema: 'Materialgestuetzte Eroerterung: Digitalisierung', download_url: SAMPLE_PDF, preview_url: SAMPLE_PDF, page_count: 5 },
|
||||
{ id: '5', dateiname: 'Deutsch_eA_2023_EH1.pdf', fach: 'Deutsch', jahr: 2023, bundesland: 'Niedersachsen', niveau: 'eA', dokumenttyp: 'Erwartungshorizont', aufgabentyp: 'Eroerterung', thema: 'EH zu Digitalisierungs-Eroerterung', download_url: SAMPLE_PDF, preview_url: SAMPLE_PDF, page_count: 10 },
|
||||
{ id: '6', dateiname: 'Deutsch_gA_2023_Aufgabe3.pdf', fach: 'Deutsch', jahr: 2023, bundesland: 'Niedersachsen', niveau: 'gA', dokumenttyp: 'Aufgabe', aufgabentyp: 'Dramenanalyse', thema: 'Szenenanalyse: "Faust I"', download_url: SAMPLE_PDF, preview_url: SAMPLE_PDF, page_count: 4 },
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
@@ -521,8 +47,6 @@ export default function ArchivPage() {
|
||||
|
||||
// State
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [suggestions, setSuggestions] = useState<ThemaSuggestion[]>([])
|
||||
const [showSuggestions, setShowSuggestions] = useState(false)
|
||||
const [dokumente, setDokumente] = useState<AbiturDokument[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -543,15 +67,9 @@ export default function ArchivPage() {
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
|
||||
// Example PDF URL (Mozilla public sample) - fallback when API fails
|
||||
const SAMPLE_PDF = 'https://mozilla.github.io/pdf.js/web/compressed.tracemonkey-pldi-09.pdf'
|
||||
|
||||
// Available filter options (updated from API response)
|
||||
const [availableFilters, setAvailableFilters] = useState<{
|
||||
subjects: string[]
|
||||
years: number[]
|
||||
niveaus: string[]
|
||||
doc_types: string[]
|
||||
subjects: string[]; years: number[]; niveaus: string[]; doc_types: string[]
|
||||
}>({
|
||||
subjects: ['Deutsch', 'Englisch', 'Mathematik'],
|
||||
years: [2025, 2024, 2023, 2022, 2021],
|
||||
@@ -564,49 +82,25 @@ export default function ArchivPage() {
|
||||
const loadDocuments = async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// Build filter params
|
||||
const filters: {
|
||||
subject?: string
|
||||
year?: number
|
||||
niveau?: string
|
||||
doc_type?: string
|
||||
search?: string
|
||||
} = {}
|
||||
|
||||
const filters: { subject?: string; year?: number; niveau?: string; doc_type?: string; search?: string } = {}
|
||||
if (fach !== 'Alle') filters.subject = fach
|
||||
if (jahr !== 'Alle') filters.year = parseInt(jahr)
|
||||
if (niveau !== 'Alle') filters.niveau = niveau
|
||||
if (dokumenttyp !== 'Alle') {
|
||||
// Map frontend names to API names
|
||||
const docTypeMap: Record<string, string> = {
|
||||
'Erwartungshorizont': 'EWH',
|
||||
'Aufgabe': 'Aufgabe',
|
||||
'Loesungshinweise': 'Material'
|
||||
}
|
||||
const docTypeMap: Record<string, string> = { 'Erwartungshorizont': 'EWH', 'Aufgabe': 'Aufgabe', 'Loesungshinweise': 'Material' }
|
||||
filters.doc_type = docTypeMap[dokumenttyp] || dokumenttyp
|
||||
}
|
||||
if (searchQuery) filters.search = searchQuery
|
||||
|
||||
const response = await korrekturApi.getArchivDocuments(filters)
|
||||
|
||||
// Map API response to frontend interface
|
||||
const mappedDokumente: AbiturDokument[] = response.documents.map((doc) => ({
|
||||
id: doc.id,
|
||||
dateiname: `${doc.subject}_${doc.niveau}_${doc.year}_${doc.doc_type}.pdf`,
|
||||
fach: doc.subject,
|
||||
jahr: doc.year,
|
||||
bundesland: doc.bundesland === 'NI' ? 'Niedersachsen' : doc.bundesland,
|
||||
niveau: doc.niveau,
|
||||
dokumenttyp: doc.doc_type === 'EWH' ? 'Erwartungshorizont' : doc.doc_type,
|
||||
aufgabentyp: doc.doc_type,
|
||||
thema: doc.title,
|
||||
download_url: doc.preview_url || SAMPLE_PDF,
|
||||
preview_url: doc.preview_url || SAMPLE_PDF,
|
||||
id: doc.id, dateiname: `${doc.subject}_${doc.niveau}_${doc.year}_${doc.doc_type}.pdf`,
|
||||
fach: doc.subject, jahr: doc.year, bundesland: doc.bundesland === 'NI' ? 'Niedersachsen' : doc.bundesland,
|
||||
niveau: doc.niveau, dokumenttyp: doc.doc_type === 'EWH' ? 'Erwartungshorizont' : doc.doc_type,
|
||||
aufgabentyp: doc.doc_type, thema: doc.title, download_url: doc.preview_url || SAMPLE_PDF, preview_url: doc.preview_url || SAMPLE_PDF,
|
||||
}))
|
||||
|
||||
// Update available filters from API
|
||||
if (response.filters) {
|
||||
setAvailableFilters({
|
||||
subjects: response.filters.subjects || ['Deutsch', 'Englisch', 'Mathematik'],
|
||||
@@ -616,163 +110,50 @@ export default function ArchivPage() {
|
||||
})
|
||||
}
|
||||
|
||||
// If API returns documents, use them
|
||||
if (mappedDokumente.length > 0) {
|
||||
setDokumente(mappedDokumente)
|
||||
} else {
|
||||
// API returned empty, use fallback mock data
|
||||
console.warn('API returned empty documents, using fallback data')
|
||||
throw new Error('Empty response')
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.warn('Failed to load from API, using fallback data:', err)
|
||||
// Fallback to mock data
|
||||
const mockDokumente: AbiturDokument[] = [
|
||||
{
|
||||
id: '1',
|
||||
dateiname: 'Deutsch_eA_2024_Aufgabe1.pdf',
|
||||
fach: 'Deutsch',
|
||||
jahr: 2024,
|
||||
bundesland: 'Niedersachsen',
|
||||
niveau: 'eA',
|
||||
dokumenttyp: 'Aufgabe',
|
||||
aufgabentyp: 'Textanalyse',
|
||||
thema: 'Textanalyse: "Der Prozess" - Kafka',
|
||||
download_url: SAMPLE_PDF,
|
||||
preview_url: SAMPLE_PDF,
|
||||
page_count: 4,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
dateiname: 'Deutsch_eA_2024_EH1.pdf',
|
||||
fach: 'Deutsch',
|
||||
jahr: 2024,
|
||||
bundesland: 'Niedersachsen',
|
||||
niveau: 'eA',
|
||||
dokumenttyp: 'Erwartungshorizont',
|
||||
aufgabentyp: 'Textanalyse',
|
||||
thema: 'EH zu Kafka-Analyse',
|
||||
download_url: SAMPLE_PDF,
|
||||
preview_url: SAMPLE_PDF,
|
||||
page_count: 8,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
dateiname: 'Deutsch_gA_2024_Aufgabe2.pdf',
|
||||
fach: 'Deutsch',
|
||||
jahr: 2024,
|
||||
bundesland: 'Niedersachsen',
|
||||
niveau: 'gA',
|
||||
dokumenttyp: 'Aufgabe',
|
||||
aufgabentyp: 'Gedichtanalyse',
|
||||
thema: 'Gedichtvergleich Romantik',
|
||||
download_url: SAMPLE_PDF,
|
||||
preview_url: SAMPLE_PDF,
|
||||
page_count: 3,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
dateiname: 'Deutsch_eA_2023_Aufgabe1.pdf',
|
||||
fach: 'Deutsch',
|
||||
jahr: 2023,
|
||||
bundesland: 'Niedersachsen',
|
||||
niveau: 'eA',
|
||||
dokumenttyp: 'Aufgabe',
|
||||
aufgabentyp: 'Eroerterung',
|
||||
thema: 'Materialgestuetzte Eroerterung: Digitalisierung',
|
||||
download_url: SAMPLE_PDF,
|
||||
preview_url: SAMPLE_PDF,
|
||||
page_count: 5,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
dateiname: 'Deutsch_eA_2023_EH1.pdf',
|
||||
fach: 'Deutsch',
|
||||
jahr: 2023,
|
||||
bundesland: 'Niedersachsen',
|
||||
niveau: 'eA',
|
||||
dokumenttyp: 'Erwartungshorizont',
|
||||
aufgabentyp: 'Eroerterung',
|
||||
thema: 'EH zu Digitalisierungs-Eroerterung',
|
||||
download_url: SAMPLE_PDF,
|
||||
preview_url: SAMPLE_PDF,
|
||||
page_count: 10,
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
dateiname: 'Deutsch_gA_2023_Aufgabe3.pdf',
|
||||
fach: 'Deutsch',
|
||||
jahr: 2023,
|
||||
bundesland: 'Niedersachsen',
|
||||
niveau: 'gA',
|
||||
dokumenttyp: 'Aufgabe',
|
||||
aufgabentyp: 'Dramenanalyse',
|
||||
thema: 'Szenenanalyse: "Faust I"',
|
||||
download_url: SAMPLE_PDF,
|
||||
preview_url: SAMPLE_PDF,
|
||||
page_count: 4,
|
||||
},
|
||||
]
|
||||
setDokumente(mockDokumente)
|
||||
setDokumente(MOCK_DOKUMENTE)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadDocuments()
|
||||
}, [fach, jahr, bundesland, niveau, dokumenttyp, searchQuery])
|
||||
|
||||
// Documents are now filtered by API, but keep local filtering for bundesland (API only filters NI)
|
||||
// and for fallback mock data, also apply search filter locally for mock data
|
||||
const filteredDokumente = useMemo(() => {
|
||||
return dokumente.filter((dok) => {
|
||||
// Bundesland filter (not yet supported by API)
|
||||
if (bundesland !== 'Alle' && dok.bundesland !== bundesland) return false
|
||||
// Search filter (for local mock data - API handles this server-side)
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase()
|
||||
const matchesSearch =
|
||||
dok.thema?.toLowerCase().includes(query) ||
|
||||
dok.fach.toLowerCase().includes(query) ||
|
||||
dok.aufgabentyp?.toLowerCase().includes(query) ||
|
||||
dok.dateiname.toLowerCase().includes(query)
|
||||
const matchesSearch = dok.thema?.toLowerCase().includes(query) || dok.fach.toLowerCase().includes(query) ||
|
||||
dok.aufgabentyp?.toLowerCase().includes(query) || dok.dateiname.toLowerCase().includes(query)
|
||||
if (!matchesSearch) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}, [dokumente, bundesland, searchQuery])
|
||||
|
||||
// Handle theme search
|
||||
const handleThemeClick = (theme: string) => {
|
||||
setSearchQuery(theme)
|
||||
setShowSuggestions(false)
|
||||
}
|
||||
const handleThemeClick = (theme: string) => { setSearchQuery(theme) }
|
||||
|
||||
// Handle use as template
|
||||
const handleUseAsTemplate = (dokument: AbiturDokument) => {
|
||||
setSelectedTemplate(dokument)
|
||||
setShowCreateModal(true)
|
||||
setCreateError(null)
|
||||
setSelectedTemplate(dokument); setShowCreateModal(true); setCreateError(null)
|
||||
}
|
||||
|
||||
// Create Klausur with template
|
||||
const handleCreateKlausur = async (title: string) => {
|
||||
if (!selectedTemplate) return
|
||||
setIsCreating(true)
|
||||
setCreateError(null)
|
||||
|
||||
setIsCreating(true); setCreateError(null)
|
||||
try {
|
||||
const newKlausur = await korrekturApi.createKlausur({
|
||||
title: title || `${selectedTemplate.fach} ${selectedTemplate.aufgabentyp || ''} ${selectedTemplate.jahr}`,
|
||||
subject: selectedTemplate.fach,
|
||||
year: selectedTemplate.jahr,
|
||||
semester: 'Abitur',
|
||||
modus: 'landes_abitur',
|
||||
subject: selectedTemplate.fach, year: selectedTemplate.jahr, semester: 'Abitur', modus: 'landes_abitur',
|
||||
})
|
||||
|
||||
setShowCreateModal(false)
|
||||
setSelectedTemplate(null)
|
||||
setShowCreateModal(false); setSelectedTemplate(null)
|
||||
router.push(`/korrektur/${newKlausur.id}`)
|
||||
} catch (err) {
|
||||
console.error('Failed to create klausur:', err)
|
||||
@@ -787,109 +168,54 @@ export default function ArchivPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to korrektur page without creating (fallback)
|
||||
const handleGoToKorrektur = () => {
|
||||
setShowCreateModal(false)
|
||||
setSelectedTemplate(null)
|
||||
router.push('/korrektur')
|
||||
}
|
||||
const handleGoToKorrektur = () => { setShowCreateModal(false); setSelectedTemplate(null); router.push('/korrektur') }
|
||||
|
||||
const activeFilters = [fach, jahr, bundesland, niveau, dokumenttyp].filter(f => f !== 'Alle').length
|
||||
|
||||
// Computed filter options with "Alle" prefix
|
||||
const FAECHER = useMemo(() => ['Alle', ...availableFilters.subjects], [availableFilters.subjects])
|
||||
const JAHRE = useMemo(() => ['Alle', ...availableFilters.years.map(String)], [availableFilters.years])
|
||||
const NIVEAUS = useMemo(() => ['Alle', ...availableFilters.niveaus], [availableFilters.niveaus])
|
||||
const DOKUMENTTYPEN = useMemo(() => ['Alle', ...availableFilters.doc_types.map(t => t === 'EWH' ? 'Erwartungshorizont' : t)], [availableFilters.doc_types])
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen flex relative overflow-hidden ${
|
||||
isDark
|
||||
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
|
||||
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
|
||||
}`}>
|
||||
<div className={`min-h-screen flex relative overflow-hidden ${isDark ? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800' : 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'}`}>
|
||||
{/* Animated Background */}
|
||||
<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-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<button
|
||||
onClick={() => router.push('/korrektur')}
|
||||
className={`p-2 rounded-xl transition-colors ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-200'}`}
|
||||
>
|
||||
<svg className={`w-5 h-5 ${isDark ? 'text-white/60' : 'text-slate-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<button onClick={() => router.push('/korrektur')} className={`p-2 rounded-xl transition-colors ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-200'}`}>
|
||||
<svg className={`w-5 h-5 ${isDark ? 'text-white/60' : 'text-slate-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
|
||||
</button>
|
||||
<h1 className={`text-3xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>Abitur-Archiv</h1>
|
||||
</div>
|
||||
<p className={isDark ? 'text-white/50' : 'text-slate-500'}>
|
||||
Zentralabitur-Materialien 2021-2025 durchsuchen
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<ThemeToggle />
|
||||
<LanguageDropdown />
|
||||
<p className={isDark ? 'text-white/50' : 'text-slate-500'}>Zentralabitur-Materialien 2021-2025 durchsuchen</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3"><ThemeToggle /><LanguageDropdown /></div>
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<GlassCard className="mb-6" size="md" delay={100} isDark={isDark}>
|
||||
<div className="relative">
|
||||
<div className="flex items-center gap-3">
|
||||
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onFocus={() => setShowSuggestions(true)}
|
||||
onBlur={() => setTimeout(() => setShowSuggestions(false), 200)}
|
||||
placeholder="Thema suchen... z.B. Gedichtanalyse, Romantik, Kafka"
|
||||
className={`flex-1 bg-transparent border-none outline-none text-lg ${
|
||||
isDark ? 'text-white placeholder-white/40' : 'text-slate-900 placeholder-slate-400'
|
||||
}`}
|
||||
/>
|
||||
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
||||
<input type="text" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="Thema suchen... z.B. Gedichtanalyse, Romantik, Kafka" className={`flex-1 bg-transparent border-none outline-none text-lg ${isDark ? 'text-white placeholder-white/40' : 'text-slate-900 placeholder-slate-400'}`} />
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className={`p-1 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-200'}`}
|
||||
>
|
||||
<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 onClick={() => setSearchQuery('')} className={`p-1 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-200'}`}>
|
||||
<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>
|
||||
|
||||
{/* Popular Themes */}
|
||||
<div className="flex flex-wrap gap-2 mt-4">
|
||||
{POPULAR_THEMES.map((theme) => (
|
||||
<button
|
||||
key={theme}
|
||||
onClick={() => handleThemeClick(theme)}
|
||||
className={`px-3 py-1.5 rounded-full text-sm transition-colors ${
|
||||
searchQuery === theme
|
||||
? 'bg-purple-500 text-white'
|
||||
: isDark
|
||||
? 'bg-white/10 text-white/70 hover:bg-white/20'
|
||||
: 'bg-slate-200 text-slate-600 hover:bg-slate-300'
|
||||
}`}
|
||||
>
|
||||
{theme}
|
||||
</button>
|
||||
<button key={theme} onClick={() => handleThemeClick(theme)} className={`px-3 py-1.5 rounded-full text-sm transition-colors ${searchQuery === theme ? 'bg-purple-500 text-white' : isDark ? 'bg-white/10 text-white/70 hover:bg-white/20' : 'bg-slate-200 text-slate-600 hover:bg-slate-300'}`}>{theme}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -903,27 +229,12 @@ export default function ArchivPage() {
|
||||
<FilterDropdown label="Bundesland" value={bundesland} options={BUNDESLAENDER} onChange={setBundesland} isDark={isDark} />
|
||||
<FilterDropdown label="Niveau" value={niveau} options={NIVEAUS} onChange={setNiveau} isDark={isDark} />
|
||||
<FilterDropdown label="Typ" value={dokumenttyp} options={DOKUMENTTYPEN} onChange={setDokumenttyp} isDark={isDark} />
|
||||
|
||||
{activeFilters > 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setFach('Alle')
|
||||
setJahr('Alle')
|
||||
setBundesland('Alle')
|
||||
setNiveau('Alle')
|
||||
setDokumenttyp('Alle')
|
||||
}}
|
||||
className={`px-3 py-2 rounded-xl text-sm transition-colors ${
|
||||
isDark ? 'text-purple-400 hover:bg-purple-500/20' : 'text-purple-600 hover:bg-purple-100'
|
||||
}`}
|
||||
>
|
||||
<button onClick={() => { setFach('Alle'); setJahr('Alle'); setBundesland('Alle'); setNiveau('Alle'); setDokumenttyp('Alle') }} className={`px-3 py-2 rounded-xl text-sm transition-colors ${isDark ? 'text-purple-400 hover:bg-purple-500/20' : 'text-purple-600 hover:bg-purple-100'}`}>
|
||||
Filter zuruecksetzen ({activeFilters})
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className={`ml-auto text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
{filteredDokumente.length} Dokumente
|
||||
</div>
|
||||
<div className={`ml-auto text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{filteredDokumente.length} Dokumente</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
@@ -938,9 +249,7 @@ export default function ArchivPage() {
|
||||
{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>
|
||||
@@ -950,21 +259,11 @@ export default function ArchivPage() {
|
||||
{!isLoading && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredDokumente.map((dok, index) => (
|
||||
<DokumentCard
|
||||
key={dok.id}
|
||||
dokument={dok}
|
||||
onPreview={() => setPreviewDokument(dok)}
|
||||
onUseAsTemplate={() => handleUseAsTemplate(dok)}
|
||||
delay={200 + index * 50}
|
||||
isDark={isDark}
|
||||
/>
|
||||
<DokumentCard key={dok.id} dokument={dok} onPreview={() => setPreviewDokument(dok)} onUseAsTemplate={() => handleUseAsTemplate(dok)} delay={200 + index * 50} isDark={isDark} />
|
||||
))}
|
||||
|
||||
{filteredDokumente.length === 0 && !isLoading && (
|
||||
<div className={`col-span-full text-center py-12 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
<svg className="w-16 h-16 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<svg className="w-16 h-16 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
|
||||
<p className="text-lg">Keine Dokumente gefunden</p>
|
||||
<p className="text-sm mt-1">Versuchen Sie andere Filtereinstellungen</p>
|
||||
</div>
|
||||
@@ -973,28 +272,10 @@ export default function ArchivPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview Modal */}
|
||||
<PreviewModal
|
||||
dokument={previewDokument}
|
||||
onClose={() => setPreviewDokument(null)}
|
||||
onUseAsTemplate={() => {
|
||||
if (previewDokument) handleUseAsTemplate(previewDokument)
|
||||
setPreviewDokument(null)
|
||||
}}
|
||||
isDark={isDark}
|
||||
/>
|
||||
<PreviewModal dokument={previewDokument} onClose={() => setPreviewDokument(null)} onUseAsTemplate={() => { if (previewDokument) handleUseAsTemplate(previewDokument); setPreviewDokument(null) }} isDark={isDark} />
|
||||
|
||||
{/* Create Klausur Modal */}
|
||||
{showCreateModal && selectedTemplate && (
|
||||
<CreateKlausurFromTemplateModal
|
||||
template={selectedTemplate}
|
||||
onClose={() => { setShowCreateModal(false); setSelectedTemplate(null); setCreateError(null) }}
|
||||
onCreate={handleCreateKlausur}
|
||||
onFallback={handleGoToKorrektur}
|
||||
isLoading={isCreating}
|
||||
error={createError}
|
||||
isDark={isDark}
|
||||
/>
|
||||
<CreateKlausurFromTemplateModal template={selectedTemplate} onClose={() => { setShowCreateModal(false); setSelectedTemplate(null); setCreateError(null) }} onCreate={handleCreateKlausur} onFallback={handleGoToKorrektur} isLoading={isCreating} error={createError} isDark={isDark} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
183
studio-v2/app/meet/_components/BreakoutTab.tsx
Normal file
183
studio-v2/app/meet/_components/BreakoutTab.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
'use client'
|
||||
|
||||
import { Icons, type BreakoutRoom } from './types'
|
||||
|
||||
interface BreakoutTabProps {
|
||||
isDark: boolean
|
||||
hasActiveMeeting: boolean
|
||||
breakoutRooms: BreakoutRoom[]
|
||||
breakoutAssignment: string
|
||||
breakoutTimer: number
|
||||
setBreakoutAssignment: (val: string) => void
|
||||
setBreakoutTimer: (val: number) => void
|
||||
addBreakoutRoom: () => void
|
||||
removeBreakoutRoom: (id: string) => void
|
||||
startQuickMeeting: () => void
|
||||
}
|
||||
|
||||
export function BreakoutTab({
|
||||
isDark, hasActiveMeeting, breakoutRooms,
|
||||
breakoutAssignment, breakoutTimer,
|
||||
setBreakoutAssignment, setBreakoutTimer,
|
||||
addBreakoutRoom, removeBreakoutRoom, startQuickMeeting,
|
||||
}: BreakoutTabProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Active Meeting Warning */}
|
||||
{!hasActiveMeeting && (
|
||||
<div className={`backdrop-blur-xl border rounded-3xl p-6 flex items-center gap-6 mb-8 ${
|
||||
isDark
|
||||
? 'bg-blue-500/10 border-blue-500/30'
|
||||
: 'bg-blue-50 border-blue-200 shadow-lg'
|
||||
}`}>
|
||||
<div className="w-14 h-14 bg-gradient-to-br from-blue-400 to-blue-600 rounded-2xl flex items-center justify-center text-white shadow-lg">
|
||||
{Icons.video}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>Kein aktives Meeting</div>
|
||||
<div className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
Breakout-Rooms koennen nur waehrend eines aktiven Meetings erstellt werden.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={startQuickMeeting}
|
||||
className="px-6 py-3 bg-gradient-to-r from-blue-500 to-purple-500 text-white rounded-2xl font-medium hover:shadow-lg hover:shadow-purple-500/30 transition-all hover:scale-105"
|
||||
>
|
||||
Meeting starten
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* How it works */}
|
||||
<div className={`backdrop-blur-xl border rounded-3xl p-6 mb-8 ${
|
||||
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'}`}>So funktionieren Breakout-Rooms</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{[
|
||||
{ icon: Icons.grid, color: 'from-blue-400 to-blue-600', title: '1. Raeume erstellen', desc: 'Erstellen Sie mehrere Breakout-Rooms fuer Gruppenarbeit.' },
|
||||
{ icon: Icons.users, color: 'from-purple-400 to-purple-600', title: '2. Teilnehmer zuweisen', desc: 'Weisen Sie Teilnehmer manuell oder automatisch zu.' },
|
||||
{ icon: Icons.play, color: 'from-green-400 to-emerald-600', title: '3. Sessions starten', desc: 'Starten Sie alle Raeume gleichzeitig oder einzeln.' },
|
||||
{ icon: Icons.clock, color: 'from-amber-400 to-orange-600', title: '4. Timer setzen', desc: 'Setzen Sie einen Timer fuer automatisches Beenden.' },
|
||||
].map((step, index) => (
|
||||
<div key={index} className="text-center">
|
||||
<div className={`w-14 h-14 bg-gradient-to-br ${step.color} rounded-2xl flex items-center justify-center text-white shadow-lg mx-auto mb-4`}>
|
||||
{step.icon}
|
||||
</div>
|
||||
<h4 className={`font-medium mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>{step.title}</h4>
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>{step.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Breakout Configuration */}
|
||||
<div className={`backdrop-blur-xl border rounded-3xl overflow-hidden ${
|
||||
isDark ? 'bg-white/10 border-white/20' : 'bg-white/70 border-black/10 shadow-lg'
|
||||
}`}>
|
||||
<div className={`p-6 border-b flex items-center justify-between ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
|
||||
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>Breakout-Konfiguration</h2>
|
||||
<button
|
||||
onClick={addBreakoutRoom}
|
||||
disabled={!hasActiveMeeting}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl font-medium transition-all disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
{Icons.plus}
|
||||
Raum hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Breakout Rooms Grid */}
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
|
||||
{breakoutRooms.map((room) => (
|
||||
<div
|
||||
key={room.id}
|
||||
className={`backdrop-blur-xl border rounded-2xl p-4 transition-all ${
|
||||
hasActiveMeeting
|
||||
? isDark ? 'bg-white/5 border-white/10' : 'bg-white/50 border-slate-200'
|
||||
: 'opacity-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>{room.name}</span>
|
||||
<span className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{room.participants.length} Teilnehmer</span>
|
||||
</div>
|
||||
<div className={`min-h-[60px] rounded-xl p-3 text-sm ${
|
||||
isDark ? 'bg-white/5 text-white/40' : 'bg-slate-50 text-slate-400'
|
||||
}`}>
|
||||
{room.participants.length > 0 ? room.participants.join(', ') : 'Keine Teilnehmer'}
|
||||
</div>
|
||||
{breakoutRooms.length > 1 && (
|
||||
<button onClick={() => removeBreakoutRoom(room.id)}
|
||||
className="mt-3 text-sm text-red-500 hover:text-red-400 transition-colors">
|
||||
Entfernen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={`border-t pt-6 space-y-4 ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-2 ${isDark ? 'text-white/70' : 'text-slate-700'}`}>
|
||||
Automatische Zuweisung
|
||||
</label>
|
||||
<select
|
||||
value={breakoutAssignment}
|
||||
onChange={(e) => setBreakoutAssignment(e.target.value)}
|
||||
disabled={!hasActiveMeeting}
|
||||
className={`w-full px-4 py-3 rounded-xl transition-all focus:outline-none focus:ring-2 disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
isDark
|
||||
? 'bg-white/10 border border-white/20 text-white focus:ring-white/30'
|
||||
: 'bg-white border border-slate-300 text-slate-900 focus:ring-blue-300'
|
||||
}`}
|
||||
>
|
||||
<option value="equal">Gleichmaessig verteilen</option>
|
||||
<option value="random">Zufaellig zuweisen</option>
|
||||
<option value="manual">Manuell zuweisen</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-2 ${isDark ? 'text-white/70' : 'text-slate-700'}`}>
|
||||
Timer (Minuten)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={breakoutTimer}
|
||||
onChange={(e) => setBreakoutTimer(Number(e.target.value))}
|
||||
disabled={!hasActiveMeeting}
|
||||
className={`w-full px-4 py-3 rounded-xl transition-all focus:outline-none focus:ring-2 disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
isDark
|
||||
? 'bg-white/10 border border-white/20 text-white focus:ring-white/30'
|
||||
: 'bg-white border border-slate-300 text-slate-900 focus:ring-blue-300'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
disabled={!hasActiveMeeting}
|
||||
className="px-6 py-3 bg-gradient-to-r from-blue-500 to-purple-500 text-white rounded-xl font-medium hover:shadow-lg hover:shadow-purple-500/30 transition-all hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
|
||||
>
|
||||
Breakout-Sessions starten
|
||||
</button>
|
||||
<button
|
||||
disabled={!hasActiveMeeting}
|
||||
className={`px-6 py-3 rounded-xl font-medium transition-all disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Alle zurueckholen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
254
studio-v2/app/meet/_components/DashboardTab.tsx
Normal file
254
studio-v2/app/meet/_components/DashboardTab.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
'use client'
|
||||
|
||||
import { Icons, formatTime, formatDate, type Meeting, type MeetingStats } from './types'
|
||||
|
||||
interface DashboardTabProps {
|
||||
isDark: boolean
|
||||
loading: boolean
|
||||
stats: MeetingStats
|
||||
activeMeetings: Meeting[]
|
||||
scheduledMeetings: Meeting[]
|
||||
errorMessage: string | null
|
||||
creating: boolean
|
||||
setErrorMessage: (msg: string | null) => void
|
||||
setShowNewMeetingModal: (show: boolean) => void
|
||||
setMeetingType: (type: string) => void
|
||||
startQuickMeeting: () => void
|
||||
joinMeeting: (roomName: string, title: string) => void
|
||||
copyMeetingLink: (roomName: string) => void
|
||||
}
|
||||
|
||||
export function DashboardTab({
|
||||
isDark, loading, stats, activeMeetings, scheduledMeetings,
|
||||
errorMessage, creating,
|
||||
setErrorMessage, setShowNewMeetingModal, setMeetingType,
|
||||
startQuickMeeting, joinMeeting, copyMeetingLink,
|
||||
}: DashboardTabProps) {
|
||||
const statsData = [
|
||||
{ label: 'Aktive Meetings', value: loading ? '-' : String(stats.active), icon: Icons.video, color: 'from-green-400 to-emerald-600' },
|
||||
{ label: 'Geplante Termine', value: loading ? '-' : String(stats.scheduled), icon: Icons.calendar, color: 'from-blue-400 to-blue-600' },
|
||||
{ label: 'Aufzeichnungen', value: loading ? '-' : String(stats.recordings), icon: Icons.record, color: 'from-red-400 to-rose-600' },
|
||||
{ label: 'Teilnehmer', value: loading ? '-' : String(stats.participants), icon: Icons.users, color: 'from-amber-400 to-orange-600' },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Error Banner */}
|
||||
{errorMessage && (
|
||||
<div className={`mb-6 p-4 rounded-xl flex items-center justify-between ${
|
||||
isDark ? 'bg-red-500/20 border border-red-500/30' : 'bg-red-50 border border-red-200'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className={`w-5 h-5 ${isDark ? 'text-red-400' : 'text-red-500'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className={isDark ? 'text-red-200' : 'text-red-700'}>{errorMessage}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setErrorMessage(null)}
|
||||
className={`p-1 rounded-lg transition-colors ${isDark ? 'hover:bg-white/10' : 'hover:bg-red-100'}`}
|
||||
>
|
||||
<svg className={`w-4 h-4 ${isDark ? 'text-red-400' : 'text-red-500'}`} 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>
|
||||
)}
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<QuickActionCard isDark={isDark} icon={Icons.video} color="from-green-400 to-emerald-600"
|
||||
title="Sofort-Meeting" subtitle="Jetzt starten" onClick={startQuickMeeting} disabled={creating} />
|
||||
<QuickActionCard isDark={isDark} icon={Icons.calendar} color="from-blue-400 to-blue-600"
|
||||
title="Meeting planen" subtitle="Termin festlegen"
|
||||
onClick={() => { setMeetingType('scheduled'); setShowNewMeetingModal(true) }} />
|
||||
<QuickActionCard isDark={isDark} icon={Icons.graduation} color="from-purple-400 to-purple-600"
|
||||
title="Schulung erstellen" subtitle="Training planen"
|
||||
onClick={() => { setMeetingType('training'); setShowNewMeetingModal(true) }} />
|
||||
<QuickActionCard isDark={isDark} icon={Icons.users} color="from-amber-400 to-orange-600"
|
||||
title="Elterngespraech" subtitle="Termin vereinbaren"
|
||||
onClick={() => { setMeetingType('parent'); setShowNewMeetingModal(true) }} />
|
||||
</div>
|
||||
|
||||
{/* Statistics */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
{statsData.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-white 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'}`}>{stat.label}</p>
|
||||
<p className={`text-3xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>{stat.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Active Meetings */}
|
||||
{activeMeetings.length > 0 && (
|
||||
<ActiveMeetingsList isDark={isDark} meetings={activeMeetings}
|
||||
joinMeeting={joinMeeting} copyMeetingLink={copyMeetingLink} />
|
||||
)}
|
||||
|
||||
{/* Scheduled Meetings */}
|
||||
<ScheduledMeetingsList isDark={isDark} loading={loading} meetings={scheduledMeetings}
|
||||
joinMeeting={joinMeeting} copyMeetingLink={copyMeetingLink}
|
||||
setShowNewMeetingModal={setShowNewMeetingModal} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// SUB-COMPONENTS
|
||||
// ============================================
|
||||
|
||||
function QuickActionCard({ isDark, icon, color, title, subtitle, onClick, disabled }: {
|
||||
isDark: boolean; icon: React.ReactNode; color: string
|
||||
title: string; subtitle: string; onClick: () => void; disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`backdrop-blur-xl border rounded-3xl p-6 text-left transition-all hover:scale-105 hover:shadow-xl ${
|
||||
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 bg-gradient-to-br ${color} rounded-2xl flex items-center justify-center text-white shadow-lg mb-4`}>
|
||||
{icon}
|
||||
</div>
|
||||
<div className={`font-semibold mb-1 ${isDark ? 'text-white' : 'text-slate-900'}`}>{title}</div>
|
||||
<div className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>{subtitle}</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ActiveMeetingsList({ isDark, meetings, joinMeeting, copyMeetingLink }: {
|
||||
isDark: boolean; meetings: Meeting[]
|
||||
joinMeeting: (roomName: string, title: string) => void
|
||||
copyMeetingLink: (roomName: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className={`backdrop-blur-xl border rounded-3xl mb-8 overflow-hidden ${
|
||||
isDark ? 'bg-white/10 border-white/20' : 'bg-white/70 border-black/10 shadow-lg'
|
||||
}`}>
|
||||
<div className={`p-6 border-b flex items-center justify-between ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
|
||||
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>Aktive Meetings</h2>
|
||||
<span className="px-3 py-1 bg-green-500/20 text-green-400 text-sm font-medium rounded-full">
|
||||
{meetings.length} Live
|
||||
</span>
|
||||
</div>
|
||||
<div className="divide-y divide-white/10">
|
||||
{meetings.map((meeting) => (
|
||||
<div key={meeting.room_name} className={`p-6 flex items-center gap-4 transition-colors ${isDark ? 'hover:bg-white/5' : 'hover:bg-slate-50'}`}>
|
||||
<div className="w-14 h-14 bg-gradient-to-br from-green-400 to-emerald-600 rounded-2xl flex items-center justify-center text-white animate-pulse">
|
||||
{Icons.video}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>{meeting.title}</div>
|
||||
<div className={`flex items-center gap-4 text-sm mt-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
<span className="flex items-center gap-1">{Icons.users} {meeting.participants || 0} Teilnehmer</span>
|
||||
{meeting.started_at && (
|
||||
<span className="flex items-center gap-1">{Icons.clock} Gestartet {formatTime(meeting.started_at)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => joinMeeting(meeting.room_name, meeting.title)}
|
||||
className="px-5 py-2.5 bg-gradient-to-r from-green-500 to-emerald-500 text-white rounded-xl font-medium hover:shadow-lg hover:shadow-green-500/30 transition-all hover:scale-105">
|
||||
Beitreten
|
||||
</button>
|
||||
<button onClick={() => copyMeetingLink(meeting.room_name)}
|
||||
className={`p-2.5 rounded-xl transition-all ${isDark ? 'text-white/40 hover:text-white hover:bg-white/10' : 'text-slate-400 hover:text-slate-600 hover:bg-slate-100'}`}
|
||||
title="Link kopieren">
|
||||
{Icons.copy}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ScheduledMeetingsList({ isDark, loading, meetings, joinMeeting, copyMeetingLink, setShowNewMeetingModal }: {
|
||||
isDark: boolean; loading: boolean; meetings: Meeting[]
|
||||
joinMeeting: (roomName: string, title: string) => void
|
||||
copyMeetingLink: (roomName: string) => void
|
||||
setShowNewMeetingModal: (show: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<div className={`backdrop-blur-xl border rounded-3xl overflow-hidden ${
|
||||
isDark ? 'bg-white/10 border-white/20' : 'bg-white/70 border-black/10 shadow-lg'
|
||||
}`}>
|
||||
<div className={`p-6 border-b flex items-center justify-between ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
|
||||
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>Naechste Meetings</h2>
|
||||
<button className={`text-sm transition-colors ${isDark ? 'text-white/60 hover:text-white' : 'text-slate-500 hover:text-slate-900'}`}>
|
||||
Alle anzeigen →
|
||||
</button>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className={`p-12 text-center ${isDark ? 'text-white/40' : 'text-slate-400'}`}>Laet...</div>
|
||||
) : meetings.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<div className={`w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-4 ${isDark ? 'bg-white/10' : 'bg-slate-100'}`}>
|
||||
<span className="text-4xl">{Icons.calendar}</span>
|
||||
</div>
|
||||
<p className={`mb-4 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Keine geplanten Meetings</p>
|
||||
<button onClick={() => setShowNewMeetingModal(true)} className="text-blue-500 hover:text-blue-400 font-medium">
|
||||
Meeting planen
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`divide-y ${isDark ? 'divide-white/10' : 'divide-slate-100'}`}>
|
||||
{meetings.slice(0, 5).map((meeting) => (
|
||||
<div key={meeting.room_name} className={`p-6 flex items-center gap-4 transition-colors ${isDark ? 'hover:bg-white/5' : 'hover:bg-slate-50'}`}>
|
||||
<div className={`text-center min-w-[70px] px-3 py-2 rounded-2xl ${isDark ? 'bg-white/10' : 'bg-slate-100'}`}>
|
||||
<div className={`text-lg font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{meeting.scheduled_at ? formatTime(meeting.scheduled_at) : '--:--'}
|
||||
</div>
|
||||
<div className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
{meeting.scheduled_at ? formatDate(meeting.scheduled_at) : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>{meeting.title}</div>
|
||||
<div className={`flex items-center gap-4 text-sm mt-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
<span className="flex items-center gap-1">{Icons.clock} {meeting.duration} Min</span>
|
||||
<span className="capitalize">{meeting.type}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`px-3 py-1 text-xs font-medium rounded-full ${isDark ? 'bg-blue-500/20 text-blue-300' : 'bg-blue-100 text-blue-700'}`}>
|
||||
Geplant
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => joinMeeting(meeting.room_name, meeting.title)}
|
||||
className="px-5 py-2.5 bg-gradient-to-r from-blue-500 to-purple-500 text-white rounded-xl font-medium hover:shadow-lg hover:shadow-purple-500/30 transition-all hover:scale-105">
|
||||
Beitreten
|
||||
</button>
|
||||
<button onClick={() => copyMeetingLink(meeting.room_name)}
|
||||
className={`p-2.5 rounded-xl transition-all ${isDark ? 'text-white/40 hover:text-white hover:bg-white/10' : 'text-slate-400 hover:text-slate-600 hover:bg-slate-100'}`}
|
||||
title="Link kopieren">
|
||||
{Icons.copy}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
55
studio-v2/app/meet/_components/JoinMeetingModal.tsx
Normal file
55
studio-v2/app/meet/_components/JoinMeetingModal.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
|
||||
import { Icons } from './types'
|
||||
|
||||
interface JoinMeetingModalProps {
|
||||
isDark: boolean
|
||||
currentMeetingUrl: string
|
||||
currentMeetingTitle: string
|
||||
openInNewTab: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function JoinMeetingModal({
|
||||
isDark, currentMeetingUrl, currentMeetingTitle,
|
||||
openInNewTab, onClose,
|
||||
}: JoinMeetingModalProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/90 flex flex-col z-50">
|
||||
<div className={`p-4 flex items-center justify-between ${isDark ? 'bg-slate-900' : 'bg-slate-800'}`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-purple-500 rounded-xl flex items-center justify-center text-white font-bold">
|
||||
BP
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-white">{currentMeetingTitle}</div>
|
||||
<div className="text-sm text-white/50">BreakPilot Meet</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={openInNewTab}
|
||||
className="flex items-center gap-2 px-4 py-2 text-white/70 hover:text-white hover:bg-white/10 rounded-xl transition-colors"
|
||||
>
|
||||
{Icons.external}
|
||||
Im neuen Tab oeffnen
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-white/50 hover:text-white hover:bg-white/10 rounded-xl transition-colors"
|
||||
>
|
||||
{Icons.close}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<iframe
|
||||
src={currentMeetingUrl}
|
||||
className="w-full h-full border-0"
|
||||
allow="camera; microphone; fullscreen; display-capture; autoplay"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
131
studio-v2/app/meet/_components/NewMeetingModal.tsx
Normal file
131
studio-v2/app/meet/_components/NewMeetingModal.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
'use client'
|
||||
|
||||
import { Icons } from './types'
|
||||
|
||||
interface NewMeetingModalProps {
|
||||
isDark: boolean
|
||||
meetingType: string
|
||||
meetingTitle: string
|
||||
meetingDuration: number
|
||||
meetingDateTime: string
|
||||
enableLobby: boolean
|
||||
enableRecording: boolean
|
||||
muteOnStart: boolean
|
||||
creating: boolean
|
||||
setMeetingType: (type: string) => void
|
||||
setMeetingTitle: (title: string) => void
|
||||
setMeetingDuration: (duration: number) => void
|
||||
setMeetingDateTime: (dateTime: string) => void
|
||||
setEnableLobby: (val: boolean) => void
|
||||
setEnableRecording: (val: boolean) => void
|
||||
setMuteOnStart: (val: boolean) => void
|
||||
onClose: () => void
|
||||
onSubmit: () => void
|
||||
}
|
||||
|
||||
export function NewMeetingModal({
|
||||
isDark, meetingType, meetingTitle, meetingDuration, meetingDateTime,
|
||||
enableLobby, enableRecording, muteOnStart, creating,
|
||||
setMeetingType, setMeetingTitle, setMeetingDuration, setMeetingDateTime,
|
||||
setEnableLobby, setEnableRecording, setMuteOnStart,
|
||||
onClose, onSubmit,
|
||||
}: NewMeetingModalProps) {
|
||||
const inputClass = `w-full px-4 py-3 rounded-xl transition-all focus:outline-none focus:ring-2 ${
|
||||
isDark
|
||||
? 'bg-white/10 border border-white/20 text-white focus:ring-white/30'
|
||||
: 'bg-slate-50 border border-slate-300 text-slate-900 focus:ring-blue-300'
|
||||
}`
|
||||
|
||||
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-lg rounded-3xl border p-6 ${
|
||||
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'}`}>Neues Meeting erstellen</h2>
|
||||
<button onClick={onClose}
|
||||
className={`p-2 rounded-lg transition-colors ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'}`}>
|
||||
{Icons.close}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-2 ${isDark ? 'text-white/70' : 'text-slate-700'}`}>Meeting-Typ</label>
|
||||
<select value={meetingType} onChange={(e) => setMeetingType(e.target.value)} className={inputClass}>
|
||||
<option value="quick">Sofort-Meeting</option>
|
||||
<option value="scheduled">Geplantes Meeting</option>
|
||||
<option value="training">Schulung</option>
|
||||
<option value="parent">Elterngespraech</option>
|
||||
<option value="class">Klassenkonferenz</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-2 ${isDark ? 'text-white/70' : 'text-slate-700'}`}>Titel</label>
|
||||
<input
|
||||
type="text"
|
||||
value={meetingTitle}
|
||||
onChange={(e) => setMeetingTitle(e.target.value)}
|
||||
placeholder="Meeting-Titel eingeben"
|
||||
className={`${inputClass} ${isDark ? 'placeholder-white/40' : 'placeholder-slate-400'}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{meetingType !== 'quick' && (
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-2 ${isDark ? 'text-white/70' : 'text-slate-700'}`}>Datum & Uhrzeit</label>
|
||||
<input type="datetime-local" value={meetingDateTime}
|
||||
onChange={(e) => setMeetingDateTime(e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-2 ${isDark ? 'text-white/70' : 'text-slate-700'}`}>Dauer</label>
|
||||
<select value={meetingDuration} onChange={(e) => setMeetingDuration(Number(e.target.value))} className={inputClass}>
|
||||
<option value={30}>30 Minuten</option>
|
||||
<option value={60}>60 Minuten</option>
|
||||
<option value={90}>90 Minuten</option>
|
||||
<option value={120}>120 Minuten</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-2">
|
||||
<CheckboxOption isDark={isDark} checked={enableLobby}
|
||||
onChange={setEnableLobby} label="Warteraum aktivieren" />
|
||||
<CheckboxOption isDark={isDark} checked={enableRecording}
|
||||
onChange={setEnableRecording} label="Aufzeichnung erlauben" />
|
||||
<CheckboxOption isDark={isDark} checked={muteOnStart}
|
||||
onChange={setMuteOnStart} label="Teilnehmer stummschalten bei Beitritt" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`mt-6 pt-6 border-t flex justify-end gap-3 ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
|
||||
<button onClick={onClose}
|
||||
className={`px-5 py-2.5 rounded-xl font-medium transition-all ${
|
||||
isDark ? 'text-white/60 hover:text-white hover:bg-white/10' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-100'
|
||||
}`}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button onClick={onSubmit} disabled={creating}
|
||||
className="px-6 py-2.5 bg-gradient-to-r from-blue-500 to-purple-500 text-white rounded-xl font-medium hover:shadow-lg hover:shadow-purple-500/30 transition-all hover:scale-105 disabled:opacity-50 disabled:hover:scale-100">
|
||||
{creating ? 'Erstellen...' : meetingType === 'quick' ? 'Meeting starten' : 'Meeting erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CheckboxOption({ isDark, checked, onChange, label }: {
|
||||
isDark: boolean; checked: boolean; onChange: (val: boolean) => void; label: string
|
||||
}) {
|
||||
return (
|
||||
<label className={`flex items-center gap-3 cursor-pointer ${isDark ? 'text-white/70' : 'text-slate-700'}`}>
|
||||
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)}
|
||||
className="w-5 h-5 rounded text-blue-500 focus:ring-blue-500" />
|
||||
<span className="text-sm">{label}</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
196
studio-v2/app/meet/_components/RecordingsTab.tsx
Normal file
196
studio-v2/app/meet/_components/RecordingsTab.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
'use client'
|
||||
|
||||
import { Icons, formatDuration, formatFileSize, type Recording } from './types'
|
||||
|
||||
interface RecordingsTabProps {
|
||||
isDark: boolean
|
||||
loading: boolean
|
||||
recordingsFilter: string
|
||||
setRecordingsFilter: (filter: string) => void
|
||||
filteredRecordings: Recording[]
|
||||
recordings: Recording[]
|
||||
totalStorageBytes: number
|
||||
maxStorageGB: number
|
||||
storagePercent: string
|
||||
fetchData: () => void
|
||||
startQuickMeeting: () => void
|
||||
playRecording: (id: string) => void
|
||||
viewTranscript: (recording: Recording) => void
|
||||
downloadRecording: (id: string) => void
|
||||
deleteRecording: (id: string) => void
|
||||
}
|
||||
|
||||
export function RecordingsTab({
|
||||
isDark, loading, recordingsFilter, setRecordingsFilter,
|
||||
filteredRecordings, recordings, totalStorageBytes, maxStorageGB, storagePercent,
|
||||
fetchData, startQuickMeeting,
|
||||
playRecording, viewTranscript, downloadRecording, deleteRecording,
|
||||
}: RecordingsTabProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Filter Tabs */}
|
||||
<div className="flex flex-wrap gap-2 mb-8">
|
||||
{[
|
||||
{ key: 'all', label: 'Alle' },
|
||||
{ key: 'uploaded', label: 'Neu' },
|
||||
{ key: 'processing', label: 'In Verarbeitung' },
|
||||
{ key: 'ready', label: 'Fertig' },
|
||||
].map((filter) => (
|
||||
<button
|
||||
key={filter.key}
|
||||
onClick={() => setRecordingsFilter(filter.key)}
|
||||
className={`px-5 py-2.5 rounded-xl font-medium transition-all ${
|
||||
recordingsFilter === filter.key
|
||||
? 'bg-gradient-to-r from-blue-500 to-purple-500 text-white shadow-lg'
|
||||
: isDark
|
||||
? 'bg-white/10 text-white/60 hover:bg-white/20 hover:text-white'
|
||||
: 'bg-white/70 text-slate-600 hover:bg-white hover:text-slate-900 shadow'
|
||||
}`}
|
||||
>
|
||||
{filter.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={fetchData}
|
||||
className={`flex items-center gap-2 px-5 py-2.5 rounded-xl font-medium transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white hover:bg-white/20'
|
||||
: 'bg-white/70 text-slate-700 hover:bg-white shadow'
|
||||
}`}
|
||||
>
|
||||
{Icons.refresh}
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Recordings List */}
|
||||
{loading ? (
|
||||
<div className={`text-center py-12 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>Lade Aufzeichnungen...</div>
|
||||
) : filteredRecordings.length === 0 ? (
|
||||
<EmptyRecordings isDark={isDark} startQuickMeeting={startQuickMeeting} />
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredRecordings.map((recording) => (
|
||||
<RecordingCard key={recording.id} isDark={isDark} recording={recording}
|
||||
playRecording={playRecording} viewTranscript={viewTranscript}
|
||||
downloadRecording={downloadRecording} deleteRecording={deleteRecording} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Storage Info */}
|
||||
<div className={`backdrop-blur-xl border rounded-3xl p-6 mt-8 ${
|
||||
isDark ? 'bg-white/10 border-white/20' : 'bg-white/70 border-black/10 shadow-lg'
|
||||
}`}>
|
||||
<h3 className={`font-medium mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>Speicherplatz</h3>
|
||||
<div className="flex justify-between text-sm mb-2">
|
||||
<span className={isDark ? 'text-white/60' : 'text-slate-500'}>
|
||||
{(totalStorageBytes / (1024 * 1024)).toFixed(1)} MB von {maxStorageGB} GB verwendet
|
||||
</span>
|
||||
<span className={isDark ? 'text-white/60' : 'text-slate-500'}>{storagePercent}%</span>
|
||||
</div>
|
||||
<div className={`h-3 rounded-full overflow-hidden ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}>
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-blue-500 to-purple-500 rounded-full transition-all"
|
||||
style={{ width: `${Math.min(Number(storagePercent), 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className={`text-sm mt-3 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
{recordings.length} Aufzeichnungen
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyRecordings({ isDark, startQuickMeeting }: { isDark: boolean; startQuickMeeting: () => void }) {
|
||||
return (
|
||||
<div className={`backdrop-blur-xl border rounded-3xl p-12 text-center ${
|
||||
isDark ? 'bg-white/10 border-white/20' : 'bg-white/70 border-black/10 shadow-lg'
|
||||
}`}>
|
||||
<div className={`w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-4 ${
|
||||
isDark ? 'bg-white/10 text-white/40' : 'bg-slate-100 text-slate-400'
|
||||
}`}>
|
||||
{Icons.record}
|
||||
</div>
|
||||
<h3 className={`font-medium mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>Keine Aufzeichnungen</h3>
|
||||
<p className={`mb-6 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
Starten Sie eine Aufzeichnung in einem Meeting, um sie hier zu sehen.
|
||||
</p>
|
||||
<button
|
||||
onClick={startQuickMeeting}
|
||||
className="px-6 py-3 bg-gradient-to-r from-blue-500 to-purple-500 text-white rounded-xl font-medium hover:shadow-lg hover:shadow-purple-500/30 transition-all hover:scale-105"
|
||||
>
|
||||
Meeting starten
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RecordingCard({ isDark, recording, playRecording, viewTranscript, downloadRecording, deleteRecording }: {
|
||||
isDark: boolean; recording: Recording
|
||||
playRecording: (id: string) => void
|
||||
viewTranscript: (recording: Recording) => void
|
||||
downloadRecording: (id: string) => void
|
||||
deleteRecording: (id: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className={`backdrop-blur-xl border rounded-3xl p-6 flex items-center gap-4 transition-all hover:scale-[1.01] ${
|
||||
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 bg-gradient-to-br from-red-400 to-rose-600 rounded-2xl flex items-center justify-center text-white shadow-lg">
|
||||
{Icons.record}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className={`font-medium flex items-center gap-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{recording.title || `Aufzeichnung ${recording.meeting_id}`}
|
||||
{recording.status === 'processing' && (
|
||||
<span className="px-2 py-0.5 bg-amber-500/20 text-amber-400 text-xs font-medium rounded-full">Verarbeitung</span>
|
||||
)}
|
||||
{recording.transcription_status === 'pending' && (
|
||||
<span className="px-2 py-0.5 bg-blue-500/20 text-blue-400 text-xs font-medium rounded-full">Transkript ausstehend</span>
|
||||
)}
|
||||
{recording.transcription_status === 'completed' && (
|
||||
<span className="px-2 py-0.5 bg-green-500/20 text-green-400 text-xs font-medium rounded-full">Transkript bereit</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={`text-sm mt-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
{new Date(recording.recorded_at).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })},{' '}
|
||||
{new Date(recording.recorded_at).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}{' '}
|
||||
| {formatDuration(recording.duration_seconds || 0)} | {formatFileSize(recording.file_size_bytes || 0)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => playRecording(recording.id)}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl font-medium transition-all ${
|
||||
isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||||
}`}>
|
||||
{Icons.play} Abspielen
|
||||
</button>
|
||||
<button onClick={() => viewTranscript(recording)}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl font-medium transition-all ${
|
||||
isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||||
}`}>
|
||||
{Icons.document} Protokoll
|
||||
</button>
|
||||
<button onClick={() => downloadRecording(recording.id)}
|
||||
className={`p-2.5 rounded-xl transition-all ${
|
||||
isDark ? 'text-white/40 hover:text-white hover:bg-white/10' : 'text-slate-400 hover:text-slate-600 hover:bg-slate-100'
|
||||
}`}
|
||||
title="Herunterladen">
|
||||
{Icons.download}
|
||||
</button>
|
||||
<button onClick={() => deleteRecording(recording.id)}
|
||||
className={`p-2.5 rounded-xl transition-all ${
|
||||
isDark ? 'text-white/40 hover:text-red-400 hover:bg-red-500/10' : 'text-slate-400 hover:text-red-500 hover:bg-red-50'
|
||||
}`}
|
||||
title="Loeschen">
|
||||
{Icons.trash}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
93
studio-v2/app/meet/_components/TranscriptModal.tsx
Normal file
93
studio-v2/app/meet/_components/TranscriptModal.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
'use client'
|
||||
|
||||
import { Icons, getBackendUrl, type Recording } from './types'
|
||||
|
||||
interface TranscriptModalProps {
|
||||
isDark: boolean
|
||||
currentRecording: Recording
|
||||
transcriptText: string
|
||||
transcriptLoading: boolean
|
||||
startTranscription: (id: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function TranscriptModal({
|
||||
isDark, currentRecording, transcriptText, transcriptLoading,
|
||||
startTranscription, onClose,
|
||||
}: TranscriptModalProps) {
|
||||
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 max-h-[80vh] flex flex-col rounded-3xl border ${
|
||||
isDark ? 'bg-slate-900 border-white/20' : 'bg-white border-slate-200 shadow-2xl'
|
||||
}`}>
|
||||
{/* Header */}
|
||||
<div className={`p-6 border-b flex items-center justify-between ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
|
||||
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Transkript: {currentRecording.title || `Aufzeichnung ${currentRecording.meeting_id}`}
|
||||
</h2>
|
||||
<button onClick={onClose}
|
||||
className={`p-2 rounded-lg transition-colors ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'}`}>
|
||||
{Icons.close}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{transcriptLoading ? (
|
||||
<div className={`text-center py-8 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>Lade Transkript...</div>
|
||||
) : transcriptText === 'PENDING' ? (
|
||||
<div className="text-center py-8">
|
||||
<div className={`w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4 ${
|
||||
isDark ? 'bg-amber-500/20 text-amber-400' : 'bg-amber-100 text-amber-600'
|
||||
}`}>
|
||||
{Icons.clock}
|
||||
</div>
|
||||
<h4 className={`font-medium mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>Transkription ausstehend</h4>
|
||||
<p className={`mb-6 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
Die Transkription wurde noch nicht gestartet oder ist in Bearbeitung.
|
||||
</p>
|
||||
<button onClick={() => startTranscription(currentRecording.id)}
|
||||
className="px-6 py-3 bg-gradient-to-r from-blue-500 to-purple-500 text-white rounded-xl font-medium hover:shadow-lg hover:shadow-purple-500/30 transition-all hover:scale-105">
|
||||
Transkription starten
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`whitespace-pre-wrap leading-relaxed ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
{transcriptText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className={`p-6 border-t flex justify-end gap-3 ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
|
||||
{transcriptText && transcriptText !== 'PENDING' && (
|
||||
<>
|
||||
<DownloadButton isDark={isDark} label="WebVTT"
|
||||
href={`${getBackendUrl()}/api/recordings/${currentRecording.id}/transcription/vtt`} />
|
||||
<DownloadButton isDark={isDark} label="SRT"
|
||||
href={`${getBackendUrl()}/api/recordings/${currentRecording.id}/transcription/srt`} />
|
||||
</>
|
||||
)}
|
||||
<button onClick={onClose}
|
||||
className={`px-5 py-2.5 rounded-xl font-medium transition-all ${
|
||||
isDark ? 'text-white/60 hover:text-white hover:bg-white/10' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-100'
|
||||
}`}>
|
||||
Schliessen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DownloadButton({ isDark, label, href }: { isDark: boolean; label: string; href: string }) {
|
||||
return (
|
||||
<button onClick={() => window.location.href = href}
|
||||
className={`px-4 py-2.5 rounded-xl font-medium transition-all ${
|
||||
isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||||
}`}>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
193
studio-v2/app/meet/_components/types.ts
Normal file
193
studio-v2/app/meet/_components/types.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
// ============================================
|
||||
// CONFIGURATION
|
||||
// ============================================
|
||||
|
||||
// API calls now go through Next.js rewrites (see next.config.js)
|
||||
// This avoids mixed-content issues when accessing via HTTPS
|
||||
export const getBackendUrl = () => {
|
||||
// Return empty string to use relative URLs that go through Next.js proxy
|
||||
return ''
|
||||
}
|
||||
|
||||
export const getJitsiUrl = () => {
|
||||
if (typeof window === 'undefined') return 'http://localhost:8443'
|
||||
const { hostname, protocol } = window.location
|
||||
// Use /jitsi/ path on same origin to avoid SSL certificate issues with separate ports
|
||||
return hostname === 'localhost' ? 'http://localhost:8443' : `${protocol}//${hostname}/jitsi`
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// TYPES
|
||||
// ============================================
|
||||
|
||||
export type TabType = 'dashboard' | 'breakout' | 'recordings'
|
||||
|
||||
export interface MeetingStats {
|
||||
active: number
|
||||
scheduled: number
|
||||
recordings: number
|
||||
participants: number
|
||||
}
|
||||
|
||||
export interface Meeting {
|
||||
room_name: string
|
||||
title: string
|
||||
type: string
|
||||
scheduled_at?: string
|
||||
duration: number
|
||||
participants?: number
|
||||
started_at?: string
|
||||
}
|
||||
|
||||
export interface MeetingConfig {
|
||||
enable_lobby: boolean
|
||||
enable_recording: boolean
|
||||
start_with_audio_muted: boolean
|
||||
start_with_video_muted: boolean
|
||||
}
|
||||
|
||||
export interface Recording {
|
||||
id: string
|
||||
meeting_id: string
|
||||
title: string
|
||||
recorded_at: string
|
||||
duration_seconds: number
|
||||
file_size_bytes: number
|
||||
status: string
|
||||
transcription_status?: string
|
||||
}
|
||||
|
||||
export interface BreakoutRoom {
|
||||
id: string
|
||||
name: string
|
||||
participants: string[]
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ICONS
|
||||
// ============================================
|
||||
|
||||
import React from 'react'
|
||||
|
||||
export const Icons = {
|
||||
video: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
),
|
||||
calendar: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
),
|
||||
users: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
),
|
||||
graduation: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 14l9-5-9-5-9 5 9 5z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 14l6.16-3.422a12.083 12.083 0 01.665 6.479A11.952 11.952 0 0012 20.055a11.952 11.952 0 00-6.824-2.998 12.078 12.078 0 01.665-6.479L12 14z" />
|
||||
</svg>
|
||||
),
|
||||
record: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10" strokeWidth={2} />
|
||||
<circle cx="12" cy="12" r="4" fill="currentColor" />
|
||||
</svg>
|
||||
),
|
||||
clock: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
plus: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
),
|
||||
copy: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
),
|
||||
external: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
),
|
||||
close: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
),
|
||||
grid: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
),
|
||||
play: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
download: (
|
||||
<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>
|
||||
),
|
||||
trash: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
),
|
||||
document: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
),
|
||||
refresh: (
|
||||
<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>
|
||||
),
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// FORMAT HELPERS
|
||||
// ============================================
|
||||
|
||||
export const formatTime = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
export const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
const today = new Date()
|
||||
const tomorrow = new Date(today)
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
|
||||
if (date.toDateString() === today.toDateString()) return 'Heute'
|
||||
if (date.toDateString() === tomorrow.toDateString()) return 'Morgen'
|
||||
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })
|
||||
}
|
||||
|
||||
export const formatDuration = (seconds: number) => {
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
const s = seconds % 60
|
||||
if (h > 0) {
|
||||
return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||
}
|
||||
return `${m}:${String(s).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024 * 1024) {
|
||||
return (bytes / 1024).toFixed(1) + ' KB'
|
||||
}
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
323
studio-v2/app/meet/_components/useMeetPage.ts
Normal file
323
studio-v2/app/meet/_components/useMeetPage.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
type TabType,
|
||||
type MeetingStats,
|
||||
type Meeting,
|
||||
type Recording,
|
||||
type BreakoutRoom,
|
||||
getBackendUrl,
|
||||
getJitsiUrl,
|
||||
} from './types'
|
||||
|
||||
export function useMeetPage() {
|
||||
// Tab state
|
||||
const [activeTab, setActiveTab] = useState<TabType>('dashboard')
|
||||
|
||||
const [stats, setStats] = useState<MeetingStats>({ active: 0, scheduled: 0, recordings: 0, participants: 0 })
|
||||
const [scheduledMeetings, setScheduledMeetings] = useState<Meeting[]>([])
|
||||
const [activeMeetings, setActiveMeetings] = useState<Meeting[]>([])
|
||||
const [recordings, setRecordings] = useState<Recording[]>([])
|
||||
const [recordingsFilter, setRecordingsFilter] = useState<string>('all')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showNewMeetingModal, setShowNewMeetingModal] = useState(false)
|
||||
const [showJoinModal, setShowJoinModal] = useState(false)
|
||||
const [showTranscriptModal, setShowTranscriptModal] = useState(false)
|
||||
const [currentMeetingUrl, setCurrentMeetingUrl] = useState('')
|
||||
const [currentMeetingTitle, setCurrentMeetingTitle] = useState('')
|
||||
const [currentRecording, setCurrentRecording] = useState<Recording | null>(null)
|
||||
const [transcriptText, setTranscriptText] = useState('')
|
||||
const [transcriptLoading, setTranscriptLoading] = useState(false)
|
||||
|
||||
// Breakout rooms state
|
||||
const [breakoutRooms, setBreakoutRooms] = useState<BreakoutRoom[]>([
|
||||
{ id: '1', name: 'Raum 1', participants: [] },
|
||||
{ id: '2', name: 'Raum 2', participants: [] },
|
||||
{ id: '3', name: 'Raum 3', participants: [] },
|
||||
])
|
||||
const [breakoutAssignment, setBreakoutAssignment] = useState('equal')
|
||||
const [breakoutTimer, setBreakoutTimer] = useState(15)
|
||||
const [hasActiveMeeting, setHasActiveMeeting] = useState(false)
|
||||
|
||||
// Form state
|
||||
const [meetingType, setMeetingType] = useState('quick')
|
||||
const [meetingTitle, setMeetingTitle] = useState('')
|
||||
const [meetingDuration, setMeetingDuration] = useState(60)
|
||||
const [meetingDateTime, setMeetingDateTime] = useState('')
|
||||
const [enableLobby, setEnableLobby] = useState(true)
|
||||
const [enableRecording, setEnableRecording] = useState(false)
|
||||
const [muteOnStart, setMuteOnStart] = useState(true)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
|
||||
// ============================================
|
||||
// DATA FETCHING
|
||||
// ============================================
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [statsRes, scheduledRes, activeRes, recordingsRes] = await Promise.all([
|
||||
fetch(`${getBackendUrl()}/api/meetings/stats`).catch(() => null),
|
||||
fetch(`${getBackendUrl()}/api/meetings/scheduled`).catch(() => null),
|
||||
fetch(`${getBackendUrl()}/api/meetings/active`).catch(() => null),
|
||||
fetch(`${getBackendUrl()}/api/recordings`).catch(() => null),
|
||||
])
|
||||
|
||||
if (statsRes?.ok) {
|
||||
const statsData = await statsRes.json()
|
||||
setStats(statsData)
|
||||
}
|
||||
|
||||
if (scheduledRes?.ok) {
|
||||
const scheduledData = await scheduledRes.json()
|
||||
setScheduledMeetings(scheduledData)
|
||||
}
|
||||
|
||||
if (activeRes?.ok) {
|
||||
const activeData = await activeRes.json()
|
||||
setActiveMeetings(activeData)
|
||||
setHasActiveMeeting(activeData.length > 0)
|
||||
}
|
||||
|
||||
if (recordingsRes?.ok) {
|
||||
const recordingsData = await recordingsRes.json()
|
||||
setRecordings(recordingsData.recordings || recordingsData || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch meeting data:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ACTIONS
|
||||
// ============================================
|
||||
|
||||
const joinMeeting = (roomName: string, title: string) => {
|
||||
const url = `${getJitsiUrl()}/${roomName}#config.prejoinPageEnabled=false&config.defaultLanguage=de&interfaceConfig.SHOW_JITSI_WATERMARK=false`
|
||||
setCurrentMeetingUrl(url)
|
||||
setCurrentMeetingTitle(title)
|
||||
setShowJoinModal(true)
|
||||
}
|
||||
|
||||
const createMeeting = async () => {
|
||||
setCreating(true)
|
||||
setErrorMessage(null)
|
||||
try {
|
||||
const response = await fetch(`${getBackendUrl()}/api/meetings/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: meetingType,
|
||||
title: meetingTitle || 'Neues Meeting',
|
||||
duration: meetingDuration,
|
||||
scheduled_at: meetingType !== 'quick' ? meetingDateTime : null,
|
||||
config: {
|
||||
enable_lobby: enableLobby,
|
||||
enable_recording: enableRecording,
|
||||
start_with_audio_muted: muteOnStart,
|
||||
start_with_video_muted: false,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const meeting = await response.json()
|
||||
setShowNewMeetingModal(false)
|
||||
|
||||
if (meetingType === 'quick') {
|
||||
joinMeeting(meeting.room_name, meetingTitle || 'Neues Meeting')
|
||||
} else {
|
||||
fetchData()
|
||||
}
|
||||
|
||||
setMeetingTitle('')
|
||||
setMeetingType('quick')
|
||||
} else {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
const errorMsg = errorData.detail || `Server-Fehler: ${response.status}`
|
||||
setErrorMessage(errorMsg)
|
||||
console.error('Failed to create meeting:', response.status, errorData)
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Netzwerkfehler - Backend nicht erreichbar'
|
||||
setErrorMessage(errorMsg)
|
||||
console.error('Failed to create meeting:', error)
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const startQuickMeeting = async () => {
|
||||
setCreating(true)
|
||||
setErrorMessage(null)
|
||||
try {
|
||||
const response = await fetch(`${getBackendUrl()}/api/meetings/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'quick',
|
||||
title: 'Sofort-Meeting',
|
||||
duration: 60,
|
||||
config: {
|
||||
enable_lobby: false,
|
||||
enable_recording: false,
|
||||
start_with_audio_muted: true,
|
||||
start_with_video_muted: false,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const meeting = await response.json()
|
||||
joinMeeting(meeting.room_name, 'Sofort-Meeting')
|
||||
} else {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
const errorMsg = errorData.detail || `Server-Fehler: ${response.status}`
|
||||
setErrorMessage(errorMsg)
|
||||
console.error('Failed to create meeting:', response.status, errorData)
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Netzwerkfehler - Backend nicht erreichbar'
|
||||
setErrorMessage(errorMsg)
|
||||
console.error('Failed to start quick meeting:', error)
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openInNewTab = () => {
|
||||
window.open(currentMeetingUrl, '_blank')
|
||||
setShowJoinModal(false)
|
||||
}
|
||||
|
||||
const copyMeetingLink = async (roomName: string) => {
|
||||
const url = `${getJitsiUrl()}/${roomName}`
|
||||
await navigator.clipboard.writeText(url)
|
||||
}
|
||||
|
||||
// Recording actions
|
||||
const playRecording = (recordingId: string) => {
|
||||
window.open(`${getBackendUrl()}/meetings/recordings/${recordingId}/play`, '_blank')
|
||||
}
|
||||
|
||||
const viewTranscript = async (recording: Recording) => {
|
||||
setCurrentRecording(recording)
|
||||
setShowTranscriptModal(true)
|
||||
setTranscriptLoading(true)
|
||||
setTranscriptText('')
|
||||
|
||||
try {
|
||||
const response = await fetch(`${getBackendUrl()}/api/recordings/${recording.id}/transcription/text`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setTranscriptText(data.text || 'Kein Transkript verfuegbar')
|
||||
} else if (response.status === 404) {
|
||||
setTranscriptText('PENDING')
|
||||
} else {
|
||||
setTranscriptText('Fehler beim Laden des Transkripts')
|
||||
}
|
||||
} catch {
|
||||
setTranscriptText('Fehler beim Laden des Transkripts')
|
||||
} finally {
|
||||
setTranscriptLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const startTranscription = async (recordingId: string) => {
|
||||
try {
|
||||
const response = await fetch(`${getBackendUrl()}/api/recordings/${recordingId}/transcribe`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ language: 'de', model: 'large-v3' }),
|
||||
})
|
||||
if (response.ok) {
|
||||
alert('Transkription gestartet! Dies kann einige Minuten dauern.')
|
||||
setShowTranscriptModal(false)
|
||||
fetchData()
|
||||
}
|
||||
} catch {
|
||||
alert('Fehler beim Starten der Transkription')
|
||||
}
|
||||
}
|
||||
|
||||
const downloadRecording = (recordingId: string) => {
|
||||
window.location.href = `${getBackendUrl()}/api/recordings/${recordingId}/download`
|
||||
}
|
||||
|
||||
const deleteRecording = async (recordingId: string) => {
|
||||
const reason = prompt('Grund fuer die Loeschung (DSGVO-Dokumentation):')
|
||||
if (!reason) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`${getBackendUrl()}/api/recordings/${recordingId}?reason=${encodeURIComponent(reason)}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (response.ok) {
|
||||
fetchData()
|
||||
}
|
||||
} catch {
|
||||
alert('Fehler beim Loeschen')
|
||||
}
|
||||
}
|
||||
|
||||
const filteredRecordings = recordings.filter((r) => {
|
||||
if (recordingsFilter === 'all') return true
|
||||
return r.status === recordingsFilter
|
||||
})
|
||||
|
||||
const totalStorageBytes = recordings.reduce((sum, r) => sum + (r.file_size_bytes || 0), 0)
|
||||
const maxStorageGB = 10
|
||||
const storagePercent = ((totalStorageBytes / (maxStorageGB * 1024 * 1024 * 1024)) * 100).toFixed(1)
|
||||
|
||||
// Breakout room actions
|
||||
const addBreakoutRoom = () => {
|
||||
const newId = String(breakoutRooms.length + 1)
|
||||
setBreakoutRooms([...breakoutRooms, { id: newId, name: `Raum ${newId}`, participants: [] }])
|
||||
}
|
||||
|
||||
const removeBreakoutRoom = (id: string) => {
|
||||
setBreakoutRooms(breakoutRooms.filter((r) => r.id !== id))
|
||||
}
|
||||
|
||||
return {
|
||||
// Tab
|
||||
activeTab, setActiveTab,
|
||||
// Data
|
||||
stats, scheduledMeetings, activeMeetings, recordings, loading,
|
||||
// Modals
|
||||
showNewMeetingModal, setShowNewMeetingModal,
|
||||
showJoinModal, setShowJoinModal,
|
||||
showTranscriptModal, setShowTranscriptModal,
|
||||
currentMeetingUrl, currentMeetingTitle,
|
||||
currentRecording, transcriptText, transcriptLoading,
|
||||
// Recordings
|
||||
recordingsFilter, setRecordingsFilter,
|
||||
filteredRecordings, totalStorageBytes, maxStorageGB, storagePercent,
|
||||
// Breakout
|
||||
breakoutRooms, breakoutAssignment, setBreakoutAssignment,
|
||||
breakoutTimer, setBreakoutTimer, hasActiveMeeting,
|
||||
addBreakoutRoom, removeBreakoutRoom,
|
||||
// Form
|
||||
meetingType, setMeetingType,
|
||||
meetingTitle, setMeetingTitle,
|
||||
meetingDuration, setMeetingDuration,
|
||||
meetingDateTime, setMeetingDateTime,
|
||||
enableLobby, setEnableLobby,
|
||||
enableRecording, setEnableRecording,
|
||||
muteOnStart, setMuteOnStart,
|
||||
creating, errorMessage, setErrorMessage,
|
||||
// Actions
|
||||
fetchData, createMeeting, startQuickMeeting,
|
||||
joinMeeting, openInNewTab, copyMeetingLink,
|
||||
playRecording, viewTranscript, startTranscription,
|
||||
downloadRecording, deleteRecording,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
425
studio-v2/app/messages/_components/ChatArea.tsx
Normal file
425
studio-v2/app/messages/_components/ChatArea.tsx
Normal file
@@ -0,0 +1,425 @@
|
||||
'use client'
|
||||
|
||||
import { useRef, useEffect } from 'react'
|
||||
import {
|
||||
formatMessageDate,
|
||||
getContactInitials,
|
||||
getRoleLabel,
|
||||
getRoleColor,
|
||||
type Conversation,
|
||||
type Message,
|
||||
type Contact,
|
||||
} from '@/lib/MessagesContext'
|
||||
import { EmojiPicker } from './EmojiPicker'
|
||||
import { TemplatesDropdown } from './TemplatesDropdown'
|
||||
|
||||
interface ChatAreaProps {
|
||||
isDark: boolean
|
||||
currentConversation: Conversation | null | undefined
|
||||
currentContact: Contact | undefined
|
||||
groupedMessages: { date: string; messages: Message[] }[]
|
||||
messageInput: string
|
||||
sendWithEmail: boolean
|
||||
isSending: boolean
|
||||
showEmojiPicker: boolean
|
||||
showTemplates: boolean
|
||||
templates: { id: string; name: string; content: string }[]
|
||||
setMessageInput: (val: string) => void
|
||||
setSendWithEmail: (val: boolean) => void
|
||||
setShowEmojiPicker: (val: boolean) => void
|
||||
setShowTemplates: (val: boolean) => void
|
||||
setShowContactInfo: (val: boolean) => void
|
||||
showContactInfo: boolean
|
||||
handleSendMessage: () => void
|
||||
handleEmojiSelect: (emoji: string) => void
|
||||
handleContextMenu: (e: React.MouseEvent, messageId: string) => void
|
||||
getSenderName: (senderId: string) => string
|
||||
pinConversation: (id: string) => void
|
||||
muteConversation: (id: string) => void
|
||||
setShowNewConversation: (val: boolean) => void
|
||||
}
|
||||
|
||||
export function ChatArea({
|
||||
isDark, currentConversation, currentContact, groupedMessages,
|
||||
messageInput, sendWithEmail, isSending, showEmojiPicker, showTemplates, templates,
|
||||
setMessageInput, setSendWithEmail, setShowEmojiPicker, setShowTemplates,
|
||||
setShowContactInfo, showContactInfo,
|
||||
handleSendMessage, handleEmojiSelect, handleContextMenu,
|
||||
getSenderName, pinConversation, muteConversation, setShowNewConversation,
|
||||
}: ChatAreaProps) {
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [groupedMessages])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSendMessage()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex-1 backdrop-blur-xl border rounded-3xl flex flex-col overflow-hidden ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20'
|
||||
: 'bg-white/70 border-black/10 shadow-xl'
|
||||
}`}>
|
||||
{currentConversation ? (
|
||||
<>
|
||||
{/* Chat Header */}
|
||||
<ChatHeader isDark={isDark} conversation={currentConversation}
|
||||
contact={currentContact} showContactInfo={showContactInfo}
|
||||
setShowContactInfo={setShowContactInfo}
|
||||
pinConversation={pinConversation} muteConversation={muteConversation} />
|
||||
|
||||
{/* Messages */}
|
||||
<div className={`flex-1 overflow-y-auto p-4 space-y-6 ${
|
||||
isDark
|
||||
? 'bg-gradient-to-b from-transparent to-black/10'
|
||||
: 'bg-gradient-to-b from-transparent to-slate-50/50'
|
||||
}`}>
|
||||
{groupedMessages.length === 0 ? (
|
||||
<div className={`text-center py-12 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
<div className="w-20 h-20 mx-auto mb-4 rounded-full flex items-center justify-center text-4xl bg-gradient-to-br from-green-500/20 to-emerald-500/20">
|
||||
👋
|
||||
</div>
|
||||
<p className="font-medium text-lg">Noch keine Nachrichten</p>
|
||||
<p className="text-sm mt-1">Starten Sie die Konversation!</p>
|
||||
</div>
|
||||
) : (
|
||||
groupedMessages.map((group, groupIndex) => (
|
||||
<MessageGroup key={groupIndex} isDark={isDark} group={group}
|
||||
currentConversation={currentConversation}
|
||||
getSenderName={getSenderName}
|
||||
handleContextMenu={handleContextMenu} />
|
||||
))
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Message Input */}
|
||||
<MessageInput isDark={isDark} messageInput={messageInput} sendWithEmail={sendWithEmail}
|
||||
isSending={isSending} showEmojiPicker={showEmojiPicker} showTemplates={showTemplates}
|
||||
templates={templates} inputRef={inputRef}
|
||||
setMessageInput={setMessageInput} setSendWithEmail={setSendWithEmail}
|
||||
setShowEmojiPicker={setShowEmojiPicker} setShowTemplates={setShowTemplates}
|
||||
handleSendMessage={handleSendMessage} handleEmojiSelect={handleEmojiSelect}
|
||||
handleKeyDown={handleKeyDown} />
|
||||
</>
|
||||
) : (
|
||||
<EmptyState isDark={isDark} setShowNewConversation={setShowNewConversation} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// SUB-COMPONENTS
|
||||
// ============================================
|
||||
|
||||
function ChatHeader({ isDark, conversation, contact, showContactInfo, setShowContactInfo, pinConversation, muteConversation }: {
|
||||
isDark: boolean; conversation: Conversation; contact: Contact | undefined
|
||||
showContactInfo: boolean; setShowContactInfo: (val: boolean) => void
|
||||
pinConversation: (id: string) => void; muteConversation: (id: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className={`p-4 border-b ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<div className={`w-12 h-12 rounded-full flex items-center justify-center text-lg font-semibold ${
|
||||
conversation.is_group
|
||||
? isDark
|
||||
? 'bg-gradient-to-br from-purple-500/30 to-pink-500/30 text-purple-300'
|
||||
: 'bg-gradient-to-br from-purple-100 to-pink-100 text-purple-700'
|
||||
: contact?.online
|
||||
? isDark
|
||||
? 'bg-gradient-to-br from-green-500/30 to-emerald-500/30 text-green-300'
|
||||
: 'bg-gradient-to-br from-green-100 to-emerald-100 text-green-700'
|
||||
: isDark
|
||||
? 'bg-slate-700 text-slate-300'
|
||||
: 'bg-slate-200 text-slate-600'
|
||||
}`}>
|
||||
{conversation.title ? getContactInitials(conversation.title) : '?'}
|
||||
</div>
|
||||
{!conversation.is_group && contact?.online && (
|
||||
<span className="absolute bottom-0 right-0 w-3 h-3 bg-green-500 rounded-full border-2 border-white dark:border-slate-900" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{conversation.title || 'Unbenannt'}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{conversation.typing ? (
|
||||
<span className={`text-sm ${isDark ? 'text-green-400' : 'text-green-600'}`}>schreibt...</span>
|
||||
) : contact ? (
|
||||
<>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${getRoleColor(contact.role, isDark)}`}>
|
||||
{getRoleLabel(contact.role)}
|
||||
</span>
|
||||
{contact.student_name && (
|
||||
<span className={`text-xs ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
• {contact.student_name}
|
||||
</span>
|
||||
)}
|
||||
{contact.online && (
|
||||
<span className={`text-xs ${isDark ? 'text-green-400' : 'text-green-600'}`}>• Online</span>
|
||||
)}
|
||||
</>
|
||||
) : conversation.is_group && (
|
||||
<span className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
{conversation.participant_ids.length} Mitglieder
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<HeaderButton isDark={isDark} active={conversation.pinned}
|
||||
activeColor="amber" onClick={() => pinConversation(conversation.id)}
|
||||
title={conversation.pinned ? 'Nicht mehr anheften' : 'Anheften'}>
|
||||
<svg className="w-5 h-5" fill={conversation.pinned ? 'currentColor' : 'none'} stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
|
||||
</svg>
|
||||
</HeaderButton>
|
||||
<HeaderButton isDark={isDark} active={conversation.muted}
|
||||
activeColor="red" onClick={() => muteConversation(conversation.id)}
|
||||
title={conversation.muted ? 'Ton aktivieren' : 'Stummschalten'}>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{conversation.muted ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2" />
|
||||
) : (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15.536 8.464a5 5 0 010 7.072M18.364 5.636a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
|
||||
)}
|
||||
</svg>
|
||||
</HeaderButton>
|
||||
<HeaderButton isDark={isDark} active={showContactInfo}
|
||||
activeColor="green" onClick={() => setShowContactInfo(!showContactInfo)}>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</HeaderButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HeaderButton({ isDark, active, activeColor, onClick, title, children }: {
|
||||
isDark: boolean; active: boolean; activeColor: string
|
||||
onClick: () => void; title?: string; children: React.ReactNode
|
||||
}) {
|
||||
const activeClasses: Record<string, string> = {
|
||||
amber: isDark ? 'bg-amber-500/20 text-amber-300' : 'bg-amber-100 text-amber-700',
|
||||
red: isDark ? 'bg-red-500/20 text-red-300' : 'bg-red-100 text-red-700',
|
||||
green: isDark ? 'bg-green-500/20 text-green-300' : 'bg-green-100 text-green-700',
|
||||
}
|
||||
return (
|
||||
<button onClick={onClick} title={title}
|
||||
className={`p-2 rounded-xl transition-all ${
|
||||
active
|
||||
? activeClasses[activeColor]
|
||||
: isDark
|
||||
? 'hover:bg-white/10 text-white/60'
|
||||
: 'hover:bg-slate-100 text-slate-400'
|
||||
}`}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function MessageGroup({ isDark, group, currentConversation, getSenderName, handleContextMenu }: {
|
||||
isDark: boolean; group: { date: string; messages: Message[] }
|
||||
currentConversation: Conversation
|
||||
getSenderName: (id: string) => string
|
||||
handleContextMenu: (e: React.MouseEvent, id: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<span className={`px-4 py-1.5 rounded-full text-xs font-medium ${
|
||||
isDark ? 'bg-white/10 text-white/60' : 'bg-slate-200 text-slate-600'
|
||||
}`}>
|
||||
{group.date}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{group.messages.map((msg) => {
|
||||
const isSelf = msg.sender_id === 'self'
|
||||
const isGroupMsg = currentConversation.is_group && !isSelf
|
||||
return (
|
||||
<div key={msg.id} className={`flex ${isSelf ? 'justify-end' : 'justify-start'}`}
|
||||
onContextMenu={(e) => handleContextMenu(e, msg.id)}>
|
||||
<div className={`max-w-[70%] ${isSelf ? 'order-2' : ''}`}>
|
||||
{isGroupMsg && (
|
||||
<span className={`text-xs ml-3 mb-1 block ${isDark ? 'text-purple-400' : 'text-purple-600'}`}>
|
||||
{getSenderName(msg.sender_id)}
|
||||
</span>
|
||||
)}
|
||||
<div className={`rounded-2xl px-4 py-2.5 shadow-sm ${
|
||||
isSelf
|
||||
? 'bg-gradient-to-r from-green-500 to-emerald-500 text-white rounded-br-md'
|
||||
: isDark
|
||||
? 'bg-white/10 text-white rounded-bl-md'
|
||||
: 'bg-white text-slate-900 rounded-bl-md shadow-lg'
|
||||
}`}>
|
||||
<p className="whitespace-pre-wrap break-words">{msg.content}</p>
|
||||
{msg.reactions && msg.reactions.length > 0 && (
|
||||
<div className="flex gap-1 mt-2">
|
||||
{msg.reactions.map((r, i) => (
|
||||
<span key={i} className={`text-sm px-1.5 py-0.5 rounded-full ${isDark ? 'bg-white/20' : 'bg-slate-100'}`}>
|
||||
{r.emoji}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className={`flex items-center gap-2 mt-1 ${
|
||||
isSelf ? 'text-white/70 justify-end' : isDark ? 'text-white/40' : 'text-slate-400'
|
||||
}`}>
|
||||
<span className="text-xs">
|
||||
{new Date(msg.timestamp).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
{isSelf && (
|
||||
<>
|
||||
{msg.delivered && (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
{msg.email_sent && <span className="text-xs">✉️</span>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MessageInput({ isDark, messageInput, sendWithEmail, isSending, showEmojiPicker, showTemplates, templates, inputRef,
|
||||
setMessageInput, setSendWithEmail, setShowEmojiPicker, setShowTemplates,
|
||||
handleSendMessage, handleEmojiSelect, handleKeyDown }: {
|
||||
isDark: boolean; messageInput: string; sendWithEmail: boolean; isSending: boolean
|
||||
showEmojiPicker: boolean; showTemplates: boolean
|
||||
templates: { id: string; name: string; content: string }[]
|
||||
inputRef: React.RefObject<HTMLTextAreaElement | null>
|
||||
setMessageInput: (val: string) => void; setSendWithEmail: (val: boolean) => void
|
||||
setShowEmojiPicker: (val: boolean) => void; setShowTemplates: (val: boolean) => void
|
||||
handleSendMessage: () => void; handleEmojiSelect: (emoji: string) => void
|
||||
handleKeyDown: (e: React.KeyboardEvent) => void
|
||||
}) {
|
||||
return (
|
||||
<div className={`p-4 border-t ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<button onClick={() => setSendWithEmail(!sendWithEmail)}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-xl text-sm transition-all ${
|
||||
sendWithEmail
|
||||
? isDark ? 'bg-blue-500/20 text-blue-300' : 'bg-blue-100 text-blue-700'
|
||||
: isDark ? 'bg-white/5 text-white/40 hover:bg-white/10' : 'bg-slate-100 text-slate-400 hover:bg-slate-200'
|
||||
}`}>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
E-Mail
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="relative">
|
||||
<button onClick={() => { setShowEmojiPicker(!showEmojiPicker); setShowTemplates(false) }}
|
||||
className={`p-3 rounded-2xl transition-all ${
|
||||
showEmojiPicker
|
||||
? isDark ? 'bg-green-500/20 text-green-300' : 'bg-green-100 text-green-700'
|
||||
: isDark ? 'bg-white/5 text-white/60 hover:bg-white/10' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
|
||||
}`}>
|
||||
<span className="text-xl">😊</span>
|
||||
</button>
|
||||
{showEmojiPicker && (
|
||||
<EmojiPicker onSelect={handleEmojiSelect} onClose={() => setShowEmojiPicker(false)} isDark={isDark} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<button onClick={() => { setShowTemplates(!showTemplates); setShowEmojiPicker(false) }}
|
||||
className={`p-3 rounded-2xl transition-all ${
|
||||
showTemplates
|
||||
? isDark ? 'bg-purple-500/20 text-purple-300' : 'bg-purple-100 text-purple-700'
|
||||
: isDark ? 'bg-white/5 text-white/60 hover:bg-white/10' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
|
||||
}`}>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
{showTemplates && (
|
||||
<TemplatesDropdown templates={templates}
|
||||
onSelect={(content) => { setMessageInput(content); setShowTemplates(false); inputRef.current?.focus() }}
|
||||
isDark={isDark} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<textarea ref={inputRef} value={messageInput}
|
||||
onChange={(e) => setMessageInput(e.target.value)} onKeyDown={handleKeyDown}
|
||||
placeholder="Nachricht schreiben..." rows={1}
|
||||
className={`flex-1 px-4 py-3 rounded-2xl border resize-none transition-all ${
|
||||
isDark
|
||||
? 'bg-white/5 border-white/10 text-white placeholder:text-white/40 focus:border-green-500/50'
|
||||
: 'bg-white border-slate-200 text-slate-900 placeholder:text-slate-400 focus:border-green-500'
|
||||
} focus:outline-none focus:ring-2 focus:ring-green-500/20`}
|
||||
style={{ maxHeight: '120px' }} />
|
||||
|
||||
<button onClick={handleSendMessage} disabled={!messageInput.trim() || isSending}
|
||||
className={`p-3 rounded-2xl transition-all ${
|
||||
messageInput.trim() && !isSending
|
||||
? 'bg-gradient-to-r from-green-500 to-emerald-500 text-white shadow-lg hover:shadow-green-500/30'
|
||||
: isDark
|
||||
? 'bg-white/5 text-white/30 cursor-not-allowed'
|
||||
: 'bg-slate-100 text-slate-300 cursor-not-allowed'
|
||||
}`}>
|
||||
{isSending ? (
|
||||
<svg className="w-6 h-6 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({ isDark, setShowNewConversation }: { isDark: boolean; setShowNewConversation: (val: boolean) => void }) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className={`text-center max-w-md px-8 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
<div className="w-24 h-24 mx-auto mb-6 rounded-full flex items-center justify-center text-5xl bg-gradient-to-br from-green-500/20 to-emerald-500/20">
|
||||
💬
|
||||
</div>
|
||||
<h3 className={`text-2xl font-bold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
BreakPilot Messenger
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed">
|
||||
Kommunizieren Sie sicher mit Eltern und Kollegen.
|
||||
Waehlen Sie eine Konversation aus der Liste oder starten Sie eine neue Unterhaltung.
|
||||
</p>
|
||||
<button onClick={() => setShowNewConversation(true)}
|
||||
className="mt-6 px-6 py-3 bg-gradient-to-r from-green-500 to-emerald-500 text-white rounded-2xl font-medium shadow-lg hover:shadow-green-500/30 transition-all">
|
||||
Neue Nachricht
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
113
studio-v2/app/messages/_components/ContactInfoPanel.tsx
Normal file
113
studio-v2/app/messages/_components/ContactInfoPanel.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
getContactInitials,
|
||||
getRoleLabel,
|
||||
getRoleColor,
|
||||
type Conversation,
|
||||
type Contact,
|
||||
} from '@/lib/MessagesContext'
|
||||
|
||||
interface ContactInfoPanelProps {
|
||||
contact: Contact | undefined
|
||||
conversation: Conversation
|
||||
onClose: () => void
|
||||
isDark: boolean
|
||||
}
|
||||
|
||||
export function ContactInfoPanel({ contact, conversation, onClose, isDark }: ContactInfoPanelProps) {
|
||||
return (
|
||||
<div className={`w-80 backdrop-blur-2xl border-l flex flex-col ${
|
||||
isDark
|
||||
? 'bg-white/5 border-white/10'
|
||||
: 'bg-white/90 border-slate-200'
|
||||
}`}>
|
||||
{/* Header */}
|
||||
<div className={`p-4 border-b ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>Info</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={`p-2 rounded-xl transition-colors ${
|
||||
isDark ? 'hover:bg-white/10 text-white/60' : 'hover:bg-slate-100 text-slate-400'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{/* Avatar & Name */}
|
||||
<div className="text-center mb-6">
|
||||
<div className={`w-20 h-20 mx-auto rounded-full flex items-center justify-center text-2xl font-bold mb-3 ${
|
||||
isDark
|
||||
? 'bg-gradient-to-br from-green-500/30 to-emerald-500/30 text-green-300'
|
||||
: 'bg-gradient-to-br from-green-100 to-emerald-100 text-green-700'
|
||||
}`}>
|
||||
{conversation.title ? getContactInitials(conversation.title) : '?'}
|
||||
</div>
|
||||
<h3 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{conversation.title}
|
||||
</h3>
|
||||
{contact && (
|
||||
<span className={`text-sm px-2 py-1 rounded-full ${getRoleColor(contact.role, isDark)}`}>
|
||||
{getRoleLabel(contact.role)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contact Details */}
|
||||
{contact && (
|
||||
<div className="space-y-4">
|
||||
{contact.email && (
|
||||
<InfoField isDark={isDark} label="E-Mail" value={contact.email} />
|
||||
)}
|
||||
{contact.phone && (
|
||||
<InfoField isDark={isDark} label="Telefon" value={contact.phone} />
|
||||
)}
|
||||
{contact.student_name && (
|
||||
<InfoField isDark={isDark} label="Schueler/in"
|
||||
value={`${contact.student_name} (${contact.class_name})`} />
|
||||
)}
|
||||
{contact.tags.length > 0 && (
|
||||
<div className={`p-3 rounded-xl ${isDark ? 'bg-white/5' : 'bg-slate-50'}`}>
|
||||
<span className={`text-xs block mb-2 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>Tags</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{contact.tags.map(tag => (
|
||||
<span key={tag} className={`text-xs px-2 py-1 rounded-full ${
|
||||
isDark ? 'bg-white/10 text-white/80' : 'bg-slate-200 text-slate-700'
|
||||
}`}>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Group Members */}
|
||||
{conversation.is_group && (
|
||||
<div className={`p-3 rounded-xl ${isDark ? 'bg-white/5' : 'bg-slate-50'}`}>
|
||||
<span className={`text-xs block mb-2 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
{conversation.participant_ids.length} Mitglieder
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoField({ isDark, label, value }: { isDark: boolean; label: string; value: string }) {
|
||||
return (
|
||||
<div className={`p-3 rounded-xl ${isDark ? 'bg-white/5' : 'bg-slate-50'}`}>
|
||||
<span className={`text-xs block mb-1 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>{label}</span>
|
||||
<span className={`text-sm ${isDark ? 'text-white' : 'text-slate-900'}`}>{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
47
studio-v2/app/messages/_components/ContextMenu.tsx
Normal file
47
studio-v2/app/messages/_components/ContextMenu.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
'use client'
|
||||
|
||||
interface ContextMenuProps {
|
||||
isDark: boolean
|
||||
contextMenu: { x: number; y: number; messageId: string }
|
||||
currentConversationId: string | null
|
||||
deleteMessage: (convId: string, msgId: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ContextMenu({ isDark, contextMenu, currentConversationId, deleteMessage, onClose }: ContextMenuProps) {
|
||||
return (
|
||||
<div
|
||||
className={`fixed rounded-xl border shadow-xl overflow-hidden z-50 ${
|
||||
isDark
|
||||
? 'bg-slate-900 border-white/20'
|
||||
: 'bg-white border-slate-200'
|
||||
}`}
|
||||
style={{ top: contextMenu.y, left: contextMenu.x }}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Show quick reactions
|
||||
onClose()
|
||||
}}
|
||||
className={`w-full px-4 py-2 text-left text-sm transition-colors ${
|
||||
isDark ? 'hover:bg-white/10 text-white' : 'hover:bg-slate-100 text-slate-700'
|
||||
}`}
|
||||
>
|
||||
Reagieren
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (currentConversationId) {
|
||||
deleteMessage(currentConversationId, contextMenu.messageId)
|
||||
}
|
||||
onClose()
|
||||
}}
|
||||
className={`w-full px-4 py-2 text-left text-sm transition-colors ${
|
||||
isDark ? 'hover:bg-red-500/20 text-red-400' : 'hover:bg-red-50 text-red-600'
|
||||
}`}
|
||||
>
|
||||
Loeschen
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
189
studio-v2/app/messages/_components/ConversationList.tsx
Normal file
189
studio-v2/app/messages/_components/ConversationList.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
formatMessageTime,
|
||||
getContactInitials,
|
||||
type Conversation,
|
||||
type Contact,
|
||||
} from '@/lib/MessagesContext'
|
||||
|
||||
interface ConversationListProps {
|
||||
isDark: boolean
|
||||
filteredConversations: Conversation[]
|
||||
currentConversationId: string | null
|
||||
unreadCount: number
|
||||
searchQuery: string
|
||||
setSearchQuery: (q: string) => void
|
||||
setShowNewConversation: (show: boolean) => void
|
||||
selectConversation: (conv: Conversation) => void
|
||||
getConversationContact: (conv: Conversation) => Contact | undefined
|
||||
}
|
||||
|
||||
export function ConversationList({
|
||||
isDark, filteredConversations, currentConversationId, unreadCount,
|
||||
searchQuery, setSearchQuery, setShowNewConversation,
|
||||
selectConversation, getConversationContact,
|
||||
}: ConversationListProps) {
|
||||
return (
|
||||
<div className={`w-96 backdrop-blur-xl border rounded-3xl flex flex-col overflow-hidden ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20'
|
||||
: 'bg-white/70 border-black/10 shadow-xl'
|
||||
}`}>
|
||||
{/* Header */}
|
||||
<div className={`p-4 border-b ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className={`text-xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Nachrichten
|
||||
</h2>
|
||||
{unreadCount > 0 && (
|
||||
<span className={`text-sm ${isDark ? 'text-green-400' : 'text-green-600'}`}>
|
||||
{unreadCount} ungelesen
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowNewConversation(true)}
|
||||
className="p-3 rounded-2xl transition-all shadow-lg bg-gradient-to-r from-green-500 to-emerald-500 text-white hover:shadow-green-500/30"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Suchen..."
|
||||
className={`w-full px-4 py-3 pl-10 rounded-2xl border transition-all ${
|
||||
isDark
|
||||
? 'bg-white/5 border-white/10 text-white placeholder:text-white/40 focus:border-green-500/50'
|
||||
: 'bg-white border-slate-200 text-slate-900 placeholder:text-slate-400 focus:border-green-500'
|
||||
} focus:outline-none focus:ring-2 focus:ring-green-500/20`}
|
||||
/>
|
||||
<svg className={`absolute left-3 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>
|
||||
</div>
|
||||
|
||||
{/* Conversation List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{filteredConversations.length === 0 ? (
|
||||
<div className={`p-8 text-center ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full flex items-center justify-center text-3xl bg-gradient-to-br from-green-500/20 to-emerald-500/20">
|
||||
💬
|
||||
</div>
|
||||
<p className="font-medium">Keine Konversationen</p>
|
||||
<p className="text-sm mt-1">Starten Sie eine neue Unterhaltung!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-white/5">
|
||||
{filteredConversations.map((conv) => (
|
||||
<ConversationItem key={conv.id} conv={conv} isDark={isDark}
|
||||
isActive={currentConversationId === conv.id}
|
||||
contact={getConversationContact(conv)}
|
||||
onClick={() => selectConversation(conv)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConversationItem({ conv, isDark, isActive, contact, onClick }: {
|
||||
conv: Conversation; isDark: boolean; isActive: boolean
|
||||
contact: Contact | undefined; onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full p-4 text-left transition-all ${
|
||||
isActive
|
||||
? isDark
|
||||
? 'bg-gradient-to-r from-green-500/20 to-emerald-500/20'
|
||||
: 'bg-gradient-to-r from-green-100 to-emerald-100'
|
||||
: isDark
|
||||
? 'hover:bg-white/5'
|
||||
: 'hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Avatar */}
|
||||
<div className="relative">
|
||||
<div className={`w-12 h-12 rounded-full flex items-center justify-center text-lg font-semibold ${
|
||||
conv.is_group
|
||||
? isDark
|
||||
? 'bg-gradient-to-br from-purple-500/30 to-pink-500/30 text-purple-300'
|
||||
: 'bg-gradient-to-br from-purple-100 to-pink-100 text-purple-700'
|
||||
: contact?.online
|
||||
? isDark
|
||||
? 'bg-gradient-to-br from-green-500/30 to-emerald-500/30 text-green-300'
|
||||
: 'bg-gradient-to-br from-green-100 to-emerald-100 text-green-700'
|
||||
: isDark
|
||||
? 'bg-slate-700 text-slate-300'
|
||||
: 'bg-slate-200 text-slate-600'
|
||||
}`}>
|
||||
{conv.title ? getContactInitials(conv.title) : '?'}
|
||||
</div>
|
||||
{!conv.is_group && contact?.online && (
|
||||
<span className="absolute bottom-0 right-0 w-3 h-3 bg-green-500 rounded-full border-2 border-white dark:border-slate-900" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{conv.pinned && <span className="text-xs">📌</span>}
|
||||
<span className={`font-semibold truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{conv.title || 'Unbenannt'}
|
||||
</span>
|
||||
{conv.muted && <span className={isDark ? 'text-white/40' : 'text-slate-400'}>🔕</span>}
|
||||
</div>
|
||||
{conv.last_message_time && (
|
||||
<span className={`text-xs flex-shrink-0 ${
|
||||
conv.unread_count > 0
|
||||
? isDark ? 'text-green-400' : 'text-green-600'
|
||||
: isDark ? 'text-white/40' : 'text-slate-400'
|
||||
}`}>
|
||||
{formatMessageTime(conv.last_message_time)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1 flex-1 min-w-0">
|
||||
{conv.typing ? (
|
||||
<span className={`text-sm italic ${isDark ? 'text-green-400' : 'text-green-600'}`}>
|
||||
schreibt...
|
||||
</span>
|
||||
) : (
|
||||
<p className={`text-sm truncate ${
|
||||
conv.unread_count > 0
|
||||
? isDark ? 'text-white font-medium' : 'text-slate-900 font-medium'
|
||||
: isDark ? 'text-white/60' : 'text-slate-500'
|
||||
}`}>
|
||||
{conv.last_message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{conv.unread_count > 0 && (
|
||||
<span className="ml-2 min-w-[20px] h-5 px-1.5 rounded-full bg-green-500 text-white text-xs flex items-center justify-center font-medium">
|
||||
{conv.unread_count > 9 ? '9+' : conv.unread_count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
81
studio-v2/app/messages/_components/EmojiPicker.tsx
Normal file
81
studio-v2/app/messages/_components/EmojiPicker.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { emojiCategories } from '@/lib/MessagesContext'
|
||||
|
||||
interface EmojiPickerProps {
|
||||
onSelect: (emoji: string) => void
|
||||
onClose: () => void
|
||||
isDark: boolean
|
||||
}
|
||||
|
||||
export function EmojiPicker({ onSelect, onClose, isDark }: EmojiPickerProps) {
|
||||
const [activeCategory, setActiveCategory] = useState('Häufig')
|
||||
|
||||
return (
|
||||
<div className={`absolute bottom-full left-0 mb-2 w-80 rounded-2xl border shadow-2xl overflow-hidden z-50 ${
|
||||
isDark
|
||||
? 'bg-slate-900 border-white/20'
|
||||
: 'bg-white border-slate-200'
|
||||
}`}>
|
||||
{/* Header */}
|
||||
<div className={`flex items-center justify-between p-3 border-b ${
|
||||
isDark ? 'border-white/10' : 'border-slate-100'
|
||||
}`}>
|
||||
<span className={`text-sm font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Emoji
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={`p-1 rounded-lg transition-colors ${
|
||||
isDark ? 'hover:bg-white/10 text-white/60' : 'hover:bg-slate-100 text-slate-400'
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* Category Tabs */}
|
||||
<div className={`flex overflow-x-auto border-b scrollbar-hide ${
|
||||
isDark ? 'border-white/10' : 'border-slate-100'
|
||||
}`}>
|
||||
{Object.keys(emojiCategories).map(cat => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setActiveCategory(cat)}
|
||||
className={`px-3 py-2 text-xs font-medium whitespace-nowrap transition-colors ${
|
||||
activeCategory === cat
|
||||
? isDark
|
||||
? 'text-green-400 border-b-2 border-green-400'
|
||||
: 'text-green-600 border-b-2 border-green-600'
|
||||
: isDark
|
||||
? 'text-white/60 hover:text-white'
|
||||
: 'text-slate-500 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Emoji Grid */}
|
||||
<div className="p-3 max-h-48 overflow-y-auto">
|
||||
<div className="grid grid-cols-8 gap-1">
|
||||
{emojiCategories[activeCategory as keyof typeof emojiCategories].map((emoji, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => onSelect(emoji)}
|
||||
className={`w-8 h-8 flex items-center justify-center text-xl rounded-lg transition-colors ${
|
||||
isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
106
studio-v2/app/messages/_components/NewConversationModal.tsx
Normal file
106
studio-v2/app/messages/_components/NewConversationModal.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
getContactInitials,
|
||||
getRoleLabel,
|
||||
getRoleColor,
|
||||
type Contact,
|
||||
} from '@/lib/MessagesContext'
|
||||
|
||||
interface NewConversationModalProps {
|
||||
isDark: boolean
|
||||
contacts: Contact[]
|
||||
onStartConversation: (contact: Contact) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function NewConversationModal({ isDark, contacts, onStartConversation, onClose }: NewConversationModalProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className={`w-full max-w-md backdrop-blur-2xl border rounded-3xl overflow-hidden shadow-2xl ${
|
||||
isDark
|
||||
? 'bg-slate-900/90 border-white/10'
|
||||
: 'bg-white border-slate-200'
|
||||
}`}>
|
||||
{/* Header */}
|
||||
<div className={`p-6 border-b ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className={`text-xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Neue Nachricht
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={`p-2 rounded-xl transition-all ${
|
||||
isDark ? 'hover:bg-white/10 text-white/60' : 'hover:bg-slate-100 text-slate-400'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact List */}
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{contacts.length === 0 ? (
|
||||
<div className={`p-8 text-center ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
<p>Keine Kontakte vorhanden</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{contacts.map((contact) => (
|
||||
<button
|
||||
key={contact.id}
|
||||
onClick={() => onStartConversation(contact)}
|
||||
className={`w-full p-4 text-left transition-all flex items-center gap-3 ${
|
||||
isDark
|
||||
? 'hover:bg-white/5 border-b border-white/5'
|
||||
: 'hover:bg-slate-50 border-b border-slate-100'
|
||||
}`}
|
||||
>
|
||||
<div className="relative">
|
||||
<div className={`w-12 h-12 rounded-full flex items-center justify-center text-lg font-semibold ${
|
||||
contact.online
|
||||
? isDark
|
||||
? 'bg-gradient-to-br from-green-500/30 to-emerald-500/30 text-green-300'
|
||||
: 'bg-gradient-to-br from-green-100 to-emerald-100 text-green-700'
|
||||
: isDark
|
||||
? 'bg-slate-700 text-slate-300'
|
||||
: 'bg-slate-200 text-slate-600'
|
||||
}`}>
|
||||
{getContactInitials(contact.name)}
|
||||
</div>
|
||||
{contact.online && (
|
||||
<span className="absolute bottom-0 right-0 w-3 h-3 bg-green-500 rounded-full border-2 border-white dark:border-slate-900" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{contact.name}
|
||||
</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${getRoleColor(contact.role, isDark)}`}>
|
||||
{getRoleLabel(contact.role)}
|
||||
</span>
|
||||
</div>
|
||||
{contact.student_name && (
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
{contact.student_name} ({contact.class_name})
|
||||
</p>
|
||||
)}
|
||||
{contact.email && (
|
||||
<p className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
{contact.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
43
studio-v2/app/messages/_components/TemplatesDropdown.tsx
Normal file
43
studio-v2/app/messages/_components/TemplatesDropdown.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
interface TemplatesDropdownProps {
|
||||
templates: { id: string; name: string; content: string }[]
|
||||
onSelect: (content: string) => void
|
||||
isDark: boolean
|
||||
}
|
||||
|
||||
export function TemplatesDropdown({ templates, onSelect, isDark }: TemplatesDropdownProps) {
|
||||
return (
|
||||
<div className={`absolute bottom-full left-0 mb-2 w-64 rounded-2xl border shadow-2xl overflow-hidden z-50 ${
|
||||
isDark
|
||||
? 'bg-slate-900 border-white/20'
|
||||
: 'bg-white border-slate-200'
|
||||
}`}>
|
||||
<div className={`p-3 border-b ${isDark ? 'border-white/10' : 'border-slate-100'}`}>
|
||||
<span className={`text-sm font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Vorlagen
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{templates.map(tpl => (
|
||||
<button
|
||||
key={tpl.id}
|
||||
onClick={() => onSelect(tpl.content)}
|
||||
className={`w-full text-left p-3 transition-colors ${
|
||||
isDark
|
||||
? 'hover:bg-white/5 border-b border-white/5'
|
||||
: 'hover:bg-slate-50 border-b border-slate-100'
|
||||
}`}
|
||||
>
|
||||
<span className={`text-sm font-medium block ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{tpl.name}
|
||||
</span>
|
||||
<span className={`text-xs line-clamp-2 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
{tpl.content}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
166
studio-v2/app/messages/_components/useMessagesPage.ts
Normal file
166
studio-v2/app/messages/_components/useMessagesPage.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import {
|
||||
useMessages,
|
||||
formatMessageDate,
|
||||
type Conversation,
|
||||
type Message,
|
||||
type Contact,
|
||||
} from '@/lib/MessagesContext'
|
||||
|
||||
export function useMessagesPage() {
|
||||
const messagesCtx = useMessages()
|
||||
const {
|
||||
contacts,
|
||||
conversations,
|
||||
messages,
|
||||
templates,
|
||||
unreadCount,
|
||||
recentConversations,
|
||||
sendMessage,
|
||||
markAsRead,
|
||||
createConversation,
|
||||
addReaction,
|
||||
deleteMessage,
|
||||
pinConversation,
|
||||
muteConversation,
|
||||
currentConversationId,
|
||||
setCurrentConversationId,
|
||||
} = messagesCtx
|
||||
|
||||
const [messageInput, setMessageInput] = useState('')
|
||||
const [sendWithEmail, setSendWithEmail] = useState(false)
|
||||
const [isSending, setIsSending] = useState(false)
|
||||
const [showNewConversation, setShowNewConversation] = useState(false)
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
|
||||
const [showTemplates, setShowTemplates] = useState(false)
|
||||
const [showContactInfo, setShowContactInfo] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; messageId: string } | null>(null)
|
||||
|
||||
// Current conversation data
|
||||
const currentConversation = conversations.find(c => c.id === currentConversationId)
|
||||
const currentMessages = currentConversationId ? (messages[currentConversationId] || []) : []
|
||||
|
||||
// Find contact for conversation
|
||||
const getConversationContact = (conv: Conversation): Contact | undefined => {
|
||||
if (conv.is_group) return undefined
|
||||
return contacts.find(c => conv.participant_ids.includes(c.id))
|
||||
}
|
||||
|
||||
// Get sender name for group messages
|
||||
const getSenderName = (senderId: string): string => {
|
||||
if (senderId === 'self') return 'Du'
|
||||
const contact = contacts.find(c => c.id === senderId)
|
||||
return contact?.name?.split(' ')[0] || 'Unbekannt'
|
||||
}
|
||||
|
||||
// Filter conversations by search
|
||||
const filteredConversations = useMemo(() => {
|
||||
if (!searchQuery) return recentConversations
|
||||
const q = searchQuery.toLowerCase()
|
||||
return recentConversations.filter(c =>
|
||||
c.title?.toLowerCase().includes(q) ||
|
||||
c.last_message?.toLowerCase().includes(q)
|
||||
)
|
||||
}, [recentConversations, searchQuery])
|
||||
|
||||
// Group messages by date
|
||||
const groupedMessages = useMemo(() => {
|
||||
const groups: { date: string; messages: Message[] }[] = []
|
||||
let currentDate = ''
|
||||
|
||||
for (const msg of currentMessages) {
|
||||
const msgDate = formatMessageDate(msg.timestamp)
|
||||
if (msgDate !== currentDate) {
|
||||
currentDate = msgDate
|
||||
groups.push({ date: msgDate, messages: [] })
|
||||
}
|
||||
groups[groups.length - 1].messages.push(msg)
|
||||
}
|
||||
|
||||
return groups
|
||||
}, [currentMessages])
|
||||
|
||||
// Select conversation
|
||||
const selectConversation = async (conv: Conversation) => {
|
||||
setCurrentConversationId(conv.id)
|
||||
if (conv.unread_count > 0) {
|
||||
await markAsRead(conv.id)
|
||||
}
|
||||
setShowContactInfo(false)
|
||||
}
|
||||
|
||||
// Send message
|
||||
const handleSendMessage = async () => {
|
||||
if (!messageInput.trim() || !currentConversationId) return
|
||||
|
||||
setIsSending(true)
|
||||
await sendMessage(currentConversationId, messageInput.trim(), sendWithEmail)
|
||||
setMessageInput('')
|
||||
setIsSending(false)
|
||||
setShowEmojiPicker(false)
|
||||
setShowTemplates(false)
|
||||
}
|
||||
|
||||
// Insert emoji
|
||||
const handleEmojiSelect = (emoji: string) => {
|
||||
setMessageInput(prev => prev + emoji)
|
||||
}
|
||||
|
||||
// Start new conversation
|
||||
const handleStartConversation = async (contact: Contact) => {
|
||||
const conv = await createConversation(contact.id)
|
||||
if (conv) {
|
||||
setCurrentConversationId(conv.id)
|
||||
setShowNewConversation(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle context menu
|
||||
const handleContextMenu = (e: React.MouseEvent, messageId: string) => {
|
||||
e.preventDefault()
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, messageId })
|
||||
}
|
||||
|
||||
// Close context menu on click outside
|
||||
useEffect(() => {
|
||||
const handleClick = () => setContextMenu(null)
|
||||
if (contextMenu) {
|
||||
document.addEventListener('click', handleClick)
|
||||
return () => document.removeEventListener('click', handleClick)
|
||||
}
|
||||
}, [contextMenu])
|
||||
|
||||
const currentContact = currentConversation ? getConversationContact(currentConversation) : undefined
|
||||
|
||||
return {
|
||||
// Context data
|
||||
contacts, templates, unreadCount,
|
||||
currentConversationId, currentConversation, currentContact,
|
||||
filteredConversations, groupedMessages,
|
||||
// UI state
|
||||
messageInput, setMessageInput,
|
||||
sendWithEmail, setSendWithEmail,
|
||||
isSending,
|
||||
showNewConversation, setShowNewConversation,
|
||||
showEmojiPicker, setShowEmojiPicker,
|
||||
showTemplates, setShowTemplates,
|
||||
showContactInfo, setShowContactInfo,
|
||||
searchQuery, setSearchQuery,
|
||||
contextMenu, setContextMenu,
|
||||
// Actions
|
||||
selectConversation,
|
||||
handleSendMessage,
|
||||
handleEmojiSelect,
|
||||
handleStartConversation,
|
||||
handleContextMenu,
|
||||
getSenderName,
|
||||
getConversationContact,
|
||||
// From context
|
||||
pinConversation,
|
||||
muteConversation,
|
||||
deleteMessage,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user