Every boolean (and `switch`) in a tile's gear popover renders as the same toggle the admin page uses for its module switches — 34×19 track, accent when on, border track and muted knob when off — with the label on the left and the switch on the right, so the two surfaces read the same. On means shown or enabled, off hidden or disabled; the label names the thing. A real checkbox still sits under the track: Tab reaches it, Space flips it (with a focus ring), clicking the label text toggles it, and the existing live-commit wiring reads it unchanged. `switch`-typed instance fields, which previously fell through to a text input, take the same path. The popover's bottom was 20 px of nothing: the last field kept its 10 px margin under the popover's own 10 px padding once the Apply/Cancel row went. The last child now drops its margin, top and bottom match (11 px each, border included), and in column layouts the grid gap alone spaces the rows. Class names, for the admin page to mirror: `.field.check` (the row), `.check-label`, `.switch`, `.switch .track`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1684 lines
61 KiB
JavaScript
1684 lines
61 KiB
JavaScript
/* 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 } = {}) {
|
||
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
|
||
|
||
/** 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 GEAR_SVG = '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm8.4-2.1.1-1.4-.1-1.4 1.7-1.3-1.6-2.8-2 .7a7.9 7.9 0 0 0-2.4-1.4l-.3-2.1H11l-.3 2.1a7.9 7.9 0 0 0-2.4 1.4l-2-.7L4.7 9.3 6.4 10.6l-.1 1.4.1 1.4-1.7 1.3 1.6 2.8 2-.7a7.9 7.9 0 0 0 2.4 1.4l.3 2.1h2.8l.3-2.1a7.9 7.9 0 0 0 2.4-1.4l2 .7 1.6-2.8-1.7-1.3Z"/></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();
|
||
// 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);
|
||
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;
|
||
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);
|
||
}
|
||
|
||
/* ── shared event streams ───────────────────────────────────────────── */
|
||
|
||
/* 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. One stream per
|
||
tile plus the shell's own /api/events reaches that ceiling at five or six
|
||
tiles, and from then on every further request — the + picker's module
|
||
list, a new tile's /state, its stream — sits in the browser's queue
|
||
waiting for a connection that is never handed back. So tiles that ask
|
||
for the same stream share one connection: the pool keeps one EventSource
|
||
per URL, fans its events out to every subscriber, and reconnects it (on
|
||
the same 3-second timer the per-tile version used) while anyone is still
|
||
listening. A tile joining an already-open stream is told `open` straight
|
||
away, so its backfill runs exactly as if the connection were its own. */
|
||
|
||
const ssePool = new Map(); // url -> shared source
|
||
|
||
function sseSubscribe(url, handlers = {}) {
|
||
let shared = ssePool.get(url);
|
||
if (!shared) {
|
||
shared = { subs: new Set(), es: null, retryTimer: null, names: new Set() };
|
||
const fanOut = (kind, e) => {
|
||
for (const sub of [...shared.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); }
|
||
}
|
||
};
|
||
shared.listen = (name) => shared.es.addEventListener(name, (e) => fanOut('event', e));
|
||
shared.connect = () => {
|
||
clearTimeout(shared.retryTimer);
|
||
shared.retryTimer = null;
|
||
try { shared.es?.close(); } catch { /* already closed */ }
|
||
const es = new EventSource(url);
|
||
shared.es = es;
|
||
es.onopen = (e) => fanOut('open', e);
|
||
es.onmessage = (e) => fanOut('message', e);
|
||
es.onerror = (e) => {
|
||
fanOut('error', e);
|
||
// EventSource retries transient drops itself but gives up for good on
|
||
// a completed non-SSE response (a 502 while the upstream is down).
|
||
if (es.readyState === EventSource.CLOSED && shared.subs.size) {
|
||
clearTimeout(shared.retryTimer);
|
||
shared.retryTimer = setTimeout(shared.connect, 3000);
|
||
}
|
||
};
|
||
for (const name of shared.names) shared.listen(name);
|
||
};
|
||
ssePool.set(url, shared);
|
||
}
|
||
|
||
const sub = { handlers };
|
||
shared.subs.add(sub);
|
||
for (const name of Object.keys(handlers.events || {})) {
|
||
if (shared.names.has(name)) continue;
|
||
shared.names.add(name);
|
||
if (shared.es) shared.listen(name);
|
||
}
|
||
if (!shared.es || shared.es.readyState === EventSource.CLOSED) {
|
||
// first subscriber, or the stream had given up while nobody listened
|
||
shared.connect();
|
||
} else if (shared.es.readyState === EventSource.OPEN) {
|
||
// Joining mid-flight: report the open the way a fresh connection would
|
||
// have, a moment later, so the caller's own setup finishes first.
|
||
setTimeout(() => {
|
||
if (!shared.subs.has(sub)) return;
|
||
try { handlers.open?.(new Event('open')); } catch (err) { console.error('[shell] stream handler failed:', err); }
|
||
}, 0);
|
||
}
|
||
|
||
return {
|
||
close() {
|
||
if (!shared.subs.delete(sub) || shared.subs.size) return;
|
||
clearTimeout(shared.retryTimer);
|
||
try { shared.es?.close(); } catch { /* already closed */ }
|
||
ssePool.delete(url);
|
||
},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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: 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. */
|
||
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._cleanup?.();
|
||
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();
|
||
|
||
/* These are display choices — a text size, a filter, a colour — so they
|
||
apply as they are changed and save with the layout. There is nothing to
|
||
confirm, so there is no Apply. Typing is debounced because applying
|
||
means remounting the module, which shouldn't happen per keystroke. */
|
||
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.
|
||
let changed = false;
|
||
for (const [key, read] of inputs) {
|
||
const value = read();
|
||
if (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));
|
||
};
|
||
|
||
// 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 isSwitch = type === 'boolean' || type === 'switch';
|
||
const field = document.createElement('label');
|
||
field.className = 'field' + (isSwitch ? ' check' : '');
|
||
|
||
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(current);
|
||
const track = document.createElement('span');
|
||
track.className = 'track';
|
||
toggle.append(input, track);
|
||
field.append(text, toggle);
|
||
inputs.set(key, () => input.checked);
|
||
live(input);
|
||
} 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);
|
||
live(select);
|
||
} 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);
|
||
live(input, 'input', 150); // dragging the picker fires continuously
|
||
} 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));
|
||
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);
|
||
}
|
||
container.appendChild(field);
|
||
}
|
||
|
||
// Nothing to press: click away, press Escape, or click 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(); };
|
||
pop._cleanup = () => {
|
||
document.removeEventListener('click', closeOnClick);
|
||
document.removeEventListener('keydown', closeOnEscape);
|
||
};
|
||
document.addEventListener('click', closeOnClick);
|
||
document.addEventListener('keydown', closeOnEscape);
|
||
|
||
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 ──────────────────────────────────────────────────────────── */
|
||
|
||
/* 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) {
|
||
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, 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. */
|
||
|
||
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.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);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ── 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(() => {});
|
||
}
|