diff options
Diffstat (limited to 'static')
| -rw-r--r-- | static/app.js | 102 | ||||
| -rw-r--r-- | static/deck.css | 18 |
2 files changed, 115 insertions, 5 deletions
diff --git a/static/app.js b/static/app.js index ed19c60..526f57a 100644 --- a/static/app.js +++ b/static/app.js @@ -1,21 +1,102 @@ // The shell: discover modules, mount each panel, poll its state. // A module is static/modules/<name>.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"; -async function mountModule(name) { +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; - const title = document.createElement("h2"); - title.textContent = panel.title ?? name; - el.append(title); + 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 { @@ -36,7 +117,18 @@ async function mountModule(name) { async function boot() { const r = await fetch("/api/modules"); if (!r.ok) return; - for (const { name } of await r.json()) await mountModule(name); + 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", () => { diff --git a/static/deck.css b/static/deck.css index afe9379..200a651 100644 --- a/static/deck.css +++ b/static/deck.css @@ -35,6 +35,24 @@ body { min-width: 0; } +.panel { position: relative; } +.panel.wide { grid-column: span 2; } +@media (max-width: 620px) { .panel.wide { grid-column: auto; } } +.panel.dragging { opacity: .6; outline: 2px dashed var(--udt-accent); } + +.panel-head { display: flex; align-items: center; gap: 4px; margin-bottom: 12px; } +.panel .panel-head h2 { margin: 0; flex: 1; } +.panel-head button { + font: inherit; + color: var(--udt-overlay0); + background: transparent; + border: 0; + padding: 2px 8px; + cursor: pointer; + touch-action: none; +} +.drag-handle { cursor: grab; } + .panel h2 { font-size: 13px; font-weight: 500; |
