From 84916f24d22cc32b5f51bbc7794b37848469edc8 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 10 Nov 2026 10:40:37 +0100 Subject: feat: fold dashboard cards and a reload button Each Dashboard card gets a fold button in its title bar. Folded, it shrinks to one grid row ending at the title bar and cannot be resized; its height is kept in localStorage (deckit.folded) and restored on unfold. The grid floats, so nothing moved into the rows a folded card gave back: cards below it in its columns are pulled up as far as the space is free, on fold and again once every card has mounted, since a card restored folded from storage never folds at runtime. Unfolding needs no mirror; the grid pushes down whatever the regrown card hits. The installed web app has no browser chrome and so no pull-to-refresh, which left no way to load new page code on the tablet. A reload button sits at the top right; JS and CSS are served no-store, so it always fetches the current code. Co-Authored-By: Claude Opus 5.5 --- static/app.js | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- static/deck.css | 33 ++++++++++++++++++++++++ static/index.html | 1 + 3 files changed, 109 insertions(+), 1 deletion(-) (limited to 'static') diff --git a/static/app.js b/static/app.js index 4c562ff..31083a6 100644 --- a/static/app.js +++ b/static/app.js @@ -20,10 +20,13 @@ // the Dashboard. Card order and size are kept in this browser's localStorage. const tabBar = document.getElementById("tabs"); +document.getElementById("reload").onclick = () => location.reload(); const tabBody = document.getElementById("tab-body"); const refreshers = []; const LAYOUT_KEY = "deckit.layout"; const TAB_KEY = "deckit.tab"; +// Folded cards: id -> the height to restore when unfolded. +const FOLD_KEY = "deckit.folded"; const DEFAULT_W = 6; const DEFAULT_H = 4; const tabs = {}; @@ -113,6 +116,65 @@ for (const node of loadLayout()) { if (node.id) savedById[node.id] = node; } +function loadFolded() { + try { + const saved = JSON.parse(localStorage.getItem(FOLD_KEY)); + return saved && typeof saved === "object" ? saved : {}; + } catch { + return {}; + } +} +const folded = loadFolded(); + +// Folding shrinks the card to one grid row and hides its body; the row it +// keeps is only the title bar (the content stops at the header). Its height +// before folding is kept so unfolding restores it, and it cannot be resized +// while folded. +function setFolded(item, fold) { + const id = item.getAttribute("gs-id"); + const content = item.querySelector(".panel"); + const button = item.querySelector(".panel-fold"); + if (fold) { + const node = item.gridstackNode; + if (!(id in folded)) folded[id] = node?.h ?? DEFAULT_H; + grid.update(item, { h: 1, noResize: true }); + closeGaps(); + } else if (id in folded) { + grid.update(item, { h: folded[id], noResize: false }); + delete folded[id]; + } + content.classList.toggle("folded", fold); + button.textContent = fold ? "▸" : "▾"; + button.setAttribute("aria-label", fold ? "Unfold panel" : "Fold panel"); + button.setAttribute("aria-expanded", String(!fold)); + try { + localStorage.setItem(FOLD_KEY, JSON.stringify(folded)); + } catch {} +} + +// The grid floats, so nothing moves into rows a folded card gave back. Pull +// each card below a folded one, in its columns, up as far as the space is +// free (never above the folded card's title bar). Unfolding needs no mirror: +// the grid pushes down whatever the regrown card hits. +function closeGaps() { + const nodes = grid.engine.nodes; + const clash = (n, y) => nodes.some((o) => o !== n && o.x < n.x + n.w && o.x + o.w > n.x + && o.y < y + n.h && o.y + o.h > y); + const folds = nodes.filter((n) => n.el.querySelector(".panel.folded")); + const below = nodes + .filter((n) => folds.some((f) => n.y > f.y && n.x < f.x + f.w && n.x + n.w > f.x)) + .sort((a, b) => a.y - b.y); + for (const n of below) { + const floor = Math.max(...folds.filter((f) => n.y > f.y && n.x < f.x + f.w && n.x + n.w > f.x) + .map((f) => f.y + f.h)); + let y = n.y; + while (y - 1 >= floor && !clash(n, y - 1)) y--; + if (y !== n.y) { + grid.update(n.el, { y }); + } + } +} + function header(title) { const head = document.createElement("div"); head.className = "panel-head"; @@ -120,11 +182,20 @@ function header(title) { dot.className = "panel-dot"; const h = document.createElement("h2"); h.textContent = title; + const fold = document.createElement("button"); + fold.className = "panel-fold"; + fold.textContent = "▾"; + fold.setAttribute("aria-label", "Fold panel"); + fold.setAttribute("aria-expanded", "true"); + fold.onclick = () => { + const item = fold.closest(".grid-stack-item"); + setFolded(item, !item.querySelector(".panel").classList.contains("folded")); + }; const drag = document.createElement("button"); drag.className = "panel-drag"; drag.textContent = "⠿"; drag.setAttribute("aria-label", "Move panel"); - head.append(dot, h, drag); + head.append(dot, h, fold, drag); return head; } @@ -147,6 +218,7 @@ function makeCard(name, title) { item.append(content); gridEl.append(item); grid.makeWidget(item); + if (name in folded) setFolded(item, true); return body; } @@ -196,7 +268,9 @@ async function boot() { // a broken panel must not take down the rest of the dashboard } } + closeGaps(); ready = true; + saveLayout(); sortTabs(); const saved = localStorage.getItem(TAB_KEY); showTab(tabs[saved] ? saved : "Dashboard"); diff --git a/static/deck.css b/static/deck.css index f0a9fdc..8a318ef 100644 --- a/static/deck.css +++ b/static/deck.css @@ -37,10 +37,28 @@ body { .topbar { flex: none; + position: relative; display: flex; justify-content: center; padding: 8px 14px 0; } +/* The installed web app has no browser chrome, so no pull-to-refresh. */ +.reload { + position: absolute; + right: 14px; + top: 8px; + min-width: 44px; + min-height: 40px; + font: inherit; + font-size: 1.286rem; + line-height: 1; + color: var(--udt-subtext0); + background: transparent; + border: 1px solid transparent; + border-radius: 10px; + cursor: pointer; +} +.reload:active { background: var(--udt-surface0); border-color: var(--udt-surface1); } .tabs { display: flex; gap: 6px; } .tabs button { font: inherit; @@ -143,6 +161,21 @@ body { touch-action: none; } .panel-drag:hover { color: var(--udt-subtext0); background: var(--udt-surface1); } +.panel-fold { + font: inherit; + min-width: 36px; + min-height: 32px; + color: var(--udt-overlay0); + background: transparent; + border: 0; + border-radius: 6px; + cursor: pointer; +} +.panel-fold:hover { color: var(--udt-subtext0); background: var(--udt-surface1); } +/* Folded: the card ends at its title bar instead of filling its grid row. */ +.grid-stack > .grid-stack-item > .grid-stack-item-content.panel.folded { bottom: auto; } +.panel.folded .panel-body { display: none; } +.panel.folded .panel-head { border-bottom: 0; } .panel-body { flex: 1; min-height: 0; overflow: auto; padding: 14px; } .section-label { diff --git a/static/index.html b/static/index.html index b2a3fca..b45904c 100644 --- a/static/index.html +++ b/static/index.html @@ -37,6 +37,7 @@
+
-- cgit v1.2.3