fix: Restore all files lost during destructive rebase

A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.

This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).

Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-02-09 09:51:32 +01:00
parent f7487ee240
commit 21a844cb8a
1986 changed files with 744143 additions and 1731 deletions

View File

@@ -0,0 +1,105 @@
"""
Meetings Module
Modular structure for the Meetings frontend.
Jitsi Meet Integration for video conferences, trainings, and parent-teacher meetings.
Modular Refactoring (2026-02-03):
- Split into sub-modules for maintainability
- Original file: meetings.py (2,639 lines)
- Now split into: styles.py, templates.py, pages/
"""
from fastapi import APIRouter
from fastapi.responses import HTMLResponse
from .pages import (
meetings_dashboard,
meeting_room,
active_meetings,
schedule_meetings,
trainings_page,
recordings_page,
play_recording,
view_transcript,
breakout_rooms_page,
quick_meeting,
parent_teacher_meeting,
)
router = APIRouter()
# ============================================
# API Routes
# ============================================
@router.get("/meetings", response_class=HTMLResponse)
def get_meetings_dashboard():
"""Main meetings dashboard"""
return meetings_dashboard()
@router.get("/meetings/room/{room_name}", response_class=HTMLResponse)
def get_meeting_room(room_name: str):
"""Meeting room with embedded Jitsi"""
return meeting_room(room_name)
@router.get("/meetings/active", response_class=HTMLResponse)
def get_active_meetings():
"""Active meetings list"""
return active_meetings()
@router.get("/meetings/schedule", response_class=HTMLResponse)
def get_schedule_meetings():
"""Schedule and manage upcoming meetings"""
return schedule_meetings()
@router.get("/meetings/trainings", response_class=HTMLResponse)
def get_trainings_page():
"""Training sessions management"""
return trainings_page()
@router.get("/meetings/recordings", response_class=HTMLResponse)
def get_recordings_page():
"""Recordings and transcripts management"""
return recordings_page()
@router.get("/meetings/breakout", response_class=HTMLResponse)
def get_breakout_rooms_page():
"""Breakout rooms management"""
return breakout_rooms_page()
@router.get("/meetings/quick", response_class=HTMLResponse)
def get_quick_meeting():
"""Start a quick meeting immediately"""
return quick_meeting()
@router.get("/meetings/parent-teacher", response_class=HTMLResponse)
def get_parent_teacher_meeting():
"""Create a parent-teacher meeting"""
return parent_teacher_meeting()
@router.get("/meetings/recordings/{recording_id}/play", response_class=HTMLResponse)
def get_play_recording(recording_id: str):
"""Play a recording"""
return play_recording(recording_id)
@router.get("/meetings/recordings/{recording_id}/transcript", response_class=HTMLResponse)
def get_view_transcript(recording_id: str):
"""View recording transcript"""
return view_transcript(recording_id)
__all__ = [
"router",
]

View File

@@ -0,0 +1,27 @@
"""
Meetings Module - Pages
Route handlers for the Meetings frontend
"""
from .dashboard import meetings_dashboard
from .meeting_room import meeting_room
from .active import active_meetings
from .schedule import schedule_meetings
from .trainings import trainings_page
from .recordings import recordings_page, play_recording, view_transcript
from .breakout import breakout_rooms_page
from .quick_actions import quick_meeting, parent_teacher_meeting
__all__ = [
"meetings_dashboard",
"meeting_room",
"active_meetings",
"schedule_meetings",
"trainings_page",
"recordings_page",
"play_recording",
"view_transcript",
"breakout_rooms_page",
"quick_meeting",
"parent_teacher_meeting",
]

View File

@@ -0,0 +1,76 @@
"""
Meetings Module - Active Meetings Page
List of currently active meetings
"""
from ..templates import ICONS, render_base_page
def active_meetings() -> str:
"""Active meetings list"""
content = f'''
<div class="page-header">
<div>
<h1 class="page-title">Aktive Meetings</h1>
<p class="page-subtitle">Laufende Videokonferenzen und Schulungen</p>
</div>
<button class="btn btn-primary" onclick="window.location.href='/meetings/quick'">
{ICONS['plus']} Neues Meeting starten
</button>
</div>
<!-- Active Meetings List -->
<div class="meeting-list" id="activeMeetingsList">
<div style="text-align: center; padding: 3rem; color: var(--bp-text-muted);">
<p>Keine aktiven Meetings</p>
<p style="font-size: 0.875rem; margin-top: 0.5rem;">
Starten Sie ein neues Meeting oder warten Sie, bis ein geplantes Meeting beginnt.
</p>
</div>
</div>
<script>
async function loadActiveMeetings() {{
try {{
const response = await fetch('/api/meetings/active');
if (response.ok) {{
const meetings = await response.json();
renderMeetings(meetings);
}}
}} catch (error) {{
console.error('Error loading active meetings:', error);
}}
}}
function renderMeetings(meetings) {{
const container = document.getElementById('activeMeetingsList');
if (!meetings || meetings.length === 0) {{
return;
}}
container.innerHTML = meetings.map(meeting => `
<div class="meeting-item">
<div class="meeting-time">
<div class="meeting-time-value">${{meeting.participants || 0}}</div>
<div class="meeting-time-date">Teilnehmer</div>
</div>
<div class="meeting-info">
<div class="meeting-title">${{meeting.title}}</div>
<div class="meeting-meta">
<span>{ICONS['clock']} Seit ${{meeting.started_at}}</span>
</div>
</div>
<span class="meeting-badge badge-live">LIVE</span>
<div class="meeting-actions">
<button class="btn btn-primary" onclick="window.location.href='/meetings/room/${{meeting.room_name}}'">Beitreten</button>
</div>
</div>
`).join('');
}}
loadActiveMeetings();
</script>
'''
return render_base_page("Aktive Meetings", content, "active")

View File

@@ -0,0 +1,136 @@
"""
Meetings Module - Breakout Rooms Page
Breakout rooms management
"""
from ..templates import ICONS, render_base_page
def breakout_rooms_page() -> str:
"""Breakout rooms management"""
content = f'''
<div class="page-header">
<div>
<h1 class="page-title">Breakout-Rooms</h1>
<p class="page-subtitle">Gruppenräume für Workshops und Übungen verwalten</p>
</div>
</div>
<!-- Active Meeting Warning -->
<div class="card" style="background: var(--bp-primary-soft); border-color: var(--bp-primary); margin-bottom: 2rem;">
<div style="display: flex; align-items: center; gap: 1rem;">
<div class="card-icon primary">{ICONS['video']}</div>
<div style="flex: 1;">
<div style="font-weight: 600;">Kein aktives Meeting</div>
<div style="font-size: 0.875rem; color: var(--bp-text-muted);">
Breakout-Rooms können nur während eines aktiven Meetings erstellt werden.
</div>
</div>
<button class="btn btn-primary" onclick="window.location.href='/meetings/quick'">
Meeting starten
</button>
</div>
</div>
<!-- How it works -->
<div class="card">
<div class="card-header">
<span class="card-title">So funktionieren Breakout-Rooms</span>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1.5rem; margin-top: 1rem;">
<div style="text-align: center;">
<div class="card-icon primary" style="margin: 0 auto 1rem;">{ICONS['grid']}</div>
<h4 style="margin-bottom: 0.5rem;">1. Räume erstellen</h4>
<p style="font-size: 0.875rem; color: var(--bp-text-muted);">
Erstellen Sie mehrere Breakout-Rooms für Gruppenarbeit.
</p>
</div>
<div style="text-align: center;">
<div class="card-icon info" style="margin: 0 auto 1rem;">{ICONS['users']}</div>
<h4 style="margin-bottom: 0.5rem;">2. Teilnehmer zuweisen</h4>
<p style="font-size: 0.875rem; color: var(--bp-text-muted);">
Weisen Sie Teilnehmer manuell oder automatisch zu.
</p>
</div>
<div style="text-align: center;">
<div class="card-icon accent" style="margin: 0 auto 1rem;">{ICONS['play']}</div>
<h4 style="margin-bottom: 0.5rem;">3. Sessions starten</h4>
<p style="font-size: 0.875rem; color: var(--bp-text-muted);">
Starten Sie alle Räume gleichzeitig oder einzeln.
</p>
</div>
<div style="text-align: center;">
<div class="card-icon warning" style="margin: 0 auto 1rem;">{ICONS['clock']}</div>
<h4 style="margin-bottom: 0.5rem;">4. Timer setzen</h4>
<p style="font-size: 0.875rem; color: var(--bp-text-muted);">
Setzen Sie einen Timer für automatisches Beenden.
</p>
</div>
</div>
</div>
<!-- Breakout Configuration Preview -->
<div class="card" style="margin-top: 2rem;">
<div class="card-header">
<span class="card-title">Breakout-Konfiguration (Vorschau)</span>
<button class="btn btn-secondary" disabled>
{ICONS['plus']} Raum hinzufügen
</button>
</div>
<div class="breakout-grid" style="margin-top: 1rem;">
<div class="breakout-room" style="opacity: 0.5;">
<div class="breakout-room-header">
<span class="breakout-room-title">Raum 1</span>
<span class="breakout-room-count">0 Teilnehmer</span>
</div>
<div class="breakout-participants">
<span style="color: var(--bp-text-muted); font-size: 0.875rem;">Keine Teilnehmer</span>
</div>
</div>
<div class="breakout-room" style="opacity: 0.5;">
<div class="breakout-room-header">
<span class="breakout-room-title">Raum 2</span>
<span class="breakout-room-count">0 Teilnehmer</span>
</div>
<div class="breakout-participants">
<span style="color: var(--bp-text-muted); font-size: 0.875rem;">Keine Teilnehmer</span>
</div>
</div>
<div class="breakout-room" style="opacity: 0.5;">
<div class="breakout-room-header">
<span class="breakout-room-title">Raum 3</span>
<span class="breakout-room-count">0 Teilnehmer</span>
</div>
<div class="breakout-participants">
<span style="color: var(--bp-text-muted); font-size: 0.875rem;">Keine Teilnehmer</span>
</div>
</div>
</div>
<div style="margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid var(--bp-border);">
<div class="form-group">
<label class="form-label">Automatische Zuweisung</label>
<select class="form-select" disabled>
<option>Gleichmäßig verteilen</option>
<option>Zufällig zuweisen</option>
<option>Manuell zuweisen</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Timer (Minuten)</label>
<input type="number" class="form-input" value="15" disabled>
</div>
<div class="btn-group">
<button class="btn btn-primary" disabled>Breakout-Sessions starten</button>
<button class="btn btn-secondary" disabled>Alle zurückholen</button>
</div>
</div>
</div>
'''
return render_base_page("Breakout-Rooms", content, "breakout")

