/* 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 = ''; 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 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 ──────────────────────────────────────── */ /** `` 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