// The shell: discover modules, mount each panel, poll its state. // A module is static/modules/.js exporting default {title, interval, // mount(el), update(state, el)}. A missing or broken panel is skipped. // Panel order and width are kept in this browser's localStorage. const grid = document.getElementById("grid"); const refreshers = []; const LAYOUT_KEY = "deckit.layout"; function loadLayout() { try { return JSON.parse(localStorage.getItem(LAYOUT_KEY)) || {}; } catch { return {}; } } function saveLayout(layout) { try { localStorage.setItem(LAYOUT_KEY, JSON.stringify(layout)); } catch { // private mode or storage full: the layout just won't persist } } function panelOrder() { return [...grid.querySelectorAll(".panel")].map((p) => p.dataset.module); } function enableDrag(el) { const handle = el.querySelector(".drag-handle"); handle.addEventListener("pointerdown", (start) => { start.preventDefault(); el.classList.add("dragging"); handle.setPointerCapture(start.pointerId); const move = (ev) => { const target = [...grid.querySelectorAll(".panel")].find((p) => { if (p === el) return false; const r = p.getBoundingClientRect(); return ev.clientX >= r.left && ev.clientX <= r.right && ev.clientY >= r.top && ev.clientY <= r.bottom; }); if (!target) return; const r = target.getBoundingClientRect(); const after = ev.clientY > r.top + r.height / 2; grid.insertBefore(el, after ? target.nextSibling : target); }; const end = () => { el.classList.remove("dragging"); handle.releasePointerCapture(start.pointerId); handle.removeEventListener("pointermove", move); handle.removeEventListener("pointerup", end); handle.removeEventListener("pointercancel", end); saveLayout({ ...loadLayout(), order: panelOrder() }); }; handle.addEventListener("pointermove", move); handle.addEventListener("pointerup", end); handle.addEventListener("pointercancel", end); }); } function enableResize(el, name) { el.querySelector(".resize-handle").addEventListener("click", () => { el.classList.toggle("wide"); const layout = loadLayout(); layout.spans = { ...(layout.spans ?? {}), [name]: el.classList.contains("wide") ? 2 : 1 }; saveLayout(layout); }); } 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 = "drag-handle"; drag.textContent = "≡"; drag.setAttribute("aria-label", "Move panel"); const resize = document.createElement("button"); resize.className = "resize-handle"; resize.textContent = "⤢"; resize.setAttribute("aria-label", "Toggle panel width"); head.append(h, drag, resize); return head; } async function mountModule(name, layout) { try { const panel = (await import(`./modules/${name}.js`)).default; const el = document.createElement("section"); el.className = "panel"; el.dataset.module = name; if ((layout.spans ?? {})[name] >= 2) el.classList.add("wide"); el.append(header(panel.title ?? name)); grid.append(el); panel.mount?.(el); enableDrag(el); enableResize(el, name); 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); } 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; const layout = loadLayout(); const order = layout.order ?? []; const mods = await r.json(); mods.sort((a, b) => { const ia = order.indexOf(a.name); const ib = order.indexOf(b.name); if (ia === -1 && ib === -1) return 0; if (ia === -1) return 1; if (ib === -1) return -1; return ia - ib; }); for (const { name } of mods) await mountModule(name, layout); } document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") refreshers.forEach((fn) => fn()); }); boot();