View File

@@ -0,0 +1,298 @@
"""
Meetings Module - Dashboard Page
Main meetings dashboard with statistics and quick actions
"""
from ..templates import ICONS, render_base_page
def meetings_dashboard() -> str:
"""Main meetings dashboard"""
content = f'''
<div class="page-header">
<div>
<h1 class="page-title">Meeting Dashboard</h1>
<p class="page-subtitle">Videokonferenzen, Schulungen und Elterngespräche verwalten</p>
</div>
<div class="btn-group">
<button class="btn btn-primary" onclick="openModal('newMeeting')">
{ICONS['plus']} Neues Meeting
</button>
</div>
</div>
<!-- Quick Actions -->
<div class="quick-actions">
<a href="/meetings/quick" class="quick-action">
<div class="quick-action-icon card-icon primary">{ICONS['video']}</div>
<span class="quick-action-label">Sofort-Meeting starten</span>
</a>
<a href="/meetings/schedule/new" class="quick-action">
<div class="quick-action-icon card-icon info">{ICONS['calendar']}</div>
<span class="quick-action-label">Meeting planen</span>
</a>
<a href="/meetings/trainings/new" class="quick-action">
<div class="quick-action-icon card-icon accent">{ICONS['graduation']}</div>
<span class="quick-action-label">Schulung erstellen</span>
</a>
<a href="/meetings/parent-teacher" class="quick-action">
<div class="quick-action-icon card-icon warning">{ICONS['users']}</div>
<span class="quick-action-label">Elterngespräch</span>
</a>
</div>
<!-- Statistics Cards -->
<div class="dashboard-grid">
<div class="card">
<div class="card-header">
<span class="card-title">Aktive Meetings</span>
<div class="card-icon primary">{ICONS['video']}</div>
</div>
<div class="stat-value" id="activeMeetings">0</div>
<div class="stat-label">Jetzt live</div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">Geplante Termine</span>
<div class="card-icon info">{ICONS['calendar']}</div>
</div>
<div class="stat-value" id="scheduledMeetings">0</div>
<div class="stat-label">Diese Woche</div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">Aufzeichnungen</span>
<div class="card-icon accent">{ICONS['record']}</div>
</div>
<div class="stat-value" id="recordings">0</div>
<div class="stat-label">Verfügbar</div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">Teilnehmer</span>
<div class="card-icon warning">{ICONS['users']}</div>
</div>
<div class="stat-value" id="totalParticipants">0</div>
<div class="stat-label">Diese Woche</div>
</div>
</div>
<!-- Upcoming Meetings -->
<div class="card">
<div class="card-header">
<span class="card-title">Nächste Meetings</span>
<a href="/meetings/schedule" class="btn btn-secondary">Alle anzeigen</a>
</div>
<div class="meeting-list" id="upcomingMeetings">
<div class="meeting-item">
<div class="meeting-time">
<div class="meeting-time-value">14:00</div>
<div class="meeting-time-date">Heute</div>
</div>
<div class="meeting-info">
<div class="meeting-title">Elterngespräch - Max Müller</div>
<div class="meeting-meta">
<span>{ICONS['clock']} 30 Min</span>
<span>{ICONS['users']} 2 Teilnehmer</span>
</div>
</div>
<span class="meeting-badge badge-scheduled">Geplant</span>
<div class="meeting-actions">
<button class="btn btn-primary" onclick="joinMeeting('parent-123')">Beitreten</button>
<button class="btn-icon" onclick="copyMeetingLink('parent-123')">{ICONS['copy']}</button>
</div>
</div>
<div class="meeting-item">
<div class="meeting-time">
<div class="meeting-time-value">15:30</div>
<div class="meeting-time-date">Heute</div>
</div>
<div class="meeting-info">
<div class="meeting-title">Go Grundlagen Schulung</div>
<div class="meeting-meta">
<span>{ICONS['clock']} 120 Min</span>
<span>{ICONS['users']} 12 Teilnehmer</span>
<span>{ICONS['record']} Aufzeichnung</span>
</div>
</div>
<span class="meeting-badge badge-scheduled">Geplant</span>
<div class="meeting-actions">
<button class="btn btn-primary" onclick="joinMeeting('training-456')">Beitreten</button>
<button class="btn-icon" onclick="copyMeetingLink('training-456')">{ICONS['copy']}</button>
</div>
</div>
</div>
</div>
<!-- New Meeting Modal -->
<div class="modal-overlay" id="newMeetingModal">
<div class="modal">
<div class="modal-header">
<h2 class="modal-title">Neues Meeting erstellen</h2>
<button class="modal-close" onclick="closeModal('newMeeting')">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">Meeting-Typ</label>
<select class="form-select" id="meetingType" onchange="updateMeetingForm()">
<option value="quick">Sofort-Meeting</option>
<option value="scheduled">Geplantes Meeting</option>
<option value="training">Schulung</option>
<option value="parent">Elterngespräch</option>
<option value="class">Klassenkonferenz</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Titel</label>
<input type="text" class="form-input" id="meetingTitle" placeholder="Meeting-Titel eingeben">
</div>
<div class="form-group" id="dateTimeGroup" style="display: none;">
<label class="form-label">Datum & Uhrzeit</label>
<input type="datetime-local" class="form-input" id="meetingDateTime">
</div>
<div class="form-group" id="durationGroup">
<label class="form-label">Dauer (Minuten)</label>
<select class="form-select" id="meetingDuration">
<option value="30">30 Minuten</option>
<option value="60" selected>60 Minuten</option>
<option value="90">90 Minuten</option>
<option value="120">120 Minuten</option>
</select>
</div>
<div class="form-group">
<label class="form-label">
<input type="checkbox" id="enableLobby" checked> Warteraum aktivieren
</label>
</div>
<div class="form-group">
<label class="form-label">
<input type="checkbox" id="enableRecording"> Aufzeichnung erlauben
</label>
</div>
<div class="form-group">
<label class="form-label">
<input type="checkbox" id="muteOnStart" checked> Teilnehmer stummschalten bei Beitritt
</label>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('newMeeting')">Abbrechen</button>
<button class="btn btn-primary" onclick="createMeeting()">Meeting erstellen</button>
</div>
</div>
</div>
<script>
// Modal Functions
function openModal(type) {{
document.getElementById(type + 'Modal').classList.add('active');
}}
function closeModal(type) {{
document.getElementById(type + 'Modal').classList.remove('active');
}}
function updateMeetingForm() {{
const type = document.getElementById('meetingType').value;
const dateTimeGroup = document.getElementById('dateTimeGroup');
if (type === 'quick') {{
dateTimeGroup.style.display = 'none';
}} else {{
dateTimeGroup.style.display = 'block';
}}
}}
// Meeting Functions
async function createMeeting() {{
const type = document.getElementById('meetingType').value;
const title = document.getElementById('meetingTitle').value || 'Neues Meeting';
const duration = document.getElementById('meetingDuration').value;
const enableLobby = document.getElementById('enableLobby').checked;
const enableRecording = document.getElementById('enableRecording').checked;
const muteOnStart = document.getElementById('muteOnStart').checked;
const payload = {{
type: type,
title: title,
duration: parseInt(duration),
config: {{
enable_lobby: enableLobby,
enable_recording: enableRecording,
start_with_audio_muted: muteOnStart
}}
}};
try {{
const response = await fetch('/api/meetings/create', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify(payload)
}});
if (response.ok) {{
const data = await response.json();
closeModal('newMeeting');
if (type === 'quick') {{
window.location.href = '/meetings/room/' + data.room_name;
}} else {{
alert('Meeting erfolgreich erstellt!\\nLink: ' + data.join_url);
loadUpcomingMeetings();
}}
}} else {{
alert('Fehler beim Erstellen des Meetings');
}}
}} catch (error) {{
console.error('Error:', error);
alert('Fehler beim Erstellen des Meetings');
}}
}}
function joinMeeting(roomId) {{
window.location.href = '/meetings/room/' + roomId;
}}
function copyMeetingLink(roomId) {{
const link = window.location.origin + '/meetings/room/' + roomId;
navigator.clipboard.writeText(link).then(() => {{
alert('Link kopiert!');
}});
}}
// Load Data
async function loadDashboardData() {{
try {{
const response = await fetch('/api/meetings/stats');
if (response.ok) {{
const data = await response.json();
document.getElementById('activeMeetings').textContent = data.active || 0;
document.getElementById('scheduledMeetings').textContent = data.scheduled || 0;
document.getElementById('recordings').textContent = data.recordings || 0;
document.getElementById('totalParticipants').textContent = data.participants || 0;
}}
}} catch (error) {{
console.error('Error loading stats:', error);
}}
}}
async function loadUpcomingMeetings() {{
// In production, load from API
console.log('Loading upcoming meetings...');
}}
// Initialize
loadDashboardData();
</script>
'''
return render_base_page("Dashboard", content, "dashboard")

