The favicons were hardcoded in launch.html, so rebranding the page required editing the template. The paths now come from the favicons block in assets/config.json. Missing keys keep the defaults from launch.html.
1621 lines
38 KiB
JavaScript
1621 lines
38 KiB
JavaScript
"use strict";
|
||
|
||
/* ============================================================
|
||
* CONFIGURATION
|
||
* ============================================================
|
||
*/
|
||
|
||
/*
|
||
* Configuration lives in assets/config.json and is loaded
|
||
* asynchronously at startup (see START).
|
||
*/
|
||
let CONFIG = {};
|
||
const CONFIG_URL = "assets/config.json";
|
||
|
||
/* ============================================================
|
||
* Translations
|
||
* ============================================================
|
||
*/
|
||
|
||
/*
|
||
* Translations live in assets/i18n.json and are loaded
|
||
* asynchronously at startup (see START).
|
||
*/
|
||
let TRANSLATIONS = {};
|
||
const I18N_URL = "assets/i18n.json";
|
||
|
||
/* ============================================================
|
||
* Detect language
|
||
* ============================================================
|
||
*/
|
||
|
||
function detectLanguage() {
|
||
/*
|
||
* Saved language choice takes precedence.
|
||
*/
|
||
try {
|
||
const saved = localStorage.getItem("jitsiLauncherLanguage");
|
||
|
||
if (saved && TRANSLATIONS[saved]) {
|
||
return saved;
|
||
}
|
||
} catch (error) {
|
||
/*
|
||
* localStorage can be blocked, e.g. in private mode.
|
||
*/
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
/*
|
||
* English is the default/fallback.
|
||
*/
|
||
return "en";
|
||
}
|
||
|
||
let currentLanguage = "en";
|
||
let translation = {};
|
||
|
||
/* ============================================================
|
||
* Apply translations to webpage
|
||
* ============================================================
|
||
*/
|
||
|
||
function applyTranslations() {
|
||
document.documentElement.lang = currentLanguage;
|
||
if (translation.pageTitle) {
|
||
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;
|
||
}
|
||
});
|
||
|
||
/*
|
||
* Translate placeholders of input fields.
|
||
*/
|
||
document
|
||
.querySelectorAll("[data-i18n-placeholder]")
|
||
.forEach((element) => {
|
||
const value = getTranslation(element.dataset.i18nPlaceholder);
|
||
|
||
if (value !== null) {
|
||
element.placeholder = value;
|
||
}
|
||
});
|
||
}
|
||
|
||
/*
|
||
* Read a translation by path.
|
||
*
|
||
* Example:
|
||
*
|
||
* "downloads.windows.title"
|
||
*
|
||
* resolves to:
|
||
*
|
||
* 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/images/uk.svg",
|
||
de: "assets/images/de.svg",
|
||
fr: "assets/images/fr.svg",
|
||
zh: "assets/images/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 can be blocked, e.g. in private mode.
|
||
*/
|
||
}
|
||
|
||
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 can be blocked, e.g. in private mode.
|
||
*/
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function systemIsDark() {
|
||
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||
}
|
||
|
||
function detectTheme() {
|
||
/*
|
||
* Saved choice takes precedence, otherwise system setting.
|
||
*/
|
||
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 can be blocked, e.g. in private mode.
|
||
*/
|
||
}
|
||
|
||
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");
|
||
}
|
||
|
||
/* ============================================================
|
||
* FAVICONS
|
||
* ============================================================
|
||
*/
|
||
|
||
/*
|
||
* Overwrite the default icons from launch.html
|
||
* (CONFIG.favicons in assets/config.json).
|
||
*/
|
||
function applyFavicons() {
|
||
if (!CONFIG.favicons) {
|
||
return;
|
||
}
|
||
|
||
if (CONFIG.favicons.ico) {
|
||
faviconIco.href = CONFIG.favicons.ico;
|
||
}
|
||
|
||
if (CONFIG.favicons.svg) {
|
||
faviconSvg.href = CONFIG.favicons.svg;
|
||
}
|
||
|
||
if (CONFIG.favicons.appleTouch) {
|
||
appleTouchIcon.href = CONFIG.favicons.appleTouch;
|
||
}
|
||
}
|
||
|
||
/* ============================================================
|
||
* NEXTCLOUD BUTTON
|
||
* ============================================================
|
||
*/
|
||
|
||
/*
|
||
* Only show if a Nextcloud instance is configured
|
||
* (CONFIG.nextcloudBase in assets/config.json).
|
||
*/
|
||
function applyNextcloudButton() {
|
||
if (!CONFIG.nextcloudBase || !createNextcloudButton) {
|
||
return;
|
||
}
|
||
|
||
createNextcloudButton.classList.remove("hidden");
|
||
}
|
||
|
||
/* ============================================================
|
||
* ADHOC BUTTON
|
||
* ============================================================
|
||
*/
|
||
|
||
/*
|
||
* Only show if explicitly enabled
|
||
* (CONFIG.allowAdhocMeetings in assets/config.json).
|
||
*/
|
||
function applyAdhocButton() {
|
||
if (!CONFIG.allowAdhocMeetings || !createAdhocButton) {
|
||
return;
|
||
}
|
||
|
||
createAdhocButton.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 iosDownload = document.getElementById("iosDownload");
|
||
const androidDownload = document.getElementById("androidDownload");
|
||
const otherDownload = document.getElementById("otherDownload");
|
||
const windowsLink = document.getElementById("windowsLink");
|
||
const macosLink = document.getElementById("macosLink");
|
||
const linuxLink = document.getElementById("linuxLink");
|
||
const iosLink = document.getElementById("iosLink");
|
||
const androidLink = document.getElementById("androidLink");
|
||
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 createNextcloudButton = document.getElementById('createNextcloudButton');
|
||
const createAdhocButton = document.getElementById("createAdhocButton");
|
||
const cancelCreateButton = document.getElementById("cancelCreateButton");
|
||
const icsSuccess = document.getElementById("icsSuccess");
|
||
const nextcloudStatus = document.getElementById('nextcloudStatus');
|
||
const nextcloudError = document.getElementById('nextcloudError');
|
||
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");
|
||
|
||
/* Favicon DOM elements */
|
||
const faviconIco = document.getElementById("faviconIco");
|
||
const faviconSvg = document.getElementById("faviconSvg");
|
||
const appleTouchIcon = document.getElementById("appleTouchIcon");
|
||
|
||
/* Theme DOM elements */
|
||
const themeButton = document.getElementById("themeButton");
|
||
const themeIconSun = document.getElementById("themeIconSun");
|
||
const themeIconMoon = document.getElementById("themeIconMoon");
|
||
|
||
/* ============================================================
|
||
* GET ROOM FROM QUERY-PARAMETER
|
||
* ============================================================
|
||
*
|
||
* Example:
|
||
*
|
||
* https://<YOURJITSIINSTANCEADDRESS>/launch.html?room=abc-defg-hij
|
||
*
|
||
* results in:
|
||
*
|
||
* abc-defg-hij
|
||
*
|
||
* ============================================================
|
||
*/
|
||
|
||
function getRoom() {
|
||
const params = new URLSearchParams(
|
||
window.location.search
|
||
);
|
||
|
||
/*
|
||
* Parameter missing.
|
||
*/
|
||
if (!params.has("room")) {
|
||
return null;
|
||
}
|
||
|
||
/*
|
||
* Prevent whitespace etc.
|
||
*
|
||
* "?room=" without a value yields an empty string.
|
||
*/
|
||
return params.get("room").trim();
|
||
}
|
||
|
||
/* ============================================================
|
||
* VALIDATE ROOM
|
||
* ============================================================
|
||
*
|
||
* Jitsi room names can contain rather different
|
||
* characters depending on the configuration.
|
||
*
|
||
* We only prevent control characters and obvious
|
||
* URL manipulation here.
|
||
*
|
||
* ============================================================
|
||
*/
|
||
|
||
const ROOM_PATTERN = /^[A-Za-z0-9-]+$/;
|
||
|
||
function isValidRoom(room) {
|
||
if (!room) {
|
||
return false;
|
||
}
|
||
|
||
/*
|
||
* Only letters, numbers and hyphens.
|
||
*/
|
||
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
|
||
* ============================================================ */
|
||
|
||
/*
|
||
* The Android app registers "org.jitsi.meet", not the
|
||
* desktop scheme. Chrome & co. require intent://; without
|
||
* the app Chrome offers the Play Store entry from "package=".
|
||
* Firefox ignores intent:// but knows the custom scheme.
|
||
*/
|
||
|
||
function createAndroidIntentUrl(room) {
|
||
return (
|
||
"intent://" +
|
||
CONFIG.jitsiHost +
|
||
"/" +
|
||
encodeURIComponent(room) +
|
||
"#Intent;scheme=org.jitsi.meet;package=org.jitsi.meet;end"
|
||
);
|
||
}
|
||
|
||
/*
|
||
* The mobile apps (iOS, Firefox on Android) register
|
||
* "org.jitsi.meet", not the desktop scheme.
|
||
*/
|
||
const APP_SCHEME = "org.jitsi.meet://";
|
||
|
||
function createAppSchemeUrl(room) {
|
||
return (
|
||
APP_SCHEME +
|
||
CONFIG.jitsiHost +
|
||
"/" +
|
||
encodeURIComponent(room)
|
||
);
|
||
}
|
||
|
||
/* ============================================================
|
||
* URL BY PLATFORM/BROWSER
|
||
* ============================================================ */
|
||
|
||
function selectProtocolUrl(room) {
|
||
const os = detectOperatingSystem();
|
||
|
||
/*
|
||
* The iOS app only knows the app scheme.
|
||
*/
|
||
if (os === "ios") {
|
||
return createAppSchemeUrl(room);
|
||
}
|
||
|
||
if (os !== "android") {
|
||
return createAppUrl(room);
|
||
}
|
||
|
||
return detectBrowser() === "firefox"
|
||
? createAppSchemeUrl(room)
|
||
: createAndroidIntentUrl(room);
|
||
}
|
||
|
||
/* ============================================================
|
||
* DETECT OPERATING SYSTEM
|
||
* ============================================================
|
||
*/
|
||
|
||
/*
|
||
* Parse once (assets/lib/ua-parser.min.js).
|
||
* If the library is missing, the page stays usable.
|
||
*/
|
||
const uaResult =
|
||
typeof UAParser === "function"
|
||
? new UAParser().getResult()
|
||
: { os: {}, browser: {} };
|
||
|
||
/*
|
||
* UAParser names -> internal shortcuts.
|
||
* iPadOS reports as macOS – touch points count as 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 in "desktop mode" on Android sends a desktop
|
||
* UA (e.g. X11; Linux). Client hints reveal the real
|
||
* system. Firefox/Safari do not support them.
|
||
*/
|
||
const hints = navigator.userAgentData;
|
||
if (hints && hints.platform === "Android") {
|
||
return OS_NAMES.Android;
|
||
}
|
||
|
||
/*
|
||
* iPadOS masquerades as macOS – touch points give the
|
||
* iPad away. The platform check also works without the
|
||
* ua-parser loaded.
|
||
*/
|
||
if (
|
||
navigator.maxTouchPoints > 1 &&
|
||
(navigator.platform === "MacIntel" || uaResult.os.name === "macOS")
|
||
) {
|
||
return OS_NAMES.iOS;
|
||
}
|
||
|
||
return OS_NAMES[uaResult.os.name] || "unknown";
|
||
}
|
||
|
||
/* ============================================================
|
||
* DETECT BROWSER
|
||
* ============================================================
|
||
*/
|
||
|
||
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");
|
||
iosDownload.classList.add("hidden");
|
||
androidDownload.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;
|
||
}
|
||
|
||
/* iOS */
|
||
if (os === "ios") {
|
||
iosLink.href = CONFIG.downloads.ios;
|
||
iosDownload.classList.remove("hidden");
|
||
return;
|
||
}
|
||
|
||
/* Android */
|
||
if (os === "android") {
|
||
androidLink.href = CONFIG.downloads.android;
|
||
androidDownload.classList.remove("hidden");
|
||
return;
|
||
}
|
||
|
||
/* Everything else */
|
||
otherDownload.classList.remove("hidden");
|
||
}
|
||
|
||
/* ============================================================
|
||
* SHOW FALLBACK
|
||
* ============================================================
|
||
*/
|
||
|
||
function showFallback() {
|
||
starting.classList.add("hidden");
|
||
launcher.classList.add("hidden");
|
||
fallback.classList.remove("hidden");
|
||
|
||
configureDownloads();
|
||
}
|
||
|
||
/* ============================================================
|
||
* START JITSI DESKTOP
|
||
* ============================================================
|
||
*/
|
||
|
||
function launchJitsi() {
|
||
const room = getRoom();
|
||
|
||
if (!isValidRoom(room)) {
|
||
return;
|
||
}
|
||
|
||
const protocolUrl = selectProtocolUrl(room);
|
||
const browserUrl = createBrowserUrl(room);
|
||
|
||
/*
|
||
* Prepare the browser fallback.
|
||
*/
|
||
browserLink.href = browserUrl;
|
||
|
||
/*
|
||
* Set the UI to the "starting" state.
|
||
*/
|
||
launcher.classList.add("hidden");
|
||
fallback.classList.add("hidden");
|
||
starting.classList.remove("hidden");
|
||
|
||
let completed = false;
|
||
let timeout = null;
|
||
|
||
/*
|
||
* Invisible iframe for the protocol handler.
|
||
*/
|
||
const iframe = document.createElement("iframe");
|
||
iframe.style.display = "none";
|
||
|
||
/*
|
||
* Clean up.
|
||
*/
|
||
function cleanup() {
|
||
if (completed) {
|
||
return;
|
||
}
|
||
|
||
completed = true;
|
||
|
||
if (timeout !== null) {
|
||
clearTimeout(timeout);
|
||
}
|
||
|
||
iframe.remove();
|
||
}
|
||
|
||
/*
|
||
* --------------------------------------------------------
|
||
* IMPORTANT:
|
||
*
|
||
* Do not use "blur" as a success criterion anymore.
|
||
*
|
||
* The browser can also trigger blur when showing
|
||
* the protocol dialog.
|
||
*
|
||
* If the user cancels the dialog, the page stays
|
||
* visible and our timeout keeps running.
|
||
* --------------------------------------------------------
|
||
*/
|
||
|
||
document.body.appendChild(iframe);
|
||
|
||
/*
|
||
* Start the protocol handler.
|
||
*
|
||
* Firefox ignores external protocols from iframes –
|
||
* start the custom scheme directly in the main navigation.
|
||
*/
|
||
try {
|
||
if (protocolUrl.startsWith(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
|
||
* --------------------------------------------------------
|
||
*
|
||
* We wait regardless of whether the browser briefly
|
||
* loses focus.
|
||
*
|
||
* If Jitsi starts successfully:
|
||
*
|
||
* -> Jitsi comes to the foreground
|
||
* -> the user no longer sees this page
|
||
*
|
||
* If the user cancels the browser dialog:
|
||
*
|
||
* -> this page stays visible
|
||
* -> the timeout expires
|
||
* -> the fallback is shown
|
||
* --------------------------------------------------------
|
||
*/
|
||
|
||
timeout = setTimeout(() => {
|
||
if (completed) {
|
||
return;
|
||
}
|
||
|
||
cleanup();
|
||
showFallback();
|
||
}, CONFIG.launchTimeout);
|
||
}
|
||
|
||
/* ============================================================
|
||
* ROOM NAME
|
||
* ============================================================
|
||
*/
|
||
|
||
/*
|
||
* Convert special characters to the closest ASCII
|
||
* character.
|
||
*
|
||
* Example:
|
||
*
|
||
* "Grundstück" becomes "Grundstueck"
|
||
* "François" becomes "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]);
|
||
}
|
||
|
||
/*
|
||
* Derive a valid room name from the meeting name.
|
||
*
|
||
* Example:
|
||
*
|
||
* "Grund Stück Planung" becomes "grund-stuck-planung"
|
||
*/
|
||
function createRoomName(meetingName) {
|
||
return transliterate(meetingName)
|
||
.toLowerCase()
|
||
.replace(/\s+/g, "-")
|
||
.replace(/[^a-z0-9-]/g, "");
|
||
}
|
||
|
||
/* ============================================================
|
||
* ICS FILE GENERATION
|
||
* ============================================================
|
||
*/
|
||
|
||
const ICS_LINE_LIMIT = 75;
|
||
|
||
/*
|
||
* UTF-8 byte length of a string.
|
||
*/
|
||
function byteLength(text) {
|
||
let length = 0;
|
||
|
||
for (const char of text) {
|
||
const code = char.codePointAt(0);
|
||
|
||
length += code <= 0x7f ? 1 : code <= 0x7ff ? 2 : code <= 0xffff ? 3 : 4;
|
||
}
|
||
|
||
return length;
|
||
}
|
||
|
||
/*
|
||
* Escape a text value per RFC 5545: backslash first,
|
||
* then semicolon and comma, newlines as literal "\n".
|
||
*/
|
||
function escapeICS(text) {
|
||
return text
|
||
.replace(/\\/g, "\\\\")
|
||
.replace(/;/g, "\\;")
|
||
.replace(/,/g, "\\,")
|
||
.replace(/\r?\n/g, "\\n");
|
||
}
|
||
|
||
/*
|
||
* Fold lines longer than 75 octets into CRLF + space
|
||
* continuations (RFC 5545).
|
||
*/
|
||
function foldICS(content) {
|
||
return content
|
||
.split("\r\n")
|
||
.map((line) => {
|
||
if (byteLength(line) <= ICS_LINE_LIMIT) {
|
||
return line;
|
||
}
|
||
|
||
const parts = [];
|
||
let current = "";
|
||
|
||
for (const char of line) {
|
||
if (byteLength(current + char) > ICS_LINE_LIMIT) {
|
||
parts.push(current);
|
||
current = " " + char;
|
||
} else {
|
||
current += char;
|
||
}
|
||
}
|
||
|
||
parts.push(current);
|
||
|
||
return parts.join("\r\n");
|
||
})
|
||
.join("\r\n");
|
||
}
|
||
|
||
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);
|
||
|
||
/*
|
||
* One property per line, CRLF endings, folded at
|
||
* 75 octets (RFC 5545).
|
||
*/
|
||
const lines = [
|
||
"BEGIN:VCALENDAR",
|
||
"VERSION:2.0",
|
||
`PRODID:-//Jitsi Meeting Launcher//NONSGML (${CONFIG.jitsiLauncher})//EN`,
|
||
"CALSCALE:GREGORIAN",
|
||
"METHOD:PUBLISH",
|
||
"BEGIN:VEVENT",
|
||
`UID:${uid}`,
|
||
`DTSTAMP:${dtStamp}`,
|
||
`DTSTART:${dtStart}`,
|
||
`DTEND:${dtEnd}`,
|
||
`SUMMARY:${escapeICS(meetingName)}`,
|
||
`DESCRIPTION:${escapeICS(description)}`,
|
||
`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"
|
||
];
|
||
|
||
const icsContent = foldICS(lines.join("\r\n")) + "\r\n";
|
||
|
||
return {
|
||
content: icsContent,
|
||
roomName: roomName,
|
||
url: meetingUrl
|
||
};
|
||
}
|
||
|
||
let icsObjectUrl = null;
|
||
|
||
/*
|
||
* Download URL for the ICS content. Revokes the URL of
|
||
* the previous download.
|
||
*/
|
||
function downloadICS(content) {
|
||
const blob = new Blob([content], { type: 'text/calendar;charset=utf-8' });
|
||
|
||
if (icsObjectUrl !== null) {
|
||
URL.revokeObjectURL(icsObjectUrl);
|
||
}
|
||
|
||
icsObjectUrl = URL.createObjectURL(blob);
|
||
|
||
return icsObjectUrl;
|
||
}
|
||
|
||
const MINUTES_STEP = 15;
|
||
const DEFAULT_DURATION_MINUTES = 30;
|
||
|
||
/*
|
||
* Round minutes up to the 15-minute grid.
|
||
*
|
||
* Example:
|
||
*
|
||
* 14:07 becomes 14:15
|
||
* 14:50 becomes 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;
|
||
}
|
||
|
||
/*
|
||
* Format local time for datetime-local.
|
||
*
|
||
* Example:
|
||
*
|
||
* January 1, 2026, 14:15 becomes "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 openNextcloudCalendarCreator() {
|
||
const meetingName =
|
||
meetingNameInput.value.trim();
|
||
|
||
const description =
|
||
meetingDescriptionInput.value.trim();
|
||
|
||
const startTime =
|
||
meetingStartInput.value;
|
||
|
||
const endTime =
|
||
meetingEndInput.value;
|
||
|
||
const organizer =
|
||
meetingOrganizerInput.value.trim();
|
||
|
||
if (!meetingName) {
|
||
alert(getTranslation('creator.meetingNameRequired') || 'Please enter a meeting name.');
|
||
meetingNameInput.focus();
|
||
return;
|
||
}
|
||
|
||
if (!startTime || !endTime) {
|
||
alert(getTranslation('creator.startTimeRequired') || 'Please select a start time.');
|
||
return;
|
||
}
|
||
|
||
const start = new Date(startTime);
|
||
const end = new Date(endTime);
|
||
|
||
if (Number.isNaN(start.getTime()) ||
|
||
Number.isNaN(end.getTime())) {
|
||
alert('Invalid start or end date.');
|
||
return;
|
||
}
|
||
|
||
if (end <= start) {
|
||
alert(getTranslation('creator.endAfterStart') || 'End time must be after start time.');
|
||
return;
|
||
}
|
||
|
||
/*
|
||
* Nextcloud validates the email server-side and
|
||
* aborts with an error page otherwise.
|
||
*/
|
||
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||
|
||
if (organizer && !emailPattern.test(organizer)) {
|
||
alert(getTranslation('creator.organizerInvalid') || 'Please enter a valid organizer email address.');
|
||
return;
|
||
}
|
||
|
||
/*
|
||
* Use exactly the same room-generation logic as the
|
||
* existing ICS creation.
|
||
*/
|
||
const roomName = createRoomName(meetingName);
|
||
|
||
const meetingUrl =
|
||
createLaunchUrl(roomName);
|
||
|
||
const params = new URLSearchParams();
|
||
|
||
params.set('summary', meetingName);
|
||
|
||
if (description) {
|
||
params.set('description', description);
|
||
}
|
||
|
||
params.set('start', startTime);
|
||
params.set('end', endTime);
|
||
|
||
if (organizer) {
|
||
params.set('organizer', organizer);
|
||
}
|
||
|
||
params.set('location', meetingUrl);
|
||
params.set('url', meetingUrl);
|
||
|
||
const nextcloudUrl =
|
||
CONFIG.nextcloudBase +
|
||
'/index.php/apps/atocal/confirm?' +
|
||
params.toString();
|
||
|
||
window.location.href = nextcloudUrl;
|
||
}
|
||
|
||
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 */
|
||
downloadIcsLink.href = downloadICS(icsData.content);
|
||
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
|
||
* ============================================================
|
||
*/
|
||
|
||
/*
|
||
* Time span after which the "Copied!" text returns
|
||
* to the normal button label.
|
||
*/
|
||
const COPY_RESET_MS = 2000;
|
||
|
||
/*
|
||
* Launch URL that opens the meeting in the Jitsi client.
|
||
*
|
||
* Example:
|
||
*
|
||
* "projekt-kickoff" becomes
|
||
* 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, with a fallback for older browsers */
|
||
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);
|
||
}
|
||
|
||
/* Brief feedback, then back to the normal 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");
|
||
}
|
||
}
|
||
|
||
/* ============================================================
|
||
* INITIALIZATION
|
||
* ============================================================
|
||
*/
|
||
|
||
function initialize() {
|
||
const room = getRoom();
|
||
|
||
/*
|
||
* No room parameter: hide the messages,
|
||
* only show the Create button.
|
||
*/
|
||
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;
|
||
}
|
||
|
||
/*
|
||
* Empty room parameter ("?room="):
|
||
* show "No meeting specified".
|
||
*/
|
||
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;
|
||
}
|
||
|
||
/*
|
||
* Invalid room parameter: only show
|
||
* the invalid-name message.
|
||
*/
|
||
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;
|
||
}
|
||
|
||
/*
|
||
* Show the room.
|
||
*/
|
||
roomName.textContent = room;
|
||
|
||
/*
|
||
* Prepare the browser fallback.
|
||
*/
|
||
browserLink.href = createBrowserUrl(room);
|
||
|
||
/*
|
||
* Show the launcher.
|
||
*/
|
||
launcher.classList.remove("hidden");
|
||
|
||
/*
|
||
* Hide the "Create New Meeting" button when a valid room is specified
|
||
*/
|
||
createNewFromLauncherButton.classList.add("hidden");
|
||
|
||
/*
|
||
* Debug information.
|
||
*/
|
||
console.log(
|
||
"Jitsi Launcher",
|
||
{
|
||
language: currentLanguage,
|
||
room: room,
|
||
operatingSystem: detectOperatingSystem(),
|
||
touchPoints: navigator.maxTouchPoints,
|
||
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
|
||
);
|
||
|
||
if (createNextcloudButton) {
|
||
createNextcloudButton.addEventListener(
|
||
'click',
|
||
openNextcloudCalendarCreator
|
||
);
|
||
}
|
||
|
||
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
|
||
);
|
||
|
||
/*
|
||
* Follow the system if no choice is saved.
|
||
*/
|
||
window
|
||
.matchMedia("(prefers-color-scheme: dark)")
|
||
.addEventListener("change", () => {
|
||
if (savedTheme() === null) {
|
||
currentTheme = systemIsDark() ? "dark" : "light";
|
||
applyTheme();
|
||
}
|
||
});
|
||
|
||
/* ============================================================
|
||
* START
|
||
* ============================================================
|
||
*/
|
||
|
||
async function loadConfig() {
|
||
try {
|
||
const response = await fetch(CONFIG_URL);
|
||
CONFIG = await response.json();
|
||
} catch (error) {
|
||
/*
|
||
* Without the file the launcher stays unusable –
|
||
* report the error and continue with an empty config.
|
||
*/
|
||
console.error("Error loading config:", error);
|
||
}
|
||
}
|
||
|
||
async function loadTranslations() {
|
||
try {
|
||
const response = await fetch(I18N_URL);
|
||
TRANSLATIONS = await response.json();
|
||
} catch (error) {
|
||
/*
|
||
* Without the file the English text from the HTML stays.
|
||
*/
|
||
console.error("Error loading translations:", error);
|
||
}
|
||
|
||
currentLanguage = detectLanguage();
|
||
translation = TRANSLATIONS[currentLanguage] || {};
|
||
}
|
||
|
||
/*
|
||
* Apply theme/logo immediately so nothing flickers –
|
||
* text only after the translations have loaded.
|
||
*/
|
||
async function start() {
|
||
applyTheme();
|
||
|
||
await loadConfig();
|
||
|
||
applyBackgroundImage();
|
||
applyLogo();
|
||
applyFavicons();
|
||
applyNextcloudButton();
|
||
applyAdhocButton();
|
||
|
||
await loadTranslations();
|
||
|
||
applyTranslations();
|
||
updateLanguageMenu();
|
||
initialize();
|
||
}
|
||
|
||
start();
|