// The shell: discover modules, sort them into tabs, mount each panel and poll // its state. A module is static/modules/.js exporting default {title, // tab, interval, mount(el), update(state, el)}. Modules without a tab go into // the Dashboard. Card order and size are kept in this browser's localStorage. const tabBar = document.getElementById("tabs"); const tabBody = document.getElementById("tab-body"); const refreshers = []; const LAYOUT_KEY = "deckit.layout"; const TAB_KEY = "deckit.tab"; const DEFAULT_W = 6; const DEFAULT_H = 4; const tabs = {}; let ready = false; function loadLayout() { try { const saved = JSON.parse(localStorage.getItem(LAYOUT_KEY)); return Array.isArray(saved) ? saved : []; } catch { return []; } } function saveLayout() { if (!ready) return; // don't clobber the stored layout while the cards mount try { localStorage.setItem(LAYOUT_KEY, JSON.stringify(grid.save(false))); } catch { // private mode or storage full: the layout just won't persist } } function ensureTab(name) { if (tabs[name]) return tabs[name]; const button = document.createElement("button"); button.textContent = name; button.onclick = () => showTab(name); tabBar.append(button); const section = document.createElement("section"); section.className = "tab-panel"; section.dataset.tab = name; section.hidden = true; tabBody.append(section); tabs[name] = { button, section }; return tabs[name]; } const TAB_ORDER = ["Dashboard", "Windows", "OBS"]; function sortTabs() { const rank = (name) => { const at = TAB_ORDER.indexOf(name); return at === -1 ? TAB_ORDER.length : at; }; for (const name of Object.keys(tabs).sort((a, b) => rank(a) - rank(b))) { tabBar.append(tabs[name].button); tabBody.append(tabs[name].section); } } function showTab(name) { for (const [n, tab] of Object.entries(tabs)) { tab.section.hidden = n !== name; tab.button.classList.toggle("active", n === name); } try { localStorage.setItem(TAB_KEY, name); } catch {} if (name === "Dashboard") window.dispatchEvent(new Event("resize")); } // Dashboard is the grid; it exists before the modules so Gridstack measures a // visible container. const dashboard = ensureTab("Dashboard"); dashboard.section.hidden = false; dashboard.button.classList.add("active"); const gridEl = document.createElement("div"); gridEl.id = "grid"; gridEl.className = "grid-stack"; dashboard.section.append(gridEl); const grid = GridStack.init({ column: 12, cellHeight: 80, margin: 8, float: true, handle: ".panel-drag", resizable: { handles: "se" }, }, gridEl); grid.on("change", saveLayout); const savedById = {}; for (const node of loadLayout()) { if (node.id) savedById[node.id] = node; } function header(title) { const head = document.createElement("div"); head.className = "panel-head"; const dot = document.createElement("span"); dot.className = "panel-dot"; const h = document.createElement("h2"); h.textContent = title; const drag = document.createElement("button"); drag.className = "panel-drag"; drag.textContent = "⠿"; drag.setAttribute("aria-label", "Move panel"); head.append(dot, h, drag); return head; } function makeCard(name, title) { const item = document.createElement("div"); item.className = "grid-stack-item"; item.setAttribute("gs-id", name); const s = savedById[name]; item.setAttribute("gs-w", String(s?.w ?? DEFAULT_W)); item.setAttribute("gs-h", String(s?.h ?? DEFAULT_H)); if (s) { item.setAttribute("gs-x", String(s.x)); item.setAttribute("gs-y", String(s.y)); } const content = document.createElement("div"); content.className = "grid-stack-item-content panel"; const body = document.createElement("div"); body.className = "panel-body"; content.append(header(title), body); item.append(content); gridEl.append(item); grid.makeWidget(item); return body; } async function startPoll(name, panel, el) { const poll = async () => { try { const r = await fetch(`/api/${name}`); if (r.ok) panel.update?.(await r.json(), el); } catch { // keep the last state; the next tick retries } }; refreshers.push(poll); await poll(); setInterval(poll, (panel.interval ?? 1) * 1000); } async function mountDashboard(name, panel) { const body = makeCard(name, panel.title ?? name); panel.mount?.(body); await startPoll(name, panel, body); } async function mountTab(name, panel, section) { const wrap = document.createElement("div"); wrap.className = "tab-content"; section.append(wrap); panel.mount?.(wrap); await startPoll(name, panel, wrap); } async function boot() { const r = await fetch("/api/modules"); if (!r.ok) return; for (const { name } of await r.json()) { let panel; try { panel = (await import(`./modules/${name}.js`)).default; } catch { continue; } const tabName = panel.tab ?? "Dashboard"; try { if (tabName === "Dashboard") await mountDashboard(name, panel); else await mountTab(name, panel, ensureTab(tabName).section); } catch { // a broken panel must not take down the rest of the dashboard } } ready = true; sortTabs(); const saved = localStorage.getItem(TAB_KEY); showTab(tabs[saved] ? saved : "Dashboard"); } document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") refreshers.forEach((fn) => fn()); }); boot();