View File

@@ -0,0 +1,266 @@
"""
Meetings Module - Meeting Room Page
Meeting room with embedded Jitsi
"""
from ..templates import ICONS, render_base_page
def meeting_room(room_name: str) -> str:
"""Meeting room with embedded Jitsi"""
content = f'''
<div class="page-header">
<div>
<h1 class="page-title">Meeting: {room_name}</h1>
<p class="page-subtitle">Verbunden mit BreakPilot Meet</p>
</div>
<div class="btn-group">
<button class="btn btn-secondary" onclick="toggleFullscreen()">
Vollbild
</button>
<button class="btn btn-danger" onclick="leaveMeeting()">
{ICONS['phone_off']} Verlassen
</button>
</div>
</div>
<!-- Video Container -->
<div class="video-container" id="jitsiContainer">
<div class="video-placeholder">
{ICONS['video']}
<p>Meeting wird geladen...</p>
</div>
</div>
<!-- Meeting Controls -->
<div class="meeting-controls">
<button class="control-btn active" id="micBtn" onclick="toggleMic()">
{ICONS['mic']}
</button>
<button class="control-btn active" id="videoBtn" onclick="toggleVideo()">
{ICONS['video']}
</button>
<button class="control-btn inactive" id="screenBtn" onclick="toggleScreenShare()">
{ICONS['screen_share']}
</button>
<button class="control-btn inactive" id="chatBtn" onclick="toggleChat()">
{ICONS['chat']}
</button>
<button class="control-btn inactive" id="recordBtn" onclick="toggleRecording()">
{ICONS['record']}
</button>
<button class="control-btn danger" onclick="leaveMeeting()">
{ICONS['phone_off']}
</button>
</div>
<!-- Participants and Chat -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem;">
<!-- Participants Panel -->
<div class="participants-panel">
<div style="font-weight: 600; margin-bottom: 1rem;">Teilnehmer (0)</div>
<div id="participantsList">
<div class="participant-item">
<div class="participant-avatar">Sie</div>
<div class="participant-info">
<div class="participant-name">Sie (Moderator)</div>
<div class="participant-role">Host</div>
</div>
<div class="participant-status">
<span class="status-indicator mic-on" title="Mikrofon an"></span>
<span class="status-indicator video-on" title="Kamera an"></span>
</div>
</div>
</div>
</div>
<!-- Chat Panel -->
<div class="chat-panel">
<div class="chat-header">Chat</div>
<div class="chat-messages" id="chatMessages">
<div class="chat-message">
<div class="chat-message-header">
<span class="chat-message-sender">System</span>
<span class="chat-message-time">Jetzt</span>
</div>
<div class="chat-message-content">Willkommen im Meeting!</div>
</div>
</div>
<div class="chat-input-area">
<input type="text" class="chat-input" id="chatInput" placeholder="Nachricht eingeben..." onkeypress="if(event.key==='Enter')sendMessage()">
<button class="btn btn-primary" onclick="sendMessage()">Senden</button>
</div>
</div>
</div>
<!-- Jitsi Integration Script -->
<script src="https://meet.jit.si/external_api.js"></script>
<script>
let api = null;
let isMuted = false;
let isVideoOff = false;
let isScreenSharing = false;
let isRecording = false;
// Initialize Jitsi
function initJitsi() {{
const domain = 'meet.jit.si';
const options = {{
roomName: 'BreakPilot-{room_name}',
width: '100%',
height: '100%',
parentNode: document.getElementById('jitsiContainer'),
configOverwrite: {{
startWithAudioMuted: false,
startWithVideoMuted: false,
enableWelcomePage: false,
prejoinPageEnabled: false,
}},
interfaceConfigOverwrite: {{
TOOLBAR_BUTTONS: [],
SHOW_JITSI_WATERMARK: false,
SHOW_BRAND_WATERMARK: false,
SHOW_WATERMARK_FOR_GUESTS: false,
DEFAULT_BACKGROUND: '#1a1a1a',
DISABLE_VIDEO_BACKGROUND: true,
}},
userInfo: {{
displayName: 'Lehrer'
}}
}};
api = new JitsiMeetExternalAPI(domain, options);
// Event Listeners
api.addListener('participantJoined', (participant) => {{
console.log('Participant joined:', participant);
updateParticipantsList();
}});
api.addListener('participantLeft', (participant) => {{
console.log('Participant left:', participant);
updateParticipantsList();
}});
api.addListener('readyToClose', () => {{
window.location.href = '/meetings';
}});
}}
// Control Functions
function toggleMic() {{
if (api) {{
api.executeCommand('toggleAudio');
isMuted = !isMuted;
updateControlButton('micBtn', !isMuted);
}}
}}
function toggleVideo() {{
if (api) {{
api.executeCommand('toggleVideo');
isVideoOff = !isVideoOff;
updateControlButton('videoBtn', !isVideoOff);
}}
}}
function toggleScreenShare() {{
if (api) {{
api.executeCommand('toggleShareScreen');
isScreenSharing = !isScreenSharing;
updateControlButton('screenBtn', isScreenSharing);
}}
}}
function toggleChat() {{
if (api) {{
api.executeCommand('toggleChat');
}}
}}
function toggleRecording() {{
if (api) {{
if (!isRecording) {{
api.executeCommand('startRecording', {{
mode: 'file'
}});
}} else {{
api.executeCommand('stopRecording', 'file');
}}
isRecording = !isRecording;
updateControlButton('recordBtn', isRecording);
}}
}}
function updateControlButton(btnId, isActive) {{
const btn = document.getElementById(btnId);
if (isActive) {{
btn.classList.remove('inactive');
btn.classList.add('active');
}} else {{
btn.classList.remove('active');
btn.classList.add('inactive');
}}
}}
function leaveMeeting() {{
if (confirm('Meeting wirklich verlassen?')) {{
if (api) {{
api.executeCommand('hangup');
}}
window.location.href = '/meetings';
}}
}}
function toggleFullscreen() {{
const container = document.getElementById('jitsiContainer');
if (document.fullscreenElement) {{
document.exitFullscreen();
}} else {{
container.requestFullscreen();
}}
}}
// Chat Functions
function sendMessage() {{
const input = document.getElementById('chatInput');
const message = input.value.trim();
if (message && api) {{
api.executeCommand('sendChatMessage', message);
addChatMessage('Sie', message);
input.value = '';
}}
}}
function addChatMessage(sender, text) {{
const container = document.getElementById('chatMessages');
const now = new Date().toLocaleTimeString('de-DE', {{ hour: '2-digit', minute: '2-digit' }});
container.innerHTML += `
<div class="chat-message">
<div class="chat-message-header">
<span class="chat-message-sender">${{sender}}</span>
<span class="chat-message-time">${{now}}</span>
</div>
<div class="chat-message-content">${{text}}</div>
</div>
`;
container.scrollTop = container.scrollHeight;
}}
function updateParticipantsList() {{
if (api) {{
const participants = api.getParticipantsInfo();
console.log('Participants:', participants);
// Update UI with participants
}}
}}
// Initialize on page load
document.addEventListener('DOMContentLoaded', initJitsi);
</script>
'''
return render_base_page(f"Meeting: {room_name}", content, "active")

View File

