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>
688 lines
18 KiB
Python
688 lines
18 KiB
Python
"""
|
|
BreakPilot Studio - Jitsi Videokonferenz Modul
|
|
|
|
Funktionen:
|
|
- Elterngespraeche (mit Lobby und Passwort)
|
|
- Klassenkonferenzen
|
|
- Schulungen (mit Aufzeichnung)
|
|
- Schnelle Meetings
|
|
|
|
Nutzt die Jitsi-API unter /api/meetings/*
|
|
"""
|
|
|
|
|
|
class JitsiModule:
|
|
"""Jitsi Videokonferenz Modul fuer BreakPilot Studio."""
|
|
|
|
@staticmethod
|
|
def get_css() -> str:
|
|
"""CSS fuer das Jitsi-Modul."""
|
|
return """
|
|
/* ==========================================
|
|
JITSI MODULE STYLES
|
|
========================================== */
|
|
|
|
/* Panel - hidden by default */
|
|
.panel-jitsi {
|
|
display: none;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
background: var(--bp-bg);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.panel-jitsi.active {
|
|
display: flex;
|
|
}
|
|
|
|
.jitsi-container {
|
|
padding: 24px;
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.jitsi-header {
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
.jitsi-title {
|
|
font-size: 24px;
|
|
font-weight: 700;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.jitsi-subtitle {
|
|
color: var(--bp-text-muted);
|
|
font-size: 14px;
|
|
}
|
|
|
|
/* Meeting Type Cards */
|
|
.meeting-types {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
|
gap: 20px;
|
|
margin-bottom: 32px;
|
|
}
|
|
|
|
.meeting-type-card {
|
|
background: var(--bp-surface-elevated);
|
|
border: 1px solid var(--bp-border);
|
|
border-radius: 12px;
|
|
padding: 24px;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.meeting-type-card:hover {
|
|
border-color: var(--bp-primary);
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
}
|
|
|
|
.meeting-type-icon {
|
|
font-size: 32px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.meeting-type-title {
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.meeting-type-desc {
|
|
font-size: 13px;
|
|
color: var(--bp-text-muted);
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.meeting-type-features {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
}
|
|
|
|
.meeting-feature {
|
|
font-size: 11px;
|
|
padding: 4px 8px;
|
|
border-radius: 4px;
|
|
background: var(--bp-bg);
|
|
color: var(--bp-text-muted);
|
|
}
|
|
|
|
.meeting-feature.highlight {
|
|
background: var(--bp-accent-soft);
|
|
color: var(--bp-accent);
|
|
}
|
|
|
|
/* Active Meetings */
|
|
.active-meetings {
|
|
margin-bottom: 32px;
|
|
}
|
|
|
|
.section-title {
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
margin-bottom: 16px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.section-badge {
|
|
font-size: 12px;
|
|
padding: 2px 8px;
|
|
border-radius: 999px;
|
|
background: var(--bp-accent);
|
|
color: white;
|
|
}
|
|
|
|
.meetings-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}
|
|
|
|
.meeting-item {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
background: var(--bp-surface-elevated);
|
|
border: 1px solid var(--bp-border);
|
|
border-radius: 8px;
|
|
padding: 16px;
|
|
}
|
|
|
|
.meeting-info {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
}
|
|
|
|
.meeting-status {
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 50%;
|
|
background: var(--bp-success);
|
|
animation: pulse 2s infinite;
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.5; }
|
|
}
|
|
|
|
.meeting-details h4 {
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.meeting-meta {
|
|
font-size: 12px;
|
|
color: var(--bp-text-muted);
|
|
}
|
|
|
|
.meeting-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
|
|
/* Create Meeting Form */
|
|
.create-meeting-form {
|
|
background: var(--bp-surface-elevated);
|
|
border: 1px solid var(--bp-border);
|
|
border-radius: 12px;
|
|
padding: 24px;
|
|
}
|
|
|
|
.form-row {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 16px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
@media (max-width: 600px) {
|
|
.form-row {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
|
|
/* Jitsi Embed */
|
|
.jitsi-embed-container {
|
|
display: none;
|
|
position: fixed;
|
|
top: 56px;
|
|
left: var(--sidebar-width);
|
|
right: 0;
|
|
bottom: 0;
|
|
background: #000;
|
|
z-index: 90;
|
|
}
|
|
|
|
.jitsi-embed-container.active {
|
|
display: block;
|
|
}
|
|
|
|
.jitsi-embed-header {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
height: 50px;
|
|
background: var(--bp-surface);
|
|
border-bottom: 1px solid var(--bp-border);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0 16px;
|
|
z-index: 10;
|
|
}
|
|
|
|
.jitsi-embed-title {
|
|
font-weight: 600;
|
|
}
|
|
|
|
.jitsi-iframe {
|
|
width: 100%;
|
|
height: calc(100% - 50px);
|
|
margin-top: 50px;
|
|
border: none;
|
|
}
|
|
|
|
/* Empty State */
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 48px;
|
|
color: var(--bp-text-muted);
|
|
}
|
|
|
|
.empty-state-icon {
|
|
font-size: 48px;
|
|
margin-bottom: 16px;
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.empty-state-text {
|
|
font-size: 14px;
|
|
}
|
|
"""
|
|
|
|
@staticmethod
|
|
def get_html() -> str:
|
|
"""HTML fuer das Jitsi-Modul."""
|
|
return """
|
|
<!-- Jitsi Panel -->
|
|
<div id="panel-jitsi" class="panel-jitsi" style="display: none;">
|
|
<div class="jitsi-container" id="jitsi-module">
|
|
<div class="jitsi-header">
|
|
<h1 class="jitsi-title">🎥 Videokonferenzen</h1>
|
|
<p class="jitsi-subtitle">Sichere Videokonferenzen fuer Elterngespraeche, Klassenkonferenzen und Schulungen</p>
|
|
</div>
|
|
|
|
<!-- Meeting Types -->
|
|
<div class="meeting-types">
|
|
<div class="meeting-type-card" onclick="showParentMeetingForm()">
|
|
<div class="meeting-type-icon">👪</div>
|
|
<div class="meeting-type-title">Elterngespraech</div>
|
|
<div class="meeting-type-desc">Vertrauliche Gespraeche mit Eltern - mit Lobby und Passwortschutz</div>
|
|
<div class="meeting-type-features">
|
|
<span class="meeting-feature highlight">Lobby</span>
|
|
<span class="meeting-feature highlight">Passwort</span>
|
|
<span class="meeting-feature">30 Min</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="meeting-type-card" onclick="showClassMeetingForm()">
|
|
<div class="meeting-type-icon">🏫</div>
|
|
<div class="meeting-type-title">Klassenkonferenz</div>
|
|
<div class="meeting-type-desc">Virtuelle Klassen-Meetings mit allen Schuelern</div>
|
|
<div class="meeting-type-features">
|
|
<span class="meeting-feature">Screen Sharing</span>
|
|
<span class="meeting-feature">Chat</span>
|
|
<span class="meeting-feature">45 Min</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="meeting-type-card" onclick="showTrainingForm()">
|
|
<div class="meeting-type-icon">🎓</div>
|
|
<div class="meeting-type-title">Schulung</div>
|
|
<div class="meeting-type-desc">Fortbildungen und Workshops mit Aufzeichnung</div>
|
|
<div class="meeting-type-features">
|
|
<span class="meeting-feature highlight">Aufzeichnung</span>
|
|
<span class="meeting-feature">Praesentation</span>
|
|
<span class="meeting-feature">90 Min</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="meeting-type-card" onclick="startQuickMeeting()">
|
|
<div class="meeting-type-icon">⚡</div>
|
|
<div class="meeting-type-title">Schnelles Meeting</div>
|
|
<div class="meeting-type-desc">Sofort starten ohne Konfiguration</div>
|
|
<div class="meeting-type-features">
|
|
<span class="meeting-feature">Sofort</span>
|
|
<span class="meeting-feature">Keine Anmeldung</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Active Meetings -->
|
|
<div class="active-meetings">
|
|
<h2 class="section-title">
|
|
Aktive Meetings
|
|
<span class="section-badge" id="active-meetings-count">0</span>
|
|
</h2>
|
|
<div class="meetings-list" id="meetings-list">
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">📷</div>
|
|
<p class="empty-state-text">Keine aktiven Meetings. Starten Sie ein neues Meeting oben.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Create Parent Meeting Form (hidden by default) -->
|
|
<div class="create-meeting-form hidden" id="parent-meeting-form">
|
|
<h3 style="margin-bottom: 16px;">Elterngespraech planen</h3>
|
|
<form onsubmit="createParentMeeting(event)">
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label class="form-label">Name des Schuelers / der Schuelerin</label>
|
|
<input type="text" class="form-input" id="pm-student-name" placeholder="Max Mustermann" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Name der Eltern</label>
|
|
<input type="text" class="form-input" id="pm-parent-name" placeholder="Familie Mustermann">
|
|
</div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label class="form-label">Datum</label>
|
|
<input type="date" class="form-input" id="pm-date" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Uhrzeit</label>
|
|
<input type="time" class="form-input" id="pm-time" value="14:00" required>
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Anlass / Thema</label>
|
|
<input type="text" class="form-input" id="pm-topic" placeholder="z.B. Halbjahresgespraech, Leistungsstand">
|
|
</div>
|
|
<div style="display: flex; gap: 12px; margin-top: 16px;">
|
|
<button type="submit" class="btn btn-primary">Meeting erstellen</button>
|
|
<button type="button" class="btn btn-ghost" onclick="hideParentMeetingForm()">Abbrechen</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Jitsi Embed Container -->
|
|
<div class="jitsi-embed-container" id="jitsi-embed">
|
|
<div class="jitsi-embed-header">
|
|
<span class="jitsi-embed-title" id="jitsi-embed-title">Meeting</span>
|
|
<button class="btn btn-sm btn-ghost" onclick="closeJitsiEmbed()">✕ Schliessen</button>
|
|
</div>
|
|
<iframe id="jitsi-iframe" class="jitsi-iframe" allow="camera; microphone; display-capture; autoplay; clipboard-write"></iframe>
|
|
</div>
|
|
</div><!-- /panel-jitsi -->
|
|
"""
|
|
|
|
@staticmethod
|
|
def get_js() -> str:
|
|
"""JavaScript fuer das Jitsi-Modul."""
|
|
return """
|
|
// ==========================================
|
|
// JITSI MODULE
|
|
// ==========================================
|
|
|
|
console.log('Jitsi Module loaded');
|
|
|
|
const JITSI_BASE_URL = 'https://meet.jit.si'; // oder eigener Server
|
|
|
|
// ==========================================
|
|
// MODULE LOADER
|
|
// ==========================================
|
|
|
|
function loadJitsiModule() {
|
|
console.log('Initializing Jitsi Module');
|
|
loadActiveMeetings();
|
|
|
|
// Set default date to today
|
|
const dateInput = document.getElementById('pm-date');
|
|
if (dateInput) {
|
|
dateInput.valueAsDate = new Date();
|
|
}
|
|
}
|
|
|
|
// ==========================================
|
|
// MEETING FORMS
|
|
// ==========================================
|
|
|
|
function showParentMeetingForm() {
|
|
document.getElementById('parent-meeting-form').classList.remove('hidden');
|
|
document.getElementById('pm-student-name').focus();
|
|
}
|
|
|
|
function hideParentMeetingForm() {
|
|
document.getElementById('parent-meeting-form').classList.add('hidden');
|
|
}
|
|
|
|
function showClassMeetingForm() {
|
|
const className = prompt('Klassenname (z.B. 7a):');
|
|
if (!className) return;
|
|
|
|
const roomName = 'klasse-' + className.toLowerCase().replace(/[^a-z0-9]/g, '') + '-' + Date.now();
|
|
createAndOpenMeeting(roomName, 'Klassenkonferenz ' + className, {
|
|
startWithAudioMuted: true,
|
|
startWithVideoMuted: false
|
|
});
|
|
}
|
|
|
|
function showTrainingForm() {
|
|
const topic = prompt('Thema der Schulung:');
|
|
if (!topic) return;
|
|
|
|
const roomName = 'schulung-' + topic.toLowerCase().replace(/[^a-z0-9]/g, '-').substring(0, 30) + '-' + Date.now();
|
|
createAndOpenMeeting(roomName, 'Schulung: ' + topic, {
|
|
startWithAudioMuted: false,
|
|
startWithVideoMuted: false,
|
|
enableRecording: true
|
|
});
|
|
}
|
|
|
|
function startQuickMeeting() {
|
|
const roomName = 'bp-quick-' + Date.now();
|
|
createAndOpenMeeting(roomName, 'Schnelles Meeting', {
|
|
startWithAudioMuted: false,
|
|
startWithVideoMuted: false
|
|
});
|
|
}
|
|
|
|
// ==========================================
|
|
// CREATE MEETINGS
|
|
// ==========================================
|
|
|
|
function createParentMeeting(event) {
|
|
event.preventDefault();
|
|
|
|
const studentName = document.getElementById('pm-student-name').value;
|
|
const parentName = document.getElementById('pm-parent-name').value;
|
|
const date = document.getElementById('pm-date').value;
|
|
const time = document.getElementById('pm-time').value;
|
|
const topic = document.getElementById('pm-topic').value;
|
|
|
|
// Generate room name
|
|
const sanitizedName = studentName.toLowerCase()
|
|
.replace(/ae/g, 'ae').replace(/oe/g, 'oe').replace(/ue/g, 'ue')
|
|
.replace(/[^a-z0-9]/g, '-');
|
|
const roomName = 'elterngespraech-' + sanitizedName + '-' + date.replace(/-/g, '');
|
|
|
|
// Generate password
|
|
const password = Math.random().toString(36).substring(2, 10);
|
|
|
|
console.log('Creating parent meeting:', { roomName, studentName, parentName, date, time, topic });
|
|
|
|
// Call API to create meeting
|
|
fetch('/api/meetings/parent-teacher', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': 'Bearer ' + (localStorage.getItem('bp-token') || '')
|
|
},
|
|
body: JSON.stringify({
|
|
student_name: studentName,
|
|
parent_name: parentName,
|
|
scheduled_date: date,
|
|
scheduled_time: time,
|
|
topic: topic,
|
|
password: password
|
|
})
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.meeting_url || data.room_name) {
|
|
hideParentMeetingForm();
|
|
showMeetingCreatedDialog({
|
|
roomName: data.room_name || roomName,
|
|
meetingUrl: data.meeting_url || JITSI_BASE_URL + '/' + roomName,
|
|
password: data.password || password,
|
|
studentName: studentName,
|
|
date: date,
|
|
time: time
|
|
});
|
|
loadActiveMeetings();
|
|
} else {
|
|
alert('Fehler beim Erstellen: ' + (data.error || 'Unbekannter Fehler'));
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.error('Error creating meeting:', err);
|
|
// Fallback: Open directly
|
|
createAndOpenMeeting(roomName, 'Elterngespraech: ' + studentName, {
|
|
startWithAudioMuted: false,
|
|
startWithVideoMuted: false,
|
|
password: password,
|
|
lobbyEnabled: true
|
|
});
|
|
});
|
|
}
|
|
|
|
function createAndOpenMeeting(roomName, title, options = {}) {
|
|
console.log('Creating meeting:', roomName, title, options);
|
|
|
|
const meetingUrl = JITSI_BASE_URL + '/' + roomName;
|
|
|
|
// Build config params
|
|
let configParams = [];
|
|
if (options.startWithAudioMuted) configParams.push('config.startWithAudioMuted=true');
|
|
if (options.startWithVideoMuted) configParams.push('config.startWithVideoMuted=true');
|
|
if (options.password) configParams.push('config.prejoinPageEnabled=true');
|
|
|
|
const fullUrl = meetingUrl + (configParams.length ? '#' + configParams.join('&') : '');
|
|
|
|
// Open in embed
|
|
openJitsiEmbed(fullUrl, title);
|
|
}
|
|
|
|
// ==========================================
|
|
// JITSI EMBED
|
|
// ==========================================
|
|
|
|
function openJitsiEmbed(url, title) {
|
|
const container = document.getElementById('jitsi-embed');
|
|
const iframe = document.getElementById('jitsi-iframe');
|
|
const titleEl = document.getElementById('jitsi-embed-title');
|
|
|
|
titleEl.textContent = title || 'Meeting';
|
|
iframe.src = url;
|
|
container.classList.add('active');
|
|
|
|
console.log('Opened Jitsi embed:', url);
|
|
}
|
|
|
|
function closeJitsiEmbed() {
|
|
const container = document.getElementById('jitsi-embed');
|
|
const iframe = document.getElementById('jitsi-iframe');
|
|
|
|
iframe.src = '';
|
|
container.classList.remove('active');
|
|
}
|
|
|
|
// ==========================================
|
|
// ACTIVE MEETINGS
|
|
// ==========================================
|
|
|
|
function loadActiveMeetings() {
|
|
fetch('/api/meetings/active', {
|
|
headers: {
|
|
'Authorization': 'Bearer ' + (localStorage.getItem('bp-token') || '')
|
|
}
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
const list = document.getElementById('meetings-list');
|
|
const countBadge = document.getElementById('active-meetings-count');
|
|
|
|
if (data.meetings && data.meetings.length > 0) {
|
|
countBadge.textContent = data.meetings.length;
|
|
list.innerHTML = data.meetings.map(meeting => `
|
|
<div class="meeting-item">
|
|
<div class="meeting-info">
|
|
<div class="meeting-status"></div>
|
|
<div class="meeting-details">
|
|
<h4>${escapeHtml(meeting.title || meeting.room_name)}</h4>
|
|
<div class="meeting-meta">${meeting.participants || 0} Teilnehmer | Gestartet ${formatTime(meeting.started_at)}</div>
|
|
</div>
|
|
</div>
|
|
<div class="meeting-actions">
|
|
<button class="btn btn-sm btn-primary" onclick="joinMeeting('${meeting.room_name}', '${escapeHtml(meeting.title || '')}')">Beitreten</button>
|
|
<button class="btn btn-sm btn-ghost" onclick="copyMeetingLink('${meeting.room_name}')">Link kopieren</button>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
} else {
|
|
countBadge.textContent = '0';
|
|
list.innerHTML = `
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">📷</div>
|
|
<p class="empty-state-text">Keine aktiven Meetings. Starten Sie ein neues Meeting oben.</p>
|
|
</div>
|
|
`;
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.log('Could not load active meetings:', err);
|
|
});
|
|
}
|
|
|
|
function joinMeeting(roomName, title) {
|
|
const url = JITSI_BASE_URL + '/' + roomName;
|
|
openJitsiEmbed(url, title || roomName);
|
|
}
|
|
|
|
function copyMeetingLink(roomName) {
|
|
const url = JITSI_BASE_URL + '/' + roomName;
|
|
navigator.clipboard.writeText(url).then(() => {
|
|
alert('Link kopiert: ' + url);
|
|
});
|
|
}
|
|
|
|
// ==========================================
|
|
// DIALOGS
|
|
// ==========================================
|
|
|
|
function showMeetingCreatedDialog(info) {
|
|
const message = `
|
|
Elterngespraech erstellt!
|
|
|
|
Schueler: ${info.studentName}
|
|
Datum: ${info.date} um ${info.time}
|
|
Passwort: ${info.password}
|
|
|
|
Link fuer Eltern:
|
|
${info.meetingUrl}
|
|
|
|
Der Link wurde in die Zwischenablage kopiert.
|
|
`;
|
|
|
|
navigator.clipboard.writeText(info.meetingUrl + '\\nPasswort: ' + info.password);
|
|
alert(message);
|
|
|
|
// Ask to open now
|
|
if (confirm('Moechten Sie das Meeting jetzt oeffnen?')) {
|
|
openJitsiEmbed(info.meetingUrl, 'Elterngespraech: ' + info.studentName);
|
|
}
|
|
}
|
|
|
|
// ==========================================
|
|
// HELPERS
|
|
// ==========================================
|
|
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function formatTime(isoString) {
|
|
if (!isoString) return '';
|
|
const date = new Date(isoString);
|
|
return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
|
|
// Auto-refresh active meetings every 30 seconds
|
|
setInterval(loadActiveMeetings, 30000);
|
|
"""
|
|
|
|
|
|
def get_jitsi_module() -> dict:
|
|
"""Gibt das komplette Jitsi-Modul als Dictionary zurueck."""
|
|
module = JitsiModule()
|
|
return {
|
|
'css': module.get_css(),
|
|
'html': module.get_html(),
|
|
'js': module.get_js(),
|
|
'init_function': 'loadJitsiModule'
|
|
}
|