Merge pull request #16 from michaelmcgary2008/claude/slack-module
Slack module: channel transcripts, PIN-gated sending as the location
This commit is contained in:
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "start",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": [
|
||||||
|
"run",
|
||||||
|
"start"
|
||||||
|
],
|
||||||
|
"port": 3000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ title-bar controls). The rules that matter most:
|
|||||||
- Verify against the guide's testing section and resilience checklist. Mocks
|
- Verify against the guide's testing section and resilience checklist. Mocks
|
||||||
for the existing upstreams: `node tools/prodcom-mock.js` (ProdCom) and
|
for the existing upstreams: `node tools/prodcom-mock.js` (ProdCom) and
|
||||||
`node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter).
|
`node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter).
|
||||||
|
`node tools/slack-mock.js 24716` (Slack; run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`).
|
||||||
|
|
||||||
## Reference code (read-only forks — don't develop these here)
|
## Reference code (read-only forks — don't develop these here)
|
||||||
|
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ touching the shell (see [docs/MODULE-GUIDE.md](docs/MODULE-GUIDE.md)).
|
|||||||
2. Discovers modules in `modules/` and serves each one's client at
|
2. Discovers modules in `modules/` and serves each one's client at
|
||||||
`/modules/<id>/`.
|
`/modules/<id>/`.
|
||||||
3. Mounts each enabled module's server routes under `/api/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
|
that's where module servers follow ProdCom, poll ProPresenter, and stream
|
||||||
live updates over SSE, so browsers never talk to production gear
|
live updates over SSE — one upstream connection per module, however many
|
||||||
directly (no CORS, and API keys never leave the server).
|
dashboards are open — 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 in a per-machine data
|
4. Stores server-wide module config and named layouts in a per-machine data
|
||||||
directory outside the app folder, so updating ProdDash never loses them.
|
directory outside the app folder, so updating ProdDash never loses them.
|
||||||
|
|
||||||
@@ -99,6 +100,7 @@ with this machine's shell settings — the same keys as the checked-in
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `port` | Port ProdDash serves on (default `24500`). The macOS launcher writes this one, so the port it is set to holds however ProdDash is started. |
|
| `port` | Port ProdDash serves on (default `24500`). The macOS launcher writes this one, so the port it is set to holds however ProdDash is started. |
|
||||||
| `adminPasscode` | If set, the admin page asks for this shared passcode. Leave `""` for none — it's a plain-HTTP LAN tool. |
|
| `adminPasscode` | If set, the admin page asks for this shared passcode. Leave `""` for none — it's a plain-HTTP LAN tool. |
|
||||||
|
| `theme` | The dashboard palette: `booth` (default), `harbor`, `graphite`, `ember` or `daylight`. The admin page's **Theme** section writes this one — see [Themes](#themes). |
|
||||||
|
|
||||||
Environment variables override both files: `PORT`, `PRODDASH_PASSCODE`, and
|
Environment variables override both files: `PORT`, `PRODDASH_PASSCODE`, and
|
||||||
`PRODDASH_DATA_DIR` to put the data directory somewhere else (a relative path
|
`PRODDASH_DATA_DIR` to put the data directory somewhere else (a relative path
|
||||||
@@ -111,6 +113,22 @@ so and falls back to the app's `config/` folder.
|
|||||||
directory, so nothing has to be re-entered. The old files are left in place and
|
directory, so nothing has to be re-entered. The old files are left in place and
|
||||||
ignored from then on.
|
ignored from then on.
|
||||||
|
|
||||||
|
### Themes
|
||||||
|
|
||||||
|
The whole dashboard — every tile, on every browser — draws with one palette,
|
||||||
|
chosen in the admin page's **Theme** section and switched live: pick a swatch
|
||||||
|
and open dashboards change without a reload. Five presets ship: **Booth**
|
||||||
|
(the default dark, mint accent), **Harbor** (deep navy, cyan), **Graphite**
|
||||||
|
(neutral charcoal, electric blue), **Ember** (warm broadcast black, orange)
|
||||||
|
and **Daylight**, a light palette for a front-of-house desk in daytime. Each
|
||||||
|
keeps the status colours apart — the accent is never near the danger red or
|
||||||
|
the warning amber — and muted text readable in a dark booth. The choice is
|
||||||
|
stored as `theme` in the data directory's `proddash.json` (table above), so
|
||||||
|
it holds across restarts and updates; a browser remembers the last theme it
|
||||||
|
saw and applies it before the server answers, so nothing flashes. Modules
|
||||||
|
style with the shell's colour variables, which is why they follow; a colour
|
||||||
|
set on a tile itself (a PCO Plan header colour, say) is deliberately kept.
|
||||||
|
|
||||||
## Installing modules and updating
|
## Installing modules and updating
|
||||||
|
|
||||||
Everything below happens in the **admin page**; ProdDash talks to its GitHub
|
Everything below happens in the **admin page**; ProdDash talks to its GitHub
|
||||||
|
|||||||
+174
-26
@@ -92,6 +92,7 @@ the app); its settings are kept for a later reinstall.
|
|||||||
| `proddash` | yes | The ProdDash versions this module works with: `">=1.1.0"`, `"^1.1.0"`, `">=1.1.0 <2.0.0"`, or `"*"`. A module whose requirement the running shell doesn't meet is listed in the admin page but never loaded, and can't be installed from the catalog. |
|
| `proddash` | yes | The ProdDash versions this module works with: `">=1.1.0"`, `"^1.1.0"`, `">=1.1.0 <2.0.0"`, or `"*"`. A module whose requirement the running shell doesn't meet is listed in the admin page but never loaded, and can't be installed from the catalog. |
|
||||||
| `description` | no | Shown in the admin page / picker. |
|
| `description` | no | Shown in the admin page / picker. |
|
||||||
| `permissions` | no | What macOS must allow before this module can work — see **Permissions macOS has to grant**. Ignored by the shell; read by the macOS launcher. |
|
| `permissions` | no | What macOS must allow before this module can work — see **Permissions macOS has to grant**. Ignored by the shell; read by the macOS launcher. |
|
||||||
|
| `provides` | no | Capabilities other modules can consume — `["timers"]` today. Forwarded in `/api/modules` so a consumer can find you. See **Working with other modules**. |
|
||||||
| `client` | yes | Client entry file, loaded as an ES module. |
|
| `client` | yes | Client entry file, loaded as an ES module. |
|
||||||
| `server` | no | Server entry file (CommonJS), loaded with `require()`. |
|
| `server` | no | Server entry file (CommonJS), loaded with `require()`. |
|
||||||
| `style` | no | A stylesheet the shell injects once per module. |
|
| `style` | no | A stylesheet the shell injects once per module. |
|
||||||
@@ -103,7 +104,7 @@ the app); its settings are kept for a later reinstall.
|
|||||||
Both schemas map field names to specs:
|
Both schemas map field names to specs:
|
||||||
|
|
||||||
```
|
```
|
||||||
{ "type": "string" | "number" | "boolean" | "switch" | "select" | "color" | "password" | "endpoint",
|
{ "type": "string" | "text" | "number" | "boolean" | "switch" | "select" | "multiselect" | "template" | "color" | "password" | "endpoint",
|
||||||
"label": "Shown next to the field",
|
"label": "Shown next to the field",
|
||||||
"default": <value>,
|
"default": <value>,
|
||||||
"group": "Section heading", // optional; see below
|
"group": "Section heading", // optional; see below
|
||||||
@@ -115,10 +116,33 @@ Both schemas map field names to specs:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`boolean` renders as a checkbox; `switch` is the same true/false value shown
|
`boolean` and `switch` are the same true/false value. In a tile's gear popover
|
||||||
as a toggle (use it for a feature on/off that other fields depend on).
|
both render as a switch; in the admin page `switch` is the toggle and
|
||||||
|
`boolean` the plainer checkbox (use `switch` for a feature on/off that other
|
||||||
|
fields depend on). **Label a switch with the noun of what it turns on** —
|
||||||
|
"Seconds", "Top bar", "Section headers" — never "Show seconds": on means
|
||||||
|
shown or enabled, off means hidden or disabled, and the label shouldn't say
|
||||||
|
so twice.
|
||||||
`color` renders a swatch picker and stores a `#rrggbb` string (give it a hex
|
`color` renders a swatch picker and stores a `#rrggbb` string (give it a hex
|
||||||
default; the tile applies it as a CSS custom property — see `pco-plan`).
|
default; the tile applies it as a CSS custom property — see `pco-plan`).
|
||||||
|
`multiselect` is several picks from one list — the same `options` /
|
||||||
|
`optionsRoute` as `select`, rendered as one switch per option and stored as
|
||||||
|
an array of values. A saved value the list no longer offers stays checked and
|
||||||
|
marked, so nothing is dropped behind anyone's back. In a tile's gear popover
|
||||||
|
it also takes `columns` (2–4) and `allByDefault: true`: a tile that has never
|
||||||
|
chosen shows every option on and saves nothing until someone flips one, so
|
||||||
|
options that turn up later are on as well (give it no `default` then).
|
||||||
|
`select` and `multiselect` may use `optionsRoute` in `instanceSchema` too —
|
||||||
|
the popover GETs `/api/modules/<id><optionsRoute>` like the admin page does.
|
||||||
|
|
||||||
|
`template` (tile settings) is a text with placeholders, built by dragging or
|
||||||
|
clicking the variables the module offers into it: `"variables": [ { "value":
|
||||||
|
"{series}", "label": "Series" }, … ]`. Keep `help` to the joining rules; the
|
||||||
|
chips carry the names.
|
||||||
|
|
||||||
|
`text` is a multi-line string — a `<textarea>` in the admin page, stored with
|
||||||
|
its newlines; one item per line is the convention (the `slack` module's quick
|
||||||
|
replies). Admin config only.
|
||||||
|
|
||||||
`group` gathers consecutive fields under a labeled subsection in the admin
|
`group` gathers consecutive fields under a labeled subsection in the admin
|
||||||
page, so a module with several distinct concerns (e.g. a ProPresenter
|
page, so a module with several distinct concerns (e.g. a ProPresenter
|
||||||
@@ -141,7 +165,15 @@ the group name:
|
|||||||
still empty. Omit it and the group is a plain, always-open block.
|
still empty. Omit it and the group is a plain, always-open block.
|
||||||
- `columns`: 2–4 lays the group's fields out in a grid — the way to keep a
|
- `columns`: 2–4 lays the group's fields out in a grid — the way to keep a
|
||||||
long checklist of booleans compact.
|
long checklist of booleans compact.
|
||||||
- `help`: a line of guidance at the top of the group.
|
- `help`: guidance for the whole group. The admin page shows it as an
|
||||||
|
ⓘ beside the group title (click or hover to read), so a group can carry a
|
||||||
|
paragraph without pushing its fields down the page. Say where a value comes
|
||||||
|
from, not what a field is.
|
||||||
|
- `toggle`: the key of a `switch` field in this group. It renders in the
|
||||||
|
group's title row, at the right, as the group's on/off; the rest of the group
|
||||||
|
dims while it is off, and the field is not repeated in the body. This is how
|
||||||
|
an integration is disabled without unmounting the module — ProPresenter
|
||||||
|
timers, say, on a Sunday when ProPresenter isn't running.
|
||||||
|
|
||||||
`help` on a field puts one line of guidance under its control — allowed
|
`help` on a field puts one line of guidance under its control — allowed
|
||||||
placeholders, where a value comes from. Keep labels short and put the
|
placeholders, where a value comes from. Keep labels short and put the
|
||||||
@@ -149,13 +181,27 @@ explanation there.
|
|||||||
|
|
||||||
`group` and `help` work on `instanceSchema` fields too, and a manifest
|
`group` and `help` work on `instanceSchema` fields too, and a manifest
|
||||||
`instanceGroups` map plays the `configGroups` role for the tile's gear
|
`instanceGroups` map plays the `configGroups` role for the tile's gear
|
||||||
popover (`collapsed: true | false`, `columns`, `help`). That is how a module
|
popover (`collapsed: true | false`, `columns`, `help`, `toggle`). That is how
|
||||||
with a long show/hide checklist keeps the popover usable — see `pco-plan`.
|
a module with a long show/hide checklist keeps the popover usable — see
|
||||||
Display choices belong there, per tile; admin config is for connections.
|
`pco-plan`. Display choices belong there, per tile; admin config is for
|
||||||
|
connections. The popover itself is a small window beside the tile — sized to
|
||||||
|
its content, moved by the slim bar on top, closed by clicking anywhere else
|
||||||
|
or pressing Escape — so a tile never has to be big enough to hold its own
|
||||||
|
settings, and a change can be watched as it lands. Under everything you
|
||||||
|
offer, the shell adds **Tile tint** — a colour the operator can wash that
|
||||||
|
tile's chrome in, saved with the layout. It is not yours to declare and it
|
||||||
|
never remounts you; don't offer one of your own.
|
||||||
|
|
||||||
|
A picker entry (see **Presenting multiple tiles**) may carry its own
|
||||||
|
`instanceSchema` and `instanceGroups`: tiles added from that entry get those
|
||||||
|
instead of the module's. A tile that shows one ProPresenter timer has no use
|
||||||
|
for a clock's format switches — give it a schema of what applies.
|
||||||
|
|
||||||
`showWhen: "<key>"` hides a field until the boolean/`switch` field named
|
`showWhen: "<key>"` hides a field until the boolean/`switch` field named
|
||||||
`<key>` is on — e.g. the audio device and channel appear only once the LTC
|
`<key>` is on — e.g. the audio device and channel appear only once the LTC
|
||||||
listener switch is enabled. The hidden field still keeps its stored value.
|
listener switch is enabled. The hidden field still keeps its stored value.
|
||||||
|
It works the same in a tile's gear (`instanceSchema`): "Short day" and
|
||||||
|
"Short month" appear once the clock's "Date" is on.
|
||||||
|
|
||||||
A `select` whose choices are only knowable at runtime (audio devices, serial
|
A `select` whose choices are only knowable at runtime (audio devices, serial
|
||||||
ports, discovered sources) sets `optionsRoute` instead of — or as a fallback
|
ports, discovered sources) sets `optionsRoute` instead of — or as a fallback
|
||||||
@@ -272,7 +318,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
| `instanceId` | Unique per tile — two tiles of the same module get different ids. Handy for keying anything per-tile. |
|
| `instanceId` | Unique per tile — two tiles of the same module get different ids. Handy for keying anything per-tile. |
|
||||||
| `variant` | For multi-tile modules: the id of the tile-list entry this tile was added as, `''` otherwise (see **Presenting multiple tiles**). |
|
| `variant` | For multi-tile modules: the id of the tile-list entry this tile was added as, `''` otherwise (see **Presenting multiple tiles**). |
|
||||||
| `config` | Admin (server-wide) config, read-only, always current. Password fields are absent — they never reach the client. |
|
| `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. |
|
| `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. Keys beginning `__` are the shell's own (`__tint`, the tile's colour) — never use that prefix, and ignore the ones you find. |
|
||||||
| `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). |
|
| `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`. |
|
| `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()`. |
|
| `sse(path, handlers)` | An `EventSource` scoped the same way, **with auto-reconnect** (see below). Returns a handle with `close()`. |
|
||||||
@@ -287,16 +333,17 @@ bar, next to the shell's gear/close buttons:
|
|||||||
|
|
||||||
```js
|
```js
|
||||||
const btn = moduleApi.header.addButton({
|
const btn = moduleApi.header.addButton({
|
||||||
icon: '<svg …>…</svg>', // inline SVG string, and/or
|
icon: '<svg …>…</svg>', // inline SVG string (13 px), and/or
|
||||||
label: 'A+', // short text
|
label: 'Timers ▾', // short text
|
||||||
title: 'Larger text', // hover tooltip
|
title: 'Larger text', // tooltip — one or two words; the shell shows
|
||||||
|
// it the moment the pointer arrives
|
||||||
onClick() { … },
|
onClick() { … },
|
||||||
});
|
});
|
||||||
btn.classList.add('active'); // toggle styling for on/off buttons
|
btn.classList.add('active'); // toggle styling for on/off buttons
|
||||||
|
|
||||||
const menu = moduleApi.header.addMenu({
|
const menu = moduleApi.header.addMenu({
|
||||||
label: 'Channels ▾',
|
label: 'Channels ▾',
|
||||||
title: 'Choose visible channels',
|
title: 'Channels',
|
||||||
build(menuEl) {
|
build(menuEl) {
|
||||||
// called on every open with an emptied menu element — fill it with
|
// called on every open with an emptied menu element — fill it with
|
||||||
// buttons (give them your scoped classes; the container is styled and
|
// buttons (give them your scoped classes; the container is styled and
|
||||||
@@ -308,7 +355,17 @@ menu.setLabel('Channels (2/4) ▾'); // update the button text any time
|
|||||||
|
|
||||||
Everything you add is removed automatically when your instance stops, so a
|
Everything you add is removed automatically when your instance stops, so a
|
||||||
remount never duplicates controls. Keep them compact (icon buttons, short
|
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.
|
labels): the title bar is 32 px tall and shared with the tile's name. Draw
|
||||||
|
icons as inline SVG rather than characters — a glyph such as ✕ or + takes
|
||||||
|
its size from whatever font each browser picks; text-size buttons are a
|
||||||
|
drawn − and +.
|
||||||
|
|
||||||
|
**One control per action.** Whatever has a dedicated header button is not
|
||||||
|
offered again in the gear popover: text size is A− / A+ up here (persisted
|
||||||
|
through `saveInstanceSettings({ textSize })`, read back with a sane
|
||||||
|
fallback), never a "Text size" field in `instanceSchema`; a timestamps or
|
||||||
|
newest-first toggle in the header means no such switch in the gear. The
|
||||||
|
gear is for display choices that have no button of their own.
|
||||||
|
|
||||||
One more thing to know: the user can hide the whole title bar with the
|
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
|
small notch on its bottom edge (a clean-view mode for wall displays). Your
|
||||||
@@ -327,14 +384,20 @@ const stream = moduleApi.sse('/stream', {
|
|||||||
// later: stream.close()
|
// later: stream.close()
|
||||||
```
|
```
|
||||||
|
|
||||||
Browsers' `EventSource` retries transient drops itself but gives up for good
|
Your route serves a plain SSE stream; how it reaches the page is the
|
||||||
when a retry gets a completed non-SSE response — which is what a proxy's 502
|
shell's business. A dashboard holds **one** connection for every stream on
|
||||||
looks like while the upstream is down. The shell's `sse()` recreates closed
|
it (`GET /api/stream`, which the server feeds from your route over
|
||||||
streams on a 3-second timer until they work again, so **reconnect logic is
|
loopback) — browsers allow about six connections to a host and a tile per
|
||||||
free**: handle `open` (refresh/backfill your state; you may have missed
|
stream ran out of them — so `open` also fires whenever that shared
|
||||||
events) and `error` (report degraded status), and you're resilient. Streams
|
connection is remade (a tile was added or removed) and whenever your route
|
||||||
you forget to close are force-closed when the tile unmounts — but close your
|
comes back after a drop. Browsers' `EventSource` retries transient drops
|
||||||
own in `stop()` anyway.
|
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 recreates a closed connection on a 3-second timer until it
|
||||||
|
works 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
|
### Lifecycle you must expect
|
||||||
|
|
||||||
@@ -363,9 +426,15 @@ own in `stop()` anyway.
|
|||||||
|
|
||||||
2. **Style with the shell's CSS variables — no hardcoded colors.** The
|
2. **Style with the shell's CSS variables — no hardcoded colors.** The
|
||||||
palette: `--bg` (page), `--panel` (tile), `--border`, `--text`,
|
palette: `--bg` (page), `--panel` (tile), `--border`, `--text`,
|
||||||
`--muted`, `--accent`, `--danger`, `--warn`. Derive tints with
|
`--muted`, `--accent`, `--accent-text` (text that sits on the accent —
|
||||||
|
dark on most themes, white where the accent is deep), `--danger`,
|
||||||
|
`--warn`. Derive tints with
|
||||||
`color-mix(in srgb, var(--accent) 20%, transparent)`. This is what makes
|
`color-mix(in srgb, var(--accent) 20%, transparent)`. This is what makes
|
||||||
every module look native on the dark, high-contrast booth theme.
|
every module look native — and what makes the dashboard's **themes**
|
||||||
|
(Admin → Theme) reach into every tile: a theme swaps those variables and
|
||||||
|
nothing else. A colour a tile is configured with (a `color` instance
|
||||||
|
setting, applied as a custom property on the tile) is set closer than the
|
||||||
|
theme and wins over it, by design.
|
||||||
|
|
||||||
3. **No external network calls from the client.** Browsers talk only to
|
3. **No external network calls from the client.** Browsers talk only to
|
||||||
ProdDash. Anything upstream (your device, service, API) goes through your
|
ProdDash. Anything upstream (your device, service, API) goes through your
|
||||||
@@ -385,7 +454,7 @@ ethos; Node 18+ so global `fetch` is available). Exports either or both of:
|
|||||||
|
|
||||||
```js
|
```js
|
||||||
module.exports = {
|
module.exports = {
|
||||||
init({ config, log }) {
|
init({ config, log, shell }) {
|
||||||
// Called on startup / re-init, BEFORE routes(). Start your upstream
|
// Called on startup / re-init, BEFORE routes(). Start your upstream
|
||||||
// connection here. Return a handle:
|
// connection here. Return a handle:
|
||||||
return {
|
return {
|
||||||
@@ -394,7 +463,7 @@ module.exports = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
routes({ config, log }) {
|
routes({ config, log, shell }) {
|
||||||
// Route table, mounted under /api/modules/<id>/
|
// Route table, mounted under /api/modules/<id>/
|
||||||
return {
|
return {
|
||||||
'GET /state': (req, res) => { … },
|
'GET /state': (req, res) => { … },
|
||||||
@@ -410,6 +479,9 @@ module.exports = {
|
|||||||
- `config` is your admin config: `configSchema` defaults overlaid with what
|
- `config` is your admin config: `configSchema` defaults overlaid with what
|
||||||
the admin page saved — **including password fields**.
|
the admin page saved — **including password fields**.
|
||||||
- `log(...)` prefixes output with your module id.
|
- `log(...)` prefixes output with your module id.
|
||||||
|
- `shell.port` is the port ProdDash is serving on — what you need to read
|
||||||
|
another module's routes over loopback (`http://127.0.0.1:${shell.port}/api/
|
||||||
|
modules/<id>/…`). Never guess it.
|
||||||
- Handlers get plain Node `(req, res)`. On a prefix route, `req.wildcard` is
|
- Handlers get plain Node `(req, res)`. On a prefix route, `req.wildcard` is
|
||||||
the matched remainder (leading `/` included) and `req.search` is the query
|
the matched remainder (leading `/` included) and `req.search` is the query
|
||||||
string (with `?`, or `''`) — so a proxy rebuilds the upstream path as
|
string (with `?`, or `''`) — so a proxy rebuilds the upstream path as
|
||||||
@@ -446,7 +518,7 @@ module.exports = {
|
|||||||
return config.pages.map((page) => ({
|
return config.pages.map((page) => ({
|
||||||
id: page.name, // stable id, stored with the tile
|
id: page.name, // stable id, stored with the tile
|
||||||
name: page.name, // picker label AND the tile's title
|
name: page.name, // picker label AND the tile's title
|
||||||
description: page.url, // optional line under the label
|
description: page.url, // optional; the picker's tooltip
|
||||||
settings: { page: page.name }, // preset instance settings
|
settings: { page: page.name }, // preset instance settings
|
||||||
defaultSize: { w: 4, h: 3 }, // optional per-entry size overrides
|
defaultSize: { w: 4, h: 3 }, // optional per-entry size overrides
|
||||||
minSize: { w: 2, h: 2 },
|
minSize: { w: 2, h: 2 },
|
||||||
@@ -459,6 +531,17 @@ module.exports = {
|
|||||||
keep it fast (answer from state you already hold; don't fetch upstream on
|
keep it fast (answer from state you already hold; don't fetch upstream on
|
||||||
demand). A throw or a hang (>2 s) falls back to the classic single entry,
|
demand). A throw or a hang (>2 s) falls back to the classic single entry,
|
||||||
so a dead upstream can't break the picker.
|
so a dead upstream can't break the picker.
|
||||||
|
- `instanceSchema` / `instanceGroups` on an entry replace the module's for
|
||||||
|
tiles added from it — settings that fit that tile, nothing else.
|
||||||
|
- `aliases: ['old-id', …]` on an entry names earlier variant ids it stands
|
||||||
|
for: a tile added under one of them (a layout saved by an older version of
|
||||||
|
your module) gets this entry's settings too. A tile whose variant matches
|
||||||
|
no entry and no alias falls back to the module's full `instanceSchema`.
|
||||||
|
- Entry `description`s are tooltips in the + picker now, not visible lines —
|
||||||
|
one short sentence.
|
||||||
|
- `single: true` on an entry allows at most one such tile per dashboard: the
|
||||||
|
picker greys the entry out once it is placed. Use it for a control surface
|
||||||
|
that would only confuse in duplicate — a send box, a master switch.
|
||||||
- When the user adds an entry, its `settings` overlay your `instanceSchema`
|
- When the user adds an entry, its `settings` overlay your `instanceSchema`
|
||||||
defaults in that tile's instance settings, the entry's `name` becomes the
|
defaults in that tile's instance settings, the entry's `name` becomes the
|
||||||
tile's title, and `moduleApi.variant` carries its `id`.
|
tile's title, and `moduleApi.variant` carries its `id`.
|
||||||
@@ -496,6 +579,71 @@ per-client `res.write` in try/catch (drop the client on failure). The
|
|||||||
`propresenter-now-next` module is the reference implementation of this
|
`propresenter-now-next` module is the reference implementation of this
|
||||||
pattern.
|
pattern.
|
||||||
|
|
||||||
|
## Working with other modules
|
||||||
|
|
||||||
|
Modules don't import each other. When one needs what another knows, it reads
|
||||||
|
that module's **own server routes over loopback**, exactly as a browser would:
|
||||||
|
`http://127.0.0.1:${shell.port}/api/modules/<id>/…`. The PCO Plan module
|
||||||
|
follows ProPresenter this way — it reads `propresenter-now-next`'s `/stream`
|
||||||
|
rather than talking to ProPresenter a second time. A consumer must survive the
|
||||||
|
provider being disabled, uninstalled, or restarting mid-service (404s and
|
||||||
|
dropped connections are normal; retry with backoff and say so in `health()`).
|
||||||
|
|
||||||
|
### Offering timers to the Timers module
|
||||||
|
|
||||||
|
The Timers module shows timers from anywhere: ProPresenter, LTC, its own
|
||||||
|
clock, and any module that offers some. A module offers timers by:
|
||||||
|
|
||||||
|
1. Declaring it — `"provides": ["timers"]` in `module.json`. The shell
|
||||||
|
forwards `provides` in `/api/modules`, which is how the Timers module finds
|
||||||
|
providers without a list anyone maintains.
|
||||||
|
2. Serving `GET /timers` from its server routes, returning a snapshot:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"source": "pco-plan",
|
||||||
|
"label": "PCO Plan",
|
||||||
|
"updatedAt": 1757800000000,
|
||||||
|
"timers": [
|
||||||
|
{
|
||||||
|
"id": "service",
|
||||||
|
"label": "Service",
|
||||||
|
"kind": "elapsed",
|
||||||
|
"state": "running",
|
||||||
|
"startedAt": 1757799000000,
|
||||||
|
"elapsedMs": 1000000,
|
||||||
|
"remainingMs": null,
|
||||||
|
"targetMs": 4500000,
|
||||||
|
"status": { "text": "Welcome & Announcements", "tone": "ok" },
|
||||||
|
"detail": "Item 3 of 12"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `source`, `label` | Your module id, and the heading the Timers module puts over your timers. |
|
||||||
|
| `updatedAt` | ms since epoch when this snapshot was taken. Every duration below is *as of this instant*. |
|
||||||
|
| `id` | Stable within your module — the Timers module keys tile settings (shown/hidden, colours) on `source` + `id`. |
|
||||||
|
| `kind` | `elapsed` (counts up), `countdown` (counts down to zero), `clock` (a time of day; put the ISO time in `status.text`). |
|
||||||
|
| `state` | `running`, `paused`, `stopped` (finished; value frozen), `idle` (exists, hasn't started — a timer that will start at 11:00). |
|
||||||
|
| `startedAt` | ms epoch when it last started running; the consumer extrapolates from here, so a running timer never needs a fast poll. |
|
||||||
|
| `elapsedMs`, `remainingMs` | The values at `updatedAt`; `null` when not meaningful for the kind. |
|
||||||
|
| `targetMs` | Planned length (elapsed) or full length (countdown), or `null`. Lets the consumer colour overruns. |
|
||||||
|
| `status` | Optional one-liner the consumer shows beside the time — the current item, "ahead 1:20". `tone`: `ok`, `warn`, `danger`, `muted`. |
|
||||||
|
| `detail` | Optional second line. |
|
||||||
|
|
||||||
|
The consumer derives the displayed time locally from `startedAt`/`elapsedMs`
|
||||||
|
and its own clock, so a snapshot every couple of seconds is plenty — `/timers`
|
||||||
|
must be cheap to answer (serve from state you already hold; never call
|
||||||
|
upstream to answer it). Optionally also serve `GET /timers/stream` as SSE
|
||||||
|
(`event: timers`, `data:` the same object, on every change plus a heartbeat
|
||||||
|
comment every 15 s); the consumer uses the stream when it exists and polls
|
||||||
|
`/timers` otherwise. Only offer timers a production tech would want on a
|
||||||
|
wall — the Timers module lets them hide any of them per tile, but every entry
|
||||||
|
you add is a decision someone has to make.
|
||||||
|
|
||||||
## Complete minimal example
|
## Complete minimal example
|
||||||
|
|
||||||
A working clock — copy-paste this as your starter. (A slightly fancier
|
A working clock — copy-paste this as your starter. (A slightly fancier
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
{
|
{
|
||||||
"id": "clock",
|
"id": "clock",
|
||||||
"name": "Clock",
|
"name": "Clock",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"proddash": ">=1.1.0",
|
"proddash": ">=1.1.0",
|
||||||
"description": "Big booth clock — the smallest possible ProdDash module",
|
|
||||||
"client": "client.js",
|
"client": "client.js",
|
||||||
"style": "style.css",
|
"style": "style.css",
|
||||||
"minSize": { "w": 2, "h": 1 },
|
"minSize": { "w": 2, "h": 1 },
|
||||||
@@ -18,7 +17,7 @@
|
|||||||
{ "value": "12h", "label": "12-hour" }
|
{ "value": "12h", "label": "12-hour" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"showSeconds": { "type": "boolean", "label": "Show seconds", "default": true },
|
"showSeconds": { "type": "boolean", "label": "Seconds", "default": true },
|
||||||
"showDate": { "type": "boolean", "label": "Show date", "default": false }
|
"showDate": { "type": "boolean", "label": "Date", "default": false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+58
-14
@@ -4,10 +4,16 @@
|
|||||||
ProPresenter module's live item; this tile only renders the plan it is
|
ProPresenter module's live item; this tile only renders the plan it is
|
||||||
streamed. What is shown is this tile's own choice (gear menu →
|
streamed. What is shown is this tile's own choice (gear menu →
|
||||||
moduleApi.instanceSettings: show/hide checklist, top-bar template,
|
moduleApi.instanceSettings: show/hide checklist, top-bar template,
|
||||||
colors, text size); /admin only holds the connection settings. */
|
colors, item-note categories); the text size is the title bar's A− / A+
|
||||||
|
(saved with the tile, 14 px until someone presses one); /admin only holds
|
||||||
|
the connection settings. */
|
||||||
|
|
||||||
const REFRESH_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>';
|
const REFRESH_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>';
|
||||||
|
|
||||||
|
/* the text-size buttons: a drawn − and + at the icon size, like every other header icon */
|
||||||
|
const MINUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14"/></svg>';
|
||||||
|
const PLUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>';
|
||||||
|
|
||||||
export default function create({ root, moduleApi }) {
|
export default function create({ root, moduleApi }) {
|
||||||
let state = null;
|
let state = null;
|
||||||
let skew = 0; // server clock − local clock, ms
|
let skew = 0; // server clock − local clock, ms
|
||||||
@@ -53,7 +59,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
|
|
||||||
const refreshBtn = moduleApi.header.addButton({
|
const refreshBtn = moduleApi.header.addButton({
|
||||||
icon: REFRESH_SVG,
|
icon: REFRESH_SVG,
|
||||||
title: 'Re-read the plan from Planning Center now',
|
title: 'Refresh',
|
||||||
onClick() {
|
onClick() {
|
||||||
refreshBtn.classList.add('active');
|
refreshBtn.classList.add('active');
|
||||||
moduleApi.fetch('/refresh', { method: 'POST' })
|
moduleApi.fetch('/refresh', { method: 'POST' })
|
||||||
@@ -67,8 +73,8 @@ export default function create({ root, moduleApi }) {
|
|||||||
moduleApi.saveInstanceSettings({ textSize: Math.min(40, Math.max(9, size + delta)) });
|
moduleApi.saveInstanceSettings({ textSize: Math.min(40, Math.max(9, size + delta)) });
|
||||||
applyPrefs();
|
applyPrefs();
|
||||||
}
|
}
|
||||||
moduleApi.header.addButton({ label: 'A−', title: 'Smaller text', onClick: () => bumpTextSize(-1) });
|
moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-1) });
|
||||||
moduleApi.header.addButton({ label: 'A+', title: 'Larger text', onClick: () => bumpTextSize(1) });
|
moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(1) });
|
||||||
|
|
||||||
/* ── formatting ─────────────────────────────────────────────────── */
|
/* ── formatting ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -87,6 +93,16 @@ export default function create({ root, moduleApi }) {
|
|||||||
return `${sign}${m}:${String(sec).padStart(2, '0')}`;
|
return `${sign}${m}:${String(sec).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "Starts in": under a day like fmtDur; a day or more reads "2 Days, 05:12:44". */
|
||||||
|
function fmtStartsIn(totalSec) {
|
||||||
|
const s = Math.max(0, Math.round(totalSec));
|
||||||
|
const days = Math.floor(s / 86400);
|
||||||
|
if (!days) return fmtDur(s);
|
||||||
|
const rest = s % 86400;
|
||||||
|
const pad = (v) => String(v).padStart(2, '0');
|
||||||
|
return `${days} ${days === 1 ? 'Day' : 'Days'}, ${pad(Math.floor(rest / 3600))}:${pad(Math.floor((rest % 3600) / 60))}:${pad(rest % 60)}`;
|
||||||
|
}
|
||||||
|
|
||||||
function fmtMinutes(totalSec) {
|
function fmtMinutes(totalSec) {
|
||||||
const m = Math.round(totalSec / 60);
|
const m = Math.round(totalSec / 60);
|
||||||
if (m < 60) return `${m} min`;
|
if (m < 60) return `${m} min`;
|
||||||
@@ -121,10 +137,19 @@ export default function create({ root, moduleApi }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which note categories this tile shows. Unset (never chosen in the gear
|
||||||
|
* window) means every category, including ones Planning Center gains
|
||||||
|
* later; an array is exactly those, by name. A layout saved before the
|
||||||
|
* categories were a list holds a comma-separated string — read as that
|
||||||
|
* list, blank as unset.
|
||||||
|
*/
|
||||||
function noteFilter() {
|
function noteFilter() {
|
||||||
const raw = String(cfg().noteCategories || '');
|
const raw = cfg().noteCategories;
|
||||||
const wanted = raw.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
|
const names = Array.isArray(raw) ? raw : String(raw ?? '').split(',');
|
||||||
return wanted.length ? (note) => wanted.includes(String(note.category || '').toLowerCase()) : () => true;
|
const wanted = new Set(names.map((s) => String(s).trim().toLowerCase()).filter(Boolean));
|
||||||
|
if (!Array.isArray(raw) && !wanted.size) return () => true;
|
||||||
|
return (note) => wanted.has(String(note.category || '').trim().toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── status dot ─────────────────────────────────────────────────── */
|
/* ── status dot ─────────────────────────────────────────────────── */
|
||||||
@@ -209,17 +234,27 @@ export default function create({ root, moduleApi }) {
|
|||||||
renderClock();
|
renderClock();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The header clock follows the service the server says owns the clock
|
||||||
|
* (state.service: idle → running → stopped, by schedule or by the admin's
|
||||||
|
* ProPresenter document cues): a countdown before it, the running time
|
||||||
|
* while it runs (with ahead/behind when following ProPresenter), the final
|
||||||
|
* time once it has ended.
|
||||||
|
*/
|
||||||
function renderClock() {
|
function renderClock() {
|
||||||
const plan = state?.plan;
|
const plan = state?.plan;
|
||||||
const live = state?.live || {};
|
const live = state?.live || {};
|
||||||
|
const svc = state?.service || {};
|
||||||
clockLabelEl.textContent = '';
|
clockLabelEl.textContent = '';
|
||||||
clockValueEl.textContent = '';
|
clockValueEl.textContent = '';
|
||||||
clockValueEl.className = 'pp-clock-value';
|
clockValueEl.className = 'pp-clock-value';
|
||||||
if (!plan || headEl.hidden) return;
|
if (!plan || headEl.hidden) return;
|
||||||
const now = serverNow();
|
const now = serverNow();
|
||||||
if (on('showRunningClock') && live.following && live.serviceStartedAt) {
|
const startedAt = svc.startedAt || live.serviceStartedAt || 0;
|
||||||
|
if (on('showRunningClock') && svc.phase === 'running' && startedAt) {
|
||||||
clockLabelEl.textContent = 'Running';
|
clockLabelEl.textContent = 'Running';
|
||||||
clockValueEl.textContent = fmtDur((now - live.serviceStartedAt) / 1000);
|
clockValueEl.textContent = fmtDur((now - startedAt) / 1000);
|
||||||
|
if (live.following) {
|
||||||
// Ahead of / behind the plan: elapsed minus what the completed items were planned to take.
|
// Ahead of / behind the plan: elapsed minus what the completed items were planned to take.
|
||||||
const done = state.items.filter((it) => live.history?.[it.id]?.endedAt);
|
const done = state.items.filter((it) => live.history?.[it.id]?.endedAt);
|
||||||
const planned = done.reduce((sum, it) => sum + (it.length || 0), 0);
|
const planned = done.reduce((sum, it) => sum + (it.length || 0), 0);
|
||||||
@@ -229,16 +264,25 @@ export default function create({ root, moduleApi }) {
|
|||||||
clockLabelEl.textContent = `Running · ${drift > 0 ? 'behind' : 'ahead'} ${fmtDur(Math.abs(drift))}`;
|
clockLabelEl.textContent = `Running · ${drift > 0 ? 'behind' : 'ahead'} ${fmtDur(Math.abs(drift))}`;
|
||||||
clockValueEl.classList.add(drift > 0 ? 'is-behind' : 'is-ahead');
|
clockValueEl.classList.add(drift > 0 ? 'is-behind' : 'is-ahead');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (on('showCountdown') && plan.serviceStartsAt && plan.serviceStartsAt > now) {
|
if (on('showRunningClock') && svc.phase === 'stopped' && startedAt && svc.endedAt) {
|
||||||
|
clockLabelEl.textContent = 'Ended';
|
||||||
|
clockValueEl.textContent = fmtDur((svc.endedAt - startedAt) / 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const startsAt = svc.startsAt || plan.serviceStartsAt || 0;
|
||||||
|
if (on('showCountdown') && startsAt > now) {
|
||||||
clockLabelEl.textContent = 'Starts in';
|
clockLabelEl.textContent = 'Starts in';
|
||||||
clockValueEl.textContent = fmtDur((plan.serviceStartsAt - now) / 1000);
|
clockValueEl.textContent = fmtStartsIn((startsAt - now) / 1000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (on('showRunningClock') && plan.serviceStartsAt) {
|
if (on('showRunningClock') && startsAt) {
|
||||||
clockLabelEl.textContent = 'Since start';
|
// Past the scheduled start and not started: waiting for the admin's
|
||||||
clockValueEl.textContent = fmtDur((now - plan.serviceStartsAt) / 1000);
|
// start document to go live in ProPresenter (or its failsafe).
|
||||||
|
clockLabelEl.textContent = 'Scheduled';
|
||||||
|
clockValueEl.textContent = fmtClock(startsAt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Title matching for the PCO Plan module — the one answer to "is this
|
||||||
|
* ProPresenter document that Planning Center thing?", used twice:
|
||||||
|
*
|
||||||
|
* - mapping the document ProPresenter has live to a plan item (the
|
||||||
|
* highlight and the item runtimes), and
|
||||||
|
* - recognising the documents an admin picked as the service start / end
|
||||||
|
* cues (see service-timing.js).
|
||||||
|
*
|
||||||
|
* A ProPresenter name and a Planning Center title rarely agree letter for
|
||||||
|
* letter ("This Is The Day - [ Full ]" vs "This Is The Day"), so both sides
|
||||||
|
* are tidied the way propresenter-core.cleanItemName does (tags in brackets,
|
||||||
|
* leading date codes and dash separators dropped), lower-cased, stripped of
|
||||||
|
* punctuation and compared:
|
||||||
|
*
|
||||||
|
* 1.00 identical after tidying "Post-Service Loop" ≈ "Post Service Loop"
|
||||||
|
* 0.85 one contains the other "Welcome" ≈ "Welcome & Announcements"
|
||||||
|
* Dice 2·shared words ÷ all words "Pre Service Loop" vs "Post Service Loop" → 0.67
|
||||||
|
*
|
||||||
|
* Filler words ("the", "of", "and"…) are ignored. The thresholds:
|
||||||
|
*
|
||||||
|
* MATCH_THRESHOLD 0.6 plan items — two of three words is enough, because
|
||||||
|
* the item that scores best *and* is next in the
|
||||||
|
* plan wins, so a near miss lands on the right row.
|
||||||
|
* TRIGGER_THRESHOLD 0.75 service cues — a cue starts or freezes the service
|
||||||
|
* clock, so one wrong word must not fire it: the
|
||||||
|
* pre-service loop (0.67 against "Post Service Loop")
|
||||||
|
* stays a non-match, while a renamed
|
||||||
|
* "Post-Service Loop", a tagged "Post Service Loop
|
||||||
|
* [Full]" (1.0) or a shortened "Post Service" (0.85)
|
||||||
|
* still trigger.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MATCH_THRESHOLD = 0.6;
|
||||||
|
const TRIGGER_THRESHOLD = 0.75;
|
||||||
|
|
||||||
|
/** Same title tidying as propresenter-core.cleanItemName (drops [tags], date codes). */
|
||||||
|
function cleanTitle(raw) {
|
||||||
|
const original = String(raw || '').trim();
|
||||||
|
let s = original;
|
||||||
|
s = s.replace(/[[({][^\])}]*[\])}]/g, ' ');
|
||||||
|
s = s.replace(/^\s*\d{6,8}\b[\s\-–—:.]*/, '');
|
||||||
|
s = s.replace(/\s[-–—]+\s/g, ' ');
|
||||||
|
s = s.replace(/^[\s\-–—:.]+|[\s\-–—:.]+$/g, '');
|
||||||
|
s = s.replace(/\s{2,}/g, ' ').trim();
|
||||||
|
return s || original;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(raw) {
|
||||||
|
return cleanTitle(raw)
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/&/g, ' and ')
|
||||||
|
.replace(/[’'`]/g, '')
|
||||||
|
.replace(/[^a-z0-9]+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const STOPWORDS = new Set(['the', 'a', 'an', 'of', 'and', 'to', 'in', 'is', 'my', 'your', 'our']);
|
||||||
|
function tokens(norm) {
|
||||||
|
return new Set(norm.split(' ').filter((t) => t && !STOPWORDS.has(t)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 0..1 similarity between two titles. */
|
||||||
|
function similarity(a, b) {
|
||||||
|
const na = normalize(a);
|
||||||
|
const nb = normalize(b);
|
||||||
|
if (!na || !nb) return 0;
|
||||||
|
if (na === nb) return 1;
|
||||||
|
if (na.length >= 4 && nb.length >= 4 && (na.includes(nb) || nb.includes(na))) return 0.85;
|
||||||
|
const ta = tokens(na);
|
||||||
|
const tb = tokens(nb);
|
||||||
|
if (!ta.size || !tb.size) return 0;
|
||||||
|
let common = 0;
|
||||||
|
for (const t of ta) if (tb.has(t)) common += 1;
|
||||||
|
return (2 * common) / (ta.size + tb.size); // Dice coefficient
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Does a live ProPresenter document count as the cue document an admin picked? */
|
||||||
|
function isCue(liveName, cueDocument) {
|
||||||
|
return Boolean(cueDocument) && similarity(liveName, cueDocument) >= TRIGGER_THRESHOLD;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { MATCH_THRESHOLD, TRIGGER_THRESHOLD, cleanTitle, normalize, similarity, isCue };
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
{
|
{
|
||||||
"id": "pco-plan",
|
"id": "pco-plan",
|
||||||
"name": "PCO Plan",
|
"name": "PCO Plan",
|
||||||
"version": "1.3.0",
|
"version": "1.4.0",
|
||||||
"proddash": ">=1.3.0",
|
"proddash": ">=1.4.0",
|
||||||
"description": "Order of service from Planning Center Services, following ProPresenter live",
|
"description": "Order of service from Planning Center Services",
|
||||||
|
"provides": ["timers"],
|
||||||
"client": "client.js",
|
"client": "client.js",
|
||||||
"server": "server.js",
|
"server": "server.js",
|
||||||
"style": "style.css",
|
"style": "style.css",
|
||||||
@@ -12,13 +13,16 @@
|
|||||||
"configGroups": {
|
"configGroups": {
|
||||||
"Planning Center credentials": {
|
"Planning Center credentials": {
|
||||||
"collapsed": "whenSet",
|
"collapsed": "whenSet",
|
||||||
"help": "Create a Personal Access Token at api.planningcenteronline.com (Application ID + Secret), or paste an OAuth 2.0 access token instead. Saved secrets never reach browsers."
|
"help": "Create a Personal Access Token at api.planningcenteronline.com and paste its Client ID + Secret here, or paste an OAuth 2.0 access token instead. Saved secrets never reach browsers."
|
||||||
|
},
|
||||||
|
"Service timing": {
|
||||||
|
"help": "Each service in the plan takes its turn owning the service clock: the next one takes over Pre-roll minutes before its scheduled start, and the elapsed clock, item timing and countdown start afresh for it. A service starts at its scheduled time — or, with document triggers on, the moment the chosen Start document goes live in ProPresenter (falling back to the schedule after the start failsafe) — and ends when the End document goes live, or when the next service takes over. The document lists hold what ProPresenter has shown or listed since ProdDash started plus what is saved here; names are matched loosely, so a renamed \"Post-Service Loop\" still counts."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"configSchema": {
|
"configSchema": {
|
||||||
"appId": {
|
"appId": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"label": "Application ID",
|
"label": "Client ID",
|
||||||
"default": "",
|
"default": "",
|
||||||
"group": "Planning Center credentials"
|
"group": "Planning Center credentials"
|
||||||
},
|
},
|
||||||
@@ -30,7 +34,7 @@
|
|||||||
},
|
},
|
||||||
"accessToken": {
|
"accessToken": {
|
||||||
"type": "password",
|
"type": "password",
|
||||||
"label": "OAuth 2.0 access token (instead of Application ID + Secret)",
|
"label": "OAuth 2.0 access token (instead of Client ID + Secret)",
|
||||||
"default": "",
|
"default": "",
|
||||||
"group": "Planning Center credentials"
|
"group": "Planning Center credentials"
|
||||||
},
|
},
|
||||||
@@ -38,8 +42,7 @@
|
|||||||
"type": "select",
|
"type": "select",
|
||||||
"label": "Service type",
|
"label": "Service type",
|
||||||
"default": "",
|
"default": "",
|
||||||
"optionsRoute": "/service-types",
|
"optionsRoute": "/service-types"
|
||||||
"help": "Your Planning Center Services folders appear as groups in this list; pick the service type whose plans the tiles follow. Save the credentials first if the list is empty."
|
|
||||||
},
|
},
|
||||||
"planId": {
|
"planId": {
|
||||||
"type": "select",
|
"type": "select",
|
||||||
@@ -47,18 +50,67 @@
|
|||||||
"default": "",
|
"default": "",
|
||||||
"options": [{ "value": "", "label": "Automatic — today's plan, else the next upcoming" }],
|
"options": [{ "value": "", "label": "Automatic — today's plan, else the next upcoming" }],
|
||||||
"optionsRoute": "/plans",
|
"optionsRoute": "/plans",
|
||||||
"optionsDependsOn": "serviceTypeId",
|
"optionsDependsOn": "serviceTypeId"
|
||||||
"help": "Leave on Automatic for services. Pin a specific plan for rehearsals or to review a past service."
|
|
||||||
},
|
},
|
||||||
"pollSeconds": {
|
"pollSeconds": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"label": "Re-read the plan every N seconds",
|
"label": "Refresh Interval (s)",
|
||||||
"default": 30
|
"default": 30
|
||||||
},
|
},
|
||||||
"followProPresenter": {
|
"followProPresenter": {
|
||||||
"type": "switch",
|
"type": "switch",
|
||||||
"label": "Follow the ProPresenter module's live item to highlight the current plan item and time it",
|
"label": "Follow ProPresenter Module",
|
||||||
"default": true
|
"default": true
|
||||||
|
},
|
||||||
|
"preRollMinutes": {
|
||||||
|
"type": "number",
|
||||||
|
"label": "Pre-roll (min)",
|
||||||
|
"default": 30,
|
||||||
|
"group": "Service timing",
|
||||||
|
"help": "How long before a service's scheduled start the clock, item timing and countdown switch to it."
|
||||||
|
},
|
||||||
|
"useDocumentTriggers": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "ProPresenter document triggers",
|
||||||
|
"default": false,
|
||||||
|
"group": "Service timing"
|
||||||
|
},
|
||||||
|
"serviceStartTrigger": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Service start",
|
||||||
|
"default": "schedule",
|
||||||
|
"group": "Service timing",
|
||||||
|
"showWhen": "useDocumentTriggers",
|
||||||
|
"options": [
|
||||||
|
{ "value": "schedule", "label": "Scheduled service time" },
|
||||||
|
{ "value": "document", "label": "ProPresenter document" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"serviceStartDocument": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Start document",
|
||||||
|
"default": "",
|
||||||
|
"group": "Service timing",
|
||||||
|
"showWhen": "useDocumentTriggers",
|
||||||
|
"optionsRoute": "/documents",
|
||||||
|
"help": "The document that goes live as the service begins."
|
||||||
|
},
|
||||||
|
"startGraceMinutes": {
|
||||||
|
"type": "number",
|
||||||
|
"label": "Start failsafe (min)",
|
||||||
|
"default": 10,
|
||||||
|
"group": "Service timing",
|
||||||
|
"showWhen": "useDocumentTriggers",
|
||||||
|
"help": "Without the start document this long after the scheduled start, the service counts from the scheduled start anyway."
|
||||||
|
},
|
||||||
|
"serviceEndDocument": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "End document",
|
||||||
|
"default": "",
|
||||||
|
"group": "Service timing",
|
||||||
|
"showWhen": "useDocumentTriggers",
|
||||||
|
"optionsRoute": "/documents",
|
||||||
|
"help": "The document that goes live as the service ends; it freezes the service clock."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"instanceGroups": {
|
"instanceGroups": {
|
||||||
@@ -69,32 +121,48 @@
|
|||||||
"collapsed": false,
|
"collapsed": false,
|
||||||
"columns": 2
|
"columns": 2
|
||||||
},
|
},
|
||||||
|
"Item notes": {
|
||||||
|
"toggle": "showNotes"
|
||||||
|
},
|
||||||
"Show / hide": {
|
"Show / hide": {
|
||||||
"collapsed": true,
|
"collapsed": true,
|
||||||
"columns": 3,
|
"columns": 3,
|
||||||
"help": "Untick anything this tile should not show."
|
"help": "Switch off anything this tile should not show."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"instanceSchema": {
|
"instanceSchema": {
|
||||||
"textSize": { "type": "number", "label": "Text size (px)", "default": 14 },
|
|
||||||
"autoScroll": { "type": "boolean", "label": "Keep the current item in view", "default": true },
|
"autoScroll": { "type": "boolean", "label": "Keep the current item in view", "default": true },
|
||||||
"noteCategories": {
|
|
||||||
"type": "string",
|
|
||||||
"label": "Item note categories to show (comma-separated, blank = all)",
|
|
||||||
"default": ""
|
|
||||||
},
|
|
||||||
"headerTemplate": {
|
"headerTemplate": {
|
||||||
"type": "string",
|
"type": "template",
|
||||||
"label": "Top bar text",
|
"label": "Top bar text",
|
||||||
"default": "{series} • {part} | {serviceType} • {date}",
|
"default": "{series} • {part} | {serviceType} • {date}",
|
||||||
"group": "Top bar",
|
"group": "Top bar",
|
||||||
"help": "Placeholders: {series} series name · {part} plan title / part number · {serviceType} · {date} · {shortDate} · {time} service start time · {length} planned length · {items} item count. Use • between items (an item whose placeholders are empty or hidden is dropped) and | to start a second line."
|
"variables": [
|
||||||
|
{ "value": "{series}", "label": "Series" },
|
||||||
|
{ "value": "{part}", "label": "Part / plan title" },
|
||||||
|
{ "value": "{serviceType}", "label": "Service type" },
|
||||||
|
{ "value": "{date}", "label": "Date" },
|
||||||
|
{ "value": "{shortDate}", "label": "Short date" },
|
||||||
|
{ "value": "{time}", "label": "Service time" },
|
||||||
|
{ "value": "{length}", "label": "Planned length" },
|
||||||
|
{ "value": "{items}", "label": "Item count" }
|
||||||
|
],
|
||||||
|
"help": "Use • between items and | to start a second line."
|
||||||
},
|
},
|
||||||
"keyColor": { "type": "color", "label": "Song key", "default": "#4ea1ff", "group": "Colors" },
|
"keyColor": { "type": "color", "label": "Song key", "default": "#4ea1ff", "group": "Colors" },
|
||||||
"headerColor": { "type": "color", "label": "Section headers", "default": "#2ee59a", "group": "Colors" },
|
"headerColor": { "type": "color", "label": "Section headers", "default": "#2ee59a", "group": "Colors" },
|
||||||
"currentColor": { "type": "color", "label": "Current item", "default": "#2ee59a", "group": "Colors" },
|
"currentColor": { "type": "color", "label": "Current item", "default": "#2ee59a", "group": "Colors" },
|
||||||
"headerGradient": { "type": "boolean", "label": "Fade header color to the right", "default": true, "group": "Colors" },
|
"headerGradient": { "type": "boolean", "label": "Fade header color to the right", "default": true, "group": "Colors" },
|
||||||
"tintByType": { "type": "boolean", "label": "Tint items by type (song / media / other)", "default": false, "group": "Colors" },
|
"tintByType": { "type": "boolean", "label": "Tint items by type (song / media / other)", "default": false, "group": "Colors" },
|
||||||
|
"showNotes": { "type": "switch", "label": "Item notes", "default": true, "group": "Item notes" },
|
||||||
|
"noteCategories": {
|
||||||
|
"type": "multiselect",
|
||||||
|
"label": "Categories",
|
||||||
|
"group": "Item notes",
|
||||||
|
"optionsRoute": "/note-categories",
|
||||||
|
"columns": 3,
|
||||||
|
"allByDefault": true
|
||||||
|
},
|
||||||
"showTopBar": { "type": "boolean", "label": "Top bar", "default": true, "group": "Show / hide" },
|
"showTopBar": { "type": "boolean", "label": "Top bar", "default": true, "group": "Show / hide" },
|
||||||
"showSeries": { "type": "boolean", "label": "Series name", "default": true, "group": "Show / hide" },
|
"showSeries": { "type": "boolean", "label": "Series name", "default": true, "group": "Show / hide" },
|
||||||
"showPart": { "type": "boolean", "label": "Part # / plan title", "default": true, "group": "Show / hide" },
|
"showPart": { "type": "boolean", "label": "Part # / plan title", "default": true, "group": "Show / hide" },
|
||||||
@@ -113,7 +181,6 @@
|
|||||||
"showArrangement": { "type": "boolean", "label": "Song arrangement", "default": false, "group": "Show / hide" },
|
"showArrangement": { "type": "boolean", "label": "Song arrangement", "default": false, "group": "Show / hide" },
|
||||||
"showSongAuthor": { "type": "boolean", "label": "Song author / CCLI", "default": false, "group": "Show / hide" },
|
"showSongAuthor": { "type": "boolean", "label": "Song author / CCLI", "default": false, "group": "Show / hide" },
|
||||||
"showDescription": { "type": "boolean", "label": "Item description", "default": true, "group": "Show / hide" },
|
"showDescription": { "type": "boolean", "label": "Item description", "default": true, "group": "Show / hide" },
|
||||||
"showNotes": { "type": "boolean", "label": "Item notes", "default": true, "group": "Show / hide" },
|
|
||||||
"showPreService": { "type": "boolean", "label": "Pre-service items", "default": true, "group": "Show / hide" },
|
"showPreService": { "type": "boolean", "label": "Pre-service items", "default": true, "group": "Show / hide" },
|
||||||
"showPostService": { "type": "boolean", "label": "Post-service items", "default": true, "group": "Show / hide" },
|
"showPostService": { "type": "boolean", "label": "Post-service items", "default": true, "group": "Show / hide" },
|
||||||
"showNowIndicator": { "type": "boolean", "label": "Now indicator", "default": true, "group": "Show / hide" },
|
"showNowIndicator": { "type": "boolean", "label": "Now indicator", "default": true, "group": "Show / hide" },
|
||||||
|
|||||||
+681
-111
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,238 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service timing for the PCO Plan module — pure functions, no I/O, no
|
||||||
|
* timers. server.js feeds them the plan's service times, the documents
|
||||||
|
* ProPresenter puts live and the clock; service-timing.test.js calls them
|
||||||
|
* directly. Two questions are answered here:
|
||||||
|
*
|
||||||
|
* 1. Which of a plan's services owns the clock right now? → serviceWindows / selectService
|
||||||
|
* 2. When does that service count as started, and as ended? → advance
|
||||||
|
*
|
||||||
|
* ── Service windows ────────────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* A plan can have several services (plan_times of type "service": 9:00 and
|
||||||
|
* 11:00, say). Only ONE owns the service-elapsed clock, the current-item
|
||||||
|
* timing and the countdown at any moment, and moving into the next
|
||||||
|
* service's window resets all three. Each service's window runs from the
|
||||||
|
* end of the previous one to a boundary the next service's pre-roll sets:
|
||||||
|
*
|
||||||
|
* boundary(i → i+1) = min( start[i+1],
|
||||||
|
* max( start[i+1] − preRoll, endOf[i] ) )
|
||||||
|
*
|
||||||
|
* endOf[i] = the moment service i ended (its end cue fired), else its
|
||||||
|
* scheduled end (plan_time ends_at, else start + the planned
|
||||||
|
* length of the "during" items, else 90 min).
|
||||||
|
*
|
||||||
|
* In words: the next service takes over `preRoll` before its scheduled
|
||||||
|
* start (30 min by default; admin "Pre-roll"), but never before the current
|
||||||
|
* one has ended or was due to end, and never later than its own start. A
|
||||||
|
* service still running at the boundary is stopped there — that is the
|
||||||
|
* "next service's start" failsafe the end cue has. The first window opens at
|
||||||
|
* the beginning of time (before 9:00 the day belongs to the 9:00 service:
|
||||||
|
* countdown running, service idle); the last window never closes — the last
|
||||||
|
* service stays "stopped" until the plan itself changes (the next day's plan
|
||||||
|
* takes over through the plan picker). The last service also has a failsafe
|
||||||
|
* end: its scheduled end + LAST_SERVICE_OVERRUN_MS.
|
||||||
|
*
|
||||||
|
* ── Start and end of a service ─────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* phase: idle → running → stopped, never backwards within one window.
|
||||||
|
*
|
||||||
|
* start, schedule mode (default) at the scheduled start; startedAt = it.
|
||||||
|
* start, document mode when the start document goes live
|
||||||
|
* (edge: it has to *become* live, from
|
||||||
|
* pre-roll before the scheduled start
|
||||||
|
* onwards — a rehearsal an hour earlier
|
||||||
|
* that shows the same document is not the
|
||||||
|
* service). Failsafe: if the document has
|
||||||
|
* not gone live by start + grace (admin
|
||||||
|
* "Start failsafe", 10 min default), the
|
||||||
|
* service starts anyway, counting from
|
||||||
|
* the scheduled start — the best estimate
|
||||||
|
* of when it really began.
|
||||||
|
* end, document when the end document goes live while
|
||||||
|
* the service is running (an end document
|
||||||
|
* live before the start — the previous
|
||||||
|
* service's loop — is ignored). Failsafe
|
||||||
|
* either way: the window boundary (see
|
||||||
|
* above), or, for the last service, its
|
||||||
|
* scheduled end + LAST_SERVICE_OVERRUN_MS.
|
||||||
|
*
|
||||||
|
* Cue documents are matched with matching.isCue (TRIGGER_THRESHOLD, see
|
||||||
|
* matching.js for the tolerance), so "Post-Service Loop" still ends the
|
||||||
|
* service when ProPresenter calls it "Post Service Loop".
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { similarity, TRIGGER_THRESHOLD } = require('./matching');
|
||||||
|
|
||||||
|
const MINUTE = 60000;
|
||||||
|
/** How long before a service's scheduled start the timers switch to it (admin "Pre-roll"). */
|
||||||
|
const DEFAULT_PRE_ROLL_MS = 30 * MINUTE;
|
||||||
|
/** Document mode: start by the schedule anyway this long after the scheduled start (admin "Start failsafe"). */
|
||||||
|
const DEFAULT_START_GRACE_MS = 10 * MINUTE;
|
||||||
|
/** The last service of a plan is stopped this long after its scheduled end if nothing ended it. */
|
||||||
|
const LAST_SERVICE_OVERRUN_MS = 60 * MINUTE;
|
||||||
|
/** A service with no end time and no planned item lengths is assumed this long. */
|
||||||
|
const DEFAULT_SERVICE_LENGTH_MS = 90 * MINUTE;
|
||||||
|
|
||||||
|
/** The plan times that are services (every timed one when none is typed "service"), earliest first. */
|
||||||
|
function serviceTimes(planTimes) {
|
||||||
|
const list = Array.isArray(planTimes) ? planTimes.filter((t) => t && Number(t.startsAt) > 0) : [];
|
||||||
|
const services = list.filter((t) => t.timeType === 'service');
|
||||||
|
return (services.length ? services : list).slice().sort((a, b) => a.startsAt - b.startsAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The window each service owns (see the file comment).
|
||||||
|
*
|
||||||
|
* @param {Array} planTimes [{ id, name, timeType, startsAt, endsAt }] (ms epochs)
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {number} [opts.preRollMs] how early the next service may take over (default 30 min)
|
||||||
|
* @param {number} [opts.plannedMs] planned length of the service — for services without ends_at
|
||||||
|
* @param {object} [opts.endedAt] { [serviceId]: ms } services already ended by their cue
|
||||||
|
* @returns {Array<{ id, name, index, count, startsAt, endsAt, windowFrom, windowUntil, cueFrom, failsafeEndAt, isLast }>}
|
||||||
|
*/
|
||||||
|
function serviceWindows(planTimes, opts = {}) {
|
||||||
|
const preRoll = Number.isFinite(opts.preRollMs) ? Math.max(0, opts.preRollMs) : DEFAULT_PRE_ROLL_MS;
|
||||||
|
const planned = Number(opts.plannedMs) > 0 ? Number(opts.plannedMs) : 0;
|
||||||
|
const ended = opts.endedAt && typeof opts.endedAt === 'object' ? opts.endedAt : {};
|
||||||
|
const services = serviceTimes(planTimes);
|
||||||
|
const windows = services.map((svc, i) => {
|
||||||
|
const scheduledEnd = svc.endsAt > svc.startsAt ? svc.endsAt : svc.startsAt + (planned || DEFAULT_SERVICE_LENGTH_MS);
|
||||||
|
const endOf = Number(ended[svc.id]) > 0 ? Number(ended[svc.id]) : scheduledEnd;
|
||||||
|
const next = services[i + 1];
|
||||||
|
const windowUntil = next ? Math.min(next.startsAt, Math.max(next.startsAt - preRoll, endOf)) : Infinity;
|
||||||
|
return {
|
||||||
|
id: String(svc.id),
|
||||||
|
name: String(svc.name || ''),
|
||||||
|
index: i,
|
||||||
|
count: services.length,
|
||||||
|
startsAt: svc.startsAt,
|
||||||
|
endsAt: scheduledEnd,
|
||||||
|
windowFrom: -Infinity, // filled in below
|
||||||
|
windowUntil,
|
||||||
|
cueFrom: svc.startsAt - preRoll, // a start document counts from here on
|
||||||
|
failsafeEndAt: next ? windowUntil : scheduledEnd + LAST_SERVICE_OVERRUN_MS,
|
||||||
|
isLast: !next,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
for (let i = 1; i < windows.length; i += 1) windows[i].windowFrom = windows[i - 1].windowUntil;
|
||||||
|
return windows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The window that holds `now` — null only when there are no services. */
|
||||||
|
function selectService(windows, now) {
|
||||||
|
if (!Array.isArray(windows) || !windows.length) return null;
|
||||||
|
return windows.find((w) => now >= w.windowFrom && now < w.windowUntil) || windows[windows.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Timing state for a service that has just come into its window. */
|
||||||
|
function freshService(window) {
|
||||||
|
return {
|
||||||
|
id: window ? String(window.id) : '',
|
||||||
|
phase: window ? 'idle' : 'none',
|
||||||
|
startedAt: 0,
|
||||||
|
endedAt: 0,
|
||||||
|
startTrigger: '', // 'schedule' | 'document' | 'failsafe'
|
||||||
|
endTrigger: '', // 'document' | 'next-service' | 'plan-end'
|
||||||
|
cue: '', // the document that started / ended it
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(ms) {
|
||||||
|
return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move a service's timing on by a clock tick or by a document going live.
|
||||||
|
* Pure: returns a new state and a one-line description of what happened
|
||||||
|
* (for the log), or `changed: false`.
|
||||||
|
*
|
||||||
|
* @param {object} svc state from freshService(window) / a previous advance()
|
||||||
|
* @param {object} window from serviceWindows()
|
||||||
|
* @param {object} opts { startMode: 'schedule'|'document', startDocument, endDocument, startGraceMs }
|
||||||
|
* @param {number} now ms epoch
|
||||||
|
* @param {string} [cue] the document that just went live (omit on a plain tick)
|
||||||
|
* @returns {{ svc: object, changed: boolean, event: string }}
|
||||||
|
*/
|
||||||
|
function advance(svc, window, opts, now, cue) {
|
||||||
|
const o = opts || {};
|
||||||
|
const grace = Number.isFinite(o.startGraceMs) ? Math.max(0, o.startGraceMs) : DEFAULT_START_GRACE_MS;
|
||||||
|
const cueName = String(cue || '').trim();
|
||||||
|
const same = { svc, changed: false, event: '' };
|
||||||
|
if (!svc || !window || svc.id !== String(window.id)) return same;
|
||||||
|
const at = fmtTime(window.startsAt);
|
||||||
|
|
||||||
|
if (svc.phase === 'idle') {
|
||||||
|
const documentMode = o.startMode === 'document' && o.startDocument;
|
||||||
|
if (documentMode) {
|
||||||
|
if (cueName && now >= window.cueFrom) {
|
||||||
|
const score = similarity(cueName, o.startDocument);
|
||||||
|
if (score >= TRIGGER_THRESHOLD) {
|
||||||
|
return {
|
||||||
|
svc: { ...svc, phase: 'running', startedAt: now, startTrigger: 'document', cue: cueName },
|
||||||
|
changed: true,
|
||||||
|
event: `started at ${fmtTime(now)} — "${cueName}" went live (start document "${o.startDocument}", match ${score.toFixed(2)})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (now >= window.startsAt + grace) {
|
||||||
|
return {
|
||||||
|
svc: { ...svc, phase: 'running', startedAt: window.startsAt, startTrigger: 'failsafe' },
|
||||||
|
changed: true,
|
||||||
|
event: `started (failsafe) — the start document "${o.startDocument}" did not go live within ${Math.round(grace / MINUTE)} min of ${at}; counting from ${at}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return same;
|
||||||
|
}
|
||||||
|
if (now >= window.startsAt) {
|
||||||
|
return {
|
||||||
|
svc: { ...svc, phase: 'running', startedAt: window.startsAt, startTrigger: 'schedule' },
|
||||||
|
changed: true,
|
||||||
|
event: `started — scheduled ${at}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return same;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (svc.phase === 'running') {
|
||||||
|
if (o.endDocument && cueName) {
|
||||||
|
const score = similarity(cueName, o.endDocument);
|
||||||
|
if (score >= TRIGGER_THRESHOLD) {
|
||||||
|
return {
|
||||||
|
svc: { ...svc, phase: 'stopped', endedAt: now, endTrigger: 'document', cue: cueName },
|
||||||
|
changed: true,
|
||||||
|
event: `ended at ${fmtTime(now)} — "${cueName}" went live (end document "${o.endDocument}", match ${score.toFixed(2)})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (now >= window.failsafeEndAt) {
|
||||||
|
const endedAt = Math.min(now, window.failsafeEndAt);
|
||||||
|
return {
|
||||||
|
svc: { ...svc, phase: 'stopped', endedAt, endTrigger: window.isLast ? 'plan-end' : 'next-service' },
|
||||||
|
changed: true,
|
||||||
|
event: window.isLast
|
||||||
|
? `stopped (failsafe) — ${Math.round(LAST_SERVICE_OVERRUN_MS / MINUTE)} min past its scheduled end ${fmtTime(window.endsAt)}`
|
||||||
|
: `stopped (failsafe) — the next service's window opened at ${fmtTime(endedAt)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return same;
|
||||||
|
}
|
||||||
|
|
||||||
|
return same;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
MINUTE,
|
||||||
|
DEFAULT_PRE_ROLL_MS,
|
||||||
|
DEFAULT_START_GRACE_MS,
|
||||||
|
LAST_SERVICE_OVERRUN_MS,
|
||||||
|
DEFAULT_SERVICE_LENGTH_MS,
|
||||||
|
serviceTimes,
|
||||||
|
serviceWindows,
|
||||||
|
selectService,
|
||||||
|
freshService,
|
||||||
|
advance,
|
||||||
|
fmtTime,
|
||||||
|
};
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for the PCO Plan module's pure service-timing logic.
|
||||||
|
*
|
||||||
|
* node modules/pco-plan/service-timing.test.js
|
||||||
|
*
|
||||||
|
* Plain Node, no dependencies; exits non-zero on the first failure. Times are
|
||||||
|
* built for a two-service Sunday (9:00 and 11:00, 75 min each, 30 min
|
||||||
|
* pre-roll) so the expectations read like the day would.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const assert = require('assert');
|
||||||
|
const { similarity, isCue, MATCH_THRESHOLD, TRIGGER_THRESHOLD } = require('./matching');
|
||||||
|
const {
|
||||||
|
MINUTE,
|
||||||
|
LAST_SERVICE_OVERRUN_MS,
|
||||||
|
serviceWindows,
|
||||||
|
selectService,
|
||||||
|
freshService,
|
||||||
|
advance,
|
||||||
|
} = require('./service-timing');
|
||||||
|
|
||||||
|
const DAY = new Date(2026, 8, 13, 0, 0, 0, 0).getTime(); // a local midnight
|
||||||
|
const at = (h, m = 0) => DAY + (h * 60 + m) * MINUTE;
|
||||||
|
const hm = (ms) => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||||
|
|
||||||
|
const PLAN_TIMES = [
|
||||||
|
{ id: 'r', name: 'Rehearsal', timeType: 'rehearsal', startsAt: at(8, 0), endsAt: at(8, 45) },
|
||||||
|
{ id: 's1', name: 'First Service', timeType: 'service', startsAt: at(9, 0), endsAt: at(10, 15) },
|
||||||
|
{ id: 's2', name: 'Second Service', timeType: 'service', startsAt: at(11, 0), endsAt: at(12, 15) },
|
||||||
|
];
|
||||||
|
const PRE_ROLL = 30 * MINUTE;
|
||||||
|
const OPTS = { preRollMs: PRE_ROLL };
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
function test(name, fn) {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
passed += 1;
|
||||||
|
console.log(` ok ${name}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(` FAIL ${name}\n ${err.message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('service windows');
|
||||||
|
|
||||||
|
test('rehearsal is not a service; windows are contiguous and ordered', () => {
|
||||||
|
const w = serviceWindows(PLAN_TIMES, OPTS);
|
||||||
|
assert.deepStrictEqual(w.map((x) => x.id), ['s1', 's2']);
|
||||||
|
assert.strictEqual(w[0].windowFrom, -Infinity);
|
||||||
|
assert.strictEqual(w[0].windowUntil, w[1].windowFrom);
|
||||||
|
assert.strictEqual(w[1].windowUntil, Infinity);
|
||||||
|
assert.strictEqual(w[0].count, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the boundary is 30 min before the next start once the first was due to end', () => {
|
||||||
|
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
|
||||||
|
// 10:15 scheduled end, 11:00 − 30 = 10:30 → boundary 10:30
|
||||||
|
assert.strictEqual(hm(w1.windowUntil), hm(at(10, 30)));
|
||||||
|
assert.strictEqual(w1.failsafeEndAt, w1.windowUntil, 'first service failsafe-stops at the boundary');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a pre-roll larger than the gap waits for the scheduled end, never passes the next start', () => {
|
||||||
|
const [w1] = serviceWindows(PLAN_TIMES, { preRollMs: 60 * MINUTE });
|
||||||
|
assert.strictEqual(hm(w1.windowUntil), hm(at(10, 15)), 'max(10:00, 10:15)');
|
||||||
|
const [tight] = serviceWindows(
|
||||||
|
[
|
||||||
|
{ id: 'a', timeType: 'service', startsAt: at(9, 0), endsAt: at(10, 30) },
|
||||||
|
{ id: 'b', timeType: 'service', startsAt: at(10, 0), endsAt: at(11, 0) },
|
||||||
|
],
|
||||||
|
OPTS
|
||||||
|
);
|
||||||
|
assert.strictEqual(hm(tight.windowUntil), hm(at(10, 0)), 'capped at the next start');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an early actual end lets the next window open at pre-roll', () => {
|
||||||
|
const [w1] = serviceWindows(PLAN_TIMES, { preRollMs: 60 * MINUTE, endedAt: { s1: at(10, 5) } });
|
||||||
|
assert.strictEqual(hm(w1.windowUntil), hm(at(10, 5)), 'ended at 10:05 inside the 10:00 pre-roll → flips at 10:05');
|
||||||
|
const [w1b] = serviceWindows(PLAN_TIMES, { preRollMs: PRE_ROLL, endedAt: { s1: at(10, 5) } });
|
||||||
|
assert.strictEqual(hm(w1b.windowUntil), hm(at(10, 30)), 'ended at 10:05 before the 10:30 pre-roll → flips at 10:30');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a service without ends_at gets the planned length, else 90 min', () => {
|
||||||
|
const [w] = serviceWindows([{ id: 'x', timeType: 'service', startsAt: at(9, 0), endsAt: 0 }], { plannedMs: 70 * MINUTE });
|
||||||
|
assert.strictEqual(hm(w.endsAt), hm(at(10, 10)));
|
||||||
|
const [w90] = serviceWindows([{ id: 'x', timeType: 'service', startsAt: at(9, 0), endsAt: 0 }], {});
|
||||||
|
assert.strictEqual(hm(w90.endsAt), hm(at(10, 30)));
|
||||||
|
assert.strictEqual(w90.failsafeEndAt, w90.endsAt + LAST_SERVICE_OVERRUN_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selectService: 8:50 → 9:00, 9:30 → 9:00, 10:55 → 11:00, 11:05 → 11:00', () => {
|
||||||
|
const w = serviceWindows(PLAN_TIMES, OPTS);
|
||||||
|
assert.strictEqual(selectService(w, at(8, 50)).id, 's1');
|
||||||
|
assert.strictEqual(selectService(w, at(9, 30)).id, 's1');
|
||||||
|
assert.strictEqual(selectService(w, at(10, 29)).id, 's1', 'still the first service until the 10:30 boundary');
|
||||||
|
assert.strictEqual(selectService(w, at(10, 55)).id, 's2');
|
||||||
|
assert.strictEqual(selectService(w, at(11, 5)).id, 's2');
|
||||||
|
assert.strictEqual(selectService(w, at(6, 0)).id, 's1', 'the morning belongs to the first service');
|
||||||
|
assert.strictEqual(selectService(w, at(15, 0)).id, 's2', 'the afternoon stays with the last service');
|
||||||
|
assert.strictEqual(selectService([], at(9, 0)), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('service start');
|
||||||
|
|
||||||
|
const SCHEDULE = { startMode: 'schedule', startDocument: '', endDocument: '', startGraceMs: 10 * MINUTE };
|
||||||
|
const DOCUMENT = {
|
||||||
|
startMode: 'document',
|
||||||
|
startDocument: 'Welcome & Announcements',
|
||||||
|
endDocument: 'Post Service Loop',
|
||||||
|
startGraceMs: 10 * MINUTE,
|
||||||
|
};
|
||||||
|
|
||||||
|
test('schedule mode: idle before 9:00, running from 9:00 counting from 9:00; documents ignored', () => {
|
||||||
|
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
|
||||||
|
let s = freshService(w1);
|
||||||
|
assert.strictEqual(advance(s, w1, SCHEDULE, at(8, 59), 'Welcome').changed, false, 'a document is not a cue in schedule mode');
|
||||||
|
assert.strictEqual(advance(s, w1, SCHEDULE, at(8, 59)).changed, false);
|
||||||
|
const out = advance(s, w1, SCHEDULE, at(9, 0, 30));
|
||||||
|
assert.strictEqual(out.changed, true);
|
||||||
|
s = out.svc;
|
||||||
|
assert.strictEqual(s.phase, 'running');
|
||||||
|
assert.strictEqual(s.startedAt, at(9, 0), 'counts from the scheduled start, not the tick that noticed');
|
||||||
|
assert.strictEqual(s.startTrigger, 'schedule');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('document mode: the start document going live starts the service at that moment', () => {
|
||||||
|
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
|
||||||
|
const s = freshService(w1);
|
||||||
|
assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 0, 30)).changed, false, 'the scheduled time alone does not start it');
|
||||||
|
const out = advance(s, w1, DOCUMENT, at(8, 58), 'Welcome and Announcements [Full]');
|
||||||
|
assert.strictEqual(out.changed, true, 'a renamed / tagged start document still counts');
|
||||||
|
assert.strictEqual(out.svc.phase, 'running');
|
||||||
|
assert.strictEqual(out.svc.startedAt, at(8, 58), 'an early start is the real start');
|
||||||
|
assert.strictEqual(out.svc.startTrigger, 'document');
|
||||||
|
assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 1), 'Post Service Loop').changed, false, 'the end document does not start an idle service');
|
||||||
|
assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 1), 'Goodness of God').changed, false, 'an unrelated document does nothing');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('document mode: the start document is only a cue from pre-roll before the start', () => {
|
||||||
|
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
|
||||||
|
const s = freshService(w1);
|
||||||
|
assert.strictEqual(advance(s, w1, DOCUMENT, at(8, 0), 'Welcome & Announcements').changed, false, 'rehearsal at 8:00 shows it — not the service');
|
||||||
|
assert.strictEqual(advance(s, w1, DOCUMENT, at(8, 30), 'Welcome & Announcements').changed, true, 'from 8:30 it counts');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('document mode: failsafe at start + grace counts from the scheduled start', () => {
|
||||||
|
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
|
||||||
|
const s = freshService(w1);
|
||||||
|
assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 9, 59)).changed, false);
|
||||||
|
const out = advance(s, w1, DOCUMENT, at(9, 10));
|
||||||
|
assert.strictEqual(out.changed, true);
|
||||||
|
assert.strictEqual(out.svc.phase, 'running');
|
||||||
|
assert.strictEqual(out.svc.startedAt, at(9, 0));
|
||||||
|
assert.strictEqual(out.svc.startTrigger, 'failsafe');
|
||||||
|
assert.match(out.event, /failsafe/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('document mode: a cue and the failsafe in the same tick — the cue wins', () => {
|
||||||
|
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
|
||||||
|
const out = advance(freshService(w1), w1, DOCUMENT, at(9, 12), 'Welcome & Announcements');
|
||||||
|
assert.strictEqual(out.svc.startTrigger, 'document');
|
||||||
|
assert.strictEqual(out.svc.startedAt, at(9, 12));
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('service end');
|
||||||
|
|
||||||
|
test('the end document freezes a running service; a second start document is ignored', () => {
|
||||||
|
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
|
||||||
|
let s = advance(freshService(w1), w1, DOCUMENT, at(9, 0), 'Welcome & Announcements').svc;
|
||||||
|
assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 40), 'Welcome & Announcements').changed, false, 'shown again mid-service: nothing');
|
||||||
|
assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 40), 'Pre Service Loop').changed, false, 'the PRE-service loop must not end it (0.67 < 0.75)');
|
||||||
|
const out = advance(s, w1, DOCUMENT, at(10, 12), 'Post-Service Loop');
|
||||||
|
assert.strictEqual(out.changed, true, 'a hyphenated end document still counts');
|
||||||
|
s = out.svc;
|
||||||
|
assert.strictEqual(s.phase, 'stopped');
|
||||||
|
assert.strictEqual(s.endedAt, at(10, 12));
|
||||||
|
assert.strictEqual(s.endTrigger, 'document');
|
||||||
|
assert.strictEqual(advance(s, w1, DOCUMENT, at(10, 20), 'Welcome & Announcements').changed, false, 'stopped stays stopped');
|
||||||
|
assert.strictEqual(advance(s, w1, DOCUMENT, at(10, 45)).changed, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failsafe: a service still running when the next window opens stops at the boundary', () => {
|
||||||
|
const [w1] = serviceWindows(PLAN_TIMES, OPTS);
|
||||||
|
const s = advance(freshService(w1), w1, SCHEDULE, at(9, 0)).svc;
|
||||||
|
assert.strictEqual(advance(s, w1, SCHEDULE, at(10, 29)).changed, false, 'running past its 10:15 end is allowed');
|
||||||
|
const out = advance(s, w1, SCHEDULE, at(10, 31));
|
||||||
|
assert.strictEqual(out.changed, true);
|
||||||
|
assert.strictEqual(out.svc.phase, 'stopped');
|
||||||
|
assert.strictEqual(hm(out.svc.endedAt), hm(at(10, 30)), 'frozen at the boundary, not at the tick');
|
||||||
|
assert.strictEqual(out.svc.endTrigger, 'next-service');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failsafe: the last service stops an hour after its scheduled end', () => {
|
||||||
|
const [, w2] = serviceWindows(PLAN_TIMES, OPTS);
|
||||||
|
const s = advance(freshService(w2), w2, SCHEDULE, at(11, 0)).svc;
|
||||||
|
assert.strictEqual(advance(s, w2, SCHEDULE, at(13, 14)).changed, false);
|
||||||
|
const out = advance(s, w2, SCHEDULE, at(13, 15));
|
||||||
|
assert.strictEqual(out.changed, true);
|
||||||
|
assert.strictEqual(out.svc.phase, 'stopped');
|
||||||
|
assert.strictEqual(hm(out.svc.endedAt), hm(at(13, 15)));
|
||||||
|
assert.strictEqual(out.svc.endTrigger, 'plan-end');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the end document does not stop a service in the wrong window', () => {
|
||||||
|
const w = serviceWindows(PLAN_TIMES, OPTS);
|
||||||
|
const s1 = advance(freshService(w[0]), w[0], DOCUMENT, at(9, 0), 'Welcome & Announcements').svc;
|
||||||
|
assert.strictEqual(advance(s1, w[1], DOCUMENT, at(10, 40), 'Post Service Loop').changed, false, 'state and window must belong together');
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('matching');
|
||||||
|
|
||||||
|
test('plan items: 0.6 — two of three words; cues: 0.75 — pre/post loops stay apart', () => {
|
||||||
|
assert.strictEqual(MATCH_THRESHOLD, 0.6);
|
||||||
|
assert.strictEqual(TRIGGER_THRESHOLD, 0.75);
|
||||||
|
assert.strictEqual(similarity('Post-Service Loop', 'Post Service Loop'), 1);
|
||||||
|
assert.strictEqual(similarity('Post Service Loop [Full]', 'Post Service Loop'), 1);
|
||||||
|
assert.strictEqual(similarity('Post Service', 'Post Service Loop'), 0.85);
|
||||||
|
assert.ok(Math.abs(similarity('Pre Service Loop', 'Post Service Loop') - 2 / 3) < 0.001);
|
||||||
|
assert.strictEqual(isCue('Pre Service Loop', 'Post Service Loop'), false);
|
||||||
|
assert.strictEqual(isCue('Post-Service Loop', 'Post Service Loop'), true);
|
||||||
|
assert.strictEqual(isCue('Welcome', 'Welcome & Announcements'), true);
|
||||||
|
assert.strictEqual(isCue('Anything', ''), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n${passed} tests passed`);
|
||||||
@@ -3,8 +3,13 @@
|
|||||||
Ported from prodcom-listener/public/app.js into the ProdDash module
|
Ported from prodcom-listener/public/app.js into the ProdDash module
|
||||||
contract: everything lives inside the tile's root, per-tile state
|
contract: everything lives inside the tile's root, per-tile state
|
||||||
(hidden channels, channel icons, timestamps, text size, flow direction)
|
(hidden channels, channel icons, timestamps, text size, flow direction)
|
||||||
persists in instance settings, and all network traffic goes through
|
persists in instance settings, and the data comes from the module's own
|
||||||
moduleApi (the module's server proxy at /prodcom/*). */
|
server, which keeps one connection to ProdCom for every dashboard: a
|
||||||
|
snapshot on connect (`state`), then each changed entry (`entry`). */
|
||||||
|
|
||||||
|
/* the text-size buttons: a drawn − and + at the icon size, like every other header icon */
|
||||||
|
const MINUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14"/></svg>';
|
||||||
|
const PLUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>';
|
||||||
|
|
||||||
export default function create({ root, moduleApi }) {
|
export default function create({ root, moduleApi }) {
|
||||||
/* ── per-instance state ─────────────────────────────────────────── */
|
/* ── per-instance state ─────────────────────────────────────────── */
|
||||||
@@ -15,7 +20,6 @@ export default function create({ root, moduleApi }) {
|
|||||||
let pinnedToLatest = true;
|
let pinnedToLatest = true;
|
||||||
let hiddenChannels = new Set(moduleApi.instanceSettings.hiddenChannels || []);
|
let hiddenChannels = new Set(moduleApi.instanceSettings.hiddenChannels || []);
|
||||||
let stream = null; // moduleApi.sse handle
|
let stream = null; // moduleApi.sse handle
|
||||||
let bootTimer = null;
|
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
|
|
||||||
/* ── DOM skeleton ───────────────────────────────────────────────── */
|
/* ── DOM skeleton ───────────────────────────────────────────────── */
|
||||||
@@ -48,7 +52,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
let channelMenuEl = null; // the open menu's element (filled on each open)
|
let channelMenuEl = null; // the open menu's element (filled on each open)
|
||||||
const channelsMenu = moduleApi.header.addMenu({
|
const channelsMenu = moduleApi.header.addMenu({
|
||||||
icon: CHANNELS_SVG,
|
icon: CHANNELS_SVG,
|
||||||
title: 'Choose visible channels',
|
title: 'Channels',
|
||||||
build(menu) {
|
build(menu) {
|
||||||
menu.classList.add('pt-menu');
|
menu.classList.add('pt-menu');
|
||||||
channelMenuEl = menu;
|
channelMenuEl = menu;
|
||||||
@@ -65,7 +69,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
let timeBtn = null;
|
let timeBtn = null;
|
||||||
timeBtn = moduleApi.header.addButton({
|
timeBtn = moduleApi.header.addButton({
|
||||||
icon: CLOCK_SVG,
|
icon: CLOCK_SVG,
|
||||||
title: 'Show / hide timestamps',
|
title: 'Timestamps',
|
||||||
onClick() {
|
onClick() {
|
||||||
moduleApi.saveInstanceSettings({ showTimes: !moduleApi.instanceSettings.showTimes });
|
moduleApi.saveInstanceSettings({ showTimes: !moduleApi.instanceSettings.showTimes });
|
||||||
applyInstanceSettings();
|
applyInstanceSettings();
|
||||||
@@ -77,8 +81,8 @@ export default function create({ root, moduleApi }) {
|
|||||||
moduleApi.saveInstanceSettings({ textSize: Math.min(48, Math.max(10, current + delta)) });
|
moduleApi.saveInstanceSettings({ textSize: Math.min(48, Math.max(10, current + delta)) });
|
||||||
applyInstanceSettings();
|
applyInstanceSettings();
|
||||||
}
|
}
|
||||||
moduleApi.header.addButton({ label: 'A−', title: 'Smaller text', onClick: () => bumpTextSize(-2) });
|
moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-2) });
|
||||||
moduleApi.header.addButton({ label: 'A+', title: 'Larger text', onClick: () => bumpTextSize(2) });
|
moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(2) });
|
||||||
|
|
||||||
// icon and tooltip track the current direction — applyInstanceSettings sets both
|
// icon and tooltip track the current direction — applyInstanceSettings sets both
|
||||||
const flowBtn = moduleApi.header.addButton({
|
const flowBtn = moduleApi.header.addButton({
|
||||||
@@ -93,7 +97,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
|
|
||||||
moduleApi.header.addButton({
|
moduleApi.header.addButton({
|
||||||
icon: JUMP_SVG,
|
icon: JUMP_SVG,
|
||||||
title: 'Jump to latest',
|
title: 'Latest',
|
||||||
onClick() {
|
onClick() {
|
||||||
pinnedToLatest = true;
|
pinnedToLatest = true;
|
||||||
jumpBtn.hidden = true;
|
jumpBtn.hidden = true;
|
||||||
@@ -103,7 +107,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
|
|
||||||
const clearBtn = moduleApi.header.addButton({
|
const clearBtn = moduleApi.header.addButton({
|
||||||
icon: CLEAR_SVG,
|
icon: CLEAR_SVG,
|
||||||
title: 'Clear this tile (does not affect ProdCom)',
|
title: 'Clear',
|
||||||
onClick: clearView,
|
onClick: clearView,
|
||||||
});
|
});
|
||||||
clearBtn.classList.add('pt-clear');
|
clearBtn.classList.add('pt-clear');
|
||||||
@@ -120,9 +124,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
wrap.classList.toggle('newest-first', up);
|
wrap.classList.toggle('newest-first', up);
|
||||||
flowBtn.innerHTML = up ? FLOW_UP_SVG : FLOW_DOWN_SVG;
|
flowBtn.innerHTML = up ? FLOW_UP_SVG : FLOW_DOWN_SVG;
|
||||||
flowBtn.classList.toggle('active', up);
|
flowBtn.classList.toggle('active', up);
|
||||||
flowBtn.title = up
|
flowBtn.title = up ? 'Newest first' : 'Oldest first';
|
||||||
? 'Newest at top — click for newest at bottom'
|
|
||||||
: 'Newest at bottom — click for newest at top';
|
|
||||||
jumpBtn.textContent = (up ? '↑' : '↓') + ' New messages';
|
jumpBtn.textContent = (up ? '↑' : '↓') + ' New messages';
|
||||||
}
|
}
|
||||||
applyInstanceSettings();
|
applyInstanceSettings();
|
||||||
@@ -174,9 +176,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
channelCountEl.textContent = filtering ? `${visibleCount}/${channels.size}` : '';
|
channelCountEl.textContent = filtering ? `${visibleCount}/${channels.size}` : '';
|
||||||
channelCountEl.hidden = !filtering;
|
channelCountEl.hidden = !filtering;
|
||||||
channelsMenu.button.classList.toggle('active', filtering);
|
channelsMenu.button.classList.toggle('active', filtering);
|
||||||
channelsMenu.button.title = filtering
|
channelsMenu.button.title = filtering ? `Channels ${visibleCount}/${channels.size}` : 'Channels';
|
||||||
? `Channels — showing ${visibleCount} of ${channels.size}`
|
|
||||||
: 'Choose visible channels';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function rebuildChannelMenu() {
|
function rebuildChannelMenu() {
|
||||||
@@ -353,120 +353,68 @@ export default function create({ root, moduleApi }) {
|
|||||||
|
|
||||||
/* The stream sends JSON objects for new/updated/completed transcript
|
/* The stream sends JSON objects for new/updated/completed transcript
|
||||||
entries. Be liberal about the envelope shape. */
|
entries. Be liberal about the envelope shape. */
|
||||||
function extractEntries(obj) {
|
/* ── the module's feed: one snapshot, then each change ─────────── */
|
||||||
if (!obj || typeof obj !== 'object') return [];
|
|
||||||
if (Array.isArray(obj)) return obj.flatMap(extractEntries);
|
function applyStatus(st) {
|
||||||
if (typeof obj.text === 'string' && (obj.id || obj.channelId)) return [obj];
|
if (!st || typeof st !== 'object') return;
|
||||||
for (const key of ['data', 'payload', 'entry', 'entries', 'transcript']) {
|
if (st.state === 'ok') moduleApi.setStatus('ok', st.message || 'Live');
|
||||||
if (obj[key] && typeof obj[key] === 'object') {
|
else moduleApi.setStatus(st.state === 'error' ? 'error' : 'connecting', st.message || 'Connecting to ProdCom…');
|
||||||
const found = extractEntries(obj[key]);
|
|
||||||
if (found.length) return found;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEventData(raw) {
|
function applySnapshot(snap) {
|
||||||
let obj;
|
if (!snap || typeof snap !== 'object') return;
|
||||||
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();
|
channels.clear();
|
||||||
for (const ch of body.data || []) {
|
for (const ch of snap.channels || []) channels.set(String(ch.id), { name: ch.name, color: ch.color || '#8b98a5' });
|
||||||
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();
|
groups.clear();
|
||||||
for (const g of body.data || []) {
|
for (const g of snap.groups || []) groups.set(String(g.id), { name: g.name, channelIds: g.channelIds || [] });
|
||||||
groups.set(g.id, { name: g.name, channelIds: g.channelIds || [] });
|
|
||||||
}
|
|
||||||
rebuildChannelMenu();
|
rebuildChannelMenu();
|
||||||
|
const list = (snap.entries || []).slice().sort((a, b) => new Date(a.date) - new Date(b.date));
|
||||||
|
list.forEach(upsertEntry); // dedupes by id, so a snapshot after a reconnect is harmless
|
||||||
|
applyStatus(snap.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadHistory() {
|
async function loadState() {
|
||||||
const res = await moduleApi.fetch('/prodcom/api/v1/transcript?limit=100');
|
try {
|
||||||
if (!res.ok) throw new Error('transcript ' + res.status);
|
const res = await moduleApi.fetch('/state');
|
||||||
const body = await res.json();
|
if (!res.ok) throw new Error(String(res.status));
|
||||||
const entries = (body.data || []).slice();
|
applySnapshot(await res.json());
|
||||||
entries.sort((a, b) => new Date(a.date) - new Date(b.date));
|
} catch { /* the stream's own snapshot covers it */ }
|
||||||
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() {
|
function connect() {
|
||||||
const onEvent = (ev) => handleEventData(ev.data);
|
stream = moduleApi.sse('/stream', {
|
||||||
const events = {};
|
|
||||||
for (const name of SSE_EVENT_NAMES) events[name] = onEvent;
|
|
||||||
|
|
||||||
stream = moduleApi.sse('/prodcom/api/v1/transcript/stream', {
|
|
||||||
open() {
|
open() {
|
||||||
moduleApi.setStatus('ok', 'Live');
|
loadState(); // a late joiner, or a reconnect: catch up on what was missed
|
||||||
// refresh channels/groups and backfill entries missed while
|
|
||||||
// disconnected (upsertEntry dedupes by id, so this is harmless)
|
|
||||||
loadChannels().catch(() => {});
|
|
||||||
loadGroups().catch(() => {});
|
|
||||||
loadHistory().catch(() => {});
|
|
||||||
},
|
},
|
||||||
error() {
|
error() {
|
||||||
moduleApi.setStatus('error', 'ProdCom unreachable — reconnecting…');
|
moduleApi.setStatus('error', 'ProdDash ProdCom module unreachable — reconnecting…');
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
state(ev) {
|
||||||
|
try { applySnapshot(JSON.parse(ev.data)); } catch { /* a bad frame is skipped */ }
|
||||||
|
},
|
||||||
|
entry(ev) {
|
||||||
|
try { upsertEntry(JSON.parse(ev.data)); } catch { /* skipped */ }
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
clearView();
|
||||||
|
},
|
||||||
|
status(ev) {
|
||||||
|
try { applyStatus(JSON.parse(ev.data)); } catch { /* skipped */ }
|
||||||
|
},
|
||||||
},
|
},
|
||||||
message: onEvent,
|
|
||||||
events,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── lifecycle ──────────────────────────────────────────────────── */
|
/* ── 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;
|
|
||||||
}
|
|
||||||
scrollToLatest();
|
|
||||||
connect();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
start() {
|
start() {
|
||||||
boot();
|
moduleApi.setStatus('connecting', 'Connecting to ProdCom…');
|
||||||
|
connect();
|
||||||
},
|
},
|
||||||
stop() {
|
stop() {
|
||||||
stopped = true;
|
stopped = true;
|
||||||
clearTimeout(bootTimer);
|
|
||||||
stream?.close();
|
stream?.close();
|
||||||
root.innerHTML = ''; // header controls are removed by the shell
|
root.innerHTML = ''; // header controls are removed by the shell
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,22 +1,34 @@
|
|||||||
{
|
{
|
||||||
"id": "prodcom-transcript",
|
"id": "prodcom-transcript",
|
||||||
"name": "ProdCom",
|
"name": "ProdCom",
|
||||||
"version": "1.2.1",
|
"version": "1.3.0",
|
||||||
"proddash": ">=1.1.0",
|
"proddash": ">=1.1.0",
|
||||||
"description": "Live ProdCom transcript stream",
|
"description": "Live ProdCom transcript",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
{ "kind": "local-network", "reason": "Reaching the ProdCom API on this network" }
|
{
|
||||||
|
"kind": "local-network",
|
||||||
|
"reason": "Reaching the ProdCom API on this network"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"client": "client.js",
|
"client": "client.js",
|
||||||
"server": "server.js",
|
"server": "server.js",
|
||||||
"style": "style.css",
|
"style": "style.css",
|
||||||
"minSize": { "w": 3, "h": 2 },
|
"minSize": {
|
||||||
"defaultSize": { "w": 4, "h": 4 },
|
"w": 3,
|
||||||
|
"h": 2
|
||||||
|
},
|
||||||
|
"defaultSize": {
|
||||||
|
"w": 4,
|
||||||
|
"h": 4
|
||||||
|
},
|
||||||
"configSchema": {
|
"configSchema": {
|
||||||
"prodcom": {
|
"prodcom": {
|
||||||
"type": "endpoint",
|
"type": "endpoint",
|
||||||
"label": "ProdCom server",
|
"label": "ProdCom server",
|
||||||
"default": { "host": "10.3.11.152", "port": 24480 }
|
"default": {
|
||||||
|
"host": "10.3.11.152",
|
||||||
|
"port": 24480
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
"type": "password",
|
"type": "password",
|
||||||
@@ -29,16 +41,6 @@
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"label": "Show channel icons",
|
"label": "Show channel icons",
|
||||||
"default": true
|
"default": true
|
||||||
},
|
|
||||||
"newestFirst": {
|
|
||||||
"type": "boolean",
|
|
||||||
"label": "Newest first (transcript reads bottom-up)",
|
|
||||||
"default": false
|
|
||||||
},
|
|
||||||
"showTimes": {
|
|
||||||
"type": "boolean",
|
|
||||||
"label": "Show timestamps",
|
|
||||||
"default": false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,73 +3,34 @@
|
|||||||
/**
|
/**
|
||||||
* ProdCom Transcript — server part.
|
* ProdCom Transcript — server part.
|
||||||
*
|
*
|
||||||
* One wildcard route that proxies /prodcom/* (including the SSE transcript
|
* One connection to ProdCom for the whole server, however many dashboards
|
||||||
* stream) to the ProdCom API configured in the admin page, so browsers never
|
* show the transcript. The module holds the channel list, the groups and
|
||||||
* talk to ProdCom directly (no CORS, and the API key never reaches clients).
|
* the last entries, follows ProdCom's SSE stream, and hands every browser a
|
||||||
* Ported from prodcom-listener/server.js.
|
* snapshot on connect plus each change as it happens. (It used to proxy each
|
||||||
|
* browser's stream to ProdCom one to one — N screens meant N upstream
|
||||||
|
* connections; ProdCom now sees one.)
|
||||||
|
*
|
||||||
|
* Routes (under /api/modules/prodcom-transcript):
|
||||||
|
* GET /state → { status, channels, groups, entries } (entries by date)
|
||||||
|
* GET /stream → SSE: `state` (the snapshot, first), then `entry` for each
|
||||||
|
* changed entry, `clear`, and `status` when the link changes
|
||||||
|
*
|
||||||
|
* ProdCom's API, as the reference listener speaks it:
|
||||||
|
* GET /api/v1/channels, /api/v1/groups, /api/v1/transcript?limit=N and the
|
||||||
|
* SSE /api/v1/transcript/stream, whose frames carry an entry, or a
|
||||||
|
* { type, entry | data } wrapper, in a few historical shapes (see
|
||||||
|
* extractEntries). A bearer API key is sent when one is configured.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
|
|
||||||
// Hop-by-hop headers that must not be forwarded
|
const HISTORY_LIMIT = 100; // entries asked for on (re)connect
|
||||||
const HOP_HEADERS = new Set([
|
const ENTRIES_MAX = 300; // entries kept in memory
|
||||||
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
|
const RETRY_MIN_MS = 2000;
|
||||||
'te', 'trailer', 'transfer-encoding', 'upgrade',
|
const RETRY_MAX_MS = 15000;
|
||||||
]);
|
const REQUEST_TIMEOUT_MS = 8000;
|
||||||
|
const REFRESH_LISTS_MS = 5 * 60 * 1000; // channels and groups re-read
|
||||||
/** Health shown in the admin page: outcome of the most recent proxied request. */
|
const HEARTBEAT_MS = 15000;
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The ProdCom base URL from the endpoint config. A legacy prodcomUrl (saved
|
/** The ProdCom base URL from the endpoint config. A legacy prodcomUrl (saved
|
||||||
before the endpoint field existed) can only still be present until the
|
before the endpoint field existed) can only still be present until the
|
||||||
@@ -82,23 +43,281 @@ function resolveTarget(config) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
/** Entries inside whatever ProdCom sent: a bare entry, or one wrapped in data / payload / entry / entries / transcript. */
|
||||||
init({ config }) {
|
function extractEntries(obj) {
|
||||||
health = resolveTarget(config)
|
if (!obj || typeof obj !== 'object') return [];
|
||||||
? { status: 'connecting', message: 'No traffic yet' }
|
if (Array.isArray(obj)) return obj.flatMap(extractEntries);
|
||||||
: { status: 'error', message: 'No ProdCom server configured' };
|
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 [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One entry as the tiles read it; an entry without an id is keyed by channel and time. */
|
||||||
|
function normalizeEntry(e) {
|
||||||
|
const id = e.id !== undefined && e.id !== null && e.id !== '' ? String(e.id) : `${e.channelId || ''}|${e.date || ''}`;
|
||||||
return {
|
return {
|
||||||
stop() { /* nothing persistent to tear down — streams die with their sockets */ },
|
id,
|
||||||
health: () => health,
|
channelId: e.channelId === undefined || e.channelId === null ? '' : String(e.channelId),
|
||||||
|
channelName: e.channelName ? String(e.channelName) : '',
|
||||||
|
text: String(e.text ?? ''),
|
||||||
|
date: e.date ? String(e.date) : new Date().toISOString(),
|
||||||
|
inProgress: Boolean(e.inProgress),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = null; // the running instance, for routes()
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
init({ config, log }) {
|
||||||
|
const target = resolveTarget(config);
|
||||||
|
const apiKey = String(config.apiKey || '');
|
||||||
|
const channels = [];
|
||||||
|
const groups = [];
|
||||||
|
const entries = new Map(); // id → entry, in arrival order
|
||||||
|
const streams = new Set(); // browsers' SSE responses
|
||||||
|
let status = { state: 'connecting', message: target ? 'Connecting to ProdCom…' : 'No ProdCom server configured' };
|
||||||
|
let stopped = false;
|
||||||
|
let upstream = null;
|
||||||
|
let generation = 0; // callbacks from a superseded connection are ignored
|
||||||
|
let retryTimer = null;
|
||||||
|
let retryMs = RETRY_MIN_MS;
|
||||||
|
|
||||||
|
const authHeaders = () => (apiKey ? { authorization: `Bearer ${apiKey}` } : {});
|
||||||
|
|
||||||
|
async function getJson(pathname) {
|
||||||
|
const res = await fetch(new URL(pathname, target), {
|
||||||
|
headers: { accept: 'application/json', ...authHeaders() },
|
||||||
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status} for ${pathname}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortedEntries() {
|
||||||
|
return [...entries.values()].sort((a, b) => new Date(a.date) - new Date(b.date));
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshot() {
|
||||||
|
return { status, channels, groups, entries: sortedEntries() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcast(event, data) {
|
||||||
|
if (!streams.size) return;
|
||||||
|
const frame = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||||
|
for (const res of streams) {
|
||||||
|
try {
|
||||||
|
res.write(frame);
|
||||||
|
} catch {
|
||||||
|
streams.delete(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(state, message) {
|
||||||
|
if (status.state === state && status.message === message) return;
|
||||||
|
status = { state, message };
|
||||||
|
broadcast('status', status);
|
||||||
|
}
|
||||||
|
|
||||||
|
function trim() {
|
||||||
|
while (entries.size > ENTRIES_MAX) entries.delete(entries.keys().next().value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A changed entry: kept, and told to every browser. */
|
||||||
|
function upsert(raw) {
|
||||||
|
const e = normalizeEntry(raw);
|
||||||
|
const prev = entries.get(e.id);
|
||||||
|
if (prev && prev.text === e.text && prev.inProgress === e.inProgress && prev.channelId === e.channelId) return;
|
||||||
|
entries.set(e.id, e);
|
||||||
|
trim();
|
||||||
|
broadcast('entry', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAll() {
|
||||||
|
entries.clear();
|
||||||
|
broadcast('clear', {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLists() {
|
||||||
|
const [ch, gr] = await Promise.all([
|
||||||
|
getJson('/api/v1/channels'),
|
||||||
|
getJson('/api/v1/groups').catch(() => ({ data: [] })), // groups are optional in ProdCom
|
||||||
|
]);
|
||||||
|
channels.splice(0, channels.length, ...(ch.data || []).map((c) => ({ id: String(c.id), name: String(c.name || c.id), color: c.color || '#8b98a5' })));
|
||||||
|
groups.splice(0, groups.length, ...(gr.data || []).map((g) => ({ id: String(g.id), name: String(g.name || g.id), channelIds: Array.isArray(g.channelIds) ? g.channelIds.map(String) : [] })));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHistory() {
|
||||||
|
const body = await getJson(`/api/v1/transcript?limit=${HISTORY_LIMIT}`);
|
||||||
|
for (const e of extractEntries(body).map(normalizeEntry)) entries.set(e.id, e);
|
||||||
|
trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFrame(block) {
|
||||||
|
const data = [];
|
||||||
|
for (const line of block.split('\n')) {
|
||||||
|
if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, ''));
|
||||||
|
}
|
||||||
|
if (!data.length) return;
|
||||||
|
let obj;
|
||||||
|
try {
|
||||||
|
obj = JSON.parse(data.join('\n'));
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const kind = String(obj?.type || obj?.event || '').toLowerCase();
|
||||||
|
if (kind.includes('clear')) return clearAll();
|
||||||
|
extractEntries(obj).forEach(upsert);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleRetry() {
|
||||||
|
if (stopped) return;
|
||||||
|
clearTimeout(retryTimer);
|
||||||
|
retryTimer = setTimeout(connect, retryMs);
|
||||||
|
retryMs = Math.min(RETRY_MAX_MS, retryMs * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(gen, why) {
|
||||||
|
if (stopped || gen !== generation) return;
|
||||||
|
generation += 1; // whatever else this connection reports is stale now
|
||||||
|
try { upstream?.destroy(); } catch { /* already gone */ }
|
||||||
|
upstream = null;
|
||||||
|
setStatus('error', `ProdCom unreachable (${why}) — retrying`);
|
||||||
|
log(`ProdCom ${target.origin}: ${why} — retrying in ${retryMs / 1000} s`);
|
||||||
|
scheduleRetry();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lists and history first (a fresh snapshot for the tiles), then the live stream. */
|
||||||
|
async function connect() {
|
||||||
|
if (stopped || !target) return;
|
||||||
|
const gen = ++generation;
|
||||||
|
try {
|
||||||
|
await loadLists();
|
||||||
|
await loadHistory();
|
||||||
|
} catch (err) {
|
||||||
|
if (gen !== generation || stopped) return;
|
||||||
|
return fail(gen, err.cause?.code || err.code || err.message);
|
||||||
|
}
|
||||||
|
if (gen !== generation || stopped) return;
|
||||||
|
broadcast('state', snapshot());
|
||||||
|
const url = new URL('/api/v1/transcript/stream', target);
|
||||||
|
const req = http.request({
|
||||||
|
hostname: url.hostname,
|
||||||
|
port: url.port || 80,
|
||||||
|
path: url.pathname,
|
||||||
|
method: 'GET',
|
||||||
|
headers: { accept: 'text/event-stream', ...authHeaders() },
|
||||||
|
}, (res) => {
|
||||||
|
if (gen !== generation) return void res.destroy();
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
res.resume();
|
||||||
|
return fail(gen, `HTTP ${res.statusCode} from the transcript stream`);
|
||||||
|
}
|
||||||
|
retryMs = RETRY_MIN_MS;
|
||||||
|
setStatus('ok', 'Live');
|
||||||
|
log(`following ${target.origin} — ${channels.length} channels, ${entries.size} entries`);
|
||||||
|
let buf = '';
|
||||||
|
res.setEncoding('utf8');
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
if (gen !== generation) return;
|
||||||
|
buf += chunk;
|
||||||
|
let i;
|
||||||
|
while ((i = buf.indexOf('\n\n')) >= 0) {
|
||||||
|
handleFrame(buf.slice(0, i));
|
||||||
|
buf = buf.slice(i + 2);
|
||||||
|
}
|
||||||
|
if (buf.length > 1e6) buf = ''; // a frame that never ends is dropped
|
||||||
|
});
|
||||||
|
res.on('end', () => fail(gen, 'stream ended'));
|
||||||
|
res.on('error', (err) => fail(gen, err.code || err.message));
|
||||||
|
});
|
||||||
|
req.on('error', (err) => fail(gen, err.code || err.message));
|
||||||
|
req.end();
|
||||||
|
upstream = req;
|
||||||
|
}
|
||||||
|
|
||||||
|
const listsTimer = setInterval(() => {
|
||||||
|
if (stopped || status.state !== 'ok') return;
|
||||||
|
loadLists().then(() => broadcast('state', snapshot())).catch(() => { /* the next reconnect re-reads them */ });
|
||||||
|
}, REFRESH_LISTS_MS);
|
||||||
|
listsTimer.unref?.();
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
for (const res of streams) {
|
||||||
|
try {
|
||||||
|
res.write(': ping\n\n');
|
||||||
|
} catch {
|
||||||
|
streams.delete(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, HEARTBEAT_MS);
|
||||||
|
heartbeat.unref?.();
|
||||||
|
|
||||||
|
if (target) {
|
||||||
|
log(`one connection to ProdCom at ${target.origin}, shared by every dashboard`);
|
||||||
|
connect();
|
||||||
|
} else {
|
||||||
|
log('no ProdCom server configured — set the endpoint in /admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
current = {
|
||||||
|
snapshot,
|
||||||
|
streams,
|
||||||
|
health() {
|
||||||
|
if (status.state === 'ok') return { status: 'ok', message: `Live — ${channels.length} channel${channels.length === 1 ? '' : 's'}, ${entries.size} entries` };
|
||||||
|
return { status: status.state === 'error' ? 'error' : 'connecting', message: status.message };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
stop() {
|
||||||
|
stopped = true;
|
||||||
|
generation += 1;
|
||||||
|
clearTimeout(retryTimer);
|
||||||
|
clearInterval(listsTimer);
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
try { upstream?.destroy(); } catch { /* already gone */ }
|
||||||
|
for (const res of streams) {
|
||||||
|
try { res.end(); } catch { /* gone */ }
|
||||||
|
}
|
||||||
|
streams.clear();
|
||||||
|
if (current?.streams === streams) current = null;
|
||||||
|
},
|
||||||
|
health: () => (current ? current.health() : { status: 'connecting', message: 'Restarting…' }),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
routes({ config, log }) {
|
routes() {
|
||||||
// An invalid host/URL throws here, which surfaces as a mount error in /admin.
|
const sendJson = (res, code, body) => {
|
||||||
const target = resolveTarget(config) || new URL('http://10.3.11.152:24480');
|
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||||
log(`proxying /prodcom/* → ${target.origin}`);
|
res.end(JSON.stringify(body));
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
'* /prodcom/*': (req, res) => proxy(req, res, target, config.apiKey || ''),
|
'GET /state': (req, res) => {
|
||||||
|
if (!current) return sendJson(res, 503, { error: 'ProdCom module is restarting — try again' });
|
||||||
|
sendJson(res, 200, current.snapshot());
|
||||||
|
},
|
||||||
|
'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');
|
||||||
|
if (!current) {
|
||||||
|
res.write(`event: state\ndata: ${JSON.stringify({ status: { state: 'connecting', message: 'ProdCom module is restarting…' }, channels: [], groups: [], entries: [] })}\n\n`);
|
||||||
|
return void res.end();
|
||||||
|
}
|
||||||
|
res.write(`event: state\ndata: ${JSON.stringify(current.snapshot())}\n\n`);
|
||||||
|
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
|
||||||
|
current.streams.add(res);
|
||||||
|
req.on('close', () => current && current.streams.delete(res));
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,34 +1,73 @@
|
|||||||
{
|
{
|
||||||
"id": "propresenter-timers",
|
"id": "propresenter-timers",
|
||||||
"name": "Timers",
|
"name": "Timers",
|
||||||
"version": "1.2.1",
|
"version": "1.3.0",
|
||||||
"proddash": ">=1.2.0",
|
"proddash": ">=1.4.0",
|
||||||
"description": "Live ProPresenter timers and LTC timecode",
|
"description": "Timers, Clocks, and LTC From configured Modules",
|
||||||
|
"provides": [
|
||||||
|
"timers"
|
||||||
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
{ "kind": "microphone", "reason": "Decoding SMPTE LTC timecode from an audio input on this machine" },
|
{
|
||||||
{ "kind": "local-network", "reason": "Reaching ProPresenter on this network" }
|
"kind": "microphone",
|
||||||
|
"reason": "Decoding SMPTE LTC timecode from an audio input on this machine"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "local-network",
|
||||||
|
"reason": "Reaching ProPresenter on this network"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"client": "client.js",
|
"client": "client.js",
|
||||||
"server": "server.js",
|
"server": "server.js",
|
||||||
"style": "style.css",
|
"style": "style.css",
|
||||||
"minSize": { "w": 2, "h": 1 },
|
"minSize": {
|
||||||
"defaultSize": { "w": 4, "h": 2 },
|
"w": 2,
|
||||||
|
"h": 1
|
||||||
|
},
|
||||||
|
"defaultSize": {
|
||||||
|
"w": 4,
|
||||||
|
"h": 3
|
||||||
|
},
|
||||||
|
"configGroups": {
|
||||||
|
"ProPresenter": {
|
||||||
|
"toggle": "ppEnabled",
|
||||||
|
"help": "The timers configured in ProPresenter, read over its network API. Enable the network in ProPresenter's Preferences › Network and enter the host and port shown there. Switch it off on a day ProPresenter isn't running and nothing is polled."
|
||||||
|
},
|
||||||
|
"LTC Timecode": {
|
||||||
|
"toggle": "ltcEnabled",
|
||||||
|
"help": "SMPTE LTC decoded from an audio input on this machine, shown as a running timecode. Pick the device and the channel the timecode arrives on; macOS has to grant ProdDash the microphone (see the launcher's Permissions)."
|
||||||
|
},
|
||||||
|
"PCO Plan": {
|
||||||
|
"toggle": "pcoEnabled",
|
||||||
|
"help": "Timers the PCO Plan module offers — service elapsed, current item, countdown to service — appear here under a PCO Plan heading when that module is installed and configured. Nothing to set up on this side."
|
||||||
|
}
|
||||||
|
},
|
||||||
"configSchema": {
|
"configSchema": {
|
||||||
|
"ppEnabled": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "ProPresenter timers",
|
||||||
|
"group": "ProPresenter",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
"propresenter": {
|
"propresenter": {
|
||||||
"type": "endpoint",
|
"type": "endpoint",
|
||||||
"label": "ProPresenter",
|
"label": "ProPresenter",
|
||||||
"group": "ProPresenter",
|
"group": "ProPresenter",
|
||||||
"default": { "host": "", "port": 1025 }
|
"default": {
|
||||||
|
"host": "",
|
||||||
|
"port": 1025
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"password": {
|
"password": {
|
||||||
"type": "password",
|
"type": "password",
|
||||||
"label": "API password (only if set in ProPresenter)",
|
"label": "API password",
|
||||||
"group": "ProPresenter",
|
"group": "ProPresenter",
|
||||||
"default": ""
|
"default": "",
|
||||||
|
"help": "Only if a password is set in ProPresenter's network preferences."
|
||||||
},
|
},
|
||||||
"ltcEnabled": {
|
"ltcEnabled": {
|
||||||
"type": "switch",
|
"type": "switch",
|
||||||
"label": "LTC listener — decode timecode from an audio input on this machine",
|
"label": "LTC timecode",
|
||||||
"group": "LTC Timecode",
|
"group": "LTC Timecode",
|
||||||
"default": false
|
"default": false
|
||||||
},
|
},
|
||||||
@@ -42,10 +81,17 @@
|
|||||||
},
|
},
|
||||||
"ltcChannel": {
|
"ltcChannel": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"label": "Channel on that device (1-based)",
|
"label": "Channel",
|
||||||
"group": "LTC Timecode",
|
"group": "LTC Timecode",
|
||||||
"showWhen": "ltcEnabled",
|
"showWhen": "ltcEnabled",
|
||||||
"default": 1
|
"default": 1,
|
||||||
|
"help": "The channel on that device carrying the timecode, counted from 1."
|
||||||
|
},
|
||||||
|
"pcoEnabled": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "PCO Plan timers",
|
||||||
|
"group": "PCO Plan",
|
||||||
|
"default": true
|
||||||
},
|
},
|
||||||
"timerFont": {
|
"timerFont": {
|
||||||
"type": "select",
|
"type": "select",
|
||||||
@@ -53,9 +99,131 @@
|
|||||||
"group": "Display",
|
"group": "Display",
|
||||||
"default": "default",
|
"default": "default",
|
||||||
"options": [
|
"options": [
|
||||||
{ "value": "default", "label": "Default (system)" },
|
{
|
||||||
{ "value": "monospace", "label": "Monospace — steadier changing digits" }
|
"value": "default",
|
||||||
|
"label": "Default (system)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "monospace",
|
||||||
|
"label": "Monospace — steadier changing digits"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"instanceGroups": {
|
||||||
|
"Show": {
|
||||||
|
"collapsed": false,
|
||||||
|
"columns": 2
|
||||||
|
},
|
||||||
|
"Clock": {
|
||||||
|
"collapsed": false,
|
||||||
|
"columns": 2
|
||||||
|
},
|
||||||
|
"Colours": {
|
||||||
|
"collapsed": true,
|
||||||
|
"columns": 2,
|
||||||
|
"help": "The time's colour per state. Left at the default, a colour follows the dashboard theme; one you change here wins over it."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"instanceSchema": {
|
||||||
|
"showNames": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "Names",
|
||||||
|
"default": true,
|
||||||
|
"group": "Show"
|
||||||
|
},
|
||||||
|
"showStatus": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "Status lines",
|
||||||
|
"default": true,
|
||||||
|
"group": "Show"
|
||||||
|
},
|
||||||
|
"showDetail": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "Detail lines",
|
||||||
|
"default": true,
|
||||||
|
"group": "Show"
|
||||||
|
},
|
||||||
|
"showSeconds": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "Seconds",
|
||||||
|
"default": true,
|
||||||
|
"group": "Show"
|
||||||
|
},
|
||||||
|
"leadingZeros": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "Leading zeros",
|
||||||
|
"default": false,
|
||||||
|
"group": "Show"
|
||||||
|
},
|
||||||
|
"groupBySource": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "Group by source",
|
||||||
|
"default": false,
|
||||||
|
"group": "Show"
|
||||||
|
},
|
||||||
|
"showHeadings": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "Source headings",
|
||||||
|
"default": true,
|
||||||
|
"group": "Show",
|
||||||
|
"showWhen": "groupBySource"
|
||||||
|
},
|
||||||
|
"clock24h": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "24-hour",
|
||||||
|
"default": true,
|
||||||
|
"group": "Clock"
|
||||||
|
},
|
||||||
|
"clockSeconds": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "Seconds",
|
||||||
|
"default": true,
|
||||||
|
"group": "Clock"
|
||||||
|
},
|
||||||
|
"clockDate": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "Date",
|
||||||
|
"default": false,
|
||||||
|
"group": "Clock"
|
||||||
|
},
|
||||||
|
"dateShortDay": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "Short day",
|
||||||
|
"default": false,
|
||||||
|
"group": "Clock",
|
||||||
|
"showWhen": "clockDate"
|
||||||
|
},
|
||||||
|
"dateShortMonth": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "Short month",
|
||||||
|
"default": false,
|
||||||
|
"group": "Clock",
|
||||||
|
"showWhen": "clockDate"
|
||||||
|
},
|
||||||
|
"runningColor": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Running",
|
||||||
|
"default": "#2ee59a",
|
||||||
|
"group": "Colours"
|
||||||
|
},
|
||||||
|
"warningColor": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Warning",
|
||||||
|
"default": "#f0b429",
|
||||||
|
"group": "Colours"
|
||||||
|
},
|
||||||
|
"overrunColor": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Overrun",
|
||||||
|
"default": "#f4433c",
|
||||||
|
"group": "Colours"
|
||||||
|
},
|
||||||
|
"idleColor": {
|
||||||
|
"type": "color",
|
||||||
|
"label": "Idle",
|
||||||
|
"default": "#8b98a5",
|
||||||
|
"group": "Colours"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1009
-244
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,16 @@
|
|||||||
/* ProPresenter Timers module — scoped to its tiles, shell variables only.
|
/* Timers module — scoped to its tiles, shell variables only.
|
||||||
The API exposes no per-timer color, so a card's color IS its state:
|
A card's colour IS its state: running / warning / overrun / idle. The four
|
||||||
accent = running, muted = stopped, warn = complete, danger = overrun.
|
tones are custom properties on the tile root: they default to the shell's
|
||||||
Overrun must be unmistakable at a distance — danger fill + pulse. */
|
palette (so a dashboard theme reaches them) and the tile's own colour
|
||||||
|
settings, when changed, are set inline on .tm-wrap and win — the
|
||||||
|
documented mechanism. Overrun must be unmistakable across the room:
|
||||||
|
danger fill + pulse. */
|
||||||
|
|
||||||
[data-module="propresenter-timers"] .tm-wrap {
|
[data-module="propresenter-timers"] .tm-wrap {
|
||||||
--tm-time-size: 32px;
|
--tm-ok: var(--accent);
|
||||||
--tm-ltc-size: 26px;
|
--tm-warn: var(--warn);
|
||||||
|
--tm-danger: var(--danger);
|
||||||
|
--tm-muted: var(--muted);
|
||||||
--tm-meta-size: 11px;
|
--tm-meta-size: 11px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -15,7 +20,7 @@
|
|||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── degraded-state banner (feed lost / ProPresenter unreachable) ── */
|
/* ── degraded-state banner (the ProdDash feed itself is down) ── */
|
||||||
|
|
||||||
[data-module="propresenter-timers"] .tm-banner {
|
[data-module="propresenter-timers"] .tm-banner {
|
||||||
flex: none;
|
flex: none;
|
||||||
@@ -30,12 +35,12 @@
|
|||||||
}
|
}
|
||||||
[data-module="propresenter-timers"] .tm-banner[hidden] { display: none; }
|
[data-module="propresenter-timers"] .tm-banner[hidden] { display: none; }
|
||||||
|
|
||||||
/* Values still on screen while the feed is down are stale — say so visually.
|
/* Values still on screen while their source is down are stale — say so
|
||||||
Per-card, because the reader-fed LTC card is not stale when only
|
visually, per group: the LTC card keeps ticking when only ProPresenter
|
||||||
ProPresenter is unreachable; it dims only when the ProdDash feed itself
|
is unreachable. When the ProdDash feed is down everything is old. */
|
||||||
is down (is-feed-down) and everything on screen really is old. */
|
[data-module="propresenter-timers"] .tm-group.is-stale .tm-card,
|
||||||
[data-module="propresenter-timers"] .tm-wrap.is-stale .tm-card { opacity: 0.45; }
|
[data-module="propresenter-timers"] .tm-card.is-stale,
|
||||||
[data-module="propresenter-timers"] .tm-wrap.is-stale:not(.is-feed-down) .tm-card-ltc.src-reader { opacity: 1; }
|
[data-module="propresenter-timers"] .tm-wrap.is-feed-down .tm-card { opacity: 0.45; }
|
||||||
|
|
||||||
[data-module="propresenter-timers"] .tm-hint {
|
[data-module="propresenter-timers"] .tm-hint {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -49,7 +54,69 @@
|
|||||||
}
|
}
|
||||||
[data-module="propresenter-timers"] .tm-hint[hidden] { display: none; }
|
[data-module="propresenter-timers"] .tm-hint[hidden] { display: none; }
|
||||||
|
|
||||||
/* ── card grid (column count set by client.js fit()) ── */
|
/* ── source groups: heading + card grid (column count set by fit()) ── */
|
||||||
|
|
||||||
|
[data-module="propresenter-timers"] .tm-groups {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--tm-gap, 8px);
|
||||||
|
padding: var(--tm-pad, 10px);
|
||||||
|
}
|
||||||
|
[data-module="propresenter-timers"] .tm-groups[hidden] { display: none; }
|
||||||
|
|
||||||
|
[data-module="propresenter-timers"] .tm-group {
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="propresenter-timers"] .tm-head {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0 2px 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 16px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
[data-module="propresenter-timers"] .tm-head[hidden] { display: none; }
|
||||||
|
|
||||||
|
[data-module="propresenter-timers"] .tm-title {
|
||||||
|
flex: none;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The source's own word while it isn't OK ("Unreachable at …"). */
|
||||||
|
[data-module="propresenter-timers"] .tm-note {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
[data-module="propresenter-timers"] .tm-group.is-down .tm-note { color: var(--tm-warn); }
|
||||||
|
[data-module="propresenter-timers"] .tm-group.is-stale .tm-note { color: var(--tm-danger); }
|
||||||
|
|
||||||
|
/* Headings off: a source in trouble, or with nothing to show, still says so. */
|
||||||
|
[data-module="propresenter-timers"] .tm-empty {
|
||||||
|
flex: none;
|
||||||
|
padding: 2px 4px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
[data-module="propresenter-timers"] .tm-empty[hidden] { display: none; }
|
||||||
|
[data-module="propresenter-timers"] .tm-group.is-down .tm-empty { color: var(--tm-warn); }
|
||||||
|
|
||||||
[data-module="propresenter-timers"] .tm-grid {
|
[data-module="propresenter-timers"] .tm-grid {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -57,16 +124,34 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-auto-rows: 1fr;
|
grid-auto-rows: 1fr;
|
||||||
gap: var(--tm-gap, 8px);
|
gap: var(--tm-gap, 8px);
|
||||||
padding: var(--tm-pad, 10px);
|
|
||||||
}
|
}
|
||||||
[data-module="propresenter-timers"] .tm-grid[hidden] { display: none; }
|
[data-module="propresenter-timers"] .tm-grid:empty { display: none; }
|
||||||
|
|
||||||
/* Compact cards (short tiles): the state line goes, its color stays.
|
/* Grouping off: one grid for every timer, filled row by row. A wrapping
|
||||||
The one exception is the LTC card with no timecode API behind it —
|
flex row instead of a CSS grid, so the last row's cards widen to use the
|
||||||
that message must never be hidden. */
|
whole width — nothing is left empty. Rows share the height equally. */
|
||||||
[data-module="propresenter-timers"] .tm-compact .tm-card .tm-state { display: none; }
|
[data-module="propresenter-timers"] .tm-grid.is-flow {
|
||||||
[data-module="propresenter-timers"] .tm-compact .tm-card-ltc.is-ltc-unavailable .tm-state { display: block; }
|
display: flex;
|
||||||
[data-module="propresenter-timers"] .tm-compact .tm-card.is-missing .tm-state { display: block; }
|
flex-wrap: wrap;
|
||||||
|
align-content: stretch;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
[data-module="propresenter-timers"] .tm-grid.is-flow .tm-card {
|
||||||
|
flex: 1 1 calc((100% - var(--tm-gap, 8px) * (var(--tm-cols, 1) - 1)) / var(--tm-cols, 1));
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
[data-module="propresenter-timers"] .tm-empty[data-tone="warn"] { color: var(--tm-warn); }
|
||||||
|
|
||||||
|
/* Compact cards (short tiles) and "Status lines" off: the lines under the
|
||||||
|
digits go, the colour stays. A note that explains a missing value (no
|
||||||
|
LTC input, a solo timer no longer offered) must never be hidden. */
|
||||||
|
[data-module="propresenter-timers"] .tm-compact .tm-status:not(.is-note),
|
||||||
|
[data-module="propresenter-timers"] .tm-compact .tm-detail,
|
||||||
|
[data-module="propresenter-timers"] .tm-nostatus .tm-status:not(.is-note),
|
||||||
|
[data-module="propresenter-timers"] .tm-nodetail .tm-detail { display: none; }
|
||||||
|
/* "Names" off hides every title; the clock never carries one — its digits say what it is */
|
||||||
|
[data-module="propresenter-timers"] .tm-nonames .tm-name,
|
||||||
|
[data-module="propresenter-timers"] .tm-card-clock .tm-name { display: none; }
|
||||||
|
|
||||||
/* ── one timer card ── */
|
/* ── one timer card ── */
|
||||||
|
|
||||||
@@ -98,13 +183,20 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The label is its own box so a long name ends in an ellipsis (a flex
|
||||||
|
container's bare text won't). */
|
||||||
|
[data-module="propresenter-timers"] .tm-label {
|
||||||
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-module="propresenter-timers"] .tm-time {
|
[data-module="propresenter-timers"] .tm-time {
|
||||||
flex: none;
|
flex: none;
|
||||||
font-size: var(--tm-time-size);
|
font-size: 32px; /* fit() sets the real size per card */
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
@@ -113,17 +205,17 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Admin "Timer font: Monospace" — steadier digits as the numbers change.
|
/* Admin "Timer font: Monospace" — steadier digits as the numbers change. */
|
||||||
Applies to timer and LTC values alike (both are .tm-time). */
|
|
||||||
[data-module="propresenter-timers"] .tm-mono .tm-time {
|
[data-module="propresenter-timers"] .tm-mono .tm-time {
|
||||||
font-family: ui-monospace, "SF Mono", "Menlo", "Cascadia Mono", "Consolas", monospace;
|
font-family: ui-monospace, "SF Mono", "Menlo", "Cascadia Mono", "Consolas", monospace;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-module="propresenter-timers"] .tm-state {
|
[data-module="propresenter-timers"] .tm-status,
|
||||||
|
[data-module="propresenter-timers"] .tm-detail {
|
||||||
flex: none;
|
flex: none;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
font-size: var(--tm-meta-size);
|
font-size: calc(var(--tm-meta-size) * 0.8); /* smaller than the title; fit() budgets the same share */
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -132,102 +224,110 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
[data-module="propresenter-timers"] .tm-status[hidden],
|
||||||
|
[data-module="propresenter-timers"] .tm-detail[hidden] { display: none; }
|
||||||
|
|
||||||
/* ── state looks ── */
|
[data-module="propresenter-timers"] .tm-detail {
|
||||||
|
font-weight: 600;
|
||||||
[data-module="propresenter-timers"] .tm-card.is-running {
|
letter-spacing: 0.04em;
|
||||||
border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
|
text-transform: none;
|
||||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
opacity: 0.85;
|
||||||
}
|
|
||||||
[data-module="propresenter-timers"] .tm-card.is-running .tm-time { color: var(--accent); }
|
|
||||||
[data-module="propresenter-timers"] .tm-card.is-running .tm-state { color: var(--accent); }
|
|
||||||
|
|
||||||
[data-module="propresenter-timers"] .tm-card.is-stopped .tm-time { color: var(--muted); }
|
|
||||||
|
|
||||||
[data-module="propresenter-timers"] .tm-card.is-complete {
|
|
||||||
border-color: color-mix(in srgb, var(--warn) 55%, var(--border));
|
|
||||||
background: color-mix(in srgb, var(--warn) 10%, transparent);
|
|
||||||
}
|
|
||||||
[data-module="propresenter-timers"] .tm-card.is-complete .tm-time { color: var(--warn); }
|
|
||||||
[data-module="propresenter-timers"] .tm-card.is-complete .tm-state { color: var(--warn); }
|
|
||||||
|
|
||||||
/* A solo tile whose timer was deleted in ProPresenter. */
|
|
||||||
[data-module="propresenter-timers"] .tm-card.is-missing { border-style: dashed; }
|
|
||||||
[data-module="propresenter-timers"] .tm-card.is-missing .tm-time { color: var(--muted); }
|
|
||||||
[data-module="propresenter-timers"] .tm-card.is-missing .tm-state { color: var(--warn); }
|
|
||||||
|
|
||||||
/* Overrun: readable across the room even on a wall display. */
|
|
||||||
[data-module="propresenter-timers"] .tm-card.is-overrun {
|
|
||||||
border-color: var(--danger);
|
|
||||||
background: color-mix(in srgb, var(--danger) 22%, transparent);
|
|
||||||
animation: tm-overrun-pulse 1.2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
[data-module="propresenter-timers"] .tm-card.is-overrun .tm-time { color: var(--danger); }
|
|
||||||
[data-module="propresenter-timers"] .tm-card.is-overrun .tm-state {
|
|
||||||
color: var(--danger);
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes tm-overrun-pulse {
|
/* A provider's status line carries its own tone ("ahead 1:20" in green). */
|
||||||
50% { background: color-mix(in srgb, var(--danger) 38%, transparent); }
|
[data-module="propresenter-timers"] .tm-status[data-tone="ok"] { color: var(--tm-ok); }
|
||||||
|
[data-module="propresenter-timers"] .tm-status[data-tone="warn"] { color: var(--tm-warn); }
|
||||||
|
[data-module="propresenter-timers"] .tm-status[data-tone="danger"] { color: var(--tm-danger); }
|
||||||
|
[data-module="propresenter-timers"] .tm-status[data-tone="muted"] { color: var(--tm-muted); }
|
||||||
|
|
||||||
|
/* The clock's date: one line when it fits, else the day on its own line
|
||||||
|
(renderDate() sizes it to the longer half). */
|
||||||
|
[data-module="propresenter-timers"] .tm-status.is-date {
|
||||||
|
white-space: normal;
|
||||||
|
overflow: visible;
|
||||||
|
text-overflow: clip;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.15;
|
||||||
}
|
}
|
||||||
|
[data-module="propresenter-timers"] .tm-status.is-date span { white-space: nowrap; }
|
||||||
|
[data-module="propresenter-timers"] .tm-status.is-date.is-wrap span { display: block; }
|
||||||
|
|
||||||
/* ── the LTC card: visually its own thing ── */
|
/* Explanatory notes read as sentences, not badges. */
|
||||||
|
[data-module="propresenter-timers"] .tm-status.is-note {
|
||||||
[data-module="propresenter-timers"] .tm-card-ltc {
|
|
||||||
border-style: dashed;
|
|
||||||
}
|
|
||||||
[data-module="propresenter-timers"] .tm-card-ltc .tm-time {
|
|
||||||
font-size: var(--tm-ltc-size);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-module="propresenter-timers"] .tm-ltc-badge {
|
|
||||||
flex: none;
|
|
||||||
padding: 0.15em 0.55em;
|
|
||||||
border-radius: 5px;
|
|
||||||
background: color-mix(in srgb, var(--accent) 24%, transparent);
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 0.95em;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-receiving {
|
|
||||||
border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
|
|
||||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
|
||||||
}
|
|
||||||
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-receiving .tm-time { color: var(--accent); }
|
|
||||||
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-receiving .tm-state { color: var(--accent); }
|
|
||||||
|
|
||||||
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-nosignal {
|
|
||||||
border-color: color-mix(in srgb, var(--warn) 55%, var(--border));
|
|
||||||
}
|
|
||||||
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-nosignal .tm-time { color: var(--muted); }
|
|
||||||
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-nosignal .tm-state {
|
|
||||||
color: var(--warn);
|
|
||||||
animation: tm-nosignal-blink 1.6s steps(2) infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes tm-nosignal-blink { 50% { opacity: 0.35; } }
|
|
||||||
|
|
||||||
/* LTC ceased mid-show: hold the last frame, quietly — no alarm colors,
|
|
||||||
stopping the clock is usually somebody doing their job. */
|
|
||||||
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-stopped .tm-time { color: var(--muted); }
|
|
||||||
|
|
||||||
/* No timecode API on this ProPresenter: say so, quietly. */
|
|
||||||
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-unavailable .tm-time { color: var(--muted); }
|
|
||||||
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-unavailable .tm-state {
|
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── state looks (feedbacks) ── */
|
||||||
|
|
||||||
|
[data-module="propresenter-timers"] .tm-card.is-ok {
|
||||||
|
border-color: color-mix(in srgb, var(--tm-ok) 55%, var(--border));
|
||||||
|
background: color-mix(in srgb, var(--tm-ok) 10%, transparent);
|
||||||
|
}
|
||||||
|
[data-module="propresenter-timers"] .tm-card.is-ok .tm-time { color: var(--tm-ok); }
|
||||||
|
|
||||||
|
[data-module="propresenter-timers"] .tm-card.is-warn {
|
||||||
|
border-color: color-mix(in srgb, var(--tm-warn) 55%, var(--border));
|
||||||
|
background: color-mix(in srgb, var(--tm-warn) 10%, transparent);
|
||||||
|
}
|
||||||
|
[data-module="propresenter-timers"] .tm-card.is-warn .tm-time { color: var(--tm-warn); }
|
||||||
|
|
||||||
|
[data-module="propresenter-timers"] .tm-card.is-muted .tm-time { color: var(--tm-muted); }
|
||||||
|
|
||||||
|
/* The wall clock is information, not a state — plain text colour. */
|
||||||
|
[data-module="propresenter-timers"] .tm-card.is-plain .tm-time { color: var(--text); }
|
||||||
|
|
||||||
|
/* Overrun: readable across the room even on a wall display. */
|
||||||
|
[data-module="propresenter-timers"] .tm-card.is-danger {
|
||||||
|
border-color: var(--tm-danger);
|
||||||
|
background: color-mix(in srgb, var(--tm-danger) 22%, transparent);
|
||||||
|
}
|
||||||
|
[data-module="propresenter-timers"] .tm-card.is-danger .tm-time { color: var(--tm-danger); }
|
||||||
|
[data-module="propresenter-timers"] .tm-card.is-overrun {
|
||||||
|
animation: tm-overrun-pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
[data-module="propresenter-timers"] .tm-card.is-overrun .tm-status { font-weight: 800; }
|
||||||
|
|
||||||
|
@keyframes tm-overrun-pulse {
|
||||||
|
50% { background: color-mix(in srgb, var(--tm-danger) 38%, transparent); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A solo tile whose timer is no longer offered. */
|
||||||
|
[data-module="propresenter-timers"] .tm-card.is-missing { border-style: dashed; }
|
||||||
|
|
||||||
|
/* ── the LTC card: visually its own thing ── */
|
||||||
|
|
||||||
|
[data-module="propresenter-timers"] .tm-card-ltc { border-style: dashed; }
|
||||||
|
|
||||||
|
[data-module="propresenter-timers"] .tm-ltc-badge {
|
||||||
|
flex: none;
|
||||||
|
padding: 0.15em 0.55em;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: color-mix(in srgb, var(--tm-ok) 24%, transparent);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.95em;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* No signal: muted digits, a blinking warning line. */
|
||||||
|
[data-module="propresenter-timers"] .tm-card.is-nosignal {
|
||||||
|
border-color: color-mix(in srgb, var(--tm-warn) 55%, var(--border));
|
||||||
|
}
|
||||||
|
[data-module="propresenter-timers"] .tm-card.is-nosignal .tm-status {
|
||||||
|
animation: tm-nosignal-blink 1.6s steps(2) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tm-nosignal-blink { 50% { opacity: 0.35; } }
|
||||||
|
|
||||||
/* ── the "Timers ▾" menu (container styled/positioned by the shell) ── */
|
/* ── the "Timers ▾" menu (container styled/positioned by the shell) ── */
|
||||||
|
|
||||||
[data-module="propresenter-timers"] .tm-menu-item {
|
[data-module="propresenter-timers"] .tm-menu-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 6px 9px;
|
padding: 6px 9px;
|
||||||
background: none;
|
background: none;
|
||||||
@@ -241,16 +341,41 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
[data-module="propresenter-timers"] .tm-menu-item:hover { background: var(--border); }
|
[data-module="propresenter-timers"] .tm-menu-item:hover { background: var(--border); }
|
||||||
|
|
||||||
[data-module="propresenter-timers"] .tm-menu-item .check {
|
|
||||||
width: 14px;
|
|
||||||
flex: none;
|
|
||||||
color: var(--accent);
|
|
||||||
font-weight: 700;
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
[data-module="propresenter-timers"] .tm-menu-item.on .check { visibility: visible; }
|
|
||||||
[data-module="propresenter-timers"] .tm-menu-item .name { font-weight: 600; }
|
[data-module="propresenter-timers"] .tm-menu-item .name { font-weight: 600; }
|
||||||
|
[data-module="propresenter-timers"] .tm-menu-item:not(.on) .name { color: var(--muted); }
|
||||||
|
|
||||||
|
/* A small switch: off = hollow pill, on = filled with the accent. (The menu
|
||||||
|
hangs off the tile's title bar, outside .tm-wrap, so the tile's own colour
|
||||||
|
properties don't reach it — the shell's palette does.) */
|
||||||
|
[data-module="propresenter-timers"] .tm-switch {
|
||||||
|
flex: none;
|
||||||
|
position: relative;
|
||||||
|
width: 26px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 7px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: color-mix(in srgb, var(--muted) 25%, transparent);
|
||||||
|
transition: background 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
[data-module="propresenter-timers"] .tm-switch::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 1px;
|
||||||
|
left: 1px;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text);
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
[data-module="propresenter-timers"] .tm-menu-item.on .tm-switch {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
[data-module="propresenter-timers"] .tm-menu-item.on .tm-switch::after {
|
||||||
|
transform: translateX(12px);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
[data-module="propresenter-timers"] .tm-menu-note {
|
[data-module="propresenter-timers"] .tm-menu-note {
|
||||||
padding: 6px 9px;
|
padding: 6px 9px;
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2013 Cal Henderson
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,129 @@
|
|||||||
|
{
|
||||||
|
"id": "slack",
|
||||||
|
"name": "Slack",
|
||||||
|
"version": "1.2.0",
|
||||||
|
"proddash": ">=1.4.0",
|
||||||
|
"description": "Live transcripts of this location's Slack channels, plus PIN-protected sending as the location",
|
||||||
|
"client": "client.js",
|
||||||
|
"server": "server.js",
|
||||||
|
"style": "style.css",
|
||||||
|
"minSize": {
|
||||||
|
"w": 2,
|
||||||
|
"h": 2
|
||||||
|
},
|
||||||
|
"defaultSize": {
|
||||||
|
"w": 4,
|
||||||
|
"h": 4
|
||||||
|
},
|
||||||
|
"configGroups": {
|
||||||
|
"Slack account": {
|
||||||
|
"collapsed": "whenSet",
|
||||||
|
"help": "Create a Slack app in the workspace (api.slack.com/apps → Create New App → From scratch) and, under OAuth & Permissions → User Token Scopes, add channels:history, channels:read, groups:history, groups:read, chat:write, users:read, reactions:write (emoji reactions) and emoji:read (the workspace's custom emoji). Sign in to Slack as this location's account — the one named for the campus — open the app's OAuth & Permissions page and press Install to Workspace (or Reinstall to Workspace), then copy the User OAuth Token (xoxp-…) it shows and paste it here: messages from this machine are then literally from the location. A bot token (xoxb-…) works too; it posts as the app, with \"Send as\" naming the location."
|
||||||
|
},
|
||||||
|
"Channels": {
|
||||||
|
"help": "Every channel picked here becomes its own transcript tile in the + picker, titled with the channel's name. The Send message and Quick replies tiles post to whichever transcript was clicked last — its bottom edge lights up in the accent colour — and to the first channel here until one is clicked. Only channels the account has joined are listed; join one in Slack first. Each channel is read on its own schedule, all of them together kept inside Slack's rate limit."
|
||||||
|
},
|
||||||
|
"Sending": {
|
||||||
|
"toggle": "sendEnabled",
|
||||||
|
"help": "Messages and emoji reactions leave this machine only after the PIN is entered — on a Send or Quick replies tile, or in the reaction menu a right-click on a transcript message opens. One PIN unlocks every Slack tile on that dashboard page until Auto-lock minutes pass without a send; a reload asks again. Sending stays refused until a 4–8 digit PIN is set here. A message goes to the channel of the transcript clicked last. Quick emojis are sent with one press from the Send message tile and offered first when reacting. Quick replies are one per line, Label | Message, with an optional | #channel-name to bind that one to a channel: a bound reply always goes there, whichever transcript is selected."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"configSchema": {
|
||||||
|
"token": {
|
||||||
|
"type": "password",
|
||||||
|
"label": "Token",
|
||||||
|
"default": "",
|
||||||
|
"group": "Slack account",
|
||||||
|
"help": "The location account's User OAuth Token (xoxp-…, recommended) or a Bot User OAuth Token (xoxb-…)."
|
||||||
|
},
|
||||||
|
"sendAs": {
|
||||||
|
"type": "string",
|
||||||
|
"label": "Send as",
|
||||||
|
"default": "",
|
||||||
|
"group": "Slack account",
|
||||||
|
"help": "Only used with a bot token — a user token always sends as its own account; needs the chat:write.customize scope."
|
||||||
|
},
|
||||||
|
"channels": {
|
||||||
|
"type": "multiselect",
|
||||||
|
"label": "Channels",
|
||||||
|
"default": [],
|
||||||
|
"group": "Channels",
|
||||||
|
"optionsRoute": "/channels",
|
||||||
|
"help": "One transcript tile per channel; the first one is where sends go until a transcript is clicked. Only channels the account has joined are listed — join one in Slack first."
|
||||||
|
},
|
||||||
|
"pollSeconds": {
|
||||||
|
"type": "number",
|
||||||
|
"label": "Refresh interval (s)",
|
||||||
|
"default": 2,
|
||||||
|
"group": "Transcript",
|
||||||
|
"help": "How often each channel is re-read (1 s at the fastest). With several channels the interval stretches so the module stays inside Slack's rate limit — at least 1.5 s × the number of channels; the health line shows the interval in use."
|
||||||
|
},
|
||||||
|
"historyCount": {
|
||||||
|
"type": "number",
|
||||||
|
"label": "Messages kept",
|
||||||
|
"default": 40,
|
||||||
|
"group": "Transcript"
|
||||||
|
},
|
||||||
|
"includeReplies": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "Thread replies",
|
||||||
|
"default": false,
|
||||||
|
"group": "Transcript"
|
||||||
|
},
|
||||||
|
"sendEnabled": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "Sending",
|
||||||
|
"default": false,
|
||||||
|
"group": "Sending"
|
||||||
|
},
|
||||||
|
"pin": {
|
||||||
|
"type": "password",
|
||||||
|
"label": "PIN",
|
||||||
|
"default": "",
|
||||||
|
"group": "Sending",
|
||||||
|
"help": "4–8 digits; the pad unlocks by itself once they are all in."
|
||||||
|
},
|
||||||
|
"pinIdleMinutes": {
|
||||||
|
"type": "number",
|
||||||
|
"label": "Auto-lock (min)",
|
||||||
|
"default": 10,
|
||||||
|
"group": "Sending"
|
||||||
|
},
|
||||||
|
"quickReplies": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Quick replies",
|
||||||
|
"default": "",
|
||||||
|
"group": "Sending",
|
||||||
|
"help": "One per line: Label | Message. Add | #channel-name to bind one to a channel — it then ignores which transcript is selected."
|
||||||
|
},
|
||||||
|
"quickEmojis": {
|
||||||
|
"type": "string",
|
||||||
|
"label": "Quick emojis",
|
||||||
|
"default": "👍 🙏 ✅ 👀 🎉",
|
||||||
|
"group": "Sending",
|
||||||
|
"help": "Emoji or :codes: separated by spaces, eight at most. One press sends one from the Send message tile; they also head the reaction menu."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"instanceSchema": {
|
||||||
|
"showAvatars": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "Avatars",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"showTimes": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "Timestamps",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"showNames": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "Names",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"showSystem": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "Channel notices",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,677 @@
|
|||||||
|
/* Slack module — scoped to its tiles, shell variables only.
|
||||||
|
--sl-size (per tile, from the header's A− / A+) drives every font size.
|
||||||
|
Transcript rows follow the ProdCom transcript's layout: a square picture,
|
||||||
|
then a name line with the time, the text, the reactions. */
|
||||||
|
|
||||||
|
[data-module="slack"] .sl-wrap {
|
||||||
|
--sl-size: 14px;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
container-type: size;
|
||||||
|
font-size: var(--sl-size);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-wrap:focus { outline: none; }
|
||||||
|
[data-module="slack"] [hidden] { display: none !important; }
|
||||||
|
|
||||||
|
/* ── degraded banner (transcript) ── */
|
||||||
|
|
||||||
|
[data-module="slack"] .sl-banner {
|
||||||
|
flex: none;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: var(--warn);
|
||||||
|
background: color-mix(in srgb, var(--warn) 12%, transparent);
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--warn) 35%, var(--border));
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-banner.is-error {
|
||||||
|
color: var(--danger);
|
||||||
|
background: color-mix(in srgb, var(--danger) 12%, transparent);
|
||||||
|
border-bottom-color: color-mix(in srgb, var(--danger) 35%, var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── the send target ── */
|
||||||
|
|
||||||
|
/* A click anywhere in a transcript's body makes its channel the one Send
|
||||||
|
message and Quick replies post to. The chosen one carries a 3px accent
|
||||||
|
stripe along the bottom edge of the tile body — drawn just inside our
|
||||||
|
root, since the tile chrome around it belongs to the shell. The others
|
||||||
|
hint at it faintly on hover. */
|
||||||
|
[data-module="slack"] .sl-transcript::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--accent);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 3;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-transcript:hover::after { opacity: 0.3; }
|
||||||
|
[data-module="slack"] .sl-transcript.is-target::after { opacity: 1; }
|
||||||
|
|
||||||
|
/* ── transcript stream ── */
|
||||||
|
|
||||||
|
[data-module="slack"] .sl-stream {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 6px 10px 10px;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="slack"] .sl-empty {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9em;
|
||||||
|
text-align: center;
|
||||||
|
padding: 14% 10px 0;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-empty.is-error { color: var(--danger); }
|
||||||
|
|
||||||
|
[data-module="slack"] .sl-entry {
|
||||||
|
padding: 5px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-entry:hover { background: var(--bg); }
|
||||||
|
[data-module="slack"] .sl-entry.sl-new { animation: sl-fade-in 0.25s ease-out; }
|
||||||
|
@keyframes sl-fade-in {
|
||||||
|
from { opacity: 0; transform: translateY(4px); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* one row: the picture, then everything else (.sl-row — .sl-msg is the
|
||||||
|
send tile's status line, a different thing) */
|
||||||
|
[data-module="slack"] .sl-row,
|
||||||
|
[data-module="slack"] .sl-reply {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-body { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
/* the square: a profile image, a bot's icon or emoji, or initials on a tint
|
||||||
|
of a hue the name hashes to — the same size and corner as ProdCom's */
|
||||||
|
[data-module="slack"] .sl-avatar {
|
||||||
|
flex: none;
|
||||||
|
width: calc(var(--sl-size) * 1.9);
|
||||||
|
height: calc(var(--sl-size) * 1.9);
|
||||||
|
border-radius: 26%;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: hsl(var(--sl-hue, 150) 45% 50% / 0.2);
|
||||||
|
color: hsl(var(--sl-hue, 150) 55% 60%);
|
||||||
|
font-size: 0.72em;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
/* "Avatars" off: the square goes and the row starts at the left edge, as in ProdCom */
|
||||||
|
[data-module="slack"] .sl-wrap.hide-avatars .sl-avatar { display: none; }
|
||||||
|
[data-module="slack"] .sl-avatar-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-avatar-emoji { font-size: 1.25em; line-height: 1; }
|
||||||
|
[data-module="slack"] .sl-reply .sl-avatar {
|
||||||
|
width: calc(var(--sl-size) * 1.5);
|
||||||
|
height: calc(var(--sl-size) * 1.5);
|
||||||
|
font-size: 0.6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="slack"] .sl-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-name {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.82em;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
color: var(--accent);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-bot .sl-name { color: var(--muted); }
|
||||||
|
[data-module="slack"] .sl-time {
|
||||||
|
flex: none;
|
||||||
|
font-size: 0.72em;
|
||||||
|
color: var(--muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-wrap:not(.show-times) .sl-time { display: none; }
|
||||||
|
[data-module="slack"] .sl-wrap:not(.show-names) .sl-body > .sl-meta { display: none; }
|
||||||
|
[data-module="slack"] .sl-wrap.show-times:not(.show-names) .sl-body > .sl-meta { display: flex; }
|
||||||
|
[data-module="slack"] .sl-wrap.show-times:not(.show-names) .sl-name { display: none; }
|
||||||
|
[data-module="slack"] .sl-wrap:not(.show-system) .sl-entry.sl-system { display: none; }
|
||||||
|
|
||||||
|
[data-module="slack"] .sl-text {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-text b { font-weight: 700; }
|
||||||
|
[data-module="slack"] .sl-text i { font-style: italic; }
|
||||||
|
[data-module="slack"] .sl-text s { color: var(--muted); }
|
||||||
|
[data-module="slack"] .sl-code {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: color-mix(in srgb, var(--text) 8%, transparent);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-code.sl-block {
|
||||||
|
display: block;
|
||||||
|
padding: 4px 8px;
|
||||||
|
margin: 3px 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-edited {
|
||||||
|
font-size: 0.75em;
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* a custom emoji is an image the size of a character */
|
||||||
|
[data-module="slack"] .sl-emoji {
|
||||||
|
display: inline-block;
|
||||||
|
width: 1.25em;
|
||||||
|
height: 1.25em;
|
||||||
|
object-fit: contain;
|
||||||
|
vertical-align: -0.28em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* reactions under a message: pills with the emoji and its count; the
|
||||||
|
location's own carry the accent — a click adds yours or takes it back */
|
||||||
|
[data-module="slack"] .sl-reactions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-reaction {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 1px 7px 1px 5px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--text) 5%, transparent);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.78em;
|
||||||
|
line-height: 1.4;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s, border-color 0.1s;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-reaction:hover { border-color: var(--accent); }
|
||||||
|
[data-module="slack"] .sl-reaction.is-me {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: color-mix(in srgb, var(--accent) 16%, transparent);
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-reaction .sl-emoji,
|
||||||
|
[data-module="slack"] .sl-reaction .sl-emoji-char { font-size: 1.15em; line-height: 1; vertical-align: middle; }
|
||||||
|
[data-module="slack"] .sl-reaction-count {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-reaction.is-me .sl-reaction-count { color: var(--text); }
|
||||||
|
|
||||||
|
/* thread replies nest under their parent; without them, a muted count */
|
||||||
|
[data-module="slack"] .sl-replies {
|
||||||
|
margin: 4px 0 0 10px;
|
||||||
|
padding-left: 10px;
|
||||||
|
border-left: 2px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-reply { font-size: 0.92em; }
|
||||||
|
[data-module="slack"] .sl-replycount {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 0.75em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* system lines: joins, leaves, topic changes */
|
||||||
|
[data-module="slack"] .sl-sysline {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.82em;
|
||||||
|
color: var(--muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-sysline .sl-time { font-style: normal; }
|
||||||
|
|
||||||
|
[data-module="slack"] .sl-jump {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 10px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
padding: 5px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── send / quick replies: the gate ── */
|
||||||
|
|
||||||
|
[data-module="slack"] .sl-gate,
|
||||||
|
[data-module="slack"] .sl-compose {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 6px 10px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="slack"] .sl-note {
|
||||||
|
margin: auto;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9em;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-note.is-error { color: var(--danger); }
|
||||||
|
|
||||||
|
/* The pad never shrinks below its content (min-height stays `auto` on it
|
||||||
|
and on the key grid) — a shrunken grid would spill its rows under the
|
||||||
|
message line, which then swallows the taps. A tile too short for it
|
||||||
|
scrolls instead (.sl-gate has overflow: auto). */
|
||||||
|
[data-module="slack"] .sl-pad {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
max-width: 340px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-pad.sl-shake { animation: sl-shake 0.4s ease; }
|
||||||
|
@keyframes sl-shake {
|
||||||
|
10%, 90% { transform: translateX(-2px); }
|
||||||
|
20%, 80% { transform: translateX(3px); }
|
||||||
|
30%, 50%, 70% { transform: translateX(-5px); }
|
||||||
|
40%, 60% { transform: translateX(5px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* one line: "Enter PIN · sending as Apollo Beach → #production" */
|
||||||
|
[data-module="slack"] .sl-pad-head {
|
||||||
|
flex: none;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: var(--muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-pad-head b { font-weight: 700; color: var(--text); }
|
||||||
|
|
||||||
|
[data-module="slack"] .sl-dots {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-dot {
|
||||||
|
width: 11px;
|
||||||
|
height: 11px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--muted);
|
||||||
|
transition: background 0.12s, border-color 0.12s;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-dot.on { background: var(--accent); border-color: var(--accent); }
|
||||||
|
|
||||||
|
[data-module="slack"] .sl-keys {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
grid-template-rows: repeat(4, minmax(24px, 1fr));
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-key {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 24px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: color-mix(in srgb, var(--text) 5%, var(--panel));
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: clamp(13px, 9cqh, 26px);
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
touch-action: manipulation;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-key:hover { background: var(--border); }
|
||||||
|
[data-module="slack"] .sl-key:active { background: color-mix(in srgb, var(--accent) 30%, var(--panel)); }
|
||||||
|
[data-module="slack"] .sl-key svg { width: 1.1em; height: 1.1em; }
|
||||||
|
[data-module="slack"] .sl-key-back { color: var(--muted); }
|
||||||
|
[data-module="slack"] .sl-key-blank { visibility: hidden; }
|
||||||
|
[data-module="slack"] .sl-pad.is-busy .sl-keys { opacity: 0.6; pointer-events: none; }
|
||||||
|
|
||||||
|
[data-module="slack"] .sl-pad-msg {
|
||||||
|
flex: none;
|
||||||
|
min-height: 1.1em;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.78em;
|
||||||
|
line-height: 1.1;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-pad-msg.is-error { color: var(--danger); font-weight: 600; }
|
||||||
|
|
||||||
|
/* A tile too short for the keypad (the smallest Send message / Quick replies
|
||||||
|
tiles) shows the dots and a hint instead — digits typed on the keyboard
|
||||||
|
still land, and a taller tile brings the keys back. .sl-wrap is the size
|
||||||
|
container the height is measured against. */
|
||||||
|
[data-module="slack"] .sl-pad-hint {
|
||||||
|
display: none;
|
||||||
|
flex: none;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.78em;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
@container (max-height: 176px) {
|
||||||
|
[data-module="slack"] .sl-pad .sl-keys { display: none; }
|
||||||
|
[data-module="slack"] .sl-pad .sl-pad-hint { display: block; }
|
||||||
|
[data-module="slack"] .sl-pad { justify-content: center; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── send / quick replies: unlocked ── */
|
||||||
|
|
||||||
|
[data-module="slack"] .sl-compose { gap: 6px; }
|
||||||
|
|
||||||
|
[data-module="slack"] .sl-compose-head {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-sending {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-lock-link {
|
||||||
|
flex: none;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: none;
|
||||||
|
color: var(--muted);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.9em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-lock-link:hover { color: var(--text); background: var(--border); }
|
||||||
|
|
||||||
|
[data-module="slack"] .sl-input {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 2.6em;
|
||||||
|
width: 100%;
|
||||||
|
resize: none;
|
||||||
|
padding: 7px 9px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-input:focus { outline: none; border-color: var(--accent); }
|
||||||
|
|
||||||
|
[data-module="slack"] .sl-compose-foot {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 1.6em;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-msg {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: var(--muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-msg.is-flash { color: var(--accent); font-weight: 700; }
|
||||||
|
[data-module="slack"] .sl-msg.is-error { color: var(--danger); white-space: normal; font-weight: 600; }
|
||||||
|
[data-module="slack"] .sl-countdown {
|
||||||
|
flex: none;
|
||||||
|
font-size: 0.75em;
|
||||||
|
color: var(--warn);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-sendbtn {
|
||||||
|
flex: none;
|
||||||
|
padding: 6px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-text);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-sendbtn:hover { background: color-mix(in srgb, var(--accent) 85%, var(--text)); }
|
||||||
|
[data-module="slack"] .sl-sendbtn:disabled { opacity: 0.6; cursor: default; }
|
||||||
|
|
||||||
|
/* quick emojis: one press each sends it as a message of its own */
|
||||||
|
[data-module="slack"] .sl-emoji-sendrow {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-emoji-send {
|
||||||
|
flex: none;
|
||||||
|
width: 2.1em;
|
||||||
|
height: 2.1em;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in srgb, var(--text) 5%, var(--panel));
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 1.1em;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
touch-action: manipulation;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
transition: background 0.1s, border-color 0.1s;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-emoji-send:hover { border-color: var(--accent); background: var(--border); }
|
||||||
|
[data-module="slack"] .sl-emoji-send:active { background: color-mix(in srgb, var(--accent) 30%, var(--panel)); }
|
||||||
|
[data-module="slack"] .sl-emoji-send.is-busy { opacity: 0.6; }
|
||||||
|
[data-module="slack"] .sl-emoji-send.is-sent { background: var(--accent); border-color: transparent; }
|
||||||
|
[data-module="slack"] .sl-emoji-send .sl-emoji { width: 1.2em; height: 1.2em; vertical-align: middle; }
|
||||||
|
|
||||||
|
/* the preset grid: big targets, labels wrap */
|
||||||
|
[data-module="slack"] .sl-grid {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(min(150px, 100%), 1fr));
|
||||||
|
grid-auto-rows: minmax(44px, auto);
|
||||||
|
gap: 6px;
|
||||||
|
align-content: start;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-grid .sl-note { grid-column: 1 / -1; }
|
||||||
|
[data-module="slack"] .sl-quick {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 44px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: color-mix(in srgb, var(--accent) 12%, var(--panel));
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.25;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: manipulation;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-quick:hover { background: color-mix(in srgb, var(--accent) 22%, var(--panel)); }
|
||||||
|
[data-module="slack"] .sl-quick:active { background: color-mix(in srgb, var(--accent) 40%, var(--panel)); }
|
||||||
|
[data-module="slack"] .sl-quick.is-busy { opacity: 0.7; }
|
||||||
|
[data-module="slack"] .sl-quick.is-sent {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: transparent;
|
||||||
|
color: var(--accent-text);
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-quick-label { overflow-wrap: anywhere; }
|
||||||
|
[data-module="slack"] .sl-quick-chan {
|
||||||
|
font-size: 0.72em;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-quick.is-sent .sl-quick-chan { color: inherit; }
|
||||||
|
|
||||||
|
/* ── the reaction menu (on document.body, so nothing clips it) ── */
|
||||||
|
|
||||||
|
[data-module="slack"].sl-emoji-menu {
|
||||||
|
--sl-size: 14px;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 90;
|
||||||
|
width: 292px;
|
||||||
|
max-width: calc(100vw - 16px);
|
||||||
|
max-height: calc(100vh - 16px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: var(--sl-size);
|
||||||
|
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.55);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
[data-module="slack"].sl-emoji-menu:focus { outline: none; }
|
||||||
|
[data-module="slack"].sl-emoji-menu.is-busy { opacity: 0.7; pointer-events: none; }
|
||||||
|
[data-module="slack"] .sl-emoji-head {
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: var(--muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-emoji-quickrow {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-emoji-quickrow:empty { display: none; }
|
||||||
|
[data-module="slack"] .sl-emoji-btn {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: none;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.1s, border-color 0.1s;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-emoji-quickrow .sl-emoji-btn { font-size: 22px; width: 38px; height: 38px; background: color-mix(in srgb, var(--text) 5%, var(--panel)); border-color: var(--border); }
|
||||||
|
[data-module="slack"] .sl-emoji-btn:hover { background: var(--border); border-color: var(--accent); }
|
||||||
|
[data-module="slack"] .sl-emoji-btn .sl-emoji { width: 1.1em; height: 1.1em; vertical-align: middle; }
|
||||||
|
[data-module="slack"] .sl-emoji-search {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 6px 9px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-emoji-search:focus { outline: none; border-color: var(--accent); }
|
||||||
|
[data-module="slack"] .sl-emoji-results {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 2px;
|
||||||
|
max-height: 150px;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 34px;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-emoji-none {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 4px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-emoji-msg {
|
||||||
|
min-height: 1em;
|
||||||
|
font-size: 0.78em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-emoji-msg:empty { display: none; }
|
||||||
|
[data-module="slack"] .sl-emoji-msg.is-error { color: var(--danger); font-weight: 600; }
|
||||||
|
|
||||||
|
/* the PIN pad inside the menu: fixed sizes, no tile to scale with */
|
||||||
|
[data-module="slack"] .sl-emoji-menu .sl-pad { max-width: 100%; gap: 6px; }
|
||||||
|
[data-module="slack"] .sl-emoji-menu .sl-keys { grid-template-rows: repeat(4, 40px); }
|
||||||
|
[data-module="slack"] .sl-emoji-menu .sl-key { font-size: 17px; }
|
||||||
+206
-6
@@ -94,6 +94,35 @@ body { overflow: auto; }
|
|||||||
}
|
}
|
||||||
.module-health.error { color: var(--danger); }
|
.module-health.error { color: var(--danger); }
|
||||||
|
|
||||||
|
/* a module card's own status line ("polling timers on 10.3.11.20:1025",
|
||||||
|
"no ProPresenter host configured"): a readout, so it sits in a bordered
|
||||||
|
monospace box a shade lighter than the card; ok / connecting / error
|
||||||
|
show as the text (and border) colour, the box itself never changes */
|
||||||
|
.module-status {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: color-mix(in srgb, var(--text) 5%, var(--panel));
|
||||||
|
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||||
|
font-size: 11.5px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--muted);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.module-status:empty { display: none; }
|
||||||
|
.module-status.ok { color: var(--text); }
|
||||||
|
.module-status.connecting {
|
||||||
|
color: var(--warn);
|
||||||
|
border-color: color-mix(in srgb, var(--warn) 40%, var(--border));
|
||||||
|
}
|
||||||
|
.module-status.error {
|
||||||
|
color: var(--danger);
|
||||||
|
border-color: color-mix(in srgb, var(--danger) 40%, var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
.module-mount-error {
|
.module-mount-error {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
@@ -106,11 +135,33 @@ body { overflow: auto; }
|
|||||||
.module-form { margin-top: 12px; border-top: 1px solid var(--border); padding-top: 12px; }
|
.module-form { margin-top: 12px; border-top: 1px solid var(--border); padding-top: 12px; }
|
||||||
.module-form .field { max-width: 420px; }
|
.module-form .field { max-width: 420px; }
|
||||||
|
|
||||||
|
/* a multi-line string (manifest type `text`): the same box as the other
|
||||||
|
inputs (those rules live in the shell's style.css), a few lines tall */
|
||||||
|
.field textarea {
|
||||||
|
padding: 7px 9px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 7px;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 72px;
|
||||||
|
}
|
||||||
|
.field textarea:focus { outline: none; border-color: var(--accent); }
|
||||||
|
|
||||||
/* endpoint fields: every module's upstream is entered the same way */
|
/* endpoint fields: every module's upstream is entered the same way */
|
||||||
.field .endpoint-row { display: flex; align-items: center; gap: 7px; }
|
.field .endpoint-row { display: flex; align-items: center; gap: 7px; }
|
||||||
.endpoint-row .ep-host { flex: 1; min-width: 0; }
|
.endpoint-row .ep-host { flex: 1; min-width: 0; }
|
||||||
.endpoint-row .ep-port { width: 92px; flex: none; }
|
.endpoint-row .ep-port { width: 92px; flex: none; }
|
||||||
.endpoint-row .ep-sep { color: var(--muted); font-weight: 600; }
|
.endpoint-row .ep-sep { color: var(--muted); font-weight: 600; }
|
||||||
|
/* several picks from a list (manifest type `multiselect`) */
|
||||||
|
.field.multiselect-field { cursor: default; }
|
||||||
|
.field.multiselect-field .multiselect { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.multiselect-row { display: flex; align-items: center; gap: 8px; cursor: pointer; }
|
||||||
|
.multiselect-empty { color: var(--muted); font-size: 12px; }
|
||||||
|
|
||||||
.module-form-empty { color: var(--muted); font-size: 12px; margin-top: 10px; }
|
.module-form-empty { color: var(--muted); font-size: 12px; margin-top: 10px; }
|
||||||
|
|
||||||
/* labeled config subsections (manifest field `group`) */
|
/* labeled config subsections (manifest field `group`) */
|
||||||
@@ -121,6 +172,10 @@ body { overflow: auto; }
|
|||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
}
|
}
|
||||||
.field-group-title {
|
.field-group-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
position: relative; /* the ⓘ popover hangs off this row */
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -128,8 +183,82 @@ body { overflow: auto; }
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
.field-group-name { min-width: 0; }
|
||||||
.field-group .field:last-child { margin-bottom: 0; }
|
.field-group .field:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
/* group help (configGroups[name].help): an ⓘ beside the title. Hover or
|
||||||
|
focus peeks at the text; a click pins it open until the next click. */
|
||||||
|
.group-info { display: inline-flex; align-items: center; }
|
||||||
|
.group-info-btn {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid var(--muted);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
font: italic 700 10px/1 ui-serif, Georgia, "Times New Roman", serif;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
.group-info-btn:hover,
|
||||||
|
.group-info.is-open .group-info-btn { color: var(--text); border-color: var(--text); }
|
||||||
|
.group-info-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||||
|
.group-info-pop {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
z-index: 5;
|
||||||
|
max-width: min(440px, 100%);
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: color-mix(in srgb, var(--text) 6%, var(--panel));
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.5;
|
||||||
|
letter-spacing: normal;
|
||||||
|
text-transform: none;
|
||||||
|
white-space: normal;
|
||||||
|
cursor: auto;
|
||||||
|
}
|
||||||
|
/* keeps the popover open while the pointer crosses the gap down to it */
|
||||||
|
.group-info-pop::before { content: ""; position: absolute; left: 0; right: 0; top: -8px; height: 8px; }
|
||||||
|
.group-info:hover .group-info-pop,
|
||||||
|
.group-info-btn:focus-visible + .group-info-pop,
|
||||||
|
.group-info.is-open .group-info-pop { display: block; }
|
||||||
|
|
||||||
|
/* group toggle (configGroups[name].toggle): the named switch sits at the
|
||||||
|
right of the title row as the group's on/off; the body dims while it is
|
||||||
|
off but stays editable */
|
||||||
|
.field-group-toggle {
|
||||||
|
margin-left: auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.field-group-toggle .field {
|
||||||
|
margin: 0;
|
||||||
|
max-width: none;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: normal;
|
||||||
|
text-transform: none;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.field-group-toggle .field.switch-field { flex-direction: row-reverse; gap: 8px; }
|
||||||
|
.field-group-toggle .field.switch-field .switch-label { font-size: 12px; }
|
||||||
|
.field-group-toggle .field.check { flex-direction: row-reverse; gap: 8px; }
|
||||||
|
.field-group.is-off > .field-group-body { opacity: 0.45; transition: opacity 0.15s; }
|
||||||
|
.field-group.has-toggle > .field-group-body { transition: opacity 0.15s; }
|
||||||
|
|
||||||
/* collapsible groups (manifest configGroups[name].collapsed) */
|
/* collapsible groups (manifest configGroups[name].collapsed) */
|
||||||
details.field-group > summary.field-group-title {
|
details.field-group > summary.field-group-title {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -160,18 +289,16 @@ details.field-group > summary.field-group-title:hover { color: var(--text); }
|
|||||||
gap: 4px 16px;
|
gap: 4px 16px;
|
||||||
}
|
}
|
||||||
.field-group-body.is-columns .field { margin-bottom: 4px; max-width: none; }
|
.field-group-body.is-columns .field { margin-bottom: 4px; max-width: none; }
|
||||||
.field-group-body.is-columns .field-group-help { grid-column: 1 / -1; }
|
|
||||||
@media (max-width: 560px) { .field-group-body.is-columns { grid-template-columns: 1fr 1fr; } }
|
@media (max-width: 560px) { .field-group-body.is-columns { grid-template-columns: 1fr 1fr; } }
|
||||||
|
|
||||||
.field-group-help,
|
/* one line of guidance under a control (field `help`) */
|
||||||
.field-help {
|
.field-help {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 11.5px;
|
font-size: 11.5px;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
.field-group-help { margin: -4px 0 8px; }
|
|
||||||
.field-help { margin-top: 2px; }
|
|
||||||
.field-help code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
.field-help code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||||
|
|
||||||
/* showWhen: a hidden dependent field must actually collapse — `.field`'s
|
/* showWhen: a hidden dependent field must actually collapse — `.field`'s
|
||||||
@@ -190,12 +317,19 @@ details.field-group > summary.field-group-title:hover { color: var(--text); }
|
|||||||
}
|
}
|
||||||
.field.switch-field .switch-label { font-size: 13px; }
|
.field.switch-field .switch-label { font-size: 13px; }
|
||||||
|
|
||||||
|
/* the card's one action row: status on the left, Configure / Apply on the right */
|
||||||
.module-actions {
|
.module-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-end; /* the button stays bottom-right when a readout wraps */
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-top: 4px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
.module-name.is-toggle { cursor: pointer; }
|
||||||
|
.module-name.is-toggle:hover,
|
||||||
|
.module-name.is-toggle:focus-visible { color: var(--accent); outline: none; }
|
||||||
|
.module-actions .admin-status { flex: 1; margin-top: 0; }
|
||||||
|
.module-actions .module-status { flex: 0 1 auto; min-width: 0; margin-top: 0; }
|
||||||
|
.module-actions .btn { flex: none; margin-left: auto; }
|
||||||
|
|
||||||
.admin-status { min-height: 16px; font-size: 12px; color: var(--muted); margin-top: 8px; }
|
.admin-status { min-height: 16px; font-size: 12px; color: var(--muted); margin-top: 8px; }
|
||||||
.admin-status.error { color: var(--danger); }
|
.admin-status.error { color: var(--danger); }
|
||||||
@@ -312,3 +446,69 @@ a.btn { text-decoration: none; }
|
|||||||
.catalog-title { display: flex; align-items: center; gap: 8px; }
|
.catalog-title { display: flex; align-items: center; gap: 8px; }
|
||||||
.catalog-row .module-desc { margin: 4px 0 2px; }
|
.catalog-row .module-desc { margin: 4px 0 2px; }
|
||||||
.catalog-row .module-health { margin-top: 2px; }
|
.catalog-row .module-health { margin-top: 2px; }
|
||||||
|
|
||||||
|
/* ── theme ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* one swatch per preset: a miniature dashboard (page, a tile on it, the
|
||||||
|
accent as a status dot) and the name; the current one wears the accent */
|
||||||
|
.theme-swatches {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(116px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.theme-swatch {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.theme-swatch:hover { border-color: var(--muted); }
|
||||||
|
.theme-swatch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
.theme-swatch.is-current { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
||||||
|
.theme-preview {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
height: 46px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--sw-bg);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--sw-panel) 60%, var(--sw-accent));
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.theme-preview::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 8px; right: 8px; top: 10px; bottom: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--sw-panel);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--sw-panel) 75%, var(--sw-accent));
|
||||||
|
}
|
||||||
|
.theme-preview::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 14px; top: 16px;
|
||||||
|
width: 8px; height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--sw-accent);
|
||||||
|
box-shadow: 0 0 6px var(--sw-accent);
|
||||||
|
}
|
||||||
|
.theme-name { display: flex; align-items: center; gap: 6px; }
|
||||||
|
.theme-swatch.is-current .theme-name::after {
|
||||||
|
content: "current";
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 9.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
#theme-status { margin-top: 4px; }
|
||||||
|
|||||||
+358
-19
@@ -22,6 +22,9 @@ const updateStatus = document.getElementById('update-status');
|
|||||||
const updateBtn = document.getElementById('update-btn');
|
const updateBtn = document.getElementById('update-btn');
|
||||||
const updateChanges = document.getElementById('update-changes');
|
const updateChanges = document.getElementById('update-changes');
|
||||||
const shellVersionEl = document.getElementById('shell-version');
|
const shellVersionEl = document.getElementById('shell-version');
|
||||||
|
const themeSection = document.getElementById('theme-section');
|
||||||
|
const themeSwatches = document.getElementById('theme-swatches');
|
||||||
|
const themeStatus = document.getElementById('theme-status');
|
||||||
|
|
||||||
/** The shell version the page loaded with — the update flow watches it change. */
|
/** The shell version the page loaded with — the update flow watches it change. */
|
||||||
let shellVersion = '';
|
let shellVersion = '';
|
||||||
@@ -58,6 +61,7 @@ function showLogin() {
|
|||||||
availableSection.hidden = true;
|
availableSection.hidden = true;
|
||||||
layoutsSection.hidden = true;
|
layoutsSection.hidden = true;
|
||||||
updateCard.hidden = true;
|
updateCard.hidden = true;
|
||||||
|
themeSection.hidden = true;
|
||||||
passcodeInput.focus();
|
passcodeInput.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,6 +228,73 @@ function fieldFor(key, spec, value, passwordSet, moduleId) {
|
|||||||
field.append(span, select);
|
field.append(span, select);
|
||||||
read = () => select.value;
|
read = () => select.value;
|
||||||
selectEl = select;
|
selectEl = select;
|
||||||
|
} else if (type === 'multiselect' && (Array.isArray(spec.options) || spec.optionsRoute)) {
|
||||||
|
// Several picks from one list — a switch per option, stored as an array
|
||||||
|
// of values. A saved value the list no longer offers stays checked and
|
||||||
|
// marked, so a channel the account has left isn't dropped in silence.
|
||||||
|
field.classList.add('multiselect-field');
|
||||||
|
// The wrapper is a <label>; with several controls inside, a click on the
|
||||||
|
// wrapper would activate the FIRST one. `for=""` makes it label nothing.
|
||||||
|
field.htmlFor = '';
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.textContent = label;
|
||||||
|
const list = document.createElement('div');
|
||||||
|
list.className = 'multiselect';
|
||||||
|
const chosen = new Set((Array.isArray(value) ? value : []).map(String).filter(Boolean));
|
||||||
|
const optValue = (opt) => String(typeof opt === 'object' ? opt.value : opt);
|
||||||
|
const fill = (options) => {
|
||||||
|
for (const input of list.querySelectorAll('input')) {
|
||||||
|
if (input.checked) chosen.add(input.value);
|
||||||
|
else chosen.delete(input.value);
|
||||||
|
}
|
||||||
|
list.innerHTML = '';
|
||||||
|
const missing = [...chosen].filter((v) => !options.some((opt) => optValue(opt) === v));
|
||||||
|
const all = [...options, ...missing.map((v) => ({ value: v, label: `${v} (saved — not listed now)` }))];
|
||||||
|
for (const opt of all) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'multiselect-row';
|
||||||
|
const toggle = document.createElement('span');
|
||||||
|
toggle.className = 'switch';
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'checkbox';
|
||||||
|
input.value = optValue(opt);
|
||||||
|
input.checked = chosen.has(input.value);
|
||||||
|
const track = document.createElement('span');
|
||||||
|
track.className = 'track';
|
||||||
|
toggle.append(input, track);
|
||||||
|
const text = document.createElement('span');
|
||||||
|
text.className = 'switch-label';
|
||||||
|
text.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt);
|
||||||
|
row.append(toggle, text);
|
||||||
|
row.addEventListener('click', (e) => {
|
||||||
|
if (e.target === input) return; // the box toggles itself
|
||||||
|
input.checked = !input.checked;
|
||||||
|
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
});
|
||||||
|
list.appendChild(row);
|
||||||
|
}
|
||||||
|
if (!all.length) {
|
||||||
|
const none = document.createElement('div');
|
||||||
|
none.className = 'multiselect-empty';
|
||||||
|
none.textContent = 'Nothing to choose from yet.';
|
||||||
|
list.appendChild(none);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fill(Array.isArray(spec.options) ? spec.options : []);
|
||||||
|
if (spec.optionsRoute && moduleId) {
|
||||||
|
refetch = (query) => {
|
||||||
|
const qs = query && Object.keys(query).length ? `?${new URLSearchParams(query)}` : '';
|
||||||
|
return fetch(`/api/modules/${encodeURIComponent(moduleId)}${spec.optionsRoute}${qs}`)
|
||||||
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
|
.then((body) => {
|
||||||
|
if (Array.isArray(body?.options)) fill(body.options);
|
||||||
|
})
|
||||||
|
.catch(() => { /* keep the static/saved options */ });
|
||||||
|
};
|
||||||
|
if (!spec.optionsDependsOn) refetch();
|
||||||
|
}
|
||||||
|
field.append(span, list);
|
||||||
|
read = () => [...list.querySelectorAll('input:checked')].map((input) => input.value);
|
||||||
} else if (type === 'color') {
|
} else if (type === 'color') {
|
||||||
// A swatch picker; the value is always a #rrggbb string.
|
// A swatch picker; the value is always a #rrggbb string.
|
||||||
field.classList.add('color-field');
|
field.classList.add('color-field');
|
||||||
@@ -235,6 +306,17 @@ function fieldFor(key, spec, value, passwordSet, moduleId) {
|
|||||||
input.value = hex(value, hex(spec?.default, '#2ee59a'));
|
input.value = hex(value, hex(spec?.default, '#2ee59a'));
|
||||||
field.append(span, input);
|
field.append(span, input);
|
||||||
read = () => input.value;
|
read = () => input.value;
|
||||||
|
} else if (type === 'text') {
|
||||||
|
// A multi-line string — one item per line by convention (a list of
|
||||||
|
// presets, say). Stored as-is, newlines included.
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.textContent = label;
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.rows = 5;
|
||||||
|
textarea.spellcheck = false;
|
||||||
|
textarea.value = value === undefined || value === null ? '' : String(value);
|
||||||
|
field.append(span, textarea);
|
||||||
|
read = () => textarea.value;
|
||||||
} else {
|
} else {
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.textContent = label;
|
span.textContent = label;
|
||||||
@@ -277,6 +359,99 @@ function groupHasValues(fields, mod) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── group title row: the ⓘ and the group toggle ────────────────────── */
|
||||||
|
|
||||||
|
let groupInfoSeq = 0;
|
||||||
|
|
||||||
|
function closeGroupInfos(except) {
|
||||||
|
for (const open of document.querySelectorAll('.group-info.is-open')) {
|
||||||
|
if (open === except) continue;
|
||||||
|
open.classList.remove('is-open');
|
||||||
|
open.querySelector('.group-info-btn')?.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* configGroups[name].help as an ⓘ beside the group title: click toggles a
|
||||||
|
* small popover with the text (hover and keyboard focus show it too), so a
|
||||||
|
* group can carry a paragraph without pushing its fields down the page. A
|
||||||
|
* real button, labelled, so it works from the keyboard; inside a <summary>
|
||||||
|
* it must never fold the group.
|
||||||
|
*/
|
||||||
|
function groupInfo(groupName, text) {
|
||||||
|
const wrap = document.createElement('span');
|
||||||
|
wrap.className = 'group-info';
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'group-info-btn';
|
||||||
|
btn.textContent = 'i';
|
||||||
|
btn.setAttribute('aria-label', `About ${groupName}`);
|
||||||
|
btn.setAttribute('aria-expanded', 'false');
|
||||||
|
const pop = document.createElement('span');
|
||||||
|
pop.className = 'group-info-pop';
|
||||||
|
pop.id = `group-info-${++groupInfoSeq}`;
|
||||||
|
pop.setAttribute('role', 'note');
|
||||||
|
pop.textContent = text;
|
||||||
|
btn.setAttribute('aria-describedby', pop.id);
|
||||||
|
wrap.append(btn, pop);
|
||||||
|
const setOpen = (open) => {
|
||||||
|
wrap.classList.toggle('is-open', open);
|
||||||
|
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||||
|
};
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault(); // a click in a <summary> would otherwise fold the group
|
||||||
|
e.stopPropagation();
|
||||||
|
const open = !wrap.classList.contains('is-open');
|
||||||
|
closeGroupInfos(wrap);
|
||||||
|
setOpen(open);
|
||||||
|
});
|
||||||
|
pop.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
wrap.addEventListener('keydown', (e) => {
|
||||||
|
// Enter / Space activate the button (a click); they must not reach a
|
||||||
|
// <summary> above as well. Escape closes and hands focus back.
|
||||||
|
if (e.key === ' ' || e.key === 'Enter') e.stopPropagation();
|
||||||
|
if (e.key === 'Escape' && wrap.classList.contains('is-open')) {
|
||||||
|
e.stopPropagation();
|
||||||
|
setOpen(false);
|
||||||
|
btn.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A click anywhere else, or Escape, closes an open popover.
|
||||||
|
document.addEventListener('pointerdown', (e) => {
|
||||||
|
if (!(e.target instanceof Element) || !e.target.closest('.group-info')) closeGroupInfos();
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') closeGroupInfos();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* configGroups[name].toggle: the named switch/boolean field is the group's
|
||||||
|
* on/off. It moves into the title row, right-aligned, and the body dims
|
||||||
|
* while it is off (fields stay editable). The field itself is untouched —
|
||||||
|
* it is still read on save and still drives showWhen dependents.
|
||||||
|
*/
|
||||||
|
function mountGroupToggle(group, field, control) {
|
||||||
|
const slot = document.createElement('span');
|
||||||
|
slot.className = 'field-group-toggle';
|
||||||
|
slot.appendChild(field);
|
||||||
|
// Inside a <summary>, flipping the switch must not fold the group.
|
||||||
|
slot.addEventListener('click', (e) => e.stopPropagation());
|
||||||
|
slot.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === ' ' || e.key === 'Enter') e.stopPropagation();
|
||||||
|
});
|
||||||
|
group.title.appendChild(slot);
|
||||||
|
group.el.classList.add('has-toggle');
|
||||||
|
const sync = () => group.el.classList.toggle('is-off', !control.checked);
|
||||||
|
control.addEventListener('change', sync);
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
|
||||||
function buildModuleCard(mod) {
|
function buildModuleCard(mod) {
|
||||||
const card = document.createElement('section');
|
const card = document.createElement('section');
|
||||||
card.className = 'admin-card';
|
card.className = 'admin-card';
|
||||||
@@ -351,8 +526,11 @@ function buildModuleCard(mod) {
|
|||||||
desc.textContent = mod.description;
|
desc.textContent = mod.description;
|
||||||
card.appendChild(desc);
|
card.appendChild(desc);
|
||||||
|
|
||||||
|
// The module's own words about its upstream ("polling timers on
|
||||||
|
// 10.3.11.20:1025") — a readout, so it sits in a bordered monospace box
|
||||||
|
// and the state (ok / connecting / error) is its text colour.
|
||||||
const healthEl = document.createElement('div');
|
const healthEl = document.createElement('div');
|
||||||
healthEl.className = 'module-health ' + (healthClass(mod) === 'error' ? 'error' : '');
|
healthEl.className = 'module-status ' + healthClass(mod);
|
||||||
healthEl.textContent = healthText(mod);
|
healthEl.textContent = healthText(mod);
|
||||||
card.appendChild(healthEl);
|
card.appendChild(healthEl);
|
||||||
|
|
||||||
@@ -395,7 +573,9 @@ function buildModuleCard(mod) {
|
|||||||
const collapsibles = []; // { el, meta, fields } groups that fold
|
const collapsibles = []; // { el, meta, fields } groups that fold
|
||||||
// Fields render in schema order; a `group` starts a labeled subsection
|
// Fields render in schema order; a `group` starts a labeled subsection
|
||||||
// and consecutive same-group fields share it. The manifest's optional
|
// and consecutive same-group fields share it. The manifest's optional
|
||||||
// `configGroups[name]` adds { collapsed, columns, help } for that group.
|
// `configGroups[name]` adds { collapsed, columns, help, toggle } for
|
||||||
|
// that group: help is an ⓘ beside the title, toggle names the switch
|
||||||
|
// that is the group's on/off and lives in the title row.
|
||||||
const groupMeta = mod.configGroups && typeof mod.configGroups === 'object' ? mod.configGroups : {};
|
const groupMeta = mod.configGroups && typeof mod.configGroups === 'object' ? mod.configGroups : {};
|
||||||
let groupName = null;
|
let groupName = null;
|
||||||
let container = form;
|
let container = form;
|
||||||
@@ -412,7 +592,11 @@ function buildModuleCard(mod) {
|
|||||||
group.className = 'field-group';
|
group.className = 'field-group';
|
||||||
const title = document.createElement(folds ? 'summary' : 'div');
|
const title = document.createElement(folds ? 'summary' : 'div');
|
||||||
title.className = 'field-group-title';
|
title.className = 'field-group-title';
|
||||||
title.textContent = g;
|
const titleName = document.createElement('span');
|
||||||
|
titleName.className = 'field-group-name';
|
||||||
|
titleName.textContent = g;
|
||||||
|
title.appendChild(titleName);
|
||||||
|
if (meta.help) title.appendChild(groupInfo(g, String(meta.help)));
|
||||||
group.appendChild(title);
|
group.appendChild(title);
|
||||||
const body = document.createElement('div');
|
const body = document.createElement('div');
|
||||||
body.className = 'field-group-body';
|
body.className = 'field-group-body';
|
||||||
@@ -421,16 +605,16 @@ function buildModuleCard(mod) {
|
|||||||
body.classList.add('is-columns');
|
body.classList.add('is-columns');
|
||||||
body.style.setProperty('--cols', String(cols));
|
body.style.setProperty('--cols', String(cols));
|
||||||
}
|
}
|
||||||
if (meta.help) {
|
|
||||||
const help = document.createElement('div');
|
|
||||||
help.className = 'field-group-help';
|
|
||||||
help.textContent = meta.help;
|
|
||||||
body.appendChild(help);
|
|
||||||
}
|
|
||||||
group.appendChild(body);
|
group.appendChild(body);
|
||||||
form.appendChild(group);
|
form.appendChild(group);
|
||||||
container = body;
|
container = body;
|
||||||
currentGroup = { el: group, meta, fields: [] };
|
currentGroup = {
|
||||||
|
el: group,
|
||||||
|
title,
|
||||||
|
meta,
|
||||||
|
fields: [],
|
||||||
|
toggleKey: typeof meta.toggle === 'string' ? meta.toggle : '',
|
||||||
|
};
|
||||||
if (folds) collapsibles.push(currentGroup);
|
if (folds) collapsibles.push(currentGroup);
|
||||||
} else {
|
} else {
|
||||||
container = form;
|
container = form;
|
||||||
@@ -444,8 +628,14 @@ function buildModuleCard(mod) {
|
|||||||
else if (refetch) reloaders.push(() => refetch());
|
else if (refetch) reloaders.push(() => refetch());
|
||||||
if (spec?.showWhen) dependents.push({ field, when: spec.showWhen });
|
if (spec?.showWhen) dependents.push({ field, when: spec.showWhen });
|
||||||
if (currentGroup) currentGroup.fields.push({ key, spec });
|
if (currentGroup) currentGroup.fields.push({ key, spec });
|
||||||
|
if (currentGroup && control && key === currentGroup.toggleKey) {
|
||||||
|
// The group's on/off: title row, not the body (never both). A
|
||||||
|
// toggle naming a non-boolean field is ignored and renders normally.
|
||||||
|
mountGroupToggle(currentGroup, field, control);
|
||||||
|
} else {
|
||||||
container.appendChild(field);
|
container.appendChild(field);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// A field with showWhen:"K" is visible only while K's toggle is on.
|
// A field with showWhen:"K" is visible only while K's toggle is on.
|
||||||
for (const dep of dependents) {
|
for (const dep of dependents) {
|
||||||
const ctl = controls.get(dep.when);
|
const ctl = controls.get(dep.when);
|
||||||
@@ -473,26 +663,52 @@ function buildModuleCard(mod) {
|
|||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'module-actions';
|
actions.className = 'module-actions';
|
||||||
const save = document.createElement('button');
|
const save = document.createElement('button');
|
||||||
save.className = 'btn primary';
|
|
||||||
save.textContent = 'Save & re-init';
|
|
||||||
const status = document.createElement('span');
|
const status = document.createElement('span');
|
||||||
status.className = 'admin-status';
|
status.className = 'admin-status';
|
||||||
actions.append(save, status);
|
actions.append(status, save);
|
||||||
form.appendChild(actions);
|
// A card is a status line until someone wants to change something: the
|
||||||
|
// form starts folded, and the row at the bottom holds one button that
|
||||||
|
// does both jobs — "Configure" opens the form and becomes "Apply", which
|
||||||
|
// saves without closing it. The name is the other way in and out.
|
||||||
card.appendChild(form);
|
card.appendChild(form);
|
||||||
|
card.appendChild(actions);
|
||||||
|
const openKey = 'proddash:admin:open:' + mod.id;
|
||||||
|
const setOpen = (open) => {
|
||||||
|
form.hidden = !open;
|
||||||
|
status.hidden = !open;
|
||||||
|
save.textContent = open ? 'Apply' : 'Configure';
|
||||||
|
save.className = open ? 'btn primary' : 'btn';
|
||||||
|
// Folded, the readout shares the bottom row with Configure instead of
|
||||||
|
// taking a line of its own; open, it goes back above the form.
|
||||||
|
if (open) card.insertBefore(healthEl, mountError);
|
||||||
|
else actions.insertBefore(healthEl, status);
|
||||||
|
try { sessionStorage.setItem(openKey, open ? '1' : '0'); } catch { /* fine */ }
|
||||||
|
};
|
||||||
|
let wasOpen = false;
|
||||||
|
try { wasOpen = sessionStorage.getItem(openKey) === '1'; } catch { /* fine */ }
|
||||||
|
setOpen(wasOpen);
|
||||||
|
name.classList.add('is-toggle');
|
||||||
|
name.title = 'Show or hide this module\'s settings';
|
||||||
|
name.tabIndex = 0;
|
||||||
|
name.setAttribute('role', 'button');
|
||||||
|
name.addEventListener('click', () => setOpen(form.hidden));
|
||||||
|
name.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setOpen(form.hidden); }
|
||||||
|
});
|
||||||
|
|
||||||
save.addEventListener('click', async () => {
|
save.addEventListener('click', async () => {
|
||||||
|
if (form.hidden) return setOpen(true);
|
||||||
const config = {};
|
const config = {};
|
||||||
for (const [key, read] of readers) config[key] = read();
|
for (const [key, read] of readers) config[key] = read();
|
||||||
save.disabled = true;
|
save.disabled = true;
|
||||||
status.textContent = 'Saving…';
|
status.textContent = 'Applying…';
|
||||||
status.className = 'admin-status';
|
status.className = 'admin-status';
|
||||||
try {
|
try {
|
||||||
await api(`/api/admin/modules/${encodeURIComponent(mod.id)}/config`, {
|
await api(`/api/admin/modules/${encodeURIComponent(mod.id)}/config`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ config }),
|
body: JSON.stringify({ config }),
|
||||||
});
|
});
|
||||||
status.textContent = 'Saved — module re-initialised. Open tiles reconnect on their own.';
|
status.textContent = 'Applied — module re-initialised. Open tiles reconnect on their own.';
|
||||||
status.className = 'admin-status ok';
|
status.className = 'admin-status ok';
|
||||||
refreshStatus();
|
refreshStatus();
|
||||||
// What the module can list may have changed (credentials just
|
// What the module can list may have changed (credentials just
|
||||||
@@ -531,7 +747,7 @@ async function refreshStatus() {
|
|||||||
if (!els) continue;
|
if (!els) continue;
|
||||||
els.dot.className = 'dot ' + healthClass(mod);
|
els.dot.className = 'dot ' + healthClass(mod);
|
||||||
els.healthEl.textContent = healthText(mod);
|
els.healthEl.textContent = healthText(mod);
|
||||||
els.healthEl.className = 'module-health ' + (healthClass(mod) === 'error' ? 'error' : '');
|
els.healthEl.className = 'module-status ' + healthClass(mod);
|
||||||
if (!mod.incompatible) {
|
if (!mod.incompatible) {
|
||||||
els.mountError.hidden = !mod.mountError;
|
els.mountError.hidden = !mod.mountError;
|
||||||
els.mountError.textContent = mod.mountError ? `Failed to start: ${mod.mountError}` : '';
|
els.mountError.textContent = mod.mountError ? `Failed to start: ${mod.mountError}` : '';
|
||||||
@@ -543,7 +759,7 @@ async function refreshStatus() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── named layouts ──────────────────────────────────────────────────── */
|
/* ── saved layouts ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
async function loadLayouts() {
|
async function loadLayouts() {
|
||||||
let body;
|
let body;
|
||||||
@@ -559,7 +775,7 @@ async function loadLayouts() {
|
|||||||
if (!layouts.length) {
|
if (!layouts.length) {
|
||||||
const none = document.createElement('div');
|
const none = document.createElement('div');
|
||||||
none.className = 'layouts-empty';
|
none.className = 'layouts-empty';
|
||||||
none.textContent = 'No named layouts saved yet. Save one from the dashboard’s Layout menu.';
|
none.textContent = 'No layouts saved yet. Save one from the dashboard’s Layout menu.';
|
||||||
layoutsList.appendChild(none);
|
layoutsList.appendChild(none);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -640,10 +856,133 @@ async function loadState() {
|
|||||||
for (const mod of state.modules || []) {
|
for (const mod of state.modules || []) {
|
||||||
modulesList.appendChild(buildModuleCard(mod));
|
modulesList.appendChild(buildModuleCard(mod));
|
||||||
}
|
}
|
||||||
|
loadTheme();
|
||||||
loadLayouts();
|
loadLayouts();
|
||||||
loadCatalog(false);
|
loadCatalog(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── theme ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* The dashboard palette is a server-wide choice. The server lists the
|
||||||
|
presets (GET /api/theme — public, dashboards need it too) and the admin
|
||||||
|
page shows them as swatches; picking one POSTs it, applies it here at
|
||||||
|
once, and the server's `theme` shell event switches every open dashboard
|
||||||
|
— and any other admin page — live. The palettes themselves are
|
||||||
|
html[data-theme="…"] variable sets in the shell's style.css, which this
|
||||||
|
page shares, so the admin page follows its own choice. */
|
||||||
|
|
||||||
|
const LS_THEME = 'proddash:theme';
|
||||||
|
let themeList = [];
|
||||||
|
let currentTheme = '';
|
||||||
|
|
||||||
|
function applyTheme(id) {
|
||||||
|
const theme = String(id || '');
|
||||||
|
if (!/^[a-z][a-z0-9-]{0,31}$/.test(theme)) return;
|
||||||
|
document.documentElement.dataset.theme = theme;
|
||||||
|
try { localStorage.setItem(LS_THEME, theme); } catch { /* private mode — fine */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The last-known theme first (shared with the dashboard, same origin), so
|
||||||
|
// the page doesn't flash the default palette before the server answers.
|
||||||
|
try { applyTheme(localStorage.getItem(LS_THEME)); } catch { /* fine */ }
|
||||||
|
|
||||||
|
function themeName(id) {
|
||||||
|
return themeList.find((t) => t.id === id)?.name || id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderThemes() {
|
||||||
|
themeSwatches.innerHTML = '';
|
||||||
|
for (const t of themeList) {
|
||||||
|
const current = t.id === currentTheme;
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'theme-swatch' + (current ? ' is-current' : '');
|
||||||
|
btn.setAttribute('role', 'radio');
|
||||||
|
btn.setAttribute('aria-checked', current ? 'true' : 'false');
|
||||||
|
btn.title = current ? `${t.name} — the current theme` : `Switch every dashboard to ${t.name}`;
|
||||||
|
const preview = document.createElement('span');
|
||||||
|
preview.className = 'theme-preview';
|
||||||
|
preview.style.setProperty('--sw-bg', String(t.colors?.bg || '#000'));
|
||||||
|
preview.style.setProperty('--sw-panel', String(t.colors?.panel || '#222'));
|
||||||
|
preview.style.setProperty('--sw-accent', String(t.colors?.accent || '#fff'));
|
||||||
|
const name = document.createElement('span');
|
||||||
|
name.className = 'theme-name';
|
||||||
|
name.textContent = t.name;
|
||||||
|
btn.append(preview, name);
|
||||||
|
btn.addEventListener('click', () => chooseTheme(t.id));
|
||||||
|
themeSwatches.appendChild(btn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the server's theme and list. Applied to this page either way (the
|
||||||
|
* endpoint is public, so even the login card wears the right palette); the
|
||||||
|
* swatch section itself is only revealed for an unlocked page.
|
||||||
|
*/
|
||||||
|
async function loadTheme(reveal = true) {
|
||||||
|
let body;
|
||||||
|
try {
|
||||||
|
body = await api('/api/theme');
|
||||||
|
} catch {
|
||||||
|
if (reveal) themeSection.hidden = true; // a server without themes — nothing to offer
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
themeList = Array.isArray(body.themes) ? body.themes.filter((t) => t && t.id && t.name) : [];
|
||||||
|
currentTheme = String(body.theme || '');
|
||||||
|
applyTheme(currentTheme);
|
||||||
|
renderThemes();
|
||||||
|
if (reveal) themeSection.hidden = !themeList.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadTheme(false);
|
||||||
|
|
||||||
|
async function chooseTheme(id) {
|
||||||
|
if (id === currentTheme) return;
|
||||||
|
const previous = currentTheme;
|
||||||
|
currentTheme = id;
|
||||||
|
applyTheme(id); // at once, here — the server's event confirms it everywhere else
|
||||||
|
renderThemes();
|
||||||
|
themeStatus.textContent = '';
|
||||||
|
themeStatus.className = 'admin-status';
|
||||||
|
try {
|
||||||
|
await api('/api/admin/theme', { method: 'POST', body: JSON.stringify({ theme: id }) });
|
||||||
|
themeStatus.textContent = `${themeName(id)} — every open dashboard switched.`;
|
||||||
|
themeStatus.className = 'admin-status ok';
|
||||||
|
} catch (e) {
|
||||||
|
currentTheme = previous;
|
||||||
|
applyTheme(previous);
|
||||||
|
renderThemes();
|
||||||
|
themeStatus.textContent = e.message;
|
||||||
|
themeStatus.className = 'admin-status error';
|
||||||
|
if (e.authRequired) showLogin();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Follow a theme change made elsewhere (another admin page) live.
|
||||||
|
let themeEvents = null;
|
||||||
|
let themeEventsRetry = null;
|
||||||
|
|
||||||
|
function subscribeThemeEvents() {
|
||||||
|
try { themeEvents?.close(); } catch { /* not open */ }
|
||||||
|
themeEvents = new EventSource('/api/events');
|
||||||
|
themeEvents.addEventListener('theme', (e) => {
|
||||||
|
let theme = '';
|
||||||
|
try { theme = String(JSON.parse(e.data).theme || ''); } catch { return; }
|
||||||
|
if (!theme || theme === currentTheme) return;
|
||||||
|
currentTheme = theme;
|
||||||
|
applyTheme(theme);
|
||||||
|
renderThemes();
|
||||||
|
});
|
||||||
|
themeEvents.onerror = () => {
|
||||||
|
if (themeEvents.readyState === EventSource.CLOSED) {
|
||||||
|
clearTimeout(themeEventsRetry);
|
||||||
|
themeEventsRetry = setTimeout(subscribeThemeEvents, 4000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribeThemeEvents();
|
||||||
|
|
||||||
/* ── the repo: available modules, module updates, shell update ──────── */
|
/* ── the repo: available modules, module updates, shell update ──────── */
|
||||||
|
|
||||||
function fmtTime(ms) {
|
function fmtTime(ms) {
|
||||||
|
|||||||
@@ -39,11 +39,14 @@
|
|||||||
<div class="admin-status" id="update-status"></div>
|
<div class="admin-status" id="update-status"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="theme-section" hidden>
|
||||||
|
<h1 class="admin-h1">Theme</h1>
|
||||||
|
<div class="theme-swatches" id="theme-swatches" role="radiogroup" aria-label="Theme"></div>
|
||||||
|
<div class="admin-status" id="theme-status"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="modules-section" hidden>
|
<section id="modules-section" hidden>
|
||||||
<h1 class="admin-h1">Modules</h1>
|
<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>
|
|
||||||
<p class="admin-copy admin-storage" id="storage-note" hidden>Saved on the server at
|
<p class="admin-copy admin-storage" id="storage-note" hidden>Saved on the server at
|
||||||
<code id="storage-path"></code> — outside the app folder, so updating ProdDash keeps them.</p>
|
<code id="storage-path"></code> — outside the app folder, so updating ProdDash keeps them.</p>
|
||||||
<div id="modules-list"></div>
|
<div id="modules-list"></div>
|
||||||
@@ -51,9 +54,6 @@
|
|||||||
|
|
||||||
<section id="available-section" hidden>
|
<section id="available-section" hidden>
|
||||||
<h1 class="admin-h1">Available modules</h1>
|
<h1 class="admin-h1">Available modules</h1>
|
||||||
<p class="admin-copy">Modules in the ProdDash repo that aren't installed here. Installing downloads the module
|
|
||||||
into the server's data directory — no restart, nothing written into the app folder. A module that needs a
|
|
||||||
newer ProdDash can't be installed until the shell is updated.</p>
|
|
||||||
<div class="catalog-toolbar">
|
<div class="catalog-toolbar">
|
||||||
<button class="btn small" id="catalog-refresh">Check again</button>
|
<button class="btn small" id="catalog-refresh">Check again</button>
|
||||||
<span class="admin-status" id="catalog-status"></span>
|
<span class="admin-status" id="catalog-status"></span>
|
||||||
@@ -62,9 +62,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="layouts-section" hidden>
|
<section id="layouts-section" hidden>
|
||||||
<h1 class="admin-h1">Named layouts</h1>
|
<h1 class="admin-h1">Saved 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>
|
<div id="layouts-list"></div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
+7
-8
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
|
|
||||||
<button class="tb-btn icon" id="fs-btn" title="Fullscreen — auto-fits the layout to the screen" aria-label="Fullscreen">
|
<button class="tb-btn icon" id="fs-btn" title="Fullscreen" aria-label="Fullscreen">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
<path d="M4 9V5a1 1 0 0 1 1-1h4"/><path d="M20 9V5a1 1 0 0 0-1-1h-4"/>
|
<path d="M4 9V5a1 1 0 0 1 1-1h4"/><path d="M20 9V5a1 1 0 0 0-1-1h-4"/>
|
||||||
<path d="M4 15v4a1 1 0 0 0 1 1h4"/><path d="M20 15v4a1 1 0 0 1-1 1h-4"/>
|
<path d="M4 15v4a1 1 0 0 0 1 1h4"/><path d="M20 15v4a1 1 0 0 1-1 1h-4"/>
|
||||||
@@ -27,12 +27,14 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="dropdown" id="add-dd">
|
<div class="dropdown" id="add-dd">
|
||||||
<button class="tb-btn icon" id="add-btn" title="Add a module tile" aria-label="Add tile">+</button>
|
<button class="tb-btn icon" id="add-btn" title="Add tile" aria-label="Add tile">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>
|
||||||
|
</button>
|
||||||
<div class="menu" id="add-menu" hidden></div>
|
<div class="menu" id="add-menu" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="dropdown" id="layout-dd">
|
<div class="dropdown" id="layout-dd">
|
||||||
<button class="tb-btn icon" id="layout-btn" title="Save, load or reset layouts" aria-label="Layouts">
|
<button class="tb-btn icon" id="layout-btn" title="Layouts" aria-label="Layouts">
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
<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="3" width="8" height="8" rx="1.5"/>
|
||||||
<rect x="3" y="13" width="8" height="8" rx="1.5"/>
|
<rect x="3" y="13" width="8" height="8" rx="1.5"/>
|
||||||
@@ -42,11 +44,8 @@
|
|||||||
<div class="menu" id="layout-menu" hidden></div>
|
<div class="menu" id="layout-menu" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a class="tb-btn" id="admin-link" href="/admin/" title="Configure modules (server-wide)">
|
<a class="tb-btn icon" id="admin-link" href="/admin/" title="Admin" aria-label="Admin">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
<svg width="14" height="14" viewBox="0 0 90 90" fill="currentColor" aria-hidden="true"><path d="M 82.94 32.538 c -2.685 0 -5.093 -1.545 -6.327 -3.929 c -0.245 -0.476 -0.504 -0.948 -0.774 -1.416 c -0.27 -0.468 -0.55 -0.928 -0.84 -1.378 c -1.448 -2.26 -1.582 -5.117 -0.239 -7.443 c 1.951 -3.378 0.792 -7.696 -2.584 -9.645 l -9.352 -5.4 c -3.378 -1.951 -7.696 -0.792 -9.645 2.584 l -0.002 0.003 c -1.322 2.291 -3.815 3.641 -6.456 3.512 c -1.149 -0.056 -2.301 -0.057 -3.455 0 c -2.638 0.128 -5.124 -1.226 -6.444 -3.513 l -0.002 -0.002 c -1.951 -3.378 -6.269 -4.535 -9.645 -2.584 l -9.352 5.4 c -3.378 1.951 -4.535 6.269 -2.584 9.645 l 0.002 0.002 c 1.321 2.286 1.25 5.117 -0.18 7.336 c -0.625 0.972 -1.201 1.97 -1.726 2.993 c -1.209 2.353 -3.625 3.836 -6.269 3.836 H 7.063 c -3.9 0 -7.061 3.162 -7.061 7.061 v 10.801 c 0 3.9 3.162 7.061 7.061 7.061 l 0 0 c 2.685 0 5.093 1.545 6.327 3.929 c 0.245 0.475 0.504 0.948 0.774 1.416 c 0.27 0.468 0.55 0.928 0.84 1.378 c 1.448 2.26 1.582 5.117 0.239 7.443 c -1.951 3.378 -0.792 7.696 2.584 9.645 l 9.352 5.4 c 3.378 1.951 7.696 0.792 9.645 -2.584 l 0.002 -0.003 c 1.322 -2.291 3.815 -3.641 6.456 -3.512 c 1.149 0.056 2.301 0.057 3.455 0 c 2.638 -0.128 5.124 1.226 6.444 3.513 l 0.002 0.002 c 1.951 3.378 6.268 4.535 9.645 2.584 l 9.352 -5.4 c 3.378 -1.949 4.535 -6.268 2.584 -9.645 l -0.002 -0.002 c -1.321 -2.286 -1.25 -5.117 0.18 -7.336 c 0.625 -0.972 1.201 -1.97 1.726 -2.993 c 1.209 -2.353 3.625 -3.836 6.269 -3.836 h 0.003 c 3.9 0 7.061 -3.162 7.061 -7.061 V 39.599 C 90.001 35.7 86.84 32.538 82.94 32.538 z M 54.998 62.318 c -9.565 5.523 -21.796 2.245 -27.317 -7.32 c -5.523 -9.565 -2.245 -21.796 7.32 -27.317 s 21.796 -2.245 27.317 7.32 C 67.841 44.566 64.563 56.797 54.998 62.318 z"/></svg>
|
||||||
<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>
|
</a>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
+709
-116
File diff suppressed because it is too large
Load Diff
+266
-26
@@ -10,6 +10,7 @@
|
|||||||
--text: #e6edf3;
|
--text: #e6edf3;
|
||||||
--muted: #8b98a5;
|
--muted: #8b98a5;
|
||||||
--accent: #2ee59a;
|
--accent: #2ee59a;
|
||||||
|
--accent-text: #06281a; /* text that sits on --accent (buttons, badges) */
|
||||||
--danger: #f4433c;
|
--danger: #f4433c;
|
||||||
--warn: #f0b429;
|
--warn: #f0b429;
|
||||||
/* grid geometry (shell.js reads these) */
|
/* grid geometry (shell.js reads these) */
|
||||||
@@ -169,6 +170,7 @@ body.menu-hidden #topbar {
|
|||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
.menu-item[disabled] { opacity: 0.45; cursor: default; }
|
||||||
.menu-item.danger { color: var(--danger); }
|
.menu-item.danger { color: var(--danger); }
|
||||||
.menu-note {
|
.menu-note {
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
@@ -232,6 +234,18 @@ body.menu-hidden #topbar {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A tile the user gave a colour: a wash over the panel, a stronger edge and
|
||||||
|
a tinted title bar. Module content keeps its own colours — this is the
|
||||||
|
tile's chrome, so a tinted tile still reads like every other one. */
|
||||||
|
.tile.is-tinted {
|
||||||
|
background: color-mix(in srgb, var(--tile-tint) 14%, var(--panel));
|
||||||
|
border-color: color-mix(in srgb, var(--tile-tint) 45%, var(--border));
|
||||||
|
}
|
||||||
|
.tile.is-tinted > .tile-head {
|
||||||
|
background: color-mix(in srgb, var(--tile-tint) 20%, transparent);
|
||||||
|
border-bottom-color: color-mix(in srgb, var(--tile-tint) 35%, var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
.tile.dragging,
|
.tile.dragging,
|
||||||
.tile.resizing {
|
.tile.resizing {
|
||||||
z-index: 40;
|
z-index: 40;
|
||||||
@@ -304,6 +318,26 @@ body.menu-hidden #topbar {
|
|||||||
.tile-btn:hover { background: var(--border); color: var(--text); }
|
.tile-btn:hover { background: var(--border); color: var(--text); }
|
||||||
.tile-btn.close:hover { color: var(--danger); }
|
.tile-btn.close:hover { color: var(--danger); }
|
||||||
.tile-btn.active { color: var(--accent); }
|
.tile-btn.active { color: var(--accent); }
|
||||||
|
.tile-btn.is-open,
|
||||||
|
.tile-btn.is-open:hover { background: var(--accent); color: var(--accent-text); }
|
||||||
|
|
||||||
|
/* the shell's tooltip: shown at once under the button the pointer is on */
|
||||||
|
#tip {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 200;
|
||||||
|
pointer-events: none;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
#tip[hidden] { display: none; }
|
||||||
.tile-btn svg { width: 13px; height: 13px; display: block; }
|
.tile-btn svg { width: 13px; height: 13px; display: block; }
|
||||||
|
|
||||||
/* controls a module adds to its own title bar (moduleApi.header) */
|
/* controls a module adds to its own title bar (moduleApi.header) */
|
||||||
@@ -532,28 +566,122 @@ body.fullscreen #grid { padding-bottom: 0; }
|
|||||||
|
|
||||||
/* ── per-tile settings popover ─────────────────────────────────────── */
|
/* ── per-tile settings popover ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* A small window beside its tile (shell.js places and clamps it), not a
|
||||||
|
box inside the tile: a tile can be smaller than its own settings, and the
|
||||||
|
whole point of live settings is watching the tile change. */
|
||||||
.tile-settings {
|
.tile-settings {
|
||||||
position: absolute;
|
position: fixed;
|
||||||
top: 36px;
|
z-index: 80;
|
||||||
right: 6px;
|
display: flex;
|
||||||
z-index: 45;
|
flex-direction: column;
|
||||||
min-width: 220px;
|
/* as wide as its content wants and no wider: a couple of switches make a
|
||||||
max-width: calc(100% - 12px);
|
small window, a three-column checklist a wide one */
|
||||||
max-height: calc(100% - 40px);
|
width: max-content;
|
||||||
overflow-y: auto;
|
min-width: 150px;
|
||||||
|
max-width: min(560px, calc(100vw - 16px));
|
||||||
|
max-height: calc(100vh - 16px);
|
||||||
|
overflow: hidden;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
/* the grab bar: a slim accent strip with a grip mark, nothing to read or
|
||||||
|
press — the gear that opened the window wears the same colour meanwhile */
|
||||||
|
.tile-settings-bar {
|
||||||
|
flex: none;
|
||||||
|
height: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--accent);
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.tile-settings-bar::before {
|
||||||
|
content: "";
|
||||||
|
width: 34px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--accent-text);
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity 0.12s;
|
||||||
|
}
|
||||||
|
.tile-settings-bar:hover::before { opacity: 1; }
|
||||||
|
.tile-settings .field[hidden] { display: none; }
|
||||||
|
/* the tint, the shell's own setting, under a hairline at the foot */
|
||||||
|
.tile-settings .tint-field {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
padding-top: 9px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.tile-settings .tint-clear {
|
||||||
|
flex: none;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: none;
|
||||||
|
color: var(--muted);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tile-settings .tint-clear:hover { color: var(--text); background: var(--border); }
|
||||||
|
.tile-settings .tint-clear[hidden] { display: none; }
|
||||||
|
.tile-settings-bar:active { cursor: grabbing; }
|
||||||
|
.tile-settings-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55);
|
|
||||||
}
|
}
|
||||||
|
/* Help lines never widen the window: they take no room of their own when the
|
||||||
|
content is measured, then wrap to whatever width the fields settled on. */
|
||||||
|
.tile-settings .field-help,
|
||||||
|
.tile-settings .field-group-help,
|
||||||
|
.tile-settings .multiselect-empty { width: 0; min-width: 100%; }
|
||||||
|
/* a group's on/off sits in its title row; the body dims while it is off */
|
||||||
|
.tile-settings .field-group-title { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.tile-settings .field-group-name { flex: 1; min-width: 0; }
|
||||||
|
.tile-settings .group-toggle { margin: 0 0 0 auto; }
|
||||||
|
.tile-settings .group-toggle .check-label { display: none; }
|
||||||
|
.tile-settings .field-group.is-off .field-group-body { opacity: 0.45; }
|
||||||
|
/* several picks from a list (type `multiselect`) */
|
||||||
|
.tile-settings .multiselect-field { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.tile-settings .multiselect { display: flex; flex-direction: column; gap: 3px; }
|
||||||
|
.tile-settings .multiselect.is-columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--cols, 2), minmax(0, 1fr));
|
||||||
|
gap: 3px 14px;
|
||||||
|
}
|
||||||
|
.tile-settings .multiselect-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tile-settings .multiselect-row .check-label { flex: 1 1 0; min-width: 0; }
|
||||||
|
.tile-settings .multiselect-empty { color: var(--muted); font-size: 12px; }
|
||||||
|
/* a text built from offered variables (type `template`) */
|
||||||
|
.tile-settings .template-vars { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 2px; }
|
||||||
|
.tile-settings .template-var {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.tile-settings .template-var:hover { border-color: var(--accent); }
|
||||||
|
.tile-settings .template-var:active { cursor: grabbing; }
|
||||||
|
|
||||||
/* A popover with grouped settings needs room for its columns; it still
|
|
||||||
never grows past the tile (max-width above). */
|
|
||||||
.tile-settings.has-groups {
|
|
||||||
min-width: min(540px, calc(100% - 12px));
|
|
||||||
container-type: inline-size;
|
|
||||||
}
|
|
||||||
.tile-settings .field-group {
|
.tile-settings .field-group {
|
||||||
margin: 0 0 10px;
|
margin: 0 0 10px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
@@ -589,19 +717,20 @@ body.fullscreen #grid { padding-bottom: 0; }
|
|||||||
.tile-settings details.field-group[open] > summary.field-group-title::before { transform: rotate(90deg); }
|
.tile-settings details.field-group[open] > summary.field-group-title::before { transform: rotate(90deg); }
|
||||||
.tile-settings details.field-group[open] > summary.field-group-title { margin-bottom: 8px; }
|
.tile-settings details.field-group[open] > summary.field-group-title { margin-bottom: 8px; }
|
||||||
.tile-settings .field-group .field:last-child { margin-bottom: 0; }
|
.tile-settings .field-group .field:last-child { margin-bottom: 0; }
|
||||||
|
/* Settings apply as they change, so nothing sits under the last field — its
|
||||||
|
bottom margin would only pad the popover's own padding a second time. */
|
||||||
|
.tile-settings-body > :last-child { margin-bottom: 0; }
|
||||||
.tile-settings .field-group-body.is-columns {
|
.tile-settings .field-group-body.is-columns {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(var(--cols, 2), minmax(0, 1fr));
|
grid-template-columns: repeat(var(--cols, 2), minmax(0, 1fr));
|
||||||
gap: 3px 14px;
|
gap: 6px 14px; /* the grid gap is the whole row spacing: no per-field margin */
|
||||||
}
|
}
|
||||||
.tile-settings .field-group-body.is-columns .field { margin-bottom: 3px; }
|
.tile-settings .field-group-body.is-columns .field { margin-bottom: 0; }
|
||||||
.tile-settings .field-group-body.is-columns .field-group-help { grid-column: 1 / -1; }
|
.tile-settings .field-group-body.is-columns .field-group-help { grid-column: 1 / -1; }
|
||||||
@container (max-width: 420px) {
|
/* a list of switches or a template with its chips is a row of its own, however
|
||||||
.tile-settings .field-group-body.is-columns { grid-template-columns: 1fr 1fr; }
|
many columns the group lays its other fields out in */
|
||||||
}
|
.tile-settings .field-group-body.is-columns .multiselect-field,
|
||||||
@container (max-width: 260px) {
|
.tile-settings .field-group-body.is-columns .template-field { grid-column: 1 / -1; }
|
||||||
.tile-settings .field-group-body.is-columns { grid-template-columns: 1fr; }
|
|
||||||
}
|
|
||||||
.tile-settings .field-group-help,
|
.tile-settings .field-group-help,
|
||||||
.tile-settings .field-help {
|
.tile-settings .field-help {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -636,14 +765,64 @@ body.fullscreen #grid { padding-bottom: 0; }
|
|||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
/* boolean / switch settings: label on the left, switch on the right (help,
|
||||||
|
when there is any, wraps under both) */
|
||||||
.field.check {
|
.field.check {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
justify-content: space-between;
|
||||||
|
gap: 4px 10px;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.field.check input { width: 15px; height: 15px; accent-color: var(--accent); }
|
.field.check .check-label { flex: 1 1 0; min-width: 0; }
|
||||||
|
.field.check .field-help { flex-basis: 100%; }
|
||||||
|
|
||||||
|
/* The switch: the admin page's module switch, same track, knob and colours,
|
||||||
|
so a toggle reads the same on both surfaces. On = shown/enabled (accent),
|
||||||
|
off = hidden/disabled (border track, muted knob). The real checkbox lies
|
||||||
|
invisibly over the track — still focusable, so Tab reaches it and Space
|
||||||
|
flips it — and the label around it makes the text clickable too. */
|
||||||
|
.switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.switch input {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.switch .track {
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
width: 34px;
|
||||||
|
height: 19px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--border);
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.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); }
|
||||||
|
.switch input:focus-visible + .track { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
|
||||||
/* color fields: label on the left, swatch on the right */
|
/* color fields: label on the left, swatch on the right */
|
||||||
.field.color-field {
|
.field.color-field {
|
||||||
@@ -709,7 +888,7 @@ body.fullscreen #grid { padding-bottom: 0; }
|
|||||||
.btn.primary {
|
.btn.primary {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
color: #06281a;
|
color: var(--accent-text);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -756,3 +935,64 @@ body.fullscreen #grid { padding-bottom: 0; }
|
|||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
#topbar { padding: 0 8px; gap: 6px; }
|
#topbar { padding: 0 8px; gap: 6px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── themes ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Preset palettes, chosen in Admin → Theme and applied by shell.js (and the
|
||||||
|
admin page) as html[data-theme="…"]. Each swaps exactly the eight palette
|
||||||
|
variables the module guide names — nothing else — so every module and
|
||||||
|
every shell element follows, and a colour a tile is configured with (set
|
||||||
|
on the tile itself) still wins. The :root block at the top of this file is
|
||||||
|
"booth", the default; the list of ids and names lives in server.js
|
||||||
|
(GET /api/theme). All four are booth-grade: high contrast, the accent
|
||||||
|
nowhere near the danger red or the warning amber, muted still readable. */
|
||||||
|
|
||||||
|
/* Harbor — deep navy, cyan accent */
|
||||||
|
html[data-theme="harbor"] {
|
||||||
|
--bg: #06101f;
|
||||||
|
--panel: #0c1a30;
|
||||||
|
--border: #1a2b48;
|
||||||
|
--text: #e6eefb;
|
||||||
|
--muted: #8ea4c8;
|
||||||
|
--accent: #2ad4ee;
|
||||||
|
--danger: #ff5a5f;
|
||||||
|
--warn: #f7b53b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Graphite — neutral charcoal, electric-blue accent */
|
||||||
|
html[data-theme="graphite"] {
|
||||||
|
--bg: #0f1012;
|
||||||
|
--panel: #17191d;
|
||||||
|
--border: #2a2d33;
|
||||||
|
--text: #f0f2f4;
|
||||||
|
--muted: #9ca3ad;
|
||||||
|
--accent: #6cb4ff;
|
||||||
|
--danger: #ff4d4f;
|
||||||
|
--warn: #ffb224;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ember — warm broadcast black, orange accent (danger stays a cool red,
|
||||||
|
warn a pale yellow, so the three status colours never blur) */
|
||||||
|
html[data-theme="ember"] {
|
||||||
|
--bg: #120c0a;
|
||||||
|
--panel: #1d1411;
|
||||||
|
--border: #35251e;
|
||||||
|
--text: #f6ede6;
|
||||||
|
--muted: #b39b8c;
|
||||||
|
--accent: #ff8f3a;
|
||||||
|
--danger: #f03247;
|
||||||
|
--warn: #ffd86b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Daylight — the light one, for a front-of-house desk in daytime */
|
||||||
|
html[data-theme="daylight"] {
|
||||||
|
--bg: #eef1f5;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--border: #cfd6e0;
|
||||||
|
--text: #14181f;
|
||||||
|
--muted: #5b6675;
|
||||||
|
--accent: #0b8a5f;
|
||||||
|
--accent-text: #ffffff; /* dark text drowns on this accent */
|
||||||
|
--danger: #d1242f;
|
||||||
|
--warn: #b26a00;
|
||||||
|
}
|
||||||
|
|||||||
@@ -373,14 +373,17 @@ function mountModule(id) {
|
|||||||
// Re-require fresh config on every (re)mount; the module code itself stays cached.
|
// Re-require fresh config on every (re)mount; the module code itself stays cached.
|
||||||
const mod = require(serverPath);
|
const mod = require(serverPath);
|
||||||
const config = effectiveConfig(id);
|
const config = effectiveConfig(id);
|
||||||
|
// What a module needs to reach the rest of ProdDash — another module's
|
||||||
|
// routes over loopback, chiefly — without guessing.
|
||||||
|
const shell = { port: PORT };
|
||||||
if (typeof mod.init === 'function') {
|
if (typeof mod.init === 'function') {
|
||||||
entry.handle = mod.init({ config, log }) || null;
|
entry.handle = mod.init({ config, log, shell }) || null;
|
||||||
}
|
}
|
||||||
if (typeof mod.routes === 'function') {
|
if (typeof mod.routes === 'function') {
|
||||||
entry.routes = parseRouteTable(mod.routes({ config, log }));
|
entry.routes = parseRouteTable(mod.routes({ config, log, shell }));
|
||||||
}
|
}
|
||||||
if (typeof mod.tiles === 'function') {
|
if (typeof mod.tiles === 'function') {
|
||||||
entry.tiles = () => mod.tiles({ config, log });
|
entry.tiles = () => mod.tiles({ config, log, shell });
|
||||||
}
|
}
|
||||||
log('mounted' + (entry.routes.length ? ` (${entry.routes.length} routes)` : ''));
|
log('mounted' + (entry.routes.length ? ` (${entry.routes.length} routes)` : ''));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -460,7 +463,20 @@ function sanitizeTileEntry(raw) {
|
|||||||
name: String(raw.name),
|
name: String(raw.name),
|
||||||
description: raw.description ? String(raw.description) : '',
|
description: raw.description ? String(raw.description) : '',
|
||||||
settings: raw.settings && typeof raw.settings === 'object' ? raw.settings : {},
|
settings: raw.settings && typeof raw.settings === 'object' ? raw.settings : {},
|
||||||
|
// at most one tile of this entry per dashboard (the picker greys it out)
|
||||||
|
single: raw.single === true,
|
||||||
};
|
};
|
||||||
|
// An entry may offer its own gear settings — a solo timer wants none of a
|
||||||
|
// full tile's switches. Same shape as the manifest's instanceSchema/Groups.
|
||||||
|
for (const key of ['instanceSchema', 'instanceGroups']) {
|
||||||
|
if (raw[key] && typeof raw[key] === 'object') out[key] = raw[key];
|
||||||
|
}
|
||||||
|
// Earlier variant ids this entry stands for, so a tile added under one of
|
||||||
|
// them (a layout from an older module version) gets this entry's settings.
|
||||||
|
if (Array.isArray(raw.aliases)) {
|
||||||
|
const aliases = raw.aliases.map((a) => String(a)).filter(Boolean);
|
||||||
|
if (aliases.length) out.aliases = aliases;
|
||||||
|
}
|
||||||
for (const key of ['minSize', 'defaultSize']) {
|
for (const key of ['minSize', 'defaultSize']) {
|
||||||
const size = raw[key];
|
const size = raw[key];
|
||||||
if (size && Number(size.w) > 0 && Number(size.h) > 0) {
|
if (size && Number(size.w) > 0 && Number(size.h) > 0) {
|
||||||
@@ -510,6 +526,8 @@ async function clientManifest(id) {
|
|||||||
instanceSchema: man.instanceSchema || {},
|
instanceSchema: man.instanceSchema || {},
|
||||||
instanceGroups: man.instanceGroups && typeof man.instanceGroups === 'object' ? man.instanceGroups : {},
|
instanceGroups: man.instanceGroups && typeof man.instanceGroups === 'object' ? man.instanceGroups : {},
|
||||||
hasServer: Boolean(man.server),
|
hasServer: Boolean(man.server),
|
||||||
|
// capabilities other modules consume ("timers") — see the module guide
|
||||||
|
provides: Array.isArray(man.provides) ? man.provides.map(String) : [],
|
||||||
config: clientConfig(id),
|
config: clientConfig(id),
|
||||||
tiles: await moduleTiles(id),
|
tiles: await moduleTiles(id),
|
||||||
};
|
};
|
||||||
@@ -541,6 +559,196 @@ setInterval(() => {
|
|||||||
}
|
}
|
||||||
}, 25000).unref();
|
}, 25000).unref();
|
||||||
|
|
||||||
|
/* ── one stream per page ─────────────────────────────────────────────
|
||||||
|
Browsers allow about six HTTP/1.1 connections to a host and an open
|
||||||
|
EventSource holds one for as long as it lives. A dashboard with six
|
||||||
|
modules ran out — Safari first — and from then on every further request
|
||||||
|
(a remounted tile's /state, an avatar) waited in the browser's queue for
|
||||||
|
ever. So a page opens ONE EventSource, GET /api/stream?s=<keys>, naming
|
||||||
|
the streams it wants: `slack:/stream`, `pco-plan:/timers/stream`, `shell:`
|
||||||
|
for the shell's own events. The server opens each module stream for it
|
||||||
|
over loopback (the module's route is untouched and still serves curl and
|
||||||
|
other tools), and forwards every frame with the event name prefixed by
|
||||||
|
its key — `event: slack:/stream|state`. Two frames of its own bracket a
|
||||||
|
stream's life: `<key>|__open` when the upstream answers, `<key>|__down`
|
||||||
|
when it ends (it is re-opened after a pause while the page stays). */
|
||||||
|
|
||||||
|
const MUX_MAX_STREAMS = 32;
|
||||||
|
const MUX_RETRY_MS = 3000;
|
||||||
|
const MUX_RETRY_REFUSED_MS = 10000;
|
||||||
|
const MUX_PING_MS = 15000;
|
||||||
|
const MUX_KEY_RE = /^([A-Za-z0-9_-]+):(\/[A-Za-z0-9_\-./]*)?$/;
|
||||||
|
|
||||||
|
function parseMuxKeys(search) {
|
||||||
|
const params = new URLSearchParams(search || '');
|
||||||
|
const out = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const raw of String(params.get('s') || '').split(',')) {
|
||||||
|
const key = raw.trim();
|
||||||
|
const m = MUX_KEY_RE.exec(key);
|
||||||
|
if (!m || seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
out.push({ key, id: m[1], subPath: m[2] || '' });
|
||||||
|
if (out.length >= MUX_MAX_STREAMS) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMuxStream(req, res, search) {
|
||||||
|
const wanted = parseMuxKeys(search);
|
||||||
|
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 */ }
|
||||||
|
let closed = false;
|
||||||
|
const send = (frame) => {
|
||||||
|
if (closed) return;
|
||||||
|
try {
|
||||||
|
res.write(frame);
|
||||||
|
} catch {
|
||||||
|
/* the client is gone; 'close' follows */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// The shell's own events ride along under `shell:` — a stand-in sits in
|
||||||
|
// shellStreams and rewrites the event line of every frame it is handed.
|
||||||
|
let shellProxy = null;
|
||||||
|
const upstreams = [];
|
||||||
|
for (const entry of wanted) {
|
||||||
|
if (entry.id === 'shell') {
|
||||||
|
if (shellProxy) continue;
|
||||||
|
shellProxy = { write(frame) { send(String(frame).replace(/^event: /m, `event: ${entry.key}|`)); } };
|
||||||
|
shellStreams.add(shellProxy);
|
||||||
|
send(`event: ${entry.key}|__open\ndata: {}\n\n`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
upstreams.push({ ...entry, req: null, timer: null, gone: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function forwardBlock(entry, block) {
|
||||||
|
let name = 'message';
|
||||||
|
const data = [];
|
||||||
|
for (const line of block.split('\n')) {
|
||||||
|
if (line.startsWith(':')) continue; // the upstream's keepalive; this connection pings on its own
|
||||||
|
if (line.startsWith('event:')) name = line.slice(6).trim() || 'message';
|
||||||
|
else if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, ''));
|
||||||
|
// id: and retry: belong to the upstream connection, not to the page's
|
||||||
|
}
|
||||||
|
if (!data.length) return;
|
||||||
|
send(`event: ${entry.key}|${name}\n${data.map((d) => `data: ${d}`).join('\n')}\n\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function down(entry, why, delay) {
|
||||||
|
if (closed || entry.gone) return;
|
||||||
|
entry.gone = true;
|
||||||
|
send(`event: ${entry.key}|__down\ndata: ${JSON.stringify({ why })}\n\n`);
|
||||||
|
try { entry.req?.destroy(); } catch { /* already gone */ }
|
||||||
|
entry.req = null;
|
||||||
|
entry.timer = setTimeout(() => {
|
||||||
|
entry.gone = false;
|
||||||
|
open(entry);
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(entry) {
|
||||||
|
if (closed) return;
|
||||||
|
const path = `/api/modules/${encodeURIComponent(entry.id)}${entry.subPath || '/'}`;
|
||||||
|
const up = http.request({ host: '127.0.0.1', port: PORT, path, method: 'GET', headers: { accept: 'text/event-stream' } }, (upRes) => {
|
||||||
|
if (upRes.statusCode !== 200 || !/text\/event-stream/.test(String(upRes.headers['content-type'] || ''))) {
|
||||||
|
upRes.resume();
|
||||||
|
return down(entry, `HTTP ${upRes.statusCode}`, MUX_RETRY_REFUSED_MS);
|
||||||
|
}
|
||||||
|
send(`event: ${entry.key}|__open\ndata: {}\n\n`);
|
||||||
|
let buf = '';
|
||||||
|
upRes.setEncoding('utf8');
|
||||||
|
upRes.on('data', (chunk) => {
|
||||||
|
buf += chunk;
|
||||||
|
let i;
|
||||||
|
while ((i = buf.indexOf('\n\n')) >= 0) {
|
||||||
|
forwardBlock(entry, buf.slice(0, i));
|
||||||
|
buf = buf.slice(i + 2);
|
||||||
|
}
|
||||||
|
if (buf.length > 1e6) buf = ''; // a frame that never ends is dropped, not kept
|
||||||
|
});
|
||||||
|
upRes.on('end', () => down(entry, 'ended', MUX_RETRY_MS));
|
||||||
|
upRes.on('error', (err) => down(entry, err.message, MUX_RETRY_MS));
|
||||||
|
});
|
||||||
|
up.on('error', (err) => down(entry, err.message, MUX_RETRY_MS));
|
||||||
|
up.end();
|
||||||
|
entry.req = up;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of upstreams) open(entry);
|
||||||
|
const ping = setInterval(() => send(': ping\n\n'), MUX_PING_MS);
|
||||||
|
req.on('close', () => {
|
||||||
|
closed = true;
|
||||||
|
clearInterval(ping);
|
||||||
|
if (shellProxy) shellStreams.delete(shellProxy);
|
||||||
|
for (const entry of upstreams) {
|
||||||
|
clearTimeout(entry.timer);
|
||||||
|
try { entry.req?.destroy(); } catch { /* already gone */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── themes ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Preset palettes for the whole dashboard, chosen in Admin → Theme. The
|
||||||
|
variable sets themselves live in public/style.css (html[data-theme="…"]);
|
||||||
|
this list is the one authority on ids and names, and carries the three
|
||||||
|
colours the admin page previews a theme with. The choice is a shell
|
||||||
|
setting — `theme` in the data directory's proddash.json — and reaches open
|
||||||
|
dashboards live as a `theme` shell event. */
|
||||||
|
const THEMES = [
|
||||||
|
{ id: 'booth', name: 'Booth', colors: { bg: '#0b0e12', panel: '#131920', accent: '#2ee59a' } },
|
||||||
|
{ id: 'harbor', name: 'Harbor', colors: { bg: '#06101f', panel: '#0c1a30', accent: '#2ad4ee' } },
|
||||||
|
{ id: 'graphite', name: 'Graphite', colors: { bg: '#0f1012', panel: '#17191d', accent: '#6cb4ff' } },
|
||||||
|
{ id: 'ember', name: 'Ember', colors: { bg: '#120c0a', panel: '#1d1411', accent: '#ff8f3a' } },
|
||||||
|
{ id: 'daylight', name: 'Daylight', colors: { bg: '#eef1f5', panel: '#ffffff', accent: '#0b8a5f' } },
|
||||||
|
];
|
||||||
|
const DEFAULT_THEME = THEMES[0].id;
|
||||||
|
|
||||||
|
function themeExists(id) {
|
||||||
|
return THEMES.some((t) => t.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentTheme = themeExists(shellConfig.theme) ? shellConfig.theme : DEFAULT_THEME;
|
||||||
|
|
||||||
|
/** What GET /api/theme returns — public, since dashboards need it before anyone logs in. */
|
||||||
|
function themeView() {
|
||||||
|
return {
|
||||||
|
theme: currentTheme,
|
||||||
|
themes: THEMES.map((t) => ({ id: t.id, name: t.name, colors: { ...t.colors } })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist the theme into this machine's proddash.json (every other key there
|
||||||
|
* — port, adminPasscode, repo… — is kept), remember it, and tell every open
|
||||||
|
* dashboard and admin page. Throws an httpError for an unknown id or an
|
||||||
|
* unreadable settings file; the route turns that into the response.
|
||||||
|
*/
|
||||||
|
function setTheme(id) {
|
||||||
|
if (!themeExists(id)) throw httpError(400, `Unknown theme "${id}".`);
|
||||||
|
const file = path.join(DATA_DIR, 'proddash.json');
|
||||||
|
let existing = {};
|
||||||
|
if (fs.existsSync(file)) {
|
||||||
|
existing = readJson(file, null);
|
||||||
|
if (!existing || typeof existing !== 'object' || Array.isArray(existing)) {
|
||||||
|
throw httpError(500, `${file} is not a JSON object — fix it by hand before changing the theme.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJson(file, { ...existing, theme: id });
|
||||||
|
currentTheme = id;
|
||||||
|
shellConfig.theme = id;
|
||||||
|
broadcastShellEvent('theme', { theme: id });
|
||||||
|
return themeView();
|
||||||
|
}
|
||||||
|
|
||||||
/* ── the repo: module catalog and shell updates ──────────────────────── */
|
/* ── the repo: module catalog and shell updates ──────────────────────── */
|
||||||
|
|
||||||
/* The admin page installs modules from, and updates the shell against, the
|
/* The admin page installs modules from, and updates the shell against, the
|
||||||
@@ -996,6 +1204,9 @@ function applyConfigPatch(id, patch) {
|
|||||||
}
|
}
|
||||||
if (type === 'number') value = Number(value) || 0;
|
if (type === 'number') value = Number(value) || 0;
|
||||||
else if (type === 'boolean' || type === 'switch') value = Boolean(value);
|
else if (type === 'boolean' || type === 'switch') value = Boolean(value);
|
||||||
|
else if (type === 'multiselect') {
|
||||||
|
value = (Array.isArray(value) ? value : []).map((v) => String(v)).filter(Boolean);
|
||||||
|
}
|
||||||
else if (type === 'endpoint') {
|
else if (type === 'endpoint') {
|
||||||
const src = value && typeof value === 'object' ? value : {};
|
const src = value && typeof value === 'object' ? value : {};
|
||||||
value = {
|
value = {
|
||||||
@@ -1109,6 +1320,22 @@ async function handleAdminApi(req, res, urlPath) {
|
|||||||
return sendJson(res, 200, { ok: true, module: adminModuleView(id) });
|
return sendJson(res, 200, { ok: true, module: adminModuleView(id) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* — theme: { "theme": "<id>" }, validated against THEMES — */
|
||||||
|
if (urlPath === '/api/admin/theme' && 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.' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return sendJson(res, 200, { ok: true, ...setTheme(String(body.theme ?? '')) });
|
||||||
|
} catch (err) {
|
||||||
|
return sendJson(res, err.status || 500, { error: err.message || String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sendJson(res, 404, { error: 'Unknown admin endpoint.' });
|
sendJson(res, 404, { error: 'Unknown admin endpoint.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1352,6 +1579,16 @@ function handleRequest(req, res) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* — theme: public (dashboards need it without a passcode); changed via POST /api/admin/theme — */
|
||||||
|
if (urlPath === '/api/theme' && req.method === 'GET') {
|
||||||
|
return sendJson(res, 200, themeView());
|
||||||
|
}
|
||||||
|
|
||||||
|
/* — every stream a page wants, over one connection — */
|
||||||
|
if (urlPath === '/api/stream' && req.method === 'GET') {
|
||||||
|
return handleMuxStream(req, res, search);
|
||||||
|
}
|
||||||
|
|
||||||
/* — shell events: open dashboards learn about admin changes live — */
|
/* — shell events: open dashboards learn about admin changes live — */
|
||||||
if (urlPath === '/api/events' && req.method === 'GET') {
|
if (urlPath === '/api/events' && req.method === 'GET') {
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
|
|||||||
@@ -11,6 +11,10 @@
|
|||||||
* GET /services/v2/service_types every service type, with its
|
* GET /services/v2/service_types every service type, with its
|
||||||
* parent folder relationship
|
* parent folder relationship
|
||||||
* GET /services/v2/service_types/{id}
|
* GET /services/v2/service_types/{id}
|
||||||
|
* GET /services/v2/service_types/{id}/item_note_categories
|
||||||
|
* the note categories the
|
||||||
|
* items below use, plus one
|
||||||
|
* ("Stage") no note uses yet
|
||||||
* GET /services/v2/service_types/{id}/plans ?filter=future|past, order,
|
* GET /services/v2/service_types/{id}/plans ?filter=future|past, order,
|
||||||
* per_page — today's plan,
|
* per_page — today's plan,
|
||||||
* next week's and last week's
|
* next week's and last week's
|
||||||
@@ -129,6 +133,22 @@ const ITEMS = [
|
|||||||
{ id: 'i13', type: 'media', title: 'Walk-out Loop', pos: 'post', length: 600, notes: [['Video', 'Announcements loop until the room clears']] },
|
{ id: 'i13', type: 'media', title: 'Walk-out Loop', pos: 'post', length: 600, notes: [['Video', 'Announcements loop until the room clears']] },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Item note categories, per service type in Planning Center: every category
|
||||||
|
* the items above use, in the order the mock account lists them, plus one no
|
||||||
|
* note uses yet — a tile that has never chosen shows it on as well.
|
||||||
|
*/
|
||||||
|
const NOTE_CATEGORIES = ['Audio', 'Video', 'Lighting', 'ProPresenter', 'Stage'];
|
||||||
|
|
||||||
|
function noteCategoryRecord(name, index, stId) {
|
||||||
|
return {
|
||||||
|
type: 'ItemNoteCategory',
|
||||||
|
id: `${stId}-nc${index + 1}`,
|
||||||
|
attributes: { name, sequence: index + 1, frequently_used: index < 2, deleted_at: null },
|
||||||
|
relationships: { service_type: { data: { type: 'ServiceType', id: stId } } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function itemsDocument(planId, include) {
|
function itemsDocument(planId, include) {
|
||||||
const wants = new Set(String(include || '').split(',').map((s) => s.trim()).filter(Boolean));
|
const wants = new Set(String(include || '').split(',').map((s) => s.trim()).filter(Boolean));
|
||||||
const data = [];
|
const data = [];
|
||||||
@@ -246,6 +266,9 @@ const server = http.createServer((req, res) => {
|
|||||||
const st = SERVICE_TYPES.find((x) => x.id === parts[1]);
|
const st = SERVICE_TYPES.find((x) => x.id === parts[1]);
|
||||||
if (!st) return notFound(res);
|
if (!st) return notFound(res);
|
||||||
if (parts.length === 2) return send(res, 200, { data: serviceTypeRecord(st) });
|
if (parts.length === 2) return send(res, 200, { data: serviceTypeRecord(st) });
|
||||||
|
if (parts[2] === 'item_note_categories' && parts.length === 3) {
|
||||||
|
return collection(res, NOTE_CATEGORIES.map((name, i) => noteCategoryRecord(name, i, st.id)), url);
|
||||||
|
}
|
||||||
if (parts[2] !== 'plans') return notFound(res);
|
if (parts[2] !== 'plans') return notFound(res);
|
||||||
const plans = plansFor(st.id);
|
const plans = plansFor(st.id);
|
||||||
if (parts.length === 3) {
|
if (parts.length === 3) {
|
||||||
|
|||||||
@@ -0,0 +1,417 @@
|
|||||||
|
/**
|
||||||
|
* Mock Slack Web API for developing the slack module without a workspace.
|
||||||
|
*
|
||||||
|
* node tools/slack-mock.js [port] (default 24716)
|
||||||
|
* SLACK_API_BASE=http://127.0.0.1:24716/api node server.js
|
||||||
|
*
|
||||||
|
* Then, in /admin → Slack, paste any token that starts with xoxp- (a user
|
||||||
|
* token: auth.test answers as the location account "Apollo Beach") or xoxb-
|
||||||
|
* (a bot token: bot_id set, user "proddash"); anything else is invalid_auth.
|
||||||
|
* Two more tokens exercise the error paths: xoxp-revoked → token_revoked,
|
||||||
|
* xoxp-noscope → missing_scope (needed channels:history) on history reads.
|
||||||
|
*
|
||||||
|
* Methods, all under /api/<method>, POST form or JSON (GET works too):
|
||||||
|
* auth.test identity for the token (see above)
|
||||||
|
* conversations.list #production, #ops, #campus-leads (private) as
|
||||||
|
* member; #announcements not a member; paginated
|
||||||
|
* conversations.info one channel, with is_member
|
||||||
|
* conversations.history newest-first window (limit); not_in_channel for
|
||||||
|
* #announcements
|
||||||
|
* conversations.replies a thread's parent + replies
|
||||||
|
* users.info display names and profile images (SVG squares
|
||||||
|
* served by this mock under /img/u/<id>.svg)
|
||||||
|
* chat.postMessage appends to the channel (echoes `username` for
|
||||||
|
* bots as a bot_message); text containing "!429"
|
||||||
|
* answers HTTP 429 with Retry-After: 3
|
||||||
|
* emoji.list four custom emoji: two images (/img/e/<name>.svg),
|
||||||
|
* an alias of a standard emoji, an alias of a custom
|
||||||
|
* reactions.add/.remove on a message (or reply) by channel + timestamp;
|
||||||
|
* already_reacted / no_reaction / message_not_found
|
||||||
|
* as Slack answers them; name "nope" → invalid_name
|
||||||
|
*
|
||||||
|
* #production is seeded with mrkdwn, mentions, a link, a thread, a bot
|
||||||
|
* attachment and a file; it gains a new fake message every ~8 s, edits one
|
||||||
|
* every ~25 s, deletes one every ~40 s and adds a thread reply every ~30 s.
|
||||||
|
* #ops gets a quieter trickle (every ~20 s), so a dashboard with both
|
||||||
|
* transcripts shows two channels moving at their own pace — and a
|
||||||
|
* chat.postMessage to either lands in that channel's history, which is how
|
||||||
|
* a send to the *selected* transcript can be watched arriving in the right
|
||||||
|
* tile. Every request is logged.
|
||||||
|
*
|
||||||
|
* Multi-channel dev flow: run this, then ProdDash with SLACK_API_BASE set,
|
||||||
|
* paste xoxp-anything in /admin → Slack, tick #production and #ops under
|
||||||
|
* Channels, Apply, and add both transcripts plus Send message from +.
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
const PORT = Number.parseInt(process.argv[2] || '24716', 10);
|
||||||
|
|
||||||
|
/* ── data ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const USERS = {
|
||||||
|
U001: { name: 'apollo.beach', real_name: 'Apollo Beach', display_name: 'Apollo Beach' },
|
||||||
|
U002: { name: 'michael', real_name: 'Michael McGary', display_name: 'Michael' },
|
||||||
|
U003: { name: 'sarah.lee', real_name: 'Sarah Lee', display_name: 'Sarah' },
|
||||||
|
U004: { name: 'north.attleboro', real_name: 'North Attleboro', display_name: 'North Attleboro' },
|
||||||
|
U005: { name: 'dan.k', real_name: 'Dan Kowalski', display_name: 'Dan K' },
|
||||||
|
UBOT: { name: 'proddash', real_name: 'ProdDash', display_name: '', is_bot: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHANNELS = [
|
||||||
|
{ id: 'C001', name: 'production', is_private: false, is_member: true },
|
||||||
|
{ id: 'C002', name: 'ops', is_private: false, is_member: true },
|
||||||
|
{ id: 'G001', name: 'campus-leads', is_private: true, is_member: true },
|
||||||
|
{ id: 'C003', name: 'announcements', is_private: false, is_member: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
let tsCounter = 0;
|
||||||
|
function ts(offsetMs = 0) {
|
||||||
|
tsCounter += 1;
|
||||||
|
return `${Math.floor((Date.now() + offsetMs) / 1000)}.${String(tsCounter).padStart(6, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** channel id -> messages (ascending); replies live inside their parent. */
|
||||||
|
const history = new Map();
|
||||||
|
for (const c of CHANNELS) history.set(c.id, []);
|
||||||
|
|
||||||
|
function push(channelId, msg) {
|
||||||
|
const list = history.get(channelId);
|
||||||
|
const m = { type: 'message', ts: msg.ts || ts(), ...msg };
|
||||||
|
list.push(m);
|
||||||
|
if (list.length > 400) list.splice(0, list.length - 400);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A message or a reply by timestamp, anywhere in a channel. */
|
||||||
|
function findMessage(channelId, stamp) {
|
||||||
|
for (const m of history.get(channelId) || []) {
|
||||||
|
if (m.ts === stamp) return m;
|
||||||
|
for (const r of m.replies || []) if (r.ts === stamp) return r;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Slack's reactions array: [{ name, users, count }], edited in place. */
|
||||||
|
function addReaction(m, name, userId) {
|
||||||
|
m.reactions = m.reactions || [];
|
||||||
|
let r = m.reactions.find((x) => x.name === name);
|
||||||
|
if (r && r.users.includes(userId)) return 'already_reacted';
|
||||||
|
if (!r) {
|
||||||
|
r = { name, users: [], count: 0 };
|
||||||
|
m.reactions.push(r);
|
||||||
|
}
|
||||||
|
r.users.push(userId);
|
||||||
|
r.count = r.users.length;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeReaction(m, name, userId) {
|
||||||
|
const r = (m.reactions || []).find((x) => x.name === name);
|
||||||
|
if (!r || !r.users.includes(userId)) return 'no_reaction';
|
||||||
|
r.users = r.users.filter((u) => u !== userId);
|
||||||
|
r.count = r.users.length;
|
||||||
|
if (!r.count) m.reactions = m.reactions.filter((x) => x !== r);
|
||||||
|
if (!m.reactions.length) delete m.reactions;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A square SVG with an initial, one hue per id — the mock's stand-in for a
|
||||||
|
profile image or a custom emoji picture. */
|
||||||
|
const IMAGE_HUES = { U001: 150, U002: 210, U003: 330, U004: 30, U005: 270, UBOT: 0, party_parrot: 120, waters: 200 };
|
||||||
|
function squareSvg(id, letter) {
|
||||||
|
const hue = IMAGE_HUES[id] ?? 180;
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72"><rect width="72" height="72" rx="18" fill="hsl(${hue} 50% 45%)"/><text x="36" y="47" text-anchor="middle" font-family="system-ui, sans-serif" font-size="34" font-weight="700" fill="#fff">${letter}</text></svg>`;
|
||||||
|
}
|
||||||
|
const CUSTOM_EMOJI = {
|
||||||
|
party_parrot: (port) => `http://127.0.0.1:${port}/img/e/party_parrot.svg`,
|
||||||
|
waters: (port) => `http://127.0.0.1:${port}/img/e/waters.svg`,
|
||||||
|
thumbsup_all: () => 'alias:+1',
|
||||||
|
parrot: () => 'alias:party_parrot',
|
||||||
|
};
|
||||||
|
|
||||||
|
function seed() {
|
||||||
|
const min = 60000;
|
||||||
|
const at = (minsAgo) => ts(-minsAgo * min);
|
||||||
|
push('C001', { ts: at(58), user: 'U003', subtype: 'channel_join', text: '<@U003> has joined the channel' });
|
||||||
|
push('C001', { ts: at(55), user: 'U002', text: 'Morning all — doors at 9:15, first service 10:00. Run of show is in <#C002|ops>.' });
|
||||||
|
push('C001', { ts: at(50), user: 'U001', text: 'Apollo Beach is up. Stream key checked, ProPresenter online :white_check_mark:' });
|
||||||
|
push('C001', { ts: at(47), user: 'U004', text: 'North Attleboro here — we have *no audio* on the sermon feed yet, working on it' });
|
||||||
|
push('C001', { ts: at(44), user: 'U002', text: '<@U004> try the Dante patch — `Sermon Feed L/R` should be on 31/32. _Ping me if not._' });
|
||||||
|
const parent = push('C001', { ts: at(40), user: 'U003', text: 'Countdown video is the new one this week, right? ~old one~ <-- not this', reply_count: 2, latest_reply: '' });
|
||||||
|
parent.replies = [
|
||||||
|
{ type: 'message', ts: at(39), user: 'U002', text: 'Yes — "Countdown 5 min (Sept)" in the media bin', thread_ts: parent.ts },
|
||||||
|
{ type: 'message', ts: at(38), user: 'U003', text: 'Got it :+1:', thread_ts: parent.ts },
|
||||||
|
];
|
||||||
|
parent.latest_reply = parent.replies[1].ts;
|
||||||
|
push('C001', {
|
||||||
|
ts: at(35), subtype: 'bot_message', bot_id: 'B002', username: 'Planning Center', text: '',
|
||||||
|
attachments: [{ fallback: 'Plan updated: Sunday Service — Sept 14 (2 items changed)', title: 'Plan updated', text: 'Sunday Service — Sept 14 (2 items changed)' }],
|
||||||
|
});
|
||||||
|
push('C001', { ts: at(30), user: 'U005', text: 'Lower thirds for the announcements: <https://waterschurch.org/plan|this week’s plan>', files: [{ id: 'F001', name: 'lower-thirds-0914.png', title: 'lower-thirds-0914.png' }] });
|
||||||
|
const fixed = push('C001', { ts: at(25), user: 'U004', text: 'Audio fixed — Dante patch was it. Thanks <@U002> :pray:', edited: { user: 'U004', ts: at(24) } });
|
||||||
|
fixed.reactions = [{ name: '+1', users: ['U002', 'U003', 'U001'], count: 3 }, { name: 'party_parrot', users: ['U005'], count: 1 }, { name: 'pray::skin-tone-3', users: ['U002'], count: 1 }];
|
||||||
|
push('C001', { ts: at(23), user: 'U002', text: 'Custom emoji check :party_parrot: :waters: and an unknown :not_an_emoji: stay put' });
|
||||||
|
push('C001', { ts: at(20), user: 'U002', text: '<!here> 10 minutes to doors. Campuses, post "ready" when your pre-service loop is running.' });
|
||||||
|
push('C001', { ts: at(15), user: 'U001', text: 'Apollo Beach ready' });
|
||||||
|
push('C001', { ts: at(14), user: 'U004', text: 'North Attleboro ready' });
|
||||||
|
|
||||||
|
push('C002', { ts: at(120), user: 'U002', text: 'Run of show for Sept 14 is pinned. Changes go here, not in DMs.' });
|
||||||
|
push('C002', { ts: at(90), user: 'U005', text: 'Bumper video is 0:42 this week, not 0:30 — adjust your timers.' });
|
||||||
|
push('G001', { ts: at(200), user: 'U002', text: 'Leads: budget call moved to Tuesday 2pm.' });
|
||||||
|
push('C003', { ts: at(300), user: 'U002', text: 'Staff meeting notes are in the drive.' });
|
||||||
|
}
|
||||||
|
seed();
|
||||||
|
|
||||||
|
const CHATTER = [
|
||||||
|
{ user: 'U003', text: 'Walk-in music is a touch loud at the back, can we come down 2 dB?' },
|
||||||
|
{ user: 'U002', text: 'Cue 4 is the *baptism video* — heads up, it has its own audio' },
|
||||||
|
{ user: 'U005', text: 'Livestream is live, 38 viewers already :tada:' },
|
||||||
|
{ user: 'U004', text: 'North Attleboro: pastor is running about 3 min long today' },
|
||||||
|
{ user: 'U002', text: 'Copy. Apollo Beach, hold your bumper until you hear from us' },
|
||||||
|
{ user: 'U003', text: 'Lyrics slide 3 of "Living Hope" has a typo — fixing for second service' },
|
||||||
|
{ user: 'U005', text: 'Stream bitrate dipped for a sec, back to normal now' },
|
||||||
|
{ user: 'U002', text: 'Offering moment is *after* the sermon this week, not before' },
|
||||||
|
{ user: 'U004', text: 'Anyone else seeing the ProPresenter stage display lag?' },
|
||||||
|
{ user: 'U003', text: 'Kids check-in is slammed, could use one more volunteer at the desk' },
|
||||||
|
{ user: 'U002', text: 'Great first service everyone :clap: reset in 15' },
|
||||||
|
];
|
||||||
|
let chatterIx = 0;
|
||||||
|
|
||||||
|
const OPS_CHATTER = [
|
||||||
|
{ user: 'U005', text: 'Ops: parking team is short two people, sending greeters over' },
|
||||||
|
{ user: 'U002', text: 'Cafe is out of oat milk again :sweat_smile:' },
|
||||||
|
{ user: 'U005', text: 'Second-service headcount: 412 in the room, 38 online' },
|
||||||
|
{ user: 'U002', text: 'Facilities: the lobby HVAC is back, thanks Dan' },
|
||||||
|
];
|
||||||
|
let opsIx = 0;
|
||||||
|
|
||||||
|
function tickNew() {
|
||||||
|
const line = CHATTER[chatterIx % CHATTER.length];
|
||||||
|
chatterIx += 1;
|
||||||
|
const m = push('C001', { user: line.user, text: line.text });
|
||||||
|
console.log(`[slack-mock] + new message ${m.ts} from ${line.user}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tickOps() {
|
||||||
|
const line = OPS_CHATTER[opsIx % OPS_CHATTER.length];
|
||||||
|
opsIx += 1;
|
||||||
|
const m = push('C002', { user: line.user, text: line.text });
|
||||||
|
console.log(`[slack-mock] + new message ${m.ts} in #ops from ${line.user}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tickEdit() {
|
||||||
|
const list = history.get('C001');
|
||||||
|
const recent = list.slice(-8).filter((m) => m.user && !m.subtype && !m.edited);
|
||||||
|
if (!recent.length) return;
|
||||||
|
const m = recent[Math.floor(Math.random() * recent.length)];
|
||||||
|
m.text += ' — fixed';
|
||||||
|
m.edited = { user: m.user, ts: ts() };
|
||||||
|
console.log(`[slack-mock] ~ edited ${m.ts}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tickDelete() {
|
||||||
|
const list = history.get('C001');
|
||||||
|
const candidates = list.slice(-10, -2).filter((m) => m.user && !m.subtype && !m.reply_count);
|
||||||
|
if (!candidates.length) return;
|
||||||
|
const victim = candidates[Math.floor(Math.random() * candidates.length)];
|
||||||
|
list.splice(list.indexOf(victim), 1);
|
||||||
|
console.log(`[slack-mock] - deleted ${victim.ts}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tickReply() {
|
||||||
|
const list = history.get('C001');
|
||||||
|
const parent = list.find((m) => m.reply_count);
|
||||||
|
if (!parent) return;
|
||||||
|
const reply = { type: 'message', ts: ts(), user: ['U002', 'U003', 'U005'][parent.replies.length % 3], text: `Thread reply ${parent.replies.length + 1} :eyes:`, thread_ts: parent.ts };
|
||||||
|
parent.replies.push(reply);
|
||||||
|
parent.reply_count = parent.replies.length;
|
||||||
|
parent.latest_reply = reply.ts;
|
||||||
|
console.log(`[slack-mock] ↳ reply on ${parent.ts} (${parent.reply_count} total)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(tickNew, 8000).unref();
|
||||||
|
setInterval(tickOps, 20000).unref();
|
||||||
|
setInterval(tickEdit, 25000).unref();
|
||||||
|
setInterval(tickDelete, 40000).unref();
|
||||||
|
setInterval(tickReply, 30000).unref();
|
||||||
|
|
||||||
|
/* ── plumbing ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function send(res, status, body, headers = {}) {
|
||||||
|
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers });
|
||||||
|
res.end(JSON.stringify(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBody(req) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let data = '';
|
||||||
|
req.on('data', (c) => { data += c; });
|
||||||
|
req.on('end', () => resolve(data));
|
||||||
|
req.on('error', () => resolve(''));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseParams(req, url, raw) {
|
||||||
|
const params = Object.fromEntries(url.searchParams.entries());
|
||||||
|
const ctype = String(req.headers['content-type'] || '');
|
||||||
|
if (raw) {
|
||||||
|
if (ctype.includes('application/json')) {
|
||||||
|
try { Object.assign(params, JSON.parse(raw)); } catch { /* ignore */ }
|
||||||
|
} else {
|
||||||
|
for (const [k, v] of new URLSearchParams(raw)) params[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenOf(req, params) {
|
||||||
|
const m = /^Bearer\s+(\S+)/.exec(String(req.headers.authorization || ''));
|
||||||
|
return m ? m[1] : String(params.token || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function identity(token) {
|
||||||
|
if (token === 'xoxp-revoked') return { error: 'token_revoked' };
|
||||||
|
if (/^xoxp-/.test(token)) return { ok: true, url: 'https://waters.slack.com/', team: 'Waters Church', user: 'apollo.beach', team_id: 'T001', user_id: 'U001', noscope: token === 'xoxp-noscope' };
|
||||||
|
if (/^xoxb-/.test(token)) return { ok: true, url: 'https://waters.slack.com/', team: 'Waters Church', user: 'proddash', team_id: 'T001', user_id: 'UBOT', bot_id: 'B001' };
|
||||||
|
return { error: 'invalid_auth' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelRecord(c) {
|
||||||
|
return { id: c.id, name: c.name, name_normalized: c.name, is_channel: !c.is_private, is_group: c.is_private, is_private: c.is_private, is_member: c.is_member, is_archived: false, num_members: 12 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicMessage(m) {
|
||||||
|
const { replies, ...rest } = m;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findChannel(ref) {
|
||||||
|
const s = String(ref || '');
|
||||||
|
return CHANNELS.find((c) => c.id === s || c.name === s.replace(/^#/, ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
|
||||||
|
const raw = req.method === 'POST' ? await readBody(req) : '';
|
||||||
|
const params = parseParams(req, url, raw);
|
||||||
|
const method = url.pathname.replace(/^\/api\//, '');
|
||||||
|
const summary = Object.entries(params).filter(([k]) => k !== 'token').map(([k, v]) => `${k}=${String(v).slice(0, 40)}`).join(' ');
|
||||||
|
console.log(`[slack-mock] ${req.method} ${url.pathname} ${summary}`);
|
||||||
|
|
||||||
|
const img = url.pathname.match(/^\/img\/(u|e)\/([A-Za-z0-9_+-]+)\.svg$/);
|
||||||
|
if (img) {
|
||||||
|
const id = img[2];
|
||||||
|
const letter = img[1] === 'u' ? (USERS[id]?.display_name || USERS[id]?.real_name || id)[0].toUpperCase() : id[0].toUpperCase();
|
||||||
|
res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'no-store' });
|
||||||
|
return res.end(squareSvg(id, letter));
|
||||||
|
}
|
||||||
|
if (!url.pathname.startsWith('/api/')) return send(res, 404, { ok: false, error: 'unknown_method' });
|
||||||
|
|
||||||
|
const token = tokenOf(req, params);
|
||||||
|
const who = identity(token);
|
||||||
|
if (!who.ok) return send(res, 200, { ok: false, error: who.error });
|
||||||
|
|
||||||
|
switch (method) {
|
||||||
|
case 'auth.test': {
|
||||||
|
const { noscope, ...rest } = who;
|
||||||
|
return send(res, 200, rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'users.info': {
|
||||||
|
const u = USERS[String(params.user)];
|
||||||
|
if (!u) return send(res, 200, { ok: false, error: 'user_not_found' });
|
||||||
|
const img = `http://127.0.0.1:${PORT}/img/u/${params.user}.svg`;
|
||||||
|
return send(res, 200, { ok: true, user: { id: params.user, name: u.name, real_name: u.real_name, is_bot: Boolean(u.is_bot), profile: { display_name: u.display_name, real_name: u.real_name, image_48: img, image_72: img } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'conversations.list': {
|
||||||
|
const types = String(params.types || 'public_channel').split(',');
|
||||||
|
let list = CHANNELS.filter((c) => (c.is_private ? types.includes('private_channel') : types.includes('public_channel')));
|
||||||
|
if (who.bot_id) list = list.filter((c) => c.is_member); // a bot sees only what it's in
|
||||||
|
const limit = Math.max(1, Math.min(1000, Number(params.limit) || 100));
|
||||||
|
const start = params.cursor ? Number(Buffer.from(String(params.cursor), 'base64').toString()) || 0 : 0;
|
||||||
|
const page = list.slice(start, start + limit);
|
||||||
|
const next = start + limit < list.length ? Buffer.from(String(start + limit)).toString('base64') : '';
|
||||||
|
return send(res, 200, { ok: true, channels: page.map(channelRecord), response_metadata: { next_cursor: next } });
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'conversations.info': {
|
||||||
|
const c = findChannel(params.channel);
|
||||||
|
if (!c) return send(res, 200, { ok: false, error: 'channel_not_found' });
|
||||||
|
return send(res, 200, { ok: true, channel: channelRecord(c) });
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'conversations.history': {
|
||||||
|
const c = findChannel(params.channel);
|
||||||
|
if (!c) return send(res, 200, { ok: false, error: 'channel_not_found' });
|
||||||
|
if (who.noscope) return send(res, 200, { ok: false, error: 'missing_scope', needed: 'channels:history', provided: 'channels:read,chat:write' });
|
||||||
|
if (!c.is_member) return send(res, 200, { ok: false, error: 'not_in_channel' });
|
||||||
|
const limit = Math.max(1, Math.min(1000, Number(params.limit) || 100));
|
||||||
|
const list = history.get(c.id);
|
||||||
|
const window = list.slice(-limit).reverse().map(publicMessage);
|
||||||
|
return send(res, 200, { ok: true, messages: window, has_more: list.length > limit, pin_count: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'conversations.replies': {
|
||||||
|
const c = findChannel(params.channel);
|
||||||
|
if (!c) return send(res, 200, { ok: false, error: 'channel_not_found' });
|
||||||
|
if (!c.is_member) return send(res, 200, { ok: false, error: 'not_in_channel' });
|
||||||
|
const parent = history.get(c.id).find((m) => m.ts === String(params.ts));
|
||||||
|
if (!parent) return send(res, 200, { ok: false, error: 'thread_not_found' });
|
||||||
|
const limit = Math.max(1, Math.min(1000, Number(params.limit) || 100));
|
||||||
|
return send(res, 200, { ok: true, messages: [publicMessage(parent), ...(parent.replies || []).slice(0, limit - 1)], has_more: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'emoji.list': {
|
||||||
|
const emoji = {};
|
||||||
|
for (const [name, v] of Object.entries(CUSTOM_EMOJI)) emoji[name] = v(PORT);
|
||||||
|
return send(res, 200, { ok: true, emoji, cache_ts: '1700000000.000000' });
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'reactions.add':
|
||||||
|
case 'reactions.remove': {
|
||||||
|
const c = findChannel(params.channel);
|
||||||
|
if (!c) return send(res, 200, { ok: false, error: 'channel_not_found' });
|
||||||
|
if (!c.is_member) return send(res, 200, { ok: false, error: 'not_in_channel' });
|
||||||
|
const name = String(params.name || '');
|
||||||
|
if (!name || name === 'nope') return send(res, 200, { ok: false, error: 'invalid_name' });
|
||||||
|
const m = findMessage(c.id, String(params.timestamp || ''));
|
||||||
|
if (!m) return send(res, 200, { ok: false, error: 'message_not_found' });
|
||||||
|
const err = method === 'reactions.add' ? addReaction(m, name, who.user_id) : removeReaction(m, name, who.user_id);
|
||||||
|
if (err) return send(res, 200, { ok: false, error: err });
|
||||||
|
console.log(`[slack-mock] ${method === 'reactions.add' ? '➕' : '➖'} :${name}: on ${m.ts} in #${c.name} by ${who.user}`);
|
||||||
|
return send(res, 200, { ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'chat.postMessage': {
|
||||||
|
const c = findChannel(params.channel);
|
||||||
|
if (!c) return send(res, 200, { ok: false, error: 'channel_not_found' });
|
||||||
|
if (!c.is_member) return send(res, 200, { ok: false, error: 'not_in_channel' });
|
||||||
|
const text = String(params.text || '');
|
||||||
|
if (!text.trim()) return send(res, 200, { ok: false, error: 'no_text' });
|
||||||
|
if (text.includes('!429')) return send(res, 429, { ok: false, error: 'ratelimited' }, { 'Retry-After': '3' });
|
||||||
|
let m;
|
||||||
|
if (who.bot_id && params.username) {
|
||||||
|
m = push(c.id, { subtype: 'bot_message', bot_id: who.bot_id, username: String(params.username), icons: params.icon_emoji ? { emoji: String(params.icon_emoji) } : undefined, text });
|
||||||
|
} else if (who.bot_id) {
|
||||||
|
m = push(c.id, { user: who.user_id, bot_id: who.bot_id, bot_profile: { id: who.bot_id, name: 'ProdDash' }, text });
|
||||||
|
} else {
|
||||||
|
m = push(c.id, { user: who.user_id, text });
|
||||||
|
}
|
||||||
|
if (params.thread_ts) m.thread_ts = String(params.thread_ts);
|
||||||
|
console.log(`[slack-mock] ✉ posted to #${c.name} as ${params.username || who.user}: ${text.slice(0, 60)}`);
|
||||||
|
return send(res, 200, { ok: true, channel: c.id, ts: m.ts, message: publicMessage(m) });
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return send(res, 200, { ok: false, error: 'unknown_method' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, '127.0.0.1', () => {
|
||||||
|
console.log(`[slack-mock] Slack Web API mock on http://127.0.0.1:${PORT}/api`);
|
||||||
|
console.log('[slack-mock] tokens: xoxp-anything (user "Apollo Beach"), xoxb-anything (bot), xoxp-revoked, xoxp-noscope');
|
||||||
|
console.log('[slack-mock] run ProdDash with: SLACK_API_BASE=http://127.0.0.1:%d/api node server.js', PORT);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user