@@ -0,0 +1,138 @@
"""
Meetings Module - Quick Actions Pages
Quick meeting start and parent-teacher meeting creation
"""
import uuid
from ..templates import ICONS, render_base_page
def quick_meeting() -> str:
"""Start a quick meeting immediately"""
room_name = f"quick-{uuid.uuid4().hex[:8]}"
content = f'''
<div style="text-align: center; padding: 3rem;">
<h1 class="page-title">Sofort-Meeting wird gestartet...</h1>
<p class="page-subtitle">Sie werden in wenigen Sekunden weitergeleitet.</p>
<div style="margin-top: 2rem;">
<div class="card-icon primary" style="margin: 0 auto; width: 80px; height: 80px; font-size: 2rem;">
{ICONS['video']}
</div>
</div>
</div>
<script>
setTimeout(() => {{
window.location.href = '/meetings/room/{room_name}';
}}, 1000);
</script>
'''
return render_base_page("Sofort-Meeting", content, "active")
def parent_teacher_meeting() -> str:
"""Create a parent-teacher meeting"""
content = f'''
<div class="page-header">
<div>
<h1 class="page-title">Elterngespräch planen</h1>
<p class="page-subtitle">Sicheres Meeting mit Warteraum und Passwort</p>
</div>
</div>
<div class="card" style="max-width: 600px;">
<div class="form-group">
<label class="form-label">Schüler/in</label>
<input type="text" class="form-input" id="studentName" placeholder="Name des Schülers/der Schülerin">
</div>
<div class="form-group">
<label class="form-label">Elternteil</label>
<input type="text" class="form-input" id="parentName" placeholder="Name der Eltern">
</div>
<div class="form-group">
<label class="form-label">E-Mail (für Einladung)</label>
<input type="email" class="form-input" id="parentEmail" placeholder="eltern@example.com">
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
<div class="form-group">
<label class="form-label">Datum</label>
<input type="date" class="form-input" id="meetingDate">
</div>
<div class="form-group">
<label class="form-label">Uhrzeit</label>
<input type="time" class="form-input" id="meetingTime">
</div>
</div>
<div class="form-group">
<label class="form-label">Anlass (optional)</label>
<textarea class="form-textarea" id="meetingReason" placeholder="Grund für das Gespräch..."></textarea>
</div>
<div class="form-group">
<label class="form-label">
<input type="checkbox" id="sendInvite" checked> Einladung per E-Mail senden
</label>
</div>
<div class="btn-group" style="margin-top: 1.5rem;">
<button class="btn btn-secondary" onclick="window.location.href='/meetings'">Abbrechen</button>
<button class="btn btn-primary" onclick="createParentTeacherMeeting()">Termin erstellen</button>
</div>
</div>
<script>
// Set minimum date to today
document.getElementById('meetingDate').min = new Date().toISOString().split('T')[0];
async function createParentTeacherMeeting() {{
const studentName = document.getElementById('studentName').value;
const parentName = document.getElementById('parentName').value;
const parentEmail = document.getElementById('parentEmail').value;
const date = document.getElementById('meetingDate').value;
const time = document.getElementById('meetingTime').value;
const reason = document.getElementById('meetingReason').value;
const sendInvite = document.getElementById('sendInvite').checked;
if (!studentName || !parentName || !date || !time) {{
alert('Bitte füllen Sie alle Pflichtfelder aus.');
return;
}}
const payload = {{
type: 'parent-teacher',
student_name: studentName,
parent_name: parentName,
parent_email: parentEmail,
scheduled_at: `${{date}}T${{time}}`,
reason: reason,
send_invite: sendInvite,
duration: 30
}};
try {{
const response = await fetch('/api/meetings/parent-teacher', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify(payload)
}});
if (response.ok) {{
const data = await response.json();
alert('Elterngespräch erfolgreich geplant!\\n\\nLink: ' + data.join_url + '\\nPasswort: ' + data.password);
window.location.href = '/meetings/schedule';
}}
}} catch (error) {{
console.error('Error:', error);
alert('Fehler beim Erstellen des Termins');
}}
}}
</script>
'''
return render_base_page("Elterngespräch", content, "schedule")

View File

@@ -0,0 +1,284 @@
"""
Meetings Module - Recordings Page
Recordings and transcripts management
"""
from ..templates import ICONS, render_base_page
def recordings_page() -> str:
"""Recordings and transcripts management"""
content = f'''
<div class="page-header">
<div>
<h1 class="page-title">Aufzeichnungen</h1>
<p class="page-subtitle">Aufzeichnungen und Protokolle verwalten</p>
</div>
</div>
<!-- Tabs -->
<div class="tabs">
<button class="tab active" onclick="filterRecordings('all')">Alle</button>
<button class="tab" onclick="filterRecordings('trainings')">Schulungen</button>
<button class="tab" onclick="filterRecordings('meetings')">Meetings</button>
</div>
<!-- Recordings List -->
<div class="recording-list">
<div class="card" style="margin-bottom: 1rem;">
<div style="display: flex; align-items: center; gap: 1rem;">
<div class="card-icon primary">{ICONS['record']}</div>
<div style="flex: 1;">
<div style="font-weight: 600; margin-bottom: 0.25rem;">Docker Grundlagen Schulung</div>
<div style="font-size: 0.875rem; color: var(--bp-text-muted);">
10.12.2025, 10:00 - 11:30 | 1:30:00 | 156 MB
</div>
</div>
<div class="btn-group">
<button class="btn btn-secondary" onclick="playRecording('docker-basics')">
{ICONS['play']} Abspielen
</button>
<button class="btn btn-secondary" onclick="viewTranscript('docker-basics')">
{ICONS['file_text']} Protokoll
</button>
<button class="btn-icon" onclick="downloadRecording('docker-basics')">
{ICONS['download']}
</button>
<button class="btn-icon" onclick="deleteRecording('docker-basics')">
{ICONS['trash']}
</button>
</div>
</div>
</div>
<div class="card" style="margin-bottom: 1rem;">
<div style="display: flex; align-items: center; gap: 1rem;">
<div class="card-icon primary">{ICONS['record']}</div>
<div style="flex: 1;">
<div style="font-weight: 600; margin-bottom: 0.25rem;">Team-Meeting KW 49</div>
<div style="font-size: 0.875rem; color: var(--bp-text-muted);">
06.12.2025, 14:00 - 15:00 | 1:00:00 | 98 MB
</div>
</div>
<div class="btn-group">
<button class="btn btn-secondary" onclick="playRecording('team-kw49')">
{ICONS['play']} Abspielen
</button>
<button class="btn btn-secondary" onclick="viewTranscript('team-kw49')">
{ICONS['file_text']} Protokoll
</button>
<button class="btn-icon" onclick="downloadRecording('team-kw49')">
{ICONS['download']}
</button>
<button class="btn-icon" onclick="deleteRecording('team-kw49')">
{ICONS['trash']}
</button>
</div>
</div>
</div>
<div class="card" style="margin-bottom: 1rem;">
<div style="display: flex; align-items: center; gap: 1rem;">
<div class="card-icon primary">{ICONS['record']}</div>
<div style="flex: 1;">
<div style="font-weight: 600; margin-bottom: 0.25rem;">Elterngespräch - Max Müller</div>
<div style="font-size: 0.875rem; color: var(--bp-text-muted);">
02.12.2025, 16:00 - 16:30 | 0:28:00 | 42 MB
</div>
</div>
<div class="btn-group">
<button class="btn btn-secondary" onclick="playRecording('parent-mueller')">
{ICONS['play']} Abspielen
</button>
<button class="btn btn-secondary" onclick="viewTranscript('parent-mueller')">
{ICONS['file_text']} Protokoll
</button>
<button class="btn-icon" onclick="downloadRecording('parent-mueller')">
{ICONS['download']}
</button>
<button class="btn-icon" onclick="deleteRecording('parent-mueller')">
{ICONS['trash']}
</button>
</div>
</div>
</div>
</div>
<!-- Storage Info -->
<div class="card" style="margin-top: 2rem;">
<div class="card-header">
<span class="card-title">Speicherplatz</span>
</div>
<div style="margin-bottom: 1rem;">
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
<span>296 MB von 10 GB verwendet</span>
<span>3%</span>
</div>
<div style="background: var(--bp-bg); border-radius: 4px; height: 8px; overflow: hidden;">
<div style="background: var(--bp-primary); width: 3%; height: 100%;"></div>
</div>
</div>
<p style="font-size: 0.875rem; color: var(--bp-text-muted);">
3 Aufzeichnungen | Älteste Aufzeichnung: 02.12.2025
</p>
</div>
<script>
function filterRecordings(filter) {{
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
event.target.classList.add('active');
console.log('Filter:', filter);
}}
function playRecording(recordingId) {{
window.location.href = '/meetings/recordings/' + recordingId + '/play';
}}
function viewTranscript(recordingId) {{
window.location.href = '/meetings/recordings/' + recordingId + '/transcript';
}}
function downloadRecording(recordingId) {{
window.location.href = '/api/recordings/' + recordingId + '/download';
}}
function deleteRecording(recordingId) {{
if (confirm('Aufzeichnung wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.')) {{
fetch('/api/recordings/' + recordingId, {{ method: 'DELETE' }})
.then(() => location.reload());
}}
}}
</script>
'''
return render_base_page("Aufzeichnungen", content, "recordings")
def play_recording(recording_id: str) -> str:
"""Play a recording"""
content = f'''
<div class="page-header">
<div>
<h1 class="page-title">Aufzeichnung abspielen</h1>
<p class="page-subtitle">{recording_id}</p>
</div>
<div class="btn-group">
<button class="btn btn-secondary" onclick="downloadRecording()">
{ICONS['download']} Herunterladen
</button>
<a href="/meetings/recordings" class="btn btn-secondary">Zurück</a>
</div>
</div>
<div class="video-container">
<div class="video-placeholder">
{ICONS['play']}
<p>Aufzeichnung wird geladen...</p>
<p style="font-size: 0.875rem;">Recording ID: {recording_id}</p>
</div>
</div>
<!-- Recording Info -->
<div class="card">
<div class="card-header">
<span class="card-title">Details</span>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem;">
<div>
<div style="font-size: 0.875rem; color: var(--bp-text-muted);">Datum</div>
<div style="font-weight: 600;">10.12.2025, 10:00</div>
</div>
<div>
<div style="font-size: 0.875rem; color: var(--bp-text-muted);">Dauer</div>
<div style="font-weight: 600;">1:30:00</div>
</div>
<div>
<div style="font-size: 0.875rem; color: var(--bp-text-muted);">Größe</div>
<div style="font-weight: 600;">156 MB</div>
</div>
<div>
<div style="font-size: 0.875rem; color: var(--bp-text-muted);">Teilnehmer</div>
<div style="font-weight: 600;">15</div>
</div>
</div>
</div>
<script>
function downloadRecording() {{
window.location.href = '/api/recordings/{recording_id}/download';
}}
</script>
'''
return render_base_page("Aufzeichnung", content, "recordings")
def view_transcript(recording_id: str) -> str:
"""View recording transcript"""
content = f'''
<div class="page-header">
<div>
<h1 class="page-title">Protokoll</h1>
<p class="page-subtitle">{recording_id}</p>
</div>
<div class="btn-group">
<button class="btn btn-secondary" onclick="downloadTranscript()">
{ICONS['download']} Als PDF exportieren
</button>
<a href="/meetings/recordings" class="btn btn-secondary">Zurück</a>
</div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">Transkript</span>
</div>
<div style="margin-top: 1rem;">
<div style="margin-bottom: 1rem; padding: 1rem; background: var(--bp-bg); border-radius: 8px;">
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
<span style="font-weight: 600;">Max Trainer</span>
<span style="font-size: 0.875rem; color: var(--bp-text-muted);">00:00:15</span>
</div>
<p style="font-size: 0.875rem;">Willkommen zur Docker Grundlagen Schulung. Heute werden wir die Basics von Containern und Images besprechen.</p>
</div>
<div style="margin-bottom: 1rem; padding: 1rem; background: var(--bp-bg); border-radius: 8px;">
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
<span style="font-weight: 600;">Max Trainer</span>
<span style="font-size: 0.875rem; color: var(--bp-text-muted);">00:02:30</span>
</div>
<p style="font-size: 0.875rem;">Docker ist eine Open-Source-Plattform, die es ermöglicht, Anwendungen in Containern zu entwickeln, zu versenden und auszuführen.</p>
</div>
<div style="margin-bottom: 1rem; padding: 1rem; background: var(--bp-bg); border-radius: 8px;">
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
<span style="font-weight: 600;">Teilnehmer 1</span>
<span style="font-size: 0.875rem; color: var(--bp-text-muted);">00:05:45</span>
</div>
<p style="font-size: 0.875rem;">Was ist der Unterschied zwischen einem Container und einer virtuellen Maschine?</p>
</div>
<div style="margin-bottom: 1rem; padding: 1rem; background: var(--bp-bg); border-radius: 8px;">
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
<span style="font-weight: 600;">Max Trainer</span>
<span style="font-size: 0.875rem; color: var(--bp-text-muted);">00:06:00</span>
</div>
<p style="font-size: 0.875rem;">Gute Frage! Container teilen sich den Kernel des Host-Systems, während VMs einen vollständigen Hypervisor und ein eigenes Betriebssystem benötigen...</p>
</div>
<div style="text-align: center; padding: 2rem; color: var(--bp-text-muted);">
<p>... Transkript wird fortgesetzt ...</p>
</div>
</div>
</div>
<script>
function downloadTranscript() {{
alert('PDF-Export wird vorbereitet...');
// In production: window.location.href = '/api/recordings/{recording_id}/transcript/pdf';
}}
</script>
'''
return render_base_page("Protokoll", content, "recordings")

