From 9d9b92e6d96161134e8ea0c8a775a5bd39538741 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 14:50:37 +0000 Subject: [PATCH 1/9] Shell: server, dashboard grid with drag/resize/remove, localStorage layouts Milestone 1 of SPEC.md. Zero-dependency Node server serving public/; 12-column tile grid with add (placeholder tile), drag to move, drag to resize, remove; collision-free placement; layout autosaves to this browser's localStorage and survives reload. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH --- config/proddash.json | 4 + public/index.html | 49 ++++ public/shell.js | 605 +++++++++++++++++++++++++++++++++++++++++++ public/style.css | 436 +++++++++++++++++++++++++++++++ server.js | 131 ++++++++++ 5 files changed, 1225 insertions(+) create mode 100644 config/proddash.json create mode 100644 public/index.html create mode 100644 public/shell.js create mode 100644 public/style.css create mode 100644 server.js diff --git a/config/proddash.json b/config/proddash.json new file mode 100644 index 0000000..869d4c8 --- /dev/null +++ b/config/proddash.json @@ -0,0 +1,4 @@ +{ + "port": 24500, + "adminPasscode": "" +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..69afd10 --- /dev/null +++ b/public/index.html @@ -0,0 +1,49 @@ + + + + + + ProdDash + + + + +
+ ProdDash + + + + + + + + + + Admin + +
+ +
+
+
No tiles yet — use “+ Add tile” to build this dashboard.
+ Every browser keeps its own layout.
+
+
+ + + + + + + + diff --git a/public/shell.js b/public/shell.js new file mode 100644 index 0000000..ada4c88 --- /dev/null +++ b/public/shell.js @@ -0,0 +1,605 @@ +/* ProdDash shell client. + + Owns the tile grid: adding, dragging, resizing and removing module tiles, + plus layout persistence. The layout (tiles, positions, sizes, per-instance + settings) autosaves to this browser's localStorage — every browser is fully + independent. Modules render inside tile bodies and never touch the shell. */ + +const COLS = 12; +const LS_LAYOUT = 'proddash:layout'; + +const grid = document.getElementById('grid'); +const gridwrap = document.getElementById('gridwrap'); +const emptyEl = document.getElementById('empty-state'); +const toastEl = document.getElementById('toast'); + +/* ── tiny helpers ───────────────────────────────────────────────────── */ + +let toastTimer = null; +function toast(msg, isError = false) { + toastEl.textContent = msg; + toastEl.classList.toggle('error', isError); + toastEl.hidden = false; + clearTimeout(toastTimer); + toastTimer = setTimeout(() => { toastEl.hidden = true; }, 3200); +} + +const modalBackdrop = document.getElementById('modal-backdrop'); +const modalEl = document.getElementById('modal'); + +/** Minimal prompt dialog; resolves with the string or null on cancel. */ +function promptModal({ title, placeholder = '', value = '', okLabel = 'Save' }) { + return new Promise((resolve) => { + modalEl.innerHTML = ''; + const h = document.createElement('h2'); + h.textContent = title; + const field = document.createElement('div'); + field.className = 'field'; + const input = document.createElement('input'); + input.type = 'text'; + input.placeholder = placeholder; + input.value = value; + field.appendChild(input); + const actions = document.createElement('div'); + actions.className = 'actions'; + const cancel = document.createElement('button'); + cancel.className = 'btn'; + cancel.textContent = 'Cancel'; + const ok = document.createElement('button'); + ok.className = 'btn primary'; + ok.textContent = okLabel; + actions.append(cancel, ok); + modalEl.append(h, field, actions); + modalBackdrop.hidden = false; + input.focus(); + const done = (result) => { + modalBackdrop.hidden = true; + modalEl.innerHTML = ''; + resolve(result); + }; + cancel.addEventListener('click', () => done(null)); + ok.addEventListener('click', () => done(input.value.trim() || null)); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') done(input.value.trim() || null); + if (e.key === 'Escape') done(null); + }); + modalBackdrop.addEventListener('click', (e) => { + if (e.target === modalBackdrop) done(null); + }, { once: true }); + }); +} + +/* ── dropdown menus ─────────────────────────────────────────────────── */ + +function wireDropdown(btnId, menuId, rebuild) { + const btn = document.getElementById(btnId); + const menu = document.getElementById(menuId); + btn.addEventListener('click', async () => { + if (menu.hidden) await rebuild(menu); + menu.hidden = !menu.hidden; + }); + document.addEventListener('click', (e) => { + if (!menu.hidden && !e.target.closest('#' + btnId) && !e.target.closest('#' + menuId)) { + menu.hidden = true; + } + }); + return menu; +} + +function menuItem(label, onClick, { sub = '', danger = false } = {}) { + const item = document.createElement('button'); + item.className = 'menu-item' + (danger ? ' danger' : ''); + const span = document.createElement('span'); + span.textContent = label; + if (sub) { + const s = document.createElement('span'); + s.className = 'sub'; + s.textContent = sub; + span.appendChild(s); + } + item.appendChild(span); + item.addEventListener('click', onClick); + return item; +} + +/* ── module registry ────────────────────────────────────────────────── */ + +/* Filled from GET /api/modules once the module system is up (milestone 2). + Until then — or if the fetch fails — the shell offers a built-in + placeholder tile so the grid itself is usable and testable. */ + +const PLACEHOLDER_MANIFEST = { + id: '__placeholder', + name: 'Placeholder', + description: 'An empty tile for trying out the grid', + defaultSize: { w: 3, h: 2 }, + minSize: { w: 1, h: 1 }, + builtin: true, +}; + +let registry = new Map(); // module id -> manifest + +async function loadRegistry() { + registry = new Map(); + try { + const res = await fetch('/api/modules'); + if (!res.ok) throw new Error('modules ' + res.status); + const body = await res.json(); + for (const m of body.modules || []) registry.set(m.id, m); + } catch { + /* no module API yet (or server just restarted) — placeholder only */ + } + if (!registry.size) registry.set(PLACEHOLDER_MANIFEST.id, PLACEHOLDER_MANIFEST); +} + +/* ── 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)); + return { + 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 : {}, + }; +} + +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 gap = parseFloat(style.columnGap) || 10; + 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 isFree(rect, excludeId) { + if (rect.x < 0 || rect.y < 0 || rect.x + rect.w > COLS) return false; + return !tiles.some((t) => t.id !== excludeId && overlaps(rect, t)); +} + +/** 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 man = registry.get(tile.module); + return { + w: Math.max(1, man?.minSize?.w || 1), + h: Math.max(1, man?.minSize?.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 ? man.name : tile.module + ' (not installed)'; + + head.append(dot, title); + + const hasSettings = man && man.instanceSchema && Object.keys(man.instanceSchema).length; + if (hasSettings) { + const gear = document.createElement('button'); + gear.className = 'tile-btn'; + gear.title = 'Tile settings (this tile only)'; + gear.innerHTML = GEAR_SVG; + gear.addEventListener('click', (e) => { + e.stopPropagation(); + toggleSettingsPopover(tile, el); + }); + head.appendChild(gear); + } + + const close = document.createElement('button'); + close.className = 'tile-btn close'; + close.title = 'Remove this tile'; + close.textContent = '✕'; + close.addEventListener('click', (e) => { + e.stopPropagation(); + removeTile(tile.id); + }); + head.appendChild(close); + + const body = document.createElement('div'); + body.className = 'tile-body'; + + const resize = document.createElement('div'); + resize.className = 'tile-resize'; + resize.title = 'Drag to resize'; + + el.append(head, body, resize); + grid.appendChild(el); + + const entry = { el, body, dot, titleEl: title, instance: null }; + tileEls.set(tile.id, entry); + applyRect(tile); + + wireDrag(tile, el, head); + wireResize(tile, el, resize); + + 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')) return; + if (ev.button !== 0 && ev.pointerType === 'mouse') 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'); + } + 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'); + if (moved) saveLayout(); + }; + handle.addEventListener('pointermove', onMove); + handle.addEventListener('pointerup', onUp); + handle.addEventListener('pointercancel', onUp); + }); +} + +/* ── drag to resize ─────────────────────────────────────────────────── */ + +function wireResize(tile, el, handle) { + handle.addEventListener('pointerdown', (ev) => { + if (ev.button !== 0 && ev.pointerType === 'mouse') return; + ev.preventDefault(); + ev.stopPropagation(); + handle.setPointerCapture(ev.pointerId); + const m = cellMetrics(); + const sx = ev.clientX; + const sy = ev.clientY; + const startW = tile.w; + const startH = tile.h; + const min = minSizeOf(tile); + el.classList.add('resizing'); + 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)); + const 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.w !== tile.w || cand.h !== tile.h) && isFree(cand, tile.id)) { + 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'); + if (changed) saveLayout(); + }; + 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 */ } +} + +/* ── add / remove tiles ─────────────────────────────────────────────── */ + +function addTile(moduleId) { + const man = registry.get(moduleId); + const size = { + w: Math.max(1, Math.min(COLS, man?.defaultSize?.w || 4)), + h: Math.max(1, man?.defaultSize?.h || 3), + }; + const spot = findSpot(size.w, size.h); + const settings = {}; + for (const [key, spec] of Object.entries(man?.instanceSchema || {})) { + if (spec && 'default' in spec) settings[key] = spec.default; + } + const tile = { id: newId(), module: moduleId, ...spot, ...size, settings }; + tiles.push(tile); + buildTile(tile); + refreshEmptyState(); + saveLayout(); +} + +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(); +} + +function clearAllTiles() { + for (const tile of tiles) { + unmountModule(tile); + tileEls.get(tile.id)?.el.remove(); + } + tiles = []; + tileEls.clear(); + refreshEmptyState(); +} + +/* ── module mounting ────────────────────────────────────────────────── */ + +/* Milestone 2 turns this into the real module loader (dynamic import of each + module's client.js plus the moduleApi). For now only the built-in + placeholder renders. */ + +function mountModule(tile) { + const entry = tileEls.get(tile.id); + if (!entry) return; + const man = registry.get(tile.module); + if (!man) { + entry.body.innerHTML = '
Module “' + tile.module + '” is not installed.
'; + entry.dot.className = 'dot error'; + return; + } + if (man.builtin) { + const msg = document.createElement('div'); + msg.className = 'tile-msg'; + msg.textContent = 'Placeholder tile — drag me by the header, resize me by the corner.'; + entry.body.appendChild(msg); + entry.dot.className = 'dot ok'; + } +} + +function unmountModule(tile) { + const entry = tileEls.get(tile.id); + try { + entry?.instance?.stop?.(); + } catch { /* stopping is best-effort */ } + if (entry) entry.instance = null; +} + +/* ── per-tile settings popover ──────────────────────────────────────── */ + +function closeSettingsPopovers() { + document.querySelectorAll('.tile-settings').forEach((p) => p.remove()); +} + +function toggleSettingsPopover(tile, el) { + const existing = el.querySelector('.tile-settings'); + closeSettingsPopovers(); + if (existing) return; + const man = registry.get(tile.module); + if (!man || !man.instanceSchema) return; + + const pop = document.createElement('div'); + pop.className = 'tile-settings'; + const inputs = new Map(); + + for (const [key, spec] of Object.entries(man.instanceSchema)) { + const type = spec?.type || 'string'; + const label = spec?.label || key; + const current = key in (tile.settings || {}) ? tile.settings[key] : spec?.default; + + const field = document.createElement('label'); + field.className = 'field' + (type === 'boolean' ? ' check' : ''); + + if (type === 'boolean') { + const input = document.createElement('input'); + input.type = 'checkbox'; + input.checked = Boolean(current); + field.append(input, document.createTextNode(label)); + inputs.set(key, () => input.checked); + } else if (type === 'select' && Array.isArray(spec.options)) { + const span = document.createElement('span'); + span.textContent = label; + const select = document.createElement('select'); + for (const opt of spec.options) { + const o = document.createElement('option'); + o.value = String(typeof opt === 'object' ? opt.value : opt); + o.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt); + select.appendChild(o); + } + select.value = String(current ?? ''); + field.append(span, select); + inputs.set(key, () => select.value); + } else { + const span = document.createElement('span'); + span.textContent = label; + const input = document.createElement('input'); + input.type = type === 'number' ? 'number' : (type === 'password' ? 'password' : 'text'); + input.value = current === undefined || current === null ? '' : String(current); + field.append(span, input); + inputs.set(key, () => (type === 'number' ? Number(input.value) : input.value)); + } + pop.appendChild(field); + } + + const actions = document.createElement('div'); + actions.className = 'actions'; + actions.style.display = 'flex'; + actions.style.justifyContent = 'flex-end'; + actions.style.gap = '8px'; + const cancel = document.createElement('button'); + cancel.className = 'btn'; + cancel.textContent = 'Cancel'; + cancel.addEventListener('click', () => pop.remove()); + const apply = document.createElement('button'); + apply.className = 'btn primary'; + apply.textContent = 'Apply'; + apply.addEventListener('click', () => { + for (const [key, read] of inputs) tile.settings[key] = read(); + pop.remove(); + saveLayout(); + remountTile(tile); + }); + actions.append(cancel, apply); + pop.appendChild(actions); + + pop.addEventListener('pointerdown', (e) => e.stopPropagation()); + el.appendChild(pop); +} + +/** Tear a tile's module down and start it again (settings changed). */ +function remountTile(tile) { + const entry = tileEls.get(tile.id); + if (!entry) return; + unmountModule(tile); + entry.body.innerHTML = ''; + entry.dot.className = 'dot'; + mountModule(tile); +} + +/* ── layout apply / reset ───────────────────────────────────────────── */ + +function renderLayout(list) { + clearAllTiles(); + tiles = list.map(sanitizeTile); + for (const tile of tiles) buildTile(tile); + refreshEmptyState(); +} + +/* ── menus ──────────────────────────────────────────────────────────── */ + +wireDropdown('add-btn', 'add-menu', async (menu) => { + await loadRegistry(); + menu.innerHTML = ''; + const manifests = [...registry.values()]; + if (!manifests.length) { + const note = document.createElement('div'); + note.className = 'menu-note'; + note.textContent = 'No modules are enabled. Enable some in Admin.'; + menu.appendChild(note); + return; + } + for (const man of manifests) { + menu.appendChild(menuItem(man.name, () => { + menu.hidden = true; + addTile(man.id); + }, { sub: man.description || '' })); + } +}); + +wireDropdown('layout-btn', 'layout-menu', async (menu) => { + menu.innerHTML = ''; + menu.appendChild(menuItem('Reset layout', () => { + menu.hidden = true; + renderLayout([]); + saveLayout(); + toast('Layout cleared'); + }, { danger: true, sub: 'Remove every tile from this browser' })); +}); + +/* ── boot ───────────────────────────────────────────────────────────── */ + +async function boot() { + await loadRegistry(); + renderLayout(loadLayoutFromStorage()); +} + +boot(); diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..cb4f1e7 --- /dev/null +++ b/public/style.css @@ -0,0 +1,436 @@ +/* ProdDash shell — dark, high-contrast, production-booth friendly. + Visual language carried over from ProdCom Listener (same variable names, so + module CSS written against these looks native everywhere). Modules must + style with these variables only — no hardcoded colors. */ + +:root { + --bg: #0b0e12; + --panel: #131920; + --border: #232b35; + --text: #e6edf3; + --muted: #8b98a5; + --accent: #2ee59a; + --danger: #f4433c; + --warn: #f0b429; + /* grid geometry (shell.js reads these) */ + --cell-h: 72px; + --gap: 10px; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +html, body { height: 100%; } + +body { + background: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + display: flex; + flex-direction: column; + overflow: hidden; +} + +button { font-family: inherit; } + +/* ── topbar ────────────────────────────────────────────────────────── */ + +#topbar { + flex: none; + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + background: var(--panel); + border-bottom: 1px solid var(--border); + user-select: none; + -webkit-user-select: none; +} + +#brand { + font-weight: 800; + font-size: 15px; + letter-spacing: 0.02em; + margin-right: 6px; +} +#brand .accent { color: var(--accent); } + +#topbar .spacer { flex: 1; } + +.tb-btn { + display: inline-flex; + align-items: center; + gap: 6px; + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + padding: 6px 10px; + font-size: 13px; + cursor: pointer; + text-decoration: none; +} +.tb-btn:hover { border-color: var(--muted); } +.tb-btn:active { background: var(--border); } + +/* ── dropdown menus ────────────────────────────────────────────────── */ + +.dropdown { position: relative; } + +.menu { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 50; + min-width: 210px; + max-height: 70vh; + overflow-y: auto; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + padding: 5px; + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55); +} +.menu[hidden] { display: none; } + +.menu-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 10px; + background: none; + border: none; + border-radius: 6px; + color: var(--text); + font-size: 13px; + text-align: left; + cursor: pointer; + white-space: nowrap; +} +.menu-item:hover { background: var(--border); } +.menu-item .sub { + display: block; + color: var(--muted); + font-size: 11px; + margin-top: 2px; + white-space: normal; +} +.menu-item.danger { color: var(--danger); } +.menu-note { + padding: 8px 10px; + color: var(--muted); + font-size: 12px; + white-space: normal; +} +.menu-divider { height: 1px; background: var(--border); margin: 5px 4px; } +.menu-title { + padding: 6px 10px 3px; + color: var(--muted); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +/* ── grid ──────────────────────────────────────────────────────────── */ + +#gridwrap { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: var(--gap); + overscroll-behavior: contain; +} + +#grid { + position: relative; + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + grid-auto-rows: var(--cell-h); + gap: var(--gap); + /* Always leave room to drop/grow a tile below the last row. */ + padding-bottom: calc(3 * (var(--cell-h) + var(--gap))); +} + +#empty-state { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding-top: 12vh; + color: var(--muted); + font-size: 14px; + pointer-events: none; +} +#empty-state[hidden] { display: none; } + +/* ── tiles ─────────────────────────────────────────────────────────── */ + +.tile { + position: relative; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; +} + +.tile.dragging, +.tile.resizing { + z-index: 40; + border-color: var(--muted); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.6); + opacity: 0.92; +} + +.tile-head { + flex: none; + display: flex; + align-items: center; + gap: 8px; + padding: 5px 8px; + background: rgba(255, 255, 255, 0.03); + border-bottom: 1px solid var(--border); + cursor: grab; + user-select: none; + -webkit-user-select: none; + touch-action: none; +} +.tile.dragging .tile-head { cursor: grabbing; } + +.tile-head .dot { + flex: none; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--muted); + transition: background 0.3s; +} +.tile-head .dot.ok { background: var(--accent); box-shadow: 0 0 5px var(--accent); } +.tile-head .dot.connecting { background: var(--warn); } +.tile-head .dot.error { background: var(--danger); } + +.tile-title { + flex: 1; + min-width: 0; + font-size: 12px; + font-weight: 700; + color: var(--muted); + letter-spacing: 0.03em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tile-btn { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + background: none; + border: none; + border-radius: 5px; + color: var(--muted); + font-size: 13px; + cursor: pointer; +} +.tile-btn:hover { background: var(--border); color: var(--text); } +.tile-btn.close:hover { color: var(--danger); } +.tile-btn svg { width: 13px; height: 13px; display: block; } + +.tile-body { + flex: 1; + position: relative; + min-height: 0; + min-width: 0; + overflow: hidden; +} + +.tile-resize { + position: absolute; + right: 0; + bottom: 0; + width: 20px; + height: 20px; + cursor: nwse-resize; + touch-action: none; + z-index: 5; + color: var(--muted); + opacity: 0.6; +} +.tile-resize:hover { opacity: 1; color: var(--text); } +.tile-resize::after { + content: ""; + position: absolute; + right: 4px; + bottom: 4px; + width: 9px; + height: 9px; + border-right: 2px solid currentColor; + border-bottom: 2px solid currentColor; + border-radius: 0 0 3px 0; +} + +/* status message a module can show while degraded (also used by the shell + when a module fails to load) */ +.tile-msg { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + padding: 12px; + color: var(--muted); + font-size: 13px; + background: var(--panel); + z-index: 4; +} + +/* ── per-tile settings popover ─────────────────────────────────────── */ + +.tile-settings { + position: absolute; + top: 30px; + right: 6px; + z-index: 45; + min-width: 220px; + max-width: calc(100% - 12px); + max-height: calc(100% - 40px); + overflow-y: auto; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + padding: 10px; + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55); +} + +.field { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 10px; + font-size: 12px; + color: var(--muted); +} +.field input[type="text"], +.field input[type="number"], +.field input[type="password"], +.field select { + padding: 7px 9px; + font-size: 13px; + font-family: inherit; + color: var(--text); + background: var(--bg); + border: 1px solid var(--border); + border-radius: 7px; +} +.field input:focus, .field select:focus { + outline: none; + border-color: var(--accent); +} +.field.check { + flex-direction: row; + align-items: center; + gap: 8px; + color: var(--text); + cursor: pointer; +} +.field.check input { width: 15px; height: 15px; accent-color: var(--accent); } + +/* ── modal dialogs (layout name prompt etc.) ───────────────────────── */ + +#modal-backdrop { + position: fixed; + inset: 0; + z-index: 90; + background: rgba(4, 6, 8, 0.65); + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 18vh; +} +#modal-backdrop[hidden] { display: none; } + +#modal { + width: min(360px, calc(100vw - 32px)); + background: var(--panel); + border: 1px solid var(--border); + border-radius: 12px; + padding: 16px; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6); +} +#modal h2 { font-size: 14px; margin-bottom: 12px; } +#modal .actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 4px; +} + +.btn { + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + padding: 6px 12px; + font-size: 13px; + cursor: pointer; +} +.btn:hover { border-color: var(--muted); } +.btn.primary { + background: var(--accent); + border-color: var(--accent); + color: #06281a; + font-weight: 600; +} + +/* toast */ +#toast { + position: fixed; + bottom: 18px; + left: 50%; + transform: translateX(-50%); + z-index: 95; + background: var(--panel); + border: 1px solid var(--border); + color: var(--text); + font-size: 13px; + border-radius: 999px; + padding: 8px 16px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5); + transition: opacity 0.25s ease; +} +#toast[hidden] { display: none; } +#toast.error { border-color: var(--danger); } + +/* ── scrollbars ────────────────────────────────────────────────────── */ + +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 999px; + border: 2px solid transparent; /* inset gutter */ + background-clip: padding-box; +} +::-webkit-scrollbar-thumb:hover { + background: #364253; + background-clip: padding-box; +} +::-webkit-scrollbar-corner { background: transparent; } + +/* Firefox (webkit pseudo-elements unsupported there) */ +@supports (-moz-appearance: none) { + * { scrollbar-width: thin; scrollbar-color: var(--border) transparent; } +} + +@media (max-width: 560px) { + #topbar { padding: 6px 8px; flex-wrap: wrap; } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..1401577 --- /dev/null +++ b/server.js @@ -0,0 +1,131 @@ +#!/usr/bin/env node +/** + * ProdDash — modular production dashboard for Waters Church. + * + * Shell server: serves the dashboard client from public/, discovers modules + * in modules/, mounts each enabled module's server routes under + * /api/modules//, and stores server-side config + named layouts under + * config/. + * + * Zero dependencies — Node built-ins only (Node 18+ for global fetch). + * Configure via config/proddash.json or env vars: + * PORT (default: value in proddash.json, else 24500) + * PRODDASH_PASSCODE (overrides adminPasscode in proddash.json) + */ +'use strict'; + +const http = require('http'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const ROOT = __dirname; +const PUBLIC_DIR = path.join(ROOT, 'public'); +const CONFIG_DIR = path.join(ROOT, 'config'); + +/* ── config ─────────────────────────────────────────────────────────── */ + +function readJson(file, fallback) { + try { + return JSON.parse(fs.readFileSync(file, 'utf8')); + } catch { + return fallback; + } +} + +const shellConfig = readJson(path.join(CONFIG_DIR, 'proddash.json'), {}); +const PORT = Number(process.env.PORT || shellConfig.port || 24500); + +/* ── static files ───────────────────────────────────────────────────── */ + +const MIME = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.webmanifest': 'application/manifest+json; charset=utf-8', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', + '.woff2': 'font/woff2', +}; + +/** Serve a file from inside baseDir; urlPath is relative to it. */ +function serveFile(res, baseDir, urlPath) { + let rel; + try { + rel = decodeURIComponent(urlPath).replace(/^\/+/, ''); + } catch { + res.writeHead(400, { 'Content-Type': 'text/plain' }); + return res.end('Bad request'); + } + if (rel === '') rel = 'index.html'; + // Resolve inside baseDir only — never let a crafted path escape the web root. + const target = path.resolve(baseDir, rel); + if (target !== baseDir && !target.startsWith(baseDir + path.sep)) { + res.writeHead(403, { 'Content-Type': 'text/plain' }); + return res.end('Forbidden'); + } + fs.stat(target, (serr, stat) => { + const file = !serr && stat.isDirectory() ? path.join(target, 'index.html') : target; + fs.readFile(file, (err, data) => { + if (err) { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + return res.end('Not found'); + } + const ext = path.extname(file).toLowerCase(); + // Icons can cache; everything else must always revalidate so UI updates + // reach open dashboards immediately. + const noCache = ext !== '.png' && ext !== '.ico' && ext !== '.svg' && ext !== '.woff2'; + res.writeHead(200, { + 'Content-Type': MIME[ext] || 'application/octet-stream', + 'Cache-Control': noCache ? 'no-cache' : 'public, max-age=3600', + }); + res.end(data); + }); + }); +} + +/* ── request routing ────────────────────────────────────────────────── */ + +const server = http.createServer((req, res) => { + // Everything is guarded: one throw must never take the dashboard down mid-service. + try { + let urlPath; + try { + urlPath = new URL(req.url, 'http://proddash.invalid').pathname; + } catch { + res.writeHead(400, { 'Content-Type': 'text/plain' }); + return res.end('Bad request'); + } + + if (req.method !== 'GET' && req.method !== 'HEAD') { + res.writeHead(405, { Allow: 'GET, HEAD' }); + return res.end('Method not allowed'); + } + serveFile(res, PUBLIC_DIR, urlPath); + } catch (err) { + console.error('[proddash] request error:', err); + try { + if (!res.headersSent) res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end('Server error'); + } catch { /* connection already gone */ } + } +}); + +// Never time out long-lived SSE connections. +server.requestTimeout = 0; + +server.listen(PORT, '0.0.0.0', () => { + console.log('ProdDash running'); + console.log(' Local : http://localhost:' + PORT); + for (const ifaces of Object.values(os.networkInterfaces())) { + for (const iface of ifaces || []) { + if (iface.family === 'IPv4' && !iface.internal) { + console.log(' Network : http://' + iface.address + ':' + PORT); + } + } + } +}); From 3ac70fc44ddc47ea65794a950299a99d9b28b1e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 14:55:25 +0000 Subject: [PATCH 2/9] Module system: discovery, route mounting, client loader, clock module Milestone 2. Server scans modules/*/module.json, serves module assets at /modules//, mounts optional server routes (exact + trailing-/* prefix patterns) under /api/modules// with per-module config from config/modules.json, and lists enabled manifests at GET /api/modules. Shell dynamically imports each module's client.js and hands it the moduleApi (config, instanceSettings, scoped fetch, auto-reconnecting sse, setStatus). Clock module proves the contract end to end; password-typed config fields never leave the server. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH --- .gitignore | 4 + modules/clock/client.js | 74 ++++++++ modules/clock/module.json | 23 +++ modules/clock/style.css | 25 +++ public/shell.js | 190 +++++++++++++++++---- server.js | 347 ++++++++++++++++++++++++++++++++++++-- 6 files changed, 617 insertions(+), 46 deletions(-) create mode 100644 modules/clock/client.js create mode 100644 modules/clock/module.json create mode 100644 modules/clock/style.css diff --git a/.gitignore b/.gitignore index db4682f..bf6e2da 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ dist/ .DS_Store *.log .env + +# runtime state written by the admin page / layout saves +config/modules.json +config/layouts/ diff --git a/modules/clock/client.js b/modules/clock/client.js new file mode 100644 index 0000000..0a8b37d --- /dev/null +++ b/modules/clock/client.js @@ -0,0 +1,74 @@ +/* Clock — the reference ProdDash module. Client-only: no server entry. + Everything a module may do is shown here: render inside root, read + instanceSettings, report status, clean up in stop(). */ + +export default function create({ root, moduleApi }) { + let timer = null; + let timeEl = null; + let dateEl = null; + let resizeObserver = null; + + function settings() { + return moduleApi.instanceSettings; // shell merges schema defaults for us + } + + function renderFrame() { + root.innerHTML = ''; + const wrap = document.createElement('div'); + wrap.className = 'clock-wrap'; + timeEl = document.createElement('div'); + timeEl.className = 'clock-time'; + dateEl = document.createElement('div'); + dateEl.className = 'clock-date'; + wrap.append(timeEl, dateEl); + root.appendChild(wrap); + } + + function tick() { + const { format, showSeconds, showDate } = settings(); + const now = new Date(); + timeEl.textContent = now.toLocaleTimeString([], { + hour12: format === '12h', + hour: format === '12h' ? 'numeric' : '2-digit', + minute: '2-digit', + ...(showSeconds ? { second: '2-digit' } : {}), + }); + dateEl.hidden = !showDate; + if (showDate) { + dateEl.textContent = now.toLocaleDateString([], { + weekday: 'long', month: 'long', day: 'numeric', + }); + } + } + + /** Scale the digits to the tile (CSS alone can't see the tile's box). */ + function fit() { + const { showSeconds, showDate } = settings(); + const chars = (showSeconds ? 8 : 5) + 1; + const size = Math.min((root.clientWidth * 1.7) / chars, root.clientHeight * (showDate ? 0.42 : 0.55)); + timeEl.style.fontSize = Math.max(12, Math.floor(size)) + 'px'; + dateEl.style.fontSize = Math.max(10, Math.floor(size * 0.28)) + 'px'; + } + + return { + start() { + renderFrame(); + tick(); + fit(); + timer = setInterval(tick, 250); + resizeObserver = new ResizeObserver(fit); + resizeObserver.observe(root); + moduleApi.setStatus('ok', 'Ticking'); + }, + stop() { + clearInterval(timer); + timer = null; + resizeObserver?.disconnect(); + resizeObserver = null; + root.innerHTML = ''; + }, + onResize() { + fit(); + }, + }; +} diff --git a/modules/clock/module.json b/modules/clock/module.json new file mode 100644 index 0000000..1f1639b --- /dev/null +++ b/modules/clock/module.json @@ -0,0 +1,23 @@ +{ + "id": "clock", + "name": "Clock", + "version": "1.0.0", + "description": "Big booth clock — the smallest possible ProdDash module", + "client": "client.js", + "style": "style.css", + "minSize": { "w": 2, "h": 1 }, + "defaultSize": { "w": 3, "h": 2 }, + "instanceSchema": { + "format": { + "type": "select", + "label": "Format", + "default": "24h", + "options": [ + { "value": "24h", "label": "24-hour" }, + { "value": "12h", "label": "12-hour" } + ] + }, + "showSeconds": { "type": "boolean", "label": "Show seconds", "default": true }, + "showDate": { "type": "boolean", "label": "Show date", "default": false } + } +} diff --git a/modules/clock/style.css b/modules/clock/style.css new file mode 100644 index 0000000..1ab57ca --- /dev/null +++ b/modules/clock/style.css @@ -0,0 +1,25 @@ +/* Clock module — scoped under its tile via [data-module="clock"], styled + with the shell's CSS variables only. */ + +[data-module="clock"] .clock-wrap { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2%; +} + +[data-module="clock"] .clock-time { + font-weight: 700; + font-variant-numeric: tabular-nums; + letter-spacing: 0.02em; + color: var(--text); + line-height: 1; +} + +[data-module="clock"] .clock-date { + color: var(--muted); + font-weight: 600; +} diff --git a/public/shell.js b/public/shell.js index ada4c88..4797c28 100644 --- a/public/shell.js +++ b/public/shell.js @@ -104,32 +104,20 @@ function menuItem(label, onClick, { sub = '', danger = false } = {}) { /* ── module registry ────────────────────────────────────────────────── */ -/* Filled from GET /api/modules once the module system is up (milestone 2). - Until then — or if the fetch fails — the shell offers a built-in - placeholder tile so the grid itself is usable and testable. */ - -const PLACEHOLDER_MANIFEST = { - id: '__placeholder', - name: 'Placeholder', - description: 'An empty tile for trying out the grid', - defaultSize: { w: 3, h: 2 }, - minSize: { w: 1, h: 1 }, - builtin: true, -}; - -let registry = new Map(); // module id -> manifest +let registry = new Map(); // module id -> manifest from GET /api/modules +/** Refresh the enabled-module list. On failure the last known list is kept + (the server may just be restarting mid-service). Returns true on success. */ async function loadRegistry() { - registry = new Map(); try { const res = await fetch('/api/modules'); if (!res.ok) throw new Error('modules ' + res.status); const body = await res.json(); - for (const m of body.modules || []) registry.set(m.id, m); + registry = new Map((body.modules || []).map((m) => [m.id, m])); + return true; } catch { - /* no module API yet (or server just restarted) — placeholder only */ + return false; } - if (!registry.size) registry.set(PLACEHOLDER_MANIFEST.id, PLACEHOLDER_MANIFEST); } /* ── layout state ───────────────────────────────────────────────────── */ @@ -434,34 +422,167 @@ function clearAllTiles() { /* ── module mounting ────────────────────────────────────────────────── */ -/* Milestone 2 turns this into the real module loader (dynamic import of each - module's client.js plus the moduleApi). For now only the built-in - placeholder renders. */ +const clientModuleCache = new Map(); // module id -> Promise -function mountModule(tile) { +function loadClientModule(man) { + if (!clientModuleCache.has(man.id)) { + clientModuleCache.set(man.id, import(`/modules/${man.id}/${man.client || 'client.js'}`)); + } + return clientModuleCache.get(man.id); +} + +/** Inject a module's stylesheet once (shared by every instance of it). */ +function ensureModuleStyle(man) { + if (!man.style) return; + const id = 'module-style-' + man.id; + if (document.getElementById(id)) return; + const link = document.createElement('link'); + link.id = id; + link.rel = 'stylesheet'; + link.href = `/modules/${man.id}/${man.style}`; + document.head.appendChild(link); +} + +function tileMessage(entry, text) { + const msg = document.createElement('div'); + msg.className = 'tile-msg'; + msg.textContent = text; + entry.body.appendChild(msg); +} + +/** + * The API handed to each module instance — the whole surface a module may + * touch outside its root element. See docs/MODULE-GUIDE.md. + */ +function buildModuleApi(tile, man, entry) { + const sseHandles = new Set(); + + const api = { + id: man.id, + instanceId: tile.id, + + /** Admin (server-wide) config, read-only. Password fields never reach the client. */ + config: Object.freeze({ ...(man.config || {}) }), + + /** Per-tile settings: schema defaults overlaid with what this tile saved. */ + get instanceSettings() { + const out = {}; + for (const [key, spec] of Object.entries(man.instanceSchema || {})) { + if (spec && 'default' in spec) out[key] = spec.default; + } + return Object.assign(out, tile.settings || {}); + }, + + /** Persist per-tile settings into the layout (no remount — the module + already knows, it made the change). */ + saveInstanceSettings(patch) { + Object.assign(tile.settings, patch || {}); + saveLayout(); + }, + + /** fetch scoped to this module's server routes. */ + fetch(subPath, opts) { + return fetch('/api/modules/' + man.id + subPath, opts); + }, + + /** + * EventSource scoped the same way, with auto-reconnect: EventSource + * retries transient drops itself but gives up for good when a retry gets + * a completed non-SSE response (a 502 while the upstream is down), so a + * closed stream is recreated on a timer until it works again. + * handlers: { open(e), error(e), message(e), events: { name: fn } } + */ + sse(subPath, handlers = {}) { + const url = '/api/modules/' + man.id + subPath; + let es = null; + let retryTimer = null; + let closed = false; + const connect = () => { + if (closed) return; + try { es?.close(); } catch { /* already closed */ } + es = new EventSource(url); + if (handlers.open) es.onopen = handlers.open; + if (handlers.message) es.onmessage = handlers.message; + for (const [name, fn] of Object.entries(handlers.events || {})) { + es.addEventListener(name, fn); + } + es.onerror = (e) => { + try { handlers.error?.(e); } catch { /* module's problem */ } + if (es.readyState === EventSource.CLOSED) { + clearTimeout(retryTimer); + retryTimer = setTimeout(connect, 3000); + } + }; + }; + connect(); + const handle = { + close() { + closed = true; + clearTimeout(retryTimer); + try { es?.close(); } catch { /* already closed */ } + sseHandles.delete(handle); + }, + }; + sseHandles.add(handle); + return handle; + }, + + /** Drive the tile's status dot: 'ok' | 'connecting' | 'error'. */ + setStatus(state, msg = '') { + const cls = state === 'ok' || state === 'connecting' || state === 'error' ? state : ''; + entry.dot.className = 'dot ' + cls; + entry.dot.title = msg; + }, + + /* shell-internal: safety net so a forgotten stream can't outlive the tile */ + _closeAll() { + for (const handle of [...sseHandles]) handle.close(); + }, + }; + return api; +} + +async function mountModule(tile) { const entry = tileEls.get(tile.id); if (!entry) return; const man = registry.get(tile.module); if (!man) { - entry.body.innerHTML = '
Module “' + tile.module + '” is not installed.
'; + tileMessage(entry, `Module “${tile.module}” is not installed or is disabled. Check Admin.`); entry.dot.className = 'dot error'; + entry.dot.title = 'Module unavailable'; return; } - if (man.builtin) { - const msg = document.createElement('div'); - msg.className = 'tile-msg'; - msg.textContent = 'Placeholder tile — drag me by the header, resize me by the corner.'; - entry.body.appendChild(msg); - entry.dot.className = 'dot ok'; + 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?.(); + entry.instance?.stop?.(); } catch { /* stopping is best-effort */ } - if (entry) entry.instance = null; + try { + entry.api?._closeAll(); + } catch { /* ditto */ } + entry.instance = null; + entry.api = null; } /* ── per-tile settings popover ──────────────────────────────────────── */ @@ -598,7 +719,12 @@ wireDropdown('layout-btn', 'layout-menu', async (menu) => { /* ── boot ───────────────────────────────────────────────────────────── */ async function boot() { - await loadRegistry(); + const ok = await loadRegistry(); + if (!ok) { + toast('ProdDash server unreachable — retrying…', true); + setTimeout(boot, 4000); + return; + } renderLayout(loadLayoutFromStorage()); } diff --git a/server.js b/server.js index 1401577..48eb34e 100644 --- a/server.js +++ b/server.js @@ -22,8 +22,10 @@ const os = require('os'); const ROOT = __dirname; const PUBLIC_DIR = path.join(ROOT, 'public'); const CONFIG_DIR = path.join(ROOT, 'config'); +const MODULES_DIR = path.join(ROOT, 'modules'); +const MODULES_CONFIG_PATH = path.join(CONFIG_DIR, 'modules.json'); -/* ── config ─────────────────────────────────────────────────────────── */ +/* ── config files ───────────────────────────────────────────────────── */ function readJson(file, fallback) { try { @@ -33,9 +35,270 @@ function readJson(file, fallback) { } } +function writeJson(file, value) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + const tmp = file + '.tmp'; + fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n'); + fs.renameSync(tmp, file); // atomic-ish: never leave a half-written config +} + const shellConfig = readJson(path.join(CONFIG_DIR, 'proddash.json'), {}); const PORT = Number(process.env.PORT || shellConfig.port || 24500); +/** Per-module server-wide state: { "": { enabled: bool, config: {…} } } */ +let modulesConfig = readJson(MODULES_CONFIG_PATH, {}); + +/* ── module discovery ───────────────────────────────────────────────── */ + +/** @type {Map} module id -> manifest (with .dir added) */ +const manifests = new Map(); + +function discoverModules() { + manifests.clear(); + let dirs = []; + try { + dirs = fs.readdirSync(MODULES_DIR, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name); + } catch { + return; // no modules/ folder yet — the shell still runs + } + for (const dir of dirs) { + const manifestPath = path.join(MODULES_DIR, dir, 'module.json'); + const man = readJson(manifestPath, null); + if (!man || typeof man !== 'object') { + if (fs.existsSync(manifestPath)) console.warn(`[proddash] ${dir}/module.json is invalid — skipped`); + continue; + } + if (man.id && man.id !== dir) { + console.warn(`[proddash] module "${dir}": manifest id "${man.id}" must match its folder name — skipped`); + continue; + } + man.id = dir; + if (!man.client) { + console.warn(`[proddash] module "${dir}": manifest has no "client" entry — skipped`); + continue; + } + man.dir = path.join(MODULES_DIR, dir); + manifests.set(dir, man); + } +} + +function moduleState(id) { + const s = modulesConfig[id]; + return s && typeof s === 'object' ? s : {}; +} + +function isEnabled(id) { + return moduleState(id).enabled !== false; // newly dropped modules default to enabled +} + +/** Admin config for a module: schema defaults overlaid with stored values. */ +function effectiveConfig(id) { + const man = manifests.get(id); + const out = {}; + for (const [key, spec] of Object.entries(man?.configSchema || {})) { + if (spec && 'default' in spec) out[key] = spec.default; + } + const stored = moduleState(id).config; + if (stored && typeof stored === 'object') Object.assign(out, stored); + return out; +} + +/** Config as sent to browsers: password-typed fields never leave the server. */ +function clientConfig(id) { + const man = manifests.get(id); + const cfg = effectiveConfig(id); + const out = {}; + for (const [key, value] of Object.entries(cfg)) { + if (man?.configSchema?.[key]?.type === 'password') continue; + out[key] = value; + } + return out; +} + +/* ── module server mounting ─────────────────────────────────────────── */ + +/** + * A module's optional server.js exports: + * init({ config, log }) -> handle with stop() (and optionally health()) + * routes({ config, log }) -> { 'GET /state': handler, 'GET /stream': handler, + * 'GET /proxy/*': handler, '* /any/*': handler } + * Routes are mounted under /api/modules//. A trailing "/*" makes a prefix + * route; the matched remainder is passed as req.wildcard (req.search carries + * the query string). init() runs before routes(), so routes can close over + * state init created. + */ +const mounted = new Map(); // id -> { routes: [...], handle, error } + +function makeLog(id) { + return (...args) => console.log(`[${id}]`, ...args); +} + +function parseRouteTable(table) { + const routes = []; + for (const [key, handler] of Object.entries(table || {})) { + if (typeof handler !== 'function') continue; + const sp = key.indexOf(' '); + if (sp < 0) continue; + const method = key.slice(0, sp).toUpperCase(); + let route = key.slice(sp + 1).trim(); + let wildcard = false; + if (route.endsWith('/*')) { + wildcard = true; + route = route.slice(0, -2) || ''; + } + if (!route.startsWith('/')) route = '/' + route; + routes.push({ method, path: route, wildcard, handler }); + } + return routes; +} + +function mountModule(id) { + const man = manifests.get(id); + if (!man) return; + const entry = { routes: [], handle: null, error: '' }; + mounted.set(id, entry); + if (!man.server) return; + const log = makeLog(id); + try { + const serverPath = path.join(man.dir, man.server); + // Re-require fresh config on every (re)mount; the module code itself stays cached. + const mod = require(serverPath); + const config = effectiveConfig(id); + if (typeof mod.init === 'function') { + entry.handle = mod.init({ config, log }) || null; + } + if (typeof mod.routes === 'function') { + entry.routes = parseRouteTable(mod.routes({ config, log })); + } + log('mounted' + (entry.routes.length ? ` (${entry.routes.length} routes)` : '')); + } catch (err) { + entry.error = err instanceof Error ? err.message : String(err); + console.error(`[proddash] module "${id}" failed to mount:`, err); + } +} + +function unmountModule(id) { + const entry = mounted.get(id); + if (!entry) return; + try { + entry.handle?.stop?.(); + } catch (err) { + console.error(`[proddash] module "${id}" failed to stop cleanly:`, err); + } + mounted.delete(id); +} + +/** Re-init a module after its config changed (admin page). */ +function remountModule(id) { + unmountModule(id); + if (manifests.has(id) && isEnabled(id)) mountModule(id); +} + +function mountAllModules() { + for (const id of manifests.keys()) { + if (isEnabled(id)) mountModule(id); + } +} + +/** Health of a mounted module, as reported by its own handle.health(). */ +function moduleHealth(id) { + const entry = mounted.get(id); + if (!entry) return null; + if (entry.error) return { status: 'error', message: entry.error }; + try { + const h = entry.handle?.health?.(); + if (h && typeof h === 'object') return { status: String(h.status || 'ok'), message: String(h.message || '') }; + } catch { /* a bad health() must not break the admin page */ } + return null; +} + +/* ── shell API helpers ──────────────────────────────────────────────── */ + +function sendJson(res, status, body) { + res.writeHead(status, { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-store', + }); + res.end(JSON.stringify(body)); +} + +function readBody(req, limitBytes = 512 * 1024) { + return new Promise((resolve, reject) => { + let data = ''; + let size = 0; + req.on('data', (chunk) => { + size += chunk.length; + if (size > limitBytes) { + reject(new Error('Request body too large.')); + req.destroy(); + return; + } + data += chunk; + }); + req.on('end', () => resolve(data)); + req.on('error', reject); + }); +} + +/** Manifest as sent to the dashboard client (enabled modules only). */ +function clientManifest(id) { + const man = manifests.get(id); + return { + id: man.id, + name: String(man.name || man.id), + version: String(man.version || ''), + description: String(man.description || ''), + client: String(man.client), + style: man.style ? String(man.style) : '', + minSize: man.minSize || { w: 1, h: 1 }, + defaultSize: man.defaultSize || { w: 4, h: 3 }, + instanceSchema: man.instanceSchema || {}, + hasServer: Boolean(man.server), + config: clientConfig(id), + }; +} + +/* ── module API dispatch ────────────────────────────────────────────── */ + +function dispatchModuleApi(req, res, id, subPath, search) { + if (!manifests.has(id)) return sendJson(res, 404, { error: `No module "${id}" is installed.` }); + if (!isEnabled(id)) return sendJson(res, 404, { error: `Module "${id}" is disabled.` }); + const entry = mounted.get(id); + if (!entry) return sendJson(res, 503, { error: `Module "${id}" is not mounted.` }); + if (entry.error) return sendJson(res, 502, { error: `Module "${id}" failed to start: ${entry.error}` }); + + for (const route of entry.routes) { + if (route.method !== '*' && route.method !== req.method) continue; + if (route.wildcard) { + if (subPath === route.path || subPath.startsWith(route.path + '/')) { + req.wildcard = subPath.slice(route.path.length) || '/'; + req.search = search; + try { + route.handler(req, res); + } catch (err) { + console.error(`[${id}] route handler threw:`, err); + if (!res.headersSent) sendJson(res, 500, { error: 'Module error.' }); + else try { res.end(); } catch { /* gone */ } + } + return; + } + } else if (subPath === route.path) { + req.search = search; + try { + route.handler(req, res); + } catch (err) { + console.error(`[${id}] route handler threw:`, err); + if (!res.headersSent) sendJson(res, 500, { error: 'Module error.' }); + else try { res.end(); } catch { /* gone */ } + } + return; + } + } + sendJson(res, 404, { error: `Module "${id}" has no route for ${req.method} ${subPath}` }); +} + /* ── static files ───────────────────────────────────────────────────── */ const MIME = { @@ -90,22 +353,61 @@ function serveFile(res, baseDir, urlPath) { /* ── request routing ────────────────────────────────────────────────── */ +function handleRequest(req, res) { + let urlPath; + let search = ''; + try { + const u = new URL(req.url, 'http://proddash.invalid'); + urlPath = u.pathname; + search = u.search || ''; + } catch { + res.writeHead(400, { 'Content-Type': 'text/plain' }); + return res.end('Bad request'); + } + + /* — shell API — */ + if (urlPath === '/api/modules' && req.method === 'GET') { + const list = [...manifests.keys()].filter(isEnabled).map(clientManifest); + return sendJson(res, 200, { modules: list }); + } + + /* — module APIs: /api/modules//… (any method; modules decide) — */ + const apiMatch = urlPath.match(/^\/api\/modules\/([^/]+)(\/.*)?$/); + if (apiMatch) { + const id = decodeURIComponent(apiMatch[1]); + const subPath = apiMatch[2] || '/'; + return dispatchModuleApi(req, res, id, subPath, search); + } + + if (urlPath.startsWith('/api/')) { + return sendJson(res, 404, { error: 'Unknown endpoint.' }); + } + + /* — static — */ + if (req.method !== 'GET' && req.method !== 'HEAD') { + res.writeHead(405, { Allow: 'GET, HEAD' }); + return res.end('Method not allowed'); + } + + /* module client assets: /modules//… */ + const assetMatch = urlPath.match(/^\/modules\/([^/]+)(\/.*)?$/); + if (assetMatch) { + const id = decodeURIComponent(assetMatch[1]); + const man = manifests.get(id); + if (!man || !isEnabled(id)) { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + return res.end('Not found'); + } + return serveFile(res, man.dir, assetMatch[2] || '/'); + } + + serveFile(res, PUBLIC_DIR, urlPath); +} + const server = http.createServer((req, res) => { // Everything is guarded: one throw must never take the dashboard down mid-service. try { - let urlPath; - try { - urlPath = new URL(req.url, 'http://proddash.invalid').pathname; - } catch { - res.writeHead(400, { 'Content-Type': 'text/plain' }); - return res.end('Bad request'); - } - - if (req.method !== 'GET' && req.method !== 'HEAD') { - res.writeHead(405, { Allow: 'GET, HEAD' }); - return res.end('Method not allowed'); - } - serveFile(res, PUBLIC_DIR, urlPath); + handleRequest(req, res); } catch (err) { console.error('[proddash] request error:', err); try { @@ -118,6 +420,11 @@ const server = http.createServer((req, res) => { // Never time out long-lived SSE connections. server.requestTimeout = 0; +/* ── boot ───────────────────────────────────────────────────────────── */ + +discoverModules(); +mountAllModules(); + server.listen(PORT, '0.0.0.0', () => { console.log('ProdDash running'); console.log(' Local : http://localhost:' + PORT); @@ -128,4 +435,16 @@ server.listen(PORT, '0.0.0.0', () => { } } } + const ids = [...manifests.keys()]; + console.log(ids.length + ? ' Modules : ' + ids.map((id) => id + (isEnabled(id) ? '' : ' (disabled)')).join(', ') + : ' Modules : none installed'); }); + +for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, () => { + for (const id of [...mounted.keys()]) unmountModule(id); + server.close(() => process.exit(0)); + setTimeout(() => process.exit(0), 1500).unref(); + }); +} From a549bb065ad0015de6c16856816788828a8d386a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 15:03:12 +0000 Subject: [PATCH 3/9] prodcom-transcript module: ProdCom proxy + live transcript tile Milestone 3. Server part proxies /prodcom/* (SSE passthrough included) to the admin-configured ProdCom URL, adding the Bearer key server-side so it never reaches browsers; reports proxy health for the admin page. Client ports the prodcom-listener UI into the module contract: history load, live SSE with in-place updates of in-progress speech, channel colors, channel visibility toggles + group presets (persisted per tile), hard channel filter / text size / timestamps as instance settings, auto-scroll with a jump-to-latest pill. tools/prodcom-mock.js simulates ProdCom (word-by-word in-progress speech) for testing; verified against it, including kill-and-restart recovery. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH --- modules/prodcom-transcript/client.js | 403 +++++++++++++++++++++++++ modules/prodcom-transcript/module.json | 40 +++ modules/prodcom-transcript/server.js | 93 ++++++ modules/prodcom-transcript/style.css | 194 ++++++++++++ tools/prodcom-mock.js | 131 ++++++++ 5 files changed, 861 insertions(+) create mode 100644 modules/prodcom-transcript/client.js create mode 100644 modules/prodcom-transcript/module.json create mode 100644 modules/prodcom-transcript/server.js create mode 100644 modules/prodcom-transcript/style.css create mode 100644 tools/prodcom-mock.js diff --git a/modules/prodcom-transcript/client.js b/modules/prodcom-transcript/client.js new file mode 100644 index 0000000..8d85ca2 --- /dev/null +++ b/modules/prodcom-transcript/client.js @@ -0,0 +1,403 @@ +/* ProdCom Transcript — client part. + + Ported from prodcom-listener/public/app.js into the ProdDash module + contract: everything lives inside the tile's root, per-tile state + (hidden channels, timestamps, text size) persists in instance settings, + and all network traffic goes through moduleApi (the module's server + proxy at /prodcom/*). */ + +export default function create({ root, moduleApi }) { + /* ── per-instance state ─────────────────────────────────────────── */ + + const channels = new Map(); // channelId -> {name, color} + const groups = new Map(); // groupId -> {name, channelIds} + const entryEls = new Map(); // entryId -> element + let pinnedToBottom = true; + let hiddenChannels = new Set(moduleApi.instanceSettings.hiddenChannels || []); + let stream = null; // moduleApi.sse handle + let bootTimer = null; + let stopped = false; + + /* ── DOM skeleton ───────────────────────────────────────────────── */ + + root.innerHTML = ` +
+
+
+ + +
+ + +
+
+
Waiting for transcript…
+
+
+ +
`; + + const wrap = root.querySelector('.pt-wrap'); + const streamEl = root.querySelector('.pt-stream'); + const entriesEl = root.querySelector('.pt-entries'); + const emptyEl = root.querySelector('.pt-empty'); + const jumpBtn = root.querySelector('.pt-jump'); + const channelBtn = root.querySelector('.pt-channels-btn'); + const channelMenu = root.querySelector('.pt-menu'); + + function applyInstanceSettings() { + const s = moduleApi.instanceSettings; + const size = Math.min(48, Math.max(10, Number(s.textSize) || 15)); + wrap.style.setProperty('--pt-size', size + 'px'); + wrap.classList.toggle('show-times', Boolean(s.showTimes)); + } + applyInstanceSettings(); + + /* ── channel filter (instance setting) ──────────────────────────── */ + + function channelFilter() { + return String(moduleApi.instanceSettings.channel || '').trim().toLowerCase(); + } + + /** Entry hidden by the hard per-tile filter (settings.channel)? */ + function filteredOut(channelId, channelName) { + const want = channelFilter(); + if (!want) return false; + const known = channels.get(channelId); + const name = ((known && known.name) || channelName || '').toLowerCase(); + return name !== want && String(channelId).toLowerCase() !== want; + } + + /* ── channel visibility toggles ─────────────────────────────────── */ + + function persistHidden() { + moduleApi.saveInstanceSettings({ hiddenChannels: [...hiddenChannels] }); + } + + function applyFilter(el) { + const hidden = hiddenChannels.has(el.dataset.channelId) + || filteredOut(el.dataset.channelId, el.dataset.channelName); + el.classList.toggle('hidden-by-filter', hidden); + } + + function toggleChannel(id) { + if (hiddenChannels.has(id)) hiddenChannels.delete(id); + else hiddenChannels.add(id); + persistHidden(); + rebuildChannelMenu(); + entryEls.forEach((el) => applyFilter(el)); + if (pinnedToBottom) scrollToBottom(); + } + + /** Show exactly this group's channels, hide the rest */ + function applyGroup(g) { + hiddenChannels = new Set([...channels.keys()].filter((cid) => !g.channelIds.includes(cid))); + persistHidden(); + rebuildChannelMenu(); + entryEls.forEach((el) => applyFilter(el)); + if (pinnedToBottom) scrollToBottom(); + } + + function groupIsActive(g) { + const members = g.channelIds.filter((cid) => channels.has(cid)); + if (!members.length) return false; + return [...channels.keys()].every((cid) => + members.includes(cid) ? !hiddenChannels.has(cid) : hiddenChannels.has(cid) + ); + } + + function rebuildChannelMenu() { + channelMenu.innerHTML = ''; + + if (groups.size) { + for (const g of groups.values()) { + const on = groupIsActive(g); + const item = document.createElement('button'); + item.className = 'pt-channel-item pt-group-item' + (on ? ' on' : ''); + item.setAttribute('role', 'menuitemradio'); + item.setAttribute('aria-checked', String(on)); + const check = document.createElement('span'); + check.className = 'check'; + check.textContent = '✓'; + const name = document.createElement('span'); + name.className = 'name'; + name.textContent = g.name; + item.append(check, name); + item.addEventListener('click', (e) => { + e.stopPropagation(); + applyGroup(g); + }); + channelMenu.appendChild(item); + } + const divider = document.createElement('div'); + divider.className = 'pt-menu-divider'; + channelMenu.appendChild(divider); + } + + let visibleCount = 0; + for (const [id, info] of channels) { + const on = !hiddenChannels.has(id); + if (on) visibleCount++; + const item = document.createElement('button'); + item.className = 'pt-channel-item' + (on ? ' on' : ''); + item.setAttribute('role', 'menuitemcheckbox'); + item.setAttribute('aria-checked', String(on)); + const check = document.createElement('span'); + check.className = 'check'; + check.textContent = '✓'; + const name = document.createElement('span'); + name.className = 'name'; + name.textContent = info.name; + name.style.color = info.color; + item.append(check, name); + item.addEventListener('click', (e) => { + // keep the menu open: the rebuild detaches this item, which would + // make the outside-click handler think we clicked outside + e.stopPropagation(); + toggleChannel(id); + }); + channelMenu.appendChild(item); + } + channelBtn.textContent = visibleCount === channels.size + ? 'Channels ▾' + : `Channels (${visibleCount}/${channels.size}) ▾`; + } + + channelBtn.addEventListener('click', (e) => { + e.stopPropagation(); + channelMenu.hidden = !channelMenu.hidden; + }); + const onDocClick = (e) => { + if (!channelMenu.hidden && !e.target.closest('.pt-dd')) channelMenu.hidden = true; + }; + document.addEventListener('click', onDocClick); + + /* ── clear (local view only) ────────────────────────────────────── */ + + root.querySelector('.pt-clear').addEventListener('click', () => { + entryEls.forEach((el) => el.remove()); + entryEls.clear(); + emptyEl.hidden = false; + }); + + /* ── scrolling ──────────────────────────────────────────────────── */ + + streamEl.addEventListener('scroll', () => { + const nearBottom = streamEl.scrollHeight - streamEl.scrollTop - streamEl.clientHeight < 60; + pinnedToBottom = nearBottom; + if (nearBottom) jumpBtn.hidden = true; + }); + + jumpBtn.addEventListener('click', () => { + pinnedToBottom = true; + jumpBtn.hidden = true; + scrollToBottom(); + }); + + function scrollToBottom() { + streamEl.scrollTop = streamEl.scrollHeight; + } + + function afterInsert(el) { + if (el.classList.contains('hidden-by-filter')) return; + if (pinnedToBottom) scrollToBottom(); + else jumpBtn.hidden = false; + } + + /* ── entry rendering ────────────────────────────────────────────── */ + + function channelInfo(entry) { + const known = channels.get(entry.channelId); + return { + name: (known && known.name) || entry.channelName || 'Unknown', + color: (known && known.color) || 'var(--muted)', + }; + } + + function fmtTime(iso) { + const d = iso ? new Date(iso) : new Date(); + return isNaN(d) ? '' : d.toLocaleTimeString([], { hour12: false }); + } + + function upsertEntry(entry) { + if (!entry || typeof entry.text !== 'string') return; + const id = entry.id || entry.channelId + '|' + entry.date; + + let el = entryEls.get(id); + if (!el) { + emptyEl.hidden = true; + el = document.createElement('div'); + el.className = 'pt-entry'; + el.dataset.channelId = entry.channelId || ''; + el.dataset.channelName = entry.channelName || ''; + const info = channelInfo(entry); + el.style.setProperty('--ch', info.color); + + const time = document.createElement('span'); + time.className = 'time'; + time.textContent = fmtTime(entry.date); + + const avatar = document.createElement('span'); + avatar.className = 'avatar'; + avatar.style.color = info.color; + avatar.innerHTML = + ''; + + const ch = document.createElement('span'); + ch.className = 'channel'; + ch.textContent = info.name; + ch.style.color = info.color; + + const text = document.createElement('span'); + text.className = 'text'; + + const body = document.createElement('div'); + body.className = 'body'; + body.append(ch, text); + + el.append(time, avatar, body); + entryEls.set(id, el); + applyFilter(el); + entriesEl.appendChild(el); + updateEntryEl(el, entry); + afterInsert(el); + } else { + updateEntryEl(el, entry); + if (pinnedToBottom && !el.classList.contains('hidden-by-filter')) scrollToBottom(); + } + } + + function updateEntryEl(el, entry) { + el.querySelector('.text').textContent = entry.translatedText || entry.text; + el.classList.toggle('in-progress', entry.inProgress === true); + } + + /* ── SSE event parsing ──────────────────────────────────────────── */ + + /* The stream sends JSON objects for new/updated/completed transcript + entries. Be liberal about the envelope shape. */ + function extractEntries(obj) { + if (!obj || typeof obj !== 'object') return []; + if (Array.isArray(obj)) return obj.flatMap(extractEntries); + if (typeof obj.text === 'string' && (obj.id || obj.channelId)) return [obj]; + for (const key of ['data', 'payload', 'entry', 'entries', 'transcript']) { + if (obj[key] && typeof obj[key] === 'object') { + const found = extractEntries(obj[key]); + if (found.length) return found; + } + } + return []; + } + + function handleEventData(raw) { + let obj; + try { obj = JSON.parse(raw); } catch { return; } + const kind = String(obj.type || obj.event || '').toLowerCase(); + if (kind.includes('clear')) { + entryEls.forEach((el) => el.remove()); + entryEls.clear(); + emptyEl.hidden = false; + return; + } + extractEntries(obj).forEach(upsertEntry); + } + + /* ── data loading (all via the module's proxy) ──────────────────── */ + + async function loadChannels() { + const res = await moduleApi.fetch('/prodcom/api/v1/channels'); + if (!res.ok) throw new Error('channels ' + res.status); + const body = await res.json(); + channels.clear(); + for (const ch of body.data || []) { + channels.set(ch.id, { name: ch.name, color: ch.color || '#8b98a5' }); + } + rebuildChannelMenu(); + } + + async function loadGroups() { + const res = await moduleApi.fetch('/prodcom/api/v1/groups'); + if (!res.ok) throw new Error('groups ' + res.status); + const body = await res.json(); + groups.clear(); + for (const g of body.data || []) { + groups.set(g.id, { name: g.name, channelIds: g.channelIds || [] }); + } + rebuildChannelMenu(); + } + + async function loadHistory() { + const res = await moduleApi.fetch('/prodcom/api/v1/transcript?limit=100'); + if (!res.ok) throw new Error('transcript ' + res.status); + const body = await res.json(); + const entries = (body.data || []).slice(); + entries.sort((a, b) => new Date(a.date) - new Date(b.date)); + entries.forEach(upsertEntry); + } + + /* ── live stream ────────────────────────────────────────────────── */ + + const SSE_EVENT_NAMES = [ + 'transcript', 'transcript.new', 'transcript.updated', 'transcript.update', + 'transcript.completed', 'transcript.complete', 'entry', 'new', 'update', + 'updated', 'complete', 'completed', + ]; + + function connect() { + const onEvent = (ev) => handleEventData(ev.data); + const events = {}; + for (const name of SSE_EVENT_NAMES) events[name] = onEvent; + + stream = moduleApi.sse('/prodcom/api/v1/transcript/stream', { + open() { + moduleApi.setStatus('ok', 'Live'); + // refresh channels/groups and backfill entries missed while + // disconnected (upsertEntry dedupes by id, so this is harmless) + loadChannels().catch(() => {}); + loadGroups().catch(() => {}); + loadHistory().catch(() => {}); + }, + error() { + moduleApi.setStatus('error', 'ProdCom unreachable — reconnecting…'); + }, + message: onEvent, + events, + }); + } + + /* ── lifecycle ──────────────────────────────────────────────────── */ + + async function boot() { + if (stopped) return; + moduleApi.setStatus('connecting', 'Connecting to ProdCom…'); + try { + await loadChannels(); + await loadGroups().catch(() => {}); // groups are optional + await loadHistory(); + } catch { + moduleApi.setStatus('error', 'ProdCom unreachable — retrying…'); + bootTimer = setTimeout(boot, 5000); + return; + } + scrollToBottom(); + connect(); + } + + return { + start() { + boot(); + }, + stop() { + stopped = true; + clearTimeout(bootTimer); + stream?.close(); + document.removeEventListener('click', onDocClick); + root.innerHTML = ''; + }, + onResize() { + if (pinnedToBottom) scrollToBottom(); + }, + }; +} diff --git a/modules/prodcom-transcript/module.json b/modules/prodcom-transcript/module.json new file mode 100644 index 0000000..8e05663 --- /dev/null +++ b/modules/prodcom-transcript/module.json @@ -0,0 +1,40 @@ +{ + "id": "prodcom-transcript", + "name": "ProdCom Transcript", + "version": "1.0.0", + "description": "Live ProdCom transcript stream", + "client": "client.js", + "server": "server.js", + "style": "style.css", + "minSize": { "w": 3, "h": 2 }, + "defaultSize": { "w": 4, "h": 4 }, + "configSchema": { + "prodcomUrl": { + "type": "string", + "label": "ProdCom URL", + "default": "http://10.3.11.152:24480" + }, + "apiKey": { + "type": "password", + "label": "API key (only if PSK auth is enabled in ProdCom)", + "default": "" + } + }, + "instanceSchema": { + "channel": { + "type": "string", + "label": "Channel filter (channel name; empty = all)", + "default": "" + }, + "textSize": { + "type": "number", + "label": "Text size (px)", + "default": 15 + }, + "showTimes": { + "type": "boolean", + "label": "Show timestamps", + "default": false + } + } +} diff --git a/modules/prodcom-transcript/server.js b/modules/prodcom-transcript/server.js new file mode 100644 index 0000000..13fa59f --- /dev/null +++ b/modules/prodcom-transcript/server.js @@ -0,0 +1,93 @@ +'use strict'; + +/** + * ProdCom Transcript — server part. + * + * One wildcard route that proxies /prodcom/* (including the SSE transcript + * stream) to the ProdCom API configured in the admin page, so browsers never + * talk to ProdCom directly (no CORS, and the API key never reaches clients). + * Ported from prodcom-listener/server.js. + */ + +const http = require('http'); + +// Hop-by-hop headers that must not be forwarded +const HOP_HEADERS = new Set([ + 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', + 'te', 'trailer', 'transfer-encoding', 'upgrade', +]); + +/** Health shown in the admin page: outcome of the most recent proxied request. */ +let health = { status: 'connecting', message: 'No traffic yet' }; + +function proxy(req, res, target, apiKey) { + const upstreamPath = (req.wildcard || '/') + (req.search || ''); + const headers = {}; + for (const [k, v] of Object.entries(req.headers)) { + if (!HOP_HEADERS.has(k.toLowerCase())) headers[k] = v; + } + headers.host = target.host; + delete headers['accept-encoding']; // keep SSE unbuffered and simple + if (apiKey) headers.authorization = 'Bearer ' + apiKey; + + const preq = http.request( + { + hostname: target.hostname, + port: target.port || 80, + path: upstreamPath, + method: req.method, + headers, + }, + (pres) => { + health = { status: 'ok', message: `ProdCom reachable (last: HTTP ${pres.statusCode})` }; + const outHeaders = {}; + for (const [k, v] of Object.entries(pres.headers)) { + if (!HOP_HEADERS.has(k.toLowerCase())) outHeaders[k] = v; + } + res.writeHead(pres.statusCode, outHeaders); + // Flush immediately — SSE streams may send no bytes for a while, and + // the browser needs the headers to consider the stream open. + res.flushHeaders(); + pres.pipe(res); + // If ProdCom dies mid-stream, pipe() unpipes on error without ending + // the browser's response, leaving SSE clients hanging on a dead + // stream. Destroy it so they notice immediately and reconnect. + pres.on('close', () => { + if (!res.writableEnded) res.destroy(); + }); + } + ); + + preq.on('error', (e) => { + health = { status: 'error', message: 'ProdCom unreachable: ' + (e.code || String(e)) }; + if (!res.headersSent) { + res.writeHead(502, { 'Content-Type': 'application/json' }); + } + res.end(JSON.stringify({ error: 'ProdCom unreachable', detail: e.code || String(e) })); + }); + + // Tear down the upstream connection when the browser disconnects + res.on('close', () => preq.destroy()); + req.pipe(preq); +} + +module.exports = { + init({ config }) { + health = config.prodcomUrl + ? { status: 'connecting', message: 'No traffic yet' } + : { status: 'error', message: 'No ProdCom URL configured' }; + return { + stop() { /* nothing persistent to tear down — streams die with their sockets */ }, + health: () => health, + }; + }, + + routes({ config, log }) { + // An invalid URL throws here, which surfaces as a mount error in /admin. + const target = new URL(config.prodcomUrl || 'http://10.3.11.152:24480'); + log(`proxying /prodcom/* → ${target.origin}`); + return { + '* /prodcom/*': (req, res) => proxy(req, res, target, config.apiKey || ''), + }; + }, +}; diff --git a/modules/prodcom-transcript/style.css b/modules/prodcom-transcript/style.css new file mode 100644 index 0000000..93dfd9e --- /dev/null +++ b/modules/prodcom-transcript/style.css @@ -0,0 +1,194 @@ +/* ProdCom Transcript module — scoped to its tiles, shell variables only. + Entry styling carried over from prodcom-listener/public/style.css. */ + +[data-module="prodcom-transcript"] .pt-wrap { + --pt-size: 15px; + position: absolute; + inset: 0; + display: flex; + flex-direction: column; +} + +/* ── toolbar ── */ + +[data-module="prodcom-transcript"] .pt-bar { + flex: none; + display: flex; + align-items: center; + gap: 6px; + padding: 5px 8px; + border-bottom: 1px solid var(--border); +} + +[data-module="prodcom-transcript"] .pt-spacer { flex: 1; } + +[data-module="prodcom-transcript"] .pt-btn { + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + padding: 3px 8px; + font-size: 11px; + font-family: inherit; + cursor: pointer; + white-space: nowrap; +} +[data-module="prodcom-transcript"] .pt-btn:hover { border-color: var(--muted); } + +[data-module="prodcom-transcript"] .pt-dd { position: relative; } + +[data-module="prodcom-transcript"] .pt-menu { + position: absolute; + top: calc(100% + 5px); + left: 0; + z-index: 30; + min-width: 170px; + max-height: 45vh; + overflow-y: auto; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + padding: 5px; + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55); +} +[data-module="prodcom-transcript"] .pt-menu[hidden] { display: none; } + +[data-module="prodcom-transcript"] .pt-channel-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 9px; + background: none; + border: none; + border-radius: 6px; + color: var(--text); + font-size: 12px; + font-family: inherit; + text-align: left; + cursor: pointer; + white-space: nowrap; +} +[data-module="prodcom-transcript"] .pt-channel-item:hover { background: var(--border); } + +[data-module="prodcom-transcript"] .pt-channel-item .check { + width: 14px; + flex: none; + color: var(--accent); + font-weight: 700; + visibility: hidden; +} +[data-module="prodcom-transcript"] .pt-channel-item.on .check { visibility: visible; } +[data-module="prodcom-transcript"] .pt-channel-item .name { font-weight: 600; } + +[data-module="prodcom-transcript"] .pt-menu-divider { + height: 1px; + background: var(--border); + margin: 5px 4px; +} + +/* ── stream ── */ + +[data-module="prodcom-transcript"] .pt-stream { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 8px 10px 12px; + overscroll-behavior: contain; +} + +[data-module="prodcom-transcript"] .pt-empty { + color: var(--muted); + font-size: 13px; + text-align: center; + padding-top: 14%; +} +[data-module="prodcom-transcript"] .pt-empty[hidden] { display: none; } + +[data-module="prodcom-transcript"] .pt-entry { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 5px 6px; + border-radius: 8px; + font-size: var(--pt-size); + line-height: 1.45; + animation: pt-fade-in 0.25s ease-out; +} +[data-module="prodcom-transcript"] .pt-entry:hover { background: var(--bg); } + +@keyframes pt-fade-in { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: none; } +} + +[data-module="prodcom-transcript"] .pt-entry .time { + display: none; + color: var(--muted); + font-size: 0.72em; + font-variant-numeric: tabular-nums; + flex: none; + padding-top: 3px; +} +[data-module="prodcom-transcript"] .show-times .pt-entry .time { display: block; } + +[data-module="prodcom-transcript"] .pt-entry .avatar { + flex: none; + width: calc(var(--pt-size) * 1.9); + height: calc(var(--pt-size) * 1.9); + border-radius: 26%; + display: flex; + align-items: center; + justify-content: center; + /* ~17% alpha tint of the channel color */ + background: color-mix(in srgb, var(--ch, var(--muted)) 17%, transparent); +} +[data-module="prodcom-transcript"] .pt-entry .avatar svg { width: 66%; height: 66%; display: block; } + +[data-module="prodcom-transcript"] .pt-entry .body { flex: 1; min-width: 0; } + +[data-module="prodcom-transcript"] .pt-entry .channel { + display: block; + font-size: 0.82em; + font-weight: 700; + letter-spacing: 0.01em; + line-height: 1; + white-space: nowrap; + margin-bottom: 4px; +} + +[data-module="prodcom-transcript"] .pt-entry .text { + display: block; + overflow-wrap: break-word; +} + +[data-module="prodcom-transcript"] .pt-entry.in-progress .text { color: var(--muted); } +[data-module="prodcom-transcript"] .pt-entry.in-progress .text::after { + content: "▍"; + color: var(--ch, var(--accent)); + animation: pt-blink 1s steps(2) infinite; +} +@keyframes pt-blink { 50% { opacity: 0; } } + +[data-module="prodcom-transcript"] .pt-entry.hidden-by-filter { display: none; } + +/* ── jump-to-latest ── */ + +[data-module="prodcom-transcript"] .pt-jump { + position: absolute; + bottom: 12px; + left: 50%; + transform: translateX(-50%); + z-index: 6; + background: var(--accent); + color: var(--bg); + font-weight: 600; + font-size: 12px; + font-family: inherit; + border: none; + border-radius: 999px; + padding: 7px 14px; + cursor: pointer; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5); +} +[data-module="prodcom-transcript"] .pt-jump[hidden] { display: none; } diff --git a/tools/prodcom-mock.js b/tools/prodcom-mock.js new file mode 100644 index 0000000..04df8b8 --- /dev/null +++ b/tools/prodcom-mock.js @@ -0,0 +1,131 @@ +/** + * Mock ProdCom API for verifying the prodcom-transcript module without a + * live ProdCom. Serves the endpoints the module uses — channels, groups, + * transcript history and the SSE stream — and simulates live speech: + * entries appear in progress, grow word by word (same id, in-place update), + * then complete. + * + * node tools/prodcom-mock.js [port] (default 24480) + * + * Point the module's admin config at http://127.0.0.1:. Kill and + * restart this process while tiles are open to watch them degrade and + * recover on their own. + */ +'use strict'; + +const http = require('http'); + +const PORT = Number.parseInt(process.argv[2] || '24480', 10); +const STEP_MS = Number.parseInt(process.env.PRODCOM_STEP_MS || '700', 10); + +const CHANNELS = [ + { id: 'ch-stage', name: 'Stage', color: '#e0645c' }, + { id: 'ch-foh', name: 'FOH', color: '#5c9de0' }, + { id: 'ch-video', name: 'Video', color: '#5cd08c' }, + { id: 'ch-light', name: 'Lighting', color: '#e0b45c' }, +]; + +const GROUPS = [ + { id: 'g-band', name: 'Band', channelIds: ['ch-stage', 'ch-foh'] }, + { id: 'g-tech', name: 'Tech', channelIds: ['ch-video', 'ch-light'] }, +]; + +const PHRASES = [ + 'Standby for the walk-in loop.', + 'Vocals are hot on channel two, trim it down a touch please.', + 'Camera three, give me a slow push on the worship leader.', + 'House to half, stage wash up for the next song.', + 'That was the last chorus, transition cue is next.', + 'Confidence monitor is frozen, refreshing it now.', + 'Great job everyone, that transition was clean.', + 'Pastor is walking up in thirty seconds.', +]; + +let history = []; // completed + in-progress entries, oldest first +// Unique across mock restarts, so a reconnecting client's dedupe-by-id +// treats post-restart entries as new (like real ProdCom ids would be). +const RUN = Date.now().toString(36); +let nextId = 1; +const streams = new Set(); + +/** The one in-progress utterance, or null. */ +let speaking = null; // { entry, words, at } + +function broadcast(entry) { + const frame = 'data: ' + JSON.stringify({ type: entry.inProgress ? 'transcript.update' : 'transcript.completed', entry }) + '\n\n'; + for (const res of streams) { + try { res.write(frame); } catch { streams.delete(res); } + } +} + +function stepSimulation() { + if (!speaking) { + const channel = CHANNELS[Math.floor(Math.random() * CHANNELS.length)]; + const words = PHRASES[Math.floor(Math.random() * PHRASES.length)].split(' '); + const entry = { + id: 'e' + RUN + '-' + nextId++, + channelId: channel.id, + channelName: channel.name, + text: words[0], + date: new Date().toISOString(), + inProgress: true, + }; + speaking = { entry, words, at: 1 }; + history.push(entry); + if (history.length > 200) history.shift(); + broadcast(entry); + return; + } + const { entry, words } = speaking; + if (speaking.at < words.length) { + entry.text = words.slice(0, ++speaking.at).join(' '); + broadcast(entry); + } else { + entry.inProgress = false; + broadcast(entry); + console.log(`[prodcom-mock] ${entry.channelName}: ${entry.text}`); + speaking = null; + } +} + +setInterval(stepSimulation, STEP_MS); + +function sendJson(res, body) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +const server = http.createServer((req, res) => { + const url = new URL(req.url, `http://x.invalid`); + const p = url.pathname; + + if (p === '/api/v1/channels') return sendJson(res, { data: CHANNELS }); + if (p === '/api/v1/groups') return sendJson(res, { data: GROUPS }); + if (p === '/api/v1/transcript') { + const limit = Number.parseInt(url.searchParams.get('limit') || '100', 10) || 100; + return sendJson(res, { data: history.slice(-limit) }); + } + if (p === '/api/v1/transcript/stream') { + res.writeHead(200, { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + }); + res.write('retry: 3000\n\n'); + streams.add(res); + console.log(`[prodcom-mock] stream client connected (${streams.size})`); + res.on('close', () => { + streams.delete(res); + console.log(`[prodcom-mock] stream client left (${streams.size})`); + }); + return; + } + + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'not found' })); +}); + +server.requestTimeout = 0; +server.listen(PORT, '127.0.0.1', () => { + console.log(`[prodcom-mock] ProdCom mock API on http://127.0.0.1:${PORT} (step ${STEP_MS}ms)`); +}); From 9298b4d142b7b2df3ebb2c1b686abf0050fbe0b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 15:09:46 +0000 Subject: [PATCH 4/9] propresenter-now-next module: Now/Next tile fed by propresenter-core Milestone 4. propresenter-core copied in unchanged (single home for every ProPresenter 7/20 quirk). Server part owns the one polling connection and exposes GET /state plus GET /stream (SSE, change-detected, heartbeated); health reported for the admin page. Client ports the wall display's rendering: fill-the-box title fit that never breaks words, tidied titles, slide meta + progress bar, NEXT list with hidden-row '+ N more' overflow, playlist name and live chip, all scaled to the tile via a JS-set --unit. Verified against pp-mock.js including the arrangement-repeat case (the counter follows core's honest-live-index rule, never X > Y) and kill-and-restart recovery. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH --- modules/propresenter-now-next/client.js | 474 ++++++++ modules/propresenter-now-next/module.json | 33 + .../propresenter-core/index.js | 1029 +++++++++++++++++ .../propresenter-core/package.json | 8 + modules/propresenter-now-next/server.js | 132 +++ modules/propresenter-now-next/style.css | 297 +++++ 6 files changed, 1973 insertions(+) create mode 100644 modules/propresenter-now-next/client.js create mode 100644 modules/propresenter-now-next/module.json create mode 100644 modules/propresenter-now-next/propresenter-core/index.js create mode 100644 modules/propresenter-now-next/propresenter-core/package.json create mode 100644 modules/propresenter-now-next/server.js create mode 100644 modules/propresenter-now-next/style.css diff --git a/modules/propresenter-now-next/client.js b/modules/propresenter-now-next/client.js new file mode 100644 index 0000000..624cca9 --- /dev/null +++ b/modules/propresenter-now-next/client.js @@ -0,0 +1,474 @@ +/* ProPresenter Now / Next — client part. + + Ported from band-lineup-display/propresenter-app/public/app.js into the + ProdDash module contract: the server part does all the ProPresenter + talking and streams the view-model here over SSE. Connection setup lives + in /admin; the tile only renders. */ + +export default function create({ root, moduleApi }) { + let state = null; + let feedOffline = false; + let stream = null; + let resizeObserver = null; + + /* ── DOM skeleton ───────────────────────────────────────────────── */ + + root.innerHTML = ` +
+
+
+
Connecting…
+
+
+
Now
+
Connecting…
+
+ +
+
+ +
+ +
+
`; + + const screenEl = root.querySelector('.nn-screen'); + const topRow = root.querySelector('.nn-top'); + const playlistNameEl = root.querySelector('.nn-playlist'); + const liveEl = root.querySelector('.nn-live'); + const liveTextEl = root.querySelector('.nn-live-text'); + const nowEl = root.querySelector('.nn-now'); + const nowTitleBox = root.querySelector('.nn-title-box'); + let nowTitleEl = root.querySelector('.nn-title'); + const nowMetaEl = root.querySelector('.nn-meta'); + const progressEl = root.querySelector('.nn-progress'); + const progressFillEl = root.querySelector('.nn-progress-fill'); + const nextLabelEl = root.querySelector('.nn-next-label'); + const nextListEl = root.querySelector('.nn-next-list'); + const nextMoreEl = root.querySelector('.nn-next-more'); + + function prefs() { + return moduleApi.instanceSettings; // showPlaylist / showProgress / cleanTitles + } + + /* ── title tidying (same rule as propresenter-core.cleanItemName) ─ */ + + function cleanItemName(raw) { + const original = String(raw || '').trim(); + let s = original; + s = s.replace(/[[({][^\])}]*[\])}]/g, ' '); // drop [..], (..), {..} tags + s = s.replace(/^\s*\d{6,8}\b[\s\-–—:.]*/, ''); // drop a leading 6–8 digit date code + s = s.replace(/\s[-–—]+\s/g, ' '); // drop " - " style separators + s = s.replace(/^[\s\-–—:.]+|[\s\-–—:.]+$/g, ''); // trim edge dashes/colons/dots/space + s = s.replace(/\s{2,}/g, ' ').trim(); // collapse runs of whitespace + return s || original; // never blank out a title (e.g. a name that was only a tag) + } + + function displayName(raw) { + return prefs().cleanTitles ? cleanItemName(raw) : String(raw || '').trim(); + } + + function itemTypeTag(type) { + switch (type) { + case 'media': return 'Media'; + case 'audio': return 'Audio'; + case 'livevideo': return 'Live'; + case 'placeholder': return 'PCO'; + default: return ''; + } + } + + /* ── rendering ──────────────────────────────────────────────────── */ + + /** The items after the live one, hidden entries dropped. */ + function upcomingItems() { + const items = Array.isArray(state?.items) ? state.items : []; + // Presenting from the playlist → everything after the live item. + // Presenting from the Library (activeIndex < 0) → the whole list is still ahead. + const from = state && state.activeIndex >= 0 ? state.activeIndex + 1 : 0; + const out = []; + for (let i = from; i < items.length; i += 1) { + if (!items[i].isHidden) out.push(items[i]); + } + return out; + } + + /** + * Hand the browser a brand-new title element whenever the text changes — + * on some GPUs a large text element keeps a stale compositor backing + * store, ghosting the previous song under the new one. A fresh node has + * no old backing store. (Carried over from the wall display.) + */ + let lastTitleText = null; + function setNowTitle(text) { + if (text === lastTitleText && nowTitleEl.isConnected) return; + lastTitleText = text; + const fresh = document.createElement('div'); + fresh.className = 'nn-title'; + fresh.textContent = text; + nowTitleEl.replaceWith(fresh); + nowTitleEl = fresh; + } + + /** + * Size the NOW title as large as fits without ever breaking a word: + * with word-breaking forbidden, an over-wide word overflows and the same + * fit test that guards height rejects that size too; binary search lands + * on the biggest size where whole words fit. Hysteresis stops ±1px flap. + */ + function fitNowTitle() { + if (nowTitleEl.classList.contains('is-waiting')) return; + const boxHeight = nowTitleBox.clientHeight; + const boxWidth = nowTitleBox.clientWidth; + const MIN = 11; + + nowTitleEl.style.whiteSpace = 'normal'; + nowTitleEl.style.overflowWrap = 'normal'; + nowTitleEl.style.wordBreak = 'normal'; + nowTitleEl.style.hyphens = 'none'; + + if (boxHeight < MIN || !boxWidth) { + nowTitleEl.style.fontSize = `${MIN}px`; + return; + } + + const fits = (size) => { + nowTitleEl.style.fontSize = `${size}px`; + return nowTitleEl.scrollWidth <= nowTitleBox.clientWidth + 1 + && nowTitleEl.scrollHeight <= nowTitleBox.clientHeight + 1; + }; + + const max = Math.max(MIN, Math.floor(boxHeight)); + let best = MIN; + if (fits(max)) { + best = max; + } else { + let lo = MIN; + let hi = max; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (fits(mid)) { best = mid; lo = mid + 1; } else { hi = mid - 1; } + } + } + const currentSize = parseFloat(nowTitleEl.style.fontSize) || 0; + // Hold the current size only if it still genuinely fits (a plain ±1px + // band can straddle the line-wrap threshold and stick on the + // overflowing side). + if (currentSize && Math.abs(currentSize - best) <= 1 && fits(currentSize)) { + nowTitleEl.style.fontSize = `${currentSize}px`; + return; + } + nowTitleEl.style.fontSize = `${best}px`; + + // Last resort: one word so long it can't fit even at MIN — allow it to + // break rather than overflow the card. + if (best === MIN && nowTitleEl.scrollWidth > nowTitleBox.clientWidth + 1) { + nowTitleEl.style.overflowWrap = 'anywhere'; + nowTitleEl.style.wordBreak = 'break-word'; + } + } + + /** + * Hide the NEXT rows past the bottom of the list and count them into + * "+ N more". Rows are hidden, never removed, so the list's height stays + * fixed and the layout can't ratchet. + */ + function markNextOverflow() { + const rows = Array.from(nextListEl.children); + for (const row of rows) row.style.visibility = ''; + let hidden = 0; + for (let pass = 0; pass < 3; pass += 1) { + const listRect = nextListEl.getBoundingClientRect(); + if (listRect.height <= 0) return; + let changed = 0; + for (const row of rows) { + if (row.style.visibility === 'hidden') continue; + if (row.getBoundingClientRect().bottom > listRect.bottom + 1) { + row.style.visibility = 'hidden'; + changed += 1; + } + } + hidden += changed; + if (hidden > 0) { + nextMoreEl.hidden = false; + nextMoreEl.textContent = `+ ${hidden} more`; + } + if (changed === 0) break; + } + if (hidden === 0) { + nextMoreEl.hidden = true; + nextMoreEl.textContent = ''; + } + } + + function renderLive() { + liveEl.classList.remove('is-live', 'is-down'); + if (feedOffline) { + liveEl.classList.add('is-down'); + liveTextEl.textContent = 'Feed offline'; + moduleApi.setStatus('error', 'ProdDash server unreachable — reconnecting…'); + return; + } + if (!state) { + liveTextEl.textContent = 'Connecting…'; + moduleApi.setStatus('connecting', 'Connecting…'); + return; + } + if (!state.enabled) { + liveTextEl.textContent = 'Not set up'; + moduleApi.setStatus('error', 'No ProPresenter host configured — set one in Admin'); + return; + } + if (state.reachable) { + liveEl.classList.add('is-live'); + liveTextEl.textContent = 'Live'; + moduleApi.setStatus('ok', 'Following ProPresenter'); + return; + } + liveEl.classList.add('is-down'); + liveTextEl.textContent = 'Reconnecting…'; + moduleApi.setStatus('error', 'ProPresenter unreachable — reconnecting…'); + } + + function renderNow() { + const activeItem = state && state.activeIndex >= 0 ? state.items[state.activeIndex] : null; + const rawName = activeItem?.name || state?.activePresentationName || ''; + + for (const stale of nowEl.querySelectorAll('.nn-hint, .nn-stale')) stale.remove(); + + if (feedOffline) { + setNowTitle(rawName ? `Last shown: ${displayName(rawName)}` : 'Feed offline'); + nowTitleEl.classList.add('is-waiting'); + nowTitleEl.style.fontSize = ''; + nowMetaEl.textContent = ''; + progressEl.hidden = true; + const staleEl = document.createElement('div'); + staleEl.className = 'nn-stale'; + staleEl.textContent = 'Not receiving updates from the ProdDash server.'; + nowEl.appendChild(staleEl); + return; + } + + if (!rawName) { + // A status message is not a song title — keep its CSS size instead of + // the fill-the-box fit. + let hint = ''; + if (!state?.enabled) { + setNowTitle('No ProPresenter connection'); + hint = 'Set the ProPresenter host and port in Admin.'; + } else { + // Connected with nothing live is normal (output cleared) — say it + // plainly so nobody goes hunting for a broken connection. + setNowTitle(state?.reachable ? 'Nothing on screen' : 'Reconnecting…'); + hint = state?.reachable + ? 'Connected to ProPresenter — waiting for a slide to go live.' + : 'Trying to reach ProPresenter.'; + } + nowTitleEl.classList.add('is-waiting'); + nowTitleEl.style.fontSize = ''; + nowMetaEl.textContent = ''; + progressEl.hidden = true; + if (hint) { + const hintEl = document.createElement('div'); + hintEl.className = 'nn-hint'; + hintEl.textContent = hint; + nowEl.appendChild(hintEl); + } + return; + } + + setNowTitle(displayName(rawName)); + nowTitleEl.classList.remove('is-waiting'); + fitNowTitle(); + // Re-fit after layout settles: on the first render the box may not have + // its final height, and a too-tall fit would overflow for a frame. + requestAnimationFrame(() => { + if (!nowTitleEl.classList.contains('is-waiting')) fitNowTitle(); + }); + + // An empty NEXT list looks broken unless the tile says why it is empty. + if (!upcomingItems().length) { + const kind = state?.playlistKind; + let why = ''; + if (kind === 'group' || kind === 'none') { + why = 'Presenting from the library — no playlist open.'; + } else if (kind === 'unknown') { + why = 'ProPresenter did not return this playlist’s items.'; + } else if (Array.isArray(state?.items) && state.items.length) { + why = 'Last item in the playlist.'; + } + if (why) { + const note = document.createElement('div'); + note.className = 'nn-hint'; + note.textContent = why; + nowEl.appendChild(note); + } + } + + const total = state.currentSlideTotal || activeItem?.slideCount || 0; + const current = state.currentSlide || 0; + if (total > 0 && current > 0) { + const remaining = Math.max(0, total - current); + nowMetaEl.textContent = `Slide ${current} / ${total} · ${remaining} left`; + } else if (total > 0) { + nowMetaEl.textContent = `${total} slides`; + } else if (current > 0) { + nowMetaEl.textContent = `Slide ${current}`; + } else { + nowMetaEl.textContent = ''; + } + + if (prefs().showProgress && total > 0) { + progressEl.hidden = false; + const pct = current > 0 ? Math.max(0, Math.min(100, (current / total) * 100)) : 0; + progressFillEl.style.width = `${pct}%`; + } else { + progressEl.hidden = true; + } + } + + function renderNext() { + nextListEl.innerHTML = ''; + const upcoming = upcomingItems(); + screenEl.classList.toggle('no-upcoming', upcoming.length === 0); + + for (const item of upcoming) { + const row = document.createElement('div'); + const nameEl = document.createElement('div'); + nameEl.className = 'nn-next-name'; + + if (item.type === 'header') { + row.className = 'nn-next-item is-header'; + nameEl.textContent = displayName(item.name || ''); + row.appendChild(nameEl); + nextListEl.appendChild(row); + continue; + } + + row.className = 'nn-next-item'; + nameEl.textContent = displayName(item.name) || '(untitled)'; + row.appendChild(nameEl); + + if (item.type === 'presentation' && Number.isInteger(item.slideCount)) { + const chip = document.createElement('div'); + chip.className = 'nn-count-chip'; + chip.textContent = String(item.slideCount); + row.appendChild(chip); + } else { + const tag = itemTypeTag(item.type); + if (tag) { + const tagEl = document.createElement('div'); + tagEl.className = 'nn-type-tag'; + tagEl.textContent = tag; + row.appendChild(tagEl); + } + } + nextListEl.appendChild(row); + } + + nextLabelEl.hidden = upcoming.length === 0; + nextMoreEl.hidden = true; + markNextOverflow(); + requestAnimationFrame(markNextOverflow); + } + + function render() { + screenEl.classList.toggle('is-offline', feedOffline); + const showTop = prefs().showPlaylist || feedOffline || !state?.reachable || !state?.enabled; + topRow.classList.toggle('hidden-row', !showTop); + playlistNameEl.textContent = prefs().showPlaylist ? state?.playlistName || '' : ''; + renderLive(); + renderNow(); + renderNext(); + } + + /* ── sizing: --unit scales the whole card to the tile ───────────── */ + + function applyUnit() { + const unit = Math.max(6.5, Math.min(20, root.clientHeight * 0.032)); + screenEl.style.setProperty('--unit', unit.toFixed(2) + 'px'); + } + + /* ── live feed ──────────────────────────────────────────────────── */ + + function connect() { + stream = moduleApi.sse('/stream', { + open() { + feedOffline = false; + render(); + }, + error() { + // EventSource retries by itself (and the shell recreates closed + // streams), but the tile must admit it is frozen meanwhile. + feedOffline = true; + render(); + }, + events: { + state(ev) { + try { + state = JSON.parse(ev.data); + } catch { + return; + } + feedOffline = false; + render(); + }, + }, + }); + } + + /* ── lifecycle ──────────────────────────────────────────────────── */ + + return { + start() { + applyUnit(); + moduleApi.setStatus('connecting', 'Connecting…'); + // Instant paint from the snapshot, then live updates. + moduleApi.fetch('/state') + .then((res) => (res.ok ? res.json() : null)) + .then((body) => { + if (body && body.state && !state) { + state = body.state; + render(); + } + }) + .catch(() => { /* the stream will cover it */ }); + connect(); + + // Re-fit on any size change; guard against reacting to our own DOM + // writes by only re-rendering when a watched box actually changed. + let lastW = 0; + let lastH = 0; + let pending = false; + resizeObserver = new ResizeObserver(() => { + const w = Math.round(root.clientWidth); + const h = Math.round(root.clientHeight); + if (w === lastW && h === lastH) return; + lastW = w; + lastH = h; + if (pending) return; + pending = true; + requestAnimationFrame(() => { + pending = false; + applyUnit(); + fitNowTitle(); + markNextOverflow(); + }); + }); + resizeObserver.observe(root); + render(); + }, + stop() { + stream?.close(); + stream = null; + resizeObserver?.disconnect(); + resizeObserver = null; + root.innerHTML = ''; + }, + onResize() { + applyUnit(); + fitNowTitle(); + markNextOverflow(); + }, + }; +} diff --git a/modules/propresenter-now-next/module.json b/modules/propresenter-now-next/module.json new file mode 100644 index 0000000..eb3feae --- /dev/null +++ b/modules/propresenter-now-next/module.json @@ -0,0 +1,33 @@ +{ + "id": "propresenter-now-next", + "name": "ProPresenter Now / Next", + "version": "1.0.0", + "description": "What is live in ProPresenter and what comes next", + "client": "client.js", + "server": "server.js", + "style": "style.css", + "minSize": { "w": 3, "h": 3 }, + "defaultSize": { "w": 4, "h": 5 }, + "configSchema": { + "host": { + "type": "string", + "label": "ProPresenter host / IP", + "default": "" + }, + "port": { + "type": "number", + "label": "ProPresenter network port", + "default": 1025 + }, + "password": { + "type": "password", + "label": "API password (only if set in ProPresenter)", + "default": "" + } + }, + "instanceSchema": { + "showPlaylist": { "type": "boolean", "label": "Show the playlist name", "default": true }, + "showProgress": { "type": "boolean", "label": "Show the slide progress bar", "default": true }, + "cleanTitles": { "type": "boolean", "label": "Tidy titles (hide [ Full ] tags and date codes)", "default": true } + } +} diff --git a/modules/propresenter-now-next/propresenter-core/index.js b/modules/propresenter-now-next/propresenter-core/index.js new file mode 100644 index 0000000..fb72fb4 --- /dev/null +++ b/modules/propresenter-now-next/propresenter-core/index.js @@ -0,0 +1,1029 @@ +'use strict'; + +/* ------------------------------------------------------------------ * + * ProPresenter 7 / 20 integration core * + * * + * Shared by two programs: * + * 1. the Band Lineup Electron app (main.js, via IPC to its renderer) * + * 2. propresenter-app — the standalone Now/Next display for studios * + * * + * Everything here is plain Node: global fetch only, no Electron, no * + * npm dependencies. All ProPresenter HTTP lives in this module so no * + * browser ever has to deal with CORS or a CSP exception. We poll the * + * basic REST endpoints (works on every ProPresenter 7+ version) and * + * hand callers a small, stable view-model. * + * ------------------------------------------------------------------ */ + +/** Default ProPresenter network API port (user-configurable in ProPresenter ▸ Preferences ▸ Network). */ +const DEFAULT_PP_PORT = 1025; +/** How often to poll ProPresenter for playlist/slide changes while connected. */ +const DEFAULT_POLL_MS = 600; +/** Slower retry cadence when ProPresenter is unreachable. */ +const DEFAULT_RETRY_MS = 2500; + +/** Normalize a stored/entered connection config into the shape the client expects. */ +function normalizeConfig(entry) { + const host = String(entry?.host || '').trim(); + const portNum = Number.parseInt(String(entry?.port ?? ''), 10); + const port = Number.isFinite(portNum) && portNum > 0 && portNum <= 65535 ? portNum : 0; + return { + enabled: Boolean(entry?.enabled) && Boolean(host) && Boolean(port), + host, + port, + playlistUuid: String(entry?.playlistUuid || ''), + playlistName: String(entry?.playlistName || ''), + }; +} + +/** The internal polling state, before it is projected into a view-model. */ +function emptyPollState() { + return { + reachable: false, + lastError: '', + playlistUuid: '', + playlistName: '', + items: [], + activeUuid: '', + activeName: '', + activeIndex: -1, + currentSlide: 0, + livePresentationUuid: '', + livePresentationSlideTotal: 0, + /** 'playlist' | 'group' | 'unknown' | 'none' — what ProPresenter pointed us at. */ + playlistKind: 'none', + itemsRetryAt: 0, + /** From /v1/status/layers: is the slide layer actually on screen right now? */ + slideLayerLive: false, + }; +} + +/** + * The "nothing connected" view-model. Consumers render this after a disconnect so + * the display clears instead of freezing on the last live slide. + */ +function clearedViewModel() { + return { + enabled: false, + reachable: false, + lastError: '', + needsPlaylist: false, + playlistName: '', + items: [], + activeIndex: -1, + activePresentationName: '', + currentSlide: 0, + currentSlideTotal: 0, + }; +} + +/** Flatten the /v1/playlists tree (groups → playlists) into a flat list a picker can show. */ +function flattenPlaylists(nodes, groupName, out) { + for (const node of Array.isArray(nodes) ? nodes : []) { + const id = node?.id || {}; + const uuid = String(id.uuid || node?.uuid || ''); + const name = String(id.name || node?.name || ''); + const fieldType = String(node?.field_type || node?.type || ''); + if (fieldType === 'playlist' || (!fieldType && uuid && !Array.isArray(node?.children))) { + if (uuid) out.push({ uuid, name, group: groupName }); + } + if (Array.isArray(node?.children) && node.children.length) { + flattenPlaylists(node.children, fieldType === 'group' ? name : groupName, out); + } + } + return out; +} + +/** Parse a /v1/playlist/{uuid} response into our item shape, tolerating ProPresenter version differences. */ +function parsePlaylistItems(data) { + const list = Array.isArray(data?.items) + ? data.items + : Array.isArray(data?.children) + ? data.children + : Array.isArray(data) + ? data + : []; + return list.map((item) => { + const id = item?.id || {}; + return { + uuid: String(id.uuid || item?.uuid || ''), + name: String(id.name || item?.name || ''), + type: String(item?.type || item?.field_type || 'presentation'), + isHidden: Boolean(item?.is_hidden ?? item?.isHidden), + isPco: Boolean(item?.is_pco ?? item?.isPco), + slideCount: null, + }; + }); +} + +/** Depth-first search the /v1/playlists tree for a playlist by uuid; returns its index path + name. */ +function findPlaylistPath(nodes, targetUuid, prefix) { + if (!Array.isArray(nodes)) return null; + for (let i = 0; i < nodes.length; i += 1) { + const node = nodes[i]; + const id = node?.id || {}; + const uuid = String(id.uuid || node?.uuid || ''); + const idx = Number.isInteger(id.index) ? id.index : i; + const here = [...prefix, idx]; + if (uuid && uuid === targetUuid) { + return { + path: here, + name: String(id.name || node?.name || ''), + fieldType: String(node?.field_type || node?.type || ''), + }; + } + const deeper = findPlaylistPath(node?.children, targetUuid, here); + if (deeper) return deeper; + } + return null; +} + +function countSlides(presentationData) { + // PP20 wraps details under `presentation`; older shapes are flat. + const presentation = presentationData?.presentation || presentationData; + const groups = Array.isArray(presentation?.groups) ? presentation.groups : []; + let count = 0; + for (const group of groups) { + const slides = Array.isArray(group?.slides) ? group.slides : []; + // Count ALL slides including disabled — slide_index.index counts disabled slides too, + // so the total must match that same sequence or "Slide X / Y" will be inconsistent. + count += slides.length; + } + return count; +} + +/** + * Tidy an item name for display only (the raw name is kept for ProPresenter matching). + * Strips arrangement/section tags in brackets ([ Full ], ( TAG ), { ... }), a leading date + * code (e.g. "260607 "), separator dashes, and any leftover edge punctuation/whitespace so the + * card reads as cleanly as possible — "260607 Taking Thoughts Captive - [ Full ]" → "Taking Thoughts Captive". + * + * Exported for Node-side consumers; the browser UIs keep their own copy of this pure + * function because they load as plain scripts with no module loader. + */ +function cleanItemName(raw) { + const original = String(raw || '').trim(); + let s = original; + s = s.replace(/[[({][^\])}]*[\])}]/g, ' '); // drop [..], (..), {..} tags + s = s.replace(/^\s*\d{6,8}\b[\s\-–—:.]*/, ''); // drop a leading 6–8 digit date code + s = s.replace(/\s[-–—]+\s/g, ' '); // drop " - " style separators + s = s.replace(/^[\s\-–—:.]+|[\s\-–—:.]+$/g, ''); // trim edge dashes/colons/dots/space + s = s.replace(/\s{2,}/g, ' ').trim(); // collapse runs of whitespace + return s || original; // never blank out a title (e.g. a name that was only a tag) +} + +/** + * Create a ProPresenter polling client. + * + * @param {object} [options] + * @param {(view: object) => void} [options.onData] Called with a view-model on every poll tick. + * @param {number} [options.pollMs] Poll cadence while reachable. + * @param {number} [options.retryMs] Poll cadence while unreachable. + */ +function createProPresenterClient(options = {}) { + const pollMs = Number.isFinite(options.pollMs) && options.pollMs > 0 ? options.pollMs : DEFAULT_POLL_MS; + const retryMs = Number.isFinite(options.retryMs) && options.retryMs > 0 ? options.retryMs : DEFAULT_RETRY_MS; + let onData = typeof options.onData === 'function' ? options.onData : null; + + /** @type {{ enabled: boolean, host: string, port: number, password: string, playlistUuid: string, playlistName: string }} */ + let config = { enabled: false, host: '', port: 0, password: '', playlistUuid: '', playlistName: '' }; + let state = emptyPollState(); + let pollTimer = null; + let stopped = true; + /** Monotonic token so a superseded poll loop can detect it should bail. */ + let runToken = 0; + /** uuid -> master (document) slide count. Counts are stable during a service, so fetch once and reuse. */ + const slideCountCache = new Map(); + /** + * uuid -> arrangement-expanded cue count (the total that matches slide_index.index). + * slide_index counts the SELECTED arrangement's expanded cue sequence (repeated groups count + * multiple times), while /v1/presentation/{uuid} only returns the unique master groups — so the + * two disagree whenever an arrangement repeats a section. We recover the true total by probing the + * thumbnail endpoint, whose index "respects the selected arrangement" (same space as slide_index). + */ + const arrangementCountCache = new Map(); + /** Cached URL that successfully returned the current playlist's item list (PP20 renamed this route). */ + let itemsSourceUrl = ''; + let itemsForUuid = ''; + /** Index path of the playlist we last proved contains the live presentation. */ + let provenPlaylistPath = ''; + /** Name of that proven playlist, so the header doesn't blank when the hint disagrees. */ + let provenPlaylistName = ''; + /** Last playlist uuid ProPresenter HINTED at, for change detection. */ + let lastHintUuid = ''; + /** Poll ticks since the last tree-wide search, so it cannot run every 600ms. */ + let searchCooldown = 0; + /** Monotonic tick count, so the heavy calls run on a slow secondary cadence. */ + let tick = 0; + /** True when the last slide_index came back null. */ + let lastIndexWasNull = false; + + function url(target) { + const built = new URL(`http://${config.host}:${config.port}${target}`); + if (config.password) built.searchParams.set('password', config.password); + return built; + } + + async function requestJson(target, init) { + const response = await fetch(url(target), { + headers: { Accept: 'application/json' }, + ...init, + }); + if (!response.ok) { + throw new Error(`ProPresenter ${response.status} ${response.statusText}`); + } + const text = await response.text(); + return text ? JSON.parse(text) : null; + } + + async function slideCount(uuid) { + if (!uuid) return null; + if (slideCountCache.has(uuid)) return slideCountCache.get(uuid); + try { + const data = await requestJson(`/v1/presentation/${encodeURIComponent(uuid)}`); + const count = countSlides(data); + slideCountCache.set(uuid, count); + return count; + } catch { + return null; + } + } + + /** + * Probe the thumbnail endpoint to find how many cues the SELECTED arrangement actually plays. + * The thumbnail index "respects the selected arrangement of cues" — the exact same index space + * slide_index reports — and returns 404 once the index runs past the end. So the count of cues is + * the smallest index that 404s. We only read the HTTP status (the JPEG body is cancelled), and do + * an exponential-then-binary search so the whole probe is ~2·log2(N) tiny requests. + * Returns the cue count, or null if the presentation can't be probed (auth error / unexpected status). + */ + async function probeArrangementLength(uuid) { + const CAP = 4000; // guard against a build that clamps instead of 404-ing — fall back rather than loop + const exists = async (index) => { + try { + const res = await fetch(url(`/v1/presentation/${encodeURIComponent(uuid)}/thumbnail/${index}`), { + headers: { Accept: 'image/jpeg' }, + }); + try { await res.body?.cancel?.(); } catch { /* body already consumed/closed */ } + if (res.status === 200) return true; + if (res.status === 404) return false; + return null; // 403 or anything unexpected → can't trust the probe + } catch { + return null; + } + }; + + const first = await exists(0); + if (first === null) return null; + if (first === false) return 0; + + // Exponential search for an index that is past the end (404). + let lo = 0; // known valid + let hi = 1; // candidate; grows until invalid + while (true) { + const r = await exists(hi); + if (r === null) return null; + if (r === false) break; + lo = hi; + hi *= 2; + if (hi > CAP) return null; + } + // Binary search the boundary: lo valid, hi invalid. + while (hi - lo > 1) { + const mid = Math.floor((lo + hi) / 2); + const r = await exists(mid); + if (r === null) return null; + if (r) lo = mid; else hi = mid; + } + return lo + 1; // cue indices are 0-based, so the count is the last valid index + 1 + } + + /** + * The slide TOTAL for the live presentation, in the same index space as slide_index.currentSlide. + * Prefers the arrangement-expanded count (cached per uuid); falls back to the master slide count if + * the thumbnail probe is unavailable. Always returns a value >= the live slide so "Slide X / Y" can + * never show X > Y (a stale arrangement is re-probed when the live index outruns the cached total). + */ + /** Pull the arrangement tag out of a playlist item name: "AMAZING! - [ Full ]" -> "full". */ + function arrangementTagFromName(name) { + const m = String(name || '').match(/[[({]\s*([^\])}]+?)\s*[\])}]/); + return m ? m[1].trim().toLowerCase() : ''; + } + + /** + * The true slide total, in the SAME index space as slide_index — computed from the + * presentation's arrangement structure rather than guessed. + * + * ProPresenter reports \`groups\` (each with slides), \`arrangements\` (each a flat ordered + * list of group UUIDs, which REPEAT sections like choruses), and \`current_arrangement\`. + * slide_index counts the selected arrangement's expanded sequence, so the correct total is + * the sum of slide counts over that arrangement's group list. The old thumbnail probe + * returned the MASTER count on this build (49 vs the real 64 for a [Full] arrangement), + * which made the live slide overshoot the total and the counter read "63/63, 64/64…". + * + * @param arrangementHint arrangement name from the playlist item, used when + * current_arrangement is blank (this PP build often leaves it blank). + */ + async function computePresentationTotal(uuid, arrangementHint) { + let data; + try { + data = await requestJson(`/v1/presentation/${encodeURIComponent(uuid)}`); + } catch { + return null; + } + const pres = data?.presentation || data; + const groups = Array.isArray(pres?.groups) ? pres.groups : []; + const groupSlides = new Map(); + let master = 0; + for (const g of groups) { + const n = Array.isArray(g?.slides) ? g.slides.length : 0; + master += n; + const gu = String(g?.uuid || g?.id?.uuid || ''); + if (gu) groupSlides.set(gu, n); + } + const arrangements = Array.isArray(pres?.arrangements) ? pres.arrangements : []; + if (!arrangements.length) return master || null; + + const currentUuid = String(pres?.current_arrangement || ''); + let chosen = currentUuid + ? arrangements.find((a) => String(a?.id?.uuid || '') === currentUuid) + : null; + // current_arrangement is frequently blank on PP20 — fall back to the item's "[ tag ]". + if (!chosen && arrangementHint) { + chosen = arrangements.find((a) => String(a?.id?.name || '').trim().toLowerCase() === arrangementHint); + } + if (!chosen && arrangements.length === 1) chosen = arrangements[0]; + if (!chosen) return master || null; + + const refs = Array.isArray(chosen.groups) ? chosen.groups : []; + let total = 0; + for (const ref of refs) { + const gu = typeof ref === 'string' ? ref : String(ref?.uuid || ref?.id?.uuid || ''); + total += groupSlides.get(gu) || 0; + } + return total > 0 ? total : (master || null); + } + + async function liveSlideTotal(uuid, currentSlide, arrangementHint) { + if (!uuid) return 0; + if (!arrangementCountCache.has(uuid)) { + const computed = await computePresentationTotal(uuid, arrangementHint); + if (computed && computed > 0) { + arrangementCountCache.set(uuid, computed); + } else { + // Last resort only if the structure was unreadable: the old probe, then master. + const probed = await probeArrangementLength(uuid); + const master = await slideCount(uuid); + arrangementCountCache.set(uuid, (probed && probed > 0) ? probed : (master || 0)); + } + } + const total = arrangementCountCache.get(uuid); + // If the live slide somehow exceeds the computed total, re-derive ONCE (an arrangement + // switch mid-song). Never poison the cache by setting total = currentSlide — that is + // exactly what produced the ever-climbing "X / X". + if (currentSlide > total) { + const recomputed = await computePresentationTotal(uuid, arrangementHint); + if (recomputed && recomputed >= currentSlide) { + arrangementCountCache.set(uuid, recomputed); + return recomputed; + } + // Can't resolve a bigger real total — show the honest live index rather than X/(smaller). + return currentSlide; + } + return total; + } + + /** + * How confident are we that this playlist item is the presentation that is live? + * 3 = same uuid, 2 = same title, 1 = one title is a prefix of the other, 0 = no. + * + * Compared on TIDIED names, because ProPresenter reports the presentation as + * "Covered By The Blood" while the playlist item carries the arrangement, + * "Covered By The Blood - [ Full ]". + * + * Deliberately NOT a substring test. A loose "does either contain the other" matched a + * pre-service playlist containing a song called "The Blood" and pointed the whole display + * at the wrong running order — a plain substring is far too eager on a song library. + */ + function matchStrength(item, liveName, liveUuid) { + if (liveUuid && item.uuid && item.uuid === liveUuid) return 3; + const itemName = cleanItemName(item.name || '').toLowerCase(); + const live = cleanItemName(liveName || '').toLowerCase(); + if (!itemName || !live) return 0; + if (itemName === live) return 2; + // A prefix relationship covers "… TAG" and "… (Reprise)" style siblings. Require a + // meaningful stem so short titles can't latch onto anything. + const stem = Math.min(itemName.length, live.length); + if (stem >= 6 && (itemName.startsWith(live) || live.startsWith(itemName))) return 1; + return 0; + } + + function itemMatchesLive(item, liveName, liveUuid) { + return matchStrength(item, liveName, liveUuid) > 0; + } + + /** Every playlist in the tree, with the index path that /v1/playlist/{a}/{b} wants. */ + async function listAllPlaylists() { + const out = []; + let tree; + try { + tree = await requestJson('/v1/playlists'); + } catch { + return out; + } + const nodes = Array.isArray(tree) ? tree : tree?.children || tree?.playlists || []; + const walk = (ns, prefix) => { + for (let i = 0; i < (Array.isArray(ns) ? ns.length : 0); i += 1) { + const node = ns[i]; + const id = node?.id || {}; + const idx = Number.isInteger(id.index) ? id.index : i; + const here = [...prefix, idx]; + const fieldType = String(node?.field_type || node?.type || ''); + if (fieldType === 'playlist') { + out.push({ path: here.join('/'), name: String(id.name || ''), uuid: String(id.uuid || '') }); + } + walk(node?.children, here); + } + }; + walk(nodes, []); + return out; + } + + /** + * Find the playlist that actually contains what is on screen. + * + * Necessary because /v1/playlist/active reports null and /v1/playlist/focused reports + * whatever the operator last clicked — which is frequently a GROUP, not the running order. + * Trusting it meant the display showed no upcoming items all through a service that was + * being run from a playlist the whole time. Searching by the live presentation is the only + * signal that reflects what is genuinely being presented. + */ + async function findPlaylistContainingLive(liveName, liveUuid) { + const playlists = await listAllPlaylists(); + if (!playlists.length) return null; + // Check the one we already proved first: while a service runs, that is a single request. + playlists.sort((a, b) => (b.path === provenPlaylistPath ? 1 : 0) - (a.path === provenPlaylistPath ? 1 : 0)); + + let best = null; + for (const entry of playlists) { + const url = `/v1/playlist/${entry.path}`; + let items = []; + try { + items = parsePlaylistItems(await requestJson(url)); + } catch { + continue; + } + if (!items.length) continue; + let strength = 0; + for (const item of items) { + strength = Math.max(strength, matchStrength(item, liveName, liveUuid)); + } + if (!strength) continue; + if (!best || strength > best.strength) { + best = { items, name: entry.name, uuid: entry.uuid, path: entry.path, strength }; + } + // A uuid or exact-title hit is as good as it gets — stop walking the tree. + if (strength >= 2) break; + } + if (!best) return null; + provenPlaylistPath = best.path; + itemsSourceUrl = `/v1/playlist/${best.path}`; + itemsForUuid = best.uuid; + return best; + } + + function recomputeActiveIndex() { + // 1. Try UUID match (works when presentation UUID === playlist item UUID). + if (state.activeUuid) { + const byUuid = state.items.findIndex((item) => item.uuid && item.uuid === state.activeUuid); + if (byUuid >= 0) { state.activeIndex = byUuid; return; } + } + // 2. Fall back to name match. On PP20, slide_index gives the presentation document UUID + // which differs from the playlist item UUID, so UUID matching silently fails. + // Names always match because both the playlist item and the presentation share the same title. + if (state.activeName) { + const normalised = state.activeName.trim().toLowerCase(); + const byName = state.items.findIndex( + (item) => item.name && item.name.trim().toLowerCase() === normalised, + ); + if (byName >= 0) { state.activeIndex = byName; return; } + // Partial name match as a last resort (handles slight label differences). + // Strongest match wins, rather than the first loose one — inside a long song list + // several titles can look similar. + let bestIndex = -1; + let bestStrength = 0; + for (let i = 0; i < state.items.length; i += 1) { + const strength = matchStrength(state.items[i], state.activeName, state.livePresentationUuid); + if (strength > bestStrength) { + bestStrength = strength; + bestIndex = i; + } + } + state.activeIndex = bestIndex; + return; + } + state.activeIndex = -1; + } + + /** + * ProPresenter 20 renamed the "list a playlist's items" route and didn't document it publicly. + * Since this app runs on the same LAN as ProPresenter, we probe the plausible address formats + * at runtime and use whichever returns a real item list, caching the winner. + */ + async function discoverItems(playlistUuid) { + if (!playlistUuid) return { items: [], kind: 'none' }; + const enc = encodeURIComponent; + + // Reuse a previously-working URL for this playlist. + if (itemsSourceUrl && itemsForUuid === playlistUuid) { + try { + const items = parsePlaylistItems(await requestJson(itemsSourceUrl)); + if (items.length) return { items, kind: 'playlist' }; + } catch { + /* re-discover below */ + } + } + + let pathStr = []; + let name = ''; + let fieldType = ''; + try { + const tree = await requestJson('/v1/playlists'); + const nodes = Array.isArray(tree) ? tree : tree?.children || tree?.playlists || []; + const found = findPlaylistPath(nodes, playlistUuid, []); + if (found) { + pathStr = found.path; + name = found.name; + fieldType = found.fieldType; + } + } catch { + /* ignore */ + } + + // A GROUP holds playlists, not items, and ProPresenter's /v1/playlist/focused will + // happily name one — which is what happens when the operator is presenting from the + // Library rather than from a service playlist. Listing its items correctly returns + // nothing, so say so instead of leaving the caller to guess from an empty array. + if (fieldType === 'group') { + return { items: [], kind: 'group' }; + } + + const candidates = [`/v1/playlist/${enc(playlistUuid)}`]; + if (name) candidates.push(`/v1/playlist/${enc(name)}`); + if (pathStr.length) { + candidates.push( + `/v1/playlist/${pathStr.join('.')}`, + `/v1/playlist/${pathStr.join(':')}`, + `/v1/playlist/${pathStr.join('-')}`, + `/v1/playlist/${pathStr.join('_')}`, + `/v1/playlist/${pathStr.join(',')}`, + `/v1/playlist/${enc(pathStr.join('/'))}`, + `/v1/playlist/${pathStr[pathStr.length - 1]}`, + ); + } + + for (const candidate of candidates) { + try { + const items = parsePlaylistItems(await requestJson(candidate)); + if (items.length) { + itemsSourceUrl = candidate; + itemsForUuid = playlistUuid; + return { items, kind: 'playlist' }; + } + } catch { + /* try next candidate */ + } + } + return { items: [], kind: 'unknown' }; + } + + /** + * Refresh which playlist we're in (for the item list). + * Uses /v1/playlist/active then /v1/playlist/focused as fallback. + * Does NOT set the active item — that comes from slide_index (see applySlideIndex). + */ + async function refreshPlaylistContext() { + let playlistUuid = ''; + let playlistName = ''; + + try { + const active = await requestJson('/v1/playlist/active'); + const pl = active?.presentation?.playlist; + if (pl?.uuid) { + playlistUuid = String(pl.uuid); + playlistName = String(pl.name || ''); + } + } catch { /* ignore */ } + + if (!playlistUuid) { + try { + const focused = await requestJson('/v1/playlist/focused'); + const pl = focused?.playlist; + if (pl?.uuid) { + playlistUuid = String(pl.uuid); + playlistName = String(pl.name || ''); + } + } catch { /* ignore */ } + } + + // Compare against the previous HINT, not against the playlist we settled on. Otherwise + // every poll looks like a change (the hint keeps naming a group while we hold the real + // playlist) and re-probes ProPresenter twice a second for nothing. + const changed = playlistUuid !== lastHintUuid; + lastHintUuid = playlistUuid; + + if (playlistUuid && changed) { + state.playlistUuid = playlistUuid; + const found = await discoverItems(playlistUuid); + state.playlistKind = found.kind; + state.itemsRetryAt = 0; + // Only REPLACE the list when we actually resolved one. During a rehearsal the operator + // clicks around groups and library items, and ProPresenter reports each of those as the + // focused "playlist"; overwriting with an empty list each time is what made the running + // order keep vanishing off the screens. + if (found.items.length) { + state.items = found.items; + await fillSlideCounts(); + } + } else if (playlistUuid && !state.items.length && state.playlistKind !== 'group') { + // Retry a discovery that came back empty. Item lookup depends on probing undocumented + // routes, so a single failure used to be sticky for the whole service — the list would + // stay blank until the operator happened to switch playlists. + state.itemsRetryAt = (state.itemsRetryAt || 0) + 1; + if (state.itemsRetryAt >= 20) { + state.itemsRetryAt = 0; + const found = await discoverItems(playlistUuid); + state.items = found.items; + state.playlistKind = found.kind; + await fillSlideCounts(); + } + } else if (!playlistUuid) { + state.playlistUuid = ''; + state.items = []; + state.playlistKind = 'none'; + } + + // Don't present a group's name as if it were a service playlist — "EVENTS" reads like a + // running order when it is really just the folder the operator has open. + state.playlistName = state.playlistKind === 'group' ? '' : playlistName; + + // The hint above is unreliable. What matters is whether the list we are holding actually + // contains what is on screen; if it doesn't, go and find the playlist that does. + const liveName = state.activeName; + if (!liveName) return; + const holdsLive = state.items.some((item) => itemMatchesLive(item, liveName, state.livePresentationUuid)); + if (holdsLive) { + searchCooldown = 0; + // Keep showing the playlist we proved, not the group the hint keeps naming. + if (provenPlaylistName) state.playlistName = provenPlaylistName; + return; + } + if (searchCooldown > 0) { + searchCooldown -= 1; + return; + } + const found = await findPlaylistContainingLive(liveName, state.livePresentationUuid); + if (found) { + state.items = found.items; + state.playlistName = found.name; + provenPlaylistName = found.name; + state.playlistUuid = found.uuid || state.playlistUuid; + state.playlistKind = 'playlist'; + await fillSlideCounts(); + recomputeActiveIndex(); + } else { + // Nothing in the tree holds it — genuinely presenting outside a playlist. Back off so + // a full tree walk cannot run on every poll. + searchCooldown = 25; + } + } + + /** + * Who is on screen when slide_index won't say. /v1/presentation/focused reports the + * presentation ProPresenter has focused — including one opened straight from the Library, + * which slide_index attributes to nothing. No slide number is available this way, so the + * card shows the title and the total without a running count. + */ + async function applyFocusedPresentation() { + try { + const focused = await requestJson('/v1/presentation/focused'); + const uuid = String(focused?.uuid || focused?.id?.uuid || ''); + const name = String(focused?.name || focused?.id?.name || ''); + if (!name && !uuid) return; + if (uuid && uuid !== state.activeUuid) { + state.activeUuid = uuid; + state.livePresentationUuid = uuid; + state.livePresentationSlideTotal = 0; + } + state.activeName = name || state.activeName; + recomputeActiveIndex(); + } catch { + /* leave the last known state alone */ + } + } + + async function fillSlideCounts() { + for (const item of state.items) { + // Try a slide count for anything that isn't an explicit header; non-presentations + // simply return null (their UUID has no presentation), which we render as "no count". + if (item.uuid && item.type !== 'header' && item.slideCount === null) { + item.slideCount = await slideCount(item.uuid); + } + } + } + + function applySlideIndex(data) { + // PP20 uses `presentation_index`; older versions used `presentation`. + const node = data?.presentation_index || data?.presentation || null; + if (!node) { + // A null here does NOT mean the screen is empty. On ProPresenter 20 this endpoint + // reports null whenever the live slide isn't attributed to a playlist presentation — + // presenting from the Library is the common case — and it also blips to null between + // triggers. Blanking on it is what made the display keep clearing itself during a + // rehearsal. /v1/status/layers is the authority on whether anything is on screen: + // while the slide layer is live, hold the last known item. + if (state.slideLayerLive) return; + state.currentSlide = 0; + state.livePresentationUuid = ''; + state.activeUuid = ''; + state.activeName = ''; + recomputeActiveIndex(); + return; + } + const idx = Number.isInteger(node.index) ? node.index : null; + state.currentSlide = idx !== null ? idx + 1 : 0; + + const presId = node?.presentation_id || node?.id || null; + const uuid = presId ? String(presId.uuid || '') : ''; + state.livePresentationUuid = uuid; + + // Use this uuid as the active item — it's always accurate regardless of how slides advanced. + if (uuid && uuid !== state.activeUuid) { + state.activeUuid = uuid; + state.livePresentationSlideTotal = 0; // reset so a fresh count is fetched this poll + // Try to find the name from the item list; fall back to presentation_id.name. + const match = state.items.find((item) => item.uuid === uuid); + state.activeName = match ? match.name : String(presId?.name || ''); + recomputeActiveIndex(); + } + } + + /** Project the internal poll state into the view-model both UIs render. */ + function getViewModel() { + // Items the operator marked hidden in ProPresenter — cut songs, contingency cues, + // staff-only slides — are REDACTED here, not removed. Both UIs already skip hidden + // rows when rendering, but their names were still being published to every screen on + // the network. Keeping the entries (minus their names) preserves the positions that + // activeIndex and the "what's next" slice depend on, which dropping them would shift. + const items = state.items.map((item) => (item.isHidden + ? { uuid: '', name: '', type: item.type, isHidden: true, isPco: false, slideCount: null } + : item)); + + // Use the live count for the presentation that's actually on screen — it reflects the + // arrangement playing right now, so it can never be stale or mismatched. + return { + enabled: config.enabled, + reachable: state.reachable, + lastError: state.lastError || '', + needsPlaylist: false, + playlistName: state.playlistName, + playlistKind: state.playlistKind || 'none', + items, + activeIndex: state.activeIndex, + activePresentationName: state.activeName, + currentSlide: state.currentSlide, + currentSlideTotal: state.livePresentationSlideTotal || 0, + }; + } + + /** Last error thrown by a consumer's onData, for callers that want to surface it. */ + let lastEmitError = ''; + + function emit() { + if (!onData) return; + // A consumer must never be able to stop the engine. emit() runs OUTSIDE pollTick's + // try/catch and BEFORE the reschedule below it, so an unhandled throw here would end + // polling for good — and with no unhandledRejection handler installed (Electron adds + // none) it would take the whole process down. Swallow, record, keep polling. + try { + onData(getViewModel()); + lastEmitError = ''; + } catch (err) { + lastEmitError = err instanceof Error ? err.message : String(err); + console.error('[propresenter-core] onData consumer threw:', err); + } + } + + async function pollTick(token) { + if (stopped || token !== runToken) return; + + try { + tick += 1; + + // THE HOT PATH — one request, every tick, exactly like version 1. This is the live + // slide counter, and it must not be starved by anything heavier sharing the loop: + // ProPresenter's little HTTP server will start returning null for slide_index if it + // is hammered, which is what stopped the counter from updating. + const raw = await requestJson('/v1/presentation/slide_index'); + lastIndexWasNull = !(raw?.presentation_index || raw?.presentation); + applySlideIndex(raw); + state.reachable = true; + state.lastError = ''; + + // Slide TOTAL for the live presentation (cached per uuid — after the first probe this + // costs nothing, so it can stay on the hot path). + if (state.livePresentationUuid) { + try { + const activeItem = state.activeIndex >= 0 ? state.items[state.activeIndex] : null; + const hint = arrangementTagFromName(activeItem?.name || state.activeName); + state.livePresentationSlideTotal = await liveSlideTotal( + state.livePresentationUuid, + state.currentSlide, + hint, + ); + } catch { + /* keep the last known total */ + } + } + + // EVERYTHING BELOW is the slow lane — the calls I added that ProPresenter does not + // need to answer on every slide. Run them roughly every 2.5s, or immediately when we + // still have no idea what is on screen. Keeps steady-state load below version 1's. + const slowTick = tick % 4 === 0; + const needBootstrap = !state.items.length || (!state.activeName && lastIndexWasNull); + + if (slowTick || needBootstrap) { + // Only ask about layers/focused when slide_index isn't naming the slide itself. + if (lastIndexWasNull) { + try { + const layers = await requestJson('/v1/status/layers'); + if (layers && typeof layers.slide === 'boolean') state.slideLayerLive = layers.slide; + } catch { + /* older builds have no layer status; leave the last value */ + } + if (state.slideLayerLive && !state.activeName) { + await applyFocusedPresentation(); + } + } + await refreshPlaylistContext(); + } + + if (state.activeUuid) { + const match = state.items.find((item) => item.uuid === state.activeUuid); + if (match) state.activeName = match.name; + } + recomputeActiveIndex(); + } catch (err) { + state.reachable = false; + state.lastError = err instanceof Error ? err.message : String(err); + } + + if (stopped || token !== runToken) return; + emit(); + pollTimer = setTimeout(() => pollTick(token), state.reachable ? pollMs : retryMs); + } + + function stop() { + stopped = true; + runToken += 1; + if (pollTimer) { + clearTimeout(pollTimer); + pollTimer = null; + } + state.reachable = false; + } + + function start() { + if (!config.enabled || !config.host || !config.port) return false; + stopped = false; + const token = (runToken += 1); + pollTick(token); + return true; + } + + /** Restart the poll loop only if it was already running (used after a config tweak). */ + function restartIfRunning() { + if (!stopped) { + stop(); + start(); + } + } + + /** Drop every cache + poll result. Call when the connection target changes. */ + function reset() { + slideCountCache.clear(); + arrangementCountCache.clear(); + itemsSourceUrl = ''; + itemsForUuid = ''; + state = emptyPollState(); + } + + /** + * Point the client at a ProPresenter instance. Does not start polling. + * Accepts a partial config; omitted fields keep their current value. + */ + function configure(next = {}) { + const merged = { + enabled: next.enabled ?? config.enabled, + host: next.host ?? config.host, + port: next.port ?? config.port, + playlistUuid: next.playlistUuid ?? config.playlistUuid, + playlistName: next.playlistName ?? config.playlistName, + }; + const normalized = normalizeConfig(merged); + config = { + ...normalized, + // enabled is only meaningful with a host+port, but callers may enable optimistically + // before the reachability check; normalizeConfig already guards that. + enabled: Boolean(merged.enabled) && Boolean(normalized.host) && Boolean(normalized.port), + password: next.password ?? config.password, + }; + return getConfig(); + } + + /** The current config, minus the password (callers get `hasPassword` instead). */ + function getConfig() { + return { + enabled: config.enabled, + host: config.host, + port: config.port, + hasPassword: Boolean(config.password), + playlistUuid: config.playlistUuid, + playlistName: config.playlistName, + }; + } + + /** + * Hit /version to confirm the API is reachable and the password (if any) is accepted. + * Resolves with ProPresenter's version payload, or throws a message worth showing a human. + */ + async function testConnection() { + if (!config.host || !config.port) { + throw new Error('Enter the ProPresenter host (IP) and port.'); + } + try { + return await requestJson('/version'); + } catch { + throw new Error( + `Could not reach ProPresenter at ${config.host}:${config.port}. Check the IP and port, and that the API is enabled in ProPresenter ▸ Preferences ▸ Network.`, + ); + } + } + + /** Every playlist ProPresenter knows about, flattened for a picker. */ + async function listPlaylists() { + if (!config.host || !config.port) return []; + const data = await requestJson('/v1/playlists'); + const nodes = Array.isArray(data) + ? data + : Array.isArray(data?.playlists) + ? data.playlists + : Array.isArray(data?.children) + ? data.children + : []; + return flattenPlaylists(nodes, '', []); + } + + /** Remember a manually chosen playlist and force the next poll to refetch its items. */ + function selectPlaylist({ uuid = '', name = '' } = {}) { + config.playlistUuid = String(uuid); + config.playlistName = String(name); + state.playlistUuid = ''; + state.items = []; + state.activeIndex = -1; + restartIfRunning(); + return getConfig(); + } + + return { + configure, + getConfig, + setOnData(cb) { onData = typeof cb === 'function' ? cb : null; }, + start, + stop, + restartIfRunning, + // NOTE: isRunning() is just !stopped — it says the loop was started, NOT that + // ProPresenter is reachable or that data is flowing. Use getViewModel().reachable for health. + isRunning: () => !stopped, + getLastEmitError: () => lastEmitError, + reset, + getViewModel, + testConnection, + listPlaylists, + selectPlaylist, + requestJson, + }; +} + +module.exports = { + DEFAULT_PP_PORT, + DEFAULT_POLL_MS, + DEFAULT_RETRY_MS, + createProPresenterClient, + clearedViewModel, + normalizeConfig, + flattenPlaylists, + parsePlaylistItems, + findPlaylistPath, + countSlides, + cleanItemName, +}; diff --git a/modules/propresenter-now-next/propresenter-core/package.json b/modules/propresenter-now-next/propresenter-core/package.json new file mode 100644 index 0000000..be1048a --- /dev/null +++ b/modules/propresenter-now-next/propresenter-core/package.json @@ -0,0 +1,8 @@ +{ + "name": "propresenter-core", + "version": "1.0.0", + "private": true, + "description": "Shared ProPresenter 7/20 network-API polling client (no dependencies, Node built-ins only)", + "main": "index.js", + "license": "MIT" +} diff --git a/modules/propresenter-now-next/server.js b/modules/propresenter-now-next/server.js new file mode 100644 index 0000000..4ef26e4 --- /dev/null +++ b/modules/propresenter-now-next/server.js @@ -0,0 +1,132 @@ +'use strict'; + +/** + * ProPresenter Now / Next — server part. + * + * Owns the one ProPresenter connection (via propresenter-core, copied here + * unchanged — it holds every ProPresenter 7/20 quirk) and fans the live + * view-model out to every tile over SSE. Browsers never talk to + * ProPresenter directly. + * + * GET /state → { state: } (instant paint for new tiles) + * GET /stream → SSE, `state` events (change-detected, heartbeated) + */ + +const { createProPresenterClient, clearedViewModel } = require('./propresenter-core'); + +/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */ +const HEARTBEAT_MS = 15000; + +/** Set by init(), read by the routes — both are rebuilt together on remount. */ +let current = null; + +module.exports = { + init({ config, log }) { + const streams = new Set(); + const enabled = Boolean(config.host && Number(config.port)); + let latest = clearedViewModel(); + let lastFrame = ''; + + const client = createProPresenterClient({ + onData(view) { + latest = view; + if (!streams.size) return; + let frame; + try { + frame = JSON.stringify(view); + } catch { + return; + } + // The poll loop emits every tick whether anything changed or not. + // Broadcasting only real changes keeps idle tiles genuinely idle. + if (frame === lastFrame) return; + lastFrame = frame; + const payload = `event: state\ndata: ${frame}\n\n`; + for (const res of streams) { + try { + res.write(payload); + } catch { + streams.delete(res); + } + } + }, + }); + + client.configure({ + enabled, + host: String(config.host || ''), + port: Number(config.port) || 1025, + password: String(config.password || ''), + }); + + if (enabled) { + client.start(); + log(`following ProPresenter at ${config.host}:${config.port}`); + } else { + log('no ProPresenter host configured — set one in /admin'); + } + + const heartbeat = setInterval(() => { + for (const res of streams) { + try { + res.write(': ping\n\n'); + } catch { + streams.delete(res); + } + } + }, HEARTBEAT_MS); + heartbeat.unref?.(); + + current = { streams, getLatest: () => latest }; + + return { + stop() { + clearInterval(heartbeat); + client.stop(); + for (const res of streams) { + try { res.end(); } catch { /* already gone */ } + } + streams.clear(); + if (current && current.streams === streams) current = null; + }, + health() { + if (!enabled) return { status: 'error', message: 'No ProPresenter host configured' }; + if (latest.reachable) return { status: 'ok', message: `Following ${config.host}:${config.port}` }; + return { + status: 'error', + message: latest.lastError + ? `ProPresenter unreachable: ${latest.lastError}` + : 'Trying to reach ProPresenter…', + }; + }, + }; + }, + + routes() { + return { + 'GET /state': (req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); + res.end(JSON.stringify({ state: current ? current.getLatest() : clearedViewModel() })); + }, + + 'GET /stream': (req, res) => { + res.writeHead(200, { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + res.write('retry: 3000\n\n'); + const view = current ? current.getLatest() : clearedViewModel(); + res.write(`event: state\ndata: ${JSON.stringify(view)}\n\n`); + if (!current) return void res.end(); + const { streams } = current; + // A viewer that stops reading (locked tablet, sleeping laptop) must + // not queue frames forever; keepalive probes make the OS notice. + try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ } + streams.add(res); + req.on('close', () => streams.delete(res)); + }, + }; + }, +}; diff --git a/modules/propresenter-now-next/style.css b/modules/propresenter-now-next/style.css new file mode 100644 index 0000000..8ef2efc --- /dev/null +++ b/modules/propresenter-now-next/style.css @@ -0,0 +1,297 @@ +/* ProPresenter Now / Next module — the wall display's visual language, + scaled to a tile. All sizes hang off --unit (set from the tile height by + client.js); all colors come from the shell's variables. */ + +[data-module="propresenter-now-next"] .nn-screen { + --unit: 12px; + --nn-pad: calc(var(--unit) * 1.1); + --nn-gap: calc(var(--unit) * 0.7); + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + gap: var(--nn-gap); + padding: var(--nn-pad); + user-select: none; + -webkit-user-select: none; +} + +/* With no NEXT list there is nothing below the card — centre it. */ +[data-module="propresenter-now-next"] .nn-screen.no-upcoming { + justify-content: center; +} + +/* ── top row ── */ + +[data-module="propresenter-now-next"] .nn-top { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--nn-gap); + flex-shrink: 0; + min-height: 0; +} +[data-module="propresenter-now-next"] .nn-top.hidden-row { display: none; } + +/* Losing the feed is the one thing the tile MUST admit to — overrides the + "hide the top row" preference. */ +[data-module="propresenter-now-next"] .is-offline .nn-top, +[data-module="propresenter-now-next"] .nn-screen.is-offline .nn-top { + display: flex !important; +} +[data-module="propresenter-now-next"] .is-offline .nn-live-text { + opacity: 1 !important; + color: var(--danger); +} +[data-module="propresenter-now-next"] .is-offline .nn-now { + border-color: color-mix(in srgb, var(--danger) 50%, transparent); +} + +[data-module="propresenter-now-next"] .nn-playlist { + flex: 1; + min-width: 0; + font-size: calc(var(--unit) * 1.0); + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +[data-module="propresenter-now-next"] .nn-live { + display: flex; + align-items: center; + gap: calc(var(--unit) * 0.45); + flex-shrink: 0; + font-size: calc(var(--unit) * 0.9); + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--muted); +} + +[data-module="propresenter-now-next"] .nn-live-dot { + width: calc(var(--unit) * 0.6); + height: calc(var(--unit) * 0.6); + border-radius: 999px; + background: var(--border); + transition: background 0.3s ease, box-shadow 0.3s ease; +} +[data-module="propresenter-now-next"] .nn-live.is-live .nn-live-dot { + background: var(--accent); + box-shadow: 0 0 0 calc(var(--unit) * 0.2) color-mix(in srgb, var(--accent) 18%, transparent); +} +[data-module="propresenter-now-next"] .nn-live.is-down .nn-live-dot { + background: var(--danger); + box-shadow: 0 0 0 calc(var(--unit) * 0.2) color-mix(in srgb, var(--danger) 16%, transparent); +} + +/* Idle tiles don't need a status chip staring at them — it fades once live. */ +[data-module="propresenter-now-next"] .nn-live.is-live .nn-live-text { + opacity: 0; + transition: opacity 0.6s ease 1.4s; +} + +/* ── NOW card ── + FIXED height so the title box is deterministic and the NOW/NEXT layout + cannot oscillate (see the wall display's notes). */ + +[data-module="propresenter-now-next"] .nn-now { + flex: 0 0 auto; + height: 42%; + min-height: calc(var(--unit) * 7); + display: flex; + flex-direction: column; + border: 1px solid var(--border); + border-radius: calc(var(--unit) * 0.9); + background: linear-gradient( + 160deg, + color-mix(in srgb, var(--accent) 9%, transparent), + color-mix(in srgb, var(--bg) 60%, transparent) + ); + padding: calc(var(--nn-pad) * 0.8) var(--nn-pad); +} + +[data-module="propresenter-now-next"] .nn-label { + flex-shrink: 0; + font-size: calc(var(--unit) * 0.9); + font-weight: 800; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--accent); +} + +[data-module="propresenter-now-next"] .nn-title-box { + flex: 1; + min-height: 0; + display: flex; + align-items: center; + overflow: hidden; + margin-top: calc(var(--unit) * 0.3); +} + +[data-module="propresenter-now-next"] .nn-title { + width: 100%; + /* Font size is set by fitNowTitle() so the title fills the box; keep + line-height ≥ the font's natural ~1.2 or large ascenders/descenders clip. */ + font-size: calc(var(--unit) * 2.4); + font-weight: 700; + line-height: 1.2; + color: var(--text); + /* Wrapping is controlled by fitNowTitle(), which forbids mid-word breaks. */ + overflow-wrap: normal; + word-break: normal; +} + +/* Status text, not a title: readable size, never the fill-the-box treatment. */ +[data-module="propresenter-now-next"] .nn-title.is-waiting { + color: var(--muted); + font-weight: 600; + font-size: calc(var(--unit) * 1.5); + white-space: normal; +} + +[data-module="propresenter-now-next"] .nn-hint { + flex-shrink: 0; + margin-top: calc(var(--unit) * 0.4); + font-size: calc(var(--unit) * 0.95); + line-height: 1.4; + color: var(--muted); +} + +[data-module="propresenter-now-next"] .nn-stale { + flex-shrink: 0; + margin-top: calc(var(--unit) * 0.4); + font-size: calc(var(--unit) * 0.95); + font-weight: 600; + color: var(--danger); +} + +[data-module="propresenter-now-next"] .nn-meta { + flex-shrink: 0; + margin-top: calc(var(--unit) * 0.45); + font-size: calc(var(--unit) * 1.15); + font-weight: 600; + color: var(--text); + opacity: 0.85; + font-variant-numeric: tabular-nums; + /* Never wrap: a wrap changes the title box height, which refits the title. */ + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +[data-module="propresenter-now-next"] .nn-meta:empty { display: none; } + +[data-module="propresenter-now-next"] .nn-progress { + flex-shrink: 0; + margin-top: calc(var(--unit) * 0.55); + height: calc(var(--unit) * 0.4); + border-radius: 999px; + background: color-mix(in srgb, var(--text) 10%, transparent); + overflow: hidden; +} +[data-module="propresenter-now-next"] .nn-progress[hidden] { display: none; } + +[data-module="propresenter-now-next"] .nn-progress-fill { + height: 100%; + border-radius: 999px; + background: color-mix(in srgb, var(--accent) 65%, var(--panel)); + transition: width 0.25s ease; +} + +/* ── NEXT list ── */ + +[data-module="propresenter-now-next"] .nn-next-wrap { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + gap: calc(var(--unit) * 0.5); +} + +[data-module="propresenter-now-next"] .nn-next-label { + flex-shrink: 0; + font-size: calc(var(--unit) * 0.9); + font-weight: 800; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--muted); +} +[data-module="propresenter-now-next"] .nn-next-label[hidden] { display: none; } + +[data-module="propresenter-now-next"] .nn-next-list { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + gap: calc(var(--unit) * 0.45); + overflow: hidden; +} + +[data-module="propresenter-now-next"] .nn-next-item { + display: flex; + align-items: center; + gap: var(--nn-gap); + padding: calc(var(--unit) * 0.6) calc(var(--unit) * 0.85); + border: 1px solid var(--border); + border-radius: calc(var(--unit) * 0.6); + background: color-mix(in srgb, var(--bg) 70%, transparent); +} + +[data-module="propresenter-now-next"] .nn-next-name { + flex: 1; + min-width: 0; + font-size: calc(var(--unit) * 1.25); + font-weight: 600; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +[data-module="propresenter-now-next"] .nn-count-chip { + flex-shrink: 0; + min-width: calc(var(--unit) * 2); + padding: calc(var(--unit) * 0.15) calc(var(--unit) * 0.5); + text-align: center; + border-radius: 999px; + background: color-mix(in srgb, var(--accent) 22%, transparent); + color: var(--text); + font-size: calc(var(--unit) * 0.95); + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +[data-module="propresenter-now-next"] .nn-type-tag { + flex-shrink: 0; + font-size: calc(var(--unit) * 0.8); + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--muted); +} + +[data-module="propresenter-now-next"] .nn-next-item.is-header { + background: none; + border: none; + border-bottom: 1px solid var(--border); + border-radius: 0; + padding: calc(var(--unit) * 0.55) calc(var(--unit) * 0.1) calc(var(--unit) * 0.2); +} +[data-module="propresenter-now-next"] .nn-next-item.is-header .nn-next-name { + font-size: calc(var(--unit) * 0.9); + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--muted); +} + +[data-module="propresenter-now-next"] .nn-next-more { + flex-shrink: 0; + font-size: calc(var(--unit) * 0.9); + color: var(--muted); + font-weight: 600; +} +[data-module="propresenter-now-next"] .nn-next-more[hidden] { display: none; } From 0b53616160531486a45b5dca7bfc897ff3eaadd3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 15:16:09 +0000 Subject: [PATCH 5/9] Admin page: schema-driven config, enable/disable, live re-init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milestone 5. GET /api/admin/state, PUT /api/admin/modules//config and /enabled persist to config/modules.json and re-init the module in place — no restart. /api/events (SSE) tells open dashboards about admin changes; the shell then calls onConfigChange(cfg) on instances that implement it and cleanly remounts the rest, so tiles reconnect on their own. Admin page renders forms from each configSchema (text/number/boolean/select/ password), shows module health from handle.health(), and never echoes password values back to a browser. Optional shared passcode (config/proddash.json adminPasscode or PRODDASH_PASSCODE) gates the admin API behind a cookie; the dashboard API stays open. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH --- public/admin/admin.css | 154 +++++++++++++++++ public/admin/admin.js | 372 ++++++++++++++++++++++++++++++++++++++++ public/admin/index.html | 49 ++++++ public/shell.js | 57 +++++- server.js | 189 ++++++++++++++++++++ 5 files changed, 819 insertions(+), 2 deletions(-) create mode 100644 public/admin/admin.css create mode 100644 public/admin/admin.js create mode 100644 public/admin/index.html diff --git a/public/admin/admin.css b/public/admin/admin.css new file mode 100644 index 0000000..5f07ca1 --- /dev/null +++ b/public/admin/admin.css @@ -0,0 +1,154 @@ +/* ProdDash admin page — on top of the shell's style.css */ + +body { overflow: auto; } + +.admin-tag { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); + border: 1px solid var(--border); + border-radius: 5px; + padding: 2px 6px; + margin-left: 6px; + vertical-align: 2px; +} + +#admin-main { + flex: 1; + overflow-y: auto; + width: min(720px, 100%); + margin: 0 auto; + padding: 18px 16px 60px; +} + +.admin-h1 { + font-size: 16px; + margin: 22px 0 6px; +} + +.admin-copy { + color: var(--muted); + font-size: 13px; + line-height: 1.5; + margin-bottom: 14px; +} + +.admin-card { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 12px; + padding: 14px 16px; + margin-bottom: 14px; +} + +.admin-card h2 { font-size: 14px; margin-bottom: 8px; } + +.login-row { display: flex; gap: 8px; } +.login-row input { + flex: 1; + padding: 8px 10px; + font-size: 14px; + color: var(--text); + background: var(--bg); + border: 1px solid var(--border); + border-radius: 7px; +} +.login-row input:focus { outline: none; border-color: var(--accent); } + +.module-head { + display: flex; + align-items: center; + gap: 10px; +} + +.module-head .dot { width: 9px; height: 9px; border-radius: 50%; background: var(--muted); flex: none; } +.module-head .dot.ok { background: var(--accent); box-shadow: 0 0 5px var(--accent); } +.module-head .dot.connecting { background: var(--warn); } +.module-head .dot.error { background: var(--danger); } + +.module-name { font-size: 14px; font-weight: 700; } +.module-version { color: var(--muted); font-size: 11px; } +.module-head .spacer { flex: 1; } + +.module-desc { color: var(--muted); font-size: 12px; margin: 6px 0 2px; } + +.module-health { + font-size: 12px; + color: var(--muted); + margin-top: 4px; + min-height: 15px; +} +.module-health.error { color: var(--danger); } + +.module-mount-error { + margin-top: 8px; + padding: 8px 10px; + border: 1px solid var(--danger); + border-radius: 8px; + color: var(--danger); + font-size: 12px; +} + +.module-form { margin-top: 12px; border-top: 1px solid var(--border); padding-top: 12px; } +.module-form .field { max-width: 420px; } +.module-form-empty { color: var(--muted); font-size: 12px; margin-top: 10px; } + +.module-actions { + display: flex; + align-items: center; + gap: 10px; + margin-top: 4px; +} + +.admin-status { min-height: 16px; font-size: 12px; color: var(--muted); margin-top: 8px; } +.admin-status.error { color: var(--danger); } +.admin-status.ok { color: var(--accent); } + +/* enable/disable switch */ +.switch { + position: relative; + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 12px; + color: var(--muted); + user-select: none; +} +.switch input { position: absolute; opacity: 0; pointer-events: none; } +.switch .track { + width: 34px; + height: 19px; + border-radius: 999px; + background: var(--border); + position: relative; + transition: background 0.2s; + flex: none; +} +.switch .track::after { + content: ""; + position: absolute; + top: 2px; left: 2px; + width: 15px; height: 15px; + border-radius: 50%; + background: var(--muted); + transition: transform 0.2s, background 0.2s; +} +.switch input:checked + .track { background: var(--accent); } +.switch input:checked + .track::after { transform: translateX(15px); background: var(--bg); } + +.layout-row { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 12px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + margin-bottom: 8px; +} +.layout-row .name { flex: 1; font-size: 13px; font-weight: 600; } +.layout-row .meta { color: var(--muted); font-size: 11px; } +.layouts-empty { color: var(--muted); font-size: 13px; } diff --git a/public/admin/admin.js b/public/admin/admin.js new file mode 100644 index 0000000..b137d68 --- /dev/null +++ b/public/admin/admin.js @@ -0,0 +1,372 @@ +/* ProdDash admin client. + + Everything here is server-wide: module enable/disable and admin config + (written to config/modules.json, module re-inited on save), plus named + layout management. Guarded by the shared passcode when one is set. */ + +const loginCard = document.getElementById('login-card'); +const loginStatus = document.getElementById('login-status'); +const passcodeInput = document.getElementById('passcode'); +const modulesSection = document.getElementById('modules-section'); +const modulesList = document.getElementById('modules-list'); +const layoutsSection = document.getElementById('layouts-section'); +const layoutsList = document.getElementById('layouts-list'); +const toastEl = document.getElementById('toast'); + +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); +} + +async function api(path, opts = {}) { + const res = await fetch(path, { + ...opts, + headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) }, + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) { + const err = new Error(body.error || `Request failed (${res.status})`); + err.status = res.status; + err.authRequired = Boolean(body.authRequired); + throw err; + } + return body; +} + +/* ── login ──────────────────────────────────────────────────────────── */ + +function showLogin() { + loginCard.hidden = false; + modulesSection.hidden = true; + layoutsSection.hidden = true; + passcodeInput.focus(); +} + +async function login() { + loginStatus.textContent = ''; + loginStatus.className = 'admin-status'; + try { + await api('/api/admin/login', { method: 'POST', body: JSON.stringify({ passcode: passcodeInput.value }) }); + passcodeInput.value = ''; + loginCard.hidden = true; + await loadState(); + } catch (e) { + loginStatus.textContent = e.message; + loginStatus.className = 'admin-status error'; + } +} + +document.getElementById('login-btn').addEventListener('click', login); +passcodeInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') login(); +}); + +/* ── module cards ───────────────────────────────────────────────────── */ + +/** moduleId -> { dot, healthEl, toggle } for cheap in-place status refreshes */ +const liveEls = new Map(); + +function healthClass(mod) { + if (!mod.enabled) return ''; + if (mod.mountError) return 'error'; + return mod.health ? mod.health.status : 'ok'; +} + +function healthText(mod) { + if (!mod.enabled) return 'Disabled — hidden from the dashboard picker, server routes unmounted.'; + if (mod.mountError) return ''; + if (mod.health) return mod.health.message || mod.health.status; + return mod.hasServer ? 'Mounted.' : 'Client-only module (no server part).'; +} + +function fieldFor(key, spec, value, passwordSet) { + const type = spec?.type || 'string'; + const label = spec?.label || key; + const field = document.createElement('label'); + field.className = 'field' + (type === 'boolean' ? ' check' : ''); + let read; + + if (type === 'boolean') { + const input = document.createElement('input'); + input.type = 'checkbox'; + input.checked = Boolean(value); + field.append(input, document.createTextNode(label)); + read = () => input.checked; + } else if (type === 'select' && Array.isArray(spec.options)) { + const span = document.createElement('span'); + span.textContent = label; + const select = document.createElement('select'); + for (const opt of spec.options) { + const o = document.createElement('option'); + o.value = String(typeof opt === 'object' ? opt.value : opt); + o.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt); + select.appendChild(o); + } + select.value = String(value ?? ''); + field.append(span, select); + read = () => select.value; + } else { + const span = document.createElement('span'); + span.textContent = label; + const input = document.createElement('input'); + input.type = type === 'number' ? 'number' : (type === 'password' ? 'password' : 'text'); + if (type === 'password') { + input.placeholder = passwordSet ? 'Saved — leave blank to keep' : 'Not set'; + input.autocomplete = 'new-password'; + } else { + input.value = value === undefined || value === null ? '' : String(value); + } + field.append(span, input); + read = () => (type === 'number' ? Number(input.value) : input.value); + } + return { field, read }; +} + +function buildModuleCard(mod) { + const card = document.createElement('section'); + card.className = 'admin-card'; + + const head = document.createElement('div'); + head.className = 'module-head'; + + const dot = document.createElement('span'); + dot.className = 'dot ' + healthClass(mod); + + const name = document.createElement('span'); + name.className = 'module-name'; + name.textContent = mod.name; + + const version = document.createElement('span'); + version.className = 'module-version'; + version.textContent = mod.version ? 'v' + mod.version : ''; + + const spacer = document.createElement('span'); + spacer.className = 'spacer'; + + const toggleLabel = document.createElement('label'); + toggleLabel.className = 'switch'; + const toggle = document.createElement('input'); + toggle.type = 'checkbox'; + toggle.checked = mod.enabled; + const track = document.createElement('span'); + track.className = 'track'; + const toggleText = document.createElement('span'); + toggleText.textContent = mod.enabled ? 'Enabled' : 'Disabled'; + toggleLabel.append(toggle, track, toggleText); + + head.append(dot, name, version, spacer, toggleLabel); + card.appendChild(head); + + const desc = document.createElement('div'); + desc.className = 'module-desc'; + desc.textContent = mod.description; + card.appendChild(desc); + + const healthEl = document.createElement('div'); + healthEl.className = 'module-health ' + (healthClass(mod) === 'error' ? 'error' : ''); + healthEl.textContent = healthText(mod); + card.appendChild(healthEl); + + const mountError = document.createElement('div'); + mountError.className = 'module-mount-error'; + mountError.hidden = !mod.mountError; + mountError.textContent = mod.mountError ? `Failed to start: ${mod.mountError}` : ''; + card.appendChild(mountError); + + liveEls.set(mod.id, { dot, healthEl, toggle, toggleText, mountError }); + + toggle.addEventListener('change', async () => { + try { + await api(`/api/admin/modules/${encodeURIComponent(mod.id)}/enabled`, { + method: 'PUT', + body: JSON.stringify({ enabled: toggle.checked }), + }); + toggleText.textContent = toggle.checked ? 'Enabled' : 'Disabled'; + toast(`${mod.name} ${toggle.checked ? 'enabled' : 'disabled'}`); + refreshStatus(); + } catch (e) { + toggle.checked = !toggle.checked; + if (e.authRequired) showLogin(); + else toast(e.message, true); + } + }); + + const schemaKeys = Object.entries(mod.configSchema || {}); + if (schemaKeys.length) { + const form = document.createElement('div'); + form.className = 'module-form'; + const readers = new Map(); + for (const [key, spec] of schemaKeys) { + const { field, read } = fieldFor(key, spec, mod.config[key], mod.passwordSet[key]); + readers.set(key, read); + form.appendChild(field); + } + const actions = document.createElement('div'); + actions.className = 'module-actions'; + const save = document.createElement('button'); + save.className = 'btn primary'; + save.textContent = 'Save & re-init'; + const status = document.createElement('span'); + status.className = 'admin-status'; + actions.append(save, status); + form.appendChild(actions); + card.appendChild(form); + + save.addEventListener('click', async () => { + const config = {}; + for (const [key, read] of readers) config[key] = read(); + save.disabled = true; + status.textContent = 'Saving…'; + status.className = 'admin-status'; + try { + await api(`/api/admin/modules/${encodeURIComponent(mod.id)}/config`, { + method: 'PUT', + body: JSON.stringify({ config }), + }); + status.textContent = 'Saved — module re-initialised. Open tiles reconnect on their own.'; + status.className = 'admin-status ok'; + refreshStatus(); + } catch (e) { + status.textContent = e.message; + status.className = 'admin-status error'; + if (e.authRequired) showLogin(); + } finally { + save.disabled = false; + } + }); + } else { + const none = document.createElement('div'); + none.className = 'module-form-empty'; + none.textContent = 'No server-wide settings — this module is configured per tile.'; + card.appendChild(none); + } + + return card; +} + +/* ── status refresh (leaves form inputs alone) ──────────────────────── */ + +async function refreshStatus() { + let state; + try { + state = await api('/api/admin/state'); + } catch { + return; // transient — the next tick will get it + } + for (const mod of state.modules || []) { + const els = liveEls.get(mod.id); + if (!els) continue; + els.dot.className = 'dot ' + healthClass(mod); + els.healthEl.textContent = healthText(mod); + els.healthEl.className = 'module-health ' + (healthClass(mod) === 'error' ? 'error' : ''); + els.mountError.hidden = !mod.mountError; + els.mountError.textContent = mod.mountError ? `Failed to start: ${mod.mountError}` : ''; + if (document.activeElement !== els.toggle) { + els.toggle.checked = mod.enabled; + els.toggleText.textContent = mod.enabled ? 'Enabled' : 'Disabled'; + } + } +} + +/* ── named layouts ──────────────────────────────────────────────────── */ + +async function loadLayouts() { + let body; + try { + body = await api('/api/layouts'); + } catch { + layoutsSection.hidden = true; + return; + } + layoutsSection.hidden = false; + layoutsList.innerHTML = ''; + const layouts = body.layouts || []; + if (!layouts.length) { + const none = document.createElement('div'); + none.className = 'layouts-empty'; + none.textContent = 'No named layouts saved yet. Save one from the dashboard’s Layout menu.'; + layoutsList.appendChild(none); + return; + } + for (const layout of layouts) { + const row = document.createElement('div'); + row.className = 'layout-row'; + const name = document.createElement('span'); + name.className = 'name'; + name.textContent = layout.name; + const meta = document.createElement('span'); + meta.className = 'meta'; + meta.textContent = `${layout.tiles} tile${layout.tiles === 1 ? '' : 's'}` + + (layout.updated ? ` · ${new Date(layout.updated).toLocaleString()}` : ''); + const rename = document.createElement('button'); + rename.className = 'btn'; + rename.textContent = 'Rename'; + rename.addEventListener('click', async () => { + const next = prompt('New name for this layout:', layout.name); + if (!next || next.trim() === '' || next === layout.name) return; + try { + await api(`/api/layouts/${encodeURIComponent(layout.name)}/rename`, { + method: 'POST', + body: JSON.stringify({ name: next.trim() }), + }); + toast('Layout renamed'); + loadLayouts(); + } catch (e) { + if (e.authRequired) showLogin(); + else toast(e.message, true); + } + }); + const del = document.createElement('button'); + del.className = 'btn'; + del.textContent = 'Delete'; + del.addEventListener('click', async () => { + if (!confirm(`Delete the layout “${layout.name}”? Browsers using it keep their current tiles.`)) return; + try { + await api(`/api/layouts/${encodeURIComponent(layout.name)}`, { method: 'DELETE' }); + toast('Layout deleted'); + loadLayouts(); + } catch (e) { + if (e.authRequired) showLogin(); + else toast(e.message, true); + } + }); + row.append(name, meta, rename, del); + layoutsList.appendChild(row); + } +} + +/* ── boot ───────────────────────────────────────────────────────────── */ + +async function loadState() { + let state; + try { + state = await api('/api/admin/state'); + } catch (e) { + if (e.status === 401 || e.authRequired) { + showLogin(); + return; + } + toast('ProdDash server unreachable — retrying…', true); + setTimeout(loadState, 4000); + return; + } + loginCard.hidden = true; + modulesSection.hidden = false; + modulesList.innerHTML = ''; + liveEls.clear(); + for (const mod of state.modules || []) { + modulesList.appendChild(buildModuleCard(mod)); + } + loadLayouts(); +} + +setInterval(() => { + if (modulesSection.hidden) return; + refreshStatus(); +}, 5000); + +loadState(); diff --git a/public/admin/index.html b/public/admin/index.html new file mode 100644 index 0000000..a273555 --- /dev/null +++ b/public/admin/index.html @@ -0,0 +1,49 @@ + + + + + + ProdDash Admin + + + + + +
+ ProdDash Admin + + ← Dashboard +
+ +
+ + + + + +
+ + + + + + diff --git a/public/shell.js b/public/shell.js index 4797c28..2c40093 100644 --- a/public/shell.js +++ b/public/shell.js @@ -461,8 +461,11 @@ function buildModuleApi(tile, man, entry) { id: man.id, instanceId: tile.id, - /** Admin (server-wide) config, read-only. Password fields never reach the client. */ - config: Object.freeze({ ...(man.config || {}) }), + /** 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() { @@ -716,6 +719,55 @@ wireDropdown('layout-btn', 'layout-menu', async (menu) => { }, { danger: true, sub: 'Remove every tile from this browser' })); }); +/* ── live updates from the admin page ───────────────────────────────── */ + +/* The server broadcasts `modules-changed` on /api/events whenever admin + config or enabled flags change. Tiles of a changed module get + onConfigChange(cfg) if they implement it, otherwise a clean remount. */ + +let eventsSource = null; +let eventsRetry = null; + +function subscribeEvents() { + try { eventsSource?.close(); } catch { /* not open */ } + eventsSource = new EventSource('/api/events'); + eventsSource.addEventListener('modules-changed', () => handleModulesChanged()); + eventsSource.onerror = () => { + if (eventsSource.readyState === EventSource.CLOSED) { + clearTimeout(eventsRetry); + eventsRetry = setTimeout(subscribeEvents, 4000); + } + }; +} + +async function handleModulesChanged() { + const before = registry; + if (!(await loadRegistry())) return; + for (const tile of [...tiles]) { + const oldMan = before.get(tile.module); + const newMan = registry.get(tile.module); + if (!newMan !== !oldMan) { + // enabled or disabled: rebuild the tile body (shows the module, or + // an "unavailable" note) + remountTile(tile); + continue; + } + if (!newMan) continue; + if (JSON.stringify(oldMan.config || {}) !== JSON.stringify(newMan.config || {})) { + const entry = tileEls.get(tile.id); + if (typeof entry?.instance?.onConfigChange === 'function') { + try { + entry.instance.onConfigChange({ ...newMan.config }); + } catch { + remountTile(tile); + } + } else { + remountTile(tile); + } + } + } +} + /* ── boot ───────────────────────────────────────────────────────────── */ async function boot() { @@ -726,6 +778,7 @@ async function boot() { return; } renderLayout(loadLayoutFromStorage()); + subscribeEvents(); } boot(); diff --git a/server.js b/server.js index 48eb34e..8670ec7 100644 --- a/server.js +++ b/server.js @@ -18,6 +18,7 @@ const http = require('http'); const fs = require('fs'); const path = require('path'); const os = require('os'); +const crypto = require('crypto'); const ROOT = __dirname; const PUBLIC_DIR = path.join(ROOT, 'public'); @@ -260,6 +261,169 @@ function clientManifest(id) { }; } +/* ── shell events (SSE to every open dashboard/admin page) ──────────── */ + +const shellStreams = new Set(); + +function broadcastShellEvent(type, payload = {}) { + if (!shellStreams.size) return; + const frame = `event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`; + for (const res of shellStreams) { + try { + res.write(frame); + } catch { + shellStreams.delete(res); + } + } +} + +setInterval(() => { + for (const res of shellStreams) { + try { + res.write(': ping\n\n'); + } catch { + shellStreams.delete(res); + } + } +}, 25000).unref(); + +/* ── admin API ──────────────────────────────────────────────────────── */ + +const ADMIN_PASSCODE = String(process.env.PRODDASH_PASSCODE || shellConfig.adminPasscode || ''); +/** Session token: regenerating it every boot is fine — admins just re-enter the passcode. */ +const adminToken = crypto.randomBytes(24).toString('hex'); + +function isAuthed(req) { + if (!ADMIN_PASSCODE) return true; + const cookies = String(req.headers.cookie || ''); + return cookies.split(';').some((c) => c.trim() === 'proddash_admin=' + adminToken); +} + +/** Guard a state-changing admin route. Returns true when the request was refused. */ +function refuseAdminWrite(req, res) { + if (!isAuthed(req)) { + sendJson(res, 401, { error: 'Enter the admin passcode first.', authRequired: true }); + return true; + } + // Requiring application/json forces a CORS preflight that a hostile page + // on some other origin cannot pass (same reasoning as the reference apps). + const contentType = String(req.headers['content-type'] || '').toLowerCase(); + if (!contentType.startsWith('application/json')) { + sendJson(res, 415, { error: 'Send application/json.' }); + return true; + } + return false; +} + +/** Everything the admin page needs, per module. */ +function adminModuleView(id) { + const man = manifests.get(id); + const cfg = effectiveConfig(id); + const values = {}; + const passwordSet = {}; + for (const [key, spec] of Object.entries(man.configSchema || {})) { + if (spec?.type === 'password') { + passwordSet[key] = Boolean(cfg[key]); + values[key] = ''; // never echo secrets back to a browser + } else { + values[key] = cfg[key]; + } + } + const entry = mounted.get(id); + return { + id, + name: String(man.name || id), + version: String(man.version || ''), + description: String(man.description || ''), + hasServer: Boolean(man.server), + enabled: isEnabled(id), + configSchema: man.configSchema || {}, + config: values, + passwordSet, + mountError: entry?.error || '', + health: isEnabled(id) ? moduleHealth(id) : null, + }; +} + +/** Coerce and store a config patch according to the module's schema. + Empty password fields mean "keep what is stored". */ +function applyConfigPatch(id, patch) { + const man = manifests.get(id); + const current = effectiveConfig(id); + const next = {}; + for (const [key, spec] of Object.entries(man.configSchema || {})) { + const type = spec?.type || 'string'; + let value = patch && typeof patch === 'object' && key in patch ? patch[key] : current[key]; + if (type === 'password' && (value === '' || value === undefined || value === null)) { + value = current[key] || ''; + } + if (type === 'number') value = Number(value) || 0; + else if (type === 'boolean') value = Boolean(value); + else value = value === undefined || value === null ? '' : String(value); + next[key] = value; + } + modulesConfig[id] = { ...moduleState(id), enabled: isEnabled(id), config: next }; + writeJson(MODULES_CONFIG_PATH, modulesConfig); +} + +async function handleAdminApi(req, res, urlPath) { + if (urlPath === '/api/admin/login' && req.method === 'POST') { + let body = {}; + try { + body = JSON.parse((await readBody(req)) || '{}'); + } catch { + return sendJson(res, 400, { error: 'Malformed request.' }); + } + if (!ADMIN_PASSCODE) return sendJson(res, 200, { ok: true, authRequired: false }); + if (String(body.passcode || '') !== ADMIN_PASSCODE) { + return sendJson(res, 403, { error: 'Wrong passcode.' }); + } + res.writeHead(200, { + 'Content-Type': 'application/json; charset=utf-8', + 'Set-Cookie': `proddash_admin=${adminToken}; Path=/; SameSite=Lax; HttpOnly`, + 'Cache-Control': 'no-store', + }); + return res.end(JSON.stringify({ ok: true })); + } + + if (urlPath === '/api/admin/state' && req.method === 'GET') { + if (!isAuthed(req)) { + return sendJson(res, 401, { authRequired: true, authed: false }); + } + return sendJson(res, 200, { + authRequired: Boolean(ADMIN_PASSCODE), + authed: true, + modules: [...manifests.keys()].sort().map(adminModuleView), + }); + } + + const m = urlPath.match(/^\/api\/admin\/modules\/([^/]+)\/(config|enabled)$/); + if (m && req.method === 'PUT') { + const id = decodeURIComponent(m[1]); + if (!manifests.has(id)) return sendJson(res, 404, { error: `No module "${id}" is installed.` }); + if (refuseAdminWrite(req, res)) return; + let body = {}; + try { + body = JSON.parse((await readBody(req)) || '{}'); + } catch { + return sendJson(res, 400, { error: 'Malformed request.' }); + } + if (m[2] === 'config') { + applyConfigPatch(id, body.config); + remountModule(id); // config changes take effect without a manual restart + } else { + const enabled = Boolean(body.enabled); + modulesConfig[id] = { ...moduleState(id), enabled }; + writeJson(MODULES_CONFIG_PATH, modulesConfig); + remountModule(id); // mounts when enabled, unmounts (only) otherwise + } + broadcastShellEvent('modules-changed', { id }); + return sendJson(res, 200, { ok: true, module: adminModuleView(id) }); + } + + sendJson(res, 404, { error: 'Unknown admin endpoint.' }); +} + /* ── module API dispatch ────────────────────────────────────────────── */ function dispatchModuleApi(req, res, id, subPath, search) { @@ -371,6 +535,31 @@ function handleRequest(req, res) { return sendJson(res, 200, { modules: list }); } + /* — shell events: open dashboards learn about admin changes live — */ + if (urlPath === '/api/events' && req.method === 'GET') { + res.writeHead(200, { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + res.write('retry: 3000\n\n'); + try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ } + shellStreams.add(res); + req.on('close', () => shellStreams.delete(res)); + return; + } + + /* — admin API — */ + if (urlPath.startsWith('/api/admin/')) { + handleAdminApi(req, res, urlPath).catch((err) => { + console.error('[proddash] admin API error:', err); + if (!res.headersSent) sendJson(res, 500, { error: 'Server error.' }); + else try { res.end(); } catch { /* gone */ } + }); + return; + } + /* — module APIs: /api/modules//… (any method; modules decide) — */ const apiMatch = urlPath.match(/^\/api\/modules\/([^/]+)(\/.*)?$/); if (apiMatch) { From f0c5a0f1a71356f07dd7d21e7986f8e175f19f2a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 15:17:52 +0000 Subject: [PATCH 6/9] Named layouts: save to server, load from any browser, admin management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milestone 6. Layouts CRUD as JSON under config/layouts/ — GET list, GET/PUT by name (open to every dashboard), DELETE and rename gated by the admin passcode. Layout menu grows 'Save as named layout…' and a 'Load from server' list; loading copies the layout into the browser's own local state, never live-links viewers. Admin page lists, renames and deletes named layouts. Verified with two isolated browser contexts holding different layouts simultaneously. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH --- public/shell.js | 62 +++++++++++++++++++++++ server.js | 131 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+) diff --git a/public/shell.js b/public/shell.js index 2c40093..6ae8ec5 100644 --- a/public/shell.js +++ b/public/shell.js @@ -711,6 +711,68 @@ wireDropdown('add-btn', 'add-menu', async (menu) => { wireDropdown('layout-btn', 'layout-menu', async (menu) => { menu.innerHTML = ''; + + menu.appendChild(menuItem('Save as named layout…', async () => { + menu.hidden = true; + if (!tiles.length) { + toast('Nothing to save — this dashboard is empty', true); + return; + } + const name = await promptModal({ + title: 'Save this layout to the server', + placeholder: 'e.g. FOH booth, Video world', + okLabel: 'Save', + }); + if (!name) return; + try { + const res = await fetch('/api/layouts/' + encodeURIComponent(name), { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ layout: { tiles } }), + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(body.error || 'Save failed (' + res.status + ')'); + toast(`Saved “${name}” — any browser can load it now`); + } catch (e) { + toast(e.message, true); + } + }, { sub: 'Share this arrangement with other browsers' })); + + const divider = document.createElement('div'); + divider.className = 'menu-divider'; + menu.appendChild(divider); + + // named layouts stored on the server + let layouts = []; + try { + const res = await fetch('/api/layouts'); + if (res.ok) layouts = (await res.json()).layouts || []; + } catch { /* server briefly away — the menu just shows none */ } + if (layouts.length) { + const title = document.createElement('div'); + title.className = 'menu-title'; + title.textContent = 'Load from server'; + menu.appendChild(title); + for (const l of layouts) { + menu.appendChild(menuItem(l.name, async () => { + menu.hidden = true; + try { + const res = await fetch('/api/layouts/' + encodeURIComponent(l.name)); + const body = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(body.error || 'Load failed (' + res.status + ')'); + // Loading copies the layout into this browser's own state — it + // does not live-link browsers together. + renderLayout(body.layout.tiles || []); + saveLayout(); + toast(`Loaded “${l.name}”`); + } catch (e) { + toast(e.message, true); + } + }, { sub: `${l.tiles} tile${l.tiles === 1 ? '' : 's'}` })); + } + menu.appendChild(divider.cloneNode()); + } + menu.appendChild(menuItem('Reset layout', () => { menu.hidden = true; renderLayout([]); diff --git a/server.js b/server.js index 8670ec7..1d8dd38 100644 --- a/server.js +++ b/server.js @@ -424,6 +424,127 @@ async function handleAdminApi(req, res, urlPath) { sendJson(res, 404, { error: 'Unknown admin endpoint.' }); } +/* ── named layouts (JSON files under config/layouts/) ───────────────── */ + +const LAYOUTS_DIR = path.join(CONFIG_DIR, 'layouts'); + +/** Names double as file names — keep them boring on purpose. */ +function layoutNameOk(name) { + return typeof name === 'string' && /^[A-Za-z0-9][A-Za-z0-9 _()-]{0,39}$/.test(name); +} + +function layoutPath(name) { + return path.join(LAYOUTS_DIR, name + '.json'); +} + +/** Keep only the fields a layout is made of; everything else is dropped. */ +function sanitizeLayout(raw) { + const tiles = Array.isArray(raw?.tiles) ? raw.tiles.slice(0, 100) : []; + return { + tiles: tiles.map((t) => ({ + id: String(t?.id || ''), + module: String(t?.module || ''), + x: Number(t?.x) || 0, + y: Number(t?.y) || 0, + w: Number(t?.w) || 1, + h: Number(t?.h) || 1, + settings: t?.settings && typeof t.settings === 'object' ? t.settings : {}, + })), + }; +} + +async function handleLayoutsApi(req, res, urlPath) { + if (urlPath === '/api/layouts' && req.method === 'GET') { + let files = []; + try { + files = fs.readdirSync(LAYOUTS_DIR).filter((f) => f.endsWith('.json')); + } catch { /* no layouts dir yet */ } + const layouts = []; + for (const file of files.sort()) { + const name = file.slice(0, -5); + const data = readJson(path.join(LAYOUTS_DIR, file), null); + if (!data) continue; + let updated = null; + try { updated = fs.statSync(path.join(LAYOUTS_DIR, file)).mtime.toISOString(); } catch { /* fine */ } + layouts.push({ name, tiles: Array.isArray(data.tiles) ? data.tiles.length : 0, updated }); + } + return sendJson(res, 200, { layouts }); + } + + const m = urlPath.match(/^\/api\/layouts\/([^/]+)(\/rename)?$/); + if (!m) return sendJson(res, 404, { error: 'Unknown endpoint.' }); + let name; + try { + name = decodeURIComponent(m[1]); + } catch { + return sendJson(res, 400, { error: 'Bad layout name.' }); + } + if (!layoutNameOk(name)) { + return sendJson(res, 400, { error: 'Layout names can use letters, numbers, spaces, dashes and parentheses (max 40).' }); + } + + if (!m[2] && req.method === 'GET') { + const data = readJson(layoutPath(name), null); + if (!data) return sendJson(res, 404, { error: `No layout named "${name}".` }); + return sendJson(res, 200, { name, layout: sanitizeLayout(data) }); + } + + // Saving is open to every dashboard (volunteers save their booth setups); + // renaming and deleting are admin actions. + if (!m[2] && req.method === 'PUT') { + const contentType = String(req.headers['content-type'] || '').toLowerCase(); + if (!contentType.startsWith('application/json')) { + return sendJson(res, 415, { error: 'Send application/json.' }); + } + let body = {}; + try { + body = JSON.parse((await readBody(req)) || '{}'); + } catch { + return sendJson(res, 400, { error: 'Malformed request.' }); + } + const layout = sanitizeLayout(body.layout); + if (!layout.tiles.length) return sendJson(res, 400, { error: 'Refusing to save an empty layout.' }); + writeJson(layoutPath(name), layout); + return sendJson(res, 200, { ok: true, name }); + } + + if (!m[2] && req.method === 'DELETE') { + if (!isAuthed(req)) return sendJson(res, 401, { error: 'Enter the admin passcode first.', authRequired: true }); + try { + fs.unlinkSync(layoutPath(name)); + } catch { + return sendJson(res, 404, { error: `No layout named "${name}".` }); + } + return sendJson(res, 200, { ok: true }); + } + + if (m[2] && req.method === 'POST') { + if (refuseAdminWrite(req, res)) return; + let body = {}; + try { + body = JSON.parse((await readBody(req)) || '{}'); + } catch { + return sendJson(res, 400, { error: 'Malformed request.' }); + } + const next = String(body.name || '').trim(); + if (!layoutNameOk(next)) { + return sendJson(res, 400, { error: 'Layout names can use letters, numbers, spaces, dashes and parentheses (max 40).' }); + } + if (!fs.existsSync(layoutPath(name))) return sendJson(res, 404, { error: `No layout named "${name}".` }); + if (next !== name && fs.existsSync(layoutPath(next))) { + return sendJson(res, 409, { error: `A layout named "${next}" already exists.` }); + } + try { + fs.renameSync(layoutPath(name), layoutPath(next)); + } catch (err) { + return sendJson(res, 500, { error: 'Rename failed: ' + (err?.message || err) }); + } + return sendJson(res, 200, { ok: true, name: next }); + } + + sendJson(res, 405, { error: 'Method not allowed.' }); +} + /* ── module API dispatch ────────────────────────────────────────────── */ function dispatchModuleApi(req, res, id, subPath, search) { @@ -550,6 +671,16 @@ function handleRequest(req, res) { return; } + /* — named layouts — */ + if (urlPath === '/api/layouts' || urlPath.startsWith('/api/layouts/')) { + handleLayoutsApi(req, res, urlPath).catch((err) => { + console.error('[proddash] layouts API error:', err); + if (!res.headersSent) sendJson(res, 500, { error: 'Server error.' }); + else try { res.end(); } catch { /* gone */ } + }); + return; + } + /* — admin API — */ if (urlPath.startsWith('/api/admin/')) { handleAdminApi(req, res, urlPath).catch((err) => { From b14bc4a182b7737a6fcea72fcaa50ab8497dc732 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 15:21:05 +0000 Subject: [PATCH 7/9] Docs, README, launchers, PWA install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milestone 7. docs/MODULE-GUIDE.md documents the whole module contract from an author's point of view — manifest reference, client factory + moduleApi, server init/routes/health, scoping and resilience rules, a copy-paste minimal module, and a testing section with the two mocks and a resilience checklist. README rewritten with run/configure/use instructions in the prodcom-listener style, plus double-click launchers for macOS and Windows. PWA: manifest, icons, and a minimal network-first service worker that never touches /api/*. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH --- README.md | 142 ++++++++++++-- Start ProdDash.command | 4 + docs/MODULE-GUIDE.md | 364 ++++++++++++++++++++++++++++++++++++ public/admin/index.html | 1 + public/icons/icon-180.png | Bin 0 -> 4193 bytes public/icons/icon-192.png | Bin 0 -> 5937 bytes public/icons/icon-512.png | Bin 0 -> 19593 bytes public/index.html | 6 + public/manifest.webmanifest | 30 +++ public/shell.js | 4 + public/sw.js | 45 +++++ start.bat | 5 + 12 files changed, 581 insertions(+), 20 deletions(-) create mode 100644 Start ProdDash.command create mode 100644 docs/MODULE-GUIDE.md create mode 100644 public/icons/icon-180.png create mode 100644 public/icons/icon-192.png create mode 100644 public/icons/icon-512.png create mode 100644 public/manifest.webmanifest create mode 100644 public/sw.js create mode 100644 start.bat diff --git a/README.md b/README.md index 9a2a0e6..dab0645 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,129 @@ # ProdDash -A new web application, built on lessons and code from two existing Waters Church -production tools, which are included here as reference forks: +A modular **production dashboard** for Waters Church. Any browser on the +building network opens one page and composes its own dashboard out of +**modules** — independent tiles that each show one aspect of a live service. +Ships with the live **ProdCom transcript**, the **ProPresenter Now / Next** +display, and a booth **clock**; new modules drop into `modules/` without +touching the shell (see [docs/MODULE-GUIDE.md](docs/MODULE-GUIDE.md)). + +## How it works + +`server.js` (zero dependencies, just Node.js) does four things: + +1. Serves the dashboard shell (`public/`) and the admin page (`/admin`). +2. Discovers modules in `modules/` and serves each one's client at + `/modules//`. +3. Mounts each enabled module's server routes under `/api/modules//` — + that's where module servers proxy ProdCom, poll ProPresenter, and stream + live updates over SSE, so browsers never talk to production gear + directly (no CORS, and API keys never leave the server). +4. Stores server-wide module config and named layouts under `config/`. + +Each browser arranges its own tiles (add, drag, resize, remove, per-tile +settings); the layout autosaves to that browser's `localStorage`. Named +layouts can also be saved to the server and loaded from any other browser. +Every module survives its upstream disappearing: tiles show a red status dot +and a clear degraded state, then recover on their own. + +## Run it + +Requires [Node.js](https://nodejs.org) 18 or newer. + +```bash +node server.js +``` + +Or double-click **Start ProdDash.command** (macOS) / **start.bat** (Windows). + +Then open the URL it prints — `http://localhost:24500` on this machine, or +the `http://:24500` address from any other device on the +network. + +## Configure + +Server-wide module settings (ProdCom URL, ProPresenter host, API keys, +enable/disable) live in the **admin page** — open **Admin** from the +dashboard's top bar, or go to `/admin`. Saving re-initialises the module +immediately; open tiles reconnect on their own. + +Shell settings live in [config/proddash.json](config/proddash.json): + +| Key | Meaning | +| --- | --- | +| `port` | Port ProdDash serves on (default `24500`) | +| `adminPasscode` | If set, the admin page asks for this shared passcode. Leave `""` for none — it's a plain-HTTP LAN tool. | + +Environment variables `PORT` and `PRODDASH_PASSCODE` override the file. +`config/modules.json` (module config) and `config/layouts/` (named layouts) +are written by the app itself. + +## Using the dashboard + +- **+ Add tile** — add any enabled module; multiple tiles of the same + module are fine (e.g. two transcripts filtered to different channels). +- Drag a tile by its **header** to move it; drag the **corner handle** to + resize; **✕** removes it. +- The **gear** on a tile holds per-tile settings (filters, text size, + display options) — these belong to this browser's layout, not the server. +- **Layout ▾** — save the current arrangement to the server under a name, + load a named layout (it becomes a local copy — browsers are never linked), + or reset this browser's dashboard. +- Every tile has a **status dot**: green = live, amber = connecting, + red = upstream unreachable (hover it for details). Tiles reconnect + automatically. + +## Included modules + +- **ProdCom Transcript** — live transcript stream with in-place updates of + in-progress speech, channel colors, per-tile channel visibility toggles + and group presets. Admin config: ProdCom URL, API key. +- **ProPresenter Now / Next** — what's live and what's next, with slide + progress, driven by the shared `propresenter-core` client (ProPresenter + 7/20). Admin config: host, port, password. +- **Clock** — a big booth clock; also the smallest possible module and the + reference for module authors. + +To develop modules against mocks instead of real gear: +`node tools/prodcom-mock.js` (ProdCom on `127.0.0.1:24480`) and +`node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter on +`127.0.0.1:1599`). See the testing section of the +[module guide](docs/MODULE-GUIDE.md). + +## Install as a desktop web app + +Open the page in a browser first, then: + +- **macOS — Safari**: File → **Add to Dock…** +- **macOS/Windows — Chrome**: click the install icon in the address bar, or + ⋮ menu → Cast, save and share → **Install page as app…** +- **Windows — Edge**: ⋯ menu → Apps → **Install this site as an app** + +Note: Chrome only offers the one-click PWA install on `http://localhost` (a +"secure context"). When opening from *another* machine over plain HTTP, use +Edge's "Install this site as an app" or Chrome's ⋮ → Cast, save and share → +"Create shortcut…" (check "Open as window") — same result: a dock/taskbar +app in its own window. + +## Repository layout ``` ProdDash/ -├── prodcom-listener/ fork of ProdCom Listener — live transcript web app -│ (zero-dependency Node server + SSE proxy to the ProdCom API) -└── band-lineup-display/ fork of BandLineupDisplay — backstage stage-lineup Electron app, - plus the shared ProPresenter 7/20 client (propresenter-core/) - and the standalone Now/Next display (propresenter-app/) +├── server.js shell server: static files, module discovery/mounting, +│ admin + layouts APIs +├── public/ dashboard shell client (index.html, shell.js, style.css) +│ └── admin/ admin page client +├── modules/ installed modules, one folder each +│ ├── clock/ +│ ├── prodcom-transcript/ +│ └── propresenter-now-next/ (carries its own copy of propresenter-core) +├── config/ proddash.json + state written by the app +├── docs/MODULE-GUIDE.md how to build and install a module +├── tools/prodcom-mock.js mock ProdCom API for development +├── prodcom-listener/ reference fork — read-only, not served +└── band-lineup-display/ reference fork — read-only, not served ``` -The new application will live at the repo root as it takes shape; the two -reference folders are snapshots (build output and `node_modules` excluded) and -are not wired into a build. - -## Reference apps at a glance - -- **prodcom-listener** — serves a web UI and proxies `/prodcom/*` to the ProdCom - API, including the Server-Sent Events transcript stream, so browsers never - talk to ProdCom directly. See [prodcom-listener/README.md](prodcom-listener/README.md). -- **band-lineup-display** — Electron app for the backstage screen showing stage - lineup photos with mic/pack labels. Carries the ProPresenter **Now / Next** - feature; `propresenter-core/` is the single home for every ProPresenter - quirk. See [band-lineup-display/README.md](band-lineup-display/README.md). +The two reference folders are snapshots of the apps ProdDash grew out of; +they are not wired into anything (but `band-lineup-display/tmp/pp-mock.js` +is handy for testing). diff --git a/Start ProdDash.command b/Start ProdDash.command new file mode 100644 index 0000000..0ca52ec --- /dev/null +++ b/Start ProdDash.command @@ -0,0 +1,4 @@ +#!/bin/zsh +# Double-click to start ProdDash (macOS) +cd "$(dirname "$0")" +exec node server.js diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md new file mode 100644 index 0000000..1dc59a4 --- /dev/null +++ b/docs/MODULE-GUIDE.md @@ -0,0 +1,364 @@ +# ProdDash Module Guide + +How to build, install and test a ProdDash module. Everything a module author +needs is on this page — you never have to read the shell's source. + +A **module** is one kind of dashboard tile: the live transcript, the +Now/Next display, a clock. Users add any number of tiles of your module to +their own dashboards, move and resize them, and give each tile its own +per-tile settings. Your module renders inside the tile and talks to the +outside world only through the small API the shell hands it. + +## The shape of a module + +A module is one folder dropped into `modules/`: + +``` +modules// +├── module.json manifest (required) +├── client.js ES module, client entry (required) +├── server.js server entry (optional — only if you need server code) +└── ... assets (css, images…) served statically at /modules// +``` + +**Installation is exactly this:** copy the folder into `modules/`, restart +ProdDash (`node server.js`), then configure and enable it at `/admin`. No +shell code changes, ever. + +## module.json + +```json +{ + "id": "my-module", + "name": "My Module", + "version": "1.0.0", + "description": "One line shown in the Add-tile picker and the admin page", + "client": "client.js", + "server": "server.js", + "style": "style.css", + "minSize": { "w": 2, "h": 2 }, + "defaultSize": { "w": 4, "h": 3 }, + "configSchema": { + "upstreamUrl": { "type": "string", "label": "Upstream URL", "default": "http://10.0.0.5:1234" }, + "apiKey": { "type": "password", "label": "API key", "default": "" } + }, + "instanceSchema": { + "channel": { "type": "string", "label": "Channel filter", "default": "" } + } +} +``` + +| Field | Required | Meaning | +| --- | --- | --- | +| `id` | yes | Must equal the folder name. Lowercase, dashes. | +| `name` | yes | Human name shown on the tile header and in menus. | +| `version`, `description` | no | Shown in the admin page / picker. | +| `client` | yes | Client entry file, loaded as an ES module. | +| `server` | no | Server entry file (CommonJS), loaded with `require()`. | +| `style` | no | A stylesheet the shell injects once per module. | +| `minSize`, `defaultSize` | no | Tile size in grid cells (12 columns wide; rows are ~72 px). Defaults: min 1×1, default 4×3. | +| `configSchema` | no | **Admin config** — server-wide settings (URLs, ports, keys) edited once at `/admin` for everyone. | +| `instanceSchema` | no | **Per-tile settings** — chosen in each tile's gear menu and stored with that browser's layout (filters, text sizes, display options). | + +Both schemas map field names to specs: + +``` +{ "type": "string" | "number" | "boolean" | "select" | "password", + "label": "Shown next to the field", + "default": , + "options": [ { "value": "a", "label": "A" }, … ] // "select" only +} +``` + +`password` fields are admin-config only in practice: their values are stored +in `config/modules.json`, handed to your *server* entry, and **never sent to +browsers** — not in `/api/modules`, and never echoed back into the admin +form. Anything upstream that needs the secret must go through your server +routes. + +Admin config lives in `config/modules.json` (written by the admin page — +your module never touches that file). Per-tile settings live inside each +browser's layout. + +## Client entry (`client.js`) + +An ES module whose **default export is a factory**. The shell calls it once +per tile: + +```js +export default function create({ root, moduleApi }) { + return { + start() {}, // begin rendering / connecting + stop() {}, // tear down EVERYTHING (timers, streams, observers) + onResize(w, h) {}, // optional; tile was resized (w, h in grid cells) + onConfigChange(cfg) {} // optional; admin config was saved + }; +} +``` + +- `root` — the tile's body element. **The module owns everything inside it + and must never touch DOM outside it.** +- All four methods are optional, but a module without `stop()` will leak + timers when its tile is removed. `stop()` must leave nothing running. + +### moduleApi + +| Member | What it is | +| --- | --- | +| `id` | Your module id. | +| `instanceId` | Unique per tile — two tiles of the same module get different ids. Handy for keying anything per-tile. | +| `config` | Admin (server-wide) config, read-only, always current. Password fields are absent — they never reach the client. | +| `instanceSettings` | This tile's settings: your `instanceSchema` defaults overlaid with whatever this tile has saved. Read it fresh whenever you render — don't cache it. | +| `saveInstanceSettings(patch)` | Merge `patch` into this tile's settings and persist them with the layout. No restart happens — you made the change, you already know. Use it for state the user sets *inside* your tile (a toggled filter, a chosen tab). | +| `fetch(path, opts)` | `fetch` scoped to your server routes: `fetch('/state')` hits `/api/modules//state`. | +| `sse(path, handlers)` | An `EventSource` scoped the same way, **with auto-reconnect** (see below). Returns a handle with `close()`. | +| `setStatus(state, msg)` | Drives the tile's status dot: `'ok'`, `'connecting'` or `'error'`, plus a hover message. Call it honestly — during a live service the dot is how an operator spots a dead feed. | + +`sse(path, handlers)` handlers: + +```js +const stream = moduleApi.sse('/stream', { + open(e) {}, // (re)connected + error(e) {}, // connection lost — say so via setStatus + message(e) {}, // unnamed "data:" events + events: { state(e) {} }, // named events by name +}); +// later: stream.close() +``` + +Browsers' `EventSource` retries transient drops itself but gives up for good +when a retry gets a completed non-SSE response — which is what a proxy's 502 +looks like while the upstream is down. The shell's `sse()` recreates closed +streams on a 3-second timer until they work again, so **reconnect logic is +free**: handle `open` (refresh/backfill your state; you may have missed +events) and `error` (report degraded status), and you're resilient. Streams +you forget to close are force-closed when the tile unmounts — but close your +own in `stop()` anyway. + +### Lifecycle you must expect + +- **Any number of instances.** Two transcript tiles with different filters + is a feature. Keep all state inside `create()`'s closure — no module-level + mutable state in `client.js`. +- **Remounts are routine.** When the user edits the tile's gear settings the + shell calls `stop()` and starts a fresh instance. When admin config + changes, the shell calls your `onConfigChange(cfg)` if you export one, + otherwise it remounts you. Either way you must come back cleanly. +- **The tile can be removed at any moment.** `stop()` is your only notice. + +### Rules for the client + +1. **Never touch DOM outside `root`. Never define global CSS.** Scope every + rule in your stylesheet with the attribute the shell puts on your tiles: + + ```css + [data-module="my-module"] .my-thing { … } + ``` + + (Shadow DOM works too, if you prefer full isolation.) + +2. **Style with the shell's CSS variables — no hardcoded colors.** The + palette: `--bg` (page), `--panel` (tile), `--border`, `--text`, + `--muted`, `--accent`, `--danger`, `--warn`. Derive tints with + `color-mix(in srgb, var(--accent) 20%, transparent)`. This is what makes + every module look native on the dark, high-contrast booth theme. + +3. **No external network calls from the client.** Browsers talk only to + ProdDash. Anything upstream (your device, service, API) goes through your + module's server routes — same reasoning as the reference apps: no CORS, + no per-browser credentials, one place that owns the connection. + +4. **Handle upstream loss.** Reconnect with backoff (or lean on `sse()`), + and report honestly via `setStatus`. Tiles run unattended during live + services; a module that silently freezes is worse than one that says + it's down. + +## Server entry (`server.js`) + +Optional — only needed when your module talks to something upstream or keeps +server-side state. CommonJS, Node built-ins only (keep the zero-dependency +ethos; Node 18+ so global `fetch` is available). Exports either or both of: + +```js +module.exports = { + init({ config, log }) { + // Called on startup / re-init, BEFORE routes(). Start your upstream + // connection here. Return a handle: + return { + stop() {}, // tear everything down + health() { return { status: 'ok', message: '' }; } // optional + }; + }, + + routes({ config, log }) { + // Route table, mounted under /api/modules// + return { + 'GET /state': (req, res) => { … }, + 'GET /stream': (req, res) => { … }, // SSE — see notes + 'POST /command': (req, res) => { … }, + 'GET /proxy/*': (req, res) => { … }, // trailing /* = prefix route + '* /anything/*': (req, res) => { … }, // * method = any method + }; + }, +}; +``` + +- `config` is your admin config: `configSchema` defaults overlaid with what + the admin page saved — **including password fields**. +- `log(...)` prefixes output with your module id. +- Handlers get plain Node `(req, res)`. On a prefix route, `req.wildcard` is + the matched remainder (leading `/` included) and `req.search` is the query + string (with `?`, or `''`) — so a proxy rebuilds the upstream path as + `req.wildcard + req.search`. +- Exact routes match the path exactly; prefix routes (`/x/*`) match `/x` and + everything under it. First declared match wins. +- **Re-init instead of restart:** when the admin page saves your config, the + shell calls your handle's `stop()`, then `init()` and `routes()` again + with the new config. Keep module-level state inside `init()`'s closure (or + reset it there) so a re-init starts clean. Disabling a module unmounts its + routes entirely (clients get 404). +- `health()` feeds the admin page's status line — report your upstream's + reachability (`status: 'ok' | 'connecting' | 'error'`), not just "running". +- A throw from `init()`/`routes()` marks the module as failed: the error + shows in `/admin`, and API calls answer 502 until a config fix re-inits + it. Validate config early so bad input fails loudly there. + +### Server-Sent Events from a module + +For live data, expose a `/stream` route and let clients use +`moduleApi.sse('/stream', …)`: + +```js +'GET /stream': (req, res) => { + res.writeHead(200, { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + }); + res.write('retry: 3000\n\n'); + res.write(`event: state\ndata: ${JSON.stringify(latest)}\n\n`); // instant paint + streams.add(res); + req.on('close', () => streams.delete(res)); +} +``` + +Broadcast only when the payload actually changed, send a `: ping\n\n` +comment every ~15 s so idle connections survive sleepy Wi-Fi, and wrap every +per-client `res.write` in try/catch (drop the client on failure). The +`propresenter-now-next` module is the reference implementation of this +pattern. + +## Complete minimal example + +A working clock — copy-paste this as your starter. (A slightly fancier +version ships in `modules/clock/`.) + +`modules/hello-clock/module.json` + +```json +{ + "id": "hello-clock", + "name": "Hello Clock", + "version": "1.0.0", + "description": "Minimal example module", + "client": "client.js", + "style": "style.css", + "minSize": { "w": 2, "h": 1 }, + "defaultSize": { "w": 3, "h": 2 }, + "instanceSchema": { + "showSeconds": { "type": "boolean", "label": "Show seconds", "default": true } + } +} +``` + +`modules/hello-clock/client.js` + +```js +export default function create({ root, moduleApi }) { + let timer = null; + + function tick() { + const { showSeconds } = moduleApi.instanceSettings; + root.querySelector('.hc-time').textContent = new Date().toLocaleTimeString( + [], { hour: '2-digit', minute: '2-digit', ...(showSeconds ? { second: '2-digit' } : {}) } + ); + } + + return { + start() { + root.innerHTML = '
'; + tick(); + timer = setInterval(tick, 250); + moduleApi.setStatus('ok', 'Ticking'); + }, + stop() { + clearInterval(timer); + root.innerHTML = ''; + }, + }; +} +``` + +`modules/hello-clock/style.css` + +```css +[data-module="hello-clock"] .hc-time { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 2.4em; + font-weight: 700; + font-variant-numeric: tabular-nums; + color: var(--text); +} +``` + +Drop the folder in `modules/`, restart ProdDash, and “Hello Clock” appears +in the Add-tile picker (new modules are enabled by default; `/admin` can +disable them). + +## Testing your module + +**Run it:** + +```bash +node server.js # from the repo root — prints the LAN URL +``` + +The server logs each module it mounts (or why it refused to). `/admin` +shows mount errors and your `health()` line; the browser console shows +client-side failures (a tile that fails to start says so in the tile). + +**Poke the pieces directly:** + +- `GET /api/modules` — what the shell sees (your manifest + non-secret config). +- `GET /api/modules//...` — your routes, `curl`-able. For SSE: + `curl -N http://localhost:24500/api/modules//stream`. +- `GET /api/admin/state` — enabled flags, health, mount errors. + +**Mock upstreams** (so you can develop without the real gear): + +- ProdCom: `node tools/prodcom-mock.js` (port 24480) — channels, groups, + history, and a live stream with in-progress speech. Point the + transcript module's ProdCom URL at `http://127.0.0.1:24480`. +- ProPresenter: `node band-lineup-display/tmp/pp-mock.js 1599` — playlists + and an advancing slide index (including the arrangement-repeat case). + Point the Now/Next module at host `127.0.0.1`, port `1599`. + +**The resilience checklist** — every module must pass this before it's done: + +1. Add your tile, then **kill the upstream** (or the mock). The status dot + must go red (with a useful hover message) and the tile must show a clear + degraded state, not a freeze. +2. **Restart the upstream.** The tile must recover by itself — no reload, + no clicking. +3. **Restart ProdDash itself** with the tile open. Same expectation. +4. Add **two tiles of your module** with different per-tile settings — both + must work independently. +5. **Remove your tile**, then check the browser console and server log: no + errors, no timers still firing, no streams still open. +6. Change your admin config in `/admin` while the tile is open — the tile + must pick it up (via `onConfigChange` or the automatic remount). +7. Reload the page — the tile comes back with its settings intact. diff --git a/public/admin/index.html b/public/admin/index.html index a273555..3081a90 100644 --- a/public/admin/index.html +++ b/public/admin/index.html @@ -5,6 +5,7 @@ ProdDash Admin + diff --git a/public/icons/icon-180.png b/public/icons/icon-180.png new file mode 100644 index 0000000000000000000000000000000000000000..f7475cdbb3fddcae36c99f3ad489a20542f49ff8 GIT binary patch literal 4193 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 AbN~PV literal 0 HcmV?d00001 diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..96ada4d8a6fb017c4274c207d277ef2be3df67d6 GIT binary patch literal 5937 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 U5pO3J#I=Cbl^;KHAJj|9lK=n! literal 0 HcmV?d00001 diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..8dfcb4f1cf08dc46b8f398c08b35ce166618733f GIT binary patch literal 19593 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 LNkvXXu0mjf=|_l$ literal 0 HcmV?d00001 diff --git a/public/index.html b/public/index.html index 69afd10..83b26d4 100644 --- a/public/index.html +++ b/public/index.html @@ -5,6 +5,12 @@ ProdDash + + + + + + diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..27b77a2 --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,30 @@ +{ + "name": "ProdDash", + "short_name": "ProdDash", + "description": "Modular production dashboard for Waters Church", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#0b0e12", + "theme_color": "#0b0e12", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/public/shell.js b/public/shell.js index 6ae8ec5..f4a8412 100644 --- a/public/shell.js +++ b/public/shell.js @@ -844,3 +844,7 @@ async function boot() { } boot(); + +if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/sw.js').catch(() => {}); +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..1d4af60 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,45 @@ +'use strict'; + +/* Minimal service worker: caches the shell so the PWA is installable. + Never touches /api/* (live data + SSE streams). Network-first, so open + dashboards always run the latest shell when the server is reachable. */ + +const CACHE = 'proddash-v1'; +const SHELL = [ + '/', + '/style.css', + '/shell.js', + '/manifest.webmanifest', + '/icons/icon-192.png', + '/icons/icon-512.png', +]; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE).then((cache) => cache.addAll(SHELL)).then(() => self.skipWaiting()) + ); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys() + .then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))) + .then(() => self.clients.claim()) + ); +}); + +self.addEventListener('fetch', (event) => { + const url = new URL(event.request.url); + if (event.request.method !== 'GET' || url.pathname.startsWith('/api/')) return; + + // Network first, cache fallback + event.respondWith( + fetch(event.request) + .then((res) => { + const copy = res.clone(); + caches.open(CACHE).then((cache) => cache.put(event.request, copy)); + return res; + }) + .catch(() => caches.match(event.request, { ignoreSearch: true })) + ); +}); diff --git a/start.bat b/start.bat new file mode 100644 index 0000000..a6086fc --- /dev/null +++ b/start.bat @@ -0,0 +1,5 @@ +@echo off +rem Double-click to start ProdDash (Windows) +cd /d %~dp0 +node server.js +pause From af46499d924abf3784ae0b37941fee1e9f95909c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 16:16:32 +0000 Subject: [PATCH 8/9] Flush tile grid + macOS-style seam pill for resizing adjacent tiles Tiles now sit with zero gap (their 1px borders are the only seam) and the grid runs edge to edge. Wherever two tiles share a vertical edge, an invisible strip sits on the seam; hovering reveals a small grab pill (split-view style) and dragging it moves the shared edge, trading width between the two tiles. The combined span is preserved, both tiles' min sizes are honored, and a third tile sitting behind part of the seam blocks the drag instead of being overlapped. Pills hide during tile drags/resizes and rebuild for the new geometry on release; seam sizes persist with the layout. Also fixes cellMetrics treating a 0px grid gap as falsy and falling back to 10px. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH --- README.md | 4 +- public/shell.js | 110 ++++++++++++++++++++++++++++++++++++++++++++--- public/style.css | 38 +++++++++++++++- 3 files changed, 144 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index dab0645..8eb3267 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,9 @@ are written by the app itself. - **+ Add tile** — add any enabled module; multiple tiles of the same module are fine (e.g. two transcripts filtered to different channels). - Drag a tile by its **header** to move it; drag the **corner handle** to - resize; **✕** removes it. + resize; **✕** removes it. Tiles sit flush — when two share an edge, hover + the seam and a small grab pill appears; drag it to trade width between + the two tiles (macOS split-view style). - The **gear** on a tile holds per-tile settings (filters, text size, display options) — these belong to this browser's layout, not the server. - **Layout ▾** — save the current arrangement to the server under a name, diff --git a/public/shell.js b/public/shell.js index f4a8412..4f759b5 100644 --- a/public/shell.js +++ b/public/shell.js @@ -161,7 +161,8 @@ function newId() { function cellMetrics() { const style = getComputedStyle(grid); - const gap = parseFloat(style.columnGap) || 10; + 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 }; @@ -171,9 +172,13 @@ 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 isFree(rect, excludeId) { +function isFreeExcept(rect, excludeIds) { if (rect.x < 0 || rect.y < 0 || rect.x + rect.w > COLS) return false; - return !tiles.some((t) => t.id !== excludeId && overlaps(rect, t)); + 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. */ @@ -296,6 +301,7 @@ function wireDrag(tile, el, handle) { 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)), @@ -314,7 +320,11 @@ function wireDrag(tile, el, handle) { handle.removeEventListener('pointerup', onUp); handle.removeEventListener('pointercancel', onUp); el.classList.remove('dragging'); - if (moved) saveLayout(); + grid.classList.remove('no-dividers'); + if (moved) { + saveLayout(); + rebuildDividers(); + } }; handle.addEventListener('pointermove', onMove); handle.addEventListener('pointerup', onUp); @@ -337,6 +347,7 @@ function wireResize(tile, el, handle) { const startH = tile.h; const min = minSizeOf(tile); el.classList.add('resizing'); + grid.classList.add('no-dividers'); let changed = false; const onMove = (e) => { @@ -361,7 +372,11 @@ function wireResize(tile, el, handle) { handle.removeEventListener('pointerup', onUp); handle.removeEventListener('pointercancel', onUp); el.classList.remove('resizing'); - if (changed) saveLayout(); + grid.classList.remove('no-dividers'); + if (changed) { + saveLayout(); + rebuildDividers(); + } }; handle.addEventListener('pointermove', onMove); handle.addEventListener('pointerup', onUp); @@ -376,6 +391,88 @@ function notifyResize(tile) { } catch { /* module's problem, not the shell's */ } } +/* ── seam dividers between adjacent tiles ───────────────────────────── */ + +/* Wherever two tiles share a vertical edge, a divider sits on the seam. + Dragging its pill moves the shared edge, trading width between the two + tiles — their combined span never changes, so nothing else can collide + except via the explicit check below (a third tile can sit behind the + seam for part of its height). */ + +function rebuildDividers() { + grid.querySelectorAll('.tile-divider').forEach((el) => el.remove()); + for (const L of tiles) { + for (const R of tiles) { + if (L === R || L.x + L.w !== R.x) continue; + const top = Math.max(L.y, R.y); + const bottom = Math.min(L.y + L.h, R.y + R.h); + if (bottom <= top) continue; + buildDivider(L, R, top, bottom); + } + } +} + +function buildDivider(L, R, top, bottom) { + const el = document.createElement('div'); + el.className = 'tile-divider'; + el.title = 'Drag to resize'; + + const place = () => { + const m = cellMetrics(); + // left as a percentage so the pill tracks window resizes for free + el.style.left = ((L.x + L.w) / COLS) * 100 + '%'; + el.style.top = top * (m.ch + m.gap) + 'px'; + el.style.height = (bottom - top) * (m.ch + m.gap) - m.gap + 'px'; + }; + place(); + + el.addEventListener('pointerdown', (ev) => { + if (ev.button !== 0 && ev.pointerType === 'mouse') return; + ev.preventDefault(); + closeSettingsPopovers(); + el.setPointerCapture(ev.pointerId); + el.classList.add('active'); + const m = cellMetrics(); + const sx = ev.clientX; + const startBoundary = L.x + L.w; + const rightEdge = R.x + R.w; // fixed for the whole drag + const minB = L.x + minSizeOf(L).w; + const maxB = rightEdge - minSizeOf(R).w; + let changed = false; + + const onMove = (e) => { + const b = Math.max(minB, Math.min(maxB, + startBoundary + Math.round((e.clientX - sx) / (m.cw + m.gap)))); + if (b === L.x + L.w) return; + const candL = { x: L.x, y: L.y, w: b - L.x, h: L.h }; + const candR = { x: b, y: R.y, w: rightEdge - b, h: R.h }; + if (!isFreeExcept(candL, [L.id, R.id]) || !isFreeExcept(candR, [L.id, R.id])) return; + L.w = candL.w; + R.x = candR.x; + R.w = candR.w; + applyRect(L); + applyRect(R); + notifyResize(L); + notifyResize(R); + place(); + changed = true; + }; + const onUp = () => { + el.removeEventListener('pointermove', onMove); + el.removeEventListener('pointerup', onUp); + el.removeEventListener('pointercancel', onUp); + el.classList.remove('active'); + if (changed) saveLayout(); + rebuildDividers(); // adjacencies may have changed + }; + el.addEventListener('pointermove', onMove); + el.addEventListener('pointerup', onUp); + el.addEventListener('pointercancel', onUp); + }); + + grid.appendChild(el); +} + /* ── add / remove tiles ─────────────────────────────────────────────── */ function addTile(moduleId) { @@ -394,6 +491,7 @@ function addTile(moduleId) { buildTile(tile); refreshEmptyState(); saveLayout(); + rebuildDividers(); } function removeTile(id) { @@ -408,6 +506,7 @@ function removeTile(id) { } refreshEmptyState(); saveLayout(); + rebuildDividers(); } function clearAllTiles() { @@ -686,6 +785,7 @@ function renderLayout(list) { tiles = list.map(sanitizeTile); for (const tile of tiles) buildTile(tile); refreshEmptyState(); + rebuildDividers(); } /* ── menus ──────────────────────────────────────────────────────────── */ diff --git a/public/style.css b/public/style.css index cb4f1e7..9500743 100644 --- a/public/style.css +++ b/public/style.css @@ -14,7 +14,7 @@ --warn: #f0b429; /* grid geometry (shell.js reads these) */ --cell-h: 72px; - --gap: 10px; + --gap: 0px; /* tiles sit flush; their 1px borders are the only seam */ } * { box-sizing: border-box; margin: 0; padding: 0; } @@ -138,7 +138,6 @@ button { font-family: inherit; } flex: 1; overflow-y: auto; overflow-x: hidden; - padding: var(--gap); overscroll-behavior: contain; } @@ -277,6 +276,41 @@ button { font-family: inherit; } border-radius: 0 0 3px 0; } +/* ── seam divider between adjacent tiles ───────────────────────────── */ + +/* When two tiles share a vertical edge, an invisible strip sits on the seam; + hovering reveals a small macOS-split-view-style grab pill. Dragging it + trades width between the two tiles. */ +.tile-divider { + position: absolute; + width: 14px; + transform: translateX(-50%); + z-index: 30; + display: flex; + align-items: center; + justify-content: center; + cursor: col-resize; + touch-action: none; +} + +.tile-divider::after { + content: ""; + width: 5px; + height: 44px; + max-height: 55%; + border-radius: 999px; + background: var(--muted); + opacity: 0; + transition: opacity 0.15s ease, background 0.15s ease; +} + +.tile-divider:hover::after { opacity: 0.7; } +.tile-divider.active::after { opacity: 1; background: var(--text); } + +/* Hide the pills while a tile itself is being moved or corner-resized — + they're rebuilt for the new geometry on release. */ +#grid.no-dividers .tile-divider { display: none; } + /* status message a module can show while degraded (also used by the shell when a module fails to load) */ .tile-msg { From 54338ddf167d6bebea95107bfadd8f9583cc836f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 16:39:22 +0000 Subject: [PATCH 9/9] Icon topbar, module title-bar controls, hideable title bars, renames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Topbar: the add button is now just + and the layout button a 3-segment grid icon (two squares + one tall rectangle). Modules no longer build their own toolbars — moduleApi.header.addButton/addMenu put controls in the tile's title bar next to the gear/close (menus are fixed-positioned by the shell so a tile's overflow can't clip them, and everything a module adds is removed automatically on unmount). Each title bar grows a notch on its bottom edge that hides/shows the bar, ProdCom-Listener style; the choice persists with the layout. Modules renamed: ProdCom and ProPresenter. The ProdCom tile regains the original listener tools as icon buttons in its title bar: timestamps toggle (clock), A−/A+ text size, jump-to-latest, and a circle-with-cross clear button, plus the Channels menu. MODULE-GUIDE documents the header API; README updated. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH --- README.md | 34 ++++--- docs/MODULE-GUIDE.md | 37 +++++++ modules/prodcom-transcript/client.js | 98 ++++++++++++------ modules/prodcom-transcript/module.json | 2 +- modules/prodcom-transcript/style.css | 46 +-------- modules/propresenter-now-next/module.json | 2 +- public/index.html | 10 +- public/shell.js | 116 +++++++++++++++++++++- public/style.css | 82 ++++++++++++++- 9 files changed, 330 insertions(+), 97 deletions(-) diff --git a/README.md b/README.md index 8eb3267..c469264 100644 --- a/README.md +++ b/README.md @@ -60,29 +60,33 @@ are written by the app itself. ## Using the dashboard -- **+ Add tile** — add any enabled module; multiple tiles of the same - module are fine (e.g. two transcripts filtered to different channels). -- Drag a tile by its **header** to move it; drag the **corner handle** to - resize; **✕** removes it. Tiles sit flush — when two share an edge, hover - the seam and a small grab pill appears; drag it to trade width between - the two tiles (macOS split-view style). +- **+** — add any enabled module; multiple tiles of the same module are + fine (e.g. two transcripts filtered to different channels). +- Drag a tile by its **title bar** to move it; drag the **corner handle** + to resize; **✕** removes it. Tiles sit flush — when two share an edge, + hover the seam and a small grab pill appears; drag it to trade width + between the two tiles (macOS split-view style). +- A tile's actions (menus, text size, clear…) live in its **title bar**; + the small **notch** on the bar's bottom edge hides/shows the whole bar + for a clean wall-display look. - The **gear** on a tile holds per-tile settings (filters, text size, display options) — these belong to this browser's layout, not the server. -- **Layout ▾** — save the current arrangement to the server under a name, - load a named layout (it becomes a local copy — browsers are never linked), - or reset this browser's dashboard. +- The **grid icon** — save the current arrangement to the server under a + name, load a named layout (it becomes a local copy — browsers are never + linked), or reset this browser's dashboard. - Every tile has a **status dot**: green = live, amber = connecting, red = upstream unreachable (hover it for details). Tiles reconnect automatically. ## Included modules -- **ProdCom Transcript** — live transcript stream with in-place updates of - in-progress speech, channel colors, per-tile channel visibility toggles - and group presets. Admin config: ProdCom URL, API key. -- **ProPresenter Now / Next** — what's live and what's next, with slide - progress, driven by the shared `propresenter-core` client (ProPresenter - 7/20). Admin config: host, port, password. +- **ProdCom** — live transcript stream with in-place updates of in-progress + speech, channel colors, per-tile channel visibility toggles and group + presets, timestamps, text size and jump-to-latest in the title bar. + Admin config: ProdCom URL, API key. +- **ProPresenter** — what's live and what's next, with slide progress, + driven by the shared `propresenter-core` client (ProPresenter 7/20). + Admin config: host, port, password. - **Clock** — a big booth clock; also the smallest possible module and the reference for module authors. diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md index 1dc59a4..55a4491 100644 --- a/docs/MODULE-GUIDE.md +++ b/docs/MODULE-GUIDE.md @@ -113,6 +113,43 @@ export default function create({ root, moduleApi }) { | `fetch(path, opts)` | `fetch` scoped to your server routes: `fetch('/state')` hits `/api/modules//state`. | | `sse(path, handlers)` | An `EventSource` scoped the same way, **with auto-reconnect** (see below). Returns a handle with `close()`. | | `setStatus(state, msg)` | Drives the tile's status dot: `'ok'`, `'connecting'` or `'error'`, plus a hover message. Call it honestly — during a live service the dot is how an operator spots a dead feed. | +| `header.addButton(…)` | Put a small button in the tile's own title bar (see below). | +| `header.addMenu(…)` | Put a dropdown menu button there (see below). | + +### Title-bar controls + +Modules don't build their own toolbars — actions live in the tile's title +bar, next to the shell's gear/close buttons: + +```js +const btn = moduleApi.header.addButton({ + icon: '…', // inline SVG string, and/or + label: 'A+', // short text + title: 'Larger text', // hover tooltip + onClick() { … }, +}); +btn.classList.add('active'); // toggle styling for on/off buttons + +const menu = moduleApi.header.addMenu({ + label: 'Channels ▾', + title: 'Choose visible channels', + build(menuEl) { + // called on every open with an emptied menu element — fill it with + // buttons (give them your scoped classes; the container is styled and + // fixed-positioned by the shell, so the tile's overflow can't clip it) + }, +}); +menu.setLabel('Channels (2/4) ▾'); // update the button text any time +``` + +Everything you add is removed automatically when your instance stops, so a +remount never duplicates controls. Keep them compact (icon buttons, short +labels): the title bar is 32 px tall and shared with the tile's name. + +One more thing to know: the user can hide the whole title bar with the +small notch on its bottom edge (a clean-view mode for wall displays). Your +controls disappear with it, so nothing essential — status, reconnects, +live data — may exist *only* as a header control. `sse(path, handlers)` handlers: diff --git a/modules/prodcom-transcript/client.js b/modules/prodcom-transcript/client.js index 8d85ca2..f81590d 100644 --- a/modules/prodcom-transcript/client.js +++ b/modules/prodcom-transcript/client.js @@ -22,14 +22,6 @@ export default function create({ root, moduleApi }) { root.innerHTML = `
-
-
- - -
- - -
Waiting for transcript…
@@ -42,14 +34,66 @@ export default function create({ root, moduleApi }) { const entriesEl = root.querySelector('.pt-entries'); const emptyEl = root.querySelector('.pt-empty'); const jumpBtn = root.querySelector('.pt-jump'); - const channelBtn = root.querySelector('.pt-channels-btn'); - const channelMenu = root.querySelector('.pt-menu'); + + /* ── title-bar controls ─────────────────────────────────────────── */ + + const CLOCK_SVG = ''; + const JUMP_SVG = ''; + const CLEAR_SVG = ''; + + let channelMenuEl = null; // the open menu's element (filled on each open) + const channelsMenu = moduleApi.header.addMenu({ + label: 'Channels ▾', + title: 'Choose visible channels', + build(menu) { + menu.classList.add('pt-menu'); + channelMenuEl = menu; + rebuildChannelMenu(); + }, + }); + channelsMenu.button.classList.add('pt-channels-btn'); + + let timeBtn = null; + timeBtn = moduleApi.header.addButton({ + icon: CLOCK_SVG, + title: 'Show / hide timestamps', + onClick() { + moduleApi.saveInstanceSettings({ showTimes: !moduleApi.instanceSettings.showTimes }); + applyInstanceSettings(); + }, + }); + + function bumpTextSize(delta) { + const current = Number(moduleApi.instanceSettings.textSize) || 15; + moduleApi.saveInstanceSettings({ textSize: Math.min(48, Math.max(10, current + delta)) }); + applyInstanceSettings(); + } + moduleApi.header.addButton({ label: 'A−', title: 'Smaller text', onClick: () => bumpTextSize(-2) }); + moduleApi.header.addButton({ label: 'A+', title: 'Larger text', onClick: () => bumpTextSize(2) }); + + moduleApi.header.addButton({ + icon: JUMP_SVG, + title: 'Jump to latest', + onClick() { + pinnedToBottom = true; + jumpBtn.hidden = true; + scrollToBottom(); + }, + }); + + const clearBtn = moduleApi.header.addButton({ + icon: CLEAR_SVG, + title: 'Clear this tile (does not affect ProdCom)', + onClick: clearView, + }); + clearBtn.classList.add('pt-clear'); function applyInstanceSettings() { const s = moduleApi.instanceSettings; const size = Math.min(48, Math.max(10, Number(s.textSize) || 15)); wrap.style.setProperty('--pt-size', size + 'px'); wrap.classList.toggle('show-times', Boolean(s.showTimes)); + timeBtn?.classList.toggle('active', Boolean(s.showTimes)); } applyInstanceSettings(); @@ -106,7 +150,20 @@ export default function create({ root, moduleApi }) { ); } + function updateChannelsLabel() { + let visibleCount = 0; + for (const id of channels.keys()) { + if (!hiddenChannels.has(id)) visibleCount++; + } + channelsMenu.setLabel(visibleCount === channels.size + ? 'Channels ▾' + : `Channels (${visibleCount}/${channels.size}) ▾`); + } + function rebuildChannelMenu() { + updateChannelsLabel(); + const channelMenu = channelMenuEl; + if (!channelMenu) return; // menu not opened yet — the label is enough channelMenu.innerHTML = ''; if (groups.size) { @@ -134,10 +191,8 @@ export default function create({ root, moduleApi }) { channelMenu.appendChild(divider); } - let visibleCount = 0; for (const [id, info] of channels) { const on = !hiddenChannels.has(id); - if (on) visibleCount++; const item = document.createElement('button'); item.className = 'pt-channel-item' + (on ? ' on' : ''); item.setAttribute('role', 'menuitemcheckbox'); @@ -158,27 +213,15 @@ export default function create({ root, moduleApi }) { }); channelMenu.appendChild(item); } - channelBtn.textContent = visibleCount === channels.size - ? 'Channels ▾' - : `Channels (${visibleCount}/${channels.size}) ▾`; } - channelBtn.addEventListener('click', (e) => { - e.stopPropagation(); - channelMenu.hidden = !channelMenu.hidden; - }); - const onDocClick = (e) => { - if (!channelMenu.hidden && !e.target.closest('.pt-dd')) channelMenu.hidden = true; - }; - document.addEventListener('click', onDocClick); - /* ── clear (local view only) ────────────────────────────────────── */ - root.querySelector('.pt-clear').addEventListener('click', () => { + function clearView() { entryEls.forEach((el) => el.remove()); entryEls.clear(); emptyEl.hidden = false; - }); + } /* ── scrolling ──────────────────────────────────────────────────── */ @@ -393,8 +436,7 @@ export default function create({ root, moduleApi }) { stopped = true; clearTimeout(bootTimer); stream?.close(); - document.removeEventListener('click', onDocClick); - root.innerHTML = ''; + root.innerHTML = ''; // header controls are removed by the shell }, onResize() { if (pinnedToBottom) scrollToBottom(); diff --git a/modules/prodcom-transcript/module.json b/modules/prodcom-transcript/module.json index 8e05663..42d808f 100644 --- a/modules/prodcom-transcript/module.json +++ b/modules/prodcom-transcript/module.json @@ -1,6 +1,6 @@ { "id": "prodcom-transcript", - "name": "ProdCom Transcript", + "name": "ProdCom", "version": "1.0.0", "description": "Live ProdCom transcript stream", "client": "client.js", diff --git a/modules/prodcom-transcript/style.css b/modules/prodcom-transcript/style.css index 93dfd9e..4b84fad 100644 --- a/modules/prodcom-transcript/style.css +++ b/modules/prodcom-transcript/style.css @@ -9,49 +9,9 @@ flex-direction: column; } -/* ── toolbar ── */ - -[data-module="prodcom-transcript"] .pt-bar { - flex: none; - display: flex; - align-items: center; - gap: 6px; - padding: 5px 8px; - border-bottom: 1px solid var(--border); -} - -[data-module="prodcom-transcript"] .pt-spacer { flex: 1; } - -[data-module="prodcom-transcript"] .pt-btn { - background: var(--bg); - color: var(--text); - border: 1px solid var(--border); - border-radius: 6px; - padding: 3px 8px; - font-size: 11px; - font-family: inherit; - cursor: pointer; - white-space: nowrap; -} -[data-module="prodcom-transcript"] .pt-btn:hover { border-color: var(--muted); } - -[data-module="prodcom-transcript"] .pt-dd { position: relative; } - -[data-module="prodcom-transcript"] .pt-menu { - position: absolute; - top: calc(100% + 5px); - left: 0; - z-index: 30; - min-width: 170px; - max-height: 45vh; - overflow-y: auto; - background: var(--panel); - border: 1px solid var(--border); - border-radius: 10px; - padding: 5px; - box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55); -} -[data-module="prodcom-transcript"] .pt-menu[hidden] { display: none; } +/* ── channels menu ── + The menu container is created and positioned by the shell (.tile-menu); + it lives inside the tile, so [data-module] scoping still applies. */ [data-module="prodcom-transcript"] .pt-channel-item { display: flex; diff --git a/modules/propresenter-now-next/module.json b/modules/propresenter-now-next/module.json index eb3feae..24d0cc5 100644 --- a/modules/propresenter-now-next/module.json +++ b/modules/propresenter-now-next/module.json @@ -1,6 +1,6 @@ { "id": "propresenter-now-next", - "name": "ProPresenter Now / Next", + "name": "ProPresenter", "version": "1.0.0", "description": "What is live in ProPresenter and what comes next", "client": "client.js", diff --git a/public/index.html b/public/index.html index 83b26d4..71abe71 100644 --- a/public/index.html +++ b/public/index.html @@ -20,12 +20,18 @@ diff --git a/public/shell.js b/public/shell.js index 4f759b5..7e44ef4 100644 --- a/public/shell.js +++ b/public/shell.js @@ -150,6 +150,7 @@ function sanitizeTile(t) { w, h: Math.max(1, Number(t.h) || 2), settings: (t.settings && typeof t.settings === 'object') ? t.settings : {}, + headHidden: Boolean(t.headHidden), }; } @@ -229,7 +230,11 @@ function buildTile(tile) { title.className = 'tile-title'; title.textContent = man ? man.name : tile.module + ' (not installed)'; - head.append(dot, title); + // where moduleApi.header puts a module's own buttons and menus + const controls = document.createElement('span'); + controls.className = 'tile-controls'; + + head.append(dot, title, controls); const hasSettings = man && man.instanceSchema && Object.keys(man.instanceSchema).length; if (hasSettings) { @@ -261,10 +266,22 @@ function buildTile(tile) { resize.className = 'tile-resize'; resize.title = 'Drag to resize'; - el.append(head, body, resize); + // notch on the title bar's bottom edge: hides/shows the bar + const notch = document.createElement('button'); + notch.className = 'tile-notch'; + notch.title = 'Hide / show the title bar'; + notch.addEventListener('click', (e) => { + e.stopPropagation(); + tile.headHidden = !tile.headHidden; + el.classList.toggle('head-hidden', tile.headHidden); + saveLayout(); + }); + + el.append(head, body, resize, notch); + el.classList.toggle('head-hidden', Boolean(tile.headHidden)); grid.appendChild(el); - const entry = { el, body, dot, titleEl: title, instance: null }; + const entry = { el, body, dot, titleEl: title, controls, instance: null }; tileEls.set(tile.id, entry); applyRect(tile); @@ -283,7 +300,7 @@ function refreshEmptyState() { function wireDrag(tile, el, handle) { handle.addEventListener('pointerdown', (ev) => { - if (ev.target.closest('.tile-btn')) return; + 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; ev.preventDefault(); closeSettingsPopovers(); @@ -555,6 +572,8 @@ function tileMessage(entry, text) { */ 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, @@ -636,9 +655,96 @@ function buildModuleApi(tile, man, entry) { entry.dot.title = msg; }, - /* shell-internal: safety net so a forgotten stream can't outlive the tile */ + /** + * 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; diff --git a/public/style.css b/public/style.css index 9500743..0cca1cf 100644 --- a/public/style.css +++ b/public/style.css @@ -71,6 +71,14 @@ button { font-family: inherit; } } .tb-btn:hover { border-color: var(--muted); } .tb-btn:active { background: var(--border); } +.tb-btn.icon { + padding: 6px 8px; + min-width: 30px; + justify-content: center; + font-size: 14px; + line-height: 1; +} +.tb-btn.icon svg { display: block; } /* ── dropdown menus ────────────────────────────────────────────────── */ @@ -191,7 +199,8 @@ button { font-family: inherit; } display: flex; align-items: center; gap: 8px; - padding: 5px 8px; + height: 32px; /* fixed so the notch below can sit at a known offset */ + padding: 0 8px; background: rgba(255, 255, 255, 0.03); border-bottom: 1px solid var(--border); cursor: grab; @@ -200,6 +209,7 @@ button { font-family: inherit; } touch-action: none; } .tile.dragging .tile-head { cursor: grabbing; } +.tile.head-hidden .tile-head { display: none; } .tile-head .dot { flex: none; @@ -241,8 +251,76 @@ button { font-family: inherit; } } .tile-btn:hover { background: var(--border); color: var(--text); } .tile-btn.close:hover { color: var(--danger); } +.tile-btn.active { color: var(--accent); } .tile-btn svg { width: 13px; height: 13px; display: block; } +/* controls a module adds to its own title bar (moduleApi.header) */ +.tile-controls { + display: flex; + align-items: center; + gap: 2px; + min-width: 0; + overflow: hidden; +} +.tile-btn.module-btn { + width: auto; + min-width: 22px; + padding: 0 5px; + font-size: 11px; + font-weight: 600; + white-space: nowrap; +} +.tile-dd { position: relative; display: inline-flex; } + +/* a module header menu — fixed-positioned by the shell so the tile's + overflow:hidden can't clip it */ +.tile-menu { + position: fixed; + z-index: 70; + min-width: 180px; + max-height: 60vh; + overflow-y: auto; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + padding: 5px; + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55); +} +.tile-menu[hidden] { display: none; } + +/* notch on the title bar's bottom edge: click to hide/show the bar + (same idea as the ProdCom Listener's pull-down menubar handle) */ +.tile-notch { + position: absolute; + top: 32px; + left: 50%; + transform: translateX(-50%); + width: 46px; + height: 13px; + padding: 0; + background: var(--panel); + border: 1px solid var(--border); + border-top: none; + border-radius: 0 0 10px 10px; + cursor: pointer; + z-index: 6; + opacity: 0; + transition: opacity 0.15s ease, top 0.15s ease; +} +.tile:hover .tile-notch { opacity: 0.6; } +.tile .tile-notch:hover { opacity: 1; } +.tile.head-hidden .tile-notch { top: 0; } +.tile-notch::after { + content: ""; + display: block; + width: 18px; + height: 3px; + margin: 4px auto 0; + border-radius: 999px; + background: var(--muted); +} +.tile-notch:hover::after { background: var(--text); } + .tile-body { flex: 1; position: relative; @@ -331,7 +409,7 @@ button { font-family: inherit; } .tile-settings { position: absolute; - top: 30px; + top: 36px; right: 6px; z-index: 45; min-width: 220px;