Merge pull request #1 from michaelmcgary2008/claude/proddash-app-build-gnwq7y
Initial ProdDash implementation: modular dashboard shell with modules
This commit is contained in:
@@ -3,3 +3,7 @@ dist/
|
||||
.DS_Store
|
||||
*.log
|
||||
.env
|
||||
|
||||
# runtime state written by the admin page / layout saves
|
||||
config/modules.json
|
||||
config/layouts/
|
||||
|
||||
@@ -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/<id>/`.
|
||||
3. Mounts each enabled module's server routes under `/api/modules/<id>/` —
|
||||
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://<this-machine's-IP>: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).
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/bin/zsh
|
||||
# Double-click to start ProdDash (macOS)
|
||||
cd "$(dirname "$0")"
|
||||
exec node server.js
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"port": 24500,
|
||||
"adminPasscode": ""
|
||||
}
|
||||
@@ -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/<id>/
|
||||
├── 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/<id>/
|
||||
```
|
||||
|
||||
**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": <value>,
|
||||
"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/<id>/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: '<svg …>…</svg>', // 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/<id>/
|
||||
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 = '<div class="hc-time"></div>';
|
||||
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/<id>/...` — your routes, `curl`-able. For SSE:
|
||||
`curl -N http://localhost:24500/api/modules/<id>/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.
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 = `
|
||||
<div class="pt-wrap">
|
||||
<div class="pt-stream">
|
||||
<div class="pt-empty">Waiting for transcript…</div>
|
||||
<div class="pt-entries"></div>
|
||||
</div>
|
||||
<button class="pt-jump" hidden>↓ New messages</button>
|
||||
</div>`;
|
||||
|
||||
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 = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>';
|
||||
const JUMP_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v11"/><path d="m7 10 5 5 5-5"/><path d="M5 20h14"/></svg>';
|
||||
const CLEAR_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="m9 9 6 6M15 9l-6 6"/></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 =
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">' +
|
||||
'<circle cx="12" cy="8.5" r="3.2"/>' +
|
||||
'<path d="M5.5 19c1.6-3.2 3.9-4.8 6.5-4.8s4.9 1.6 6.5 4.8"/></svg>';
|
||||
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 || ''),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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; }
|
||||
@@ -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 = `
|
||||
<div class="nn-screen">
|
||||
<header class="nn-top">
|
||||
<div class="nn-playlist"></div>
|
||||
<div class="nn-live"><span class="nn-live-dot"></span><span class="nn-live-text">Connecting…</span></div>
|
||||
</header>
|
||||
<section class="nn-now">
|
||||
<div class="nn-label">Now</div>
|
||||
<div class="nn-title-box"><div class="nn-title is-waiting">Connecting…</div></div>
|
||||
<div class="nn-meta"></div>
|
||||
<div class="nn-progress" hidden><div class="nn-progress-fill"></div></div>
|
||||
</section>
|
||||
<section class="nn-next-wrap">
|
||||
<div class="nn-next-label" hidden>Next</div>
|
||||
<div class="nn-next-list"></div>
|
||||
<div class="nn-next-more" hidden></div>
|
||||
</section>
|
||||
</div>`;
|
||||
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
@@ -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: <view-model> } (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));
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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; }
|
||||
@@ -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; }
|
||||
@@ -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();
|
||||
@@ -0,0 +1,50 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>ProdDash Admin</title>
|
||||
<meta name="theme-color" content="#0b0e12">
|
||||
<link rel="icon" href="/icons/icon-192.png">
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<link rel="stylesheet" href="/admin/admin.css">
|
||||
</head>
|
||||
<body>
|
||||
<header id="topbar">
|
||||
<span id="brand">Prod<span class="accent">Dash</span> <span class="admin-tag">Admin</span></span>
|
||||
<span class="spacer"></span>
|
||||
<a class="tb-btn" href="/">← Dashboard</a>
|
||||
</header>
|
||||
|
||||
<main id="admin-main">
|
||||
<section class="admin-card" id="login-card" hidden>
|
||||
<h2>Admin passcode</h2>
|
||||
<p class="admin-copy">This page changes the server-wide configuration for every dashboard in the building.</p>
|
||||
<div class="login-row">
|
||||
<input type="password" id="passcode" placeholder="Passcode" autocomplete="current-password">
|
||||
<button class="btn primary" id="login-btn">Unlock</button>
|
||||
</div>
|
||||
<div class="admin-status" id="login-status"></div>
|
||||
</section>
|
||||
|
||||
<section id="modules-section" hidden>
|
||||
<h1 class="admin-h1">Modules</h1>
|
||||
<p class="admin-copy">Server-wide settings, shared by every dashboard. Saving re-initialises the
|
||||
module immediately — open tiles reconnect on their own. Per-tile options (filters, text size)
|
||||
live in each tile's gear menu instead.</p>
|
||||
<div id="modules-list"></div>
|
||||
</section>
|
||||
|
||||
<section id="layouts-section" hidden>
|
||||
<h1 class="admin-h1">Named layouts</h1>
|
||||
<p class="admin-copy">Layouts saved to the server from the dashboard's Layout menu. Loading one
|
||||
copies it into that browser; deleting one here does not touch any browser's current layout.</p>
|
||||
<div id="layouts-list"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="toast" hidden></div>
|
||||
|
||||
<script type="module" src="/admin/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,61 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>ProdDash</title>
|
||||
<meta name="theme-color" content="#0b0e12">
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<link rel="icon" href="/icons/icon-192.png">
|
||||
<link rel="apple-touch-icon" href="/icons/icon-180.png">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="ProdDash">
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header id="topbar">
|
||||
<span id="brand">Prod<span class="accent">Dash</span></span>
|
||||
|
||||
<span class="spacer"></span>
|
||||
|
||||
<div class="dropdown" id="add-dd">
|
||||
<button class="tb-btn icon" id="add-btn" title="Add a module tile" aria-label="Add tile">+</button>
|
||||
<div class="menu" id="add-menu" hidden></div>
|
||||
</div>
|
||||
|
||||
<div class="dropdown" id="layout-dd">
|
||||
<button class="tb-btn icon" id="layout-btn" title="Save, load or reset layouts" aria-label="Layouts">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<rect x="3" y="3" width="8" height="8" rx="1.5"/>
|
||||
<rect x="3" y="13" width="8" height="8" rx="1.5"/>
|
||||
<rect x="13" y="3" width="8" height="18" rx="1.5"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="menu" id="layout-menu" hidden></div>
|
||||
</div>
|
||||
|
||||
<a class="tb-btn" href="/admin/" title="Configure modules (server-wide)">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm8.4-2.1.1-1.4-.1-1.4 1.7-1.3-1.6-2.8-2 .7a7.9 7.9 0 0 0-2.4-1.4l-.3-2.1H11l-.3 2.1a7.9 7.9 0 0 0-2.4 1.4l-2-.7L4.7 9.3 6.4 10.6l-.1 1.4.1 1.4-1.7 1.3 1.6 2.8 2-.7a7.9 7.9 0 0 0 2.4 1.4l.3 2.1h2.8l.3-2.1a7.9 7.9 0 0 0 2.4-1.4l2 .7 1.6-2.8-1.7-1.3Z"/>
|
||||
</svg>
|
||||
Admin
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<main id="gridwrap">
|
||||
<div id="grid">
|
||||
<div id="empty-state">No tiles yet — use “+ Add tile” to build this dashboard.<br>
|
||||
Every browser keeps its own layout.</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div id="modal-backdrop" hidden>
|
||||
<div id="modal" role="dialog" aria-modal="true"></div>
|
||||
</div>
|
||||
|
||||
<div id="toast" hidden></div>
|
||||
|
||||
<script type="module" src="/shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
+1056
File diff suppressed because it is too large
Load Diff
@@ -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; }
|
||||
}
|
||||
@@ -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 }))
|
||||
);
|
||||
});
|
||||
@@ -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/<id>/, 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: { "<id>": { enabled: bool, config: {…} } } */
|
||||
let modulesConfig = readJson(MODULES_CONFIG_PATH, {});
|
||||
|
||||
/* ── module discovery ───────────────────────────────────────────────── */
|
||||
|
||||
/** @type {Map<string, object>} 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/<id>/. 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/<id>/… (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/<id>/… */
|
||||
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();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
rem Double-click to start ProdDash (Windows)
|
||||
cd /d %~dp0
|
||||
node server.js
|
||||
pause
|
||||
@@ -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:<port>. 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)`);
|
||||
});
|
||||
Reference in New Issue
Block a user