View File

@@ -0,0 +1,206 @@
"""
Meetings Module - Schedule Page
Schedule and manage upcoming meetings
"""
from ..templates import ICONS, render_base_page
def schedule_meetings() -> str:
"""Schedule and manage upcoming meetings"""
content = f'''
<div class="page-header">
<div>
<h1 class="page-title">Termine</h1>
<p class="page-subtitle">Geplante Meetings und Termine verwalten</p>
</div>
<button class="btn btn-primary" onclick="openScheduleModal()">
{ICONS['plus']} Meeting planen
</button>
</div>
<!-- Tabs -->
<div class="tabs">
<button class="tab active" onclick="filterMeetings('all')">Alle</button>
<button class="tab" onclick="filterMeetings('today')">Heute</button>
<button class="tab" onclick="filterMeetings('week')">Diese Woche</button>
<button class="tab" onclick="filterMeetings('month')">Dieser Monat</button>
</div>
<div class="meeting-list" id="scheduledMeetings">
<div class="meeting-item">
<div class="meeting-time">
<div class="meeting-time-value">14:00</div>
<div class="meeting-time-date">Mo, 16.12.</div>
</div>
<div class="meeting-info">
<div class="meeting-title">Team-Besprechung</div>
<div class="meeting-meta">
<span>{ICONS['clock']} 60 Min</span>
<span>{ICONS['users']} 5 Teilnehmer</span>
</div>
</div>
<span class="meeting-badge badge-scheduled">Geplant</span>
<div class="meeting-actions">
<button class="btn btn-primary" onclick="joinMeeting('team-abc')">Beitreten</button>
<button class="btn-icon" onclick="editMeeting('team-abc')">{ICONS['settings']}</button>
<button class="btn-icon" onclick="copyLink('team-abc')">{ICONS['link']}</button>
<button class="btn-icon" onclick="deleteMeeting('team-abc')">{ICONS['trash']}</button>
</div>
</div>
<div class="meeting-item">
<div class="meeting-time">
<div class="meeting-time-value">09:30</div>
<div class="meeting-time-date">Di, 17.12.</div>
</div>
<div class="meeting-info">
<div class="meeting-title">Elterngespräch - Anna Schmidt</div>
<div class="meeting-meta">
<span>{ICONS['clock']} 30 Min</span>
<span>{ICONS['users']} 2 Teilnehmer</span>
</div>
</div>
<span class="meeting-badge badge-scheduled">Geplant</span>
<div class="meeting-actions">
<button class="btn btn-primary" onclick="joinMeeting('parent-xyz')">Beitreten</button>
<button class="btn-icon" onclick="editMeeting('parent-xyz')">{ICONS['settings']}</button>
<button class="btn-icon" onclick="copyLink('parent-xyz')">{ICONS['link']}</button>
<button class="btn-icon" onclick="deleteMeeting('parent-xyz')">{ICONS['trash']}</button>
</div>
</div>
</div>
<!-- Schedule Modal -->
<div class="modal-overlay" id="scheduleModal">
<div class="modal">
<div class="modal-header">
<h2 class="modal-title">Meeting planen</h2>
<button class="modal-close" onclick="closeScheduleModal()">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">Titel</label>
<input type="text" class="form-input" id="scheduleTitle" placeholder="Meeting-Titel">
</div>
<div class="form-group">
<label class="form-label">Datum</label>
<input type="date" class="form-input" id="scheduleDate">
</div>
<div class="form-group">
<label class="form-label">Uhrzeit</label>
<input type="time" class="form-input" id="scheduleTime">
</div>
<div class="form-group">
<label class="form-label">Dauer</label>
<select class="form-select" id="scheduleDuration">
<option value="30">30 Minuten</option>
<option value="60" selected>60 Minuten</option>
<option value="90">90 Minuten</option>
<option value="120">120 Minuten</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Beschreibung (optional)</label>
<textarea class="form-textarea" id="scheduleDescription" placeholder="Agenda oder Beschreibung..."></textarea>
</div>
<div class="form-group">
<label class="form-label">Teilnehmer einladen</label>
<input type="email" class="form-input" id="scheduleInvites" placeholder="E-Mail-Adressen (kommagetrennt)">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeScheduleModal()">Abbrechen</button>
<button class="btn btn-primary" onclick="scheduleMeeting()">Meeting planen</button>
</div>
</div>
</div>
<script>
function openScheduleModal() {{
document.getElementById('scheduleModal').classList.add('active');
// Set default date to today
document.getElementById('scheduleDate').valueAsDate = new Date();
}}
function closeScheduleModal() {{
document.getElementById('scheduleModal').classList.remove('active');
}}
async function scheduleMeeting() {{
const title = document.getElementById('scheduleTitle').value;
const date = document.getElementById('scheduleDate').value;
const time = document.getElementById('scheduleTime').value;
const duration = document.getElementById('scheduleDuration').value;
const description = document.getElementById('scheduleDescription').value;
const invites = document.getElementById('scheduleInvites').value;
if (!title || !date || !time) {{
alert('Bitte füllen Sie alle Pflichtfelder aus.');
return;
}}
const payload = {{
title,
scheduled_at: `${{date}}T${{time}}`,
duration: parseInt(duration),
description,
invites: invites.split(',').map(e => e.trim()).filter(e => e)
}};
try {{
const response = await fetch('/api/meetings/schedule', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify(payload)
}});
if (response.ok) {{
alert('Meeting erfolgreich geplant!');
closeScheduleModal();
location.reload();
}}
}} catch (error) {{
console.error('Error:', error);
}}
}}
function filterMeetings(filter) {{
// Update active tab
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
event.target.classList.add('active');
// Filter logic would go here
console.log('Filter:', filter);
}}
function joinMeeting(roomId) {{
window.location.href = '/meetings/room/' + roomId;
}}
function editMeeting(roomId) {{
// Open edit modal
console.log('Edit:', roomId);
}}
function copyLink(roomId) {{
const link = window.location.origin + '/meetings/room/' + roomId;
navigator.clipboard.writeText(link);
alert('Link kopiert!');
}}
function deleteMeeting(roomId) {{
if (confirm('Meeting wirklich löschen?')) {{
// Delete logic
console.log('Delete:', roomId);
}}
}}
</script>
'''
return render_base_page("Termine", content, "schedule")

