From 1292ba33babac764b12235c3dfded05aeb54e56f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 14:42:15 +0000 Subject: [PATCH 1/5] Keep module config and layouts in a per-machine data directory Connection settings, API keys, enabled flags and named layouts were written to config/ inside the checkout, so a re-clone or reset from main started the server with nothing configured. They now live in a data directory outside the app folder: Windows %APPDATA%\ProdDash macOS ~/Library/Application Support/ProdDash other $XDG_CONFIG_HOME/proddash, else ~/.config/proddash PRODDASH_DATA_DIR overrides the location (a relative path resolves against the app folder, so "config" restores the old spot). The first start after upgrading copies an existing config/modules.json and config/layouts/ into the data directory, so nothing has to be re-entered. If the directory cannot be created or written, the server warns and falls back to config/ rather than refusing to start. A proddash.json in the data directory overrides the checked-in defaults (port, adminPasscode). The startup banner prints the directory in use, /api/admin/state exposes it, and the admin page shows it under the Modules heading. Admin config and layout writes that fail on disk now report the path instead of a generic server error. README, MODULE-GUIDE and CLAUDE.md updated. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH --- CLAUDE.md | 5 +- README.md | 41 ++++++++++-- docs/MODULE-GUIDE.md | 9 +-- public/admin/admin.css | 12 ++++ public/admin/admin.js | 5 ++ public/admin/index.html | 2 + server.js | 141 ++++++++++++++++++++++++++++++++++------ 7 files changed, 184 insertions(+), 31 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 56f7f2a..f08eb4b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,10 @@ Modular production dashboard web app for Waters Church, developed in this repo. The app lives at the repo root and is built: zero-dependency Node server (`server.js`, Node 18+), dashboard shell client in `public/`, admin page at -`/admin`, modules in `modules/`. [SPEC.md](SPEC.md) was the authority for the +`/admin`, modules in `modules/`. Runtime state (module config, named layouts) +is written to a per-machine data directory outside the checkout +(`PRODDASH_DATA_DIR` overrides; see README "Where settings are kept") — never +add code that writes runtime state into the repo folder. [SPEC.md](SPEC.md) was the authority for the initial build; [README.md](README.md) has run/configure/use instructions. ## Building a new module — read docs/MODULE-GUIDE.md first diff --git a/README.md b/README.md index c2b5201..925b752 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,8 @@ touching the shell (see [docs/MODULE-GUIDE.md](docs/MODULE-GUIDE.md)). 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/`. +4. Stores server-wide module config and named layouts in a per-machine data + directory outside the app folder, so updating ProdDash never loses them. Each browser arranges its own tiles (add, drag, resize, remove, per-tile settings); the layout autosaves to that browser's `localStorage`. Named @@ -47,16 +48,43 @@ 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): +### Where settings are kept + +Everything you enter in the admin page (module connection settings, API keys, +enabled/disabled flags) and every named layout is written to a **data +directory on the machine running the server** — not into the app folder: + +| OS | Data directory | +| --- | --- | +| Windows | `%APPDATA%\ProdDash` (e.g. `C:\Users\\AppData\Roaming\ProdDash`) | +| macOS | `~/Library/Application Support/ProdDash` | +| Linux | `$XDG_CONFIG_HOME/proddash`, else `~/.config/proddash` | + +Because it lives outside the checkout, you can `git pull`, re-clone, or reset +the app folder from `main` and start the new version with everything still +configured. The admin page shows the exact path in use, and the server prints +it at startup (`Settings : …`). + +The directory holds `modules.json` (module config) and `layouts/` (named +layouts), both written by the app itself. It may also hold a `proddash.json` +with this machine's shell settings — the same keys as the checked-in +[config/proddash.json](config/proddash.json), which supplies the defaults: | 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. +Environment variables override both files: `PORT`, `PRODDASH_PASSCODE`, and +`PRODDASH_DATA_DIR` to put the data directory somewhere else (a relative path +is taken from the app folder; `PRODDASH_DATA_DIR=config` restores the old +in-repo location). If the default directory can't be created, the server says +so and falls back to the app's `config/` folder. + +**Upgrading from an earlier version:** the first start copies an existing +`config/modules.json` and `config/layouts/` from the app folder into the data +directory, so nothing has to be re-entered. The old files are left in place and +ignored from then on. ## Using the dashboard @@ -130,7 +158,8 @@ ProdDash/ │ ├── clock/ │ ├── prodcom-transcript/ │ └── propresenter-now-next/ (carries its own copy of propresenter-core) -├── config/ proddash.json + state written by the app +├── config/ proddash.json defaults (runtime state lives in the +│ per-machine data directory — see "Configure") ├── 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 diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md index 87786db..6741fa1 100644 --- a/docs/MODULE-GUIDE.md +++ b/docs/MODULE-GUIDE.md @@ -72,8 +72,8 @@ Both schemas map field names to specs: ``` `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 +in the server's `modules.json` (in the per-machine data directory, see the +README), 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. @@ -105,8 +105,9 @@ web address where protocol or path matter (a page to embed, a webhook). Endpoints belong in admin config, not per-tile settings — connections are server-wide by design. -Admin config lives in `config/modules.json` (written by the admin page — -your module never touches that file). Per-tile settings live inside each +Admin config lives in `modules.json` inside the server's data directory +(written by the admin page — your module never touches that file, and it +survives updates of the app folder). Per-tile settings live inside each browser's layout. ## Client entry (`client.js`) diff --git a/public/admin/admin.css b/public/admin/admin.css index 2e54255..4a7c0e7 100644 --- a/public/admin/admin.css +++ b/public/admin/admin.css @@ -35,6 +35,18 @@ body { overflow: auto; } margin-bottom: 14px; } +.admin-storage { margin-top: -6px; font-size: 12px; } +.admin-storage code { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 11.5px; + color: var(--text); + background: var(--panel); + border: 1px solid var(--border); + border-radius: 5px; + padding: 1px 5px; + overflow-wrap: anywhere; +} + .admin-card { background: var(--panel); border: 1px solid var(--border); diff --git a/public/admin/admin.js b/public/admin/admin.js index fdcd56c..9f43bbc 100644 --- a/public/admin/admin.js +++ b/public/admin/admin.js @@ -382,6 +382,11 @@ async function loadState() { } loginCard.hidden = true; modulesSection.hidden = false; + const storageNote = document.getElementById('storage-note'); + if (state.dataDir) { + document.getElementById('storage-path').textContent = state.dataDir; + storageNote.hidden = false; + } modulesList.innerHTML = ''; liveEls.clear(); for (const mod of state.modules || []) { diff --git a/public/admin/index.html b/public/admin/index.html index 3081a90..c386ec5 100644 --- a/public/admin/index.html +++ b/public/admin/index.html @@ -32,6 +32,8 @@

Server-wide settings, shared by every dashboard. Saving re-initialises the module immediately — open tiles reconnect on their own. Per-tile options (filters, text size) live in each tile's gear menu instead.

