From 9d9b92e6d96161134e8ea0c8a775a5bd39538741 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 14:50:37 +0000 Subject: [PATCH] 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); + } + } + } +});