aboutsummaryrefslogtreecommitdiffstats
path: root/static/app.js
blob: c7730e329100684279302de1a760ade1a3dff606 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
// The shell: discover modules, mount each panel as a Gridstack card, poll its
// state. A module is static/modules/<name>.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 dot = document.createElement("span");
  dot.className = "panel-dot";
  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(dot, 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";
  const body = document.createElement("div");
  body.className = "panel-body";
  content.append(header(title), body);
  item.append(content);
  gridEl.append(item);
  grid.makeWidget(item);
  return body;
}

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();