+
diff --git a/server.js b/server.js index 8204103..98be267 100644 --- a/server.js +++ b/server.js @@ -4,13 +4,16 @@ * * 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/. + * /api/modules//, and stores server-side module config + named layouts + * in a per-machine data directory (see DATA_DIR below) so that updating the + * checkout never loses them. * * 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) + * Configure via config/proddash.json (defaults, in the repo), an optional + * proddash.json in the data directory (this machine's overrides), or env vars: + * PORT (default: value in proddash.json, else 24500) + * PRODDASH_PASSCODE (overrides adminPasscode in proddash.json) + * PRODDASH_DATA_DIR (where module config + layouts are kept; see below) */ 'use strict'; @@ -22,9 +25,54 @@ const crypto = require('crypto'); 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'); +/** Checked-in defaults (proddash.json) — and where runtime state used to live. */ +const REPO_CONFIG_DIR = path.join(ROOT, 'config'); + +/* ── data directory ─────────────────────────────────────────────────── */ + +/** + * Everything the app writes — module config (connection settings, API keys, + * enabled flags) and named layouts — lives in a per-machine data directory + * OUTSIDE the checkout. Pulling, re-cloning or resetting the app folder from + * main therefore never touches it, and nothing has to be re-entered in /admin. + * + * PRODDASH_DATA_DIR overrides the location. A relative path resolves + * against the app folder ("config" = the old in-repo spot). + * default Windows %APPDATA%\ProdDash + * macOS ~/Library/Application Support/ProdDash + * other $XDG_CONFIG_HOME/proddash, else ~/.config/proddash + * + * If the directory cannot be created or written, the server says so and + * falls back to the in-repo config/ folder rather than refusing to start. + */ +function defaultDataDir() { + const home = os.homedir(); + if (process.platform === 'win32') { + return path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'ProdDash'); + } + if (process.platform === 'darwin') { + return path.join(home, 'Library', 'Application Support', 'ProdDash'); + } + return path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'proddash'); +} + +function resolveDataDir() { + const fromEnv = String(process.env.PRODDASH_DATA_DIR || '').trim(); + const dir = fromEnv ? path.resolve(ROOT, fromEnv) : defaultDataDir(); + try { + fs.mkdirSync(dir, { recursive: true }); + fs.accessSync(dir, fs.constants.W_OK); + return dir; + } catch (err) { + console.warn(`[proddash] cannot use data directory ${dir} (${err.message}) — falling back to ${REPO_CONFIG_DIR}`); + return REPO_CONFIG_DIR; + } +} + +const DATA_DIR = resolveDataDir(); +const MODULES_CONFIG_PATH = path.join(DATA_DIR, 'modules.json'); +const LAYOUTS_DIR = path.join(DATA_DIR, 'layouts'); /* ── config files ───────────────────────────────────────────────────── */ @@ -43,7 +91,52 @@ function writeJson(file, value) { fs.renameSync(tmp, file); // atomic-ish: never leave a half-written config } -const shellConfig = readJson(path.join(CONFIG_DIR, 'proddash.json'), {}); +/** + * One-time move-in. Earlier versions kept modules.json and layouts/ inside the + * repo's config/ folder; if the data directory has none yet and those files + * exist, copy them across (the originals are left alone). + */ +function migrateLegacyState() { + if (DATA_DIR === REPO_CONFIG_DIR) return; + const moved = []; + const oldModules = path.join(REPO_CONFIG_DIR, 'modules.json'); + if (!fs.existsSync(MODULES_CONFIG_PATH) && fs.existsSync(oldModules)) { + try { + fs.copyFileSync(oldModules, MODULES_CONFIG_PATH); + moved.push('modules.json'); + } catch (err) { + console.warn(`[proddash] could not copy ${oldModules} into the data directory: ${err.message}`); + } + } + const oldLayouts = path.join(REPO_CONFIG_DIR, 'layouts'); + let oldFiles = []; + try { + oldFiles = fs.readdirSync(oldLayouts).filter((f) => f.endsWith('.json')); + } catch { /* none to move */ } + let haveLayouts = false; + try { + haveLayouts = fs.readdirSync(LAYOUTS_DIR).some((f) => f.endsWith('.json')); + } catch { /* no layouts dir yet */ } + if (oldFiles.length && !haveLayouts) { + try { + fs.mkdirSync(LAYOUTS_DIR, { recursive: true }); + for (const f of oldFiles) fs.copyFileSync(path.join(oldLayouts, f), path.join(LAYOUTS_DIR, f)); + moved.push(`${oldFiles.length} layout${oldFiles.length === 1 ? '' : 's'}`); + } catch (err) { + console.warn(`[proddash] could not copy layouts into the data directory: ${err.message}`); + } + } + if (moved.length) { + console.log(`[proddash] moved ${moved.join(' and ')} from ${REPO_CONFIG_DIR} into ${DATA_DIR}`); + } +} +migrateLegacyState(); + +/** Shell settings: checked-in defaults, then this machine's overrides on top. */ +const shellConfig = { + ...readJson(path.join(REPO_CONFIG_DIR, 'proddash.json'), {}), + ...readJson(path.join(DATA_DIR, 'proddash.json'), {}), +}; const PORT = Number(process.env.PORT || shellConfig.port || 24500); /** Per-module server-wide state: { "": { enabled: bool, config: {…} } } */ @@ -453,6 +546,7 @@ async function handleAdminApi(req, res, urlPath) { return sendJson(res, 200, { authRequired: Boolean(ADMIN_PASSCODE), authed: true, + dataDir: DATA_DIR, modules: [...manifests.keys()].sort().map(adminModuleView), }); } @@ -468,15 +562,19 @@ async function handleAdminApi(req, res, urlPath) { } 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 + try { + if (m[2] === 'config') { + applyConfigPatch(id, body.config); + } else { + modulesConfig[id] = { ...moduleState(id), enabled: Boolean(body.enabled) }; + writeJson(MODULES_CONFIG_PATH, modulesConfig); + } + } catch (err) { + return sendJson(res, 500, { error: `Could not save ${MODULES_CONFIG_PATH}: ${err?.message || err}` }); } + // Config changes take effect without a manual restart; a toggle mounts when + // enabled and unmounts (only) otherwise. + remountModule(id); broadcastShellEvent('modules-changed', { id }); return sendJson(res, 200, { ok: true, module: adminModuleView(id) }); } @@ -484,9 +582,7 @@ 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'); +/* ── named layouts (JSON files under /layouts/) ───────────── */ /** Names double as file names — keep them boring on purpose. */ function layoutNameOk(name) { @@ -564,7 +660,11 @@ async function handleLayoutsApi(req, res, urlPath) { } const layout = sanitizeLayout(body.layout); if (!layout.tiles.length) return sendJson(res, 400, { error: 'Refusing to save an empty layout.' }); - writeJson(layoutPath(name), layout); + try { + writeJson(layoutPath(name), layout); + } catch (err) { + return sendJson(res, 500, { error: `Could not save ${layoutPath(name)}: ${err?.message || err}` }); + } return sendJson(res, 200, { ok: true, name }); } @@ -824,6 +924,7 @@ server.listen(PORT, '0.0.0.0', () => { console.log(ids.length ? ' Modules : ' + ids.map((id) => id + (isEnabled(id) ? '' : ' (disabled)')).join(', ') : ' Modules : none installed'); + console.log(' Settings : ' + DATA_DIR + (DATA_DIR === REPO_CONFIG_DIR ? ' (inside the app folder)' : '')); }); for (const signal of ['SIGINT', 'SIGTERM']) { From 66f45609f0557aba4d326e638ef71ed54463b06c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 21:20:52 +0000 Subject: [PATCH 2/5] Bottom-edge height pill on tiles; fullscreen fit runs every column to the bottom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tiles get a third resize handle: a pill on the bottom edge (hover-only, between the two corner handles) that moves that edge. With nothing directly below, it changes just the tile's height. When other tiles share the edge, the drag trades rows with them so the column keeps its total height — the vertical counterpart of the seam pill. Clamped to every tile's minimum height; blocked by collisions like the corner handles. Fullscreen auto-fit reworked so columns actually reach the bottom of the screen. The old rule only stretched tiles whose bottom edge was within two rows of the viewport bottom, which on a display taller than the browser window the layout was designed in left a blank band below every column. Now the bottom-most tile of each column stretches to the bottom regardless of distance. Short tiles (h <= 2, the clock) are never stretched into a ribbon: under another tile they drop to the bottom edge and the tile above grows into the gap (stopping at anything else below it); alone in their columns they stay put. The fit re-runs from a ResizeObserver on the grid area, so the browser's animated fullscreen transition and the menubar collapse both settle into a correct fit (replaces the resize/transitionend listeners). README documents the pill and the fullscreen behaviour. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH --- README.md | 19 +++-- public/shell.js | 180 +++++++++++++++++++++++++++++++++++++++-------- public/style.css | 34 +++++++++ 3 files changed, 198 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 925b752..85d1241 100644 --- a/README.md +++ b/README.md @@ -90,10 +90,21 @@ ignored from then on. - **+** — 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). +- Drag a tile by its **title bar** to move it; drag either **bottom + corner** to resize; **✕** removes it. Hover a tile and a small pill + appears on its **bottom edge** — drag it to change just the height. If + another tile sits directly below, the pill moves the shared edge instead, + trading rows between the two so the column keeps its total height. Tiles + sit flush — when two share a vertical edge, hover the seam and the same + kind of pill appears; drag it to trade width (macOS split-view style). +- **Fullscreen** (the ⛶ button) is a viewing mode: the menu bar hides, the + occupied columns stretch to the full width, and every column runs to the + bottom of the screen — the last tile in each column is stretched to end + there. A short tile like the clock is never stretched into a ribbon: under + another tile it slides to the bottom edge and the tile above grows into the + gap; alone in its columns it stays as it is. Nothing is saved — the layout + reverts when you leave fullscreen. A layout deeper than the screen keeps + its normal row height and scrolls. - 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. diff --git a/public/shell.js b/public/shell.js index d0d10bd..3936ede 100644 --- a/public/shell.js +++ b/public/shell.js @@ -277,6 +277,11 @@ function buildTile(tile) { resizeLeft.className = 'tile-resize-left'; resizeLeft.title = 'Drag to resize'; + // pill on the bottom edge: moves that edge (trading rows with tiles below) + const resizeBottom = document.createElement('div'); + resizeBottom.className = 'tile-resize-bottom'; + resizeBottom.title = 'Drag to adjust height'; + // notch on the title bar's bottom edge: hides/shows the bar const notch = document.createElement('button'); notch.className = 'tile-notch'; @@ -288,7 +293,7 @@ function buildTile(tile) { saveLayout(); }); - el.append(head, body, resize, resizeLeft, notch); + el.append(head, body, resize, resizeLeft, resizeBottom, notch); el.classList.toggle('head-hidden', Boolean(tile.headHidden)); grid.appendChild(el); @@ -299,6 +304,7 @@ function buildTile(tile) { wireDrag(tile, el, head); wireResize(tile, el, resize, false); wireResize(tile, el, resizeLeft, true); + wireBottomResize(tile, el, resizeBottom); mountModule(tile); return entry; @@ -429,6 +435,79 @@ function wireResize(tile, el, handle, fromLeft) { }); } +/* ── drag the bottom edge: height only ──────────────────────────────── */ + +/* The pill on a tile's bottom edge moves that edge. When other tiles sit + directly below it (sharing the edge), the drag trades rows with them — + the column's total height stays the same, like the seam pill trades + columns. With nothing directly below, it simply changes this tile's + height. */ + +function tilesDirectlyBelow(tile) { + return tiles.filter((t) => t !== tile + && t.y === tile.y + tile.h + && t.x < tile.x + tile.w && tile.x < t.x + t.w); +} + +function wireBottomResize(tile, el, handle) { + handle.addEventListener('pointerdown', (ev) => { + if (ev.button !== 0 && ev.pointerType === 'mouse') return; + if (document.body.classList.contains('fullscreen')) return; + ev.preventDefault(); + ev.stopPropagation(); + closeSettingsPopovers(); + handle.setPointerCapture(ev.pointerId); + const m = cellMetrics(); + const sy = ev.clientY; + const startH = tile.h; + const below = tilesDirectlyBelow(tile).map((t) => ({ t, bottom: t.y + t.h })); + const group = [tile.id, ...below.map((b) => b.t.id)]; + const minH = minSizeOf(tile).h; + // the edge can rise until this tile hits its minimum, and drop until the + // shortest neighbour below hits its own + const maxH = below.length + ? Math.min(...below.map((b) => b.bottom - minSizeOf(b.t).h)) - tile.y + : Infinity; + el.classList.add('resizing'); + grid.classList.add('no-dividers'); + let changed = false; + + const onMove = (e) => { + const dRows = Math.round((e.clientY - sy) / (m.ch + m.gap)); + const h = Math.max(minH, Math.min(maxH, startH + dRows)); + if (h === tile.h || h > maxH) return; + const edge = tile.y + h; + const candSelf = { x: tile.x, y: tile.y, w: tile.w, h }; + const candBelow = below.map((b) => ({ x: b.t.x, y: edge, w: b.t.w, h: b.bottom - edge })); + if (!isFreeExcept(candSelf, group) || candBelow.some((c) => !isFreeExcept(c, group))) return; + tile.h = h; + applyRect(tile); + notifyResize(tile); + below.forEach((b, i) => { + b.t.y = candBelow[i].y; + b.t.h = candBelow[i].h; + applyRect(b.t); + notifyResize(b.t); + }); + changed = true; + }; + const onUp = () => { + handle.removeEventListener('pointermove', onMove); + handle.removeEventListener('pointerup', onUp); + handle.removeEventListener('pointercancel', onUp); + el.classList.remove('resizing'); + grid.classList.remove('no-dividers'); + if (changed) { + saveLayout(); + rebuildDividers(); + } + }; + handle.addEventListener('pointermove', onMove); + handle.addEventListener('pointerup', onUp); + handle.addEventListener('pointercancel', onUp); + }); +} + function notifyResize(tile) { const entry = tileEls.get(tile.id); try { @@ -1079,27 +1158,68 @@ try { /* ── fullscreen: auto-fit the layout to the screen ──────────────────── */ /* Fullscreen is a viewing mode. The occupied columns stretch proportionally - to the full width; tiles whose bottom edge lands near the bottom of the - viewport are stretched to end exactly at it; when nothing extends below - the viewport, scrolling is disabled. All of it is visual-only — the saved + to the full width, and every column runs to the bottom of the screen: the + bottom-most tile of each column stretches to end exactly there. A small + tile (the clock) is never stretched into a ribbon — with a tile above it, + it drops to the bottom edge and the tile above grows into the gap; alone + in its columns, it stays exactly as it is. When nothing extends below the + viewport, scrolling is disabled. All of it is visual-only — the saved layout is untouched and everything reverts on exit. */ const fsBtn = document.getElementById('fs-btn'); -/** Rows this close to the viewport bottom count as "near" and get stretched. */ -const FS_NEAR_ROWS = 2; +/** Tiles this short (the clock) are moved, never stretched, in fullscreen. */ +const FS_SMALL_ROWS = 2; let menuHiddenBeforeFs = false; -function fsSmallExempt(tile) { - // A small tile (like the clock) alone in its columns must not become a - // full-height ribbon. - if (tile.h > 2) return false; - return !tiles.some((t) => t !== tile && t.x < tile.x + tile.w && tile.x < t.x + t.w); +function colsOverlap(a, b) { + return a.x < b.x + b.w && b.x < a.x + a.w; +} + +function fsSmall(tile) { + return tile.h <= FS_SMALL_ROWS; +} + +function fsAloneInColumns(tile) { + return !tiles.some((t) => t !== tile && colsOverlap(t, tile)); } function fsBottomMost(tile) { - return !tiles.some((t) => t !== tile - && t.x < tile.x + tile.w && tile.x < t.x + t.w - && t.y >= tile.y + tile.h); + return !tiles.some((t) => t !== tile && colsOverlap(t, tile) && t.y >= tile.y + tile.h); +} + +/** + * Fullscreen row placement for every tile, given R rows on screen: + * { id -> { y, h } }. Only bottom-most tiles change (stretch, or drop when + * small), plus the tile directly above a dropped small one, which grows + * into the space it left — stopping at anything else below it. + */ +function fullscreenRows(R) { + const rows = new Map(tiles.map((t) => [t.id, { y: t.y, h: t.h }])); + const dropped = []; + for (const tile of tiles) { + if (!fsBottomMost(tile)) continue; + if (!fsSmall(tile)) { + rows.get(tile.id).h = R - tile.y; + } else if (!fsAloneInColumns(tile) && R - tile.h > tile.y) { + rows.get(tile.id).y = R - tile.h; + dropped.push(tile); + } + } + for (const small of dropped) { + const gapTop = rows.get(small.id).y; + for (const above of tiles) { + if (above === small || fsSmall(above) || !colsOverlap(above, small)) continue; + if (above.y + above.h !== small.y) continue; // only a tile touching it + let limit = gapTop; + for (const other of tiles) { + if (other === above || other === small || !colsOverlap(above, other)) continue; + const oy = rows.get(other.id).y; + if (oy >= above.y + above.h) limit = Math.min(limit, oy); + } + rows.get(above.id).h = Math.max(above.h, limit - above.y); + } + } + return rows; } function applyFullscreenFit() { @@ -1128,16 +1248,14 @@ function applyFullscreenFit() { const cellH = (wrapH - (R - 1) * m.gap) / R; grid.style.gridAutoRows = cellH + 'px'; + const rows = fullscreenRows(R); for (const tile of tiles) { - applyRect(tile); // reset any previous stretch first - const bottom = tile.y + tile.h; - if (R - bottom <= FS_NEAR_ROWS && fsBottomMost(tile) && !fsSmallExempt(tile)) { - const entry = tileEls.get(tile.id); - if (entry) { - entry.el.style.gridRow = `${tile.y + 1} / span ${R - tile.y}`; - try { entry.instance?.onResize?.(tile.w, R - tile.y); } catch { /* module's problem */ } - } - } + const entry = tileEls.get(tile.id); + if (!entry) continue; + const r = rows.get(tile.id); + entry.el.style.gridColumn = `${tile.x + 1} / span ${tile.w}`; + entry.el.style.gridRow = `${r.y + 1} / span ${r.h}`; + try { entry.instance?.onResize?.(tile.w, r.h); } catch { /* module's problem, not the shell's */ } } } @@ -1174,14 +1292,14 @@ document.addEventListener('fullscreenchange', () => { } }); -window.addEventListener('resize', () => { - if (document.body.classList.contains('fullscreen')) applyFullscreenFit(); -}); - -// the menubar collapse animates, so the grid's final height arrives late -topbar.addEventListener('transitionend', () => { - if (document.body.classList.contains('fullscreen')) applyFullscreenFit(); -}); +// The grid area's size settles late: the browser animates into fullscreen, +// then the menubar collapse animates. Re-fit whenever its size changes. +let fsRefit = 0; +new ResizeObserver(() => { + if (!document.body.classList.contains('fullscreen')) return; + cancelAnimationFrame(fsRefit); + fsRefit = requestAnimationFrame(applyFullscreenFit); +}).observe(gridwrap); /* ── live updates from the admin page ───────────────────────────────── */ diff --git a/public/style.css b/public/style.css index f435d9c..0c0e936 100644 --- a/public/style.css +++ b/public/style.css @@ -411,6 +411,39 @@ body.menu-hidden #topbar { border-radius: 0 0 0 3px; } +/* bottom-edge pill: drag to move the tile's bottom edge (height only); + sits between the two corner handles */ +.tile-resize-bottom { + position: absolute; + left: 24px; + right: 24px; + bottom: 0; + height: 10px; + z-index: 5; + display: flex; + align-items: flex-end; + justify-content: center; + cursor: ns-resize; + touch-action: none; + opacity: 0; + transition: opacity 0.15s ease; +} +.tile-resize-bottom::after { + content: ""; + width: 44px; + max-width: 60%; + height: 5px; + margin-bottom: 2px; + border-radius: 999px; + background: var(--muted); + transition: background 0.15s ease; +} +.tile:hover .tile-resize-bottom { opacity: 0.6; } +.tile .tile-resize-bottom:hover, +.tile.resizing .tile-resize-bottom { opacity: 1; } +.tile .tile-resize-bottom:hover::after, +.tile.resizing .tile-resize-bottom::after { background: var(--text); } + /* ── seam divider between adjacent tiles ───────────────────────────── */ /* When two tiles share a vertical edge, an invisible strip sits on the seam; @@ -452,6 +485,7 @@ body.menu-hidden #topbar { editing affordances (corner handles, seam pills) are parked. */ body.fullscreen .tile-resize, body.fullscreen .tile-resize-left, +body.fullscreen .tile-resize-bottom, body.fullscreen .tile-divider { display: none; } body.fullscreen #grid { padding-bottom: 0; } #gridwrap.no-scroll { overflow-y: hidden; } From 483c353843bf9e89352dee38533da177add9b8d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 22:02:43 +0000 Subject: [PATCH 3/5] Menubar tab moves under the brand in accent green; Admin hidden in fullscreen The menu bar's show/hide tab was centred on the screen, exactly where a centred tile's own title-bar tab lands once both bars are hidden: two identical tabs on top of each other, with the tile's unreachable. The tab now sits at the left under the ProdDash brand and wears the accent colour, so it never coincides with a tile tab and reads unmistakably as the dashboard's. To keep it off a top-row tile's status dot and title it straddles the bar's bottom edge while the bar is shown (mostly on the bar, a short lip below) and becomes a slim tab at the very top edge when the bar is hidden. The Admin link is hidden in fullscreen, including when the menu bar is brought back there. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH --- README.md | 9 ++++++--- public/index.html | 2 +- public/style.css | 29 +++++++++++++++++------------ 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 85d1241..3cd53b3 100644 --- a/README.md +++ b/README.md @@ -97,8 +97,9 @@ ignored from then on. trading rows between the two so the column keeps its total height. Tiles sit flush — when two share a vertical edge, hover the seam and the same kind of pill appears; drag it to trade width (macOS split-view style). -- **Fullscreen** (the ⛶ button) is a viewing mode: the menu bar hides, the - occupied columns stretch to the full width, and every column runs to the +- **Fullscreen** (the ⛶ button) is a viewing mode: the menu bar hides (its + green tab brings it back, minus the Admin button), the occupied columns + stretch to the full width, and every column runs to the bottom of the screen — the last tile in each column is stretched to end there. A short tile like the clock is never stretched into a ribbon: under another tile it slides to the bottom edge and the tile above grows into the @@ -107,7 +108,9 @@ ignored from then on. its normal row height and scrolls. - 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. + for a clean wall-display look. The menu bar has the same kind of tab — + the green one under the ProdDash name, kept at the left so it never sits + on top of a tile's own tab. - The **gear** on a tile holds per-tile settings (filters, text size, display options) — these belong to this browser's layout, not the server. - The **grid icon** — save the current arrangement to the server under a diff --git a/public/index.html b/public/index.html index eab549b..35f8f08 100644 --- a/public/index.html +++ b/public/index.html @@ -42,7 +42,7 @@ - + diff --git a/public/style.css b/public/style.css index 0c0e936..77a5b71 100644 --- a/public/style.css +++ b/public/style.css @@ -54,25 +54,30 @@ body.menu-hidden #topbar { overflow: hidden; } -/* the menubar's own show/hide tab, same as the tile notches */ +/* The menubar's own show/hide tab. It sits under the ProdDash brand at the + left — not screen-centre, where a tile's own (centred) tab would land + right under it once both bars are hidden — and wears the accent colour + so it never reads as a tile tab. */ #topbar-notch { position: fixed; - top: var(--topbar-h, 46px); - left: 50%; - transform: translateX(-50%); + /* straddles the bar's bottom edge — mostly on the bar, a short lip below + it — so it never covers a top-row tile's status dot or title */ + top: calc(var(--topbar-h, 46px) - 8px); + left: 12px; width: 54px; height: 14px; padding: 0; - background: var(--panel); - border: 1px solid var(--border); - border-top: none; + background: var(--accent); + border: none; border-radius: 0 0 10px 10px; cursor: pointer; z-index: 60; - opacity: 0.35; - transition: opacity 0.15s ease, top 0.22s ease; + opacity: 0.55; + transition: opacity 0.15s ease, top 0.22s ease, height 0.22s ease; } #topbar-notch:hover { opacity: 1; } +/* bar hidden: a slim tab at the very top edge, clear of a top-row tile's dot and title */ +body.menu-hidden #topbar-notch { top: 0; height: 10px; } #topbar-notch::after { content: ""; display: block; @@ -80,9 +85,8 @@ body.menu-hidden #topbar { height: 3px; margin: 4px auto 0; border-radius: 999px; - background: var(--muted); + background: var(--bg); } -#topbar-notch:hover::after { background: var(--text); } #brand { font-weight: 800; @@ -486,7 +490,8 @@ body.menu-hidden #topbar { body.fullscreen .tile-resize, body.fullscreen .tile-resize-left, body.fullscreen .tile-resize-bottom, -body.fullscreen .tile-divider { display: none; } +body.fullscreen .tile-divider, +body.fullscreen #admin-link { display: none; } body.fullscreen #grid { padding-bottom: 0; } #gridwrap.no-scroll { overflow-y: hidden; } From 2daf8897a123839d1fd6bbea6015e69c1e3ee6b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 21:12:46 +0000 Subject: [PATCH 4/5] Install and update from the admin page: repo catalog, module version requirements, shell updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProdDash now has a version (package.json, 1.1.0) and modules declare the shell versions they need ("proddash": ">=1.1.0" in the manifest). A module whose requirement this shell doesn't meet is listed in the admin page but never loaded, and cannot be installed from the catalog. Modules come from two places: bundled with the checkout (modules/) and installed from the admin page into the data directory (/modules/). Same id in both → the newer version runs; a downloaded copy the bundled one has overtaken is removed. The admin page's new "Available modules" section lists modules in the ProdDash repo that aren't installed here — one small tar.gz of the branch is fetched (at most every few minutes, only while the admin page is open or for the periodic update check) and read with a dependency-free tar reader (ustar, GNU long names, pax headers; the pax global comment carries the commit sha). Install downloads the module folder into the data directory and mounts it immediately; a module card offers "Update to vX" when the repo has a newer version. Uninstall removes an installed module's files; a bundled module is hidden instead (its folder belongs to the app) and can be restored from Available modules without a download. Settings are kept across uninstall/reinstall. Shell updates: the server compares the repo's package.json version and commit sha with its own (git rev-parse when the app folder is a checkout), shows a banner in the admin page, and "Update now" brings the app folder up to date — git pull --ff-only for a checkout that is on the repo branch with no local changes (file modes ignored), otherwise the archive is unpacked over the folder (runtime state and .git untouched). The server then exits with code 75 and starts its own replacement unless PRODDASH_LAUNCHER is set; the replacement waits for the port to free up (EADDRINUSE retry). The page polls until the new version answers. PRODDASH_REPO (owner/repo#branch), PRODDASH_ARCHIVE_BASE and PRODDASH_NO_REMOTE_CHECK configure/disable it. Docs: MODULE-GUIDE gets the rules every module follows (all settings in the admin page, dependencies shipped inside the module, everything runs inside the module's server entry — no helper programs, declare the ProdDash requirement and bump the version), the proddash/version manifest fields and the new installation story; README gets "Installing modules and updating"; CLAUDE.md summarises the rules. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH --- CLAUDE.md | 12 +- README.md | 47 +- config/proddash.json | 4 +- docs/MODULE-GUIDE.md | 43 +- modules/clock/module.json | 1 + modules/prodcom-transcript/module.json | 1 + modules/propresenter-now-next/module.json | 1 + modules/propresenter-timers/module.json | 1 + package.json | 9 + public/admin/admin.css | 65 +++ public/admin/admin.js | 255 +++++++++- public/admin/index.html | 26 +- server.js | 580 +++++++++++++++++++++- 13 files changed, 1015 insertions(+), 30 deletions(-) create mode 100644 package.json diff --git a/CLAUDE.md b/CLAUDE.md index f08eb4b..b5670ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,8 +16,16 @@ contract** (manifest, client factory + moduleApi, optional server entry, title-bar controls). The rules that matter most: - A module is one self-contained folder in `modules/` — **never modify the - shell** (`server.js`, `public/`) to add one. Installation = drop the folder, - restart, configure in `/admin`. + shell** (`server.js`, `public/`) to add one. Installation = the admin page's + Available modules (downloads from the repo into the data directory), or + drop the folder and restart; configure in `/admin`. +- **All settings in the admin page** (`configSchema` / `instanceSchema`) — + never a separate setup page, hand-edited file or env var. **Dependencies + ship inside the module folder** (zero deps is the norm). **Everything the + module does runs inside its server entry** — no helper scripts, companion + programs or daemons the operator starts by hand; a listener or decoder is + started in `init()` and stopped in `stop()`. Declare `"proddash": ">=x.y.z"` + and bump the module's `version` on every change. - Browsers never call upstream services directly; the module's server routes proxy everything (no CORS, secrets stay server-side). - Style with the shell's CSS variables only, scoped under diff --git a/README.md b/README.md index 3cd53b3..f585138 100644 --- a/README.md +++ b/README.md @@ -65,8 +65,9 @@ the app folder from `main` and start the new version with everything still configured. The admin page shows the exact path in use, and the server prints it at startup (`Settings : …`). -The directory holds `modules.json` (module config) and `layouts/` (named -layouts), both written by the app itself. It may also hold a `proddash.json` +The directory holds `modules.json` (module config), `layouts/` (named +layouts) and `modules/` (modules installed from the admin page), all written +by the app itself. It may also hold a `proddash.json` with this machine's shell settings — the same keys as the checked-in [config/proddash.json](config/proddash.json), which supplies the defaults: @@ -86,6 +87,47 @@ so and falls back to the app's `config/` folder. directory, so nothing has to be re-entered. The old files are left in place and ignored from then on. +## Installing modules and updating + +Everything below happens in the **admin page**; ProdDash talks to its GitHub +repository (`michaelmcgary2008/ProdDash`, branch `main` — change with +`repo` / `branch` in `proddash.json` or the `PRODDASH_REPO` variable, +`owner/repo#branch`). It downloads one small archive of the branch, at most +every few minutes, only while the admin page is open or for the update check +below. Nothing is installed without someone clicking. + +- **Available modules** lists modules in the repo that aren't installed + here. **Install** downloads the module into the data directory's `modules/` + and starts it right away — nothing is written into the app folder, and no + restart is needed. Modules that ship with your copy of ProdDash are + "bundled"; ones you installed are "installed"; if both exist, the newer + version runs. +- **Uninstall** on a module card removes an installed module's files. For a + bundled module (its folder belongs to the app) it hides the module from + every dashboard instead; it reappears under Available modules and can be + brought back without downloading. Settings are kept either way. +- **Module updates**: when the repo has a newer version of an installed + module, its card shows **Update to vX**. +- **Version requirements**: every module declares the ProdDash versions it + needs (`"proddash": ">=1.1.0"` in its manifest). A module that needs a + newer shell is listed but not loaded, and cannot be installed until + ProdDash is updated. +- **ProdDash updates**: the admin page checks the repo shortly after start + and every six hours, and shows a banner when `main` has a newer version + (or new commits). **Update now** brings the app folder up to date and + restarts the server; dashboards reconnect on their own. A git checkout is + updated with `git pull --ff-only` (only when it is on the repo branch with + no local changes — otherwise the banner says why and you update with git). + A plain folder (no `.git`) has the repo archive unpacked over it; files + removed upstream are left behind but harmless. Settings and layouts are + never touched. **What changed** opens the commit comparison on GitHub. +- **Restarting**: after an update the server exits with code 75 and starts + its own replacement, which waits for the port to free up. If you supervise + ProdDash with your own launcher, set `PRODDASH_LAUNCHER=1` so the server + just exits and your launcher restarts it on exit code 75. + `PRODDASH_NO_REMOTE_CHECK=1` turns off the periodic check (the admin page + still checks when opened). + ## Using the dashboard - **+** — add any enabled module; multiple tiles of the same module are @@ -172,6 +214,7 @@ ProdDash/ │ ├── clock/ │ ├── prodcom-transcript/ │ └── propresenter-now-next/ (carries its own copy of propresenter-core) +├── package.json name + version (what the update check compares) ├── config/ proddash.json defaults (runtime state lives in the │ per-machine data directory — see "Configure") ├── docs/MODULE-GUIDE.md how to build and install a module diff --git a/config/proddash.json b/config/proddash.json index 869d4c8..d43bd7c 100644 --- a/config/proddash.json +++ b/config/proddash.json @@ -1,4 +1,6 @@ { "port": 24500, - "adminPasscode": "" + "adminPasscode": "", + "repo": "michaelmcgary2008/ProdDash", + "branch": "main" } diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md index 6741fa1..d03474c 100644 --- a/docs/MODULE-GUIDE.md +++ b/docs/MODULE-GUIDE.md @@ -9,6 +9,31 @@ 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. +## Rules every module follows + +These are not style preferences — the admin page, the installer and the +people running the booth depend on them. + +1. **All settings live in the ProdDash admin page.** Server-wide settings go + in `configSchema`, per-tile settings in `instanceSchema`. Never a separate + setup page, wizard, config file the operator edits by hand, or environment + variable. +2. **Dependencies ship inside the module folder.** Zero dependencies is the + norm (the shell has none). Anything you genuinely need is vendored into + the module — or fetched by your own `init()` on first start, with the + admin health line saying what is happening. No `npm install`, no + installer, no "run this first". +3. **Everything the module does runs inside the module.** No helper scripts, + companion programs, daemons or services the operator has to start next to + ProdDash. If your feature needs a listener, a decoder or a worker, your + server entry owns it: start it in `init()`, stop it in `stop()`, report + it in `health()`. A child process the module itself spawns and supervises + is fine; a program someone launches by hand is not. +4. **Declare the ProdDash version you need** (`"proddash": ">=1.1.0"` in the + manifest) and **bump your own `version`** whenever you change the module. + The admin page refuses to install a module whose requirement the running + shell doesn't meet, and it offers updates by comparing versions. + ## The shape of a module A module is one folder dropped into `modules/`: @@ -21,9 +46,16 @@ modules// └── ... 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. +**Installation is exactly this:** in the admin page, open **Available +modules** and press **Install** — the module is downloaded from the ProdDash +repo into the server's data directory (`/modules//`) and starts +immediately. Developing locally? Drop the folder into `modules/` (bundled +with the checkout) or into the data directory's `modules/` and restart. No +shell code changes, ever. Modules bundled with a checkout and modules +installed from the admin page live side by side; when both have the same id +the newer version runs. **Uninstall** in the admin page removes an installed +module's files (a bundled one is hidden instead, since its folder belongs to +the app); its settings are kept for a later reinstall. ## module.json @@ -32,6 +64,7 @@ shell code changes, ever. "id": "my-module", "name": "My Module", "version": "1.0.0", + "proddash": ">=1.1.0", "description": "One line shown in the Add-tile picker and the admin page", "client": "client.js", "server": "server.js", @@ -52,7 +85,9 @@ shell code changes, ever. | --- | --- | --- | | `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. | +| `version` | yes | Your module's version (`x.y.z`). Bump it on every change — the admin page offers updates when the repo's copy is newer than the installed one. | +| `proddash` | yes | The ProdDash versions this module works with: `">=1.1.0"`, `"^1.1.0"`, `">=1.1.0 <2.0.0"`, or `"*"`. A module whose requirement the running shell doesn't meet is listed in the admin page but never loaded, and can't be installed from the catalog. | +| `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. | diff --git a/modules/clock/module.json b/modules/clock/module.json index 1f1639b..f95087f 100644 --- a/modules/clock/module.json +++ b/modules/clock/module.json @@ -2,6 +2,7 @@ "id": "clock", "name": "Clock", "version": "1.0.0", + "proddash": ">=1.1.0", "description": "Big booth clock — the smallest possible ProdDash module", "client": "client.js", "style": "style.css", diff --git a/modules/prodcom-transcript/module.json b/modules/prodcom-transcript/module.json index e35b928..95772dd 100644 --- a/modules/prodcom-transcript/module.json +++ b/modules/prodcom-transcript/module.json @@ -2,6 +2,7 @@ "id": "prodcom-transcript", "name": "ProdCom", "version": "1.0.0", + "proddash": ">=1.1.0", "description": "Live ProdCom transcript stream", "client": "client.js", "server": "server.js", diff --git a/modules/propresenter-now-next/module.json b/modules/propresenter-now-next/module.json index 5600866..e567176 100644 --- a/modules/propresenter-now-next/module.json +++ b/modules/propresenter-now-next/module.json @@ -2,6 +2,7 @@ "id": "propresenter-now-next", "name": "ProPresenter", "version": "1.0.0", + "proddash": ">=1.1.0", "description": "What is live in ProPresenter and what comes next", "client": "client.js", "server": "server.js", diff --git a/modules/propresenter-timers/module.json b/modules/propresenter-timers/module.json index 4818d69..2bddace 100644 --- a/modules/propresenter-timers/module.json +++ b/modules/propresenter-timers/module.json @@ -2,6 +2,7 @@ "id": "propresenter-timers", "name": "Timers", "version": "1.0.0", + "proddash": ">=1.1.0", "description": "Live ProPresenter timers and LTC timecode", "client": "client.js", "server": "server.js", diff --git a/package.json b/package.json new file mode 100644 index 0000000..d30ba57 --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "name": "proddash", + "version": "1.1.0", + "private": true, + "description": "Modular production dashboard for Waters Church — zero-dependency Node server + vanilla client", + "license": "UNLICENSED", + "engines": { "node": ">=18" }, + "scripts": { "start": "node server.js" } +} diff --git a/public/admin/admin.css b/public/admin/admin.css index 4a7c0e7..60e4d3d 100644 --- a/public/admin/admin.css +++ b/public/admin/admin.css @@ -170,3 +170,68 @@ body { overflow: auto; } .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; } + +/* ── version, update banner, catalog ───────────────────────────────── */ + +.admin-version { + font-size: 11px; + color: var(--muted); + margin-left: 4px; + vertical-align: 2px; +} + +.update-card { + border-color: color-mix(in srgb, var(--warn) 55%, var(--border)); + background: color-mix(in srgb, var(--warn) 7%, var(--panel)); + margin-top: 18px; +} +.update-head { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +.update-head strong { font-size: 14px; } +.update-head .spacer { flex: 1; } +.update-card .admin-copy { margin: 8px 0 0; } + +.btn.small { padding: 4px 9px; font-size: 12px; } +.btn.danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 45%, var(--border)); } +.btn.danger:hover { background: color-mix(in srgb, var(--danger) 14%, var(--bg)); } +.btn[disabled] { opacity: 0.5; cursor: not-allowed; } +a.btn { text-decoration: none; } + +.module-source { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--muted); + border: 1px solid var(--border); + border-radius: 4px; + padding: 1px 5px; +} +.module-update-slot:empty { display: none; } + +.catalog-toolbar { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 10px; +} +.catalog-toolbar .admin-status { margin-top: 0; } + +.catalog-row { + display: flex; + align-items: flex-start; + gap: 12px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + padding: 12px 14px; + margin-bottom: 10px; +} +.catalog-main { flex: 1; min-width: 0; } +.catalog-title { display: flex; align-items: center; gap: 8px; } +.catalog-row .module-desc { margin: 4px 0 2px; } +.catalog-row .module-health { margin-top: 2px; } diff --git a/public/admin/admin.js b/public/admin/admin.js index 9f43bbc..a9b4fa3 100644 --- a/public/admin/admin.js +++ b/public/admin/admin.js @@ -12,6 +12,19 @@ const modulesList = document.getElementById('modules-list'); const layoutsSection = document.getElementById('layouts-section'); const layoutsList = document.getElementById('layouts-list'); const toastEl = document.getElementById('toast'); +const availableSection = document.getElementById('available-section'); +const availableList = document.getElementById('available-list'); +const catalogStatus = document.getElementById('catalog-status'); +const updateCard = document.getElementById('update-card'); +const updateTitle = document.getElementById('update-title'); +const updateCopy = document.getElementById('update-copy'); +const updateStatus = document.getElementById('update-status'); +const updateBtn = document.getElementById('update-btn'); +const updateChanges = document.getElementById('update-changes'); +const shellVersionEl = document.getElementById('shell-version'); + +/** The shell version the page loaded with — the update flow watches it change. */ +let shellVersion = ''; let toastTimer = null; function toast(msg, isError = false) { @@ -42,7 +55,9 @@ async function api(path, opts = {}) { function showLogin() { loginCard.hidden = false; modulesSection.hidden = true; + availableSection.hidden = true; layoutsSection.hidden = true; + updateCard.hidden = true; passcodeInput.focus(); } @@ -170,9 +185,43 @@ function buildModuleCard(mod) { version.className = 'module-version'; version.textContent = mod.version ? 'v' + mod.version : ''; + const source = document.createElement('span'); + source.className = 'module-source'; + source.textContent = mod.source === 'installed' ? 'installed' : 'bundled'; + source.title = mod.source === 'installed' + ? 'Installed from the repo into the data directory' + : 'Ships with this copy of ProdDash'; + const spacer = document.createElement('span'); spacer.className = 'spacer'; + // filled by the catalog when the repo has a newer version + const updateSlot = document.createElement('span'); + updateSlot.className = 'module-update-slot'; + + const uninstall = document.createElement('button'); + uninstall.className = 'btn small danger'; + uninstall.textContent = 'Uninstall'; + uninstall.title = mod.source === 'installed' + ? 'Remove this module\'s files from the data directory' + : 'Hide this bundled module from every dashboard (its folder stays with the app)'; + uninstall.addEventListener('click', async () => { + const what = mod.source === 'installed' + ? `Uninstall ${mod.name}? Its files are removed from the data directory. Its settings are kept in case you install it again.` + : `Uninstall ${mod.name}? It disappears from every dashboard and the picker. It ships with ProdDash, so it can be brought back from "Available modules" any time.`; + if (!window.confirm(what)) return; + uninstall.disabled = true; + try { + await api(`/api/admin/modules/${encodeURIComponent(mod.id)}`, { method: 'DELETE' }); + toast(`${mod.name} uninstalled`); + await loadState(); + } catch (e) { + uninstall.disabled = false; + if (e.authRequired) showLogin(); + else toast(e.message, true); + } + }); + const toggleLabel = document.createElement('label'); toggleLabel.className = 'switch'; const toggle = document.createElement('input'); @@ -184,7 +233,7 @@ function buildModuleCard(mod) { toggleText.textContent = mod.enabled ? 'Enabled' : 'Disabled'; toggleLabel.append(toggle, track, toggleText); - head.append(dot, name, version, spacer, toggleLabel); + head.append(dot, name, version, source, spacer, updateSlot, toggleLabel, uninstall); card.appendChild(head); const desc = document.createElement('div'); @@ -199,11 +248,13 @@ function buildModuleCard(mod) { const mountError = document.createElement('div'); mountError.className = 'module-mount-error'; - mountError.hidden = !mod.mountError; - mountError.textContent = mod.mountError ? `Failed to start: ${mod.mountError}` : ''; + mountError.hidden = !mod.mountError && !mod.incompatible; + mountError.textContent = mod.incompatible + ? `Not loaded — ${mod.incompatible}. Update ProdDash from the banner above.` + : (mod.mountError ? `Failed to start: ${mod.mountError}` : ''); card.appendChild(mountError); - liveEls.set(mod.id, { dot, healthEl, toggle, toggleText, mountError }); + liveEls.set(mod.id, { dot, healthEl, toggle, toggleText, mountError, updateSlot }); toggle.addEventListener('change', async () => { try { @@ -289,8 +340,10 @@ async function refreshStatus() { 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 (!mod.incompatible) { + 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'; @@ -382,6 +435,9 @@ async function loadState() { } loginCard.hidden = true; modulesSection.hidden = false; + availableSection.hidden = false; + shellVersion = String(state.version || ''); + shellVersionEl.textContent = shellVersion ? 'v' + shellVersion : ''; const storageNote = document.getElementById('storage-note'); if (state.dataDir) { document.getElementById('storage-path').textContent = state.dataDir; @@ -393,8 +449,195 @@ async function loadState() { modulesList.appendChild(buildModuleCard(mod)); } loadLayouts(); + loadCatalog(false); } +/* ── the repo: available modules, module updates, shell update ──────── */ + +function fmtTime(ms) { + return ms ? new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''; +} + +async function loadCatalog(refresh) { + catalogStatus.className = 'admin-status'; + catalogStatus.textContent = refresh ? 'Checking the repo…' : 'Checking…'; + let cat; + try { + cat = await api('/api/admin/catalog' + (refresh ? '?refresh=1' : '')); + } catch (e) { + if (e.authRequired) return showLogin(); + catalogStatus.className = 'admin-status error'; + catalogStatus.textContent = e.message; + return; + } + renderShell(cat.shell || {}); + renderAvailable(cat); +} + +function renderShell(shell) { + const remote = shell.remote || {}; + if (remote.error) { + catalogStatus.className = 'admin-status error'; + catalogStatus.textContent = `Couldn't reach ${shell.repo}: ${remote.error}`; + } else { + catalogStatus.className = 'admin-status'; + catalogStatus.textContent = `${shell.repo}@${shell.branch}` + + (remote.version ? ` · latest ProdDash ${remote.version}` : '') + + (remote.checkedAt ? ` · checked ${fmtTime(remote.checkedAt)}` : ''); + } + if (!shell.updateAvailable) { + updateCard.hidden = true; + return; + } + updateCard.hidden = false; + updateTitle.textContent = shell.versionBehind + ? `ProdDash ${remote.version} is available` + : `New commits on ${shell.branch}`; + const where = shell.local?.isGit + ? `a git checkout of ${shell.local.branch} at ${String(shell.local.sha || '').slice(0, 7)}; updating runs git pull` + : 'not a git checkout; updating unpacks the repo archive over the app folder'; + updateCopy.textContent = `You're running ProdDash ${shell.version} (${where}). Settings and layouts live outside the app folder and are not touched.`; + updateChanges.href = shell.changesUrl || '#'; + updateBtn.disabled = Boolean(shell.updateBlocker) || updateBtn.dataset.busy === '1'; + if (shell.updateBlocker) { + updateStatus.className = 'admin-status error'; + updateStatus.textContent = `Can't update from here: ${shell.updateBlocker}.`; + } else if (updateBtn.dataset.busy !== '1') { + updateStatus.className = 'admin-status'; + updateStatus.textContent = 'Updating restarts ProdDash; open dashboards reconnect on their own.'; + } +} + +function renderAvailable(cat) { + availableList.innerHTML = ''; + const updates = cat.updates || []; + // newer versions of installed modules → a button on their card + for (const els of liveEls.values()) els.updateSlot.innerHTML = ''; + for (const up of updates) { + const els = liveEls.get(up.id); + if (!els) continue; + const btn = document.createElement('button'); + btn.className = 'btn small primary'; + btn.textContent = `Update to v${up.version}`; + btn.disabled = !up.compatible; + btn.title = up.compatible ? `Download v${up.version} from the repo` : up.reason; + btn.addEventListener('click', () => installFromCatalog(up, btn)); + els.updateSlot.appendChild(btn); + } + const available = cat.available || []; + if (!available.length) { + const none = document.createElement('div'); + none.className = 'layouts-empty'; + none.textContent = cat.shell?.remote?.error + ? 'The repo could not be reached, and nothing bundled is waiting to be installed.' + : 'Everything the repo offers is installed.'; + availableList.appendChild(none); + return; + } + for (const item of available) { + const row = document.createElement('div'); + row.className = 'catalog-row'; + const main = document.createElement('div'); + main.className = 'catalog-main'; + const title = document.createElement('div'); + title.className = 'catalog-title'; + const name = document.createElement('span'); + name.className = 'module-name'; + name.textContent = item.name; + const version = document.createElement('span'); + version.className = 'module-version'; + version.textContent = item.version ? 'v' + item.version : ''; + const source = document.createElement('span'); + source.className = 'module-source'; + source.textContent = item.source === 'bundled' ? 'bundled' : 'repo'; + source.title = item.source === 'bundled' ? 'Ships with this copy of ProdDash — restoring it downloads nothing' : 'Downloaded from the repo'; + title.append(name, version, source); + const desc = document.createElement('div'); + desc.className = 'module-desc'; + desc.textContent = item.description || ''; + const req = document.createElement('div'); + req.className = 'module-health' + (item.compatible ? '' : ' error'); + req.textContent = item.compatible + ? (item.requires ? `Requires ProdDash ${item.requires}` : '') + : item.reason; + main.append(title, desc, req); + const btn = document.createElement('button'); + btn.className = 'btn small primary'; + btn.textContent = 'Install'; + btn.disabled = !item.compatible; + btn.title = item.compatible ? '' : item.reason; + btn.addEventListener('click', () => installFromCatalog(item, btn)); + row.append(main, btn); + availableList.appendChild(row); + } +} + +async function installFromCatalog(item, btn) { + btn.disabled = true; + const label = btn.textContent; + btn.textContent = 'Installing…'; + try { + const r = await api(`/api/admin/modules/${encodeURIComponent(item.id)}/install`, { method: 'POST', body: '{}' }); + toast(`${item.name} ${r.downloaded ? 'installed' : 'restored'}${r.version ? ' (v' + r.version + ')' : ''}`); + await loadState(); + } catch (e) { + btn.disabled = false; + btn.textContent = label; + if (e.authRequired) showLogin(); + else toast(e.message, true); + } +} + +document.getElementById('catalog-refresh').addEventListener('click', () => loadCatalog(true)); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +/** After the update the server exits and comes back; wait for it. */ +async function waitForRestart(before) { + const started = Date.now(); + let sawDown = false; + while (Date.now() - started < 90000) { + await sleep(1500); + try { + const s = await api('/api/admin/state'); + if (sawDown || s.version !== before) { + toast(`ProdDash ${s.version} is running`); + await sleep(600); + location.reload(); + return; + } + } catch (e) { + if (e.status === 401) { location.reload(); return; } // back up, passcode wanted again + sawDown = true; + } + } + updateStatus.className = 'admin-status error'; + updateStatus.textContent = 'ProdDash did not come back on its own — start it again on the server, then reload this page.'; + updateBtn.dataset.busy = ''; + updateBtn.disabled = false; +} + +updateBtn.addEventListener('click', async () => { + if (!window.confirm('Update ProdDash now? The server restarts and every open dashboard reconnects a few seconds later.')) return; + updateBtn.dataset.busy = '1'; + updateBtn.disabled = true; + updateStatus.className = 'admin-status'; + updateStatus.textContent = 'Updating…'; + const before = shellVersion; + try { + const r = await api('/api/admin/update', { method: 'POST', body: '{}' }); + updateStatus.textContent = `Updated ${r.from} → ${r.to || 'latest commit'} via ${r.method}. Restarting…`; + } catch (e) { + updateStatus.className = 'admin-status error'; + updateStatus.textContent = e.message; + updateBtn.dataset.busy = ''; + updateBtn.disabled = false; + if (e.authRequired) showLogin(); + return; + } + await waitForRestart(before); +}); + setInterval(() => { if (modulesSection.hidden) return; refreshStatus(); diff --git a/public/admin/index.html b/public/admin/index.html index c386ec5..ec8864c 100644 --- a/public/admin/index.html +++ b/public/admin/index.html @@ -11,7 +11,7 @@
- ProdDash Admin + ProdDash Admin ← Dashboard
@@ -27,6 +27,18 @@
+ + + +