View File

@@ -0,0 +1,267 @@
"""
Meetings Module - Trainings Page
Training sessions management
"""
from ..templates import ICONS, render_base_page
def trainings_page() -> str:
"""Training sessions management"""
content = f'''
<div class="page-header">
<div>
<h1 class="page-title">Schulungen</h1>
<p class="page-subtitle">Schulungen und Workshops verwalten</p>
</div>
<button class="btn btn-primary" onclick="openTrainingModal()">
{ICONS['plus']} Neue Schulung
</button>
</div>
<!-- Training Cards -->
<div class="dashboard-grid">
<div class="training-card">
<div class="training-card-header">
<div class="training-card-title">Go Grundlagen Workshop</div>
<div class="training-card-subtitle">Einführung in die Go-Programmierung</div>
</div>
<div class="training-card-body">
<div style="display: flex; gap: 1rem; margin-bottom: 1rem;">
<span style="display: flex; align-items: center; gap: 0.5rem; color: var(--bp-text-muted);">
{ICONS['calendar']} 18.12.2025, 14:00
</span>
<span style="display: flex; align-items: center; gap: 0.5rem; color: var(--bp-text-muted);">
{ICONS['clock']} 120 Min
</span>
</div>
<div style="display: flex; gap: 0.5rem; margin-bottom: 1rem;">
<span class="meeting-badge badge-scheduled">Geplant</span>
<span class="meeting-badge" style="background: var(--bp-accent-soft); color: var(--bp-accent);">Aufzeichnung</span>
</div>
<p style="font-size: 0.875rem; color: var(--bp-text-muted);">
12 von 20 Teilnehmern angemeldet
</p>
</div>
<div class="training-card-footer">
<span style="font-size: 0.875rem; color: var(--bp-text-muted);">Trainer: Max Mustermann</span>
<div class="btn-group">
<button class="btn btn-secondary" onclick="editTraining('go-basics')">{ICONS['settings']}</button>
<button class="btn btn-primary" onclick="startTraining('go-basics')">Starten</button>
</div>
</div>
</div>
<div class="training-card">
<div class="training-card-header">
<div class="training-card-title">PWA Entwicklung</div>
<div class="training-card-subtitle">Progressive Web Apps mit JavaScript</div>
</div>
<div class="training-card-body">
<div style="display: flex; gap: 1rem; margin-bottom: 1rem;">
<span style="display: flex; align-items: center; gap: 0.5rem; color: var(--bp-text-muted);">
{ICONS['calendar']} 20.12.2025, 10:00
</span>
<span style="display: flex; align-items: center; gap: 0.5rem; color: var(--bp-text-muted);">
{ICONS['clock']} 180 Min
</span>
</div>
<div style="display: flex; gap: 0.5rem; margin-bottom: 1rem;">
<span class="meeting-badge badge-scheduled">Geplant</span>
<span class="meeting-badge" style="background: var(--bp-accent-soft); color: var(--bp-accent);">Aufzeichnung</span>
<span class="meeting-badge" style="background: var(--bp-info); color: white;">Breakout</span>
</div>
<p style="font-size: 0.875rem; color: var(--bp-text-muted);">
8 von 15 Teilnehmern angemeldet
</p>
</div>
<div class="training-card-footer">
<span style="font-size: 0.875rem; color: var(--bp-text-muted);">Trainer: Lisa Schmidt</span>
<div class="btn-group">
<button class="btn btn-secondary" onclick="editTraining('pwa-dev')">{ICONS['settings']}</button>
<button class="btn btn-primary" onclick="startTraining('pwa-dev')">Starten</button>
</div>
</div>
</div>
</div>
<!-- Past Trainings -->
<div class="card" style="margin-top: 2rem;">
<div class="card-header">
<span class="card-title">Vergangene Schulungen</span>
</div>
<div class="meeting-list">
<div class="meeting-item">
<div class="meeting-time">
<div class="meeting-time-value">10:00</div>
<div class="meeting-time-date">10.12.</div>
</div>
<div class="meeting-info">
<div class="meeting-title">Docker Grundlagen</div>
<div class="meeting-meta">
<span>{ICONS['clock']} 90 Min</span>
<span>{ICONS['users']} 15 Teilnehmer</span>
<span>{ICONS['record']} Aufzeichnung verfügbar</span>
</div>
</div>
<span class="meeting-badge badge-ended">Beendet</span>
<div class="meeting-actions">
<button class="btn btn-secondary" onclick="viewRecording('docker-basics')">{ICONS['play']} Ansehen</button>
<button class="btn-icon" onclick="downloadRecording('docker-basics')">{ICONS['download']}</button>
</div>
</div>
</div>
</div>
<!-- Training Modal -->
<div class="modal-overlay" id="trainingModal">
<div class="modal" style="max-width: 600px;">
<div class="modal-header">
<h2 class="modal-title">Neue Schulung erstellen</h2>
<button class="modal-close" onclick="closeTrainingModal()">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">Titel</label>
<input type="text" class="form-input" id="trainingTitle" placeholder="Schulungstitel">
</div>
<div class="form-group">
<label class="form-label">Beschreibung</label>
<textarea class="form-textarea" id="trainingDescription" placeholder="Beschreibung der Schulung..."></textarea>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
<div class="form-group">
<label class="form-label">Datum</label>
<input type="date" class="form-input" id="trainingDate">
</div>
<div class="form-group">
<label class="form-label">Uhrzeit</label>
<input type="time" class="form-input" id="trainingTime">
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
<div class="form-group">
<label class="form-label">Dauer (Minuten)</label>
<select class="form-select" id="trainingDuration">
<option value="60">60 Minuten</option>
<option value="90">90 Minuten</option>
<option value="120" selected>120 Minuten</option>
<option value="180">180 Minuten</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Max. Teilnehmer</label>
<input type="number" class="form-input" id="trainingMaxParticipants" value="20">
</div>
</div>
<div class="form-group">
<label class="form-label">Trainer</label>
<input type="text" class="form-input" id="trainingTrainer" placeholder="Name des Trainers">
</div>
<div class="form-group">
<label class="form-label">
<input type="checkbox" id="trainingRecording" checked> Aufzeichnung aktivieren
</label>
</div>
<div class="form-group">
<label class="form-label">
<input type="checkbox" id="trainingBreakout"> Breakout-Rooms aktivieren
</label>
</div>
<div class="form-group">
<label class="form-label">
<input type="checkbox" id="trainingLobby" checked> Warteraum aktivieren (Trainer lässt Teilnehmer ein)
</label>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeTrainingModal()">Abbrechen</button>
<button class="btn btn-primary" onclick="createTraining()">Schulung erstellen</button>
</div>
</div>
</div>
<script>
function openTrainingModal() {{
document.getElementById('trainingModal').classList.add('active');
}}
function closeTrainingModal() {{
document.getElementById('trainingModal').classList.remove('active');
}}
async function createTraining() {{
const title = document.getElementById('trainingTitle').value;
const description = document.getElementById('trainingDescription').value;
const date = document.getElementById('trainingDate').value;
const time = document.getElementById('trainingTime').value;
const duration = document.getElementById('trainingDuration').value;
const maxParticipants = document.getElementById('trainingMaxParticipants').value;
const trainer = document.getElementById('trainingTrainer').value;
const enableRecording = document.getElementById('trainingRecording').checked;
const enableBreakout = document.getElementById('trainingBreakout').checked;
const enableLobby = document.getElementById('trainingLobby').checked;
if (!title || !date || !time || !trainer) {{
alert('Bitte füllen Sie alle Pflichtfelder aus.');
return;
}}
const payload = {{
title,
description,
scheduled_at: `${{date}}T${{time}}`,
duration: parseInt(duration),
max_participants: parseInt(maxParticipants),
trainer,
config: {{
enable_recording: enableRecording,
enable_breakout: enableBreakout,
enable_lobby: enableLobby,
start_with_audio_muted: true
}}
}};
try {{
const response = await fetch('/api/meetings/training', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify(payload)
}});
if (response.ok) {{
alert('Schulung erfolgreich erstellt!');
closeTrainingModal();
location.reload();
}}
}} catch (error) {{
console.error('Error:', error);
}}
}}
function startTraining(trainingId) {{
window.location.href = '/meetings/room/training-' + trainingId;
}}
function editTraining(trainingId) {{
console.log('Edit training:', trainingId);
}}
function viewRecording(trainingId) {{
window.location.href = '/meetings/recordings/' + trainingId;
}}
function downloadRecording(trainingId) {{
console.log('Download recording:', trainingId);
}}
</script>
'''
return render_base_page("Schulungen", content, "trainings")

