Which timers appear is the header's Timers ▾ menu's job, so the gear's source switches (ProPresenter / LTC / module timers, the Clock on/off) are gone. In their place the Show group offers the card's elements — Names, Status lines, Detail lines, Seconds, Leading zeros, Source headings; without seconds a countdown reads in whole minutes, rounded up. The clock's format select is a 24-hour switch (an older layout's "12h" choice is honoured); the clock card has no CLOCK label; Short day and Short month appear once Date is on, and the date fits its card — one line when it can, else a break after the day and a size that fits the longer half. Solo tiles get the same element switches. Shell: the settings window's grab bar is the accent colour and the gear that opened it wears the same colour while it is open; showWhen works in a tile's gear as it does on the admin page. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1994 lines
75 KiB
JavaScript
1994 lines
75 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, disabled = false, title = '' } = {}) {
|
||
const item = document.createElement('button');
|
||
item.className = 'menu-item' + (danger ? ' danger' : '');
|
||
item.disabled = disabled;
|
||
if (title) item.title = title;
|
||
const span = document.createElement('span');
|
||
span.textContent = label;
|
||
if (sub) {
|
||
const s = document.createElement('span');
|
||
s.className = 'sub';
|
||
s.textContent = sub;
|
||
span.appendChild(s);
|
||
}
|
||
item.appendChild(span);
|
||
item.addEventListener('click', onClick);
|
||
return item;
|
||
}
|
||
|
||
/* ── module registry ────────────────────────────────────────────────── */
|
||
|
||
let registry = new Map(); // module id -> manifest from GET /api/modules
|
||
|
||
/** fetch that gives up after `ms`. A request the browser has queued behind
|
||
held connections would otherwise pend forever, and so would anything
|
||
awaiting it. */
|
||
function fetchWithTimeout(url, ms, opts = {}) {
|
||
const ctl = new AbortController();
|
||
const timer = setTimeout(() => ctl.abort(), ms);
|
||
return fetch(url, { ...opts, signal: ctl.signal }).finally(() => clearTimeout(timer));
|
||
}
|
||
|
||
/** Refresh the enabled-module list. On failure the last known list is kept
|
||
(the server may just be restarting mid-service). Returns true on success. */
|
||
async function loadRegistry() {
|
||
try {
|
||
// Slow modules' tiles() are cut off server-side at 2 s, so a healthy
|
||
// answer is well inside this.
|
||
const res = await fetchWithTimeout('/api/modules', 8000);
|
||
if (!res.ok) throw new Error('modules ' + res.status);
|
||
const body = await res.json();
|
||
registry = new Map((body.modules || []).map((m) => [m.id, m]));
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/* ── layout state ───────────────────────────────────────────────────── */
|
||
|
||
/** @type {{id:string,module:string,x:number,y:number,w:number,h:number,settings:object}[]} */
|
||
let tiles = [];
|
||
const tileEls = new Map(); // tile id -> { el, body, dot, titleEl, instance }
|
||
|
||
function saveLayout() {
|
||
try {
|
||
localStorage.setItem(LS_LAYOUT, JSON.stringify({ v: 1, tiles }));
|
||
} catch { /* private mode / full — layout just won't persist */ }
|
||
}
|
||
|
||
function loadLayoutFromStorage() {
|
||
try {
|
||
const raw = JSON.parse(localStorage.getItem(LS_LAYOUT) || 'null');
|
||
if (raw && Array.isArray(raw.tiles)) return raw.tiles;
|
||
} catch { /* corrupted — start fresh */ }
|
||
return [];
|
||
}
|
||
|
||
function sanitizeTile(t) {
|
||
const w = Math.max(1, Math.min(COLS, Number(t.w) || 3));
|
||
const tile = {
|
||
id: String(t.id || newId()),
|
||
module: String(t.module || ''),
|
||
x: Math.max(0, Math.min(COLS - w, Number(t.x) || 0)),
|
||
y: Math.max(0, Number(t.y) || 0),
|
||
w,
|
||
h: Math.max(1, Number(t.h) || 2),
|
||
settings: (t.settings && typeof t.settings === 'object') ? t.settings : {},
|
||
headHidden: Boolean(t.headHidden),
|
||
// set when the tile was added from a module's tile list (multi-tile modules)
|
||
variant: t.variant ? String(t.variant) : '',
|
||
title: t.title ? String(t.title) : '',
|
||
};
|
||
if (t.minSize && Number(t.minSize.w) > 0 && Number(t.minSize.h) > 0) {
|
||
tile.minSize = { w: Number(t.minSize.w), h: Number(t.minSize.h) };
|
||
}
|
||
return tile;
|
||
}
|
||
|
||
function newId() {
|
||
return 't' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
|
||
}
|
||
|
||
/* ── grid geometry & collision ──────────────────────────────────────── */
|
||
|
||
function cellMetrics() {
|
||
const style = getComputedStyle(grid);
|
||
const gapRaw = parseFloat(style.columnGap);
|
||
const gap = Number.isFinite(gapRaw) ? gapRaw : 0;
|
||
const cw = (grid.clientWidth - gap * (COLS - 1)) / COLS;
|
||
const ch = parseFloat(style.gridAutoRows) || 72;
|
||
return { cw, ch, gap };
|
||
}
|
||
|
||
function overlaps(a, b) {
|
||
return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
|
||
}
|
||
|
||
function isFreeExcept(rect, excludeIds) {
|
||
if (rect.x < 0 || rect.y < 0 || rect.x + rect.w > COLS) return false;
|
||
return !tiles.some((t) => !excludeIds.includes(t.id) && overlaps(rect, t));
|
||
}
|
||
|
||
function isFree(rect, excludeId) {
|
||
return isFreeExcept(rect, [excludeId]);
|
||
}
|
||
|
||
/** First free spot scanning top-to-bottom, left-to-right. */
|
||
function findSpot(w, h) {
|
||
for (let y = 0; y < 200; y += 1) {
|
||
for (let x = 0; x <= COLS - w; x += 1) {
|
||
if (isFree({ x, y, w, h })) return { x, y };
|
||
}
|
||
}
|
||
return { x: 0, y: 0 };
|
||
}
|
||
|
||
function applyRect(tile) {
|
||
const entry = tileEls.get(tile.id);
|
||
if (!entry) return;
|
||
entry.el.style.gridColumn = `${tile.x + 1} / span ${tile.w}`;
|
||
entry.el.style.gridRow = `${tile.y + 1} / span ${tile.h}`;
|
||
}
|
||
|
||
function minSizeOf(tile) {
|
||
const min = tile.minSize || registry.get(tile.module)?.minSize;
|
||
return {
|
||
w: Math.max(1, min?.w || 1),
|
||
h: Math.max(1, min?.h || 1),
|
||
};
|
||
}
|
||
|
||
/* ── tile DOM ───────────────────────────────────────────────────────── */
|
||
|
||
const 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 && Object.keys(tileSchema(tile, man).schema).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, gear);
|
||
});
|
||
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);
|
||
if (entry?.single && tiles.some((t) => t.module === moduleId && t.variant === String(entry.id))) {
|
||
toast(`${entry.name} is already on this dashboard`);
|
||
return;
|
||
}
|
||
const defaultSize = entry?.defaultSize || man?.defaultSize;
|
||
const size = {
|
||
w: Math.max(1, Math.min(COLS, defaultSize?.w || 4)),
|
||
h: Math.max(1, defaultSize?.h || 3),
|
||
};
|
||
const spot = findSpot(size.w, size.h);
|
||
const settings = {};
|
||
const { schema: addSchema } = tileSchema({ variant: entry ? String(entry.id) : '' }, man);
|
||
for (const [key, spec] of Object.entries(addSchema)) {
|
||
if (spec && 'default' in spec) settings[key] = spec.default;
|
||
}
|
||
Object.assign(settings, entry?.settings || {});
|
||
const tile = { id: newId(), module: moduleId, ...spot, ...size, settings };
|
||
if (entry) {
|
||
tile.variant = String(entry.id);
|
||
tile.title = String(entry.name || '');
|
||
if (entry.minSize) tile.minSize = { w: entry.minSize.w, h: entry.minSize.h };
|
||
}
|
||
tiles.push(tile);
|
||
buildTile(tile);
|
||
refreshEmptyState();
|
||
saveLayout();
|
||
rebuildDividers();
|
||
}
|
||
|
||
function removeTile(id) {
|
||
const idx = tiles.findIndex((t) => t.id === id);
|
||
if (idx < 0) return;
|
||
clearPeekTimer(id);
|
||
unmountModule(tiles[idx]);
|
||
tiles.splice(idx, 1);
|
||
const entry = tileEls.get(id);
|
||
if (entry) {
|
||
entry.el.remove();
|
||
tileEls.delete(id);
|
||
}
|
||
refreshEmptyState();
|
||
saveLayout();
|
||
rebuildDividers();
|
||
}
|
||
|
||
function clearAllTiles() {
|
||
for (const tile of tiles) {
|
||
unmountModule(tile);
|
||
tileEls.get(tile.id)?.el.remove();
|
||
}
|
||
tiles = [];
|
||
tileEls.clear();
|
||
refreshEmptyState();
|
||
}
|
||
|
||
/* ── module mounting ────────────────────────────────────────────────── */
|
||
|
||
const clientModuleCache = new Map(); // module id -> Promise<ES module>
|
||
|
||
function loadClientModule(man) {
|
||
if (!clientModuleCache.has(man.id)) {
|
||
clientModuleCache.set(man.id, import(`/modules/${man.id}/${man.client || 'client.js'}`));
|
||
}
|
||
return clientModuleCache.get(man.id);
|
||
}
|
||
|
||
/** Inject a module's stylesheet once (shared by every instance of it). */
|
||
function ensureModuleStyle(man) {
|
||
if (!man.style) return;
|
||
const id = 'module-style-' + man.id;
|
||
if (document.getElementById(id)) return;
|
||
const link = document.createElement('link');
|
||
link.id = id;
|
||
link.rel = 'stylesheet';
|
||
link.href = `/modules/${man.id}/${man.style}`;
|
||
document.head.appendChild(link);
|
||
}
|
||
|
||
function tileMessage(entry, text) {
|
||
const msg = document.createElement('div');
|
||
msg.className = 'tile-msg';
|
||
msg.textContent = text;
|
||
entry.body.appendChild(msg);
|
||
}
|
||
|
||
/* ── 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(tileSchema(tile, man).schema)) {
|
||
if (spec && 'default' in spec) out[key] = spec.default;
|
||
}
|
||
return Object.assign(out, tile.settings || {});
|
||
},
|
||
|
||
/** Persist per-tile settings into the layout (no remount — the module
|
||
already knows, it made the change). */
|
||
saveInstanceSettings(patch) {
|
||
Object.assign(tile.settings, patch || {});
|
||
saveLayout();
|
||
},
|
||
|
||
/** fetch scoped to this module's server routes. */
|
||
fetch(subPath, opts) {
|
||
return fetch('/api/modules/' + man.id + subPath, opts);
|
||
},
|
||
|
||
/**
|
||
* EventSource scoped the same way, with auto-reconnect: a stream that
|
||
* gives up (a 502 while the upstream is down) is recreated on a timer
|
||
* until it works again. Tiles asking for the same stream share one
|
||
* connection — see the pool above; a module sees no difference.
|
||
* handlers: { open(e), error(e), message(e), events: { name: fn } }
|
||
*/
|
||
sse(subPath, handlers = {}) {
|
||
const shared = sseSubscribe('/api/modules/' + man.id + subPath, handlers);
|
||
const handle = {
|
||
close() {
|
||
shared.close();
|
||
sseHandles.delete(handle);
|
||
},
|
||
};
|
||
sseHandles.add(handle);
|
||
return handle;
|
||
},
|
||
|
||
/** Drive the tile's status dot: 'ok' | 'connecting' | 'error'. */
|
||
setStatus(state, msg = '') {
|
||
const cls = state === 'ok' || state === 'connecting' || state === 'error' ? state : '';
|
||
entry.dot.className = 'dot ' + cls;
|
||
entry.dot.title = msg;
|
||
},
|
||
|
||
/**
|
||
* The tile's title bar. Controls added here live next to the gear/close
|
||
* buttons and are removed automatically when the instance stops. The
|
||
* whole bar can be hidden by the user via the notch, so nothing
|
||
* essential should exist ONLY as a header control.
|
||
*/
|
||
header: {
|
||
/** A small button. icon = inline SVG string, label = short text (either or both). */
|
||
addButton({ icon = '', label = '', title = '', onClick } = {}) {
|
||
const btn = document.createElement('button');
|
||
btn.className = 'tile-btn module-btn';
|
||
btn.title = title || label;
|
||
if (icon) btn.innerHTML = icon;
|
||
if (label) btn.appendChild(document.createTextNode(label));
|
||
if (typeof onClick === 'function') {
|
||
btn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
onClick(e);
|
||
});
|
||
}
|
||
entry.controls.appendChild(btn);
|
||
headerEls.add(btn);
|
||
return btn;
|
||
},
|
||
|
||
/**
|
||
* A dropdown menu button. build(menuEl) runs on every open and fills
|
||
* the (emptied) menu element. Returns { button, menu, setLabel, close }.
|
||
*/
|
||
addMenu({ icon = '', label = '', title = '', build } = {}) {
|
||
const wrap = document.createElement('span');
|
||
wrap.className = 'tile-dd';
|
||
const btn = document.createElement('button');
|
||
btn.className = 'tile-btn module-btn';
|
||
btn.title = title || label;
|
||
if (icon) btn.innerHTML = icon;
|
||
if (label) btn.appendChild(document.createTextNode(label));
|
||
const menu = document.createElement('div');
|
||
menu.className = 'tile-menu';
|
||
menu.hidden = true;
|
||
|
||
const open = () => {
|
||
if (typeof build === 'function') {
|
||
menu.innerHTML = '';
|
||
build(menu);
|
||
}
|
||
menu.hidden = false;
|
||
// fixed positioning (escapes the tile's overflow): under the
|
||
// button, right-aligned, clamped to the viewport
|
||
const r = btn.getBoundingClientRect();
|
||
menu.style.top = Math.min(r.bottom + 6, window.innerHeight - 80) + 'px';
|
||
menu.style.left = '0px';
|
||
const w = menu.offsetWidth;
|
||
menu.style.left = Math.max(8, Math.min(r.right - w, window.innerWidth - w - 8)) + 'px';
|
||
};
|
||
btn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
if (menu.hidden) open();
|
||
else menu.hidden = true;
|
||
});
|
||
const onDocClick = (e) => {
|
||
if (!menu.hidden && !menu.contains(e.target) && !btn.contains(e.target)) menu.hidden = true;
|
||
};
|
||
document.addEventListener('click', onDocClick);
|
||
docListeners.add(onDocClick);
|
||
|
||
wrap.append(btn, menu);
|
||
entry.controls.appendChild(wrap);
|
||
headerEls.add(wrap);
|
||
return {
|
||
button: btn,
|
||
menu,
|
||
setLabel(text) {
|
||
btn.textContent = text;
|
||
},
|
||
close() {
|
||
menu.hidden = true;
|
||
},
|
||
};
|
||
},
|
||
},
|
||
|
||
/* shell-internal: safety net so a forgotten stream, header control or
|
||
listener can't outlive the tile */
|
||
_closeAll() {
|
||
for (const handle of [...sseHandles]) handle.close();
|
||
for (const el of headerEls) el.remove();
|
||
headerEls.clear();
|
||
for (const fn of docListeners) document.removeEventListener('click', fn);
|
||
docListeners.clear();
|
||
},
|
||
};
|
||
return api;
|
||
}
|
||
|
||
async function mountModule(tile) {
|
||
const entry = tileEls.get(tile.id);
|
||
if (!entry) return;
|
||
const man = registry.get(tile.module);
|
||
if (!man) {
|
||
tileMessage(entry, `Module “${tile.module}” is not installed or is disabled. Check Admin.`);
|
||
entry.dot.className = 'dot error';
|
||
entry.dot.title = 'Module unavailable';
|
||
return;
|
||
}
|
||
entry.dot.className = 'dot connecting';
|
||
ensureModuleStyle(man);
|
||
try {
|
||
const mod = await loadClientModule(man);
|
||
if (tileEls.get(tile.id) !== entry || entry.instance) return; // tile removed/remounted while loading
|
||
const factory = mod.default;
|
||
if (typeof factory !== 'function') throw new Error('client.js has no default-export factory');
|
||
const api = buildModuleApi(tile, man, entry);
|
||
const instance = factory({ root: entry.body, moduleApi: api }) || {};
|
||
entry.instance = instance;
|
||
entry.api = api;
|
||
instance.start?.();
|
||
} catch (err) {
|
||
console.error(`[shell] module "${tile.module}" failed to start:`, err);
|
||
entry.body.innerHTML = '';
|
||
tileMessage(entry, `“${man.name}” failed to start: ${err?.message || err}`);
|
||
entry.dot.className = 'dot error';
|
||
}
|
||
}
|
||
|
||
function unmountModule(tile) {
|
||
const entry = tileEls.get(tile.id);
|
||
if (!entry) return;
|
||
try {
|
||
entry.instance?.stop?.();
|
||
} catch { /* stopping is best-effort */ }
|
||
try {
|
||
entry.api?._closeAll();
|
||
} catch { /* ditto */ }
|
||
entry.instance = null;
|
||
entry.api = null;
|
||
}
|
||
|
||
/* ── per-tile settings popover ──────────────────────────────────────── */
|
||
|
||
/** `<input type="color">` only accepts #rrggbb — anything else falls back. */
|
||
function colorHex(value, fallback) {
|
||
const s = String(value ?? '').trim();
|
||
if (/^#[0-9a-fA-F]{6}$/.test(s)) return s.toLowerCase();
|
||
if (/^#[0-9a-fA-F]{3}$/.test(s)) return ('#' + s.slice(1).split('').map((c) => c + c).join('')).toLowerCase();
|
||
return fallback;
|
||
}
|
||
|
||
/** The settings a tile offers: its picker entry's own schema when it has one
|
||
— a solo timer wants none of a full tile's switches — else the module's.
|
||
Same for the groups that arrange them. */
|
||
function tileSchema(tile, man) {
|
||
const own = (v) => (v && typeof v === 'object' ? v : null);
|
||
const variant = String(tile?.variant || '');
|
||
const entry = variant && Array.isArray(man?.tiles)
|
||
? man.tiles.find((e) => String(e.id) === variant)
|
||
|| man.tiles.find((e) => Array.isArray(e.aliases) && e.aliases.includes(variant))
|
||
: null;
|
||
return {
|
||
schema: own(entry?.instanceSchema) || own(man?.instanceSchema) || {},
|
||
groups: own(entry?.instanceGroups) || own(man?.instanceGroups) || {},
|
||
};
|
||
}
|
||
|
||
function closeSettingsPopovers() {
|
||
document.querySelectorAll('.tile-settings').forEach((p) => {
|
||
p._cleanup?.();
|
||
p._button?.classList.remove('is-open');
|
||
p.remove();
|
||
});
|
||
}
|
||
|
||
/* ── the per-tile settings window ───────────────────────────────────── */
|
||
|
||
/* It opens beside its tile — to the right when there is room, else the
|
||
left, else over it — and always inside the viewport. A window rather than
|
||
something inside the tile, because a tile can be smaller than its own
|
||
settings, and because the point of live settings is watching the tile
|
||
change while you change them: the window must not sit on top of it. */
|
||
function placePopover(pop, anchor) {
|
||
const vw = window.innerWidth;
|
||
const vh = window.innerHeight;
|
||
const pad = 8;
|
||
const w = pop.offsetWidth;
|
||
const h = pop.offsetHeight;
|
||
let left = anchor.right + pad;
|
||
if (left + w > vw - pad) left = anchor.left - w - pad;
|
||
if (left < pad) left = Math.min(Math.max(pad, anchor.left), Math.max(pad, vw - w - pad));
|
||
let top = anchor.top;
|
||
if (top + h > vh - pad) top = vh - h - pad;
|
||
if (top < pad) top = pad;
|
||
pop.style.left = Math.round(left) + 'px';
|
||
pop.style.top = Math.round(top) + 'px';
|
||
}
|
||
|
||
function clampPopover(pop) {
|
||
const pad = 8;
|
||
const maxLeft = Math.max(pad, window.innerWidth - pop.offsetWidth - pad);
|
||
const maxTop = Math.max(pad, window.innerHeight - pop.offsetHeight - pad);
|
||
pop.style.left = Math.min(Math.max(pad, pop.offsetLeft), maxLeft) + 'px';
|
||
pop.style.top = Math.min(Math.max(pad, pop.offsetTop), maxTop) + 'px';
|
||
}
|
||
|
||
/** Open (or close) the settings window for `tile`, beside its element `el`; `button` is the gear that asked. */
|
||
function toggleSettingsPopover(tile, el, button = null) {
|
||
const existing = document.querySelector(`.tile-settings[data-tile-id="${tile.id}"]`);
|
||
closeSettingsPopovers();
|
||
if (existing) return;
|
||
const man = registry.get(tile.module);
|
||
const { schema, groups: groupMeta } = tileSchema(tile, man);
|
||
if (!man || !Object.keys(schema).length) return;
|
||
|
||
const pop = document.createElement('div');
|
||
pop.className = 'tile-settings';
|
||
pop.dataset.tileId = tile.id;
|
||
pop.setAttribute('role', 'dialog');
|
||
pop.setAttribute('aria-label', `${tile.title || man.name} settings`);
|
||
// The gear that opened it wears the window's colour while it is open.
|
||
if (button) {
|
||
button.classList.add('is-open');
|
||
pop._button = button;
|
||
}
|
||
|
||
// A slim bar to move it by — nothing else. No title (the window sits beside
|
||
// the tile it belongs to) and no close button: a click anywhere else, or
|
||
// Escape, closes it.
|
||
const bar = document.createElement('div');
|
||
bar.className = 'tile-settings-bar';
|
||
bar.title = 'Drag to move';
|
||
const body = document.createElement('div');
|
||
body.className = 'tile-settings-body';
|
||
pop.append(bar, body);
|
||
|
||
const inputs = new Map();
|
||
const shownWhen = []; // fields that appear only while another has a given value
|
||
|
||
/* Display choices apply as they change and save with the layout — nothing
|
||
to confirm, so no Apply. Typing is debounced because applying means
|
||
remounting the module, which shouldn't happen per keystroke. */
|
||
const same = (a, b) => a === b
|
||
|| (Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((v, i) => v === b[i]));
|
||
let applyTimer = null;
|
||
const commit = (delay) => {
|
||
clearTimeout(applyTimer);
|
||
applyTimer = setTimeout(() => {
|
||
applyTimer = null;
|
||
// Only when something really moved: a text field fires `input` and then
|
||
// `change` on the way out, and a module shouldn't be torn down twice
|
||
// over for one edit. A reader answering undefined has nothing to say.
|
||
let changed = false;
|
||
for (const [key, read] of inputs) {
|
||
const value = read();
|
||
if (value === undefined || same(tile.settings[key], value)) continue;
|
||
tile.settings[key] = value;
|
||
changed = true;
|
||
}
|
||
if (!changed) return;
|
||
saveLayout();
|
||
remountTile(tile);
|
||
}, delay);
|
||
};
|
||
const live = (input, event = 'change', delay = 0) => {
|
||
input.addEventListener(event, () => commit(delay));
|
||
};
|
||
/** Choices a module only knows at runtime, from one of its own routes. */
|
||
const fetchOptions = async (route) => {
|
||
try {
|
||
const res = await fetchWithTimeout(`/api/modules/${encodeURIComponent(man.id)}${route}`, 8000);
|
||
const data = res.ok ? await res.json() : null;
|
||
return Array.isArray(data?.options) ? data.options : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
};
|
||
const optValue = (opt) => String(typeof opt === 'object' ? opt.value : opt);
|
||
const optLabel = (opt) => String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt);
|
||
|
||
// Fields render in schema order; a field's `group` starts a labeled
|
||
// subsection that consecutive same-group fields share, and the groups map
|
||
// adds { collapsed, columns, help, toggle } — the same conventions as the
|
||
// admin page's config form.
|
||
let groupName = null;
|
||
let container = body;
|
||
let current = null; // the open group: { el, title, toggleKey }
|
||
|
||
for (const [key, spec] of Object.entries(schema)) {
|
||
const type = spec?.type || 'string';
|
||
const label = spec?.label || key;
|
||
const value = key in (tile.settings || {}) ? tile.settings[key] : spec?.default;
|
||
|
||
const g = spec?.group || '';
|
||
if (g !== groupName) {
|
||
groupName = g;
|
||
if (g) {
|
||
const meta = groupMeta[g] && typeof groupMeta[g] === 'object' ? groupMeta[g] : {};
|
||
const folds = meta.collapsed !== undefined;
|
||
const group = document.createElement(folds ? 'details' : 'div');
|
||
group.className = 'field-group';
|
||
if (folds) group.open = !meta.collapsed;
|
||
const title = document.createElement(folds ? 'summary' : 'div');
|
||
title.className = 'field-group-title';
|
||
const titleText = document.createElement('span');
|
||
titleText.className = 'field-group-name';
|
||
titleText.textContent = g;
|
||
title.appendChild(titleText);
|
||
group.appendChild(title);
|
||
const groupBody = document.createElement('div');
|
||
groupBody.className = 'field-group-body';
|
||
const cols = Math.min(4, Math.trunc(Number(meta.columns)) || 0);
|
||
if (cols > 1) {
|
||
groupBody.classList.add('is-columns');
|
||
groupBody.style.setProperty('--cols', String(cols));
|
||
}
|
||
if (meta.help) {
|
||
const help = document.createElement('div');
|
||
help.className = 'field-group-help';
|
||
help.textContent = meta.help;
|
||
groupBody.appendChild(help);
|
||
}
|
||
group.appendChild(groupBody);
|
||
body.appendChild(group);
|
||
pop.classList.add('has-groups');
|
||
container = groupBody;
|
||
current = { el: group, title, toggleKey: typeof meta.toggle === 'string' ? meta.toggle : '' };
|
||
} else {
|
||
container = body;
|
||
current = null;
|
||
}
|
||
}
|
||
|
||
const isSwitch = type === 'boolean' || type === 'switch';
|
||
// A multiselect holds several controls, so it can't be a <label>: a click
|
||
// anywhere in one would flip the first control.
|
||
const field = document.createElement(type === 'multiselect' ? 'div' : 'label');
|
||
field.className = 'field' + (isSwitch ? ' check' : '');
|
||
let switchInput = null;
|
||
|
||
if (isSwitch) {
|
||
// A switch: on is shown/enabled, off is hidden/disabled — the label
|
||
// names what it turns on. A real checkbox stays underneath (keyboard,
|
||
// label clicks and `change` all keep working); the track is the look.
|
||
const text = document.createElement('span');
|
||
text.className = 'check-label';
|
||
text.textContent = label;
|
||
const toggle = document.createElement('span');
|
||
toggle.className = 'switch';
|
||
const input = document.createElement('input');
|
||
input.type = 'checkbox';
|
||
input.checked = Boolean(value);
|
||
input.setAttribute('aria-label', label);
|
||
const track = document.createElement('span');
|
||
track.className = 'track';
|
||
toggle.append(input, track);
|
||
field.append(text, toggle);
|
||
inputs.set(key, () => input.checked);
|
||
live(input);
|
||
switchInput = input;
|
||
} else if (type === 'select' && (Array.isArray(spec.options) || spec.optionsRoute)) {
|
||
const span = document.createElement('span');
|
||
span.textContent = label;
|
||
const select = document.createElement('select');
|
||
const saved = value === undefined || value === null ? '' : String(value);
|
||
const fill = (options) => {
|
||
const wanted = select.options.length ? select.value : saved;
|
||
select.innerHTML = '';
|
||
// What this tile saved stays choosable even when the live list has
|
||
// moved on (a source that is off right now, say).
|
||
if (saved && !options.some((o) => optValue(o) === saved)) {
|
||
options = [{ value: saved, label: `${saved} (saved)` }, ...options];
|
||
}
|
||
for (const opt of options) {
|
||
const o = document.createElement('option');
|
||
o.value = optValue(opt);
|
||
o.textContent = optLabel(opt);
|
||
select.appendChild(o);
|
||
}
|
||
select.value = options.some((o) => optValue(o) === wanted) ? wanted : saved;
|
||
};
|
||
fill(Array.isArray(spec.options) ? spec.options : []);
|
||
if (spec.optionsRoute) fetchOptions(spec.optionsRoute).then((opts) => { if (opts) fill(opts); });
|
||
field.append(span, select);
|
||
inputs.set(key, () => select.value);
|
||
live(select);
|
||
} else if (type === 'multiselect' && (Array.isArray(spec.options) || spec.optionsRoute)) {
|
||
// Several picks from a list, one switch each, stored as an array. With
|
||
// `allByDefault`, a tile that has never chosen shows every option on
|
||
// and saves nothing until someone flips one — so options that turn up
|
||
// later are on as well, until there is an explicit list.
|
||
field.classList.add('multiselect-field');
|
||
const span = document.createElement('span');
|
||
span.className = 'field-label';
|
||
span.textContent = label;
|
||
const list = document.createElement('div');
|
||
list.className = 'multiselect';
|
||
const cols = Math.min(4, Math.trunc(Number(spec.columns)) || 0);
|
||
if (cols > 1) {
|
||
list.classList.add('is-columns');
|
||
list.style.setProperty('--cols', String(cols));
|
||
}
|
||
const saved = Array.isArray(value) ? value.map(String) : null; // null: never chosen
|
||
let touched = false;
|
||
const chosen = new Set(saved || []);
|
||
const fill = (options) => {
|
||
for (const input of list.querySelectorAll('input')) {
|
||
if (input.checked) chosen.add(input.value);
|
||
else chosen.delete(input.value);
|
||
}
|
||
list.innerHTML = '';
|
||
const missing = [...chosen].filter((v) => !options.some((o) => optValue(o) === v));
|
||
const all = [...options, ...missing.map((v) => ({ value: v, label: `${v} (saved — not listed now)` }))];
|
||
for (const opt of all) {
|
||
const row = document.createElement('label');
|
||
row.className = 'multiselect-row';
|
||
const text = document.createElement('span');
|
||
text.className = 'check-label';
|
||
text.textContent = optLabel(opt);
|
||
const toggle = document.createElement('span');
|
||
toggle.className = 'switch';
|
||
const input = document.createElement('input');
|
||
input.type = 'checkbox';
|
||
input.value = optValue(opt);
|
||
input.checked = saved === null && !touched && spec.allByDefault ? true : chosen.has(input.value);
|
||
if (input.checked) chosen.add(input.value);
|
||
const track = document.createElement('span');
|
||
track.className = 'track';
|
||
toggle.append(input, track);
|
||
row.append(text, toggle);
|
||
input.addEventListener('change', () => {
|
||
touched = true;
|
||
commit(0);
|
||
});
|
||
list.appendChild(row);
|
||
}
|
||
if (!all.length) {
|
||
const none = document.createElement('div');
|
||
none.className = 'multiselect-empty';
|
||
none.textContent = 'Nothing to choose from yet.';
|
||
list.appendChild(none);
|
||
}
|
||
};
|
||
fill(Array.isArray(spec.options) ? spec.options : []);
|
||
if (spec.optionsRoute) fetchOptions(spec.optionsRoute).then((opts) => { if (opts) fill(opts); });
|
||
field.append(span, list);
|
||
inputs.set(key, () => (saved === null && !touched
|
||
? undefined
|
||
: [...list.querySelectorAll('input:checked')].map((input) => input.value)));
|
||
} else if (type === 'color') {
|
||
// A swatch picker; the value is always a #rrggbb string.
|
||
field.classList.add('color-field');
|
||
const span = document.createElement('span');
|
||
span.textContent = label;
|
||
const input = document.createElement('input');
|
||
input.type = 'color';
|
||
input.value = colorHex(value, colorHex(spec?.default, '#2ee59a'));
|
||
field.append(span, input);
|
||
inputs.set(key, () => input.value);
|
||
live(input, 'input', 150); // dragging the picker fires continuously
|
||
} else if (type === 'template') {
|
||
// Text with placeholders, built by dragging (or clicking) the variables
|
||
// the module offers into it — nobody should have to remember the names.
|
||
field.classList.add('template-field');
|
||
const span = document.createElement('span');
|
||
span.textContent = label;
|
||
const input = document.createElement('input');
|
||
input.type = 'text';
|
||
input.spellcheck = false;
|
||
input.value = value === undefined || value === null ? '' : String(value);
|
||
const vars = document.createElement('div');
|
||
vars.className = 'template-vars';
|
||
for (const v of Array.isArray(spec.variables) ? spec.variables : []) {
|
||
const chip = document.createElement('button');
|
||
chip.type = 'button';
|
||
chip.className = 'template-var';
|
||
chip.draggable = true;
|
||
const token = optValue(v);
|
||
chip.textContent = optLabel(v);
|
||
chip.title = `Drag or click to insert ${token}`;
|
||
chip.addEventListener('dragstart', (e) => {
|
||
e.dataTransfer.setData('text/plain', token);
|
||
e.dataTransfer.effectAllowed = 'copy';
|
||
});
|
||
chip.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
const start = input.selectionStart ?? input.value.length;
|
||
const end = input.selectionEnd ?? start;
|
||
input.setRangeText(token, start, end, 'end');
|
||
input.focus();
|
||
commit(0);
|
||
});
|
||
vars.appendChild(chip);
|
||
}
|
||
field.append(span, input, vars);
|
||
inputs.set(key, () => input.value);
|
||
live(input, 'input', 400);
|
||
live(input, 'change');
|
||
} else {
|
||
const span = document.createElement('span');
|
||
span.textContent = label;
|
||
const input = document.createElement('input');
|
||
input.type = type === 'number' ? 'number' : (type === 'password' ? 'password' : 'text');
|
||
input.value = value === undefined || value === null ? '' : String(value);
|
||
field.append(span, input);
|
||
inputs.set(key, () => (type === 'number' ? Number(input.value) : input.value));
|
||
live(input, 'input', 400); // mid-word is not the moment to remount
|
||
live(input, 'change'); // …but blur or Enter is
|
||
}
|
||
if (spec?.help) {
|
||
const help = document.createElement('small');
|
||
help.className = 'field-help';
|
||
help.textContent = spec.help;
|
||
field.appendChild(help);
|
||
}
|
||
|
||
if (typeof spec?.showWhen === 'string' && spec.showWhen) shownWhen.push({ field, key: spec.showWhen });
|
||
|
||
if (current && switchInput && key === current.toggleKey) {
|
||
// The group's on/off lives in its title row, never in the body, and a
|
||
// click on it must not fold a <details>.
|
||
const grp = current;
|
||
field.classList.add('group-toggle');
|
||
field.addEventListener('click', (e) => e.stopPropagation());
|
||
const sync = () => grp.el.classList.toggle('is-off', !switchInput.checked);
|
||
switchInput.addEventListener('change', sync);
|
||
sync();
|
||
grp.title.appendChild(field);
|
||
} else {
|
||
container.appendChild(field);
|
||
}
|
||
}
|
||
|
||
// "Short day" appears once "Date" is on: a field with showWhen:"K" follows
|
||
// K's switch, live — the same rule the admin page applies.
|
||
if (shownWhen.length) {
|
||
const refresh = () => {
|
||
for (const rule of shownWhen) {
|
||
const read = inputs.get(rule.key);
|
||
rule.field.hidden = read ? !read() : false;
|
||
}
|
||
};
|
||
refresh();
|
||
body.addEventListener('change', refresh);
|
||
body.addEventListener('input', refresh);
|
||
}
|
||
|
||
// Nothing to press: click away, press Escape, or the gear again.
|
||
// (The gear's own click stops propagating, so it toggles rather than
|
||
// closing and reopening.)
|
||
const closeOnClick = (e) => { if (!pop.contains(e.target)) closeSettingsPopovers(); };
|
||
const closeOnEscape = (e) => { if (e.key === 'Escape') closeSettingsPopovers(); };
|
||
const keepInView = () => clampPopover(pop);
|
||
pop._cleanup = () => {
|
||
document.removeEventListener('click', closeOnClick);
|
||
document.removeEventListener('keydown', closeOnEscape);
|
||
window.removeEventListener('resize', keepInView);
|
||
};
|
||
document.addEventListener('click', closeOnClick);
|
||
document.addEventListener('keydown', closeOnEscape);
|
||
window.addEventListener('resize', keepInView);
|
||
|
||
// Moved by its bar.
|
||
bar.addEventListener('pointerdown', (e) => {
|
||
if (e.button !== 0 && e.pointerType === 'mouse') return;
|
||
e.preventDefault();
|
||
bar.setPointerCapture(e.pointerId);
|
||
const dx = e.clientX - pop.offsetLeft;
|
||
const dy = e.clientY - pop.offsetTop;
|
||
const onMove = (ev) => {
|
||
pop.style.left = (ev.clientX - dx) + 'px';
|
||
pop.style.top = (ev.clientY - dy) + 'px';
|
||
clampPopover(pop);
|
||
};
|
||
const onUp = () => {
|
||
bar.removeEventListener('pointermove', onMove);
|
||
bar.removeEventListener('pointerup', onUp);
|
||
bar.removeEventListener('pointercancel', onUp);
|
||
};
|
||
bar.addEventListener('pointermove', onMove);
|
||
bar.addEventListener('pointerup', onUp);
|
||
bar.addEventListener('pointercancel', onUp);
|
||
});
|
||
|
||
pop.addEventListener('pointerdown', (e) => e.stopPropagation());
|
||
document.body.appendChild(pop);
|
||
placePopover(pop, el.getBoundingClientRect());
|
||
}
|
||
|
||
/** Tear a tile's module down and start it again (settings changed). */
|
||
function remountTile(tile) {
|
||
const entry = tileEls.get(tile.id);
|
||
if (!entry) return;
|
||
unmountModule(tile);
|
||
entry.body.innerHTML = '';
|
||
entry.dot.className = 'dot';
|
||
mountModule(tile);
|
||
}
|
||
|
||
/* ── layout apply / reset ───────────────────────────────────────────── */
|
||
|
||
function renderLayout(list) {
|
||
clearAllTiles();
|
||
tiles = list.map(sanitizeTile);
|
||
for (const tile of tiles) buildTile(tile);
|
||
refreshEmptyState();
|
||
rebuildDividers();
|
||
if (document.body.classList.contains('fullscreen')) applyFullscreenFit();
|
||
}
|
||
|
||
/* ── menus ──────────────────────────────────────────────────────────── */
|
||
|
||
/* What the + picker shows, as a string: repainting a menu the user is
|
||
already pointing at is only worth it when something in it changed. */
|
||
function addMenuSignature() {
|
||
return JSON.stringify([...registry.values()].map((m) => [m.id, m.name, m.description, m.tiles]));
|
||
}
|
||
|
||
wireDropdown('add-btn', 'add-menu', async (menu, stillOpen) => {
|
||
// Painted from the list already in hand, then refreshed: a module's tile
|
||
// list is live data, and Admin may have enabled or disabled something.
|
||
const shown = addMenuSignature();
|
||
renderAddMenu(menu);
|
||
if (await loadRegistry() && stillOpen() && addMenuSignature() !== shown) renderAddMenu(menu);
|
||
});
|
||
|
||
function renderAddMenu(menu) {
|
||
menu.innerHTML = '';
|
||
const manifests = [...registry.values()];
|
||
if (!manifests.length) {
|
||
const note = document.createElement('div');
|
||
note.className = 'menu-note';
|
||
note.textContent = 'No modules are enabled. Enable some in Admin.';
|
||
menu.appendChild(note);
|
||
return;
|
||
}
|
||
for (const man of manifests) {
|
||
const entries = Array.isArray(man.tiles) ? man.tiles : [];
|
||
if (entries.length) {
|
||
// A multi-tile module: its name becomes a group heading and each of
|
||
// its tiles is added individually.
|
||
const title = document.createElement('div');
|
||
title.className = 'menu-title';
|
||
title.textContent = man.name;
|
||
menu.appendChild(title);
|
||
for (const entry of entries) {
|
||
// An entry that allows one tile at most is offered greyed out once
|
||
// that tile exists — a second Send box would only confuse.
|
||
const placed = entry.single && tiles.some((t) => t.module === man.id && t.variant === String(entry.id));
|
||
// Descriptions ride along as tooltips: the list stays a list.
|
||
menu.appendChild(menuItem(entry.name, () => {
|
||
menu.hidden = true;
|
||
addTile(man.id, entry);
|
||
}, { disabled: placed, title: placed ? 'Already on this dashboard' : (entry.description || '') }));
|
||
}
|
||
} else {
|
||
menu.appendChild(menuItem(man.name, () => {
|
||
menu.hidden = true;
|
||
addTile(man.id);
|
||
}, { title: man.description || '' }));
|
||
}
|
||
}
|
||
}
|
||
|
||
wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
|
||
menu.innerHTML = '';
|
||
|
||
menu.appendChild(menuItem('Save as named layout…', async () => {
|
||
menu.hidden = true;
|
||
if (!tiles.length) {
|
||
toast('Nothing to save — this dashboard is empty', true);
|
||
return;
|
||
}
|
||
const name = await promptModal({
|
||
title: 'Save this layout to the server',
|
||
placeholder: 'e.g. FOH booth, Video world',
|
||
okLabel: 'Save',
|
||
});
|
||
if (!name) return;
|
||
try {
|
||
const res = await fetch('/api/layouts/' + encodeURIComponent(name), {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ layout: { tiles } }),
|
||
});
|
||
const body = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(body.error || 'Save failed (' + res.status + ')');
|
||
toast(`Saved “${name}” — any browser can load it now`);
|
||
} catch (e) {
|
||
toast(e.message, true);
|
||
}
|
||
}, { sub: 'Share this arrangement with other browsers' }));
|
||
|
||
const divider = document.createElement('div');
|
||
divider.className = 'menu-divider';
|
||
menu.appendChild(divider);
|
||
|
||
const reset = menuItem('Reset layout', () => {
|
||
menu.hidden = true;
|
||
renderLayout([]);
|
||
saveLayout();
|
||
toast('Layout cleared');
|
||
}, { danger: true, sub: 'Remove every tile from this browser' });
|
||
menu.appendChild(reset);
|
||
|
||
// Named layouts stored on the server slot in above Reset once they
|
||
// arrive; until then (or if they never do) the menu is already usable.
|
||
let layouts = [];
|
||
try {
|
||
const res = await fetchWithTimeout('/api/layouts', 8000);
|
||
if (res.ok) layouts = (await res.json()).layouts || [];
|
||
} catch { /* server briefly away — the menu just shows none */ }
|
||
if (!layouts.length || !stillOpen()) return;
|
||
const block = document.createDocumentFragment();
|
||
const title = document.createElement('div');
|
||
title.className = 'menu-title';
|
||
title.textContent = 'Load from server';
|
||
block.appendChild(title);
|
||
for (const l of layouts) {
|
||
block.appendChild(menuItem(l.name, async () => {
|
||
menu.hidden = true;
|
||
try {
|
||
const res = await fetch('/api/layouts/' + encodeURIComponent(l.name));
|
||
const body = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(body.error || 'Load failed (' + res.status + ')');
|
||
// Loading copies the layout into this browser's own state — it
|
||
// does not live-link browsers together.
|
||
renderLayout(body.layout.tiles || []);
|
||
saveLayout();
|
||
toast(`Loaded “${l.name}”`);
|
||
} catch (e) {
|
||
toast(e.message, true);
|
||
}
|
||
}, { sub: `${l.tiles} tile${l.tiles === 1 ? '' : 's'}` }));
|
||
}
|
||
block.appendChild(divider.cloneNode());
|
||
menu.insertBefore(block, reset);
|
||
});
|
||
|
||
/* ── menu bar show/hide (notch, like the tile title bars) ───────────── */
|
||
|
||
const topbar = document.getElementById('topbar');
|
||
const topbarNotch = document.getElementById('topbar-notch');
|
||
const LS_MENU = 'proddash:menuHidden';
|
||
|
||
/** Keep the notch glued to the menubar's bottom edge, whatever its height. */
|
||
const topbarSizer = new ResizeObserver(() => {
|
||
document.documentElement.style.setProperty('--topbar-h', topbar.offsetHeight + 'px');
|
||
});
|
||
topbarSizer.observe(topbar);
|
||
|
||
function setMenuHidden(hidden, { persist = true } = {}) {
|
||
document.body.classList.toggle('menu-hidden', hidden);
|
||
if (persist) {
|
||
try { localStorage.setItem(LS_MENU, hidden ? '1' : '0'); } catch { /* fine */ }
|
||
}
|
||
}
|
||
|
||
topbarNotch.addEventListener('click', () => {
|
||
setMenuHidden(!document.body.classList.contains('menu-hidden'));
|
||
});
|
||
|
||
try {
|
||
if (localStorage.getItem(LS_MENU) === '1') setMenuHidden(true, { persist: false });
|
||
} catch { /* fine */ }
|
||
|
||
/* ── fullscreen: peeking at a tile's title bar ──────────────────────── */
|
||
|
||
/** How long a peeked title bar stays after the pointer leaves its tile. */
|
||
const PEEK_HIDE_MS = 10000;
|
||
const peekTimers = new Map(); // tile id → timeout
|
||
|
||
function clearPeekTimer(id) {
|
||
const timer = peekTimers.get(id);
|
||
if (timer === undefined) return;
|
||
clearTimeout(timer);
|
||
peekTimers.delete(id);
|
||
}
|
||
|
||
function schedulePeekHide(id, el) {
|
||
clearPeekTimer(id);
|
||
peekTimers.set(id, setTimeout(() => {
|
||
peekTimers.delete(id);
|
||
el.classList.remove('head-peek');
|
||
}, PEEK_HIDE_MS));
|
||
}
|
||
|
||
function peekHead(tile, el, on) {
|
||
el.classList.toggle('head-peek', on);
|
||
clearPeekTimer(tile.id);
|
||
// Opened from a click the pointer is on the tile, so the countdown waits
|
||
// for it to leave — unless it already has (a keyboard or touch open).
|
||
if (on && !el.matches(':hover')) schedulePeekHide(tile.id, el);
|
||
}
|
||
|
||
/** Back to a plain screen: no peeks left open, no timers left running. */
|
||
function clearPeeks() {
|
||
for (const id of [...peekTimers.keys()]) clearPeekTimer(id);
|
||
document.querySelectorAll('.tile.head-peek').forEach((el) => el.classList.remove('head-peek'));
|
||
}
|
||
|
||
/* ── fullscreen: auto-fit the layout to the screen ──────────────────── */
|
||
|
||
/* Fullscreen is a viewing mode. The occupied columns stretch proportionally
|
||
to the full width, and every column runs to the bottom of the screen: the
|
||
bottom-most tile of each column stretches to end exactly there. A small
|
||
tile (the clock) is never stretched into a ribbon — with a tile above it,
|
||
it drops to the bottom edge and the tile above grows into the gap; alone
|
||
in its columns, it stays exactly as it is. When nothing extends below the
|
||
viewport, scrolling is disabled. All of it is visual-only — the saved
|
||
layout is untouched and everything reverts on exit. */
|
||
|
||
const fsBtn = document.getElementById('fs-btn');
|
||
/** Tiles this short (the clock) are moved, never stretched, in fullscreen. */
|
||
const FS_SMALL_ROWS = 2;
|
||
let menuHiddenBeforeFs = false;
|
||
|
||
function colsOverlap(a, b) {
|
||
return a.x < b.x + b.w && b.x < a.x + a.w;
|
||
}
|
||
|
||
function fsSmall(tile) {
|
||
return tile.h <= FS_SMALL_ROWS;
|
||
}
|
||
|
||
function fsAloneInColumns(tile) {
|
||
return !tiles.some((t) => t !== tile && colsOverlap(t, tile));
|
||
}
|
||
|
||
function fsBottomMost(tile) {
|
||
return !tiles.some((t) => t !== tile && colsOverlap(t, tile) && t.y >= tile.y + tile.h);
|
||
}
|
||
|
||
/**
|
||
* Fullscreen row placement for every tile, given R rows on screen:
|
||
* { id -> { y, h } }. Only bottom-most tiles change (stretch, or drop when
|
||
* small), plus the tile directly above a dropped small one, which grows
|
||
* into the space it left — stopping at anything else below it.
|
||
*/
|
||
function fullscreenRows(R) {
|
||
const rows = new Map(tiles.map((t) => [t.id, { y: t.y, h: t.h }]));
|
||
const dropped = [];
|
||
for (const tile of tiles) {
|
||
if (!fsBottomMost(tile)) continue;
|
||
if (!fsSmall(tile)) {
|
||
rows.get(tile.id).h = R - tile.y;
|
||
} else if (!fsAloneInColumns(tile) && R - tile.h > tile.y) {
|
||
rows.get(tile.id).y = R - tile.h;
|
||
dropped.push(tile);
|
||
}
|
||
}
|
||
for (const small of dropped) {
|
||
const gapTop = rows.get(small.id).y;
|
||
for (const above of tiles) {
|
||
if (above === small || fsSmall(above) || !colsOverlap(above, small)) continue;
|
||
if (above.y + above.h !== small.y) continue; // only a tile touching it
|
||
let limit = gapTop;
|
||
for (const other of tiles) {
|
||
if (other === above || other === small || !colsOverlap(above, other)) continue;
|
||
const oy = rows.get(other.id).y;
|
||
if (oy >= above.y + above.h) limit = Math.min(limit, oy);
|
||
}
|
||
rows.get(above.id).h = Math.max(above.h, limit - above.y);
|
||
}
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
function applyFullscreenFit() {
|
||
if (!document.body.classList.contains('fullscreen') || !tiles.length) return;
|
||
const m = cellMetrics();
|
||
const baseCellH = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--cell-h')) || 72;
|
||
const wrapH = gridwrap.clientHeight;
|
||
const maxCol = Math.max(1, ...tiles.map((t) => t.x + t.w));
|
||
const maxRow = Math.max(1, ...tiles.map((t) => t.y + t.h));
|
||
|
||
// x: the occupied columns stretch proportionally across the full width
|
||
grid.style.gridTemplateColumns = `repeat(${maxCol}, minmax(0, 1fr))`;
|
||
|
||
const layoutH = maxRow * (baseCellH + m.gap) - m.gap;
|
||
if (layoutH > wrapH) {
|
||
// deeper than the screen: keep natural rows and scrolling
|
||
grid.style.gridAutoRows = '';
|
||
gridwrap.classList.remove('no-scroll');
|
||
for (const tile of tiles) applyRect(tile);
|
||
return;
|
||
}
|
||
|
||
// nothing below the fold: lock scrolling and make R rows fill the height exactly
|
||
gridwrap.classList.add('no-scroll');
|
||
const R = Math.max(maxRow, Math.floor((wrapH + m.gap) / (baseCellH + m.gap)));
|
||
const cellH = (wrapH - (R - 1) * m.gap) / R;
|
||
grid.style.gridAutoRows = cellH + 'px';
|
||
|
||
const rows = fullscreenRows(R);
|
||
for (const tile of tiles) {
|
||
const entry = tileEls.get(tile.id);
|
||
if (!entry) continue;
|
||
const r = rows.get(tile.id);
|
||
entry.el.style.gridColumn = `${tile.x + 1} / span ${tile.w}`;
|
||
entry.el.style.gridRow = `${r.y + 1} / span ${r.h}`;
|
||
try { entry.instance?.onResize?.(tile.w, r.h); } catch { /* module's problem, not the shell's */ }
|
||
}
|
||
}
|
||
|
||
function clearFullscreenFit() {
|
||
grid.style.gridTemplateColumns = '';
|
||
grid.style.gridAutoRows = '';
|
||
gridwrap.classList.remove('no-scroll');
|
||
for (const tile of tiles) {
|
||
applyRect(tile);
|
||
notifyResize(tile);
|
||
}
|
||
}
|
||
|
||
fsBtn.addEventListener('click', () => {
|
||
if (!document.fullscreenElement) {
|
||
document.documentElement.requestFullscreen?.().catch(() => {});
|
||
} else {
|
||
document.exitFullscreen?.().catch(() => {});
|
||
}
|
||
});
|
||
|
||
document.addEventListener('fullscreenchange', () => {
|
||
const active = Boolean(document.fullscreenElement);
|
||
document.body.classList.toggle('fullscreen', active);
|
||
if (active) {
|
||
closeSettingsPopovers();
|
||
menuHiddenBeforeFs = document.body.classList.contains('menu-hidden');
|
||
setMenuHidden(true, { persist: false }); // the menu auto-hides; its notch brings it back
|
||
// let the collapsed menubar's space settle before measuring
|
||
requestAnimationFrame(() => requestAnimationFrame(applyFullscreenFit));
|
||
} else {
|
||
setMenuHidden(menuHiddenBeforeFs, { persist: false });
|
||
clearPeeks();
|
||
clearFullscreenFit();
|
||
}
|
||
});
|
||
|
||
// The grid area's size settles late: the browser animates into fullscreen,
|
||
// then the menubar collapse animates. Re-fit whenever its size changes.
|
||
let fsRefit = 0;
|
||
new ResizeObserver(() => {
|
||
if (!document.body.classList.contains('fullscreen')) return;
|
||
cancelAnimationFrame(fsRefit);
|
||
fsRefit = requestAnimationFrame(applyFullscreenFit);
|
||
}).observe(gridwrap);
|
||
|
||
/* ── live updates from the admin page ───────────────────────────────── */
|
||
|
||
/* The server broadcasts `modules-changed` on /api/events whenever admin
|
||
config or enabled flags change. Tiles of a changed module get
|
||
onConfigChange(cfg) if they implement it, otherwise a clean remount. */
|
||
|
||
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(() => {});
|
||
}
|