Files
jitsi-launch/launch.html
T

2466 lines
74 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Jitsi Meeting</title>
<link rel='shortlink' href='https://meet.bnvb.eu/' />
<link href="https://meet.bnvb.eu/assets/favicon.ico" rel="alternate icon">
<link href="https://meet.bnvb.eu/assets/apple-touch-icon.png" rel="apple-touch-icon" sizes="180x180">
<link href="https://meet.bnvb.eu/assets/favicon.svg" rel="icon" type="image/svg+xml">
<style>
:root {
color-scheme: light;
}
:root.dark {
color-scheme: dark;
}
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f5f5f5;
color: #222;
}
.container {
display: flex;
flex-direction: column;
align-items: center;
width: min(700px, calc(100% - 32px));
}
.navbar {
position: fixed;
top: 16px;
left: 2%;
width: 96%;
box-sizing: border-box;
z-index: 100;
display: flex;
align-items: center;
gap: 8px;
padding: 15px;
background: rgba(255, 255, 255, 0.2);
-webkit-backdrop-filter: blur(7px);
backdrop-filter: blur(7px);
border-radius: 14px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.30), 0 12px 40px rgba(0, 0, 0, 0.45);
}
.logo-bar {
display: flex;
align-items: center;
flex: 0 1 auto;
min-width: 0;
}
.logo-bar img {
display: block;
height: 60px;
max-width: 100%;
width: auto;
}
.nav-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
margin-left: auto;
}
/* Hamburger: nur im Phone-Portrait sichtbar.
* "button.nav-toggle" ist spezifischer als ".button",
* sonst überschreibt display: inline-block diese Regel. */
button.nav-toggle {
display: none;
}
/* Enger als der globale 6px-Margin der Buttons */
.navbar .button {
margin: 0;
}
.lang-menu {
position: relative;
}
.lang-dropdown {
display: none;
position: absolute;
right: 0;
top: 100%;
min-width: 150px;
margin-top: 6px;
padding: 6px;
background: white;
border-radius: 10px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.lang-dropdown.open {
display: block;
}
.lang-dropdown button {
display: block;
width: 100%;
margin: 0;
padding: 10px 14px;
border: 0;
border-radius: 6px;
background: none;
text-align: left;
font-size: 14px;
font-family: inherit;
cursor: pointer;
}
.lang-dropdown button:hover {
background: #f0f0f0;
}
.lang-dropdown button.active {
font-weight: 600;
background: #f0f8ff;
}
.lang-flag {
width: 20px;
height: 13px;
margin-right: 6px;
border-radius: 2px;
vertical-align: -2px;
}
.card {
box-sizing: border-box;
width: min(600px, 100%);
padding: 32px;
background: rgba(255, 255, 255, 0.2);
-webkit-backdrop-filter: blur(7px);
backdrop-filter: blur(7px);
border-radius: 14px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.30), 0 12px 40px rgba(0, 0, 0, 0.45);
text-align: center;
}
h1 {
margin-top: 0;
}
p {
color: #555;
}
button, .button {
display: inline-block;
border: 0;
border-radius: 8px;
padding: 12px 20px;
margin: 6px;
font-size: 16px;
font-family: inherit;
cursor: pointer;
text-decoration: none;
}
.primary {
background: #1f73e8;
color: white;
}
.secondary {
background: #e9e9e9;
color: #222;
}
.hidden {
display: none !important;
}
.status {
margin-top: 24px;
padding: 16px;
border-radius: 8px;
background: #f0f0f0;
}
.spinner {
display: inline-block;
width: 16px;
height: 16px;
margin-right: 8px;
border: 2px solid #ccc;
border-top-color: #333;
border-radius: 50%;
animation: spin 0.7s linear infinite;
vertical-align: -3px;
}
.download {
margin-top: 24px;
padding: 20px;
border-radius: 8px;
background: #f5f5f5;
}
.room {
font-family: monospace;
word-break: break-all;
}
.error {
padding: 16px;
border-radius: 8px;
background: #fff0f0;
color: #900;
}
.form-group {
margin-bottom: 16px;
text-align: left;
}
.form-group label {
display: block;
margin-bottom: 4px;
font-weight: 500;
color: #333;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
box-sizing: border-box;
}
.form-group textarea {
min-height: 80px;
resize: vertical;
}
.form-actions {
margin-top: 24px;
display: flex;
gap: 12px;
justify-content: center;
flex-wrap: wrap;
}
.success-message {
margin-top: 24px;
padding: 16px;
border-radius: 8px;
background: #f0f8ff;
color: #0066cc;
}
.adhoc-result {
margin-top: 24px;
padding: 16px;
border-radius: 8px;
background: #f0fff4;
color: #1a7f37;
}
.adhoc-link {
margin-top: 12px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
flex-wrap: wrap;
}
.adhoc-link a {
font-family: monospace;
word-break: break-all;
color: #0066cc;
}
/*
* Verhindert abgeschnittene Inhalte, wenn die Karte
* höher als der Viewport ist ("center" schneidet oben ab).
*/
@supports (align-items: safe center) {
body {
align-items: safe center;
}
}
/*
* Schmale Bildschirme: Navbar umbrechen lassen,
* Abstände und Logo verkleinern.
*/
@media (max-width: 600px) {
body {
/* 130px räumen die fixe Navbar frei */
padding-top: 130px;
/* min-height gilt für die Content-Box: Navbar-Freiraum
* abziehen, sonst sitzt die Karte unter der Mitte */
min-height: calc(100vh - 130px);
}
.navbar {
top: 8px;
left: 4%;
width: 92%;
padding: 10px;
flex-wrap: wrap;
}
.logo-bar img {
height: 40px;
}
button, .button {
padding: 10px 14px;
font-size: 15px;
}
.card {
padding: 20px;
}
}
/* Sehr schmale Telefone: noch kompakter */
@media (max-width: 400px) {
.navbar {
left: 2%;
width: 96%;
padding: 8px;
gap: 6px;
}
.logo-bar img {
height: 32px;
}
.nav-actions {
gap: 6px;
}
.nav-actions .button {
padding: 8px 12px;
font-size: 14px;
}
}
/*
* Phone-Portrait: Navbar-Aktionen hinter dem
* Hamburger-Button als Dropdown anzeigen.
*/
@media (orientation: portrait) and (max-width: 600px) {
body {
/* Navbar ist hier einzeilig */
padding-top: 80px;
min-height: calc(100vh - 80px);
}
button.nav-toggle {
display: inline-flex;
}
/* ".navbar .button" setzt margin: 0 – deshalb
* höher spezifisch ans Ziel: Button nach rechts */
.navbar .nav-toggle {
margin-left: auto;
}
.nav-actions {
display: none;
position: absolute;
top: 100%;
right: 0;
margin: 8px 0 0;
padding: 8px;
min-width: 200px;
background: white;
border-radius: 10px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
flex-direction: column;
align-items: stretch;
}
.nav-actions.open {
display: flex;
}
/* Alle Panel-Buttons gleich breit, Text zentriert */
.nav-actions .button {
text-align: center;
}
/* Sprach-Button liegt im .lang-menu-Wrapper */
.nav-actions .lang-menu .button {
display: block;
width: 100%;
box-sizing: border-box;
}
:root.dark .nav-actions {
background: #242424;
}
}
/*
* Sicherheit: Hamburger im Landscape grundsätzlich
* ausblenden, falls orientation falsch gemeldet wird.
*/
@media (orientation: landscape) {
button.nav-toggle {
display: none;
}
}
/*
* Phone-Landscape: schmaler, hoher Karteninhalt –
* einzeilige Navbar (~106px) freihalten.
*/
@media (orientation: landscape) and (max-height: 500px) {
body {
padding-top: 120px;
/* min-height gilt für die Content-Box: Navbar-Freiraum
* abziehen, sonst sitzt die Karte unter der Mitte */
min-height: calc(100vh - 120px);
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/*
* Dark-Theme über die Klasse "dark" am html-Element.
* JS setzt sie anhand der gespeicherten Wahl oder der
* System-Einstellung (siehe detectTheme).
*/
:root.dark body {
background: #181818;
color: #eee;
}
:root.dark .navbar {
background: rgba(36, 36, 36, 0.72);
}
:root.dark .card {
background: rgba(36, 36, 36, 0.72);
}
:root.dark .lang-dropdown {
background: #242424;
}
:root.dark .lang-dropdown button:hover {
background: #303030;
}
:root.dark .lang-dropdown button.active {
background: #1a3a5a;
}
:root.dark p {
color: #bbb;
}
:root.dark .secondary {
background: #444;
color: #eee;
}
:root.dark .status,
:root.dark .download {
background: #303030;
}
:root.dark .spinner {
border-color: #555;
border-top-color: #ddd;
}
:root.dark .form-group label {
color: #ddd;
}
:root.dark .form-group input,
:root.dark .form-group textarea {
background: #303030;
border-color: #555;
color: #eee;
}
:root.dark .success-message {
background: #1a3a5a;
color: #99ccff;
}
:root.dark .adhoc-result {
background: #1c3527;
color: #7ee2a8;
}
:root.dark .logo-bar img {
filter: url(#logo-dark-tint);
}
:root.dark .adhoc-link a {
color: #99ccff;
}
</style>
</head>
<body>
<svg width="0" height="0" style="position: absolute;" aria-hidden="true">
<filter id="logo-dark-tint">
<feColorMatrix type="matrix" values="0 0 0 0 0.8667
0 0 0 0 0.8431
0 0 0 0 0.8235
0 0 0 1 0"/>
</filter>
</svg>
<nav class="navbar">
<div id="logoBar" class="logo-bar hidden">
<img id="logoImage" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" alt="Logo">
</div>
<button id="navToggle" class="button secondary nav-toggle" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="navActions" aria-label="Menu">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 7h16M4 12h16M4 17h16"/>
</svg>
</button>
<div id="navActions" class="nav-actions">
<a class="button secondary" href="https://www.bnvb-ingenieure.de/impressum" target="_blank" rel="noopener noreferrer" data-i18n="impressum">
Impressum
</a>
<a class="button secondary" href="https://git.bnvb.eu/stefan.tollkuehn/jitsi-launch" target="_blank" rel="noopener noreferrer" data-i18n="source">
Source
</a>
<div id="langMenu" class="lang-menu">
<button id="langButton" class="button secondary" type="button" aria-haspopup="true" aria-expanded="false">
<img id="langFlag" class="lang-flag" src="assets/uk.svg" alt="">
<span id="langLabel">English</span>
</button>
<div id="langDropdown" class="lang-dropdown">
<button type="button" data-lang="en"><img class="lang-flag" src="assets/uk.svg" alt="">English</button>
<button type="button" data-lang="de"><img class="lang-flag" src="assets/de.svg" alt="">Deutsch</button>
<button type="button" data-lang="fr"><img class="lang-flag" src="assets/fr.svg" alt="">Français</button>
<button type="button" data-lang="zh"><img class="lang-flag" src="assets/cn.svg" alt="">中文</button>
</div>
</div>
<button id="themeButton" class="button secondary" type="button" aria-label="Toggle dark mode">
<svg id="themeIconMoon" class="theme-icon" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>
</svg>
<svg id="themeIconSun" class="theme-icon hidden" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4"/>
<path d="M12 2v2m0 16v2M4.9 4.9l1.4 1.4m11.4 11.4 1.4 1.4M2 12h2m16 0h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>
</svg>
</button>
</div>
</nav>
<div class="container">
<div class="card">
<!-- NORMALER LAUNCHER -->
<div id="launcher" class="hidden">
<h1 data-i18n="launcher.title">Jitsi Meeting</h1>
<p>
<span data-i18n="launcher.meetingPrefix">
The meeting
</span>
<span class="room">
<strong id="roomName"></strong>
</span>
<span data-i18n="launcher.meetingSuffix">
will be opened.
</span>
</p>
<button id="openButton" class="button primary" type="button" data-i18n="launcher.openButton">
Open meeting
</button>
<button id="createNewFromLauncherButton" class="button secondary hidden" type="button" data-i18n="creator.createNewButton">
Create New Meeting
</button>
</div>
<!-- STARTVORGANG -->
<div id="starting" class="hidden">
<h1 data-i18n="starting.title">
Starting Jitsi Meet …
</h1>
<div class="status">
<span class="spinner"></span>
<span data-i18n="starting.status">
The Jitsi Meet desktop client is being opened.
</span>
</div>
</div>
<!-- FALLBACK -->
<div id="fallback" class="hidden">
<h1 data-i18n="fallback.title">
Jitsi Meet could not be opened
</h1>
<p data-i18n="fallback.description">
The Jitsi Meet desktop client may not have been started.
</p>
<button id="retryButton" class="button primary" type="button" data-i18n="fallback.retryButton">
Try again
</button>
<!-- Windows -->
<div id="windowsDownload" class="download hidden">
<h2 data-i18n="downloads.windows.title">
Jitsi Meet for Microsoft Windows
</h2>
<p data-i18n="downloads.windows.description">
Install the Jitsi Meet desktop client and then try opening the meeting again.
</p>
<a id="windowsLink" class="button secondary" target="_blank" rel="noopener noreferrer" data-i18n="downloads.windows.button">
Download Jitsi for Windows
</a>
</div>
<!-- macOS -->
<div id="macosDownload" class="download hidden">
<h2 data-i18n="downloads.macos.title">
Jitsi Meet for Apple macOS
</h2>
<p data-i18n="downloads.macos.description">
Install the Jitsi Meet desktop client and then try opening the meeting again.
</p>
<a id="macosLink" class="button secondary" target="_blank" rel="noopener noreferrer" data-i18n="downloads.macos.button">
Download Jitsi for macOS
</a>
</div>
<!-- Linux -->
<div id="linuxDownload" class="download hidden">
<h2 data-i18n="downloads.linux.title">
Jitsi Meet for Linux
</h2>
<p data-i18n="downloads.linux.description">
Install the Jitsi Meet desktop client and then try opening the meeting again.
</p>
<a id="linuxLink" class="button secondary" target="_blank" rel="noopener noreferrer" data-i18n="downloads.linux.button">
Download Jitsi for Linux
</a>
</div>
<!-- unknown -->
<div id="otherDownload" class="download hidden">
<h2 data-i18n="downloads.other.title">
Jitsi Meet Desktop
</h2>
<p data-i18n="downloads.other.description">
No desktop download is configured for your operating system.
</p>
</div>
<!-- Browser -->
<div class="download">
<h2 data-i18n="browser.title">
Join in browser
</h2>
<p data-i18n="browser.description">
You can also join the meeting directly in your browser.
</p>
<a id="browserLink" class="button secondary" data-i18n="browser.button">
Join in browser
</a>
</div>
</div>
<!-- FEHLER -->
<div id="invalidRoom" class="hidden">
<h1 id="missingRoomTitle" data-i18n="error.title">
No meeting specified
</h1>
<div id="missingRoomError" class="error">
<span data-i18n="error.description">
Please specify a meeting using the
</span>
<code>?room=...</code>
<span data-i18n="error.descriptionSuffix">
parameter.
</span>
</div>
<h1 id="invalidNameTitle" class="hidden" data-i18n="error.invalidTitle">
Invalid meeting name
</h1>
<div id="invalidNameError" class="error hidden">
<span data-i18n="error.invalidDescription">
Only letters, numbers and hyphens are allowed:
</span>
<br>
<span class="room">
<strong id="invalidRoomName"></strong>
</span>
</div>
<div style="margin-top: 24px;">
<button id="createNewMeetingButton" class="button primary hidden" type="button" data-i18n="creator.createNewButton">
Create New Meeting
</button>
</div>
</div>
<!-- MEETING CREATOR -->
<div id="meetingCreator" class="hidden">
<h1 data-i18n="creator.title">Create New Meeting</h1>
<div class="form-group">
<label for="meetingName" data-i18n="creator.meetingNameLabel">Meeting Name</label>
<input type="text" id="meetingName" placeholder="My Meeting" />
</div>
<div class="form-group">
<label for="meetingDescription" data-i18n="creator.meetingDescriptionLabel">Description</label>
<textarea id="meetingDescription" placeholder="Meeting description (optional)"></textarea>
</div>
<div class="form-group">
<label for="meetingStart" data-i18n="creator.meetingStartLabel">Start Date & Time</label>
<input type="datetime-local" id="meetingStart" step="900" />
</div>
<div class="form-group">
<label for="meetingEnd" data-i18n="creator.meetingEndLabel">End Date & Time</label>
<input type="datetime-local" id="meetingEnd" step="900" />
</div>
<div class="form-group">
<label for="meetingOrganizer" data-i18n="creator.meetingOrganizerLabel">Organizer Email</label>
<input type="email" id="meetingOrganizer" placeholder="organizer@example.com" />
</div>
<div class="form-actions">
<button id="createMeetingButton" class="button primary" type="button" data-i18n="creator.createButton">
Create Meeting
</button>
<button id="createAdhocButton" class="button secondary" type="button" data-i18n="creator.adhocButton">
Create Adhoc Link
</button>
<button id="cancelCreateButton" class="button secondary" type="button" data-i18n="creator.cancelButton">
Cancel
</button>
</div>
<div id="adhocResult" class="adhoc-result hidden">
<span data-i18n="creator.adhocLinkLabel">Your adhoc meeting link:</span>
<div class="adhoc-link">
<a id="adhocLink" target="_blank" rel="noopener noreferrer"></a>
<button id="copyAdhocLinkButton" class="button secondary" type="button" data-i18n="creator.copyLinkButton">Copy Link</button>
</div>
</div>
<div id="icsSuccess" class="success-message hidden">
<span data-i18n="creator.successMessage">Meeting created! Download your calendar file and open it to import the meeting into your personal calendar.</span>
<a id="downloadIcsLink" class="button primary" style="margin-top: 12px; display: inline-block;" data-i18n="creator.downloadButton">Download ICS File</a>
</div>
</div>
</div>
</div>
<script src="assets/ua-parser.min.js"></script>
<script>
"use strict";
/* ============================================================
* KONFIGURATION
* ============================================================
*/
const CONFIG = {
/*
* Your Jitsi Launcher domain
*/
jitsiLauncher: "meet.bnvb.eu",
/*
* Your Jitsi domain.
*/
jitsiHost: "meet.bnvb-ingenieure.de",
/*
* Timeout trying to start the jitsi-meet-electron client
*/
launchTimeout: 2000,
/*
* set wallpaper for webpage
*/
backgroundImage: "assets/background.png",
/*
* Set logo
*/
logo: "assets/logo.svg",
/*
* Download URLs for jitsi-meet-electron client
*/
downloads: {
windows: "https://github.com/jitsi/jitsi-meet-electron/releases",
macos: "https://github.com/jitsi/jitsi-meet-electron/releases",
linux: "https://github.com/jitsi/jitsi-meet-electron/releases"
}
};
/* ============================================================
* Translations
* ============================================================
*/
const TRANSLATIONS = {
en: {
pageTitle: "Jitsi Meeting",
impressum: "Legal Notice",
source: "Source",
launcher: {
title: "Jitsi Meeting",
meetingPrefix: "The meeting",
meetingSuffix: "will be joined.",
openButton: "Join meeting"
},
starting: {
title: "Starting Jitsi Meet …",
status: "The Jitsi Meet desktop client is being opened."
},
fallback: {
title: "Jitsi Meet could not be opened",
description: "The Jitsi Meet desktop client may not have been started.",
retryButton: "Try again"
},
downloads: {
windows: {
title: "Jitsi Meet for Windows",
description:
"Install the Jitsi Meet desktop client and then try opening the meeting again.",
button: "Download Jitsi for Windows"
},
macos: {
title: "Jitsi Meet for macOS",
description:
"Install the Jitsi Meet desktop client and then try opening the meeting again.",
button: "Download Jitsi for macOS"
},
linux: {
title: "Jitsi Meet for Linux",
description:
"Install the Jitsi Meet desktop client and then try opening the meeting again.",
button: "Download Jitsi for Linux"
},
other: {
title: "Jitsi Meet Desktop",
description:
"No desktop download is configured for your operating system."
}
},
browser: {
title: "Join in browser",
description:
"You can also join the meeting directly in your browser.",
button: "Join in browser"
},
error: {
title: "No meeting specified",
description: "Please specify a meeting using the",
descriptionSuffix: "parameter.",
invalidTitle: "Invalid meeting name",
invalidDescription: "Only letters, numbers and hyphens are allowed:"
},
creator: {
title: "Create New Meeting",
meetingNameLabel: "Meeting Name",
meetingDescriptionLabel: "Description",
meetingStartLabel: "Start Date & Time",
meetingEndLabel: "End Date & Time",
meetingOrganizerLabel: "Organizer Email",
createButton: "Create Meeting",
cancelButton: "Cancel",
successMessage: "Meeting created! Download your calendar file and open it to import the meeting into your personal calendar.",
downloadButton: "Download ICS File",
createNewButton: "Create New Meeting",
adhocButton: "Create Adhoc Link",
adhocLinkLabel: "Your adhoc meeting link:",
copyLinkButton: "Copy Link",
copiedLabel: "Copied!",
meetingNameRequired: "Please enter a meeting name",
meetingNameInvalid: "Meeting name may only contain letters, numbers and hyphens",
startTimeRequired: "Please select a start time",
endTimeRequired: "Please select an end time",
endAfterStart: "End time must be after start time",
organizerRequired: "Please enter an organizer email",
organizerInvalid: "Please enter a valid organizer email address"
}
},
de: {
pageTitle: "Jitsi Meeting öffnen",
impressum: "Impressum",
source: "Quellcode",
launcher: {
title: "Jitsi Meeting",
meetingPrefix: "Das Meeting",
meetingSuffix: "wird geöffnet.",
openButton: "An Meeting teilnehmen"
},
starting: {
title: "Jitsi Meet wird gestartet …",
status: "Der Jitsi Meet Desktop-Client wird geöffnet."
},
fallback: {
title: "Jitsi Meet konnte nicht geöffnet werden",
description:
"Der Jitsi Meet Desktop-Client wurde möglicherweise nicht gestartet.",
retryButton: "Erneut versuchen"
},
downloads: {
windows: {
title: "Jitsi Meet für Windows",
description:
"Installieren Sie den Jitsi Meet Desktop-Client und versuchen Sie anschließend erneut, das Meeting zu öffnen.",
button: "Jitsi für Windows herunterladen"
},
macos: {
title: "Jitsi Meet für macOS",
description:
"Installieren Sie den Jitsi Meet Desktop-Client und versuchen Sie anschließend erneut, das Meeting zu öffnen.",
button: "Jitsi für macOS herunterladen"
},
linux: {
title: "Jitsi Meet für Linux",
description:
"Installieren Sie den Jitsi Meet Desktop-Client und versuchen Sie anschließend erneut, das Meeting zu öffnen.",
button: "Jitsi für Linux herunterladen"
},
other: {
title: "Jitsi Meet Desktop",
description:
"Für Ihr Betriebssystem ist kein Desktop-Download hinterlegt."
}
},
browser: {
title: "Im Browser teilnehmen",
description:
"Sie können dem Meeting auch direkt über Ihren Browser beitreten.",
button: "Im Browser beitreten"
},
error: {
title: "Kein Meeting angegeben",
description:
"Bitte geben Sie ein Meeting über den Parameter",
descriptionSuffix: "an.",
invalidTitle: "Ungültiger Meeting-Name",
invalidDescription: "Erlaubt sind nur Buchstaben, Zahlen und Bindestriche:"
},
creator: {
title: "Neues Meeting erstellen",
meetingNameLabel: "Meeting-Name",
meetingDescriptionLabel: "Beschreibung",
meetingStartLabel: "Start Datum & Uhrzeit",
meetingEndLabel: "Ende Datum & Uhrzeit",
meetingOrganizerLabel: "Organisator E-Mail",
createButton: "Meeting erstellen",
cancelButton: "Abbrechen",
successMessage: "Meeting erstellt! Laden Sie Ihre Kalenderdatei herunter und öffnen Sie diese, um das Meeting in Ihren persönlichen Kalender zu importieren.",
downloadButton: "ICS-Datei herunterladen",
createNewButton: "Neues Meeting erstellen",
adhocButton: "Adhoc-Link erstellen",
adhocLinkLabel: "Ihr Adhoc-Meeting-Link:",
copyLinkButton: "Link kopieren",
copiedLabel: "Kopiert!",
meetingNameRequired: "Bitte geben Sie einen Meeting-Namen ein",
meetingNameInvalid: "Der Meeting-Name darf nur Buchstaben, Zahlen und Bindestriche enthalten",
startTimeRequired: "Bitte wählen Sie eine Startzeit",
endTimeRequired: "Bitte wählen Sie eine Endzeit",
endAfterStart: "Die Endzeit muss nach der Startzeit liegen",
organizerRequired: "Bitte geben Sie eine Organisator-E-Mail ein",
organizerInvalid: "Bitte geben Sie eine gültige Organisator-E-Mail-Adresse ein"
}
},
fr: {
pageTitle: "Réunion Jitsi",
impressum: "Mentions légales",
source: "Code source",
launcher: {
title: "Réunion Jitsi",
meetingPrefix: "La réunion",
meetingSuffix: "va être ouverte.",
openButton: "Rejoindre la réunion"
},
starting: {
title: "Démarrage de Jitsi Meet…",
status: "Le client bureau Jitsi Meet est en cours d'ouverture."
},
fallback: {
title: "Jitsi Meet n'a pas pu être ouvert",
description:
"Le client bureau Jitsi Meet n'a peut-être pas été démarré.",
retryButton: "Réessayer"
},
downloads: {
windows: {
title: "Jitsi Meet pour Windows",
description:
"Installez le client bureau Jitsi Meet, puis essayez de rouvrir la réunion.",
button: "Télécharger Jitsi pour Windows"
},
macos: {
title: "Jitsi Meet pour macOS",
description:
"Installez le client bureau Jitsi Meet, puis essayez de rouvrir la réunion.",
button: "Télécharger Jitsi pour macOS"
},
linux: {
title: "Jitsi Meet pour Linux",
description:
"Installez le client bureau Jitsi Meet, puis essayez de rouvrir la réunion.",
button: "Télécharger Jitsi pour Linux"
},
other: {
title: "Jitsi Meet Desktop",
description:
"Aucun téléchargement bureau n'est configuré pour votre système d'exploitation."
}
},
browser: {
title: "Rejoindre dans le navigateur",
description:
"Vous pouvez également rejoindre la réunion directement dans votre navigateur.",
button: "Rejoindre dans le navigateur"
},
error: {
title: "Aucune réunion spécifiée",
description: "Veuillez spécifier une réunion à l'aide du paramètre",
descriptionSuffix: ".",
invalidTitle: "Nom de réunion invalide",
invalidDescription: "Seuls les lettres, les chiffres et les tirets sont autorisés :"
},
creator: {
title: "Créer une nouvelle réunion",
meetingNameLabel: "Nom de la réunion",
meetingDescriptionLabel: "Description",
meetingStartLabel: "Date et heure de début",
meetingEndLabel: "Date et heure de fin",
meetingOrganizerLabel: "Email de l'organisateur",
createButton: "Créer la réunion",
cancelButton: "Annuler",
successMessage: "Réunion créée ! Téléchargez votre fichier calendrier et ouvrez-le pour importer la réunion dans votre calendrier personnel.",
downloadButton: "Télécharger le fichier ICS",
createNewButton: "Créer une nouvelle réunion",
adhocButton: "Créer un lien adhoc",
adhocLinkLabel: "Votre lien de réunion adhoc :",
copyLinkButton: "Copier le lien",
copiedLabel: "Copié !",
meetingNameRequired: "Veuillez entrer un nom de réunion",
meetingNameInvalid: "Le nom de la réunion ne peut contenir que des lettres, des chiffres et des tirets",
startTimeRequired: "Veuillez sélectionner une heure de début",
endTimeRequired: "Veuillez sélectionner une heure de fin",
endAfterStart: "L'heure de fin doit être après l'heure de début",
organizerRequired: "Veuillez entrer un email d'organisateur",
organizerInvalid: "Veuillez entrer une adresse email d'organisateur valide"
}
},
zh: {
pageTitle: "Jitsi 会议",
impressum: "法律声明",
source: "源代码",
launcher: {
title: "Jitsi 会议",
meetingPrefix: "会议",
meetingSuffix: "即将打开。",
openButton: "加入会议"
},
starting: {
title: "正在启动 Jitsi Meet …",
status: "正在打开 Jitsi Meet 桌面客户端。"
},
fallback: {
title: "无法打开 Jitsi Meet",
description: "Jitsi Meet 桌面客户端可能尚未启动。",
retryButton: "重试"
},
downloads: {
windows: {
title: "适用于 Windows 的 Jitsi Meet",
description: "请安装 Jitsi Meet 桌面客户端,然后再次尝试打开会议。",
button: "下载 Windows 版 Jitsi"
},
macos: {
title: "适用于 macOS 的 Jitsi Meet",
description: "请安装 Jitsi Meet 桌面客户端,然后再次尝试打开会议。",
button: "下载 macOS 版 Jitsi"
},
linux: {
title: "适用于 Linux 的 Jitsi Meet",
description: "请安装 Jitsi Meet 桌面客户端,然后再次尝试打开会议。",
button: "下载 Linux 版 Jitsi"
},
other: {
title: "Jitsi Meet 桌面版",
description: "没有为您的操作系统配置桌面版下载。"
}
},
browser: {
title: "在浏览器中加入",
description: "您也可以直接通过浏览器加入会议。",
button: "在浏览器中加入"
},
error: {
title: "未指定会议",
description: "请使用参数指定会议",
descriptionSuffix: "。",
invalidTitle: "会议名称无效",
invalidDescription: "仅允许使用字母、数字和连字符:"
},
creator: {
title: "创建新会议",
meetingNameLabel: "会议名称",
meetingDescriptionLabel: "描述",
meetingStartLabel: "开始日期和时间",
meetingEndLabel: "结束日期和时间",
meetingOrganizerLabel: "组织者邮箱",
createButton: "创建会议",
cancelButton: "取消",
successMessage: "会议已创建!请下载您的日历文件并打开它,将会议导入您的个人日历。",
downloadButton: "下载 ICS 文件",
createNewButton: "创建新会议",
adhocButton: "创建临时链接",
adhocLinkLabel: "您的临时会议链接:",
copyLinkButton: "复制链接",
copiedLabel: "已复制!",
meetingNameRequired: "请输入会议名称",
meetingNameInvalid: "会议名称只能包含字母、数字和连字符",
startTimeRequired: "请选择开始时间",
endTimeRequired: "请选择结束时间",
endAfterStart: "结束时间必须晚于开始时间",
organizerRequired: "请输入组织者邮箱",
organizerInvalid: "请输入有效的组织者邮箱地址"
}
}
};
/* ============================================================
* Detect language
* ============================================================
*/
function detectLanguage() {
/*
* Gespeicherte Sprachwahl hat Vorrang.
*/
try {
const saved = localStorage.getItem("jitsiLauncherLanguage");
if (saved && TRANSLATIONS[saved]) {
return saved;
}
} catch (error) {
/*
* localStorage kann z.B. im Privat-Modus blockiert sein.
*/
}
const languages = navigator.languages || [navigator.language];
for (const language of languages) {
if (!language) {
continue;
}
const languageCode = language
.toLowerCase()
.split("-")[0];
if (TRANSLATIONS[languageCode]) {
return languageCode;
}
}
/*
* Englisch ist der Standard/Fallback.
*/
return "en";
}
let currentLanguage = detectLanguage();
let translation = TRANSLATIONS[currentLanguage];
/* ============================================================
* Apply translations to webpage
* ============================================================
*/
function applyTranslations() {
document.documentElement.lang = currentLanguage;
document.title = translation.pageTitle;
document
.querySelectorAll("[data-i18n]")
.forEach((element) => {
const key = element.dataset.i18n;
const value = getTranslation(key);
if (value !== null) {
element.textContent = value;
}
});
}
/*
* Übersetzung anhand eines Pfades lesen.
*
* Beispiel:
*
* "downloads.windows.title"
*
* ergibt:
*
* translation.downloads.windows.title
*/
function getTranslation(key) {
const parts = key.split(".");
let value = translation;
for (const part of parts) {
if (
value === null ||
value === undefined ||
typeof value !== "object" ||
!(part in value)
) {
return null;
}
value = value[part];
}
return typeof value === "string" ? value : null;
}
/* ============================================================
* LANGUAGE MENU
* ============================================================
*/
const LANGUAGE_NAMES = {
en: "English",
de: "Deutsch",
fr: "Français",
zh: "中文"
};
const LANGUAGE_FLAGS = {
en: "assets/uk.svg",
de: "assets/de.svg",
fr: "assets/fr.svg",
zh: "assets/cn.svg"
};
function updateLanguageMenu() {
langFlag.src = LANGUAGE_FLAGS[currentLanguage];
langLabel.textContent = LANGUAGE_NAMES[currentLanguage];
langDropdown
.querySelectorAll("button[data-lang]")
.forEach((item) => {
item.classList.toggle(
"active",
item.dataset.lang === currentLanguage
);
});
}
function setLanguage(language) {
if (!TRANSLATIONS[language]) {
return;
}
currentLanguage = language;
translation = TRANSLATIONS[language];
try {
localStorage.setItem("jitsiLauncherLanguage", language);
} catch (error) {
/*
* localStorage kann z.B. im Privat-Modus blockiert sein.
*/
}
applyTranslations();
updateLanguageMenu();
}
function toggleLanguageMenu() {
const isOpen = langDropdown.classList.toggle("open");
langButton.setAttribute(
"aria-expanded",
isOpen ? "true" : "false"
);
}
function closeLanguageMenu() {
langDropdown.classList.remove("open");
langButton.setAttribute("aria-expanded", "false");
}
/* ============================================================
* NAV MENU (Hamburger)
* ============================================================
*/
function toggleNavMenu() {
const isOpen = navActions.classList.toggle("open");
navToggle.setAttribute("aria-expanded", isOpen ? "true" : "false");
}
function closeNavMenu() {
navActions.classList.remove("open");
navToggle.setAttribute("aria-expanded", "false");
}
/* ============================================================
* THEME
* ============================================================
*/
const THEME_KEY = "jitsiLauncherTheme";
function savedTheme() {
try {
const saved = localStorage.getItem(THEME_KEY);
if (saved === "dark" || saved === "light") {
return saved;
}
} catch (error) {
/*
* localStorage kann z.B. im Privat-Modus blockiert sein.
*/
}
return null;
}
function systemIsDark() {
return window.matchMedia("(prefers-color-scheme: dark)").matches;
}
function detectTheme() {
/*
* Gespeicherte Wahl hat Vorrang, sonst System-Einstellung.
*/
return savedTheme() || (systemIsDark() ? "dark" : "light");
}
let currentTheme = detectTheme();
function updateThemeToggle() {
themeIconSun.classList.toggle("hidden", currentTheme !== "dark");
themeIconMoon.classList.toggle("hidden", currentTheme === "dark");
}
function applyTheme() {
document.documentElement.classList.toggle("dark", currentTheme === "dark");
updateThemeToggle();
}
function toggleTheme() {
currentTheme = currentTheme === "dark" ? "light" : "dark";
try {
localStorage.setItem(THEME_KEY, currentTheme);
} catch (error) {
/*
* localStorage kann z.B. im Privat-Modus blockiert sein.
*/
}
applyTheme();
}
/* ============================================================
* BACKGROUND IMAGE
* ============================================================
*/
function applyBackgroundImage() {
if (!CONFIG.backgroundImage) {
return;
}
document.body.style.backgroundImage = `url("${CONFIG.backgroundImage}")`;
document.body.style.backgroundSize = "cover";
document.body.style.backgroundPosition = "center";
}
/* ============================================================
* LOGO
* ============================================================
*/
function applyLogo() {
if (!CONFIG.logo) {
return;
}
logoImage.src = CONFIG.logo;
logoBar.classList.remove("hidden");
}
/* ============================================================
* DOM elements
* ============================================================
*/
const launcher = document.getElementById("launcher");
const starting = document.getElementById("starting");
const fallback = document.getElementById("fallback");
const invalidRoom = document.getElementById("invalidRoom");
const meetingCreator = document.getElementById("meetingCreator");
const roomName = document.getElementById("roomName");
const openButton = document.getElementById("openButton");
const retryButton = document.getElementById("retryButton");
const browserLink = document.getElementById("browserLink");
const windowsDownload = document.getElementById("windowsDownload");
const macosDownload = document.getElementById("macosDownload");
const linuxDownload = document.getElementById("linuxDownload");
const otherDownload = document.getElementById("otherDownload");
const windowsLink = document.getElementById("windowsLink");
const macosLink = document.getElementById("macosLink");
const linuxLink = document.getElementById("linuxLink");
const createNewMeetingButton = document.getElementById("createNewMeetingButton");
const createNewFromLauncherButton = document.getElementById("createNewFromLauncherButton");
/* Meeting Creator DOM elements */
const meetingNameInput = document.getElementById("meetingName");
const meetingDescriptionInput = document.getElementById("meetingDescription");
const meetingStartInput = document.getElementById("meetingStart");
const meetingEndInput = document.getElementById("meetingEnd");
const meetingOrganizerInput = document.getElementById("meetingOrganizer");
const createMeetingButton = document.getElementById("createMeetingButton");
const createAdhocButton = document.getElementById("createAdhocButton");
const cancelCreateButton = document.getElementById("cancelCreateButton");
const icsSuccess = document.getElementById("icsSuccess");
const downloadIcsLink = document.getElementById("downloadIcsLink");
const adhocResult = document.getElementById("adhocResult");
const adhocLink = document.getElementById("adhocLink");
const copyAdhocLinkButton = document.getElementById("copyAdhocLinkButton");
/* Navbar DOM elements */
const navbar = document.querySelector(".navbar");
const navToggle = document.getElementById("navToggle");
const navActions = document.getElementById("navActions");
/* Language menu DOM elements */
const langMenu = document.getElementById("langMenu");
const langButton = document.getElementById("langButton");
const langDropdown = document.getElementById("langDropdown");
const langFlag = document.getElementById("langFlag");
const langLabel = document.getElementById("langLabel");
/* Invalid room view DOM elements */
const missingRoomTitle = document.getElementById("missingRoomTitle");
const missingRoomError = document.getElementById("missingRoomError");
const invalidNameTitle = document.getElementById("invalidNameTitle");
const invalidNameError = document.getElementById("invalidNameError");
const invalidRoomName = document.getElementById("invalidRoomName");
/* Logo DOM elements */
const logoBar = document.getElementById("logoBar");
const logoImage = document.getElementById("logoImage");
/* Theme DOM elements */
const themeButton = document.getElementById("themeButton");
const themeIconSun = document.getElementById("themeIconSun");
const themeIconMoon = document.getElementById("themeIconMoon");
/* ============================================================
* GET ROOM FROM QUERY-PARAMETER
* ============================================================
*
* Beispiel:
*
* https://<YOURJITSIINSTANCEADDRESS>/launch.html?room=abc-defg-hij
*
* ergibt:
*
* abc-defg-hij
*
* ============================================================
*/
function getRoom() {
const params = new URLSearchParams(
window.location.search
);
/*
* Parameter nicht vorhanden.
*/
if (!params.has("room")) {
return null;
}
/*
* Leerzeichen etc. verhindern.
*
* "?room=" ohne Wert ergibt einen leeren String.
*/
return params.get("room").trim();
}
/* ============================================================
* VALIDATE ROOM
* ============================================================
*
* Jitsi-Roomnamen können je nach Konfiguration relativ
* unterschiedliche Zeichen enthalten.
*
* Wir verhindern hier lediglich Steuerzeichen und
* offensichtliche URL-Manipulation.
*
* ============================================================
*/
const ROOM_PATTERN = /^[A-Za-z0-9-]+$/;
function isValidRoom(room) {
if (!room) {
return false;
}
/*
* Nur Buchstaben, Zahlen und Bindestriche.
*/
return ROOM_PATTERN.test(room);
}
/* ============================================================
* JITSI WEB URL
* ============================================================
*/
function createBrowserUrl(room) {
return (
"https://" +
CONFIG.jitsiHost +
"/" +
encodeURIComponent(room)
);
}
/* ============================================================
* JITSI DESKTOP PROTOCOL URL
* ============================================================
*/
function createAppUrl(room) {
return (
"jitsi-meet://" +
CONFIG.jitsiHost +
"/" +
encodeURIComponent(room)
);
}
/* ============================================================
* JITSI ANDROID URL
* ============================================================ */
/*
* Die Android-App registriert "org.jitsi.meet", nicht das
* Desktop-Schema. Chrome & Co. verlangen intent://, ohne App
* bietet Chrome den Play-Store-Eintrag aus "package=" an.
* Firefox ignoriert intent://, kennt aber das Custom-Schema.
*/
function createAndroidIntentUrl(room) {
return (
"intent://" +
CONFIG.jitsiHost +
"/" +
encodeURIComponent(room) +
"#Intent;scheme=org.jitsi.meet;package=org.jitsi.meet;end"
);
}
const ANDROID_APP_SCHEME = "org.jitsi.meet://";
function createAndroidAppUrl(room) {
return (
ANDROID_APP_SCHEME +
CONFIG.jitsiHost +
"/" +
encodeURIComponent(room)
);
}
/* ============================================================
* URL NACH PLATTFORM/BROWSER
* ============================================================ */
function selectProtocolUrl(room) {
if (detectOperatingSystem() !== "android") {
return createAppUrl(room);
}
return detectBrowser() === "firefox"
? createAndroidAppUrl(room)
: createAndroidIntentUrl(room);
}
/* ============================================================
* DETECT OPERATING SYSTEM
* ============================================================
*/
/*
* Einmal parsen (assets/ua-parser.min.js).
* Fällt die Bibliothek aus, bleibt die Seite bedienbar.
*/
const uaResult =
typeof UAParser === "function"
? new UAParser().getResult()
: { os: {}, browser: {} };
/*
* UAParser-Namen -> interne Kürzel.
* iPadOS meldet sich als macOS – Touchpunkte gelten als iOS.
*/
const OS_NAMES = {
Android: "android",
iOS: "ios",
Windows: "windows",
macOS: "macos",
Linux: "linux",
Ubuntu: "linux",
Debian: "linux",
Fedora: "linux",
Mint: "linux",
CentOS: "linux",
openSUSE: "linux",
Raspbian: "linux"
};
const BROWSER_NAMES = {
Edge: "edge",
"Mobile Edge": "edge",
Firefox: "firefox",
"Mobile Firefox": "firefox",
Chrome: "chrome",
"Mobile Chrome": "chrome",
"Chrome Headless": "chrome",
"Chrome WebView": "chrome",
"Samsung Internet": "chrome",
Safari: "safari",
"Mobile Safari": "safari"
};
function detectOperatingSystem() {
/*
* iPadOS tarnt sich als macOS.
*/
if (
uaResult.os.name === "macOS" &&
navigator.maxTouchPoints > 1
) {
return OS_NAMES.iOS;
}
return OS_NAMES[uaResult.os.name] || "unknown";
}
/* ============================================================
* BROWSER ERKENNEN
* ============================================================
*/
function detectBrowser() {
return BROWSER_NAMES[uaResult.browser.name] || "unknown";
}
/* ============================================================
* DOWNLOADS
* ============================================================
*/
function configureDownloads() {
const os = detectOperatingSystem();
windowsDownload.classList.add("hidden");
macosDownload.classList.add("hidden");
linuxDownload.classList.add("hidden");
otherDownload.classList.add("hidden");
/* Windows */
if (os === "windows") {
windowsLink.href = CONFIG.downloads.windows;
windowsDownload.classList.remove("hidden");
return;
}
/* macOS */
if (os === "macos") {
macosLink.href = CONFIG.downloads.macos;
macosDownload.classList.remove("hidden");
return;
}
/* Linux */
if (os === "linux") {
linuxLink.href = CONFIG.downloads.linux;
linuxDownload.classList.remove("hidden");
return;
}
/* Alles andere */
otherDownload.classList.remove("hidden");
}
/* ============================================================
* FALLBACK ANZEIGEN
* ============================================================
*/
function showFallback() {
starting.classList.add("hidden");
launcher.classList.add("hidden");
fallback.classList.remove("hidden");
configureDownloads();
}
/* ============================================================
* JITSI DESKTOP STARTEN
* ============================================================
*/
function launchJitsi() {
const room = getRoom();
if (!isValidRoom(room)) {
return;
}
const protocolUrl = selectProtocolUrl(room);
const browserUrl = createBrowserUrl(room);
/*
* Browser-Fallback vorbereiten.
*/
browserLink.href = browserUrl;
/*
* UI auf "Startvorgang" setzen.
*/
launcher.classList.add("hidden");
fallback.classList.add("hidden");
starting.classList.remove("hidden");
let completed = false;
let timeout = null;
/*
* Unsichtbarer iframe für den Protocol Handler.
*/
const iframe = document.createElement("iframe");
iframe.style.display = "none";
/*
* Aufräumen.
*/
function cleanup() {
if (completed) {
return;
}
completed = true;
if (timeout !== null) {
clearTimeout(timeout);
}
iframe.remove();
}
/*
* --------------------------------------------------------
* WICHTIG:
*
* Kein "blur" mehr als Erfolgskriterium verwenden.
*
* Der Browser kann beim Protocol-Dialog ebenfalls
* blur auslösen.
*
* Wenn der Benutzer den Dialog abbricht, bleibt die
* Seite sichtbar und unser Timeout läuft weiter.
* --------------------------------------------------------
*/
document.body.appendChild(iframe);
/*
* Protocol Handler starten.
*
* Firefox ignoriert externe Protokolle aus iframes –
* das Custom-Schema direkt in der Hauptnavigation starten.
*/
try {
if (protocolUrl.startsWith(ANDROID_APP_SCHEME)) {
window.location.href = protocolUrl;
} else {
iframe.src = protocolUrl;
}
} catch (error) {
console.error(
"Error opening the Jitsi Protocol Handler:",
error
);
cleanup();
showFallback();
return;
}
/*
* --------------------------------------------------------
* FALLBACK
* --------------------------------------------------------
*
* Wir warten unabhängig davon, ob der Browser kurz
* den Fokus verliert.
*
* Wenn Jitsi erfolgreich startet:
*
* -> Jitsi kommt in den Vordergrund
* -> Benutzer sieht diese Seite nicht mehr
*
* Wenn der Benutzer den Browser-Dialog abbricht:
*
* -> diese Seite bleibt sichtbar
* -> Timeout läuft ab
* -> Fallback wird angezeigt
* --------------------------------------------------------
*/
timeout = setTimeout(() => {
if (completed) {
return;
}
cleanup();
showFallback();
}, CONFIG.launchTimeout);
}
/* ============================================================
* ROOM NAME
* ============================================================
*/
/*
* Sonderzeichen in das nächstliegende ASCII-Zeichen
* umwandeln.
*
* Beispiel:
*
* "Grundstück" ergibt "Grundstueck"
* "François" ergibt "Francois"
*/
function transliterate(text) {
const extras = {
"ß": "ss",
"ẞ": "ss",
"æ": "ae",
"Æ": "ae",
"œ": "oe",
"Œ": "oe",
"ø": "o",
"Ø": "o"
};
return text
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[ßẞæÆœŒøØ]/g, (char) => extras[char]);
}
/*
* Aus dem Meeting-Namen einen gültigen Room-Namen ableiten.
*
* Beispiel:
*
* "Grund Stück Planung" ergibt "grund-stuck-planung"
*/
function createRoomName(meetingName) {
return transliterate(meetingName)
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "");
}
/* ============================================================
* ICS FILE GENERATION
* ============================================================
*/
function generateICS(meetingName, description, startTime, endTime, organizer) {
/* Generate a unique room name from the meeting name */
const roomName = createRoomName(meetingName);
const meetingUrl = createLaunchUrl(roomName);
/* Format dates for ICS */
const formatICSDate = (date) => {
const pad = (num) => num.toString().padStart(2, '0');
const year = date.getFullYear();
const month = pad(date.getMonth() + 1);
const day = pad(date.getDate());
const hours = pad(date.getHours());
const minutes = pad(date.getMinutes());
const seconds = pad(date.getSeconds());
return `${year}${month}${day}T${hours}${minutes}${seconds}`;
};
const uid = `jitsi-${Date.now()}@${CONFIG.jitsiHost}`;
const dtStamp = formatICSDate(new Date());
const dtStart = formatICSDate(startTime);
const dtEnd = formatICSDate(endTime);
/* Build ICS content */
const icsContent = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//BNVB//Jitsi Meeting//EN
CALSCALE:GREGORIAN
METHOD:PUBLISH
BEGIN:VEVENT
UID:${uid}
DTSTAMP:${dtStamp}
DTSTART:${dtStart}
DTEND:${dtEnd}
SUMMARY:${meetingName.replace(/,/g, '\\,')}
DESCRIPTION:${description.replace(/,/g, '\\,').replace(/\n/g, '\\n')}
LOCATION:${meetingUrl}
ORGANIZER:MAILTO:${organizer}
STATUS:CONFIRMED
URL:${meetingUrl}
SEQUENCE:0
TRANSP:OPAQUE
BEGIN:VALARM
ACTION:DISPLAY
DESCRIPTION:Reminder
TRIGGER:-PT15M
END:VALARM
END:VEVENT
END:VCALENDAR`;
return {
content: icsContent,
roomName: roomName,
url: meetingUrl
};
}
function downloadICS(content, filename) {
const blob = new Blob([content], { type: 'text/calendar;charset=utf-8' });
const url = URL.createObjectURL(blob);
return url;
}
const MINUTES_STEP = 15;
const DEFAULT_DURATION_MINUTES = 30;
/*
* Minuten auf das 15-Minuten-Raster aufrunden.
*
* Beispiel:
*
* 14:07 ergibt 14:15
* 14:50 ergibt 15:00
*/
function roundMinutes(date) {
const rounded = new Date(date);
rounded.setSeconds(0, 0);
rounded.setMinutes(
Math.ceil(date.getMinutes() / MINUTES_STEP) * MINUTES_STEP
);
return rounded;
}
/*
* Lokale Zeit für datetime-local formatieren.
*
* Beispiel:
*
* 1. Januar 2026, 14:15 ergibt "2026-01-01T14:15"
*/
function formatLocal(date) {
const pad = (num) => num.toString().padStart(2, "0");
return (
date.getFullYear() +
"-" +
pad(date.getMonth() + 1) +
"-" +
pad(date.getDate()) +
"T" +
pad(date.getHours()) +
":" +
pad(date.getMinutes())
);
}
function showMeetingCreator() {
/* Hide all other views */
launcher.classList.add("hidden");
starting.classList.add("hidden");
fallback.classList.add("hidden");
invalidRoom.classList.add("hidden");
/* Show meeting creator */
meetingCreator.classList.remove("hidden");
/* Reset form */
meetingNameInput.value = '';
meetingDescriptionInput.value = '';
meetingStartInput.value = '';
meetingEndInput.value = '';
meetingOrganizerInput.value = '';
icsSuccess.classList.add("hidden");
adhocResult.classList.add("hidden");
/* Set default start and end times */
const start = roundMinutes(new Date());
const end = new Date(
start.getTime() + DEFAULT_DURATION_MINUTES * 60 * 1000
);
meetingStartInput.value = formatLocal(start);
meetingEndInput.value = formatLocal(end);
}
function createMeeting() {
const meetingName = meetingNameInput.value.trim();
const description = meetingDescriptionInput.value.trim();
const startTime = new Date(meetingStartInput.value);
const endTime = new Date(meetingEndInput.value);
const organizer = meetingOrganizerInput.value.trim();
if (!meetingName) {
alert(getTranslation('creator.meetingNameRequired') || 'Please enter a meeting name');
return;
}
if (!ROOM_PATTERN.test(createRoomName(meetingName))) {
alert(getTranslation('creator.meetingNameInvalid') || 'Meeting name may only contain letters, numbers and hyphens');
return;
}
if (!meetingStartInput.value) {
alert(getTranslation('creator.startTimeRequired') || 'Please select a start time');
return;
}
if (!meetingEndInput.value) {
alert(getTranslation('creator.endTimeRequired') || 'Please select an end time');
return;
}
if (startTime >= endTime) {
alert(getTranslation('creator.endAfterStart') || 'End time must be after start time');
return;
}
if (!organizer) {
alert(getTranslation('creator.organizerRequired') || 'Please enter an organizer email');
return;
}
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailPattern.test(organizer)) {
alert(getTranslation('creator.organizerInvalid') || 'Please enter a valid organizer email address');
return;
}
/* Generate ICS */
const icsData = generateICS(meetingName, description || meetingName, startTime, endTime, organizer);
/* Create download link */
const blob = new Blob([icsData.content], { type: 'text/calendar;charset=utf-8' });
const url = URL.createObjectURL(blob);
downloadIcsLink.href = url;
downloadIcsLink.download = `${icsData.roomName}.ics`;
/* Show success message */
icsSuccess.classList.remove("hidden");
adhocResult.classList.add("hidden");
/* Scroll to success message */
icsSuccess.scrollIntoView({ behavior: 'smooth' });
}
/* ============================================================
* ADHOC LINK
* ============================================================
*/
/*
* Zeitspanne, nach der der "Kopiert!"-Text zum
* normalen Button-Label zurückkehrt.
*/
const COPY_RESET_MS = 2000;
/*
* Launch-URL, die das Meeting im Jitsi-Client öffnet.
*
* Beispiel:
*
* "projekt-kickoff" ergibt
* https://meet.bnvb.eu/launch.html?room=projekt-kickoff
*/
function createLaunchUrl(roomName) {
return `https://${CONFIG.jitsiLauncher}/launch.html?room=${encodeURIComponent(roomName)}`;
}
function createAdhocLink() {
const meetingName = meetingNameInput.value.trim();
if (!meetingName) {
alert(getTranslation('creator.meetingNameRequired') || 'Please enter a meeting name');
return;
}
const roomName = createRoomName(meetingName);
if (!ROOM_PATTERN.test(roomName)) {
alert(getTranslation('creator.meetingNameInvalid') || 'Meeting name may only contain letters, numbers and hyphens');
return;
}
const url = createLaunchUrl(roomName);
adhocLink.href = url;
adhocLink.textContent = url;
icsSuccess.classList.add("hidden");
adhocResult.classList.remove("hidden");
adhocResult.scrollIntoView({ behavior: 'smooth' });
}
async function copyAdhocLink() {
const url = adhocLink.href;
/* Clipboard-API, mit Fallback für ältere Browser */
try {
await navigator.clipboard.writeText(url);
} catch (error) {
const input = document.createElement('textarea');
input.value = url;
document.body.appendChild(input);
input.select();
document.execCommand('copy');
document.body.removeChild(input);
}
/* Kurzes Feedback, dann zurück zum normalen Label */
copyAdhocLinkButton.textContent = getTranslation('creator.copiedLabel') || 'Copied!';
setTimeout(() => {
copyAdhocLinkButton.textContent = getTranslation('creator.copyLinkButton') || 'Copy Link';
}, COPY_RESET_MS);
}
function cancelCreateMeeting() {
meetingCreator.classList.add("hidden");
/* If there was a room parameter, show the launcher */
const room = getRoom();
if (isValidRoom(room)) {
roomName.textContent = room;
browserLink.href = createBrowserUrl(room);
launcher.classList.remove("hidden");
} else {
invalidRoom.classList.remove("hidden");
}
}
/* ============================================================
* INITIALISIERUNG
* ============================================================
*/
function initialize() {
const room = getRoom();
/*
* Kein room-Parameter: Meldungen ausblenden,
* nur den Create-Button anzeigen.
*/
if (room === null) {
missingRoomTitle.classList.add("hidden");
missingRoomError.classList.add("hidden");
invalidNameTitle.classList.add("hidden");
invalidNameError.classList.add("hidden");
invalidRoom.classList.remove("hidden");
createNewMeetingButton.classList.remove("hidden");
return;
}
/*
* Leerer room-Parameter ("?room="):
* "No meeting specified" anzeigen.
*/
if (room === "") {
missingRoomTitle.classList.remove("hidden");
missingRoomError.classList.remove("hidden");
invalidNameTitle.classList.add("hidden");
invalidNameError.classList.add("hidden");
invalidRoom.classList.remove("hidden");
createNewMeetingButton.classList.remove("hidden");
return;
}
/*
* Ungültiger room-Parameter: nur die
* Invalid-Name-Meldung anzeigen.
*/
if (!isValidRoom(room)) {
missingRoomTitle.classList.add("hidden");
missingRoomError.classList.add("hidden");
invalidNameTitle.classList.remove("hidden");
invalidNameError.classList.remove("hidden");
invalidRoomName.textContent = room;
invalidRoom.classList.remove("hidden");
createNewMeetingButton.classList.remove("hidden");
return;
}
/*
* Room anzeigen.
*/
roomName.textContent = room;
/*
* Browser-Fallback vorbereiten.
*/
browserLink.href = createBrowserUrl(room);
/*
* Launcher anzeigen.
*/
launcher.classList.remove("hidden");
/*
* Hide the "Create New Meeting" button when a valid room is specified
*/
createNewFromLauncherButton.classList.add("hidden");
/*
* Debug-Informationen.
*/
console.log(
"Jitsi Launcher",
{
language: currentLanguage,
room: room,
operatingSystem: detectOperatingSystem(),
browser: detectBrowser(),
protocol: selectProtocolUrl(room),
browserUrl: createBrowserUrl(room)
}
);
}
/* ============================================================
* EVENTS
* ============================================================
*/
openButton.addEventListener(
"click",
launchJitsi
);
retryButton.addEventListener(
"click",
launchJitsi
);
/* Meeting Creator Events */
createNewMeetingButton.addEventListener(
"click",
showMeetingCreator
);
createNewFromLauncherButton.addEventListener(
"click",
showMeetingCreator
);
createMeetingButton.addEventListener(
"click",
createMeeting
);
createAdhocButton.addEventListener(
"click",
createAdhocLink
);
copyAdhocLinkButton.addEventListener(
"click",
copyAdhocLink
);
cancelCreateButton.addEventListener(
"click",
cancelCreateMeeting
);
/* Language menu events */
langButton.addEventListener(
"click",
toggleLanguageMenu
);
langDropdown.addEventListener(
"click",
(event) => {
const item = event.target.closest("button[data-lang]");
if (!item) {
return;
}
setLanguage(item.dataset.lang);
closeLanguageMenu();
}
);
document.addEventListener(
"click",
(event) => {
if (!langMenu.contains(event.target)) {
closeLanguageMenu();
}
if (!navbar.contains(event.target)) {
closeNavMenu();
}
}
);
/* Theme events */
themeButton.addEventListener(
"click",
toggleTheme
);
/* Hamburger events */
navToggle.addEventListener(
"click",
toggleNavMenu
);
/*
* Ohne gespeicherte Wahl dem System folgen.
*/
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", () => {
if (savedTheme() === null) {
currentTheme = systemIsDark() ? "dark" : "light";
applyTheme();
}
});
/* ============================================================
* START
* ============================================================
*/
applyTranslations();
applyTheme();
applyBackgroundImage();
applyLogo();
updateLanguageMenu();
initialize();
</script>
</body>
</html>