View File

@@ -0,0 +1,918 @@
"""
Meetings Module - CSS Styles
BreakPilot Design System for the Meetings frontend
"""
BREAKPILOT_STYLES = """
:root {
--bp-primary: #6C1B1B;
--bp-primary-soft: rgba(108, 27, 27, 0.1);
--bp-bg: #F8F8F8;
--bp-surface: #FFFFFF;
--bp-surface-elevated: #FFFFFF;
--bp-border: #E0E0E0;
--bp-border-subtle: rgba(108, 27, 27, 0.15);
--bp-accent: #5ABF60;
--bp-accent-soft: rgba(90, 191, 96, 0.15);
--bp-text: #4A4A4A;
--bp-text-muted: #6B6B6B;
--bp-danger: #ef4444;
--bp-warning: #F1C40F;
--bp-info: #3b82f6;
--bp-gold: #F1C40F;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Manrope', system-ui, -apple-system, sans-serif;
background: var(--bp-bg);
color: var(--bp-text);
min-height: 100vh;
}
/* Layout */
.app-container {
display: flex;
min-height: 100vh;
}
/* Sidebar */
.sidebar {
width: 260px;
background: var(--bp-surface);
border-right: 1px solid var(--bp-border);
padding: 1.5rem;
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.logo {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 2rem;
}
.logo-icon {
width: 36px;
height: 36px;
background: var(--bp-primary);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 700;
}
.logo-text {
font-size: 1.25rem;
font-weight: 700;
color: var(--bp-primary);
}
.nav-section {
margin-bottom: 1.5rem;
}
.nav-section-title {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: var(--bp-text-muted);
margin-bottom: 0.75rem;
padding-left: 0.75rem;
}
.nav-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
color: var(--bp-text);
text-decoration: none;
}
.nav-item:hover {
background: var(--bp-primary-soft);
}
.nav-item.active {
background: var(--bp-primary);
color: white;
}
.nav-item svg {
width: 20px;
height: 20px;
}
/* Main Content */
.main-content {
flex: 1;
padding: 2rem;
overflow-y: auto;
}
.page-header {
margin-bottom: 2rem;
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.page-title {
font-size: 1.75rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
.page-subtitle {
color: var(--bp-text-muted);
}
/* Dashboard Grid */
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
/* Cards */
.card {
background: var(--bp-surface);
border-radius: 12px;
border: 1px solid var(--bp-border);
padding: 1.5rem;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.card-title {
font-size: 1rem;
font-weight: 600;
}
.card-icon {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
}
.card-icon.primary { background: var(--bp-primary-soft); color: var(--bp-primary); }
.card-icon.accent { background: var(--bp-accent-soft); color: var(--bp-accent); }
.card-icon.warning { background: rgba(241, 196, 15, 0.15); color: var(--bp-warning); }
.card-icon.info { background: rgba(59, 130, 246, 0.15); color: var(--bp-info); }
.card-icon.danger { background: rgba(239, 68, 68, 0.15); color: var(--bp-danger); }
.stat-value {
font-size: 2rem;
font-weight: 700;
margin-bottom: 0.25rem;
}
.stat-label {
color: var(--bp-text-muted);
font-size: 0.875rem;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
border-radius: 8px;
font-weight: 600;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
border: none;
text-decoration: none;
}
.btn-primary {
background: var(--bp-primary);
color: white;
}
.btn-primary:hover {
background: #5a1717;
}
.btn-accent {
background: var(--bp-accent);
color: white;
}
.btn-accent:hover {
background: #4aa850;
}
.btn-secondary {
background: var(--bp-bg);
color: var(--bp-text);
border: 1px solid var(--bp-border);
}
.btn-secondary:hover {
background: var(--bp-border);
}
.btn-danger {
background: var(--bp-danger);
color: white;
}
.btn-danger:hover {
background: #dc2626;
}
.btn-icon {
padding: 0.5rem;
border-radius: 8px;
background: transparent;
border: 1px solid var(--bp-border);
cursor: pointer;
color: var(--bp-text);
}
.btn-icon:hover {
background: var(--bp-primary-soft);
color: var(--bp-primary);
}
.btn-group {
display: flex;
gap: 0.5rem;
}
/* Meeting List */
.meeting-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.meeting-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: var(--bp-surface);
border: 1px solid var(--bp-border);
border-radius: 10px;
transition: all 0.2s;
}
.meeting-item:hover {
border-color: var(--bp-primary);
box-shadow: 0 2px 8px rgba(108, 27, 27, 0.1);
}
.meeting-time {
text-align: center;
min-width: 70px;
}
.meeting-time-value {
font-size: 1.25rem;
font-weight: 700;
color: var(--bp-primary);
}
.meeting-time-date {
font-size: 0.75rem;
color: var(--bp-text-muted);
}
.meeting-info {
flex: 1;
}
.meeting-title {
font-weight: 600;
margin-bottom: 0.25rem;
}
.meeting-meta {
display: flex;
gap: 1rem;
font-size: 0.875rem;
color: var(--bp-text-muted);
}
.meeting-meta span {
display: flex;
align-items: center;
gap: 0.25rem;
}
.meeting-actions {
display: flex;
gap: 0.5rem;
}
.meeting-badge {
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
}
.badge-live {
background: var(--bp-danger);
color: white;
animation: pulse 2s infinite;
}
.badge-scheduled {
background: var(--bp-info);
color: white;
}
.badge-ended {
background: var(--bp-border);
color: var(--bp-text-muted);
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
/* Video Container */
.video-container {
background: #1a1a1a;
border-radius: 12px;
overflow: hidden;
position: relative;
aspect-ratio: 16/9;
margin-bottom: 1.5rem;
}
.video-container iframe {
width: 100%;
height: 100%;
border: none;
}
.video-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #888;
}
.video-placeholder svg {
width: 64px;
height: 64px;
margin-bottom: 1rem;
opacity: 0.5;
}
/* Meeting Controls */
.meeting-controls {
display: flex;
justify-content: center;
gap: 1rem;
margin-bottom: 1.5rem;
}
.control-btn {
width: 56px;
height: 56px;
border-radius: 50%;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.control-btn svg {
width: 24px;
height: 24px;
}
.control-btn.active {
background: var(--bp-primary);
color: white;
}
.control-btn.inactive {
background: var(--bp-bg);
color: var(--bp-text);
}
.control-btn.danger {
background: var(--bp-danger);
color: white;
}
.control-btn:hover {
transform: scale(1.1);
}
/* Participants Panel */
.participants-panel {
background: var(--bp-surface);
border-radius: 12px;
border: 1px solid var(--bp-border);
padding: 1rem;
max-height: 400px;
overflow-y: auto;
}
.participant-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
border-radius: 8px;
margin-bottom: 0.5rem;
}
.participant-item:hover {
background: var(--bp-bg);
}
.participant-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--bp-primary-soft);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
color: var(--bp-primary);
}
.participant-info {
flex: 1;
}
.participant-name {
font-weight: 600;
font-size: 0.875rem;
}
.participant-role {
font-size: 0.75rem;
color: var(--bp-text-muted);
}
.participant-status {
display: flex;
gap: 0.5rem;
}
.status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-indicator.mic-on { background: var(--bp-accent); }
.status-indicator.mic-off { background: var(--bp-danger); }
.status-indicator.video-on { background: var(--bp-accent); }
.status-indicator.video-off { background: var(--bp-danger); }
/* Chat Panel */
.chat-panel {
background: var(--bp-surface);
border-radius: 12px;
border: 1px solid var(--bp-border);
display: flex;
flex-direction: column;
height: 400px;
}
.chat-header {
padding: 1rem;
border-bottom: 1px solid var(--bp-border);
font-weight: 600;
}
.chat-messages {
flex: 1;
padding: 1rem;
overflow-y: auto;
}
.chat-message {
margin-bottom: 1rem;
}
.chat-message-header {
display: flex;
gap: 0.5rem;
align-items: baseline;
margin-bottom: 0.25rem;
}
.chat-message-sender {
font-weight: 600;
font-size: 0.875rem;
}
.chat-message-time {
font-size: 0.75rem;
color: var(--bp-text-muted);
}
.chat-message-content {
font-size: 0.875rem;
line-height: 1.4;
}
.chat-input-area {
padding: 1rem;
border-top: 1px solid var(--bp-border);
display: flex;
gap: 0.5rem;
}
.chat-input {
flex: 1;
padding: 0.75rem;
border: 1px solid var(--bp-border);
border-radius: 8px;
font-size: 0.875rem;
outline: none;
}
.chat-input:focus {
border-color: var(--bp-primary);
}
/* Breakout Rooms */
.breakout-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
}
.breakout-room {
background: var(--bp-surface);
border: 1px solid var(--bp-border);
border-radius: 10px;
padding: 1rem;
}
.breakout-room-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.breakout-room-title {
font-weight: 600;
}
.breakout-room-count {
font-size: 0.75rem;
color: var(--bp-text-muted);
background: var(--bp-bg);
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.breakout-participants {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.breakout-participant {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--bp-bg);
border-radius: 20px;
font-size: 0.75rem;
}
/* Recording Panel */
.recording-panel {
background: var(--bp-surface);
border-radius: 12px;
border: 1px solid var(--bp-border);
padding: 1.5rem;
}
.recording-status {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
.recording-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--bp-danger);
animation: blink 1s infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.recording-time {
font-family: monospace;
font-size: 1.25rem;
font-weight: 600;
}
.recording-list {
margin-top: 1rem;
}
.recording-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem;
border-bottom: 1px solid var(--bp-border);
}
.recording-item:last-child {
border-bottom: none;
}
.recording-info {
flex: 1;
}
.recording-name {
font-weight: 600;
font-size: 0.875rem;
}
.recording-meta {
font-size: 0.75rem;
color: var(--bp-text-muted);
}
/* Form Elements */
.form-group {
margin-bottom: 1rem;
}
.form-label {
display: block;
font-weight: 600;
margin-bottom: 0.5rem;
font-size: 0.875rem;
}
.form-input, .form-select, .form-textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--bp-border);
border-radius: 8px;
font-size: 0.875rem;
background: var(--bp-surface);
outline: none;
transition: border-color 0.2s;
}
.form-input:focus, .form-select:focus, .form-textarea:focus {
border-color: var(--bp-primary);
}
.form-textarea {
resize: vertical;
min-height: 100px;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: all 0.3s;
}
.modal-overlay.active {
opacity: 1;
visibility: visible;
}
.modal {
background: var(--bp-surface);
border-radius: 12px;
width: 90%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
transform: translateY(20px);
transition: transform 0.3s;
}
.modal-overlay.active .modal {
transform: translateY(0);
}
.modal-header {
padding: 1.5rem;
border-bottom: 1px solid var(--bp-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-title {
font-size: 1.25rem;
font-weight: 700;
}
.modal-close {
background: none;
border: none;
cursor: pointer;
color: var(--bp-text-muted);
padding: 0.5rem;
}
.modal-close:hover {
color: var(--bp-text);
}
.modal-body {
padding: 1.5rem;
}
.modal-footer {
padding: 1rem 1.5rem;
border-top: 1px solid var(--bp-border);
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
/* Tabs */
.tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
border-bottom: 1px solid var(--bp-border);
padding-bottom: 0.5rem;
}
.tab {
padding: 0.75rem 1.25rem;
border-radius: 8px 8px 0 0;
border: none;
background: transparent;
cursor: pointer;
font-weight: 600;
color: var(--bp-text-muted);
transition: all 0.2s;
}
.tab:hover {
color: var(--bp-text);
background: var(--bp-bg);
}
.tab.active {
color: var(--bp-primary);
background: var(--bp-primary-soft);
}
/* Training Card */
.training-card {
background: var(--bp-surface);
border: 1px solid var(--bp-border);
border-radius: 12px;
overflow: hidden;
}
.training-card-header {
padding: 1rem 1.5rem;
background: linear-gradient(135deg, var(--bp-primary), #8B2E2E);
color: white;
}
.training-card-title {
font-size: 1.125rem;
font-weight: 700;
margin-bottom: 0.25rem;
}
.training-card-subtitle {
font-size: 0.875rem;
opacity: 0.9;
}
.training-card-body {
padding: 1.5rem;
}
.training-card-footer {
padding: 1rem 1.5rem;
border-top: 1px solid var(--bp-border);
display: flex;
justify-content: space-between;
align-items: center;
}
/* Quick Action Buttons */
.quick-actions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.quick-action {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1.5rem;
background: var(--bp-surface);
border: 1px solid var(--bp-border);
border-radius: 12px;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
color: var(--bp-text);
}
.quick-action:hover {
border-color: var(--bp-primary);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(108, 27, 27, 0.1);
}
.quick-action-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.quick-action-icon svg {
width: 24px;
height: 24px;
}
.quick-action-label {
font-weight: 600;
font-size: 0.875rem;
text-align: center;
}
/* Responsive */
@media (max-width: 768px) {
.sidebar {
display: none;
}
.main-content {
padding: 1rem;
}
.dashboard-grid {
grid-template-columns: 1fr;
}
.meeting-controls {
flex-wrap: wrap;
}
}
"""

