diff --git a/assets/launch.js b/assets/launch.js new file mode 100644 index 0000000..5ecf16b --- /dev/null +++ b/assets/launch.js @@ -0,0 +1,1673 @@ +"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:///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() { + /* + * Chrome im "Desktop-Modus" auf Android sendet einen + * Desktop-UA (z.B. X11; Linux). Client Hints verraten + * das echte System. Firefox/Safari kennen sie nicht. + */ + const hints = navigator.userAgentData; + if (hints && hints.platform === "Android") { + return OS_NAMES.Android; + } + + /* + * 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(); diff --git a/launch.html b/launch.html index 378743d..78a0c72 100644 --- a/launch.html +++ b/launch.html @@ -797,1680 +797,6 @@ - + \ No newline at end of file