/* 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 ─────────────────────────────────────────────────── */
function wireDropdown(btnId, menuId, rebuild) {
const btn = document.getElementById(btnId);
const menu = document.getElementById(menuId);
btn.addEventListener('click', async () => {
if (menu.hidden) await rebuild(menu);
menu.hidden = !menu.hidden;
});
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 } = {}) {
const item = document.createElement('button');
item.className = 'menu-item' + (danger ? ' danger' : '');
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
/** 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 {
const res = await fetch('/api/modules');
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 GEAR_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;
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);
const hasSettings = man && man.instanceSchema && Object.keys(man.instanceSchema).length;
if (hasSettings) {
const gear = document.createElement('button');
gear.className = 'tile-btn';
gear.title = 'Tile settings (this tile only)';
gear.innerHTML = GEAR_SVG;
gear.addEventListener('click', (e) => {
e.stopPropagation();
toggleSettingsPopover(tile, el);
});
head.appendChild(gear);
}
const close = document.createElement('button');
close.className = 'tile-btn close';
close.title = 'Remove this tile';
close.textContent = '✕';
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();
tile.headHidden = !tile.headHidden;
el.classList.toggle('head-hidden', tile.headHidden);
saveLayout();
});
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 its pill moves the shared edge, trading width between the two
tiles — their 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). */
function rebuildDividers() {
grid.querySelectorAll('.tile-divider').forEach((el) => el.remove());
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);
}
}
}
function buildDivider(L, R, top, bottom) {
const el = document.createElement('div');
el.className = 'tile-divider';
el.title = 'Drag to resize';
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();
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);
el.classList.add('active');
const m = cellMetrics();
const sx = ev.clientX;
const startBoundary = L.x + L.w;
const rightEdge = R.x + R.w; // fixed for the whole drag
const minB = L.x + minSizeOf(L).w;
const maxB = rightEdge - minSizeOf(R).w;
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 === L.x + L.w) return;
const candL = { x: L.x, y: L.y, w: b - L.x, h: L.h };
const candR = { x: b, y: R.y, w: rightEdge - b, h: R.h };
if (!isFreeExcept(candL, [L.id, R.id]) || !isFreeExcept(candR, [L.id, R.id])) return;
L.w = candL.w;
R.x = candR.x;
R.w = candR.w;
applyRect(L);
applyRect(R);
notifyResize(L);
notifyResize(R);
place();
changed = true;
};
const onUp = () => {
el.removeEventListener('pointermove', onMove);
el.removeEventListener('pointerup', onUp);
el.removeEventListener('pointercancel', onUp);
el.classList.remove('active');
if (changed) saveLayout();
rebuildDividers(); // adjacencies may have changed
};
el.addEventListener('pointermove', onMove);
el.addEventListener('pointerup', onUp);
el.addEventListener('pointercancel', onUp);
});
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);
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 = {};
for (const [key, spec] of Object.entries(man?.instanceSchema || {})) {
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;
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
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);
}
/**
* 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(man.instanceSchema || {})) {
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: EventSource
* retries transient drops itself but gives up for good when a retry gets
* a completed non-SSE response (a 502 while the upstream is down), so a
* closed stream is recreated on a timer until it works again.
* handlers: { open(e), error(e), message(e), events: { name: fn } }
*/
sse(subPath, handlers = {}) {
const url = '/api/modules/' + man.id + subPath;
let es = null;
let retryTimer = null;
let closed = false;
const connect = () => {
if (closed) return;
try { es?.close(); } catch { /* already closed */ }
es = new EventSource(url);
if (handlers.open) es.onopen = handlers.open;
if (handlers.message) es.onmessage = handlers.message;
for (const [name, fn] of Object.entries(handlers.events || {})) {
es.addEventListener(name, fn);
}
es.onerror = (e) => {
try { handlers.error?.(e); } catch { /* module's problem */ }
if (es.readyState === EventSource.CLOSED) {
clearTimeout(retryTimer);
retryTimer = setTimeout(connect, 3000);
}
};
};
connect();
const handle = {
close() {
closed = true;
clearTimeout(retryTimer);
try { es?.close(); } catch { /* already closed */ }
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 ──────────────────────────────────────── */
/** `` only accepts #rrggbb — anything else falls back. */
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;
}
function closeSettingsPopovers() {
document.querySelectorAll('.tile-settings').forEach((p) => p.remove());
}
function toggleSettingsPopover(tile, el) {
const existing = el.querySelector('.tile-settings');
closeSettingsPopovers();
if (existing) return;
const man = registry.get(tile.module);
if (!man || !man.instanceSchema) return;
const pop = document.createElement('div');
pop.className = 'tile-settings';
const inputs = new Map();
// Fields render in schema order; a field's `group` starts a labeled
// subsection that consecutive same-group fields share, and the manifest's
// optional `instanceGroups[name]` adds { collapsed, columns, help } —
// the same conventions as the admin page's config form.
const groupMeta = man.instanceGroups && typeof man.instanceGroups === 'object' ? man.instanceGroups : {};
let groupName = null;
let container = pop;
for (const [key, spec] of Object.entries(man.instanceSchema)) {
const type = spec?.type || 'string';
const label = spec?.label || key;
const current = 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';
title.textContent = g;
group.appendChild(title);
const body = document.createElement('div');
body.className = 'field-group-body';
const cols = Math.min(4, Math.trunc(Number(meta.columns)) || 0);
if (cols > 1) {
body.classList.add('is-columns');
body.style.setProperty('--cols', String(cols));
}
if (meta.help) {
const help = document.createElement('div');
help.className = 'field-group-help';
help.textContent = meta.help;
body.appendChild(help);
}
group.appendChild(body);
pop.appendChild(group);
pop.classList.add('has-groups');
container = body;
} else {
container = pop;
}
}
const field = document.createElement('label');
field.className = 'field' + (type === 'boolean' ? ' check' : '');
if (type === 'boolean') {
const input = document.createElement('input');
input.type = 'checkbox';
input.checked = Boolean(current);
field.append(input, document.createTextNode(label));
inputs.set(key, () => input.checked);
} else if (type === 'select' && Array.isArray(spec.options)) {
const span = document.createElement('span');
span.textContent = label;
const select = document.createElement('select');
for (const opt of spec.options) {
const o = document.createElement('option');
o.value = String(typeof opt === 'object' ? opt.value : opt);
o.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt);
select.appendChild(o);
}
select.value = String(current ?? '');
field.append(span, select);
inputs.set(key, () => select.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(current, colorHex(spec?.default, '#2ee59a'));
field.append(span, input);
inputs.set(key, () => input.value);
} 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 = current === undefined || current === null ? '' : String(current);
field.append(span, input);
inputs.set(key, () => (type === 'number' ? Number(input.value) : input.value));
}
if (spec?.help) {
const help = document.createElement('small');
help.className = 'field-help';
help.textContent = spec.help;
field.appendChild(help);
}
container.appendChild(field);
}
const actions = document.createElement('div');
actions.className = 'actions';
actions.style.display = 'flex';
actions.style.justifyContent = 'flex-end';
actions.style.gap = '8px';
const cancel = document.createElement('button');
cancel.className = 'btn';
cancel.textContent = 'Cancel';
cancel.addEventListener('click', () => pop.remove());
const apply = document.createElement('button');
apply.className = 'btn primary';
apply.textContent = 'Apply';
apply.addEventListener('click', () => {
for (const [key, read] of inputs) tile.settings[key] = read();
pop.remove();
saveLayout();
remountTile(tile);
});
actions.append(cancel, apply);
pop.appendChild(actions);
pop.addEventListener('pointerdown', (e) => e.stopPropagation());
el.appendChild(pop);
}
/** 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 ──────────────────────────────────────────────────────────── */
wireDropdown('add-btn', 'add-menu', async (menu) => {
await loadRegistry();
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) {
menu.appendChild(menuItem(entry.name, () => {
menu.hidden = true;
addTile(man.id, entry);
}, { sub: entry.description || '' }));
}
} else {
menu.appendChild(menuItem(man.name, () => {
menu.hidden = true;
addTile(man.id);
}, { sub: man.description || '' }));
}
}
});
wireDropdown('layout-btn', 'layout-menu', async (menu) => {
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);
// named layouts stored on the server
let layouts = [];
try {
const res = await fetch('/api/layouts');
if (res.ok) layouts = (await res.json()).layouts || [];
} catch { /* server briefly away — the menu just shows none */ }
if (layouts.length) {
const title = document.createElement('div');
title.className = 'menu-title';
title.textContent = 'Load from server';
menu.appendChild(title);
for (const l of layouts) {
menu.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'}` }));
}
menu.appendChild(divider.cloneNode());
}
menu.appendChild(menuItem('Reset layout', () => {
menu.hidden = true;
renderLayout([]);
saveLayout();
toast('Layout cleared');
}, { danger: true, sub: 'Remove every tile from this browser' }));
});
/* ── 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: 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 });
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. */
let eventsSource = null;
let eventsRetry = null;
function subscribeEvents() {
try { eventsSource?.close(); } catch { /* not open */ }
eventsSource = new EventSource('/api/events');
eventsSource.addEventListener('modules-changed', () => handleModulesChanged());
eventsSource.addEventListener('theme', handleThemeEvent);
eventsSource.onerror = () => {
if (eventsSource.readyState === EventSource.CLOSED) {
clearTimeout(eventsRetry);
eventsRetry = setTimeout(subscribeEvents, 4000);
}
};
}
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(() => {});
}