[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:
Benjamin Admin
2026-04-24 17:52:36 +02:00
parent b681ddb131
commit 0b37c5e692
143 changed files with 15822 additions and 15889 deletions

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

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

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

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

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

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

View 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'
}

View 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,
}
}