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