(() => { 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 = `
Unsaved changes Review and save changed settings on this page.
`; 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(); }); })();