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