View File

@@ -0,0 +1,116 @@
"""
Meetings Module - Templates and Icons
Base templates and SVG icons for the Meetings frontend
"""
from .styles import BREAKPILOT_STYLES
# ============================================
# SVG Icons
# ============================================
ICONS = {
"video": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>',
"video_off": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 16v1a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2m5.66 0H14a2 2 0 0 1 2 2v3.34l1 1L23 7v10"/><line x1="1" y1="1" x2="23" y2="23"/></svg>',
"mic": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>',
"mic_off": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="1" y1="1" x2="23" y2="23"/><path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"/><path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>',
"screen_share": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 3H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-3"/><polyline points="8 21 12 17 16 21"/><line x1="12" y1="12" x2="12" y2="17"/><path d="M17 8V3h5"/><path d="M22 3l-7 7"/></svg>',
"chat": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>',
"users": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>',
"calendar": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>',
"record": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>',
"phone_off": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7 2 2 0 0 1 1.72 2v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.42 19.42 0 0 1-3.33-2.67m-2.67-3.34a19.79 19.79 0 0 1-3.07-8.63A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91"/><line x1="23" y1="1" x2="1" y2="23"/></svg>',
"settings": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>',
"grid": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>',
"plus": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>',
"download": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>',
"play": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>',
"trash": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>',
"link": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>',
"copy": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>',
"clock": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>',
"home": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>',
"graduation": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 10v6M2 10l10-5 10 5-10 5z"/><path d="M6 12v5c0 2 2 3 6 3s6-1 6-3v-5"/></svg>',
"external": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>',
"file_text": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>',
}
# ============================================
# Sidebar Component
# ============================================
def render_sidebar(active_page: str = "dashboard") -> str:
"""Render the meetings sidebar navigation"""
nav_items = [
{"id": "dashboard", "label": "Dashboard", "icon": "home", "href": "/meetings"},
{"id": "active", "label": "Aktive Meetings", "icon": "video", "href": "/meetings/active"},
{"id": "schedule", "label": "Termine", "icon": "calendar", "href": "/meetings/schedule"},
{"id": "trainings", "label": "Schulungen", "icon": "graduation", "href": "/meetings/trainings"},
{"id": "recordings", "label": "Aufzeichnungen", "icon": "record", "href": "/meetings/recordings"},
{"id": "breakout", "label": "Breakout-Rooms", "icon": "grid", "href": "/meetings/breakout"},
]
nav_html = ""
for item in nav_items:
active_class = "active" if item["id"] == active_page else ""
nav_html += f'''
<a href="{item['href']}" class="nav-item {active_class}">
{ICONS[item['icon']]}
<span>{item['label']}</span>
</a>
'''
return f'''
<aside class="sidebar">
<div class="logo">
<div class="logo-icon">BP</div>
<span class="logo-text">BreakPilot Meet</span>
</div>
<nav class="nav-section">
<div class="nav-section-title">Navigation</div>
{nav_html}
</nav>
<nav class="nav-section" style="margin-top: auto;">
<div class="nav-section-title">Links</div>
<a href="/studio" class="nav-item">
{ICONS['external']}
<span>Zurück zum Studio</span>
</a>
<a href="/school" class="nav-item">
{ICONS['users']}
<span>Schulverwaltung</span>
</a>
</nav>
</aside>
'''
# ============================================
# Page Templates
# ============================================
def render_base_page(title: str, content: str, active_page: str = "dashboard") -> str:
"""Render the base page template"""
return f'''
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>BreakPilot Meet {title}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>{BREAKPILOT_STYLES}</style>
</head>
<body>
<div class="app-container">
{render_sidebar(active_page)}
<main class="main-content">
{content}
</main>
</div>
</body>
</html>
'''