// deck-it - the page shell: tabs, cards and polling // Copyright (C) 2026 Danilo M. // // This program is free software; you can redistribute it and/or modify // it under the terms of the GNU General Public License version 2 as // published by the Free Software Foundation. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, write to the Free Software Foundation, Inc., // 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. // 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"); } function stepTab(dir) { const names = [...tabBar.children].map((button) => button.textContent); const at = names.findIndex((name) => !tabs[name]?.section.hidden); const next = at + dir; if (at !== -1 && next >= 0 && next < names.length) showTab(names[next]); } // Swipe the content sideways to change tab; gestures on sliders and buttons // (including the card drag handles) are theirs, not the shell's. let swipe = null; tabBody.addEventListener("pointerdown", (e) => { if (e.pointerType === "mouse" && e.buttons !== 1) return; if (e.target.closest("input, select, textarea, button")) { swipe = null; return; } swipe = { x: e.clientX, y: e.clientY }; }); tabBody.addEventListener("pointerup", (e) => { if (!swipe) return; const dx = e.clientX - swipe.x; const dy = e.clientY - swipe.y; swipe = null; if (Math.abs(dx) < 70 || Math.abs(dx) < Math.abs(dy) * 1.5) return; stepTab(dx < 0 ? 1 : -1); }); tabBody.addEventListener("pointercancel", () => { swipe = null; }); // theme.css is rendered per request from UDT's live files, so re-reading it // carries a scheme, wallpaper or font-size change to an open page. The