530 lines
22 KiB
JavaScript
530 lines
22 KiB
JavaScript
(() => {
|
|
const initializedForms = new WeakSet();
|
|
const initializedEnhancedForms = new WeakSet();
|
|
const initializedExpandables = new WeakSet();
|
|
const REFRESH_COOLDOWN_MS = 3000;
|
|
let saveBar = null;
|
|
let stream = null;
|
|
let dirtyNavigationBound = false;
|
|
let navigationController = null;
|
|
const eventSubscriptions = new Map();
|
|
|
|
const init = (root = document) => {
|
|
initFormSemantics(root);
|
|
initSettingsDirty(root);
|
|
initEnhancedForms(root);
|
|
initExpandables(root);
|
|
initSoftNavigation(root);
|
|
};
|
|
|
|
function semanticId(value) {
|
|
return String(value || "field").toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "field";
|
|
}
|
|
|
|
function initFormSemantics(root) {
|
|
root.querySelectorAll?.("form").forEach((form, formIndex) => {
|
|
const formKey = semanticId(form.dataset.lumiFormId || form.getAttribute("action") || `form-${formIndex + 1}`);
|
|
form.querySelectorAll("input:not([type='hidden']), select, textarea").forEach((control, controlIndex) => {
|
|
const wrapped = control.closest("label");
|
|
const field = control.closest(".field, fieldset");
|
|
const directLabel = field && !wrapped
|
|
? Array.from(field.children).find((item) => item.tagName === "LABEL" && !item.contains(control))
|
|
: null;
|
|
if (!control.id && (wrapped || directLabel)) {
|
|
control.id = `lumi-${formKey}-${semanticId(control.name || control.type)}-${controlIndex + 1}`;
|
|
}
|
|
if (directLabel && !directLabel.htmlFor) directLabel.htmlFor = control.id;
|
|
const help = field?.querySelector(":scope > .hint, :scope > small.hint");
|
|
if (help && control.id) {
|
|
if (!help.id) help.id = `${control.id}-help`;
|
|
const ids = new Set(String(control.getAttribute("aria-describedby") || "").split(/\s+/).filter(Boolean));
|
|
ids.add(help.id);
|
|
control.setAttribute("aria-describedby", Array.from(ids).join(" "));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
const ensureSaveBar = () => {
|
|
if (saveBar) return saveBar;
|
|
saveBar = document.createElement("div");
|
|
saveBar.className = "lumi-savebar";
|
|
saveBar.hidden = true;
|
|
saveBar.innerHTML = `
|
|
<div>
|
|
<strong data-savebar-count>Unsaved changes</strong>
|
|
<span class="hint" data-savebar-status role="status" aria-live="polite">Review and save changed settings on this page.</span>
|
|
</div>
|
|
<div class="lumi-savebar-actions">
|
|
<button type="button" class="button subtle" data-savebar-discard>Revert changes</button>
|
|
<button type="button" class="button" data-savebar-submit>Save changes</button>
|
|
</div>
|
|
`;
|
|
document.body.append(saveBar);
|
|
saveBar.querySelector("[data-savebar-submit]").addEventListener("click", saveDirtyForms);
|
|
saveBar.querySelector("[data-savebar-discard]").addEventListener("click", discardDirtyForms);
|
|
return saveBar;
|
|
};
|
|
|
|
const formFields = (form) => Array.from(form.elements).filter((field) =>
|
|
field.name && !field.disabled && !["submit", "button", "reset", "file"].includes(field.type)
|
|
);
|
|
|
|
const fieldValue = (field) => {
|
|
if (field.type === "checkbox") return field.checked ? "on" : "";
|
|
if (field.type === "radio") return field.checked ? field.value : "";
|
|
return field.value;
|
|
};
|
|
|
|
const snapshotForm = (form) => {
|
|
const snapshot = new Map();
|
|
for (const field of formFields(form)) {
|
|
if (field.type === "radio") {
|
|
if (!snapshot.has(field.name)) snapshot.set(field.name, "");
|
|
if (field.checked) snapshot.set(field.name, field.value);
|
|
} else {
|
|
snapshot.set(field.name, fieldValue(field));
|
|
}
|
|
}
|
|
return snapshot;
|
|
};
|
|
|
|
const isFieldDirty = (field, snapshot) => {
|
|
const original = snapshot.get(field.name) || "";
|
|
if (field.type === "radio") {
|
|
return field.checked && field.value !== original;
|
|
}
|
|
return fieldValue(field) !== original;
|
|
};
|
|
|
|
const updateDirtyState = () => {
|
|
const forms = Array.from(document.querySelectorAll("form[data-lumi-settings-form]"));
|
|
let dirtyCount = 0;
|
|
for (const form of forms) {
|
|
const snapshot = form._lumiSnapshot || snapshotForm(form);
|
|
let formDirty = false;
|
|
for (const field of formFields(form)) {
|
|
const dirty = isFieldDirty(field, snapshot);
|
|
const container = field.closest(".field, .theme-color-control, .theme-range-control, .theme-select-control, fieldset");
|
|
container?.classList.toggle("is-unsaved", dirty);
|
|
formDirty = formDirty || dirty;
|
|
if (dirty) dirtyCount += 1;
|
|
}
|
|
form.classList.toggle("has-unsaved-settings", formDirty);
|
|
}
|
|
const bar = ensureSaveBar();
|
|
bar.hidden = dirtyCount === 0;
|
|
bar.classList.toggle("is-visible", dirtyCount > 0);
|
|
bar.querySelector("[data-savebar-count]").textContent =
|
|
dirtyCount === 1 ? "1 unsaved setting" : `${dirtyCount} unsaved settings`;
|
|
if (dirtyCount === 0) bar.querySelector("[data-savebar-status]").textContent = "Saved.";
|
|
};
|
|
|
|
function initSettingsDirty(root) {
|
|
root.querySelectorAll?.("form[data-lumi-settings-form]").forEach((form) => {
|
|
if (initializedForms.has(form)) return;
|
|
initializedForms.add(form);
|
|
form._lumiSnapshot = snapshotForm(form);
|
|
form.addEventListener("input", (event) => {
|
|
form._lumiLastEditedField = event.target;
|
|
updateDirtyState();
|
|
});
|
|
form.addEventListener("change", (event) => {
|
|
form._lumiLastEditedField = event.target;
|
|
updateDirtyState();
|
|
});
|
|
form.addEventListener("submit", () => {
|
|
form._lumiSnapshot = snapshotForm(form);
|
|
window.setTimeout(updateDirtyState, 0);
|
|
});
|
|
});
|
|
if (document.querySelector("form[data-lumi-settings-form]")) {
|
|
ensureSaveBar();
|
|
updateDirtyState();
|
|
if (!dirtyNavigationBound) {
|
|
window.addEventListener("beforeunload", warnDirtyNavigation);
|
|
dirtyNavigationBound = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
function ensureFormStatus(form) {
|
|
let status = form.querySelector("[data-lumi-form-status]");
|
|
if (status) return status;
|
|
status = document.createElement("p");
|
|
status.className = "hint lumi-form-status";
|
|
status.dataset.lumiFormStatus = "";
|
|
status.setAttribute("role", "status");
|
|
status.setAttribute("aria-live", "polite");
|
|
const actions = form.querySelector(".form-actions, .modal-actions");
|
|
(actions || form).append(status);
|
|
return status;
|
|
}
|
|
|
|
function initEnhancedForms(root) {
|
|
root.querySelectorAll?.("form[data-lumi-enhanced-form]").forEach((form) => {
|
|
if (initializedEnhancedForms.has(form)) return;
|
|
initializedEnhancedForms.add(form);
|
|
form.addEventListener("submit", async (event) => {
|
|
const submitter = event.submitter;
|
|
if (submitter?.matches("[data-no-enhance]") || !window.LumiRequest) return;
|
|
if (!form.checkValidity()) {
|
|
event.preventDefault();
|
|
form.reportValidity();
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
clearFieldErrors(form);
|
|
const status = ensureFormStatus(form);
|
|
const label = submitter?.textContent || "Save";
|
|
const isStateButton = submitter?.matches?.("[data-lumi-state-button]");
|
|
if (isStateButton) {
|
|
window.LumiStateButton?.setState?.(submitter, submitter.dataset.loadingState || "loading", { busy: true });
|
|
} else if (submitter) {
|
|
submitter.disabled = true;
|
|
submitter.setAttribute("aria-busy", "true");
|
|
submitter.textContent = submitter.dataset.pendingLabel || "Saving…";
|
|
}
|
|
form.setAttribute("aria-busy", "true");
|
|
status.setAttribute("role", "status");
|
|
status.textContent = "Saving…";
|
|
try {
|
|
const result = await window.LumiRequest.form(form, { submitter });
|
|
form._lumiSnapshot = snapshotForm(form);
|
|
form.classList.remove("has-unsaved-settings");
|
|
status.textContent = result.message || "Saved.";
|
|
form.dispatchEvent(new CustomEvent("lumi:form-saved", { bubbles: true, detail: result }));
|
|
if (isStateButton) window.LumiStateButton?.success?.(submitter);
|
|
if (result.reloadRequired) window.location.reload();
|
|
else if (result.redirect && form.dataset.lumiFollowRedirect === "true") window.location.assign(result.redirect);
|
|
} catch (error) {
|
|
status.setAttribute("role", "alert");
|
|
status.textContent = error.message || "The change could not be saved. Your input is still here.";
|
|
applyFieldErrors(form, error.fieldErrors)?.focus();
|
|
form.dispatchEvent(new CustomEvent("lumi:form-error", { bubbles: true, detail: { error } }));
|
|
if (isStateButton) window.LumiStateButton?.error?.(submitter);
|
|
} finally {
|
|
form.removeAttribute("aria-busy");
|
|
if (submitter && !isStateButton) {
|
|
submitter.disabled = false;
|
|
submitter.removeAttribute("aria-busy");
|
|
submitter.textContent = label;
|
|
}
|
|
if (document.querySelector("form[data-lumi-settings-form]")) updateDirtyState();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function clearFieldErrors(form) {
|
|
form.querySelectorAll("[data-lumi-field-error]").forEach((item) => item.remove());
|
|
form.querySelectorAll("[aria-invalid='true']").forEach((field) => {
|
|
field.removeAttribute("aria-invalid");
|
|
const describedBy = String(field.getAttribute("aria-describedby") || "")
|
|
.split(/\s+/).filter((id) => id && !id.startsWith("lumi-error-"));
|
|
if (describedBy.length) field.setAttribute("aria-describedby", describedBy.join(" "));
|
|
else field.removeAttribute("aria-describedby");
|
|
});
|
|
form.querySelector("[data-lumi-form-error]")?.remove();
|
|
}
|
|
|
|
function applyFieldErrors(form, fieldErrors = {}) {
|
|
clearFieldErrors(form);
|
|
const entries = Object.entries(fieldErrors || {}).filter(([, message]) => message);
|
|
if (!entries.length) return null;
|
|
const summary = document.createElement("div");
|
|
summary.className = "notice danger lumi-form-error-summary";
|
|
summary.dataset.lumiFormError = "";
|
|
summary.setAttribute("role", "alert");
|
|
summary.textContent = entries.length === 1 ? "Correct the highlighted field and try again." : `Correct ${entries.length} highlighted fields and try again.`;
|
|
form.prepend(summary);
|
|
let first = null;
|
|
entries.forEach(([name, message], index) => {
|
|
const field = form.elements.namedItem(name);
|
|
const control = field instanceof RadioNodeList ? field[0] : field;
|
|
if (!(control instanceof HTMLElement)) return;
|
|
first ||= control;
|
|
const id = `lumi-error-${Date.now()}-${index}`;
|
|
const error = document.createElement("span");
|
|
error.id = id;
|
|
error.className = "field-error";
|
|
error.dataset.lumiFieldError = "";
|
|
error.textContent = String(message);
|
|
control.setAttribute("aria-invalid", "true");
|
|
control.setAttribute("aria-describedby", `${control.getAttribute("aria-describedby") || ""} ${id}`.trim());
|
|
(control.closest(".field, fieldset") || control.parentElement)?.append(error);
|
|
});
|
|
return first;
|
|
}
|
|
|
|
async function saveDirtyForms() {
|
|
const bar = ensureSaveBar();
|
|
const button = bar.querySelector("[data-savebar-submit]");
|
|
const discard = bar.querySelector("[data-savebar-discard]");
|
|
const status = bar.querySelector("[data-savebar-status]");
|
|
const forms = Array.from(document.querySelectorAll("form[data-lumi-settings-form].has-unsaved-settings"));
|
|
if (!forms.length) return;
|
|
const focusReturn = [...forms].reverse().map((form) => form._lumiLastEditedField).find((field) => field?.isConnected);
|
|
button.disabled = true;
|
|
discard.disabled = true;
|
|
button.setAttribute("aria-busy", "true");
|
|
status.textContent = forms.length === 1 ? "Saving 1 section…" : `Saving ${forms.length} sections…`;
|
|
bar.classList.remove("has-error", "has-success");
|
|
let saved = 0;
|
|
const failures = [];
|
|
let firstInvalid = null;
|
|
for (const form of forms) {
|
|
clearFieldErrors(form);
|
|
form.setAttribute("aria-busy", "true");
|
|
try {
|
|
const result = await window.LumiRequest.form(form);
|
|
form._lumiSnapshot = snapshotForm(form);
|
|
form.classList.remove("has-unsaved-settings");
|
|
saved += 1;
|
|
if (result.reloadRequired) {
|
|
status.textContent = result.message || "Saved. Lumi needs to reload this page to finish applying the change.";
|
|
window.removeEventListener("beforeunload", warnDirtyNavigation);
|
|
window.location.reload();
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
failures.push({ form, error });
|
|
firstInvalid ||= applyFieldErrors(form, error.fieldErrors);
|
|
} finally {
|
|
form.removeAttribute("aria-busy");
|
|
}
|
|
}
|
|
if (failures.length) {
|
|
const failed = failures.length;
|
|
status.textContent = saved
|
|
? `${saved} section${saved === 1 ? "" : "s"} saved. ${failed} failed and remains unsaved: ${failures[0].error.message}`
|
|
: `Nothing was saved. ${failures[0].error.message}`;
|
|
bar.classList.add("has-error");
|
|
status.setAttribute("role", "alert");
|
|
firstInvalid?.focus({ preventScroll: false });
|
|
} else {
|
|
const successMessage = forms.length === 1 ? "Section saved." : `${saved} sections saved.`;
|
|
status.textContent = successMessage;
|
|
status.setAttribute("role", "status");
|
|
bar.classList.add("has-success");
|
|
showEventNotice({ message: successMessage }, "success");
|
|
}
|
|
updateDirtyState();
|
|
if (!failures.length && focusReturn) focusReturn.focus({ preventScroll: true });
|
|
button.disabled = false;
|
|
discard.disabled = false;
|
|
button.removeAttribute("aria-busy");
|
|
}
|
|
|
|
function discardDirtyForms() {
|
|
const forms = Array.from(document.querySelectorAll("form[data-lumi-settings-form].has-unsaved-settings"));
|
|
for (const form of forms) {
|
|
const snapshot = form._lumiSnapshot;
|
|
if (!snapshot) continue;
|
|
for (const field of formFields(form)) {
|
|
const original = snapshot.get(field.name) || "";
|
|
if (field.type === "checkbox") field.checked = original === "on";
|
|
else if (field.type === "radio") field.checked = field.value === original;
|
|
else field.value = original;
|
|
field.dispatchEvent(new Event("input", { bubbles: true }));
|
|
field.dispatchEvent(new Event("change", { bubbles: true }));
|
|
}
|
|
clearFieldErrors(form);
|
|
}
|
|
const bar = ensureSaveBar();
|
|
bar.querySelector("[data-savebar-status]").textContent = forms.length === 1 ? "Changes reverted." : `${forms.length} sections reverted.`;
|
|
updateDirtyState();
|
|
}
|
|
|
|
function warnDirtyNavigation(event) {
|
|
if (!document.querySelector("form[data-lumi-settings-form].has-unsaved-settings")) return;
|
|
event.preventDefault();
|
|
event.returnValue = "";
|
|
}
|
|
|
|
function initExpandables(root) {
|
|
root.querySelectorAll?.("[data-lumi-expandable-settings]").forEach((item) => {
|
|
if (initializedExpandables.has(item)) return;
|
|
initializedExpandables.add(item);
|
|
item.querySelectorAll("[data-placeholder-preview]").forEach(updatePlaceholderPreview);
|
|
item.addEventListener("input", () => {
|
|
item.querySelectorAll("[data-placeholder-preview]").forEach(updatePlaceholderPreview);
|
|
});
|
|
});
|
|
}
|
|
|
|
function updatePlaceholderPreview(target) {
|
|
const source = target.closest("[data-lumi-expandable-settings]")?.querySelector(target.dataset.placeholderPreview);
|
|
const text = source?.value || target.dataset.fallback || "";
|
|
const replacements = {
|
|
gifter_username: "SomeUser123",
|
|
item_name: "Cool Item",
|
|
creator_username: "CreatorName",
|
|
amount_display: "$12.34",
|
|
username: "SomeUser123",
|
|
platform: "Twitch"
|
|
};
|
|
target.textContent = text.replace(/\{([^{}]+)\}/g, (full, key) => replacements[key] || full);
|
|
}
|
|
|
|
function connectEvents() {
|
|
if (!window.EventSource || stream || document.body?.dataset.authenticated !== "true") return;
|
|
stream = new EventSource("/api/events");
|
|
stream.addEventListener("server:warning", (event) => showEventNotice(readEvent(event), "warning"));
|
|
stream.addEventListener("server:status", (event) => {
|
|
const data = readEvent(event);
|
|
if (data.status === "connected") {
|
|
document.body.dataset.eventStream = "connected";
|
|
window.dispatchEvent(new CustomEvent("lumi:event-status", { detail: { status: "connected" } }));
|
|
}
|
|
});
|
|
stream.addEventListener("ai:model_status", (event) => showEventNotice(readEvent(event), "danger"));
|
|
stream.addEventListener("data:new_available", (event) => showRefreshPrompt(readEvent(event)));
|
|
stream.addEventListener("log:created", (event) => {
|
|
window.dispatchEvent(new CustomEvent("lumi:log-created", { detail: readEvent(event) }));
|
|
});
|
|
stream.onerror = () => {
|
|
document.body.dataset.eventStream = "disconnected";
|
|
window.dispatchEvent(new CustomEvent("lumi:event-status", { detail: { status: "disconnected" } }));
|
|
};
|
|
for (const [eventName, listeners] of eventSubscriptions) {
|
|
for (const listener of listeners) stream.addEventListener(eventName, listener);
|
|
}
|
|
}
|
|
|
|
function subscribeEvent(eventName, listener) {
|
|
if (!eventName || typeof listener !== "function") return () => {};
|
|
let listeners = eventSubscriptions.get(eventName);
|
|
if (!listeners) {
|
|
listeners = new Set();
|
|
eventSubscriptions.set(eventName, listeners);
|
|
}
|
|
listeners.add(listener);
|
|
stream?.addEventListener(eventName, listener);
|
|
connectEvents();
|
|
return () => {
|
|
listeners.delete(listener);
|
|
stream?.removeEventListener(eventName, listener);
|
|
if (!listeners.size) eventSubscriptions.delete(eventName);
|
|
};
|
|
}
|
|
|
|
function closeEvents() {
|
|
stream?.close();
|
|
stream = null;
|
|
}
|
|
|
|
function readEvent(event) {
|
|
try { return JSON.parse(event.data || "{}"); } catch { return {}; }
|
|
}
|
|
|
|
function noticeRoot() {
|
|
let root = document.querySelector("[data-lumi-event-notices]");
|
|
if (!root) {
|
|
root = document.createElement("div");
|
|
root.className = "lumi-event-notices";
|
|
root.dataset.lumiEventNotices = "";
|
|
document.body.append(root);
|
|
}
|
|
return root;
|
|
}
|
|
|
|
function showEventNotice(data, tone = "info") {
|
|
const item = document.createElement("div");
|
|
item.className = `lumi-event-notice ${tone}`;
|
|
item.setAttribute("role", tone === "danger" ? "alert" : "status");
|
|
item.textContent = data.message || data.status || "Lumi status changed.";
|
|
noticeRoot().append(item);
|
|
window.setTimeout(() => item.remove(), 9000);
|
|
}
|
|
|
|
function showRefreshPrompt(data) {
|
|
const item = document.createElement("div");
|
|
item.className = "lumi-refresh-prompt";
|
|
item.setAttribute("role", "status");
|
|
const label = document.createElement("span");
|
|
label.textContent = data.message || "New data is available.";
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = "button subtle";
|
|
button.textContent = "Refresh";
|
|
button.addEventListener("click", () => {
|
|
button.disabled = true;
|
|
window.setTimeout(() => { button.disabled = false; }, REFRESH_COOLDOWN_MS);
|
|
if (data.url) window.location.assign(data.url);
|
|
else window.location.reload();
|
|
});
|
|
item.append(label, button);
|
|
noticeRoot().append(item);
|
|
}
|
|
|
|
function initSoftNavigation(root) {
|
|
root.querySelectorAll?.("a[data-lumi-soft-nav][href]").forEach((link) => {
|
|
if (link.dataset.softNavBound || link.target || link.hasAttribute("download")) return;
|
|
const url = new URL(link.href, window.location.href);
|
|
if (url.origin !== window.location.origin || url.pathname.startsWith("/auth/")) return;
|
|
link.dataset.softNavBound = "true";
|
|
link.addEventListener("click", (event) => {
|
|
if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
const main = document.querySelector("main.content");
|
|
if (!main || document.querySelector("form[data-lumi-settings-form].has-unsaved-settings")) return;
|
|
event.preventDefault();
|
|
softNavigate(url.href);
|
|
});
|
|
});
|
|
}
|
|
|
|
async function softNavigate(url, push = true) {
|
|
const main = document.querySelector("main.content");
|
|
if (!main) return window.location.assign(url);
|
|
navigationController?.abort();
|
|
navigationController = new AbortController();
|
|
main.classList.add("is-soft-loading");
|
|
try {
|
|
const response = await fetch(url, { headers: { "X-Lumi-Soft-Navigation": "1" }, signal: navigationController.signal });
|
|
if (!response.ok) throw new Error("Navigation failed.");
|
|
const html = await response.text();
|
|
const doc = new DOMParser().parseFromString(html, "text/html");
|
|
const nextMain = doc.querySelector("main.content");
|
|
if (!nextMain) throw new Error("Navigation target did not contain page content.");
|
|
window.LumiPage?.unmount();
|
|
document.title = doc.title || document.title;
|
|
main.replaceChildren(...Array.from(nextMain.childNodes));
|
|
main.classList.remove("is-soft-loading");
|
|
main.classList.add("is-soft-loaded");
|
|
window.setTimeout(() => main.classList.remove("is-soft-loaded"), 180);
|
|
updateActiveNavigation(new URL(url, window.location.href).pathname);
|
|
if (push) history.pushState({ lumiSoft: true }, "", url);
|
|
init(main);
|
|
window.LumiPage?.mount(main);
|
|
window.scrollTo({ top: 0, behavior: "auto" });
|
|
const focusTarget = main.querySelector("h1") || main;
|
|
if (!focusTarget.hasAttribute("tabindex")) focusTarget.setAttribute("tabindex", "-1");
|
|
focusTarget.focus({ preventScroll: true });
|
|
} catch {
|
|
window.location.assign(url);
|
|
}
|
|
}
|
|
|
|
function updateActiveNavigation(pathname) {
|
|
document.querySelectorAll(".nav-link").forEach((link) => {
|
|
const url = new URL(link.href, window.location.href);
|
|
link.classList.toggle("active", url.pathname === pathname || (url.pathname !== "/" && pathname.startsWith(`${url.pathname}/`)));
|
|
});
|
|
}
|
|
|
|
window.addEventListener("popstate", (event) => {
|
|
if (event.state?.lumiSoft) softNavigate(window.location.href, false);
|
|
});
|
|
window.LumiEvents = Object.freeze({ subscribe: subscribeEvent, connect: connectEvents, close: closeEvents });
|
|
window.LumiInteractions = { init, connectEvents, showEventNotice, showRefreshPrompt };
|
|
window.addEventListener("beforeunload", closeEvents);
|
|
window.addEventListener("pagehide", closeEvents);
|
|
window.addEventListener("pageshow", connectEvents);
|
|
window.addEventListener("focus", connectEvents);
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
init(document);
|
|
connectEvents();
|
|
});
|
|
})();
|