Files
ProdDash/public/shell.js
T
Waters ChurchandClaude Opus 5 720e5bb590 Shell: every tile can be tinted from its gear
A colour picker at the foot of every gear window washes that tile's
chrome — panel, border and title bar — in a colour of the operator's
choosing, so one screen's tiles can be told apart at a glance. "Clear"
takes it off again. The value saves with the layout under the reserved
key __tint; it is the shell's, not a module's, so picking a colour paints
the tile without remounting the module, and every tile now has a gear
even when its module offers no settings of its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 14:39:35 -04:00

2161 lines
83 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ProdDash shell client.
Owns the tile grid: adding, dragging, resizing and removing module tiles,
plus layout persistence. The layout (tiles, positions, sizes, per-instance
settings) autosaves to this browser's localStorage — every browser is fully
independent. Modules render inside tile bodies and never touch the shell. */
const COLS = 12;
const LS_LAYOUT = 'proddash:layout';
const grid = document.getElementById('grid');
const gridwrap = document.getElementById('gridwrap');
const emptyEl = document.getElementById('empty-state');
const toastEl = document.getElementById('toast');
/* ── tiny helpers ───────────────────────────────────────────────────── */
let toastTimer = null;
function toast(msg, isError = false) {
toastEl.textContent = msg;
toastEl.classList.toggle('error', isError);
toastEl.hidden = false;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { toastEl.hidden = true; }, 3200);
}
const modalBackdrop = document.getElementById('modal-backdrop');
const modalEl = document.getElementById('modal');
/** Minimal prompt dialog; resolves with the string or null on cancel. */
function promptModal({ title, placeholder = '', value = '', okLabel = 'Save' }) {
return new Promise((resolve) => {
modalEl.innerHTML = '';
const h = document.createElement('h2');
h.textContent = title;
const field = document.createElement('div');
field.className = 'field';
const input = document.createElement('input');
input.type = 'text';
input.placeholder = placeholder;
input.value = value;
field.appendChild(input);
const actions = document.createElement('div');
actions.className = 'actions';
const cancel = document.createElement('button');
cancel.className = 'btn';
cancel.textContent = 'Cancel';
const ok = document.createElement('button');
ok.className = 'btn primary';
ok.textContent = okLabel;
actions.append(cancel, ok);
modalEl.append(h, field, actions);
modalBackdrop.hidden = false;
input.focus();
const done = (result) => {
modalBackdrop.hidden = true;
modalEl.innerHTML = '';
resolve(result);
};
cancel.addEventListener('click', () => done(null));
ok.addEventListener('click', () => done(input.value.trim() || null));
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') done(input.value.trim() || null);
if (e.key === 'Escape') done(null);
});
modalBackdrop.addEventListener('click', (e) => {
if (e.target === modalBackdrop) done(null);
}, { once: true });
});
}
/* ── dropdown menus ─────────────────────────────────────────────────── */
/* A menu opens the moment its button is clicked, drawn from what the page
already knows; whatever its rebuild has to fetch is slotted in when it
lands. It used to open only once the rebuild had finished, which made the
+ picker hostage to a network round trip — one that never completes when
the tiles' streams hold every connection the browser allows (see the
shared-stream pool below). rebuild(menu, stillOpen) may be async;
stillOpen() says whether the open it was started for is still current,
so a late fetch can't repaint a menu that has since closed or reopened. */
function wireDropdown(btnId, menuId, rebuild) {
const btn = document.getElementById(btnId);
const menu = document.getElementById(menuId);
let generation = 0;
btn.addEventListener('click', () => {
if (!menu.hidden) {
menu.hidden = true;
return;
}
const gen = ++generation;
const stillOpen = () => gen === generation && !menu.hidden;
menu.hidden = false;
// Whatever the rebuild does, the menu is on screen; a failure (now or
// later) leaves its previous contents rather than an invisible menu.
const failed = (err) => console.error(`[shell] ${menuId} could not be rebuilt:`, err);
try {
Promise.resolve(rebuild(menu, stillOpen)).catch(failed);
} catch (err) {
failed(err);
}
});
document.addEventListener('click', (e) => {
if (!menu.hidden && !e.target.closest('#' + btnId) && !e.target.closest('#' + menuId)) {
menu.hidden = true;
}
});
return menu;
}
function menuItem(label, onClick, { sub = '', danger = false, disabled = false, title = '' } = {}) {
const item = document.createElement('button');
item.className = 'menu-item' + (danger ? ' danger' : '');
item.disabled = disabled;
if (title) item.title = title;
const span = document.createElement('span');
span.textContent = label;
if (sub) {
const s = document.createElement('span');
s.className = 'sub';
s.textContent = sub;
span.appendChild(s);
}
item.appendChild(span);
item.addEventListener('click', onClick);
return item;
}
/* ── module registry ────────────────────────────────────────────────── */
let registry = new Map(); // module id -> manifest from GET /api/modules
/** fetch that gives up after `ms`. A request the browser has queued behind
held connections would otherwise pend forever, and so would anything
awaiting it. */
function fetchWithTimeout(url, ms, opts = {}) {
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), ms);
return fetch(url, { ...opts, signal: ctl.signal }).finally(() => clearTimeout(timer));
}
/** Refresh the enabled-module list. On failure the last known list is kept
(the server may just be restarting mid-service). Returns true on success. */
async function loadRegistry() {
try {
// Slow modules' tiles() are cut off server-side at 2 s, so a healthy
// answer is well inside this.
const res = await fetchWithTimeout('/api/modules', 8000);
if (!res.ok) throw new Error('modules ' + res.status);
const body = await res.json();
registry = new Map((body.modules || []).map((m) => [m.id, m]));
return true;
} catch {
return false;
}
}
/* ── layout state ───────────────────────────────────────────────────── */
/** @type {{id:string,module:string,x:number,y:number,w:number,h:number,settings:object}[]} */
let tiles = [];
const tileEls = new Map(); // tile id -> { el, body, dot, titleEl, instance }
function saveLayout() {
try {
localStorage.setItem(LS_LAYOUT, JSON.stringify({ v: 1, tiles }));
} catch { /* private mode / full — layout just won't persist */ }
}
function loadLayoutFromStorage() {
try {
const raw = JSON.parse(localStorage.getItem(LS_LAYOUT) || 'null');
if (raw && Array.isArray(raw.tiles)) return raw.tiles;
} catch { /* corrupted — start fresh */ }
return [];
}
function sanitizeTile(t) {
const w = Math.max(1, Math.min(COLS, Number(t.w) || 3));
const tile = {
id: String(t.id || newId()),
module: String(t.module || ''),
x: Math.max(0, Math.min(COLS - w, Number(t.x) || 0)),
y: Math.max(0, Number(t.y) || 0),
w,
h: Math.max(1, Number(t.h) || 2),
settings: (t.settings && typeof t.settings === 'object') ? t.settings : {},
headHidden: Boolean(t.headHidden),
// set when the tile was added from a module's tile list (multi-tile modules)
variant: t.variant ? String(t.variant) : '',
title: t.title ? String(t.title) : '',
};
if (t.minSize && Number(t.minSize.w) > 0 && Number(t.minSize.h) > 0) {
tile.minSize = { w: Number(t.minSize.w), h: Number(t.minSize.h) };
}
return tile;
}
function newId() {
return 't' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
}
/* ── grid geometry & collision ──────────────────────────────────────── */
function cellMetrics() {
const style = getComputedStyle(grid);
const gapRaw = parseFloat(style.columnGap);
const gap = Number.isFinite(gapRaw) ? gapRaw : 0;
const cw = (grid.clientWidth - gap * (COLS - 1)) / COLS;
const ch = parseFloat(style.gridAutoRows) || 72;
return { cw, ch, gap };
}
function overlaps(a, b) {
return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
}
function isFreeExcept(rect, excludeIds) {
if (rect.x < 0 || rect.y < 0 || rect.x + rect.w > COLS) return false;
return !tiles.some((t) => !excludeIds.includes(t.id) && overlaps(rect, t));
}
function isFree(rect, excludeId) {
return isFreeExcept(rect, [excludeId]);
}
/** First free spot scanning top-to-bottom, left-to-right. */
function findSpot(w, h) {
for (let y = 0; y < 200; y += 1) {
for (let x = 0; x <= COLS - w; x += 1) {
if (isFree({ x, y, w, h })) return { x, y };
}
}
return { x: 0, y: 0 };
}
function applyRect(tile) {
const entry = tileEls.get(tile.id);
if (!entry) return;
entry.el.style.gridColumn = `${tile.x + 1} / span ${tile.w}`;
entry.el.style.gridRow = `${tile.y + 1} / span ${tile.h}`;
}
function minSizeOf(tile) {
const min = tile.minSize || registry.get(tile.module)?.minSize;
return {
w: Math.max(1, min?.w || 1),
h: Math.max(1, min?.h || 1),
};
}
/* ── tile DOM ───────────────────────────────────────────────────────── */
const CLOSE_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>';
const GEAR_SVG = '<svg viewBox="0 0 90 90" fill="currentColor" aria-hidden="true"><path d="M 82.94 32.538 c -2.685 0 -5.093 -1.545 -6.327 -3.929 c -0.245 -0.476 -0.504 -0.948 -0.774 -1.416 c -0.27 -0.468 -0.55 -0.928 -0.84 -1.378 c -1.448 -2.26 -1.582 -5.117 -0.239 -7.443 c 1.951 -3.378 0.792 -7.696 -2.584 -9.645 l -9.352 -5.4 c -3.378 -1.951 -7.696 -0.792 -9.645 2.584 l -0.002 0.003 c -1.322 2.291 -3.815 3.641 -6.456 3.512 c -1.149 -0.056 -2.301 -0.057 -3.455 0 c -2.638 0.128 -5.124 -1.226 -6.444 -3.513 l -0.002 -0.002 c -1.951 -3.378 -6.269 -4.535 -9.645 -2.584 l -9.352 5.4 c -3.378 1.951 -4.535 6.269 -2.584 9.645 l 0.002 0.002 c 1.321 2.286 1.25 5.117 -0.18 7.336 c -0.625 0.972 -1.201 1.97 -1.726 2.993 c -1.209 2.353 -3.625 3.836 -6.269 3.836 H 7.063 c -3.9 0 -7.061 3.162 -7.061 7.061 v 10.801 c 0 3.9 3.162 7.061 7.061 7.061 l 0 0 c 2.685 0 5.093 1.545 6.327 3.929 c 0.245 0.475 0.504 0.948 0.774 1.416 c 0.27 0.468 0.55 0.928 0.84 1.378 c 1.448 2.26 1.582 5.117 0.239 7.443 c -1.951 3.378 -0.792 7.696 2.584 9.645 l 9.352 5.4 c 3.378 1.951 7.696 0.792 9.645 -2.584 l 0.002 -0.003 c 1.322 -2.291 3.815 -3.641 6.456 -3.512 c 1.149 0.056 2.301 0.057 3.455 0 c 2.638 -0.128 5.124 1.226 6.444 3.513 l 0.002 0.002 c 1.951 3.378 6.268 4.535 9.645 2.584 l 9.352 -5.4 c 3.378 -1.949 4.535 -6.268 2.584 -9.645 l -0.002 -0.002 c -1.321 -2.286 -1.25 -5.117 0.18 -7.336 c 0.625 -0.972 1.201 -1.97 1.726 -2.993 c 1.209 -2.353 3.625 -3.836 6.269 -3.836 h 0.003 c 3.9 0 7.061 -3.162 7.061 -7.061 V 39.599 C 90.001 35.7 86.84 32.538 82.94 32.538 z M 54.998 62.318 c -9.565 5.523 -21.796 2.245 -27.317 -7.32 c -5.523 -9.565 -2.245 -21.796 7.32 -27.317 s 21.796 -2.245 27.317 7.32 C 67.841 44.566 64.563 56.797 54.998 62.318 z"/></svg>';
function buildTile(tile) {
const man = registry.get(tile.module);
const el = document.createElement('section');
el.className = 'tile';
el.dataset.tileId = tile.id;
if (man) el.dataset.module = man.id;
applyTint(tile, el);
const head = document.createElement('div');
head.className = 'tile-head';
const dot = document.createElement('span');
dot.className = 'dot';
dot.title = '';
const title = document.createElement('span');
title.className = 'tile-title';
title.textContent = man ? (tile.title || man.name) : tile.module + ' (not installed)';
// where moduleApi.header puts a module's own buttons and menus
const controls = document.createElement('span');
controls.className = 'tile-controls';
head.append(dot, title, controls);
// Every tile has something to set: its own settings when the module
// offers any, and the tint either way.
if (man) {
const gear = document.createElement('button');
gear.className = 'tile-btn';
gear.title = 'Settings';
gear.innerHTML = GEAR_SVG;
gear.addEventListener('click', (e) => {
e.stopPropagation();
toggleSettingsPopover(tile, el, gear);
});
head.appendChild(gear);
}
const close = document.createElement('button');
close.className = 'tile-btn close';
close.title = 'Remove';
close.innerHTML = CLOSE_SVG; // a drawn cross, the same size in every browser (the ✕ glyph varies)
close.addEventListener('click', (e) => {
e.stopPropagation();
removeTile(tile.id);
});
head.appendChild(close);
const body = document.createElement('div');
body.className = 'tile-body';
const resize = document.createElement('div');
resize.className = 'tile-resize';
resize.title = 'Drag to resize';
const resizeLeft = document.createElement('div');
resizeLeft.className = 'tile-resize-left';
resizeLeft.title = 'Drag to resize';
// pill on the bottom edge: moves that edge (trading rows with tiles below)
const resizeBottom = document.createElement('div');
resizeBottom.className = 'tile-resize-bottom';
resizeBottom.title = 'Drag to adjust height';
// notch on the title bar's bottom edge: hides/shows the bar
const notch = document.createElement('button');
notch.className = 'tile-notch';
notch.title = 'Hide / show the title bar';
notch.addEventListener('click', (e) => {
e.stopPropagation();
// In fullscreen the bars are all folded away for watching; the notch
// peeks one open for a moment without touching what this tile saved.
if (document.body.classList.contains('fullscreen')) {
peekHead(tile, el, !el.classList.contains('head-peek'));
return;
}
tile.headHidden = !tile.headHidden;
el.classList.toggle('head-hidden', tile.headHidden);
saveLayout();
});
// A peeked title bar stays while the pointer is on its tile, and starts
// counting down as soon as it leaves.
el.addEventListener('pointerenter', () => clearPeekTimer(tile.id));
el.addEventListener('pointerleave', () => {
if (el.classList.contains('head-peek')) schedulePeekHide(tile.id, el);
});
el.append(head, body, resize, resizeLeft, resizeBottom, notch);
el.classList.toggle('head-hidden', Boolean(tile.headHidden));
grid.appendChild(el);
const entry = { el, body, dot, titleEl: title, controls, instance: null };
tileEls.set(tile.id, entry);
applyRect(tile);
wireDrag(tile, el, head);
wireResize(tile, el, resize, false);
wireResize(tile, el, resizeLeft, true);
wireBottomResize(tile, el, resizeBottom);
mountModule(tile);
return entry;
}
function refreshEmptyState() {
emptyEl.hidden = tiles.length > 0;
}
/* ── drag to move ───────────────────────────────────────────────────── */
function wireDrag(tile, el, handle) {
handle.addEventListener('pointerdown', (ev) => {
if (ev.target.closest('.tile-btn') || ev.target.closest('.tile-menu') || ev.target.closest('.tile-notch')) return;
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
if (document.body.classList.contains('fullscreen')) return;
ev.preventDefault();
closeSettingsPopovers();
handle.setPointerCapture(ev.pointerId);
const m = cellMetrics();
const sx = ev.clientX;
const sy = ev.clientY;
const startX = tile.x;
const startY = tile.y;
let moved = false;
const onMove = (e) => {
const dCols = Math.round((e.clientX - sx) / (m.cw + m.gap));
const dRows = Math.round((e.clientY - sy) / (m.ch + m.gap));
if (!moved && (dCols || dRows)) {
moved = true;
el.classList.add('dragging');
grid.classList.add('no-dividers');
}
const cand = {
x: Math.max(0, Math.min(COLS - tile.w, startX + dCols)),
y: Math.max(0, startY + dRows),
w: tile.w,
h: tile.h,
};
if ((cand.x !== tile.x || cand.y !== tile.y) && isFree(cand, tile.id)) {
tile.x = cand.x;
tile.y = cand.y;
applyRect(tile);
}
};
const onUp = () => {
handle.removeEventListener('pointermove', onMove);
handle.removeEventListener('pointerup', onUp);
handle.removeEventListener('pointercancel', onUp);
el.classList.remove('dragging');
grid.classList.remove('no-dividers');
if (moved) {
saveLayout();
rebuildDividers();
}
};
handle.addEventListener('pointermove', onMove);
handle.addEventListener('pointerup', onUp);
handle.addEventListener('pointercancel', onUp);
});
}
/* ── drag to resize (bottom-right and bottom-left corners) ──────────── */
function wireResize(tile, el, handle, fromLeft) {
handle.addEventListener('pointerdown', (ev) => {
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
if (document.body.classList.contains('fullscreen')) return;
ev.preventDefault();
ev.stopPropagation();
handle.setPointerCapture(ev.pointerId);
const m = cellMetrics();
const sx = ev.clientX;
const sy = ev.clientY;
const startX = tile.x;
const startW = tile.w;
const startH = tile.h;
const min = minSizeOf(tile);
el.classList.add('resizing');
grid.classList.add('no-dividers');
let changed = false;
const onMove = (e) => {
const dCols = Math.round((e.clientX - sx) / (m.cw + m.gap));
const dRows = Math.round((e.clientY - sy) / (m.ch + m.gap));
let cand;
if (fromLeft) {
// the left edge moves; the right edge stays put
const newX = Math.max(0, Math.min(startX + startW - min.w, startX + dCols));
cand = {
x: newX,
y: tile.y,
w: startX + startW - newX,
h: Math.max(min.h, startH + dRows),
};
} else {
cand = {
x: tile.x,
y: tile.y,
w: Math.max(min.w, Math.min(COLS - tile.x, startW + dCols)),
h: Math.max(min.h, startH + dRows),
};
}
if ((cand.x !== tile.x || cand.w !== tile.w || cand.h !== tile.h) && isFree(cand, tile.id)) {
tile.x = cand.x;
tile.w = cand.w;
tile.h = cand.h;
applyRect(tile);
changed = true;
notifyResize(tile);
}
};
const onUp = () => {
handle.removeEventListener('pointermove', onMove);
handle.removeEventListener('pointerup', onUp);
handle.removeEventListener('pointercancel', onUp);
el.classList.remove('resizing');
grid.classList.remove('no-dividers');
if (changed) {
saveLayout();
rebuildDividers();
}
};
handle.addEventListener('pointermove', onMove);
handle.addEventListener('pointerup', onUp);
handle.addEventListener('pointercancel', onUp);
});
}
/* ── drag the bottom edge: height only ──────────────────────────────── */
/* The pill on a tile's bottom edge moves that edge. When other tiles sit
directly below it (sharing the edge), the drag trades rows with them —
the column's total height stays the same, like the seam pill trades
columns. With nothing directly below, it simply changes this tile's
height. */
function tilesDirectlyBelow(tile) {
return tiles.filter((t) => t !== tile
&& t.y === tile.y + tile.h
&& t.x < tile.x + tile.w && tile.x < t.x + t.w);
}
function wireBottomResize(tile, el, handle) {
handle.addEventListener('pointerdown', (ev) => {
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
if (document.body.classList.contains('fullscreen')) return;
ev.preventDefault();
ev.stopPropagation();
closeSettingsPopovers();
handle.setPointerCapture(ev.pointerId);
const m = cellMetrics();
const sy = ev.clientY;
const startH = tile.h;
const below = tilesDirectlyBelow(tile).map((t) => ({ t, bottom: t.y + t.h }));
const group = [tile.id, ...below.map((b) => b.t.id)];
const minH = minSizeOf(tile).h;
// the edge can rise until this tile hits its minimum, and drop until the
// shortest neighbour below hits its own
const maxH = below.length
? Math.min(...below.map((b) => b.bottom - minSizeOf(b.t).h)) - tile.y
: Infinity;
el.classList.add('resizing');
grid.classList.add('no-dividers');
let changed = false;
const onMove = (e) => {
const dRows = Math.round((e.clientY - sy) / (m.ch + m.gap));
const h = Math.max(minH, Math.min(maxH, startH + dRows));
if (h === tile.h || h > maxH) return;
const edge = tile.y + h;
const candSelf = { x: tile.x, y: tile.y, w: tile.w, h };
const candBelow = below.map((b) => ({ x: b.t.x, y: edge, w: b.t.w, h: b.bottom - edge }));
if (!isFreeExcept(candSelf, group) || candBelow.some((c) => !isFreeExcept(c, group))) return;
tile.h = h;
applyRect(tile);
notifyResize(tile);
below.forEach((b, i) => {
b.t.y = candBelow[i].y;
b.t.h = candBelow[i].h;
applyRect(b.t);
notifyResize(b.t);
});
changed = true;
};
const onUp = () => {
handle.removeEventListener('pointermove', onMove);
handle.removeEventListener('pointerup', onUp);
handle.removeEventListener('pointercancel', onUp);
el.classList.remove('resizing');
grid.classList.remove('no-dividers');
if (changed) {
saveLayout();
rebuildDividers();
}
};
handle.addEventListener('pointermove', onMove);
handle.addEventListener('pointerup', onUp);
handle.addEventListener('pointercancel', onUp);
});
}
function notifyResize(tile) {
const entry = tileEls.get(tile.id);
try {
entry?.instance?.onResize?.(tile.w, tile.h);
} catch { /* module's problem, not the shell's */ }
}
/* ── seam dividers between adjacent tiles ───────────────────────────── */
/* Wherever two tiles share a vertical edge, a divider sits on the seam.
Dragging one moves that column boundary wherever it appears: every pair of
tiles meeting at the same x, in every row, moves with it, so a column of
tiles keeps its edge as one line rather than drifting row by row. Hovering
a seam lights up the others that travel with it.
Each pair trades width within its own span — the pair's combined span never
changes — so nothing else can collide except via the explicit check below
(a third tile can sit behind the seam for part of its height). A row where
one tile simply spans the boundary has no seam there and stays put. */
/** Live dividers, so a drag can reposition all of them as the tiles move. */
const dividers = [];
function rebuildDividers() {
grid.querySelectorAll('.tile-divider').forEach((el) => el.remove());
dividers.length = 0;
for (const L of tiles) {
for (const R of tiles) {
if (L === R || L.x + L.w !== R.x) continue;
const top = Math.max(L.y, R.y);
const bottom = Math.min(L.y + L.h, R.y + R.h);
if (bottom <= top) continue;
buildDivider(L, R, top, bottom);
}
}
}
/** Every pair of tiles that meets at this column boundary, in any row. */
function seamPairsAt(boundary) {
const pairs = [];
for (const L of tiles) {
if (L.x + L.w !== boundary) continue;
for (const R of tiles) {
if (R === L || R.x !== boundary) continue;
if (Math.min(L.y + L.h, R.y + R.h) <= Math.max(L.y, R.y)) continue;
pairs.push({ L, R });
}
}
return pairs;
}
function buildDivider(L, R, top, bottom) {
const el = document.createElement('div');
el.className = 'tile-divider';
el.title = 'Drag to resize this column';
const place = () => {
const m = cellMetrics();
// left as a percentage so the pill tracks window resizes for free
el.style.left = ((L.x + L.w) / COLS) * 100 + '%';
el.style.top = top * (m.ch + m.gap) + 'px';
el.style.height = (bottom - top) * (m.ch + m.gap) - m.gap + 'px';
};
place();
const boundary = () => L.x + L.w;
const groupEls = () => dividers.filter((d) => d.boundary() === boundary()).map((d) => d.el);
const unlink = () => { for (const d of dividers) d.el.classList.remove('linked'); };
// Before anything is dragged, show which seams travel with this one.
el.addEventListener('pointerenter', () => {
if (document.body.classList.contains('fullscreen')) return;
for (const other of groupEls()) other.classList.add('linked');
});
el.addEventListener('pointerleave', unlink);
el.addEventListener('pointerdown', (ev) => {
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
if (document.body.classList.contains('fullscreen')) return;
ev.preventDefault();
closeSettingsPopovers();
el.setPointerCapture(ev.pointerId);
const m = cellMetrics();
const sx = ev.clientX;
const startBoundary = boundary();
// Every row that has a seam here moves with it. Each pair's outer edges
// are fixed for the whole drag, so each row only trades width internally.
const parts = seamPairsAt(startBoundary).map(({ L: a, R: b }) => ({
L: a, R: b, leftX: a.x, rightEdge: b.x + b.w,
}));
if (!parts.length) return;
const moving = parts.flatMap((p) => [p.L.id, p.R.id]);
// The whole column stops at whichever row runs out of room first.
const minB = Math.max(...parts.map((p) => p.leftX + minSizeOf(p.L).w));
const maxB = Math.min(...parts.map((p) => p.rightEdge - minSizeOf(p.R).w));
const group = groupEls();
for (const other of group) other.classList.add('active');
let changed = false;
const onMove = (e) => {
const b = Math.max(minB, Math.min(maxB,
startBoundary + Math.round((e.clientX - sx) / (m.cw + m.gap))));
if (b === boundary()) return;
const next = parts.map((p) => ({
p,
candL: { x: p.leftX, y: p.L.y, w: b - p.leftX, h: p.L.h },
candR: { x: b, y: p.R.y, w: p.rightEdge - b, h: p.R.h },
}));
// All rows move or none do — a column edge that bends is worse than one
// that won't budge.
if (next.some(({ candL, candR }) =>
!isFreeExcept(candL, moving) || !isFreeExcept(candR, moving))) return;
for (const { p, candL, candR } of next) {
p.L.w = candL.w;
p.R.x = candR.x;
p.R.w = candR.w;
applyRect(p.L);
applyRect(p.R);
notifyResize(p.L);
notifyResize(p.R);
}
for (const d of dividers) d.place();
changed = true;
};
const onUp = () => {
el.removeEventListener('pointermove', onMove);
el.removeEventListener('pointerup', onUp);
el.removeEventListener('pointercancel', onUp);
for (const other of group) other.classList.remove('active');
unlink();
if (changed) saveLayout();
rebuildDividers(); // adjacencies may have changed
};
el.addEventListener('pointermove', onMove);
el.addEventListener('pointerup', onUp);
el.addEventListener('pointercancel', onUp);
});
dividers.push({ el, place, boundary });
grid.appendChild(el);
}
/* ── add / remove tiles ─────────────────────────────────────────────── */
/**
* Add a tile. `entry` is one item of a multi-tile module's tile list
* (picker variants): its preset settings overlay the schema defaults, and
* its name/sizes stick to the tile.
*/
function addTile(moduleId, entry = null) {
const man = registry.get(moduleId);
if (entry?.single && tiles.some((t) => t.module === moduleId && t.variant === String(entry.id))) {
toast(`${entry.name} is already on this dashboard`);
return;
}
const defaultSize = entry?.defaultSize || man?.defaultSize;
const size = {
w: Math.max(1, Math.min(COLS, defaultSize?.w || 4)),
h: Math.max(1, defaultSize?.h || 3),
};
const spot = findSpot(size.w, size.h);
const settings = {};
const { schema: addSchema } = tileSchema({ variant: entry ? String(entry.id) : '' }, man);
for (const [key, spec] of Object.entries(addSchema)) {
if (spec && 'default' in spec) settings[key] = spec.default;
}
Object.assign(settings, entry?.settings || {});
const tile = { id: newId(), module: moduleId, ...spot, ...size, settings };
if (entry) {
tile.variant = String(entry.id);
tile.title = String(entry.name || '');
if (entry.minSize) tile.minSize = { w: entry.minSize.w, h: entry.minSize.h };
}
tiles.push(tile);
buildTile(tile);
refreshEmptyState();
saveLayout();
rebuildDividers();
}
function removeTile(id) {
const idx = tiles.findIndex((t) => t.id === id);
if (idx < 0) return;
clearPeekTimer(id);
unmountModule(tiles[idx]);
tiles.splice(idx, 1);
const entry = tileEls.get(id);
if (entry) {
entry.el.remove();
tileEls.delete(id);
}
refreshEmptyState();
saveLayout();
rebuildDividers();
}
function clearAllTiles() {
for (const tile of tiles) {
unmountModule(tile);
tileEls.get(tile.id)?.el.remove();
}
tiles = [];
tileEls.clear();
refreshEmptyState();
}
/* ── module mounting ────────────────────────────────────────────────── */
const clientModuleCache = new Map(); // module id -> Promise<ES module>
function loadClientModule(man) {
if (!clientModuleCache.has(man.id)) {
clientModuleCache.set(man.id, import(`/modules/${man.id}/${man.client || 'client.js'}`));
}
return clientModuleCache.get(man.id);
}
/** Inject a module's stylesheet once (shared by every instance of it). */
function ensureModuleStyle(man) {
if (!man.style) return;
const id = 'module-style-' + man.id;
if (document.getElementById(id)) return;
const link = document.createElement('link');
link.id = id;
link.rel = 'stylesheet';
link.href = `/modules/${man.id}/${man.style}`;
document.head.appendChild(link);
}
function tileMessage(entry, text) {
const msg = document.createElement('div');
msg.className = 'tile-msg';
msg.textContent = text;
entry.body.appendChild(msg);
}
/* ── one stream per page ─────────────────────────────────────────────── */
/* Browsers allow about six HTTP/1.1 connections to one host, and an open
EventSource holds one of them for as long as it lives. Sharing one
stream per URL was not enough: a dashboard with six modules still ran
out (Safari first), and from then on every further request — a remounted
tile's /state, an avatar, the + picker's list — sat in the browser's
queue waiting for a connection that was never handed back. So the page
holds ONE EventSource, GET /api/stream?s=<keys>, and the server relays
every stream named in it (a module's, or `shell:` for the shell's own)
with the key prefixed to each event name. Subscribers still see the
events they asked for by their own names. Adding or dropping a stream
remakes the connection with the new list, a moment later so several
tiles mounting together cause one reconnect; every stream announces
`open` again after it, so backfills run as before. */
const muxSubs = new Map(); // key -> { subs: Set<{ handlers }> }
let muxEs = null; // the page's one EventSource
let muxKeys = ''; // the key list it was opened with
let muxTimer = null; // the pending (re)connect
let muxRetry = null; // retry after the connection gave up
const muxListened = new Set(); // "key|name" pairs with a listener on muxEs
/** The stream key for a URL: `/api/modules/<id><path>` → `<id>:<path>`, `/api/events` → `shell:`. */
function muxKeyFor(url) {
if (url === '/api/events') return 'shell:';
const m = /^\/api\/modules\/([^/?#]+)(\/[^?#]*)?$/.exec(url);
return m ? `${decodeURIComponent(m[1])}:${m[2] || '/'}` : null;
}
function muxFanOut(key, kind, e) {
for (const sub of [...(muxSubs.get(key)?.subs || [])]) {
const fn = kind === 'event' ? sub.handlers.events?.[e.type] : sub.handlers[kind];
try { fn?.(e); } catch (err) { console.error('[shell] stream handler failed:', err); }
}
}
/** Listen for `<key>|<name>` on the open connection and hand it to that key's subscribers as `name`. */
function muxListen(key, name) {
const full = `${key}|${name}`;
if (!muxEs || muxListened.has(full)) return;
muxListened.add(full);
muxEs.addEventListener(full, (e) => {
if (name === '__open') return muxFanOut(key, 'open', new Event('open'));
if (name === '__down') return muxFanOut(key, 'error', new Event('error'));
const ev = { type: name, data: e.data, lastEventId: e.lastEventId };
muxFanOut(key, name === 'message' ? 'message' : 'event', ev);
});
}
function muxConnect() {
clearTimeout(muxTimer);
muxTimer = null;
clearTimeout(muxRetry);
muxRetry = null;
const keys = [...muxSubs.keys()].sort();
const wanted = keys.join(',');
if (!keys.length) {
try { muxEs?.close(); } catch { /* not open */ }
muxEs = null;
muxKeys = '';
return;
}
if (muxEs && muxKeys === wanted && muxEs.readyState !== EventSource.CLOSED) return;
try { muxEs?.close(); } catch { /* not open */ }
muxListened.clear();
muxKeys = wanted;
const es = new EventSource(`/api/stream?s=${encodeURIComponent(wanted)}`);
muxEs = es;
// Each stream says `open` for itself (the server's __open), so nothing to do on the connection's own open.
es.onerror = () => {
if (es !== muxEs) return;
for (const key of muxSubs.keys()) muxFanOut(key, 'error', new Event('error'));
// EventSource retries transient drops itself but gives up for good on a
// completed non-SSE response (a 502 while the server restarts).
if (es.readyState === EventSource.CLOSED && muxSubs.size) {
clearTimeout(muxRetry);
muxRetry = setTimeout(muxConnect, 3000);
}
};
for (const [key, entry] of muxSubs) {
muxListen(key, '__open');
muxListen(key, '__down');
muxListen(key, 'message');
for (const sub of entry.subs) for (const name of Object.keys(sub.handlers.events || {})) muxListen(key, name);
}
}
function sseSubscribe(url, handlers = {}) {
const key = muxKeyFor(url);
if (!key) {
console.warn('[shell] not a stream this page can carry:', url);
return { close() {} };
}
let entry = muxSubs.get(key);
if (!entry) {
entry = { subs: new Set() };
muxSubs.set(key, entry);
}
const sub = { handlers };
entry.subs.add(sub);
const carried = Boolean(muxEs) && muxKeys.split(',').includes(key) && muxEs.readyState !== EventSource.CLOSED;
if (carried) {
// Joining a stream the connection already carries: listeners for any
// new event names, and `open` a moment later so the caller's own setup
// finishes first — exactly as a fresh connection would have reported it.
for (const name of Object.keys(handlers.events || {})) muxListen(key, name);
if (muxEs.readyState === EventSource.OPEN) {
setTimeout(() => {
if (!entry.subs.has(sub)) return;
try { handlers.open?.(new Event('open')); } catch (err) { console.error('[shell] stream handler failed:', err); }
}, 0);
}
} else {
clearTimeout(muxTimer);
muxTimer = setTimeout(muxConnect, 20);
}
return {
close() {
if (!entry.subs.delete(sub) || entry.subs.size) return;
muxSubs.delete(key);
// A remount closes and reopens within milliseconds: wait before remaking the connection.
clearTimeout(muxTimer);
muxTimer = setTimeout(muxConnect, 500);
},
};
}
/**
* The API handed to each module instance — the whole surface a module may
* touch outside its root element. See docs/MODULE-GUIDE.md.
*/
function buildModuleApi(tile, man, entry) {
const sseHandles = new Set();
const headerEls = new Set(); // controls this instance added to the title bar
const docListeners = new Set(); // outside-click closers for header menus
const api = {
id: man.id,
instanceId: tile.id,
/** For multi-tile modules: which entry of the module's tile list this
tile was added as ('' when added as the plain module). */
variant: tile.variant || '',
/** Admin (server-wide) config, read-only. Password fields never reach
the client. Live: reflects the latest registry after admin saves. */
get config() {
return { ...((registry.get(man.id) || man).config || {}) };
},
/** Per-tile settings: schema defaults overlaid with what this tile saved. */
get instanceSettings() {
const out = {};
for (const [key, spec] of Object.entries(tileSchema(tile, man).schema)) {
if (spec && 'default' in spec) out[key] = spec.default;
}
return Object.assign(out, tile.settings || {});
},
/** Persist per-tile settings into the layout (no remount — the module
already knows, it made the change). */
saveInstanceSettings(patch) {
Object.assign(tile.settings, patch || {});
saveLayout();
},
/** fetch scoped to this module's server routes. */
fetch(subPath, opts) {
return fetch('/api/modules/' + man.id + subPath, opts);
},
/**
* EventSource scoped the same way, with auto-reconnect: a stream that
* gives up (a 502 while the upstream is down) is recreated on a timer
* until it works again. Tiles asking for the same stream share one
* connection — see the pool above; a module sees no difference.
* handlers: { open(e), error(e), message(e), events: { name: fn } }
*/
sse(subPath, handlers = {}) {
const shared = sseSubscribe('/api/modules/' + man.id + subPath, handlers);
const handle = {
close() {
shared.close();
sseHandles.delete(handle);
},
};
sseHandles.add(handle);
return handle;
},
/** Drive the tile's status dot: 'ok' | 'connecting' | 'error'. */
setStatus(state, msg = '') {
const cls = state === 'ok' || state === 'connecting' || state === 'error' ? state : '';
entry.dot.className = 'dot ' + cls;
entry.dot.title = msg;
},
/**
* The tile's title bar. Controls added here live next to the gear/close
* buttons and are removed automatically when the instance stops. The
* whole bar can be hidden by the user via the notch, so nothing
* essential should exist ONLY as a header control.
*/
header: {
/** A small button. icon = inline SVG string, label = short text (either or both). */
addButton({ icon = '', label = '', title = '', onClick } = {}) {
const btn = document.createElement('button');
btn.className = 'tile-btn module-btn';
btn.title = title || label;
if (icon) btn.innerHTML = icon;
if (label) btn.appendChild(document.createTextNode(label));
if (typeof onClick === 'function') {
btn.addEventListener('click', (e) => {
e.stopPropagation();
onClick(e);
});
}
entry.controls.appendChild(btn);
headerEls.add(btn);
return btn;
},
/**
* A dropdown menu button. build(menuEl) runs on every open and fills
* the (emptied) menu element. Returns { button, menu, setLabel, close }.
*/
addMenu({ icon = '', label = '', title = '', build } = {}) {
const wrap = document.createElement('span');
wrap.className = 'tile-dd';
const btn = document.createElement('button');
btn.className = 'tile-btn module-btn';
btn.title = title || label;
if (icon) btn.innerHTML = icon;
if (label) btn.appendChild(document.createTextNode(label));
const menu = document.createElement('div');
menu.className = 'tile-menu';
menu.hidden = true;
const open = () => {
if (typeof build === 'function') {
menu.innerHTML = '';
build(menu);
}
menu.hidden = false;
// fixed positioning (escapes the tile's overflow): under the
// button, right-aligned, clamped to the viewport
const r = btn.getBoundingClientRect();
menu.style.top = Math.min(r.bottom + 6, window.innerHeight - 80) + 'px';
menu.style.left = '0px';
const w = menu.offsetWidth;
menu.style.left = Math.max(8, Math.min(r.right - w, window.innerWidth - w - 8)) + 'px';
};
btn.addEventListener('click', (e) => {
e.stopPropagation();
if (menu.hidden) open();
else menu.hidden = true;
});
const onDocClick = (e) => {
if (!menu.hidden && !menu.contains(e.target) && !btn.contains(e.target)) menu.hidden = true;
};
document.addEventListener('click', onDocClick);
docListeners.add(onDocClick);
wrap.append(btn, menu);
entry.controls.appendChild(wrap);
headerEls.add(wrap);
return {
button: btn,
menu,
setLabel(text) {
btn.textContent = text;
},
close() {
menu.hidden = true;
},
};
},
},
/* shell-internal: safety net so a forgotten stream, header control or
listener can't outlive the tile */
_closeAll() {
for (const handle of [...sseHandles]) handle.close();
for (const el of headerEls) el.remove();
headerEls.clear();
for (const fn of docListeners) document.removeEventListener('click', fn);
docListeners.clear();
},
};
return api;
}
async function mountModule(tile) {
const entry = tileEls.get(tile.id);
if (!entry) return;
const man = registry.get(tile.module);
if (!man) {
tileMessage(entry, `Module “${tile.module}” is not installed or is disabled. Check Admin.`);
entry.dot.className = 'dot error';
entry.dot.title = 'Module unavailable';
return;
}
entry.dot.className = 'dot connecting';
ensureModuleStyle(man);
try {
const mod = await loadClientModule(man);
if (tileEls.get(tile.id) !== entry || entry.instance) return; // tile removed/remounted while loading
const factory = mod.default;
if (typeof factory !== 'function') throw new Error('client.js has no default-export factory');
const api = buildModuleApi(tile, man, entry);
const instance = factory({ root: entry.body, moduleApi: api }) || {};
entry.instance = instance;
entry.api = api;
instance.start?.();
} catch (err) {
console.error(`[shell] module "${tile.module}" failed to start:`, err);
entry.body.innerHTML = '';
tileMessage(entry, `“${man.name}” failed to start: ${err?.message || err}`);
entry.dot.className = 'dot error';
}
}
function unmountModule(tile) {
const entry = tileEls.get(tile.id);
if (!entry) return;
try {
entry.instance?.stop?.();
} catch { /* stopping is best-effort */ }
try {
entry.api?._closeAll();
} catch { /* ditto */ }
entry.instance = null;
entry.api = null;
}
/* ── per-tile settings popover ──────────────────────────────────────── */
/** `<input type="color">` only accepts #rrggbb — anything else falls back. */
/* ── the tile tint ───────────────────────────────────────────────────
Every tile can be washed in a colour of its own — a way to tell one
screen's tiles apart at a glance (this campus green, that one amber).
It is the shell's, not a module's: the key is reserved, it is offered in
every gear window, and it saves with the layout like any other tile
setting. A tile with no tint looks exactly as before. */
const TINT_KEY = '__tint';
/** Paint (or clear) a tile's tint on its element. */
function applyTint(tile, el = tileEls.get(tile.id)?.el) {
if (!el) return;
const hex = colorHex(tile.settings?.[TINT_KEY], '');
if (hex) el.style.setProperty('--tile-tint', hex);
else el.style.removeProperty('--tile-tint');
el.classList.toggle('is-tinted', Boolean(hex));
}
function colorHex(value, fallback) {
const s = String(value ?? '').trim();
if (/^#[0-9a-fA-F]{6}$/.test(s)) return s.toLowerCase();
if (/^#[0-9a-fA-F]{3}$/.test(s)) return ('#' + s.slice(1).split('').map((c) => c + c).join('')).toLowerCase();
return fallback;
}
/** The settings a tile offers: its picker entry's own schema when it has one
— a solo timer wants none of a full tile's switches — else the module's.
Same for the groups that arrange them. */
function tileSchema(tile, man) {
const own = (v) => (v && typeof v === 'object' ? v : null);
const variant = String(tile?.variant || '');
const entry = variant && Array.isArray(man?.tiles)
? man.tiles.find((e) => String(e.id) === variant)
|| man.tiles.find((e) => Array.isArray(e.aliases) && e.aliases.includes(variant))
: null;
return {
schema: own(entry?.instanceSchema) || own(man?.instanceSchema) || {},
groups: own(entry?.instanceGroups) || own(man?.instanceGroups) || {},
};
}
/* ── tooltips: at once on hover, short, one for the whole page ──────────
Every header and top-bar button's `title` becomes a tip the shell shows
the moment the pointer arrives — the browser's own waits about a second
and looks different in each browser. The text moves from `title` to
data-tip so the native one never appears; a module that sets `title`
again later is picked up on the next hover. */
let tipEl = null;
const TIP_TARGETS = '.tile-btn, .tb-btn, [data-tip]';
function tipTextFor(el) {
if (el.title) {
el.dataset.tip = el.title;
if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', el.title);
el.removeAttribute('title');
}
return el.dataset.tip || '';
}
function showTip(el) {
const text = tipTextFor(el);
if (!text) return;
if (!tipEl) {
tipEl = document.createElement('div');
tipEl.id = 'tip';
tipEl.setAttribute('role', 'tooltip');
document.body.appendChild(tipEl);
}
tipEl.textContent = text;
tipEl.hidden = false;
const r = el.getBoundingClientRect();
const w = tipEl.offsetWidth;
const h = tipEl.offsetHeight;
const left = Math.max(6, Math.min(r.left + r.width / 2 - w / 2, window.innerWidth - w - 6));
let top = r.bottom + 6;
if (top + h > window.innerHeight - 6) top = r.top - h - 6;
tipEl.style.left = `${Math.round(left)}px`;
tipEl.style.top = `${Math.round(top)}px`;
tipEl._for = el;
}
function hideTip(el = null) {
if (!tipEl || tipEl.hidden) return;
if (el && tipEl._for !== el) return;
tipEl.hidden = true;
tipEl._for = null;
}
document.addEventListener('pointerover', (e) => {
const el = e.target?.closest?.(TIP_TARGETS);
if (!el || (tipEl && tipEl._for === el && !tipEl.hidden)) return;
showTip(el);
});
document.addEventListener('pointerout', (e) => {
const el = e.target?.closest?.(TIP_TARGETS);
if (!el || (e.relatedTarget && el.contains(e.relatedTarget))) return;
hideTip(el);
});
document.addEventListener('pointerdown', () => hideTip(), true);
document.addEventListener('focusin', (e) => {
const el = e.target?.closest?.(TIP_TARGETS);
if (el) showTip(el);
});
document.addEventListener('focusout', () => hideTip());
window.addEventListener('scroll', () => hideTip(), true);
window.addEventListener('resize', () => hideTip());
function closeSettingsPopovers() {
document.querySelectorAll('.tile-settings').forEach((p) => {
p._cleanup?.();
p._button?.classList.remove('is-open');
p.remove();
});
}
/* ── the per-tile settings window ───────────────────────────────────── */
/* It opens beside its tile — to the right when there is room, else the
left, else over it — and always inside the viewport. A window rather than
something inside the tile, because a tile can be smaller than its own
settings, and because the point of live settings is watching the tile
change while you change them: the window must not sit on top of it. */
function placePopover(pop, anchor) {
const vw = window.innerWidth;
const vh = window.innerHeight;
const pad = 8;
const w = pop.offsetWidth;
const h = pop.offsetHeight;
let left = anchor.right + pad;
if (left + w > vw - pad) left = anchor.left - w - pad;
if (left < pad) left = Math.min(Math.max(pad, anchor.left), Math.max(pad, vw - w - pad));
let top = anchor.top;
if (top + h > vh - pad) top = vh - h - pad;
if (top < pad) top = pad;
pop.style.left = Math.round(left) + 'px';
pop.style.top = Math.round(top) + 'px';
}
function clampPopover(pop) {
const pad = 8;
const maxLeft = Math.max(pad, window.innerWidth - pop.offsetWidth - pad);
const maxTop = Math.max(pad, window.innerHeight - pop.offsetHeight - pad);
pop.style.left = Math.min(Math.max(pad, pop.offsetLeft), maxLeft) + 'px';
pop.style.top = Math.min(Math.max(pad, pop.offsetTop), maxTop) + 'px';
}
/** Open (or close) the settings window for `tile`, beside its element `el`; `button` is the gear that asked. */
function toggleSettingsPopover(tile, el, button = null) {
const existing = document.querySelector(`.tile-settings[data-tile-id="${tile.id}"]`);
closeSettingsPopovers();
if (existing) return;
const man = registry.get(tile.module);
if (!man) return;
const { schema, groups: groupMeta } = tileSchema(tile, man);
const pop = document.createElement('div');
pop.className = 'tile-settings';
pop.dataset.tileId = tile.id;
pop.setAttribute('role', 'dialog');
pop.setAttribute('aria-label', `${tile.title || man.name} settings`);
// The gear that opened it wears the window's colour while it is open.
if (button) {
button.classList.add('is-open');
pop._button = button;
}
// A slim bar to move it by — nothing else. No title (the window sits beside
// the tile it belongs to) and no close button: a click anywhere else, or
// Escape, closes it.
const bar = document.createElement('div');
bar.className = 'tile-settings-bar';
bar.title = 'Drag to move';
const body = document.createElement('div');
body.className = 'tile-settings-body';
pop.append(bar, body);
const inputs = new Map();
const shownWhen = []; // fields that appear only while another has a given value
/* Display choices apply as they change and save with the layout — nothing
to confirm, so no Apply. Typing is debounced because applying means
remounting the module, which shouldn't happen per keystroke. */
const same = (a, b) => a === b
|| (Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((v, i) => v === b[i]));
let applyTimer = null;
const commit = (delay) => {
clearTimeout(applyTimer);
applyTimer = setTimeout(() => {
applyTimer = null;
// Only when something really moved: a text field fires `input` and then
// `change` on the way out, and a module shouldn't be torn down twice
// over for one edit. A reader answering undefined has nothing to say.
let changed = false;
for (const [key, read] of inputs) {
const value = read();
if (value === undefined || same(tile.settings[key], value)) continue;
tile.settings[key] = value;
changed = true;
}
if (!changed) return;
saveLayout();
remountTile(tile);
}, delay);
};
const live = (input, event = 'change', delay = 0) => {
input.addEventListener(event, () => commit(delay));
};
/** Choices a module only knows at runtime, from one of its own routes. */
const fetchOptions = async (route) => {
try {
const res = await fetchWithTimeout(`/api/modules/${encodeURIComponent(man.id)}${route}`, 8000);
const data = res.ok ? await res.json() : null;
return Array.isArray(data?.options) ? data.options : null;
} catch {
return null;
}
};
const optValue = (opt) => String(typeof opt === 'object' ? opt.value : opt);
const optLabel = (opt) => String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt);
// Fields render in schema order; a field's `group` starts a labeled
// subsection that consecutive same-group fields share, and the groups map
// adds { collapsed, columns, help, toggle } — the same conventions as the
// admin page's config form.
let groupName = null;
let container = body;
let current = null; // the open group: { el, title, toggleKey }
for (const [key, spec] of Object.entries(schema)) {
const type = spec?.type || 'string';
const label = spec?.label || key;
const value = key in (tile.settings || {}) ? tile.settings[key] : spec?.default;
const g = spec?.group || '';
if (g !== groupName) {
groupName = g;
if (g) {
const meta = groupMeta[g] && typeof groupMeta[g] === 'object' ? groupMeta[g] : {};
const folds = meta.collapsed !== undefined;
const group = document.createElement(folds ? 'details' : 'div');
group.className = 'field-group';
if (folds) group.open = !meta.collapsed;
const title = document.createElement(folds ? 'summary' : 'div');
title.className = 'field-group-title';
const titleText = document.createElement('span');
titleText.className = 'field-group-name';
titleText.textContent = g;
title.appendChild(titleText);
group.appendChild(title);
const groupBody = document.createElement('div');
groupBody.className = 'field-group-body';
const cols = Math.min(4, Math.trunc(Number(meta.columns)) || 0);
if (cols > 1) {
groupBody.classList.add('is-columns');
groupBody.style.setProperty('--cols', String(cols));
}
if (meta.help) {
const help = document.createElement('div');
help.className = 'field-group-help';
help.textContent = meta.help;
groupBody.appendChild(help);
}
group.appendChild(groupBody);
body.appendChild(group);
pop.classList.add('has-groups');
container = groupBody;
current = { el: group, title, toggleKey: typeof meta.toggle === 'string' ? meta.toggle : '' };
} else {
container = body;
current = null;
}
}
const isSwitch = type === 'boolean' || type === 'switch';
// A multiselect holds several controls, so it can't be a <label>: a click
// anywhere in one would flip the first control.
const field = document.createElement(type === 'multiselect' ? 'div' : 'label');
field.className = 'field' + (isSwitch ? ' check' : '');
let switchInput = null;
if (isSwitch) {
// A switch: on is shown/enabled, off is hidden/disabled — the label
// names what it turns on. A real checkbox stays underneath (keyboard,
// label clicks and `change` all keep working); the track is the look.
const text = document.createElement('span');
text.className = 'check-label';
text.textContent = label;
const toggle = document.createElement('span');
toggle.className = 'switch';
const input = document.createElement('input');
input.type = 'checkbox';
input.checked = Boolean(value);
input.setAttribute('aria-label', label);
const track = document.createElement('span');
track.className = 'track';
toggle.append(input, track);
field.append(text, toggle);
inputs.set(key, () => input.checked);
live(input);
switchInput = input;
} else if (type === 'select' && (Array.isArray(spec.options) || spec.optionsRoute)) {
const span = document.createElement('span');
span.textContent = label;
const select = document.createElement('select');
const saved = value === undefined || value === null ? '' : String(value);
const fill = (options) => {
const wanted = select.options.length ? select.value : saved;
select.innerHTML = '';
// What this tile saved stays choosable even when the live list has
// moved on (a source that is off right now, say).
if (saved && !options.some((o) => optValue(o) === saved)) {
options = [{ value: saved, label: `${saved} (saved)` }, ...options];
}
for (const opt of options) {
const o = document.createElement('option');
o.value = optValue(opt);
o.textContent = optLabel(opt);
select.appendChild(o);
}
select.value = options.some((o) => optValue(o) === wanted) ? wanted : saved;
};
fill(Array.isArray(spec.options) ? spec.options : []);
if (spec.optionsRoute) fetchOptions(spec.optionsRoute).then((opts) => { if (opts) fill(opts); });
field.append(span, select);
inputs.set(key, () => select.value);
live(select);
} else if (type === 'multiselect' && (Array.isArray(spec.options) || spec.optionsRoute)) {
// Several picks from a list, one switch each, stored as an array. With
// `allByDefault`, a tile that has never chosen shows every option on
// and saves nothing until someone flips one — so options that turn up
// later are on as well, until there is an explicit list.
field.classList.add('multiselect-field');
const span = document.createElement('span');
span.className = 'field-label';
span.textContent = label;
const list = document.createElement('div');
list.className = 'multiselect';
const cols = Math.min(4, Math.trunc(Number(spec.columns)) || 0);
if (cols > 1) {
list.classList.add('is-columns');
list.style.setProperty('--cols', String(cols));
}
const saved = Array.isArray(value) ? value.map(String) : null; // null: never chosen
let touched = false;
const chosen = new Set(saved || []);
const fill = (options) => {
for (const input of list.querySelectorAll('input')) {
if (input.checked) chosen.add(input.value);
else chosen.delete(input.value);
}
list.innerHTML = '';
const missing = [...chosen].filter((v) => !options.some((o) => optValue(o) === v));
const all = [...options, ...missing.map((v) => ({ value: v, label: `${v} (saved — not listed now)` }))];
for (const opt of all) {
const row = document.createElement('label');
row.className = 'multiselect-row';
const text = document.createElement('span');
text.className = 'check-label';
text.textContent = optLabel(opt);
const toggle = document.createElement('span');
toggle.className = 'switch';
const input = document.createElement('input');
input.type = 'checkbox';
input.value = optValue(opt);
input.checked = saved === null && !touched && spec.allByDefault ? true : chosen.has(input.value);
if (input.checked) chosen.add(input.value);
const track = document.createElement('span');
track.className = 'track';
toggle.append(input, track);
row.append(text, toggle);
input.addEventListener('change', () => {
touched = true;
commit(0);
});
list.appendChild(row);
}
if (!all.length) {
const none = document.createElement('div');
none.className = 'multiselect-empty';
none.textContent = 'Nothing to choose from yet.';
list.appendChild(none);
}
};
fill(Array.isArray(spec.options) ? spec.options : []);
if (spec.optionsRoute) fetchOptions(spec.optionsRoute).then((opts) => { if (opts) fill(opts); });
field.append(span, list);
inputs.set(key, () => (saved === null && !touched
? undefined
: [...list.querySelectorAll('input:checked')].map((input) => input.value)));
} else if (type === 'color') {
// A swatch picker; the value is always a #rrggbb string.
field.classList.add('color-field');
const span = document.createElement('span');
span.textContent = label;
const input = document.createElement('input');
input.type = 'color';
input.value = colorHex(value, colorHex(spec?.default, '#2ee59a'));
field.append(span, input);
inputs.set(key, () => input.value);
live(input, 'input', 150); // dragging the picker fires continuously
} else if (type === 'template') {
// Text with placeholders, built by dragging (or clicking) the variables
// the module offers into it — nobody should have to remember the names.
field.classList.add('template-field');
const span = document.createElement('span');
span.textContent = label;
const input = document.createElement('input');
input.type = 'text';
input.spellcheck = false;
input.value = value === undefined || value === null ? '' : String(value);
const vars = document.createElement('div');
vars.className = 'template-vars';
for (const v of Array.isArray(spec.variables) ? spec.variables : []) {
const chip = document.createElement('button');
chip.type = 'button';
chip.className = 'template-var';
chip.draggable = true;
const token = optValue(v);
chip.textContent = optLabel(v);
chip.title = `Drag or click to insert ${token}`;
chip.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', token);
e.dataTransfer.effectAllowed = 'copy';
});
chip.addEventListener('click', (e) => {
e.preventDefault();
const start = input.selectionStart ?? input.value.length;
const end = input.selectionEnd ?? start;
input.setRangeText(token, start, end, 'end');
input.focus();
commit(0);
});
vars.appendChild(chip);
}
field.append(span, input, vars);
inputs.set(key, () => input.value);
live(input, 'input', 400);
live(input, 'change');
} else {
const span = document.createElement('span');
span.textContent = label;
const input = document.createElement('input');
input.type = type === 'number' ? 'number' : (type === 'password' ? 'password' : 'text');
input.value = value === undefined || value === null ? '' : String(value);
field.append(span, input);
inputs.set(key, () => (type === 'number' ? Number(input.value) : input.value));
live(input, 'input', 400); // mid-word is not the moment to remount
live(input, 'change'); // …but blur or Enter is
}
if (spec?.help) {
const help = document.createElement('small');
help.className = 'field-help';
help.textContent = spec.help;
field.appendChild(help);
}
if (typeof spec?.showWhen === 'string' && spec.showWhen) shownWhen.push({ field, key: spec.showWhen });
if (current && switchInput && key === current.toggleKey) {
// The group's on/off lives in its title row, never in the body, and a
// click on it must not fold a <details>.
const grp = current;
field.classList.add('group-toggle');
field.addEventListener('click', (e) => e.stopPropagation());
const sync = () => grp.el.classList.toggle('is-off', !switchInput.checked);
switchInput.addEventListener('change', sync);
sync();
grp.title.appendChild(field);
} else {
container.appendChild(field);
}
}
/* The tint sits on its own at the foot of the window, under a hairline —
it is the shell's setting, not one of the module's. Unlike those, a
change needs no remount: nothing but the tile's own chrome reads it. */
{
const field = document.createElement('div');
field.className = 'field color-field tint-field'; // the swatch reads like a module's colour field
const text = document.createElement('span');
text.textContent = 'Tile tint';
const input = document.createElement('input');
input.type = 'color';
input.setAttribute('aria-label', 'Tile tint');
const clear = document.createElement('button');
clear.type = 'button';
clear.className = 'tint-clear';
clear.textContent = 'Clear';
clear.title = 'No tint';
const paint = () => {
const hex = colorHex(tile.settings[TINT_KEY], '');
// With no tint the swatch opens on the panel colour, so the picker
// starts somewhere sane rather than at black.
input.value = hex || colorHex(getComputedStyle(document.documentElement).getPropertyValue('--panel'), '#1a1d21');
clear.hidden = !hex;
};
const set = (hex) => {
if (hex) tile.settings[TINT_KEY] = hex;
else delete tile.settings[TINT_KEY];
applyTint(tile);
paint();
saveLayout();
};
input.addEventListener('input', () => set(colorHex(input.value, '')));
clear.addEventListener('click', () => set(''));
paint();
field.append(text, clear, input);
body.appendChild(field);
}
// "Short day" appears once "Date" is on: a field with showWhen:"K" follows
// K's switch, live — the same rule the admin page applies.
if (shownWhen.length) {
const refresh = () => {
for (const rule of shownWhen) {
const read = inputs.get(rule.key);
rule.field.hidden = read ? !read() : false;
}
};
refresh();
body.addEventListener('change', refresh);
body.addEventListener('input', refresh);
}
// Nothing to press: click away, press Escape, or the gear again.
// (The gear's own click stops propagating, so it toggles rather than
// closing and reopening.)
const closeOnClick = (e) => { if (!pop.contains(e.target)) closeSettingsPopovers(); };
const closeOnEscape = (e) => { if (e.key === 'Escape') closeSettingsPopovers(); };
const keepInView = () => clampPopover(pop);
pop._cleanup = () => {
document.removeEventListener('click', closeOnClick);
document.removeEventListener('keydown', closeOnEscape);
window.removeEventListener('resize', keepInView);
};
document.addEventListener('click', closeOnClick);
document.addEventListener('keydown', closeOnEscape);
window.addEventListener('resize', keepInView);
// Moved by its bar.
bar.addEventListener('pointerdown', (e) => {
if (e.button !== 0 && e.pointerType === 'mouse') return;
e.preventDefault();
bar.setPointerCapture(e.pointerId);
const dx = e.clientX - pop.offsetLeft;
const dy = e.clientY - pop.offsetTop;
const onMove = (ev) => {
pop.style.left = (ev.clientX - dx) + 'px';
pop.style.top = (ev.clientY - dy) + 'px';
clampPopover(pop);
};
const onUp = () => {
bar.removeEventListener('pointermove', onMove);
bar.removeEventListener('pointerup', onUp);
bar.removeEventListener('pointercancel', onUp);
};
bar.addEventListener('pointermove', onMove);
bar.addEventListener('pointerup', onUp);
bar.addEventListener('pointercancel', onUp);
});
pop.addEventListener('pointerdown', (e) => e.stopPropagation());
document.body.appendChild(pop);
placePopover(pop, el.getBoundingClientRect());
}
/** Tear a tile's module down and start it again (settings changed). */
function remountTile(tile) {
const entry = tileEls.get(tile.id);
if (!entry) return;
unmountModule(tile);
entry.body.innerHTML = '';
entry.dot.className = 'dot';
mountModule(tile);
}
/* ── layout apply / reset ───────────────────────────────────────────── */
function renderLayout(list) {
clearAllTiles();
tiles = list.map(sanitizeTile);
for (const tile of tiles) buildTile(tile);
refreshEmptyState();
rebuildDividers();
if (document.body.classList.contains('fullscreen')) applyFullscreenFit();
}
/* ── menus ──────────────────────────────────────────────────────────── */
/* What the + picker shows, as a string: repainting a menu the user is
already pointing at is only worth it when something in it changed. */
function addMenuSignature() {
return JSON.stringify([...registry.values()].map((m) => [m.id, m.name, m.description, m.tiles]));
}
wireDropdown('add-btn', 'add-menu', async (menu, stillOpen) => {
// Painted from the list already in hand, then refreshed: a module's tile
// list is live data, and Admin may have enabled or disabled something.
const shown = addMenuSignature();
renderAddMenu(menu);
if (await loadRegistry() && stillOpen() && addMenuSignature() !== shown) renderAddMenu(menu);
});
function renderAddMenu(menu) {
menu.innerHTML = '';
const manifests = [...registry.values()];
if (!manifests.length) {
const note = document.createElement('div');
note.className = 'menu-note';
note.textContent = 'No modules are enabled. Enable some in Admin.';
menu.appendChild(note);
return;
}
for (const man of manifests) {
const entries = Array.isArray(man.tiles) ? man.tiles : [];
if (entries.length) {
// A multi-tile module: its name becomes a group heading and each of
// its tiles is added individually.
const title = document.createElement('div');
title.className = 'menu-title';
title.textContent = man.name;
menu.appendChild(title);
for (const entry of entries) {
// An entry that allows one tile at most is offered greyed out once
// that tile exists — a second Send box would only confuse.
const placed = entry.single && tiles.some((t) => t.module === man.id && t.variant === String(entry.id));
// Descriptions ride along as tooltips: the list stays a list.
menu.appendChild(menuItem(entry.name, () => {
menu.hidden = true;
addTile(man.id, entry);
}, { disabled: placed, title: placed ? 'Already on this dashboard' : (entry.description || '') }));
}
} else {
menu.appendChild(menuItem(man.name, () => {
menu.hidden = true;
addTile(man.id);
}, { title: man.description || '' }));
}
}
}
wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
menu.innerHTML = '';
menu.appendChild(menuItem('Save as named layout…', async () => {
menu.hidden = true;
if (!tiles.length) {
toast('Nothing to save — this dashboard is empty', true);
return;
}
const name = await promptModal({
title: 'Save this layout to the server',
placeholder: 'e.g. FOH booth, Video world',
okLabel: 'Save',
});
if (!name) return;
try {
const res = await fetch('/api/layouts/' + encodeURIComponent(name), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ layout: { tiles } }),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.error || 'Save failed (' + res.status + ')');
toast(`Saved “${name}” — any browser can load it now`);
} catch (e) {
toast(e.message, true);
}
}, { sub: 'Share this arrangement with other browsers' }));
const divider = document.createElement('div');
divider.className = 'menu-divider';
menu.appendChild(divider);
const reset = menuItem('Reset layout', () => {
menu.hidden = true;
renderLayout([]);
saveLayout();
toast('Layout cleared');
}, { danger: true, sub: 'Remove every tile from this browser' });
menu.appendChild(reset);
// Named layouts stored on the server slot in above Reset once they
// arrive; until then (or if they never do) the menu is already usable.
let layouts = [];
try {
const res = await fetchWithTimeout('/api/layouts', 8000);
if (res.ok) layouts = (await res.json()).layouts || [];
} catch { /* server briefly away — the menu just shows none */ }
if (!layouts.length || !stillOpen()) return;
const block = document.createDocumentFragment();
const title = document.createElement('div');
title.className = 'menu-title';
title.textContent = 'Load from server';
block.appendChild(title);
for (const l of layouts) {
block.appendChild(menuItem(l.name, async () => {
menu.hidden = true;
try {
const res = await fetch('/api/layouts/' + encodeURIComponent(l.name));
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.error || 'Load failed (' + res.status + ')');
// Loading copies the layout into this browser's own state — it
// does not live-link browsers together.
renderLayout(body.layout.tiles || []);
saveLayout();
toast(`Loaded “${l.name}”`);
} catch (e) {
toast(e.message, true);
}
}, { sub: `${l.tiles} tile${l.tiles === 1 ? '' : 's'}` }));
}
block.appendChild(divider.cloneNode());
menu.insertBefore(block, reset);
});
/* ── menu bar show/hide (notch, like the tile title bars) ───────────── */
const topbar = document.getElementById('topbar');
const topbarNotch = document.getElementById('topbar-notch');
const LS_MENU = 'proddash:menuHidden';
/** Keep the notch glued to the menubar's bottom edge, whatever its height. */
const topbarSizer = new ResizeObserver(() => {
document.documentElement.style.setProperty('--topbar-h', topbar.offsetHeight + 'px');
});
topbarSizer.observe(topbar);
function setMenuHidden(hidden, { persist = true } = {}) {
document.body.classList.toggle('menu-hidden', hidden);
if (persist) {
try { localStorage.setItem(LS_MENU, hidden ? '1' : '0'); } catch { /* fine */ }
}
}
topbarNotch.addEventListener('click', () => {
setMenuHidden(!document.body.classList.contains('menu-hidden'));
});
try {
if (localStorage.getItem(LS_MENU) === '1') setMenuHidden(true, { persist: false });
} catch { /* fine */ }
/* ── fullscreen: peeking at a tile's title bar ──────────────────────── */
/** How long a peeked title bar stays after the pointer leaves its tile. */
const PEEK_HIDE_MS = 10000;
const peekTimers = new Map(); // tile id → timeout
function clearPeekTimer(id) {
const timer = peekTimers.get(id);
if (timer === undefined) return;
clearTimeout(timer);
peekTimers.delete(id);
}
function schedulePeekHide(id, el) {
clearPeekTimer(id);
peekTimers.set(id, setTimeout(() => {
peekTimers.delete(id);
el.classList.remove('head-peek');
}, PEEK_HIDE_MS));
}
function peekHead(tile, el, on) {
el.classList.toggle('head-peek', on);
clearPeekTimer(tile.id);
// Opened from a click the pointer is on the tile, so the countdown waits
// for it to leave — unless it already has (a keyboard or touch open).
if (on && !el.matches(':hover')) schedulePeekHide(tile.id, el);
}
/** Back to a plain screen: no peeks left open, no timers left running. */
function clearPeeks() {
for (const id of [...peekTimers.keys()]) clearPeekTimer(id);
document.querySelectorAll('.tile.head-peek').forEach((el) => el.classList.remove('head-peek'));
}
/* ── fullscreen: auto-fit the layout to the screen ──────────────────── */
/* Fullscreen is a viewing mode. The occupied columns stretch proportionally
to the full width, and every column runs to the bottom of the screen: the
bottom-most tile of each column stretches to end exactly there. A small
tile (the clock) is never stretched into a ribbon — with a tile above it,
it drops to the bottom edge and the tile above grows into the gap; alone
in its columns, it stays exactly as it is. When nothing extends below the
viewport, scrolling is disabled. All of it is visual-only — the saved
layout is untouched and everything reverts on exit. */
const fsBtn = document.getElementById('fs-btn');
/** Tiles this short (the clock) are moved, never stretched, in fullscreen. */
const FS_SMALL_ROWS = 2;
let menuHiddenBeforeFs = false;
function colsOverlap(a, b) {
return a.x < b.x + b.w && b.x < a.x + a.w;
}
function fsSmall(tile) {
return tile.h <= FS_SMALL_ROWS;
}
function fsAloneInColumns(tile) {
return !tiles.some((t) => t !== tile && colsOverlap(t, tile));
}
function fsBottomMost(tile) {
return !tiles.some((t) => t !== tile && colsOverlap(t, tile) && t.y >= tile.y + tile.h);
}
/**
* Fullscreen row placement for every tile, given R rows on screen:
* { id -> { y, h } }. Only bottom-most tiles change (stretch, or drop when
* small), plus the tile directly above a dropped small one, which grows
* into the space it left — stopping at anything else below it.
*/
function fullscreenRows(R) {
const rows = new Map(tiles.map((t) => [t.id, { y: t.y, h: t.h }]));
const dropped = [];
for (const tile of tiles) {
if (!fsBottomMost(tile)) continue;
if (!fsSmall(tile)) {
rows.get(tile.id).h = R - tile.y;
} else if (!fsAloneInColumns(tile) && R - tile.h > tile.y) {
rows.get(tile.id).y = R - tile.h;
dropped.push(tile);
}
}
for (const small of dropped) {
const gapTop = rows.get(small.id).y;
for (const above of tiles) {
if (above === small || fsSmall(above) || !colsOverlap(above, small)) continue;
if (above.y + above.h !== small.y) continue; // only a tile touching it
let limit = gapTop;
for (const other of tiles) {
if (other === above || other === small || !colsOverlap(above, other)) continue;
const oy = rows.get(other.id).y;
if (oy >= above.y + above.h) limit = Math.min(limit, oy);
}
rows.get(above.id).h = Math.max(above.h, limit - above.y);
}
}
return rows;
}
function applyFullscreenFit() {
if (!document.body.classList.contains('fullscreen') || !tiles.length) return;
const m = cellMetrics();
const baseCellH = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--cell-h')) || 72;
const wrapH = gridwrap.clientHeight;
const maxCol = Math.max(1, ...tiles.map((t) => t.x + t.w));
const maxRow = Math.max(1, ...tiles.map((t) => t.y + t.h));
// x: the occupied columns stretch proportionally across the full width
grid.style.gridTemplateColumns = `repeat(${maxCol}, minmax(0, 1fr))`;
const layoutH = maxRow * (baseCellH + m.gap) - m.gap;
if (layoutH > wrapH) {
// deeper than the screen: keep natural rows and scrolling
grid.style.gridAutoRows = '';
gridwrap.classList.remove('no-scroll');
for (const tile of tiles) applyRect(tile);
return;
}
// nothing below the fold: lock scrolling and make R rows fill the height exactly
gridwrap.classList.add('no-scroll');
const R = Math.max(maxRow, Math.floor((wrapH + m.gap) / (baseCellH + m.gap)));
const cellH = (wrapH - (R - 1) * m.gap) / R;
grid.style.gridAutoRows = cellH + 'px';
const rows = fullscreenRows(R);
for (const tile of tiles) {
const entry = tileEls.get(tile.id);
if (!entry) continue;
const r = rows.get(tile.id);
entry.el.style.gridColumn = `${tile.x + 1} / span ${tile.w}`;
entry.el.style.gridRow = `${r.y + 1} / span ${r.h}`;
try { entry.instance?.onResize?.(tile.w, r.h); } catch { /* module's problem, not the shell's */ }
}
}
function clearFullscreenFit() {
grid.style.gridTemplateColumns = '';
grid.style.gridAutoRows = '';
gridwrap.classList.remove('no-scroll');
for (const tile of tiles) {
applyRect(tile);
notifyResize(tile);
}
}
fsBtn.addEventListener('click', () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen?.().catch(() => {});
} else {
document.exitFullscreen?.().catch(() => {});
}
});
document.addEventListener('fullscreenchange', () => {
const active = Boolean(document.fullscreenElement);
document.body.classList.toggle('fullscreen', active);
if (active) {
closeSettingsPopovers();
menuHiddenBeforeFs = document.body.classList.contains('menu-hidden');
setMenuHidden(true, { persist: false }); // the menu auto-hides; its notch brings it back
// let the collapsed menubar's space settle before measuring
requestAnimationFrame(() => requestAnimationFrame(applyFullscreenFit));
} else {
setMenuHidden(menuHiddenBeforeFs, { persist: false });
clearPeeks();
clearFullscreenFit();
}
});
// The grid area's size settles late: the browser animates into fullscreen,
// then the menubar collapse animates. Re-fit whenever its size changes.
let fsRefit = 0;
new ResizeObserver(() => {
if (!document.body.classList.contains('fullscreen')) return;
cancelAnimationFrame(fsRefit);
fsRefit = requestAnimationFrame(applyFullscreenFit);
}).observe(gridwrap);
/* ── live updates from the admin page ───────────────────────────────── */
/* The server broadcasts `modules-changed` on /api/events whenever admin
config or enabled flags change. Tiles of a changed module get
onConfigChange(cfg) if they implement it, otherwise a clean remount. */
function subscribeEvents() {
// Rides on the page's one stream connection (see sseSubscribe) as `shell:`.
sseSubscribe('/api/events', {
events: {
'modules-changed': () => handleModulesChanged(),
theme: handleThemeEvent,
},
});
}
async function handleModulesChanged() {
const before = registry;
if (!(await loadRegistry())) return;
for (const tile of [...tiles]) {
const oldMan = before.get(tile.module);
const newMan = registry.get(tile.module);
if (!newMan !== !oldMan) {
// enabled or disabled: rebuild the tile body (shows the module, or
// an "unavailable" note)
remountTile(tile);
continue;
}
if (!newMan) continue;
if (JSON.stringify(oldMan.config || {}) !== JSON.stringify(newMan.config || {})) {
const entry = tileEls.get(tile.id);
if (typeof entry?.instance?.onConfigChange === 'function') {
try {
entry.instance.onConfigChange({ ...newMan.config });
} catch {
remountTile(tile);
}
} else {
remountTile(tile);
}
}
}
}
/* ── theme ──────────────────────────────────────────────────────────── */
/* The dashboard's palette is a server-wide choice (Admin → Theme). Five
preset variable sets live in style.css as html[data-theme="…"]; applying
one is setting that attribute. The last-known theme goes on synchronously
from localStorage so a reload never flashes the default palette, then the
server's answer (GET /api/theme) wins, and a `theme` shell event on
/api/events switches every open dashboard the moment an admin picks
another. Modules style with the shell variables, so they follow; a colour
a tile is configured with is set on the tile itself and stays as chosen. */
const LS_THEME = 'proddash:theme';
function applyTheme(id) {
const theme = String(id || '');
if (!/^[a-z][a-z0-9-]{0,31}$/.test(theme)) return;
document.documentElement.dataset.theme = theme;
try { localStorage.setItem(LS_THEME, theme); } catch { /* private mode — fine */ }
}
/** `theme` shell event from /api/events (wired in subscribeEvents). */
function handleThemeEvent(e) {
try {
applyTheme(JSON.parse(e.data).theme);
} catch { /* a malformed frame changes nothing */ }
}
function initTheme() {
try { applyTheme(localStorage.getItem(LS_THEME)); } catch { /* fine */ }
fetch('/api/theme', { cache: 'no-store' })
.then((res) => (res.ok ? res.json() : null))
.then((body) => { if (body?.theme) applyTheme(body.theme); })
.catch(() => { /* offline: the remembered theme stands */ });
}
initTheme();
/* ── boot ───────────────────────────────────────────────────────────── */
async function boot() {
const ok = await loadRegistry();
if (!ok) {
toast('ProdDash server unreachable — retrying…', true);
setTimeout(boot, 4000);
return;
}
renderLayout(loadLayoutFromStorage());
subscribeEvents();
}
boot();
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});
}