// The shell: discover modules, mount each panel as a Gridstack card, poll its // state. A module is static/modules/.js exporting default {title, // interval, mount(el), update(state, el)}. Card position and size are kept in // this browser's localStorage. const gridEl = document.getElementById("grid"); const refreshers = []; const LAYOUT_KEY = "deckit.layout"; const DEFAULT_W = 6; const DEFAULT_H = 4; const grid = GridStack.init({ column: 12, cellHeight: 80, margin: 8, float: true, handle: ".panel-drag", resizable: { handles: "se" }, }, gridEl); 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 } } let ready = false; 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 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(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"; content.append(header(title)); item.append(content); gridEl.append(item); grid.makeWidget(item); return content; } async function mountModule(name) { try { const panel = (await import(`./modules/${name}.js`)).default; const content = makeCard(name, panel.title ?? name); panel.mount?.(content); const poll = async () => { try { const r = await fetch(`/api/${name}`); if (r.ok) panel.update?.(await r.json(), content); } catch { // keep the last state; the next tick retries } }; refreshers.push(poll); await poll(); setInterval(poll, (panel.interval ?? 1) * 1000); } catch { // a broken panel must not take down the rest of the dashboard } } async function boot() { const r = await fetch("/api/modules"); if (!r.ok) return; for (const { name } of await r.json()) await mountModule(name); ready = true; } document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") refreshers.forEach((fn) => fn()); }); boot();