aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-11-09 18:47:57 +0100
committerDanilo M. <danix@danix.xyz>2026-11-09 18:47:57 +0100
commitc00a4775106bc5927c821f5359ec265c318d73ec (patch)
tree81f3ca5025b1db812bf469bf8db4c51434cd80f9
parent47421647d7d08919d28660a2b2201e0d1d704c18 (diff)
downloaddeck-it-c00a4775106bc5927c821f5359ec265c318d73ec.tar.gz
deck-it-c00a4775106bc5927c821f5359ec265c318d73ec.zip
feat: browser-local panel move and resize
-rw-r--r--static/app.js102
-rw-r--r--static/deck.css18
-rw-r--r--tests/test_frontend.py7
3 files changed, 122 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;
diff --git a/tests/test_frontend.py b/tests/test_frontend.py
index cdd31fc..4f8392b 100644
--- a/tests/test_frontend.py
+++ b/tests/test_frontend.py
@@ -74,6 +74,13 @@ class Frontend(unittest.TestCase):
for name in names:
self.assert_panel(name)
+ def test_layout_is_browser_local(self):
+ status, body = self.get("/app.js")
+ self.assertEqual(status, 200)
+ text = body.decode()
+ for part in ("localStorage", "deckit.layout", "drag-handle", "resize-handle", "wide"):
+ self.assertIn(part, text)
+
if __name__ == "__main__":
unittest.main()