/* 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. */ /* ── grid density ───────────────────────────────────────────────────── How many columns the board is cut into. Twelve is the default; a finer grid lets tiles be smaller and more of them fit across (a 4K wall), a coarser one suits a small screen read close up. It is this browser's choice — two displays at one location may want different densities — so it saves with the layout, local and named, and a named layout comes back at the density it was arranged on. Row height follows the column width, so cells keep their shape: a 3×2 tile is still a 3×2 tile, only smaller. */ const GRID_STEPS = [8, 10, 12, 16, 20, 24]; const GRID_DEFAULT = 12; const CELL_H_DEFAULT = 72; // px per row at the default density let COLS = GRID_DEFAULT; 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, { action = null, ms } = {}) { toastEl.textContent = msg; if (action) { // One button, one verb — "Undo" — for a change worth taking back. const btn = document.createElement('button'); btn.type = 'button'; btn.textContent = action.label; btn.addEventListener('click', () => { clearTimeout(toastTimer); toastEl.hidden = true; action.onClick(); }); toastEl.appendChild(btn); } toastEl.classList.toggle('error', isError); toastEl.hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(() => { toastEl.hidden = true; }, ms ?? (action ? 8000 : 3200)); } const autoHideBtn = document.getElementById('autohide-btn'); 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 }); }); } /** A yes-or-no question in the same window: resolves true for the OK button. */ function confirmModal({ title, body = '', okLabel = 'OK' }) { return new Promise((resolve) => { modalEl.innerHTML = ''; const h = document.createElement('h2'); h.textContent = title; const copy = document.createElement('p'); copy.className = 'modal-copy'; copy.textContent = body; 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, copy, actions); modalBackdrop.hidden = false; ok.focus(); const done = (result) => { modalBackdrop.hidden = true; modalEl.innerHTML = ''; resolve(result); }; cancel.addEventListener('click', () => done(false)); ok.addEventListener('click', () => done(true)); modalEl.addEventListener('keydown', (e) => { if (e.key === 'Escape') done(false); }, { once: true }); modalBackdrop.addEventListener('click', (e) => { if (e.target === modalBackdrop) done(false); }, { 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 /* The installed modules that are not running — disabled, or needing a newer shell — id -> { id, name, reason }, from the same GET. Read for one thing: to say why a tile one of them left in the layout is not drawn. */ let inactiveModules = new Map(); /** 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])); inactiveModules = new Map((body.inactive || []).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 } /* Whether tile title bars fold away until you point at them. It is part of how a dashboard is arranged rather than a preference of this browser, so it saves with the layout — including a named layout on the server — and a fresh dashboard starts with the bars showing. Fullscreen folds every bar away regardless; this is the windowed equivalent, asked for by hand. */ let autoHideHeads = false; /* Which of the six alert channels this dashboard listens to. Which screen flashes for what is part of how a booth is arranged — the stage screen takes the stage's alerts, the video world its own — so like auto-hide it saves with the layout, local and named. Every dashboard hears every alert over the shell stream; this decides which ones it shows (see the alerts section). */ let alertChannels = []; /** True while an alert this dashboard listens to is on screen: the menu bar is held open for it. */ let alertShowing = false; /* Beside the layout itself: the copy it replaced (restored if the layout ever fails to parse) and, after loading a named layout, the arrangement that was on screen before (what Undo goes back to). */ const LS_LAYOUT_BACKUP = LS_LAYOUT + '.backup'; const LS_LAYOUT_PREVIOUS = LS_LAYOUT + '.previous'; function saveLayout() { try { const next = JSON.stringify({ v: 1, autoHideHeads, gridCols: COLS, alertChannels, tiles }); const current = localStorage.getItem(LS_LAYOUT); if (current && current !== next) localStorage.setItem(LS_LAYOUT_BACKUP, current); localStorage.setItem(LS_LAYOUT, next); } catch { /* private mode / full — layout just won't persist */ } } function loadLayoutFromStorage() { for (const key of [LS_LAYOUT, LS_LAYOUT_BACKUP]) { try { const raw = JSON.parse(localStorage.getItem(key) || 'null'); if (raw && Array.isArray(raw.tiles)) { // A layout that would not parse is not a reason for an empty wall. if (key === LS_LAYOUT_BACKUP) { toast('This browser’s layout was unreadable — the last good copy is back', true); try { localStorage.setItem(LS_LAYOUT, JSON.stringify(raw)); } catch { /* fine */ } } return raw; } } catch { /* unreadable — try the copy behind it */ } } return { tiles: [] }; } 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 : {}, headPinned: Boolean(t.headPinned), // 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 normalizeGridCols(value) { const n = Number(value); return GRID_STEPS.includes(n) ? n : GRID_DEFAULT; } function cellHeightFor(cols) { return Math.round((CELL_H_DEFAULT * GRID_DEFAULT) / cols); } /** Cut the board into `cols` columns. Tiles keep their grid units — the board gets denser or coarser and everything on it changes size with it. A tile now past the right edge is pulled back in, and one that would then sit on another moves to the first free spot instead. */ function setGridCols(value, { save = true } = {}) { const next = normalizeGridCols(value); const changed = next !== COLS; COLS = next; grid.style.gridTemplateColumns = `repeat(${COLS}, minmax(0, 1fr))`; document.documentElement.style.setProperty('--cell-h', cellHeightFor(COLS) + 'px'); if (changed) { // A coarser board may not have room for every tile where it was. Settle // them in reading order: pull a tile past the right edge back in, and // give one that would then land on a tile already settled the first free // spot instead — tiles never overlap here, any more than under a drag. const settled = []; for (const tile of tiles.slice().sort((a, b) => a.y - b.y || a.x - b.x)) { tile.w = Math.max(1, Math.min(COLS, tile.w)); tile.x = Math.max(0, Math.min(COLS - tile.w, tile.x)); if (settled.some((s) => overlaps(tile, s))) Object.assign(tile, spotAmong(tile.w, tile.h, settled)); settled.push(tile); applyRect(tile); notifyResize(tile); } rebuildDividers(); if (document.body.classList.contains('fullscreen')) applyFullscreenFit(); } if (save) saveLayout(); } const STEP_MINUS_SVG = ''; const STEP_PLUS_SVG = ''; /** The Layouts menu's density row — Grid Size − 12 + — which steps without closing the menu, so the board can be watched changing behind it. */ function gridStepper() { const row = document.createElement('div'); row.className = 'menu-stepper'; const label = document.createElement('span'); label.className = 'menu-stepper-label'; label.textContent = 'Grid Size'; const minus = document.createElement('button'); minus.type = 'button'; minus.className = 'menu-stepper-btn'; minus.setAttribute('data-tip', 'Coarser'); minus.innerHTML = STEP_MINUS_SVG; const value = document.createElement('span'); value.className = 'menu-stepper-value'; const plus = document.createElement('button'); plus.type = 'button'; plus.className = 'menu-stepper-btn'; plus.setAttribute('data-tip', 'Finer'); plus.innerHTML = STEP_PLUS_SVG; const paint = () => { const i = GRID_STEPS.indexOf(COLS); value.textContent = String(COLS); minus.disabled = i <= 0; plus.disabled = i >= GRID_STEPS.length - 1; }; const step = (dir) => { const i = GRID_STEPS.indexOf(COLS) + dir; if (i < 0 || i >= GRID_STEPS.length) return; setGridCols(GRID_STEPS[i]); paint(); }; minus.addEventListener('click', () => step(-1)); plus.addEventListener('click', () => step(1)); paint(); row.append(label, minus, value, plus); return row; } 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 spot, scanning top-to-bottom, left-to-right, clear of these tiles. */ function spotAmong(w, h, others) { for (let y = 0; y < 400; y += 1) { for (let x = 0; x <= COLS - w; x += 1) { const rect = { x, y, w, h }; if (!others.some((o) => overlaps(rect, o))) return { x, y }; } } return { x: 0, y: 0 }; } /** 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), }; } /* ── title bars that fold away ──────────────────────────────────────── */ /* A bar is "off" when it is folded out of sight: the dashboard has auto-hide on and this tile is not pinned, or the screen is in fullscreen, which folds every bar for watching whatever the tiles say. Off is never gone — the top edge of the tile brings it back for as long as the pointer is there. */ /** How close to the tile's top edge the pointer opens a folded-away bar. */ const HEAD_REVEAL_PX = 22; /** How far down it may wander before that bar folds away again (bar + a little). */ const HEAD_HOLD_PX = 38; function headIsOff(tile) { if (document.body.classList.contains('fullscreen')) return true; return autoHideHeads && !tile.headPinned; } function applyHeadState(tile, el = tileEls.get(tile.id)?.el) { if (!el) return; const off = headIsOff(tile); el.classList.toggle('head-off', off); if (!off) el.classList.remove('head-peek'); } /** Re-read every tile's bar — after the toggle, or after fullscreen changes. */ function applyAllHeadStates() { for (const tile of tiles) applyHeadState(tile); } function setAutoHideHeads(on, { save = true } = {}) { autoHideHeads = Boolean(on); document.body.classList.toggle('autohide-heads', autoHideHeads); autoHideBtn?.classList.toggle('is-on', autoHideHeads); autoHideBtn?.setAttribute('aria-pressed', autoHideHeads ? 'true' : 'false'); applyAllHeadStates(); if (save) saveLayout(); } /* ── tile DOM ───────────────────────────────────────────────────────── */ const CLOSE_SVG = ''; const PIN_SVG = ''; const GEAR_SVG = ''; /* ── what a saved tile can be shown as ───────────────────────────────── A layout keeps every tile it was given; the board shows the ones whose module is installed and enabled and — for a tile added from a picker entry — whose entry (or an alias of it) the module offers right now. The rest keep their place and draw nothing, and appear the moment the registry offers them again: a Service timer waits for PCO Plan, a transcript for its channel. Never the module's default in their place — a wall of "All timers" where five different timers were is worse than five gaps that fill themselves in. For the one that is not coming back (a module stopped offering that kind of tile) the Layouts menu lists them under Not shown, each with a ✕ — the only way to let its cells go. */ function tileIsOffered(tile) { const man = registry.get(tile.module); if (!man) return false; const variant = String(tile.variant || ''); if (!variant) return true; // the plain module tile — the module says what that means const entries = Array.isArray(man.tiles) ? man.tiles : []; // No list at all is a module whose tiles() did not answer in time (the // server falls back to the classic single entry): mount, and let it sort // its own variant out rather than blank the board over a slow answer. if (!entries.length) return true; return entries.some((e) => String(e.id) === variant || (Array.isArray(e.aliases) && e.aliases.includes(variant))); } /** Show tiles that have become offered; fold away tiles that have stopped being. */ function reconcileOfferedTiles() { let changed = false; for (const tile of tiles) { const shown = tileEls.has(tile.id); const offered = tileIsOffered(tile); if (offered && !shown) { buildTile(tile); changed = true; } else if (!offered && shown) { unmountModule(tile); tileEls.get(tile.id)?.el.remove(); tileEls.delete(tile.id); changed = true; } } if (changed) rebuildDividers(); scheduleOfferedRecheck(); refreshEmptyState(); // the registry may have gone from nothing to something repaintUnshown(); // a Not shown row goes the moment its tile is drawn again } /* A module's entry list can change without anyone saving admin config: PCO Plan comes back and, a few seconds later, the timers module is offering its timers again. No event marks that moment, so while any tile is folded away the registry is re-read every few seconds until nothing is. It costs one GET while something is hidden and nothing at all otherwise. */ const OFFERED_RECHECK_MS = 6000; let offeredRecheck = 0; function scheduleOfferedRecheck() { clearTimeout(offeredRecheck); if (!tiles.some((t) => !tileEls.has(t.id))) return; offeredRecheck = setTimeout(async () => { offeredRecheck = 0; if (await loadRegistry()) reconcileOfferedTiles(); else scheduleOfferedRecheck(); }, OFFERED_RECHECK_MS); } /** The tiles a layout holds that the board is not showing, each with what to call it and, in a few words, why it is not drawn. */ function unshownTiles() { return tiles.filter((t) => !tileEls.has(t.id)).map((tile) => { const man = registry.get(tile.module); if (man) { const title = tile.title || (tile.variant ? `${man.name} · ${tile.variant}` : man.name); return { tile, title, reason: 'not offered right now' }; } const off = inactiveModules.get(tile.module); if (off) { const reason = off.reason === 'incompatible' ? 'module needs a newer ProdDash' : 'module disabled'; return { tile, title: tile.title || off.name || tile.module, reason }; } return { tile, title: tile.title || tile.module, reason: 'module not installed' }; }); } /** The tiles a layout holds that the board is not showing, by name. */ function unshownTileNames() { return unshownTiles().map((u) => u.title); } /* Repaints the Layouts menu's Not shown section while the menu is open — set by unshownSection(), called whenever tiles become offered or stop being, so the list never lags the board behind it. */ let repaintUnshown = () => {}; /** The Layouts menu's Not shown section: one row per tile the layout holds that the board is not drawing — its name, why in a word or two, and a ✕ that removes it — present only while there is one. A tile that waits for its module (a Service timer before PCO Plan is up) is listed but can stay; this exists for the one that will never be back, which would otherwise hold its cells with no control anywhere to let them go. */ function unshownSection() { const wrap = document.createElement('div'); wrap.className = 'menu-unshown'; const signature = (list) => list.map((u) => `${u.tile.id}|${u.reason}`).join('\n'); let painted = ''; const paint = () => { wrap.innerHTML = ''; const list = unshownTiles(); painted = signature(list); wrap.hidden = !list.length; if (!list.length) return; const divider = document.createElement('div'); divider.className = 'menu-divider'; const title = document.createElement('div'); title.className = 'menu-title'; title.textContent = 'Not shown'; wrap.append(divider, title); for (const { tile, title: name, reason } of list) { const row = document.createElement('div'); row.className = 'menu-item menu-unshown-row'; const label = document.createElement('span'); label.textContent = name; const sub = document.createElement('span'); sub.className = 'sub'; sub.textContent = reason; label.appendChild(sub); const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'menu-x'; remove.setAttribute('data-tip', 'Remove'); remove.setAttribute('aria-label', `Remove ${name}`); remove.innerHTML = CLOSE_SVG; remove.addEventListener('click', (e) => { // The row goes with the tile, so the click must not reach the // document as one from outside the menu — the menu stays open // to show the next row (or none), as the stepper and Alerts do. e.stopPropagation(); removeTile(tile.id); // saves the layout and frees the cells, as a drawn tile's ✕ does paint(); }); row.append(label, remove); wrap.appendChild(row); } }; paint(); // Only when something changed: the registry is re-read every few seconds // while a tile is folded away, and rows rebuilt under the pointer for // nothing would lose the tooltip (and the click) the operator was on. repaintUnshown = () => { if (wrap.isConnected && signature(unshownTiles()) !== painted) paint(); }; return wrap; } function buildTile(tile) { const man = registry.get(tile.module); const el = document.createElement('section'); el.className = 'tile'; el.dataset.tileId = tile.id; if (man) el.dataset.module = man.id; applyTint(tile, el); const head = document.createElement('div'); head.className = 'tile-head'; const dot = document.createElement('span'); dot.className = 'dot'; dot.title = ''; const title = document.createElement('span'); title.className = 'tile-title'; title.textContent = man ? (tile.title || man.name) : tile.module + ' (not installed)'; // where moduleApi.header puts a module's own buttons and menus const controls = document.createElement('span'); controls.className = 'tile-controls'; /* Pinned tiles keep their bar through auto-hide. The switch sits right beside the label rather than out with the actions, because it is about this bar rather than about the tile's contents. */ const pin = document.createElement('button'); pin.className = 'tile-btn pin'; pin.innerHTML = PIN_SVG; const paintPin = () => { const on = Boolean(tile.headPinned); pin.classList.toggle('is-pinned', on); pin.setAttribute('aria-pressed', on ? 'true' : 'false'); // Instant tooltips read `title` the first time and `data-tip` after. pin.title = on ? 'Unpin bar' : 'Pin bar'; pin.setAttribute('data-tip', pin.title); }; paintPin(); pin.addEventListener('click', (e) => { e.stopPropagation(); tile.headPinned = !tile.headPinned; paintPin(); applyHeadState(tile, el); saveLayout(); }); // Keeps the label hugging the pin however wide the tile gets. const gap = document.createElement('span'); gap.className = 'tile-head-gap'; head.append(dot, title, pin, gap, controls); // Every tile has something to set: its own settings when the module // offers any, and the tint either way. if (man) { const gear = document.createElement('button'); gear.className = 'tile-btn'; gear.title = 'Settings'; gear.innerHTML = GEAR_SVG; gear.addEventListener('click', (e) => { e.stopPropagation(); toggleSettingsPopover(tile, el, gear); }); head.appendChild(gear); } const close = document.createElement('button'); close.className = 'tile-btn close'; close.title = 'Remove'; close.innerHTML = CLOSE_SVG; // a drawn cross, the same size in every browser (the ✕ glyph varies) close.addEventListener('click', (e) => { e.stopPropagation(); removeTile(tile.id); }); head.appendChild(close); const body = document.createElement('div'); body.className = 'tile-body'; /* The rim: both bottom corners resize in two directions, the bottom edge moves that edge (trading rows with tiles below), and the other three move only themselves — the top edge changes where the tile starts, the sides how wide it is — each stopping where a neighbour or the tile's own minimum says it must. None of them draw anything: the cursor over an edge is what says it can be dragged, as on a window. */ const grip = (cls) => { const gripEl = document.createElement('div'); gripEl.className = cls; return gripEl; }; const resize = grip('tile-resize'); const resizeLeft = grip('tile-resize-left'); const resizeBottom = grip('tile-resize-bottom'); const edgeTop = grip('tile-resize-top'); const edgeRight = grip('tile-resize-right'); const edgeLeft = grip('tile-resize-side'); // A folded-away bar is still reachable: point at the tile's top edge and it // slides in over the content, where its buttons click and its whole width // drags the tile exactly as when it is showing. el.addEventListener('pointermove', (ev) => { if (!headIsOff(tile)) return; // Mid-drag the tile moves under the pointer, and mid-resize the edges do // — neither is a moment to take the bar away. Nor is an open header // menu, which hangs below the bar and is about to be pointed at. if (el.classList.contains('dragging') || el.classList.contains('resizing')) return; if (el.classList.contains('menu-open')) return; const y = ev.clientY - el.getBoundingClientRect().top; const shown = el.classList.contains('head-peek'); // It takes less to hold the bar open than to open it, so a bar that // appears under the pointer cannot flicker straight back out. if (!shown && y <= HEAD_REVEAL_PX) el.classList.add('head-peek'); else if (shown && y > HEAD_HOLD_PX) el.classList.remove('head-peek'); }); el.addEventListener('pointerleave', () => { if (el.classList.contains('dragging') || el.classList.contains('resizing')) return; if (el.classList.contains('menu-open')) return; // the pointer is on its way to the menu el.classList.remove('head-peek'); }); el.append(head, body, resize, resizeLeft, resizeBottom, edgeTop, edgeRight, edgeLeft); applyHeadState(tile, el); grid.appendChild(el); const entry = { el, body, dot, titleEl: title, controls, instance: null, openMenus: new Set() }; 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); wireEdgeResize(tile, el, edgeTop, 'n'); wireEdgeResize(tile, el, edgeRight, 'e'); wireEdgeResize(tile, el, edgeLeft, 'w'); mountModule(tile); return entry; } function refreshEmptyState() { emptyEl.hidden = tiles.length > 0; if (emptyEl.hidden) return; // Two different empties: a bare board, and a ProdDash with nothing to put // on one — a packaged install starts without modules until Admin adds some. const noModules = registry.size === 0; if (noModules === emptyEl.classList.contains('no-modules')) return; emptyEl.classList.toggle('no-modules', noModules); emptyEl.innerHTML = noModules ? 'No modules yet — open Admin and install some under Available modules.' : 'No tiles yet — use “+ Add tile” to build this dashboard.
Every browser keeps its own layout.'; } /* ── drag to move ───────────────────────────────────────────────────── */ function wireDrag(tile, el, handle) { handle.addEventListener('pointerdown', (ev) => { if (ev.target.closest('.tile-btn') || ev.target.closest('.tile-menu')) 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 top, left or right edge ───────────────────────────────── */ /* One edge moves and the opposite one stays where it is, so the tile grows or shrinks into the space beside it. Unlike the bottom-edge pill these do not trade rows or columns with a neighbour: the edge simply stops when it meets one, which is what "resize this tile" should mean. */ function wireEdgeResize(tile, el, handle, edge) { 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 sx = ev.clientX; const sy = ev.clientY; const startX = tile.x; const startY = tile.y; 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 (edge === 'n') { // the top edge moves; the bottom edge stays put const y = Math.max(0, Math.min(startY + startH - min.h, startY + dRows)); cand = { x: tile.x, y, w: tile.w, h: startY + startH - y }; } else if (edge === 'w') { // the left edge moves; the right edge stays put const x = Math.max(0, Math.min(startX + startW - min.w, startX + dCols)); cand = { x, y: tile.y, w: startX + startW - x, h: tile.h }; } else { cand = { x: tile.x, y: tile.y, w: Math.max(min.w, Math.min(COLS - tile.x, startW + dCols)), h: tile.h, }; } if (cand.x === tile.x && cand.y === tile.y && cand.w === tile.w && cand.h === tile.h) return; if (!isFree(cand, tile.id)) return; tile.x = cand.x; tile.y = cand.y; 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; if (!tileEls.has(L.id) || !tileEls.has(R.id)) 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'; 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); 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'); 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; } // Sizes are declared for the default twelve-column grid; scaled so a new // tile lands at about the size its author meant, whatever the density here. const scale = COLS / GRID_DEFAULT; const defaultSize = entry?.defaultSize || man?.defaultSize; const floor = minSizeOf({ module: moduleId, minSize: entry?.minSize }); const size = { w: Math.max(floor.w, 1, Math.min(COLS, Math.round((defaultSize?.w || 4) * scale))), h: Math.max(floor.h, 1, Math.round((defaultSize?.h || 3) * scale)), }; 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, headPinned: false }; if (entry) { tile.variant = String(entry.id); tile.title = String(entry.name || ''); if (entry.minSize) tile.minSize = { w: entry.minSize.w, h: entry.minSize.h }; } tiles.push(tile); buildTile(tile); refreshEmptyState(); saveLayout(); rebuildDividers(); } function removeTile(id) { const idx = tiles.findIndex((t) => t.id === id); if (idx < 0) return; unmountModule(tiles[idx]); tiles.splice(idx, 1); const entry = tileEls.get(id); if (entry) { entry.el.remove(); tileEls.delete(id); } refreshEmptyState(); saveLayout(); rebuildDividers(); } function clearAllTiles() { for (const tile of tiles) { unmountModule(tile); tileEls.get(tile.id)?.el.remove(); } tiles = []; tileEls.clear(); refreshEmptyState(); } /* ── module mounting ────────────────────────────────────────────────── */ const clientModuleCache = new Map(); // module id -> Promise function loadClientModule(man) { if (!clientModuleCache.has(man.id)) { clientModuleCache.set(man.id, import(`/modules/${man.id}/${man.client || 'client.js'}`)); } return clientModuleCache.get(man.id); } /** Inject a module's stylesheet once (shared by every instance of it). */ function ensureModuleStyle(man) { if (!man.style) return; const id = 'module-style-' + man.id; if (document.getElementById(id)) return; const link = document.createElement('link'); link.id = id; link.rel = 'stylesheet'; link.href = `/modules/${man.id}/${man.style}`; document.head.appendChild(link); } function tileMessage(entry, text) { const msg = document.createElement('div'); msg.className = 'tile-msg'; msg.textContent = text; entry.body.appendChild(msg); } /* ── one stream per page ─────────────────────────────────────────────── */ /* Browsers allow about six HTTP/1.1 connections to one host, and an open EventSource holds one of them for as long as it lives. Sharing one stream per URL was not enough: a dashboard with six modules still ran out (Safari first), and from then on every further request — a remounted tile's /state, an avatar, the + picker's list — sat in the browser's queue waiting for a connection that was never handed back. So the page holds ONE EventSource, GET /api/stream?s=, and the server relays every stream named in it (a module's, or `shell:` for the shell's own) with the key prefixed to each event name. Subscribers still see the events they asked for by their own names. Adding or dropping a stream remakes the connection with the new list, a moment later so several tiles mounting together cause one reconnect; every stream announces `open` again after it, so backfills run as before. */ const muxSubs = new Map(); // key -> { subs: Set<{ handlers }> } let muxEs = null; // the page's one EventSource let muxKeys = ''; // the key list it was opened with let muxTimer = null; // the pending (re)connect let muxRetry = null; // retry after the connection gave up const muxListened = new Set(); // "key|name" pairs with a listener on muxEs /** The stream key for a URL: `/api/modules/` → `:`, `/api/events` → `shell:`. */ function muxKeyFor(url) { if (url === '/api/events') return 'shell:'; const m = /^\/api\/modules\/([^/?#]+)(\/[^?#]*)?$/.exec(url); return m ? `${decodeURIComponent(m[1])}:${m[2] || '/'}` : null; } function muxFanOut(key, kind, e) { for (const sub of [...(muxSubs.get(key)?.subs || [])]) { const fn = kind === 'event' ? sub.handlers.events?.[e.type] : sub.handlers[kind]; try { fn?.(e); } catch (err) { console.error('[shell] stream handler failed:', err); } } } /** Listen for `|` on the open connection and hand it to that key's subscribers as `name`. */ function muxListen(key, name) { const full = `${key}|${name}`; if (!muxEs || muxListened.has(full)) return; muxListened.add(full); muxEs.addEventListener(full, (e) => { if (name === '__open') return muxFanOut(key, 'open', new Event('open')); if (name === '__down') return muxFanOut(key, 'error', new Event('error')); const ev = { type: name, data: e.data, lastEventId: e.lastEventId }; muxFanOut(key, name === 'message' ? 'message' : 'event', ev); }); } function muxConnect() { clearTimeout(muxTimer); muxTimer = null; clearTimeout(muxRetry); muxRetry = null; const keys = [...muxSubs.keys()].sort(); const wanted = keys.join(','); if (!keys.length) { try { muxEs?.close(); } catch { /* not open */ } muxEs = null; muxKeys = ''; return; } if (muxEs && muxKeys === wanted && muxEs.readyState !== EventSource.CLOSED) return; try { muxEs?.close(); } catch { /* not open */ } muxListened.clear(); muxKeys = wanted; const es = new EventSource(`/api/stream?s=${encodeURIComponent(wanted)}`); muxEs = es; // Each stream says `open` for itself (the server's __open), so nothing to do on the connection's own open. es.onerror = () => { if (es !== muxEs) return; for (const key of muxSubs.keys()) muxFanOut(key, 'error', new Event('error')); // EventSource retries transient drops itself but gives up for good on a // completed non-SSE response (a 502 while the server restarts). if (es.readyState === EventSource.CLOSED && muxSubs.size) { clearTimeout(muxRetry); muxRetry = setTimeout(muxConnect, 3000); } }; for (const [key, entry] of muxSubs) { muxListen(key, '__open'); muxListen(key, '__down'); muxListen(key, 'message'); for (const sub of entry.subs) for (const name of Object.keys(sub.handlers.events || {})) muxListen(key, name); } } function sseSubscribe(url, handlers = {}) { const key = muxKeyFor(url); if (!key) { console.warn('[shell] not a stream this page can carry:', url); return { close() {} }; } let entry = muxSubs.get(key); if (!entry) { entry = { subs: new Set() }; muxSubs.set(key, entry); } const sub = { handlers }; entry.subs.add(sub); const carried = Boolean(muxEs) && muxKeys.split(',').includes(key) && muxEs.readyState !== EventSource.CLOSED; if (carried) { // Joining a stream the connection already carries: listeners for any // new event names, and `open` a moment later so the caller's own setup // finishes first — exactly as a fresh connection would have reported it. for (const name of Object.keys(handlers.events || {})) muxListen(key, name); if (muxEs.readyState === EventSource.OPEN) { setTimeout(() => { if (!entry.subs.has(sub)) return; try { handlers.open?.(new Event('open')); } catch (err) { console.error('[shell] stream handler failed:', err); } }, 0); } } else { clearTimeout(muxTimer); muxTimer = setTimeout(muxConnect, 20); } return { close() { if (!entry.subs.delete(sub) || entry.subs.size) return; muxSubs.delete(key); // A remount closes and reopens within milliseconds: wait before remaking the connection. clearTimeout(muxTimer); muxTimer = setTimeout(muxConnect, 500); }, }; } /** * The API handed to each module instance — the whole surface a module may * touch outside its root element. See docs/MODULE-GUIDE.md. */ function buildModuleApi(tile, man, entry) { const sseHandles = new Set(); const headerEls = new Set(); // controls this instance added to the title bar const docListeners = new Set(); // outside-click closers for header menus const api = { id: man.id, instanceId: tile.id, /** For multi-tile modules: which entry of the module's tile list this tile was added as ('' when added as the plain module). */ variant: tile.variant || '', /** Admin (server-wide) config, read-only. Password fields never reach the client. Live: reflects the latest registry after admin saves. */ get config() { return { ...((registry.get(man.id) || man).config || {}) }; }, /** Per-tile settings: schema defaults overlaid with what this tile saved. */ get instanceSettings() { const out = {}; for (const [key, spec] of Object.entries(tileSchema(tile, man).schema)) { if (spec && 'default' in spec) out[key] = spec.default; } return Object.assign(out, tile.settings || {}); }, /** Persist per-tile settings into the layout (no remount — the module already knows, it made the change). */ saveInstanceSettings(patch) { Object.assign(tile.settings, patch || {}); saveLayout(); }, /** fetch scoped to this module's server routes. */ fetch(subPath, opts) { return fetch('/api/modules/' + man.id + subPath, opts); }, /** * EventSource scoped the same way, with auto-reconnect: a stream that * gives up (a 502 while the upstream is down) is recreated on a timer * until it works again. Tiles asking for the same stream share one * connection — see the pool above; a module sees no difference. * handlers: { open(e), error(e), message(e), events: { name: fn } } */ sse(subPath, handlers = {}) { const shared = sseSubscribe('/api/modules/' + man.id + subPath, handlers); const handle = { close() { shared.close(); sseHandles.delete(handle); }, }; sseHandles.add(handle); return handle; }, /** Drive the tile's status dot: 'ok' | 'connecting' | 'error'. */ setStatus(state, msg = '') { const cls = state === 'ok' || state === 'connecting' || state === 'error' ? state : ''; entry.dot.className = 'dot ' + cls; entry.dot.title = msg; }, /** * Flash every dashboard listening to an alert channel — this one too, * if it listens. { channel: 1–6, level: 'low' | 'medium' | 'critical', * text (80 chars at most), duration: seconds, 0 to hold it until * cleared, 15 when omitted }. Goes through the server, which is the * hub; resolves with the alert as raised, rejects with the reason it * was refused. A module with a server entry raises from there instead * (shell.alert) — see docs/MODULE-GUIDE.md, "Raising alerts". */ async alert(opts = {}) { const res = await fetch('/api/alerts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...opts, source: man.id }), }); const body = await res.json().catch(() => ({})); if (!res.ok) throw new Error(body.error || `Alert refused (${res.status})`); return body.alert; }, /** End the alert on a channel early. One alert per channel, so this ends whatever is on it. */ async clearAlert(channel) { const res = await fetch('/api/alerts/' + encodeURIComponent(String(Number(channel))), { method: 'DELETE' }); const body = await res.json().catch(() => ({})); if (!res.ok) throw new Error(body.error || `Clear refused (${res.status})`); return Boolean(body.cleared); }, /** * 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 folds away when the dashboard auto-hides it, 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; /* The menu lives on the body, not in the bar. A folded-away bar (auto-hide, fullscreen) carries a backdrop-filter and a transform, and either one makes a fixed-positioned descendant position itself against the bar instead of the viewport — the menu landed a screen-height below its button, inside the tile's overflow, and never showed. Its host carries the tile's data-module so the module's scoped CSS still reaches the items. */ const host = document.createElement('div'); host.className = 'tile-menu-host'; if (man) host.dataset.module = man.id; host.appendChild(menu); const setOpen = (on) => { if (on === !menu.hidden) return; menu.hidden = !on; btn.classList.toggle('is-open', on); if (on) entry.openMenus.add(menu); else entry.openMenus.delete(menu); // an open menu holds a folded-away bar out; when the last one // closes with the pointer elsewhere, the bar goes back entry.el.classList.toggle('menu-open', entry.openMenus.size > 0); if (!on && !entry.openMenus.size && !entry.el.matches(':hover')) entry.el.classList.remove('head-peek'); }; const open = () => { if (typeof build === 'function') { menu.innerHTML = ''; build(menu); } setOpen(true); // under the button, right-aligned, kept whole inside the viewport const r = btn.getBoundingClientRect(); menu.style.left = '0px'; menu.style.top = '0px'; const w = menu.offsetWidth; const h = menu.offsetHeight; menu.style.top = Math.max(8, Math.min(r.bottom + 6, window.innerHeight - h - 8)) + 'px'; 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 setOpen(false); }); const onDocClick = (e) => { if (!menu.hidden && !menu.contains(e.target) && !btn.contains(e.target)) setOpen(false); }; document.addEventListener('click', onDocClick); docListeners.add(onDocClick); wrap.append(btn); entry.controls.appendChild(wrap); document.body.appendChild(host); headerEls.add(wrap); headerEls.add(host); return { button: btn, menu, setLabel(text) { btn.textContent = text; }, close() { setOpen(false); }, }; }, }, /* 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(); entry.openMenus.clear(); entry.el.classList.remove('menu-open'); for (const fn of docListeners) document.removeEventListener('click', fn); docListeners.clear(); }, }; return api; } async function mountModule(tile) { const entry = tileEls.get(tile.id); if (!entry) return; const man = registry.get(tile.module); if (!man) { tileMessage(entry, `Module “${tile.module}” is not installed or is disabled. Check Admin.`); entry.dot.className = 'dot error'; entry.dot.title = 'Module unavailable'; return; } entry.dot.className = 'dot connecting'; ensureModuleStyle(man); try { const mod = await loadClientModule(man); if (tileEls.get(tile.id) !== entry || entry.instance) return; // tile removed/remounted while loading const factory = mod.default; if (typeof factory !== 'function') throw new Error('client.js has no default-export factory'); const api = buildModuleApi(tile, man, entry); const instance = factory({ root: entry.body, moduleApi: api }) || {}; entry.instance = instance; entry.api = api; instance.start?.(); } catch (err) { console.error(`[shell] module "${tile.module}" failed to start:`, err); entry.body.innerHTML = ''; tileMessage(entry, `“${man.name}” failed to start: ${err?.message || err}`); entry.dot.className = 'dot error'; } } function unmountModule(tile) { const entry = tileEls.get(tile.id); if (!entry) return; try { entry.instance?.stop?.(); } catch { /* stopping is best-effort */ } try { entry.api?._closeAll(); } catch { /* ditto */ } entry.instance = null; entry.api = null; } /* ── per-tile settings popover ──────────────────────────────────────── */ /** `` only accepts #rrggbb — anything else falls back. */ /* ── the tile tint ─────────────────────────────────────────────────── Every tile can be washed in a colour of its own — a way to tell one screen's tiles apart at a glance (this campus green, that one amber). It is the shell's, not a module's: the key is reserved, it is offered in every gear window, and it saves with the layout like any other tile setting. A tile with no tint looks exactly as before. */ const TINT_KEY = '__tint'; /** Paint (or clear) a tile's tint on its element. */ function applyTint(tile, el = tileEls.get(tile.id)?.el) { if (!el) return; const hex = colorHex(tile.settings?.[TINT_KEY], ''); if (hex) el.style.setProperty('--tile-tint', hex); else el.style.removeProperty('--tile-tint'); el.classList.toggle('is-tinted', Boolean(hex)); } function colorHex(value, fallback) { const s = String(value ?? '').trim(); if (/^#[0-9a-fA-F]{6}$/.test(s)) return s.toLowerCase(); if (/^#[0-9a-fA-F]{3}$/.test(s)) return ('#' + s.slice(1).split('').map((c) => c + c).join('')).toLowerCase(); return fallback; } /** The settings a tile offers: its picker entry's own schema when it has one — a solo timer wants none of a full tile's switches — else the module's. Same for the groups that arrange them. */ function tileSchema(tile, man) { const own = (v) => (v && typeof v === 'object' ? v : null); const variant = String(tile?.variant || ''); const entry = variant && Array.isArray(man?.tiles) ? man.tiles.find((e) => String(e.id) === variant) || man.tiles.find((e) => Array.isArray(e.aliases) && e.aliases.includes(variant)) : null; return { schema: own(entry?.instanceSchema) || own(man?.instanceSchema) || {}, groups: own(entry?.instanceGroups) || own(man?.instanceGroups) || {}, }; } /* ── tooltips: at once on hover, short, one for the whole page ────────── Every header and top-bar button's `title` becomes a tip the shell shows the moment the pointer arrives — the browser's own waits about a second and looks different in each browser. The text moves from `title` to data-tip so the native one never appears; a module that sets `title` again later is picked up on the next hover. */ let tipEl = null; const TIP_TARGETS = '.tile-btn, .tb-btn, [data-tip]'; function tipTextFor(el) { if (el.title) { el.dataset.tip = el.title; if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', el.title); el.removeAttribute('title'); } return el.dataset.tip || ''; } function showTip(el) { const text = tipTextFor(el); if (!text) return; if (!tipEl) { tipEl = document.createElement('div'); tipEl.id = 'tip'; tipEl.setAttribute('role', 'tooltip'); document.body.appendChild(tipEl); } tipEl.textContent = text; tipEl.hidden = false; const r = el.getBoundingClientRect(); const w = tipEl.offsetWidth; const h = tipEl.offsetHeight; const left = Math.max(6, Math.min(r.left + r.width / 2 - w / 2, window.innerWidth - w - 6)); let top = r.bottom + 6; if (top + h > window.innerHeight - 6) top = r.top - h - 6; tipEl.style.left = `${Math.round(left)}px`; tipEl.style.top = `${Math.round(top)}px`; tipEl._for = el; } function hideTip(el = null) { if (!tipEl || tipEl.hidden) return; if (el && tipEl._for !== el) return; tipEl.hidden = true; tipEl._for = null; } document.addEventListener('pointerover', (e) => { const el = e.target?.closest?.(TIP_TARGETS); if (!el || (tipEl && tipEl._for === el && !tipEl.hidden)) return; showTip(el); }); document.addEventListener('pointerout', (e) => { const el = e.target?.closest?.(TIP_TARGETS); if (!el || (e.relatedTarget && el.contains(e.relatedTarget))) return; hideTip(el); }); document.addEventListener('pointerdown', () => hideTip(), true); document.addEventListener('focusin', (e) => { const el = e.target?.closest?.(TIP_TARGETS); if (el) showTip(el); }); document.addEventListener('focusout', () => hideTip()); window.addEventListener('scroll', () => hideTip(), true); window.addEventListener('resize', () => hideTip()); function closeSettingsPopovers() { document.querySelectorAll('.tile-settings').forEach((p) => { p._cleanup?.(); p._button?.classList.remove('is-open'); p.remove(); }); } /* ── the per-tile settings window ───────────────────────────────────── */ /* It opens beside its tile — to the right when there is room, else the left, else over it — and always inside the viewport. A window rather than something inside the tile, because a tile can be smaller than its own settings, and because the point of live settings is watching the tile change while you change them: the window must not sit on top of it. */ function placePopover(pop, anchor) { const vw = window.innerWidth; const vh = window.innerHeight; const pad = 8; const w = pop.offsetWidth; const h = pop.offsetHeight; let left = anchor.right + pad; if (left + w > vw - pad) left = anchor.left - w - pad; if (left < pad) left = Math.min(Math.max(pad, anchor.left), Math.max(pad, vw - w - pad)); let top = anchor.top; if (top + h > vh - pad) top = vh - h - pad; if (top < pad) top = pad; pop.style.left = Math.round(left) + 'px'; pop.style.top = Math.round(top) + 'px'; } function clampPopover(pop) { const pad = 8; const maxLeft = Math.max(pad, window.innerWidth - pop.offsetWidth - pad); const maxTop = Math.max(pad, window.innerHeight - pop.offsetHeight - pad); pop.style.left = Math.min(Math.max(pad, pop.offsetLeft), maxLeft) + 'px'; pop.style.top = Math.min(Math.max(pad, pop.offsetTop), maxTop) + 'px'; } /** Open (or close) the settings window for `tile`, beside its element `el`; `button` is the gear that asked. */ function toggleSettingsPopover(tile, el, button = null) { const existing = document.querySelector(`.tile-settings[data-tile-id="${tile.id}"]`); closeSettingsPopovers(); if (existing) return; const man = registry.get(tile.module); if (!man) return; const { schema, groups: groupMeta } = tileSchema(tile, man); const pop = document.createElement('div'); pop.className = 'tile-settings'; pop.dataset.tileId = tile.id; pop.setAttribute('role', 'dialog'); pop.setAttribute('aria-label', `${tile.title || man.name} settings`); // The gear that opened it wears the window's colour while it is open. if (button) { button.classList.add('is-open'); pop._button = button; } // A slim bar to move it by — nothing else. No title (the window sits beside // the tile it belongs to) and no close button: a click anywhere else, or // Escape, closes it. const bar = document.createElement('div'); bar.className = 'tile-settings-bar'; bar.title = 'Drag to move'; const body = document.createElement('div'); body.className = 'tile-settings-body'; pop.append(bar, body); const inputs = new Map(); const shownWhen = []; // fields that appear only while another has a given value /* Display choices apply as they change and save with the layout — nothing to confirm, so no Apply. Typing is debounced because applying means remounting the module, which shouldn't happen per keystroke. */ const same = (a, b) => a === b || (Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((v, i) => v === b[i])); let applyTimer = null; const commit = (delay) => { clearTimeout(applyTimer); applyTimer = setTimeout(() => { applyTimer = null; // Only when something really moved: a text field fires `input` and then // `change` on the way out, and a module shouldn't be torn down twice // over for one edit. A reader answering undefined has nothing to say. let changed = false; for (const [key, read] of inputs) { const value = read(); if (value === undefined || same(tile.settings[key], value)) continue; tile.settings[key] = value; changed = true; } if (!changed) return; saveLayout(); remountTile(tile); }, delay); }; const live = (input, event = 'change', delay = 0) => { input.addEventListener(event, () => commit(delay)); }; /** Choices a module only knows at runtime, from one of its own routes. */ const fetchOptions = async (route) => { try { const res = await fetchWithTimeout(`/api/modules/${encodeURIComponent(man.id)}${route}`, 8000); const data = res.ok ? await res.json() : null; return Array.isArray(data?.options) ? data.options : null; } catch { return null; } }; const optValue = (opt) => String(typeof opt === 'object' ? opt.value : opt); const optLabel = (opt) => String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt); // Fields render in schema order; a field's `group` starts a labeled // subsection that consecutive same-group fields share, and the groups map // adds { collapsed, columns, help, toggle } — the same conventions as the // admin page's config form. let groupName = null; let container = body; let current = null; // the open group: { el, title, toggleKey } for (const [key, spec] of Object.entries(schema)) { const type = spec?.type || 'string'; const label = spec?.label || key; const value = key in (tile.settings || {}) ? tile.settings[key] : spec?.default; const g = spec?.group || ''; if (g !== groupName) { groupName = g; if (g) { const meta = groupMeta[g] && typeof groupMeta[g] === 'object' ? groupMeta[g] : {}; const folds = meta.collapsed !== undefined; const group = document.createElement(folds ? 'details' : 'div'); group.className = 'field-group'; if (folds) group.open = !meta.collapsed; const title = document.createElement(folds ? 'summary' : 'div'); title.className = 'field-group-title'; const titleText = document.createElement('span'); titleText.className = 'field-group-name'; titleText.textContent = g; title.appendChild(titleText); group.appendChild(title); const groupBody = document.createElement('div'); groupBody.className = 'field-group-body'; const cols = Math.min(4, Math.trunc(Number(meta.columns)) || 0); if (cols > 1) { groupBody.classList.add('is-columns'); groupBody.style.setProperty('--cols', String(cols)); } if (meta.help) { const help = document.createElement('div'); help.className = 'field-group-help'; help.textContent = meta.help; groupBody.appendChild(help); } group.appendChild(groupBody); body.appendChild(group); pop.classList.add('has-groups'); container = groupBody; current = { el: group, title, toggleKey: typeof meta.toggle === 'string' ? meta.toggle : '' }; } else { container = body; current = null; } } const isSwitch = type === 'boolean' || type === 'switch'; // A multiselect holds several controls, so it can't be a