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..3cd53b3 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,28 +48,69 @@ 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 - **+** — 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 (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 + 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. + 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 @@ -130,7 +172,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/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/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..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; @@ -411,6 +415,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,7 +489,9 @@ body.menu-hidden #topbar { editing affordances (corner handles, seam pills) are parked. */ body.fullscreen .tile-resize, body.fullscreen .tile-resize-left, -body.fullscreen .tile-divider { display: none; } +body.fullscreen .tile-resize-bottom, +body.fullscreen .tile-divider, +body.fullscreen #admin-link { display: none; } body.fullscreen #grid { padding-bottom: 0; } #gridwrap.no-scroll { overflow-y: hidden; } 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']) {