Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb233ca8d7 |
@@ -37,14 +37,13 @@ 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)
|
||||||
|
|
||||||
- `prodcom-listener/` — live ProdCom transcript web app. Zero-dependency Node
|
- `prodcom-listener/` — live ProdCom transcript web app. Zero-dependency Node
|
||||||
`server.js` that serves `public/` and proxies `/prodcom/*` (including the SSE
|
`server.js` that serves `public/` and proxies `/prodcom/*` (including the SSE
|
||||||
transcript stream) to the ProdCom API at `config.json → prodcomUrl`
|
transcript stream) to the ProdCom API at `config.json → prodcomUrl`
|
||||||
(set per site; each campus runs its own). Good reference for: SSE handling,
|
(default `http://10.3.11.152:24480`). Good reference for: SSE handling,
|
||||||
reconnect logic, channel colors/filtering, PWA install.
|
reconnect logic, channel colors/filtering, PWA install.
|
||||||
- `band-lineup-display/` — backstage Electron app (stage lineup photos with
|
- `band-lineup-display/` — backstage Electron app (stage lineup photos with
|
||||||
mic/pack labels) plus ProPresenter Now/Next:
|
mic/pack labels) plus ProPresenter Now/Next:
|
||||||
|
|||||||
@@ -15,10 +15,9 @@ 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 follow ProdCom, poll ProPresenter, and stream
|
that's where module servers proxy ProdCom, poll ProPresenter, and stream
|
||||||
live updates over SSE — one upstream connection per module, however many
|
live updates over SSE, so browsers never talk to production gear
|
||||||
dashboards are open — so browsers never talk to production gear directly
|
directly (no CORS, and API keys never leave the server).
|
||||||
(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.
|
||||||
|
|
||||||
@@ -100,24 +99,10 @@ 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. |
|
||||||
| `forge` | Where updates and module installs are fetched from. Absent or `"github"` uses GitHub; a Gitea or Forgejo base URL such as `"http://10.3.8.90:3000"` uses a server of your own. Set it per machine in the data directory, alongside `repo`. |
|
|
||||||
| `theme` | The dashboard palette: `booth` (default), `harbor`, `graphite`, `ember` or `daylight`. The admin page's **Theme** section writes this one — see [Themes](#themes). |
|
| `theme` | The dashboard palette: `booth` (default), `harbor`, `graphite`, `ember` or `daylight`. The admin page's **Theme** section writes this one — see [Themes](#themes). |
|
||||||
|
|
||||||
**Updating from your own git server.** ProdDash fetches one `tar.gz` of the
|
|
||||||
branch — nothing else — so any Gitea or Forgejo works. Point `forge` at its
|
|
||||||
base URL and `repo` at the owner/repo there:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "forge": "http://10.3.8.90:3000", "repo": "michael/ProdDash", "branch": "main" }
|
|
||||||
```
|
|
||||||
|
|
||||||
Gitea spells an archive URL differently from GitHub, which is the only thing
|
|
||||||
that changes; the tarball itself is identical, commit hash included, so update
|
|
||||||
detection and module installs behave exactly as before. Make the repository
|
|
||||||
readable without signing in, or ProdDash cannot fetch it.
|
|
||||||
|
|
||||||
Environment variables override both files: `PORT`, `PRODDASH_PASSCODE`, and
|
Environment variables override both files: `PORT`, `PRODDASH_PASSCODE`, and
|
||||||
`PRODDASH_FORGE`, 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
|
||||||
is taken from the app folder; `PRODDASH_DATA_DIR=config` restores the old
|
is taken from the app folder; `PRODDASH_DATA_DIR=config` restores the old
|
||||||
in-repo location). If the default directory can't be created, the server says
|
in-repo location). If the default directory can't be created, the server says
|
||||||
so and falls back to the app's `config/` folder.
|
so and falls back to the app's `config/` folder.
|
||||||
@@ -145,11 +130,10 @@ 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 git
|
Everything below happens in the **admin page**; ProdDash talks to its GitHub
|
||||||
repository — `michael/ProdDash` on the church's own Gitea at
|
repository (`michaelmcgary2008/ProdDash`, branch `main` — change with
|
||||||
`chronicles-ab.waterschurch.org:3000`, branch `main`. Change it with `forge`,
|
`repo` / `branch` in `proddash.json` or the `PRODDASH_REPO` variable,
|
||||||
`repo` and `branch` in `proddash.json`, or the `PRODDASH_FORGE` and
|
`owner/repo#branch`). It downloads one small archive of the branch, at most
|
||||||
`PRODDASH_REPO` variables (`owner/repo#branch`). It downloads one small archive of the branch, at most
|
|
||||||
every few minutes, only while the admin page is open or for the update check
|
every few minutes, only while the admin page is open or for the update check
|
||||||
below. Nothing is installed without someone clicking.
|
below. Nothing is installed without someone clicking.
|
||||||
|
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ modules/<id>/
|
|||||||
"minSize": { "w": 2, "h": 2 },
|
"minSize": { "w": 2, "h": 2 },
|
||||||
"defaultSize": { "w": 4, "h": 3 },
|
"defaultSize": { "w": 4, "h": 3 },
|
||||||
"configSchema": {
|
"configSchema": {
|
||||||
"prodcomUrl": { "type": "string", "label": "ProdCom URL", "default": "" },
|
"prodcomUrl": { "type": "string", "label": "ProdCom URL", "default": "http://10.3.11.152:24480" },
|
||||||
"apiKey": { "type": "password", "label": "API key (if PSK auth enabled)", "default": "" }
|
"apiKey": { "type": "password", "label": "API key (if PSK auth enabled)", "default": "" }
|
||||||
},
|
},
|
||||||
"instanceSchema": {
|
"instanceSchema": {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"port": 24500,
|
"port": 24500,
|
||||||
"adminPasscode": "",
|
"adminPasscode": "",
|
||||||
"forge": "http://chronicles-ab.waterschurch.org:3000",
|
"repo": "michaelmcgary2008/ProdDash",
|
||||||
"repo": "michael/ProdDash",
|
|
||||||
"branch": "main"
|
"branch": "main"
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-79
@@ -104,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" | "text" | "number" | "boolean" | "switch" | "select" | "multiselect" | "template" | "color" | "password" | "endpoint",
|
{ "type": "string" | "number" | "boolean" | "switch" | "select" | "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
|
||||||
@@ -125,24 +125,6 @@ shown or enabled, off means hidden or disabled, and the label shouldn't say
|
|||||||
so twice.
|
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
|
||||||
@@ -181,27 +163,13 @@ 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`, `toggle`). That is how
|
popover (`collapsed: true | false`, `columns`, `help`). That is how a module
|
||||||
a module with a long show/hide checklist keeps the popover usable — see
|
with a long show/hide checklist keeps the popover usable — see `pco-plan`.
|
||||||
`pco-plan`. Display choices belong there, per tile; admin config is for
|
Display choices belong there, per tile; admin config is for connections.
|
||||||
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
|
||||||
@@ -318,7 +286,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. Keys beginning `__` are the shell's own (`__tint`, the tile's colour) — never use that prefix, and ignore the ones you find. |
|
| `instanceSettings` | This tile's settings: your `instanceSchema` defaults overlaid with whatever this tile has saved. Read it fresh whenever you render — don't cache it. |
|
||||||
| `saveInstanceSettings(patch)` | Merge `patch` into this tile's settings and persist them with the layout. No restart happens — you made the change, you already know. Use it for state the user sets *inside* your tile (a toggled filter, a chosen tab). |
|
| `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()`. |
|
||||||
@@ -333,17 +301,16 @@ 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 (13 px), and/or
|
icon: '<svg …>…</svg>', // inline SVG string, and/or
|
||||||
label: 'Timers ▾', // short text
|
label: 'A+', // short text
|
||||||
title: 'Larger text', // tooltip — one or two words; the shell shows
|
title: 'Larger text', // hover tooltip
|
||||||
// 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: 'Channels',
|
title: 'Choose visible 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
|
||||||
@@ -355,17 +322,7 @@ 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. Draw
|
labels): the title bar is 32 px tall and shared with the tile's name.
|
||||||
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
|
||||||
@@ -384,20 +341,14 @@ const stream = moduleApi.sse('/stream', {
|
|||||||
// later: stream.close()
|
// later: stream.close()
|
||||||
```
|
```
|
||||||
|
|
||||||
Your route serves a plain SSE stream; how it reaches the page is the
|
Browsers' `EventSource` retries transient drops itself but gives up for good
|
||||||
shell's business. A dashboard holds **one** connection for every stream on
|
when a retry gets a completed non-SSE response — which is what a proxy's 502
|
||||||
it (`GET /api/stream`, which the server feeds from your route over
|
looks like while the upstream is down. The shell's `sse()` recreates closed
|
||||||
loopback) — browsers allow about six connections to a host and a tile per
|
streams on a 3-second timer until they work again, so **reconnect logic is
|
||||||
stream ran out of them — so `open` also fires whenever that shared
|
free**: handle `open` (refresh/backfill your state; you may have missed
|
||||||
connection is remade (a tile was added or removed) and whenever your route
|
events) and `error` (report degraded status), and you're resilient. Streams
|
||||||
comes back after a drop. Browsers' `EventSource` retries transient drops
|
you forget to close are force-closed when the tile unmounts — but close your
|
||||||
itself but gives up for good when a retry gets a completed non-SSE
|
own in `stop()` anyway.
|
||||||
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
|
||||||
|
|
||||||
@@ -518,7 +469,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; the picker's tooltip
|
description: page.url, // optional line under the label
|
||||||
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 },
|
||||||
@@ -531,17 +482,6 @@ 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`.
|
||||||
|
|||||||
@@ -4,16 +4,10 @@
|
|||||||
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, item-note categories); the text size is the title bar's A− / A+
|
colors, text size); /admin only holds the connection settings. */
|
||||||
(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
|
||||||
@@ -59,7 +53,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
|
|
||||||
const refreshBtn = moduleApi.header.addButton({
|
const refreshBtn = moduleApi.header.addButton({
|
||||||
icon: REFRESH_SVG,
|
icon: REFRESH_SVG,
|
||||||
title: 'Refresh',
|
title: 'Re-read the plan from Planning Center now',
|
||||||
onClick() {
|
onClick() {
|
||||||
refreshBtn.classList.add('active');
|
refreshBtn.classList.add('active');
|
||||||
moduleApi.fetch('/refresh', { method: 'POST' })
|
moduleApi.fetch('/refresh', { method: 'POST' })
|
||||||
@@ -73,8 +67,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({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-1) });
|
moduleApi.header.addButton({ label: 'A−', title: 'Smaller text', onClick: () => bumpTextSize(-1) });
|
||||||
moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(1) });
|
moduleApi.header.addButton({ label: 'A+', title: 'Larger text', onClick: () => bumpTextSize(1) });
|
||||||
|
|
||||||
/* ── formatting ─────────────────────────────────────────────────── */
|
/* ── formatting ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -93,16 +87,6 @@ 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`;
|
||||||
@@ -137,19 +121,10 @@ 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 = cfg().noteCategories;
|
const raw = String(cfg().noteCategories || '');
|
||||||
const names = Array.isArray(raw) ? raw : String(raw ?? '').split(',');
|
const wanted = raw.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
|
||||||
const wanted = new Set(names.map((s) => String(s).trim().toLowerCase()).filter(Boolean));
|
return wanted.length ? (note) => wanted.includes(String(note.category || '').toLowerCase()) : () => true;
|
||||||
if (!Array.isArray(raw) && !wanted.size) return () => true;
|
|
||||||
return (note) => wanted.has(String(note.category || '').trim().toLowerCase());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── status dot ─────────────────────────────────────────────────── */
|
/* ── status dot ─────────────────────────────────────────────────── */
|
||||||
@@ -275,7 +250,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
const startsAt = svc.startsAt || plan.serviceStartsAt || 0;
|
const startsAt = svc.startsAt || plan.serviceStartsAt || 0;
|
||||||
if (on('showCountdown') && startsAt > now) {
|
if (on('showCountdown') && startsAt > now) {
|
||||||
clockLabelEl.textContent = 'Starts in';
|
clockLabelEl.textContent = 'Starts in';
|
||||||
clockValueEl.textContent = fmtStartsIn((startsAt - now) / 1000);
|
clockValueEl.textContent = fmtDur((startsAt - now) / 1000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (on('showRunningClock') && startsAt) {
|
if (on('showRunningClock') && startsAt) {
|
||||||
|
|||||||
@@ -121,9 +121,6 @@
|
|||||||
"collapsed": false,
|
"collapsed": false,
|
||||||
"columns": 2
|
"columns": 2
|
||||||
},
|
},
|
||||||
"Item notes": {
|
|
||||||
"toggle": "showNotes"
|
|
||||||
},
|
|
||||||
"Show / hide": {
|
"Show / hide": {
|
||||||
"collapsed": true,
|
"collapsed": true,
|
||||||
"columns": 3,
|
"columns": 3,
|
||||||
@@ -131,38 +128,25 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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": "template",
|
"type": "string",
|
||||||
"label": "Top bar text",
|
"label": "Top bar text",
|
||||||
"default": "{series} • {part} | {serviceType} • {date}",
|
"default": "{series} • {part} | {serviceType} • {date}",
|
||||||
"group": "Top bar",
|
"group": "Top bar",
|
||||||
"variables": [
|
"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."
|
||||||
{ "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" },
|
||||||
@@ -181,6 +165,7 @@
|
|||||||
"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" },
|
||||||
|
|||||||
@@ -15,8 +15,6 @@
|
|||||||
* GET /plans?serviceTypeId=… → { options } for the admin "Plan" select
|
* GET /plans?serviceTypeId=… → { options } for the admin "Plan" select
|
||||||
* GET /documents → { options } for the admin start / end document selects
|
* GET /documents → { options } for the admin start / end document selects
|
||||||
* (documents ProPresenter has shown or lists — see below)
|
* (documents ProPresenter has shown or lists — see below)
|
||||||
* GET /note-categories → { options } for a tile's "Item notes" categories:
|
|
||||||
* the service type's item note categories, by name
|
|
||||||
* GET /timers → snapshot for the Timers module (guide: "Offering
|
* GET /timers → snapshot for the Timers module (guide: "Offering
|
||||||
* GET /timers/stream → SSE, `timers` events timers to the Timers module")
|
* GET /timers/stream → SSE, `timers` events timers to the Timers module")
|
||||||
*
|
*
|
||||||
@@ -28,7 +26,6 @@
|
|||||||
* GET /service_types/{st}/plans/{id} (title, dates…)
|
* GET /service_types/{st}/plans/{id} (title, dates…)
|
||||||
* GET …/plans/{id}/plan_times (service start times)
|
* GET …/plans/{id}/plan_times (service start times)
|
||||||
* GET …/plans/{id}/items?include=song,arrangement,item_notes
|
* GET …/plans/{id}/items?include=song,arrangement,item_notes
|
||||||
* GET /service_types/{st}/item_note_categories (note category names)
|
|
||||||
*
|
*
|
||||||
* Following ProPresenter: instead of talking to ProPresenter a second time,
|
* Following ProPresenter: instead of talking to ProPresenter a second time,
|
||||||
* this module reads the propresenter-now-next module's own SSE feed over
|
* this module reads the propresenter-now-next module's own SSE feed over
|
||||||
@@ -81,8 +78,6 @@ const NOW_NEXT_RETRY_MS = 3000;
|
|||||||
const NOW_NEXT_MISSING_RETRY_MS = 15000;
|
const NOW_NEXT_MISSING_RETRY_MS = 15000;
|
||||||
/** How many distinct documents seen live are remembered for the admin selects. */
|
/** How many distinct documents seen live are remembered for the admin selects. */
|
||||||
const SEEN_DOCUMENTS_MAX = 200;
|
const SEEN_DOCUMENTS_MAX = 200;
|
||||||
/** How long a service type's item note categories are believed before Planning Center is asked again. */
|
|
||||||
const NOTE_CATEGORIES_TTL_MS = 10 * MINUTE;
|
|
||||||
/**
|
/**
|
||||||
* Service timer tone (the Timers module colours by it): running behind the
|
* Service timer tone (the Timers module colours by it): running behind the
|
||||||
* plan — completed items over their planned length, plus the current item's
|
* plan — completed items over their planned length, plus the current item's
|
||||||
@@ -596,44 +591,6 @@ module.exports = {
|
|||||||
syncServiceSafe(Date.now(), null);
|
syncServiceSafe(Date.now(), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* — item note categories (a tile's "Item notes" → Categories switches) — */
|
|
||||||
|
|
||||||
let noteCategories = { at: 0, options: [] }; // the last good answer from Planning Center
|
|
||||||
let noteCategoriesInFlight = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The service type's item note categories, by name — notes are matched
|
|
||||||
* to categories by name, so the names are the values. Believed for
|
|
||||||
* NOTE_CATEGORIES_TTL_MS, read once however many tiles ask at the same
|
|
||||||
* moment; a failed re-read keeps serving the last good list. A module
|
|
||||||
* that cannot ask (no credentials, no service type) answers an empty
|
|
||||||
* list — the gear window then says there is nothing to choose from yet.
|
|
||||||
*/
|
|
||||||
async function noteCategoryOptions() {
|
|
||||||
if (!pco.configured || !serviceTypeId) return [];
|
|
||||||
if (Date.now() - noteCategories.at < NOTE_CATEGORIES_TTL_MS) return noteCategories.options;
|
|
||||||
if (!noteCategoriesInFlight) {
|
|
||||||
noteCategoriesInFlight = (async () => {
|
|
||||||
const body = await pco.getAll(`${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}/item_note_categories`);
|
|
||||||
const seen = new Set();
|
|
||||||
const options = (body.data || [])
|
|
||||||
.filter((c) => !c?.attributes?.deleted_at)
|
|
||||||
.map((c) => ({ name: String(c.attributes?.name || '').trim(), sequence: Number(c.attributes?.sequence) || 0 }))
|
|
||||||
.sort((a, b) => a.sequence - b.sequence)
|
|
||||||
.filter((c) => c.name && !seen.has(c.name.toLowerCase()) && seen.add(c.name.toLowerCase()))
|
|
||||||
.map((c) => ({ value: c.name, label: c.name }));
|
|
||||||
noteCategories = { at: Date.now(), options };
|
|
||||||
return options;
|
|
||||||
})().finally(() => { noteCategoriesInFlight = null; });
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return await noteCategoriesInFlight;
|
|
||||||
} catch (err) {
|
|
||||||
log(`item note categories read failed: ${err?.message || err}`);
|
|
||||||
return noteCategories.options;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function schedulePoll(ms) {
|
function schedulePoll(ms) {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
clearTimeout(pollTimer);
|
clearTimeout(pollTimer);
|
||||||
@@ -1257,7 +1214,6 @@ module.exports = {
|
|||||||
pickPlan,
|
pickPlan,
|
||||||
timersSnapshot,
|
timersSnapshot,
|
||||||
documentOptions,
|
documentOptions,
|
||||||
noteCategoryOptions,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1356,23 +1312,6 @@ module.exports = {
|
|||||||
json(res, 200, current.documentOptions());
|
json(res, 200, current.documentOptions());
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Options for a tile's "Item notes" → Categories switches: the
|
|
||||||
* configured service type's item note categories, by name. Always a
|
|
||||||
* 200 with a list — empty while the module is unconfigured, not
|
|
||||||
* running or cannot reach Planning Center — because the gear window
|
|
||||||
* has nothing better to do with an error than show an empty list.
|
|
||||||
*/
|
|
||||||
'GET /note-categories': async (req, res) => {
|
|
||||||
let options = [];
|
|
||||||
try {
|
|
||||||
if (current) options = await current.noteCategoryOptions();
|
|
||||||
} catch (err) {
|
|
||||||
log(`note categories: ${err?.message || err}`);
|
|
||||||
}
|
|
||||||
json(res, 200, { options });
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options for the admin page's "Service type" select: every service
|
* Options for the admin page's "Service type" select: every service
|
||||||
* type, grouped by its Planning Center folder path — so the folders
|
* type, grouped by its Planning Center folder path — so the folders
|
||||||
|
|||||||
@@ -3,13 +3,8 @@
|
|||||||
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 the data comes from the module's own
|
persists in instance settings, and all network traffic goes through
|
||||||
server, which keeps one connection to ProdCom for every dashboard: a
|
moduleApi (the module's server proxy at /prodcom/*). */
|
||||||
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 ─────────────────────────────────────────── */
|
||||||
@@ -20,6 +15,7 @@ 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 ───────────────────────────────────────────────── */
|
||||||
@@ -52,7 +48,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: 'Channels',
|
title: 'Choose visible channels',
|
||||||
build(menu) {
|
build(menu) {
|
||||||
menu.classList.add('pt-menu');
|
menu.classList.add('pt-menu');
|
||||||
channelMenuEl = menu;
|
channelMenuEl = menu;
|
||||||
@@ -69,7 +65,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: 'Timestamps',
|
title: 'Show / hide timestamps',
|
||||||
onClick() {
|
onClick() {
|
||||||
moduleApi.saveInstanceSettings({ showTimes: !moduleApi.instanceSettings.showTimes });
|
moduleApi.saveInstanceSettings({ showTimes: !moduleApi.instanceSettings.showTimes });
|
||||||
applyInstanceSettings();
|
applyInstanceSettings();
|
||||||
@@ -81,8 +77,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({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-2) });
|
moduleApi.header.addButton({ label: 'A−', title: 'Smaller text', onClick: () => bumpTextSize(-2) });
|
||||||
moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(2) });
|
moduleApi.header.addButton({ label: 'A+', 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({
|
||||||
@@ -97,7 +93,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
|
|
||||||
moduleApi.header.addButton({
|
moduleApi.header.addButton({
|
||||||
icon: JUMP_SVG,
|
icon: JUMP_SVG,
|
||||||
title: 'Latest',
|
title: 'Jump to latest',
|
||||||
onClick() {
|
onClick() {
|
||||||
pinnedToLatest = true;
|
pinnedToLatest = true;
|
||||||
jumpBtn.hidden = true;
|
jumpBtn.hidden = true;
|
||||||
@@ -107,7 +103,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',
|
title: 'Clear this tile (does not affect ProdCom)',
|
||||||
onClick: clearView,
|
onClick: clearView,
|
||||||
});
|
});
|
||||||
clearBtn.classList.add('pt-clear');
|
clearBtn.classList.add('pt-clear');
|
||||||
@@ -124,7 +120,9 @@ 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 ? 'Newest first' : 'Oldest first';
|
flowBtn.title = up
|
||||||
|
? '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();
|
||||||
@@ -176,7 +174,9 @@ 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 ? `Channels ${visibleCount}/${channels.size}` : 'Channels';
|
channelsMenu.button.title = filtering
|
||||||
|
? `Channels — showing ${visibleCount} of ${channels.size}`
|
||||||
|
: 'Choose visible channels';
|
||||||
}
|
}
|
||||||
|
|
||||||
function rebuildChannelMenu() {
|
function rebuildChannelMenu() {
|
||||||
@@ -353,68 +353,120 @@ 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. */
|
||||||
/* ── the module's feed: one snapshot, then each change ─────────── */
|
function extractEntries(obj) {
|
||||||
|
if (!obj || typeof obj !== 'object') return [];
|
||||||
function applyStatus(st) {
|
if (Array.isArray(obj)) return obj.flatMap(extractEntries);
|
||||||
if (!st || typeof st !== 'object') return;
|
if (typeof obj.text === 'string' && (obj.id || obj.channelId)) return [obj];
|
||||||
if (st.state === 'ok') moduleApi.setStatus('ok', st.message || 'Live');
|
for (const key of ['data', 'payload', 'entry', 'entries', 'transcript']) {
|
||||||
else moduleApi.setStatus(st.state === 'error' ? 'error' : 'connecting', st.message || 'Connecting to ProdCom…');
|
if (obj[key] && typeof obj[key] === 'object') {
|
||||||
|
const found = extractEntries(obj[key]);
|
||||||
|
if (found.length) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySnapshot(snap) {
|
function handleEventData(raw) {
|
||||||
if (!snap || typeof snap !== 'object') return;
|
let obj;
|
||||||
|
try { obj = JSON.parse(raw); } catch { return; }
|
||||||
|
const kind = String(obj.type || obj.event || '').toLowerCase();
|
||||||
|
if (kind.includes('clear')) {
|
||||||
|
entryEls.forEach((el) => el.remove());
|
||||||
|
entryEls.clear();
|
||||||
|
emptyEl.hidden = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
extractEntries(obj).forEach(upsertEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── data loading (all via the module's proxy) ──────────────────── */
|
||||||
|
|
||||||
|
async function loadChannels() {
|
||||||
|
const res = await moduleApi.fetch('/prodcom/api/v1/channels');
|
||||||
|
if (!res.ok) throw new Error('channels ' + res.status);
|
||||||
|
const body = await res.json();
|
||||||
channels.clear();
|
channels.clear();
|
||||||
for (const ch of snap.channels || []) channels.set(String(ch.id), { name: ch.name, color: ch.color || '#8b98a5' });
|
for (const ch of body.data || []) {
|
||||||
groups.clear();
|
channels.set(ch.id, { name: ch.name, color: ch.color || '#8b98a5' });
|
||||||
for (const g of snap.groups || []) groups.set(String(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 loadState() {
|
async function loadGroups() {
|
||||||
try {
|
const res = await moduleApi.fetch('/prodcom/api/v1/groups');
|
||||||
const res = await moduleApi.fetch('/state');
|
if (!res.ok) throw new Error('groups ' + res.status);
|
||||||
if (!res.ok) throw new Error(String(res.status));
|
const body = await res.json();
|
||||||
applySnapshot(await res.json());
|
groups.clear();
|
||||||
} catch { /* the stream's own snapshot covers it */ }
|
for (const g of body.data || []) {
|
||||||
|
groups.set(g.id, { name: g.name, channelIds: g.channelIds || [] });
|
||||||
|
}
|
||||||
|
rebuildChannelMenu();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadHistory() {
|
||||||
|
const res = await moduleApi.fetch('/prodcom/api/v1/transcript?limit=100');
|
||||||
|
if (!res.ok) throw new Error('transcript ' + res.status);
|
||||||
|
const body = await res.json();
|
||||||
|
const entries = (body.data || []).slice();
|
||||||
|
entries.sort((a, b) => new Date(a.date) - new Date(b.date));
|
||||||
|
entries.forEach(upsertEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── live stream ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const SSE_EVENT_NAMES = [
|
||||||
|
'transcript', 'transcript.new', 'transcript.updated', 'transcript.update',
|
||||||
|
'transcript.completed', 'transcript.complete', 'entry', 'new', 'update',
|
||||||
|
'updated', 'complete', 'completed',
|
||||||
|
];
|
||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
stream = moduleApi.sse('/stream', {
|
const onEvent = (ev) => handleEventData(ev.data);
|
||||||
|
const events = {};
|
||||||
|
for (const name of SSE_EVENT_NAMES) events[name] = onEvent;
|
||||||
|
|
||||||
|
stream = moduleApi.sse('/prodcom/api/v1/transcript/stream', {
|
||||||
open() {
|
open() {
|
||||||
loadState(); // a late joiner, or a reconnect: catch up on what was missed
|
moduleApi.setStatus('ok', 'Live');
|
||||||
|
// refresh channels/groups and backfill entries missed while
|
||||||
|
// disconnected (upsertEntry dedupes by id, so this is harmless)
|
||||||
|
loadChannels().catch(() => {});
|
||||||
|
loadGroups().catch(() => {});
|
||||||
|
loadHistory().catch(() => {});
|
||||||
},
|
},
|
||||||
error() {
|
error() {
|
||||||
moduleApi.setStatus('error', 'ProdDash ProdCom module unreachable — reconnecting…');
|
moduleApi.setStatus('error', 'ProdCom 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() {
|
||||||
moduleApi.setStatus('connecting', 'Connecting to ProdCom…');
|
boot();
|
||||||
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,35 +1,22 @@
|
|||||||
{
|
{
|
||||||
"id": "prodcom-transcript",
|
"id": "prodcom-transcript",
|
||||||
"name": "ProdCom",
|
"name": "ProdCom",
|
||||||
"version": "1.3.1",
|
"version": "1.2.1",
|
||||||
"proddash": ">=1.1.0",
|
"proddash": ">=1.1.0",
|
||||||
"description": "Live ProdCom transcript",
|
"description": "Live ProdCom transcript stream",
|
||||||
"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": {
|
"minSize": { "w": 3, "h": 2 },
|
||||||
"w": 3,
|
"defaultSize": { "w": 4, "h": 4 },
|
||||||
"h": 2
|
|
||||||
},
|
|
||||||
"defaultSize": {
|
|
||||||
"w": 4,
|
|
||||||
"h": 4
|
|
||||||
},
|
|
||||||
"configSchema": {
|
"configSchema": {
|
||||||
"prodcom": {
|
"prodcom": {
|
||||||
"type": "endpoint",
|
"type": "endpoint",
|
||||||
"label": "ProdCom server",
|
"label": "ProdCom server",
|
||||||
"default": {
|
"default": { "host": "10.3.11.152", "port": 24480 }
|
||||||
"host": "",
|
|
||||||
"port": 24480
|
|
||||||
},
|
|
||||||
"help": "The ProdCom server at THIS location — every campus has its own. Blank until you set it, so a new install never reads another campus's comms."
|
|
||||||
},
|
},
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
"type": "password",
|
"type": "password",
|
||||||
@@ -42,6 +29,16 @@
|
|||||||
"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,34 +3,73 @@
|
|||||||
/**
|
/**
|
||||||
* ProdCom Transcript — server part.
|
* ProdCom Transcript — server part.
|
||||||
*
|
*
|
||||||
* One connection to ProdCom for the whole server, however many dashboards
|
* One wildcard route that proxies /prodcom/* (including the SSE transcript
|
||||||
* show the transcript. The module holds the channel list, the groups and
|
* stream) to the ProdCom API configured in the admin page, so browsers never
|
||||||
* the last entries, follows ProdCom's SSE stream, and hands every browser a
|
* talk to ProdCom directly (no CORS, and the API key never reaches clients).
|
||||||
* snapshot on connect plus each change as it happens. (It used to proxy each
|
* Ported from prodcom-listener/server.js.
|
||||||
* 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');
|
||||||
|
|
||||||
const HISTORY_LIMIT = 100; // entries asked for on (re)connect
|
// Hop-by-hop headers that must not be forwarded
|
||||||
const ENTRIES_MAX = 300; // entries kept in memory
|
const HOP_HEADERS = new Set([
|
||||||
const RETRY_MIN_MS = 2000;
|
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
|
||||||
const RETRY_MAX_MS = 15000;
|
'te', 'trailer', 'transfer-encoding', 'upgrade',
|
||||||
const REQUEST_TIMEOUT_MS = 8000;
|
]);
|
||||||
const REFRESH_LISTS_MS = 5 * 60 * 1000; // channels and groups re-read
|
|
||||||
const HEARTBEAT_MS = 15000;
|
/** Health shown in the admin page: outcome of the most recent proxied request. */
|
||||||
|
let health = { status: 'connecting', message: 'No traffic yet' };
|
||||||
|
|
||||||
|
function proxy(req, res, target, apiKey) {
|
||||||
|
const upstreamPath = (req.wildcard || '/') + (req.search || '');
|
||||||
|
const headers = {};
|
||||||
|
for (const [k, v] of Object.entries(req.headers)) {
|
||||||
|
if (!HOP_HEADERS.has(k.toLowerCase())) headers[k] = v;
|
||||||
|
}
|
||||||
|
headers.host = target.host;
|
||||||
|
delete headers['accept-encoding']; // keep SSE unbuffered and simple
|
||||||
|
if (apiKey) headers.authorization = 'Bearer ' + apiKey;
|
||||||
|
|
||||||
|
const preq = http.request(
|
||||||
|
{
|
||||||
|
hostname: target.hostname,
|
||||||
|
port: target.port || 80,
|
||||||
|
path: upstreamPath,
|
||||||
|
method: req.method,
|
||||||
|
headers,
|
||||||
|
},
|
||||||
|
(pres) => {
|
||||||
|
health = { status: 'ok', message: `ProdCom reachable (last: HTTP ${pres.statusCode})` };
|
||||||
|
const outHeaders = {};
|
||||||
|
for (const [k, v] of Object.entries(pres.headers)) {
|
||||||
|
if (!HOP_HEADERS.has(k.toLowerCase())) outHeaders[k] = v;
|
||||||
|
}
|
||||||
|
res.writeHead(pres.statusCode, outHeaders);
|
||||||
|
// Flush immediately — SSE streams may send no bytes for a while, and
|
||||||
|
// the browser needs the headers to consider the stream open.
|
||||||
|
res.flushHeaders();
|
||||||
|
pres.pipe(res);
|
||||||
|
// If ProdCom dies mid-stream, pipe() unpipes on error without ending
|
||||||
|
// the browser's response, leaving SSE clients hanging on a dead
|
||||||
|
// stream. Destroy it so they notice immediately and reconnect.
|
||||||
|
pres.on('close', () => {
|
||||||
|
if (!res.writableEnded) res.destroy();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
preq.on('error', (e) => {
|
||||||
|
health = { status: 'error', message: 'ProdCom unreachable: ' + (e.code || String(e)) };
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.writeHead(502, { 'Content-Type': 'application/json' });
|
||||||
|
}
|
||||||
|
res.end(JSON.stringify({ error: 'ProdCom unreachable', detail: e.code || String(e) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tear down the upstream connection when the browser disconnects
|
||||||
|
res.on('close', () => preq.destroy());
|
||||||
|
req.pipe(preq);
|
||||||
|
}
|
||||||
|
|
||||||
/** 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
|
||||||
@@ -43,284 +82,23 @@ function resolveTarget(config) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Entries inside whatever ProdCom sent: a bare entry, or one wrapped in data / payload / entry / entries / transcript. */
|
|
||||||
function extractEntries(obj) {
|
|
||||||
if (!obj || typeof obj !== 'object') return [];
|
|
||||||
if (Array.isArray(obj)) return obj.flatMap(extractEntries);
|
|
||||||
if (typeof obj.text === 'string' && (obj.id || obj.channelId)) return [obj];
|
|
||||||
for (const key of ['data', 'payload', 'entry', 'entries', 'transcript']) {
|
|
||||||
if (obj[key] && typeof obj[key] === 'object') {
|
|
||||||
const found = extractEntries(obj[key]);
|
|
||||||
if (found.length) return found;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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 {
|
|
||||||
id,
|
|
||||||
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 = {
|
module.exports = {
|
||||||
init({ config, log }) {
|
init({ config }) {
|
||||||
const target = resolveTarget(config);
|
health = resolveTarget(config)
|
||||||
const apiKey = String(config.apiKey || '');
|
? { status: 'connecting', message: 'No traffic yet' }
|
||||||
const channels = [];
|
: { status: 'error', message: 'No ProdCom server configured' };
|
||||||
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` };
|
|
||||||
// Nothing to connect to is a setting that is missing, not a link that
|
|
||||||
// is coming up — it reads red, like the other modules say it.
|
|
||||||
if (!target) return { status: 'error', message: status.message };
|
|
||||||
return { status: status.state === 'error' ? 'error' : 'connecting', message: status.message };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
stop() {
|
stop() { /* nothing persistent to tear down — streams die with their sockets */ },
|
||||||
stopped = true;
|
health: () => health,
|
||||||
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() {
|
routes({ config, log }) {
|
||||||
const sendJson = (res, code, body) => {
|
// An invalid host/URL throws here, which surfaces as a mount error in /admin.
|
||||||
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
const target = resolveTarget(config) || new URL('http://10.3.11.152:24480');
|
||||||
res.end(JSON.stringify(body));
|
log(`proxying /prodcom/* → ${target.origin}`);
|
||||||
};
|
|
||||||
return {
|
return {
|
||||||
'GET /state': (req, res) => {
|
'* /prodcom/*': (req, res) => proxy(req, res, target, config.apiKey || ''),
|
||||||
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));
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,10 +18,6 @@
|
|||||||
"Timers ▾" header menu, which hides any single timer — keyed `source:id`
|
"Timers ▾" header menu, which hides any single timer — keyed `source:id`
|
||||||
and saved with the layout via saveInstanceSettings. The header can be
|
and saved with the layout via saveInstanceSettings. The header can be
|
||||||
hidden by the user, so the static switches stay the primary controls.
|
hidden by the user, so the static switches stay the primary controls.
|
||||||
A solo tile's gear carries only the settings its one card can use (the
|
|
||||||
picker entry's own schema — see tiles() in server.js), so a setting may
|
|
||||||
simply be absent here: every read goes through pref(), which falls back
|
|
||||||
to DEFAULTS rather than to whatever schema the tile happens to have.
|
|
||||||
|
|
||||||
Feedback — the state colours, and the thresholds behind them:
|
Feedback — the state colours, and the thresholds behind them:
|
||||||
running elapsed, no planned length running
|
running elapsed, no planned length running
|
||||||
@@ -41,6 +37,8 @@
|
|||||||
theme; left at their defaults they follow the theme's variables. */
|
theme; left at their defaults they follow the theme's variables. */
|
||||||
|
|
||||||
/** Which instance switch governs a source kind. */
|
/** Which instance switch governs a source kind. */
|
||||||
|
const SOURCE_SWITCH = { propresenter: 'showProPresenter', ltc: 'showLtc', clock: 'showClock', module: 'showModules' };
|
||||||
|
|
||||||
/** Colour settings → custom property, and the schema default (module.json) that means "follow the theme". */
|
/** Colour settings → custom property, and the schema default (module.json) that means "follow the theme". */
|
||||||
const COLOR_SETTINGS = [
|
const COLOR_SETTINGS = [
|
||||||
['runningColor', '--tm-ok', '#2ee59a'],
|
['runningColor', '--tm-ok', '#2ee59a'],
|
||||||
@@ -58,25 +56,6 @@ const PAINT_MS = 200;
|
|||||||
|
|
||||||
const LTC_KEY = 'ltc:ltc';
|
const LTC_KEY = 'ltc:ltc';
|
||||||
|
|
||||||
/** What the tile assumes for a setting its schema doesn't carry (a solo
|
|
||||||
tile offers only what applies to its card) or that was never saved. The
|
|
||||||
switches default to on, the clock to 24-hour with seconds and no date —
|
|
||||||
the same values module.json gives the full "All timers" schema. */
|
|
||||||
const DEFAULTS = {
|
|
||||||
showNames: true,
|
|
||||||
groupBySource: false,
|
|
||||||
showHeadings: true,
|
|
||||||
showStatus: true,
|
|
||||||
showDetail: true,
|
|
||||||
showSeconds: true,
|
|
||||||
leadingZeros: false,
|
|
||||||
clock24h: true,
|
|
||||||
clockSeconds: true,
|
|
||||||
clockDate: false,
|
|
||||||
dateShortDay: false,
|
|
||||||
dateShortMonth: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ── SMPTE timecode math (module-scope, exported for tests) ─────────
|
/* ── SMPTE timecode math (module-scope, exported for tests) ─────────
|
||||||
The server anchors us ~4×/s with { time, fps, df, ageMs }; the card
|
The server anchors us ~4×/s with { time, fps, df, ageMs }; the card
|
||||||
counts every frame in between locally. That needs exact frame↔timecode
|
counts every frame in between locally. That needs exact frame↔timecode
|
||||||
@@ -141,26 +120,14 @@ export function valueAt(timer, sourceUpdatedAt, now) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Signed ms → "M:SS" / "H:MM:SS" (whole seconds, floor of the magnitude). */
|
/** Signed ms → "M:SS" / "H:MM:SS" (whole seconds, floor of the magnitude). */
|
||||||
/**
|
export function fmtDuration(ms) {
|
||||||
* A duration as digits. `seconds: false` shows minutes only — a countdown
|
|
||||||
* rounds up (3:59 left reads "4", 0:30 over reads "-1"), an elapsed timer
|
|
||||||
* rounds down; `zeros` pads the leading field to two digits ("05:07").
|
|
||||||
*/
|
|
||||||
export function fmtDuration(ms, { seconds = true, zeros = false, up = false } = {}) {
|
|
||||||
const sign = ms < 0 ? '-' : '';
|
const sign = ms < 0 ? '-' : '';
|
||||||
const total = Math.abs(ms) / 1000;
|
const s = Math.floor(Math.abs(ms) / 1000);
|
||||||
const pad = (v) => String(v).padStart(2, '0');
|
|
||||||
if (!seconds) {
|
|
||||||
const mins = up ? Math.ceil(total / 60) : Math.floor(total / 60);
|
|
||||||
const h = Math.floor(mins / 60);
|
|
||||||
const m = mins % 60;
|
|
||||||
return h ? `${sign}${zeros ? pad(h) : h}:${pad(m)}` : `${sign}${zeros ? pad(m) : m}`;
|
|
||||||
}
|
|
||||||
const s = Math.floor(total);
|
|
||||||
const h = Math.floor(s / 3600);
|
const h = Math.floor(s / 3600);
|
||||||
const m = Math.floor((s % 3600) / 60);
|
const m = Math.floor((s % 3600) / 60);
|
||||||
const sec = s % 60;
|
const sec = s % 60;
|
||||||
return h ? `${sign}${zeros ? pad(h) : h}:${pad(m)}:${pad(sec)}` : `${sign}${zeros ? pad(m) : m}:${pad(sec)}`;
|
const pad = (v) => String(v).padStart(2, '0');
|
||||||
|
return h ? `${sign}${h}:${pad(m)}:${pad(sec)}` : `${sign}${m}:${pad(sec)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function warnWindow(targetMs) {
|
function warnWindow(targetMs) {
|
||||||
@@ -207,15 +174,6 @@ export default function create({ root, moduleApi }) {
|
|||||||
let needFit = true;
|
let needFit = true;
|
||||||
|
|
||||||
const settings = () => moduleApi.instanceSettings;
|
const settings = () => moduleApi.instanceSettings;
|
||||||
/** One setting, resolved here: what this tile saved, else DEFAULTS — never
|
|
||||||
whatever its (possibly slimmer) schema would or wouldn't default. */
|
|
||||||
const pref = (key) => {
|
|
||||||
const v = settings()[key];
|
|
||||||
if (v !== undefined && v !== null) return v;
|
|
||||||
// A layout from before the 24-hour switch chose a "clockFormat".
|
|
||||||
if (key === 'clock24h' && settings().clockFormat === '12h') return false;
|
|
||||||
return DEFAULTS[key];
|
|
||||||
};
|
|
||||||
const keyOf = (source, timer) => `${source.id}:${timer.id}`;
|
const keyOf = (source, timer) => `${source.id}:${timer.id}`;
|
||||||
|
|
||||||
/* Per-tile hidden timers, keyed source:id. Layouts saved by the earlier
|
/* Per-tile hidden timers, keyed source:id. Layouts saved by the earlier
|
||||||
@@ -284,7 +242,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
let menuEl = null; // the open menu's element (filled on each open)
|
let menuEl = null; // the open menu's element (filled on each open)
|
||||||
const itemsMenu = solo ? null : moduleApi.header.addMenu({
|
const itemsMenu = solo ? null : moduleApi.header.addMenu({
|
||||||
label: 'Timers ▾',
|
label: 'Timers ▾',
|
||||||
title: 'Choose timers',
|
title: 'Choose which timers this tile shows',
|
||||||
build(menu) {
|
build(menu) {
|
||||||
// the shell hands over an emptied element on every open
|
// the shell hands over an emptied element on every open
|
||||||
menu.classList.add('tm-menu');
|
menu.classList.add('tm-menu');
|
||||||
@@ -364,6 +322,11 @@ export default function create({ root, moduleApi }) {
|
|||||||
|
|
||||||
/* ── state → what this tile shows ───────────────────────────────── */
|
/* ── state → what this tile shows ───────────────────────────────── */
|
||||||
|
|
||||||
|
function sourceShown(source) {
|
||||||
|
const key = SOURCE_SWITCH[source.kind] || 'showModules';
|
||||||
|
return settings()[key] !== false;
|
||||||
|
}
|
||||||
|
|
||||||
/** [{ id, source, items: [{ key, source, timer } | { key, missing }] }] */
|
/** [{ id, source, items: [{ key, source, timer } | { key, missing }] }] */
|
||||||
function visibleGroups() {
|
function visibleGroups() {
|
||||||
if (!state) return [];
|
if (!state) return [];
|
||||||
@@ -383,6 +346,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
}
|
}
|
||||||
const groups = [];
|
const groups = [];
|
||||||
for (const source of state.sources) {
|
for (const source of state.sources) {
|
||||||
|
if (!sourceShown(source)) continue;
|
||||||
const items = [];
|
const items = [];
|
||||||
for (const timer of source.timers) {
|
for (const timer of source.timers) {
|
||||||
const key = keyOf(source, timer);
|
const key = keyOf(source, timer);
|
||||||
@@ -392,18 +356,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
if (!items.length && source.timers.length) continue;
|
if (!items.length && source.timers.length) continue;
|
||||||
groups.push({ id: source.id, source, items });
|
groups.push({ id: source.id, source, items });
|
||||||
}
|
}
|
||||||
if (pref('groupBySource') === true) return groups;
|
return groups;
|
||||||
// Grouping off: every timer in one grid, filled row by row. A source in
|
|
||||||
// trouble is still named, in one line above the cards.
|
|
||||||
const trouble = groups.filter((g) => g.source && g.source.status !== 'ok');
|
|
||||||
return [{
|
|
||||||
id: 'all',
|
|
||||||
source: null,
|
|
||||||
flat: true,
|
|
||||||
items: groups.flatMap((g) => g.items),
|
|
||||||
status: trouble.length ? 'error' : 'ok',
|
|
||||||
message: trouble.map((g) => `${g.source.label}: ${g.source.message || 'unavailable'}`).join(' · '),
|
|
||||||
}];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── cards ──────────────────────────────────────────────────────── */
|
/* ── cards ──────────────────────────────────────────────────────── */
|
||||||
@@ -439,7 +392,6 @@ export default function create({ root, moduleApi }) {
|
|||||||
/** The parts of a card that only change with the snapshot (label, kind). */
|
/** The parts of a card that only change with the snapshot (label, kind). */
|
||||||
function updateCard(card, item) {
|
function updateCard(card, item) {
|
||||||
card.item = item;
|
card.item = item;
|
||||||
card.isClock = !item.missing && item.source?.kind === 'clock';
|
|
||||||
const isLtc = item.source?.kind === 'ltc';
|
const isLtc = item.source?.kind === 'ltc';
|
||||||
const badge = card.nameEl.querySelector('.tm-ltc-badge');
|
const badge = card.nameEl.querySelector('.tm-ltc-badge');
|
||||||
if (isLtc && !badge) {
|
if (isLtc && !badge) {
|
||||||
@@ -450,11 +402,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
} else if (!isLtc && badge) {
|
} else if (!isLtc && badge) {
|
||||||
badge.remove();
|
badge.remove();
|
||||||
}
|
}
|
||||||
const label = item.missing ? (lastSoloLabel || 'Timer') : card.isClock ? '' : item.timer.label;
|
setText(card.labelEl, item.missing ? (lastSoloLabel || 'Timer') : item.timer.label);
|
||||||
if (card.labelEl.textContent !== label) {
|
|
||||||
setText(card.labelEl, label);
|
|
||||||
needFit = true; // the title's size follows its length
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateMissingCard(card) {
|
function updateMissingCard(card) {
|
||||||
@@ -476,12 +424,13 @@ export default function create({ root, moduleApi }) {
|
|||||||
/* ── clocks ─────────────────────────────────────────────────────── */
|
/* ── clocks ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
function clockOptions() {
|
function clockOptions() {
|
||||||
const h12 = pref('clock24h') === false;
|
const s = settings();
|
||||||
|
const h12 = s.clockFormat === '12h';
|
||||||
return {
|
return {
|
||||||
hour12: h12,
|
hour12: h12,
|
||||||
hour: h12 ? 'numeric' : '2-digit',
|
hour: h12 ? 'numeric' : '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
...(pref('clockSeconds') !== false ? { second: '2-digit' } : {}),
|
...(s.clockSeconds !== false ? { second: '2-digit' } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,43 +438,8 @@ export default function create({ root, moduleApi }) {
|
|||||||
return new Date().toLocaleTimeString([], clockOptions());
|
return new Date().toLocaleTimeString([], clockOptions());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The date as the day and the rest — the break, when one is needed, falls between them. */
|
function dateText() {
|
||||||
function dateParts() {
|
return new Date().toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' });
|
||||||
const d = new Date();
|
|
||||||
return {
|
|
||||||
day: d.toLocaleDateString([], { weekday: pref('dateShortDay') ? 'short' : 'long' }),
|
|
||||||
rest: d.toLocaleDateString([], { month: pref('dateShortMonth') ? 'short' : 'long', day: 'numeric' }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The clock's date under its digits: one line when it fits the card,
|
|
||||||
* otherwise a line break after the day and a size that fits the longer
|
|
||||||
* half. Re-done only when the text or the card width changes.
|
|
||||||
*/
|
|
||||||
function renderDate(card, d) {
|
|
||||||
const cw = lastFit.cw;
|
|
||||||
const lineSize = lastFit.meta * LINE_SCALE;
|
|
||||||
const whole = `${d.day}, ${d.rest}`;
|
|
||||||
const key = `${whole}|${Math.round(cw)}|${lineSize}`;
|
|
||||||
if (card.dateKey === key) return;
|
|
||||||
card.dateKey = key;
|
|
||||||
let px = lineSize;
|
|
||||||
let wrapIt = false;
|
|
||||||
if (textWidth(whole, lineSize, 0.1) > cw) {
|
|
||||||
wrapIt = true;
|
|
||||||
const widest = Math.max(textWidth(`${d.day},`, lineSize, 0.1), textWidth(d.rest, lineSize, 0.1));
|
|
||||||
px = Math.max(8, Math.floor(lineSize * Math.min(1, cw / widest)));
|
|
||||||
}
|
|
||||||
const el = card.statusEl;
|
|
||||||
el.textContent = '';
|
|
||||||
const a = document.createElement('span');
|
|
||||||
a.textContent = `${d.day},`;
|
|
||||||
const b = document.createElement('span');
|
|
||||||
b.textContent = d.rest;
|
|
||||||
el.append(a, document.createTextNode(' '), b);
|
|
||||||
el.style.fontSize = px === lineSize ? '' : `${px}px`;
|
|
||||||
card.dateCls = wrapIt ? ' is-date is-wrap' : ' is-date';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A provider's kind:"clock" timer carries an ISO time in status.text as of its snapshot. */
|
/** A provider's kind:"clock" timer carries an ISO time in status.text as of its snapshot. */
|
||||||
@@ -615,9 +529,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
function paint() {
|
function paint() {
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
const now = serverNow();
|
const now = serverNow();
|
||||||
const showStatus = pref('showStatus') !== false;
|
const showStatus = settings().showStatus !== false;
|
||||||
const showDetail = pref('showDetail') !== false;
|
|
||||||
const digits = { seconds: pref('showSeconds') !== false, zeros: pref('leadingZeros') === true };
|
|
||||||
let charsChanged = false;
|
let charsChanged = false;
|
||||||
for (const card of cards.values()) {
|
for (const card of cards.values()) {
|
||||||
const { item } = card;
|
const { item } = card;
|
||||||
@@ -632,7 +544,6 @@ export default function create({ root, moduleApi }) {
|
|||||||
let statusTone = '';
|
let statusTone = '';
|
||||||
let detailText = '';
|
let detailText = '';
|
||||||
let extra = '';
|
let extra = '';
|
||||||
let date = null;
|
|
||||||
if (timer.kind === 'clock') {
|
if (timer.kind === 'clock') {
|
||||||
if (source.kind === 'ltc') {
|
if (source.kind === 'ltc') {
|
||||||
const l = timer.ltc || {};
|
const l = timer.ltc || {};
|
||||||
@@ -648,8 +559,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
} else if (source.kind === 'clock') {
|
} else if (source.kind === 'clock') {
|
||||||
text = wallClockText();
|
text = wallClockText();
|
||||||
tone = 'plain';
|
tone = 'plain';
|
||||||
date = pref('clockDate') ? dateParts() : null;
|
statusText = settings().clockDate ? dateText() : '';
|
||||||
statusText = date ? `${date.day}, ${date.rest}` : '';
|
|
||||||
statusTone = 'muted';
|
statusTone = 'muted';
|
||||||
} else {
|
} else {
|
||||||
text = providerClockText(timer, source, now);
|
text = providerClockText(timer, source, now);
|
||||||
@@ -659,7 +569,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const value = valueAt(timer, source.updatedAt, now);
|
const value = valueAt(timer, source.updatedAt, now);
|
||||||
text = value === null ? '—' : fmtDuration(value, { ...digits, up: timer.kind === 'countdown' });
|
text = value === null ? '—' : fmtDuration(value);
|
||||||
tone = toneFor(timer, value);
|
tone = toneFor(timer, value);
|
||||||
statusText = timer.status?.text || defaultStatus(timer, value, tone);
|
statusText = timer.status?.text || defaultStatus(timer, value, tone);
|
||||||
statusTone = timer.status?.tone || tone;
|
statusTone = timer.status?.tone || tone;
|
||||||
@@ -676,21 +586,14 @@ export default function create({ root, moduleApi }) {
|
|||||||
}
|
}
|
||||||
// Notes that must survive the compact look: an unavailable LTC input.
|
// Notes that must survive the compact look: an unavailable LTC input.
|
||||||
const isNote = extra === ' is-unavailable';
|
const isNote = extra === ' is-unavailable';
|
||||||
setText(card.statusEl, statusText); // a no-op over the date's two spans: same text
|
setText(card.statusEl, statusText);
|
||||||
card.statusEl.hidden = !statusText || (!showStatus && !isNote);
|
card.statusEl.hidden = !statusText || (!showStatus && !isNote);
|
||||||
if (date && !card.statusEl.hidden) renderDate(card, date);
|
card.statusEl.className = 'tm-status' + (isNote ? ' is-note' : '');
|
||||||
else if (card.dateCls) {
|
|
||||||
card.dateCls = '';
|
|
||||||
card.dateKey = '';
|
|
||||||
card.statusEl.style.fontSize = '';
|
|
||||||
}
|
|
||||||
card.statusEl.className = 'tm-status' + (isNote ? ' is-note' : '') + (card.dateCls || '');
|
|
||||||
card.statusEl.dataset.tone = statusTone;
|
card.statusEl.dataset.tone = statusTone;
|
||||||
setText(card.detailEl, detailText);
|
setText(card.detailEl, detailText);
|
||||||
card.detailEl.hidden = !detailText || !showDetail;
|
card.detailEl.hidden = !detailText || !showStatus;
|
||||||
const kindCls = source.kind === 'ltc' ? ' tm-card-ltc' : source.kind === 'clock' ? ' tm-card-clock' : '';
|
const kindCls = source.kind === 'ltc' ? ' tm-card-ltc' : source.kind === 'clock' ? ' tm-card-clock' : '';
|
||||||
const stale = source.status === 'error' ? ' is-stale' : '';
|
setCardClass(card, `tm-card is-${tone}${extra}${kindCls}`);
|
||||||
setCardClass(card, `tm-card is-${tone}${extra}${kindCls}${stale}`);
|
|
||||||
}
|
}
|
||||||
if (charsChanged) fit();
|
if (charsChanged) fit();
|
||||||
}
|
}
|
||||||
@@ -761,20 +664,11 @@ export default function create({ root, moduleApi }) {
|
|||||||
wrap.classList.toggle('tm-mono', mono);
|
wrap.classList.toggle('tm-mono', mono);
|
||||||
needFit = true;
|
needFit = true;
|
||||||
}
|
}
|
||||||
// Headings belong to sections; without grouping there is one grid and no heading.
|
const s = settings();
|
||||||
const showHeadings = !solo && pref('groupBySource') === true && pref('showHeadings') !== false;
|
const showHeadings = !solo && s.showHeadings !== false;
|
||||||
|
const showStatus = s.showStatus !== false;
|
||||||
wrap.classList.toggle('tm-headings', showHeadings);
|
wrap.classList.toggle('tm-headings', showHeadings);
|
||||||
for (const [cls, on] of [
|
wrap.classList.toggle('tm-nostatus', !showStatus);
|
||||||
['tm-grouped', !solo && pref('groupBySource') === true],
|
|
||||||
['tm-nostatus', pref('showStatus') === false],
|
|
||||||
['tm-nodetail', pref('showDetail') === false],
|
|
||||||
['tm-nonames', pref('showNames') === false],
|
|
||||||
]) {
|
|
||||||
if (wrap.classList.contains(cls) !== on) {
|
|
||||||
wrap.classList.toggle(cls, on);
|
|
||||||
needFit = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let hint = '';
|
let hint = '';
|
||||||
const groups = visibleGroups();
|
const groups = visibleGroups();
|
||||||
@@ -796,24 +690,18 @@ export default function create({ root, moduleApi }) {
|
|||||||
structureChanged = true;
|
structureChanged = true;
|
||||||
}
|
}
|
||||||
const source = g.source;
|
const source = g.source;
|
||||||
const status = g.status ?? (source ? source.status : 'ok');
|
const status = source ? source.status : 'ok';
|
||||||
const message = g.message ?? (source ? source.message : '');
|
const message = source ? source.message : '';
|
||||||
ge.headEl.hidden = !showHeadings;
|
ge.headEl.hidden = !showHeadings;
|
||||||
setText(ge.titleEl, source ? source.label : 'Timers');
|
setText(ge.titleEl, source ? source.label : 'Timer');
|
||||||
setText(ge.noteEl, status !== 'ok' ? message : g.items.length ? '' : 'no timers');
|
setText(ge.noteEl, status !== 'ok' ? message : g.items.length ? '' : 'no timers');
|
||||||
// Without headings the source's trouble still has to be said somewhere.
|
// Without headings the source's trouble still has to be said somewhere.
|
||||||
const emptyText = status !== 'ok' ? message : 'No timers';
|
const emptyText = status !== 'ok' ? message : 'No timers';
|
||||||
const showEmpty = !showHeadings && (status !== 'ok' || !g.items.length);
|
const showEmpty = !showHeadings && (status !== 'ok' || !g.items.length);
|
||||||
ge.emptyEl.hidden = !showEmpty;
|
ge.emptyEl.hidden = !showEmpty;
|
||||||
setText(ge.emptyEl, showEmpty ? emptyText : '');
|
setText(ge.emptyEl, showEmpty ? emptyText : '');
|
||||||
ge.emptyEl.dataset.tone = showEmpty && status !== 'ok' ? 'warn' : '';
|
ge.el.classList.toggle('is-down', status !== 'ok');
|
||||||
// In the flat grid a card dims for its own source (paint), not the whole grid.
|
ge.el.classList.toggle('is-stale', status === 'error' && g.items.length > 0);
|
||||||
ge.el.classList.toggle('is-down', !g.flat && status !== 'ok');
|
|
||||||
ge.el.classList.toggle('is-stale', !g.flat && status === 'error' && g.items.length > 0);
|
|
||||||
if (ge.gridEl.classList.contains('is-flow') !== Boolean(g.flat)) {
|
|
||||||
ge.gridEl.classList.toggle('is-flow', Boolean(g.flat));
|
|
||||||
structureChanged = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const els = [];
|
const els = [];
|
||||||
for (const item of g.items) {
|
for (const item of g.items) {
|
||||||
@@ -860,30 +748,6 @@ export default function create({ root, moduleApi }) {
|
|||||||
|
|
||||||
/* ── sizing: the column count that gives the biggest digits ─────── */
|
/* ── sizing: the column count that gives the biggest digits ─────── */
|
||||||
|
|
||||||
/** Status and detail lines are drawn at this share of the label's size (style.css agrees). */
|
|
||||||
const LINE_SCALE = 0.8;
|
|
||||||
|
|
||||||
/* Text measured off-screen the way the CSS draws it (bold, uppercase,
|
|
||||||
letter-spaced), so a title or the date can shrink before the card
|
|
||||||
would cut it off. */
|
|
||||||
const measurer = document.createElement('canvas').getContext('2d');
|
|
||||||
let textFamily = '';
|
|
||||||
function textWidth(text, px, spacingEm) {
|
|
||||||
if (!textFamily) textFamily = getComputedStyle(root).fontFamily || 'sans-serif';
|
|
||||||
measurer.font = `700 ${px}px ${textFamily}`;
|
|
||||||
const t = String(text || '').toUpperCase();
|
|
||||||
return measurer.measureText(t).width + spacingEm * px * Math.max(0, t.length - 1);
|
|
||||||
}
|
|
||||||
/** The last fit's card width and label size — what the date is fitted against. */
|
|
||||||
let lastFit = { cw: 200, meta: 12 };
|
|
||||||
function fitLabel(card, meta, avail) {
|
|
||||||
if (card.isClock) return; // the clock has no label line
|
|
||||||
const room = Math.max(24, avail - (card.nameEl.querySelector('.tm-ltc-badge') ? 34 : 0));
|
|
||||||
const natural = textWidth(card.labelEl.textContent, meta, 0.08);
|
|
||||||
const px = natural > room ? Math.max(8, Math.floor((meta * room) / natural)) : meta;
|
|
||||||
card.labelEl.style.fontSize = px === meta ? '' : `${px}px`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fit() {
|
function fit() {
|
||||||
const list = [...cards.values()];
|
const list = [...cards.values()];
|
||||||
const n = list.length;
|
const n = list.length;
|
||||||
@@ -895,90 +759,54 @@ export default function create({ root, moduleApi }) {
|
|||||||
const CARD_PAD = 12; // a card's own vertical padding + borders
|
const CARD_PAD = 12; // a card's own vertical padding + borders
|
||||||
const HEAD_H = 20; // a source heading line, margin included
|
const HEAD_H = 20; // a source heading line, margin included
|
||||||
const groups = [...groupEls.values()].filter((g) => g.el.isConnected);
|
const groups = [...groupEls.values()].filter((g) => g.el.isConnected);
|
||||||
// Grouped: each source's grid wraps on its own, so its rows add up; flat:
|
|
||||||
// one grid. Headings only add a line each — they never decide the rows.
|
|
||||||
const grouped = wrap.classList.contains('tm-grouped') && groups.length > 1;
|
|
||||||
const headings = wrap.classList.contains('tm-headings') ? groups.length : 0;
|
const headings = wrap.classList.contains('tm-headings') ? groups.length : 0;
|
||||||
const showStatus = !wrap.classList.contains('tm-nostatus');
|
const showStatus = !wrap.classList.contains('tm-nostatus');
|
||||||
const showDetail = !wrap.classList.contains('tm-nodetail');
|
// Lines under the digits besides the label: status, and detail if any card has one.
|
||||||
const namesOn = !wrap.classList.contains('tm-nonames');
|
const metaLines = showStatus ? 1 + (list.some((c) => !c.detailEl.hidden) ? 1 : 0) : 0;
|
||||||
// Lines under the digits: status, and detail if any card has one.
|
|
||||||
const metaLines = (showStatus ? 1 : 0) + (showDetail && list.some((c) => !c.detailEl.hidden) ? 1 : 0);
|
|
||||||
const date = showStatus && pref('clockDate') && list.some((c) => c.isClock) ? dateParts() : null;
|
|
||||||
const dateWhole = date ? `${date.day}, ${date.rest}` : '';
|
|
||||||
|
|
||||||
// For each column count, budget a card's full stack (label, digits,
|
// For each column count, budget a card's full stack (label, digits,
|
||||||
// lines) and score by the digit size it affords — the SMALLEST card's,
|
// lines) and score by the digit size it affords — the SMALLEST card's,
|
||||||
// so no timer ends up unreadable. Cards too short for the full stack
|
// so no timer ends up unreadable. Cards too short for the full stack
|
||||||
// drop to a compact look (label + digits, the colour carries the
|
// drop to a compact look (label + digits, the colour carries the
|
||||||
// state), but any layout with a readable full stack beats every
|
// state), but any layout with a readable full stack beats every
|
||||||
// compact one. The clock has no label line, and its date may take two.
|
// compact one.
|
||||||
const budgetFor = (c, ch, meta, mode, dateWrap) => {
|
|
||||||
const nameLine = namesOn && !c.isClock ? 1.3 * meta : 0;
|
|
||||||
if (mode === 'compact') return ch - nameLine;
|
|
||||||
const lines = metaLines + (c.isClock && dateWrap ? 1 : 0);
|
|
||||||
return ch - nameLine - lines * 1.3 * meta * LINE_SCALE;
|
|
||||||
};
|
|
||||||
const rowsFor = (cols) => (grouped
|
|
||||||
? groups.reduce((a, g) => a + Math.max(1, Math.ceil(g.count / cols)), 0)
|
|
||||||
: Math.ceil(n / cols));
|
|
||||||
let best = null;
|
let best = null;
|
||||||
for (let cols = 1; cols <= n; cols += 1) {
|
for (let cols = 1; cols <= n; cols += 1) {
|
||||||
const rows = rowsFor(cols);
|
const rows = headings
|
||||||
|
? groups.reduce((a, g) => a + Math.max(1, Math.ceil(g.count / cols)), 0)
|
||||||
|
: Math.ceil(n / cols);
|
||||||
const cw = (W - PAD * 2 - GAP * (cols - 1)) / cols - 18;
|
const cw = (W - PAD * 2 - GAP * (cols - 1)) / cols - 18;
|
||||||
// Every row the same height: padding, one heading line per group, and
|
const ch = (H - PAD * 2 - headings * HEAD_H - GAP * (rows - 1) - (headings ? (headings - 1) * GAP : 0)) / rows - CARD_PAD;
|
||||||
// a gap between any two rows (between groups too) come off the top.
|
|
||||||
const rowH = (H - PAD * 2 - headings * HEAD_H - GAP * (rows - 1)) / rows;
|
|
||||||
const ch = rowH - CARD_PAD;
|
|
||||||
if (cw <= 0 || ch <= 0) continue;
|
if (cw <= 0 || ch <= 0) continue;
|
||||||
const meta = Math.max(9, Math.min(22, Math.floor(ch * 0.16)));
|
const meta = Math.max(9, Math.min(22, Math.floor(ch * 0.16)));
|
||||||
const dateWrap = Boolean(dateWhole) && textWidth(dateWhole, meta * LINE_SCALE, 0.1) > cw;
|
const fullBudget = ch - (1 + metaLines) * 1.3 * meta;
|
||||||
|
const compactBudget = ch - 1.3 * meta;
|
||||||
let full = Infinity;
|
let full = Infinity;
|
||||||
let compact = Infinity;
|
let compact = Infinity;
|
||||||
for (const c of list) {
|
for (const c of list) {
|
||||||
const byWidth = (cw * 1.55) / c.chars;
|
const byWidth = (cw * 1.55) / c.chars;
|
||||||
full = Math.min(full, byWidth, budgetFor(c, ch, meta, 'full', dateWrap));
|
full = Math.min(full, byWidth, fullBudget);
|
||||||
compact = Math.min(compact, byWidth, budgetFor(c, ch, meta, 'compact', dateWrap));
|
compact = Math.min(compact, byWidth, compactBudget);
|
||||||
}
|
}
|
||||||
const mode = full >= 14 ? 'full' : 'compact';
|
const mode = full >= 14 ? 'full' : 'compact';
|
||||||
const size = mode === 'full' ? full : compact;
|
const size = mode === 'full' ? full : compact;
|
||||||
const score = (mode === 'full' ? 1000 : 0) + size;
|
const score = (mode === 'full' ? 1000 : 0) + size;
|
||||||
if (!best || score > best.score) best = { cols, meta, mode, cw, ch, rowH, dateWrap, score };
|
if (!best || score > best.score) best = { cols, meta, mode, cw, budget: mode === 'full' ? fullBudget : compactBudget, score };
|
||||||
}
|
}
|
||||||
if (!best) return;
|
if (!best) return;
|
||||||
for (const g of groups) {
|
for (const g of groups) {
|
||||||
g.gridEl.style.gridTemplateColumns = `repeat(${best.cols}, 1fr)`;
|
g.gridEl.style.gridTemplateColumns = `repeat(${best.cols}, 1fr)`;
|
||||||
g.gridEl.style.setProperty('--tm-cols', String(best.cols));
|
// Space is shared in proportion to each group's rows, so a card is
|
||||||
// Each group takes exactly the height its rows, gaps and heading need,
|
// the same height under every heading.
|
||||||
// so a card is the same height under every heading.
|
g.el.style.flexGrow = String(Math.max(1, Math.ceil(g.count / best.cols)));
|
||||||
const rows = Math.max(1, Math.ceil(g.count / best.cols));
|
|
||||||
const head = headings ? HEAD_H : 0;
|
|
||||||
g.el.style.flexGrow = String(Math.max(1, Math.round(head + rows * best.rowH + (rows - 1) * GAP)));
|
|
||||||
}
|
}
|
||||||
wrap.style.setProperty('--tm-pad', PAD + 'px');
|
wrap.style.setProperty('--tm-pad', PAD + 'px');
|
||||||
wrap.style.setProperty('--tm-gap', GAP + 'px');
|
wrap.style.setProperty('--tm-gap', GAP + 'px');
|
||||||
wrap.style.setProperty('--tm-meta-size', best.meta + 'px');
|
wrap.style.setProperty('--tm-meta-size', best.meta + 'px');
|
||||||
wrap.classList.toggle('tm-compact', best.mode === 'compact');
|
wrap.classList.toggle('tm-compact', best.mode === 'compact');
|
||||||
lastFit = { cw: best.cw, meta: best.meta };
|
|
||||||
for (const c of list) {
|
for (const c of list) {
|
||||||
const budget = budgetFor(c, best.ch, best.meta, best.mode, best.dateWrap);
|
const px = Math.max(11, Math.floor(Math.min((best.cw * 1.55) / c.chars, best.budget)));
|
||||||
const px = Math.max(11, Math.floor(Math.min((best.cw * 1.55) / c.chars, budget)));
|
|
||||||
c.timeEl.style.fontSize = px + 'px';
|
c.timeEl.style.fontSize = px + 'px';
|
||||||
fitLabel(c, best.meta, best.cw);
|
|
||||||
c.dateKey = ''; // the date re-fits to the new card width on the next paint
|
|
||||||
}
|
|
||||||
// Then a look at the real thing: the layout is applied, so each card's
|
|
||||||
// height is known. Digits that would still spill past it — an estimate
|
|
||||||
// off by a line, a heading taller than budgeted — shrink to what is
|
|
||||||
// there. Only ever smaller; the estimate above sets the ceiling.
|
|
||||||
for (const c of list) {
|
|
||||||
const box = c.el.clientHeight;
|
|
||||||
if (!box) continue;
|
|
||||||
const parts = [c.nameEl, c.timeEl, c.statusEl, c.detailEl].filter((el) => !el.hidden && getComputedStyle(el).display !== 'none');
|
|
||||||
const others = parts.filter((el) => el !== c.timeEl).reduce((a, el) => a + el.getBoundingClientRect().height, 0);
|
|
||||||
const room = box - CARD_PAD - others - 0.02 * box * Math.max(0, parts.length - 1); // .tm-card's 2% gaps
|
|
||||||
const px = parseFloat(c.timeEl.style.fontSize) || 0;
|
|
||||||
if (room > 0 && px * 1.05 > room) c.timeEl.style.fontSize = `${Math.max(11, Math.floor(room / 1.05))}px`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,30 +4,16 @@
|
|||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"proddash": ">=1.4.0",
|
"proddash": ">=1.4.0",
|
||||||
"description": "Timers, Clocks, and LTC From configured Modules",
|
"description": "Timers, Clocks, and LTC From configured Modules",
|
||||||
"provides": [
|
"provides": ["timers"],
|
||||||
"timers"
|
|
||||||
],
|
|
||||||
"permissions": [
|
"permissions": [
|
||||||
{
|
{ "kind": "microphone", "reason": "Decoding SMPTE LTC timecode from an audio input on this machine" },
|
||||||
"kind": "microphone",
|
{ "kind": "local-network", "reason": "Reaching ProPresenter on this network" }
|
||||||
"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": {
|
"minSize": { "w": 2, "h": 1 },
|
||||||
"w": 2,
|
"defaultSize": { "w": 4, "h": 3 },
|
||||||
"h": 1
|
|
||||||
},
|
|
||||||
"defaultSize": {
|
|
||||||
"w": 4,
|
|
||||||
"h": 3
|
|
||||||
},
|
|
||||||
"configGroups": {
|
"configGroups": {
|
||||||
"ProPresenter": {
|
"ProPresenter": {
|
||||||
"toggle": "ppEnabled",
|
"toggle": "ppEnabled",
|
||||||
@@ -53,10 +39,7 @@
|
|||||||
"type": "endpoint",
|
"type": "endpoint",
|
||||||
"label": "ProPresenter",
|
"label": "ProPresenter",
|
||||||
"group": "ProPresenter",
|
"group": "ProPresenter",
|
||||||
"default": {
|
"default": { "host": "", "port": 1025 }
|
||||||
"host": "",
|
|
||||||
"port": 1025
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"password": {
|
"password": {
|
||||||
"type": "password",
|
"type": "password",
|
||||||
@@ -99,26 +82,14 @@
|
|||||||
"group": "Display",
|
"group": "Display",
|
||||||
"default": "default",
|
"default": "default",
|
||||||
"options": [
|
"options": [
|
||||||
{
|
{ "value": "default", "label": "Default (system)" },
|
||||||
"value": "default",
|
{ "value": "monospace", "label": "Monospace — steadier changing digits" }
|
||||||
"label": "Default (system)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": "monospace",
|
|
||||||
"label": "Monospace — steadier changing digits"
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"instanceGroups": {
|
"instanceGroups": {
|
||||||
"Show": {
|
"Show": { "collapsed": false, "columns": 2 },
|
||||||
"collapsed": false,
|
"Clock": { "collapsed": false },
|
||||||
"columns": 2
|
|
||||||
},
|
|
||||||
"Clock": {
|
|
||||||
"collapsed": false,
|
|
||||||
"columns": 2
|
|
||||||
},
|
|
||||||
"Colours": {
|
"Colours": {
|
||||||
"collapsed": true,
|
"collapsed": true,
|
||||||
"columns": 2,
|
"columns": 2,
|
||||||
@@ -126,104 +97,27 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"instanceSchema": {
|
"instanceSchema": {
|
||||||
"showNames": {
|
"showClock": { "type": "boolean", "label": "Clock", "default": true, "group": "Show" },
|
||||||
"type": "switch",
|
"showProPresenter": { "type": "boolean", "label": "ProPresenter timers", "default": true, "group": "Show" },
|
||||||
"label": "Names",
|
"showLtc": { "type": "boolean", "label": "LTC timecode", "default": true, "group": "Show" },
|
||||||
"default": true,
|
"showModules": { "type": "boolean", "label": "Module timers", "default": true, "group": "Show" },
|
||||||
"group": "Show"
|
"showHeadings": { "type": "boolean", "label": "Source headings", "default": true, "group": "Show" },
|
||||||
},
|
"showStatus": { "type": "boolean", "label": "Status lines", "default": true, "group": "Show" },
|
||||||
"showStatus": {
|
"clockFormat": {
|
||||||
"type": "switch",
|
"type": "select",
|
||||||
"label": "Status lines",
|
"label": "Clock format",
|
||||||
"default": true,
|
"default": "24h",
|
||||||
"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",
|
"group": "Clock",
|
||||||
"showWhen": "clockDate"
|
"options": [
|
||||||
|
{ "value": "24h", "label": "24-hour" },
|
||||||
|
{ "value": "12h", "label": "12-hour" }
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"dateShortMonth": {
|
"clockSeconds": { "type": "boolean", "label": "Seconds", "default": true, "group": "Clock" },
|
||||||
"type": "switch",
|
"clockDate": { "type": "boolean", "label": "Date", "default": false, "group": "Clock" },
|
||||||
"label": "Short month",
|
"runningColor": { "type": "color", "label": "Running", "default": "#2ee59a", "group": "Colours" },
|
||||||
"default": false,
|
"warningColor": { "type": "color", "label": "Warning", "default": "#f0b429", "group": "Colours" },
|
||||||
"group": "Clock",
|
"overrunColor": { "type": "color", "label": "Overrun", "default": "#f4433c", "group": "Colours" },
|
||||||
"showWhen": "clockDate"
|
"idleColor": { "type": "color", "label": "Idle", "default": "#8b98a5", "group": "Colours" }
|
||||||
},
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,6 @@ const http = require('http');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
const { createLtcListener } = require('./ltc-listener');
|
const { createLtcListener } = require('./ltc-listener');
|
||||||
const manifest = require('./module.json');
|
|
||||||
|
|
||||||
/** This module's id — left out of provider discovery (we don't consume ourselves). */
|
/** This module's id — left out of provider discovery (we don't consume ourselves). */
|
||||||
const SELF_ID = 'propresenter-timers';
|
const SELF_ID = 'propresenter-timers';
|
||||||
@@ -171,14 +170,14 @@ function fmtMs(ms) {
|
|||||||
return h ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`;
|
return h ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ProPresenter's count_down_to_time target, short: "9:30a", "12:05p", or "21:30" for a 24-hour clock. */
|
/** ProPresenter's count_down_to_time target: seconds since midnight + am/pm/24h. */
|
||||||
function timeOfDayText(seconds, period) {
|
function timeOfDayText(seconds, period) {
|
||||||
const s = Number(seconds);
|
const s = Number(seconds);
|
||||||
if (!Number.isFinite(s)) return '';
|
if (!Number.isFinite(s)) return '';
|
||||||
const h = Math.floor(s / 3600) % 24;
|
const h = Math.floor(s / 3600) % 24;
|
||||||
const m = Math.floor((s % 3600) / 60);
|
const m = Math.floor((s % 3600) / 60);
|
||||||
const p = String(period || '').toLowerCase();
|
const p = String(period || '').toLowerCase();
|
||||||
if (p === 'am' || p === 'pm') return `${h % 12 || 12}:${String(m).padStart(2, '0')}${p[0]}`;
|
if (p === 'am' || p === 'pm') return `${h % 12 || 12}:${String(m).padStart(2, '0')} ${p}`;
|
||||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,7 +277,7 @@ function createProPresenterSource({ config, log }) {
|
|||||||
}
|
}
|
||||||
if (entry.count_down_to_time) {
|
if (entry.count_down_to_time) {
|
||||||
const at = timeOfDayText(entry.count_down_to_time.time_of_day, entry.count_down_to_time.period);
|
const at = timeOfDayText(entry.count_down_to_time.time_of_day, entry.count_down_to_time.period);
|
||||||
return { type: 'countdown_to_time', targetMs: null, detail: at ? `to ${at}` : 'to a time of day' };
|
return { type: 'countdown_to_time', targetMs: null, detail: at ? `Counts down to ${at}` : 'Counts down to a time of day' };
|
||||||
}
|
}
|
||||||
if (entry.elapsed) {
|
if (entry.elapsed) {
|
||||||
const end = Number(entry.elapsed.end_time);
|
const end = Number(entry.elapsed.end_time);
|
||||||
@@ -874,70 +873,6 @@ function createModuleDiscovery({ shellPort, log, flags, onAdd, onRemove }) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── what each tile's gear offers ─────────────────────────────────────
|
|
||||||
module.json's instanceSchema is the full set — what an "All timers" tile
|
|
||||||
offers, and what the shell falls back to for a tile with no picker entry
|
|
||||||
(one added before the picker listed timers). A solo tile shows one card,
|
|
||||||
so its picker entry carries a schema of just what that card can use, and
|
|
||||||
the shell renders that instead (module guide, "Presenting multiple
|
|
||||||
tiles"): a Message Timer has no use for the clock's format switches.
|
|
||||||
Every field is drawn from the manifest so it is defined once; the client
|
|
||||||
falls back to its own defaults for keys a slimmer schema leaves out. */
|
|
||||||
|
|
||||||
const FULL_SCHEMA = manifest.instanceSchema || {};
|
|
||||||
const FULL_GROUPS = manifest.instanceGroups || {};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A gear schema of just `keys` (manifest order kept) with the groups they
|
|
||||||
* use. `loose` names keys shown outside their group; `groups` overrides a
|
|
||||||
* group's meta. A group toggle whose switch isn't among the keys is dropped.
|
|
||||||
*/
|
|
||||||
function gearOf(keys, { loose = [], groups = {} } = {}) {
|
|
||||||
const instanceSchema = {};
|
|
||||||
for (const key of Object.keys(FULL_SCHEMA)) {
|
|
||||||
if (!keys.includes(key)) continue;
|
|
||||||
const spec = { ...FULL_SCHEMA[key] };
|
|
||||||
if (loose.includes(key)) delete spec.group;
|
|
||||||
instanceSchema[key] = spec;
|
|
||||||
}
|
|
||||||
const instanceGroups = {};
|
|
||||||
for (const spec of Object.values(instanceSchema)) {
|
|
||||||
if (!spec.group || instanceGroups[spec.group]) continue;
|
|
||||||
const meta = { ...(FULL_GROUPS[spec.group] || {}), ...(groups[spec.group] || {}) };
|
|
||||||
if (meta.toggle && !(meta.toggle in instanceSchema)) delete meta.toggle;
|
|
||||||
instanceGroups[spec.group] = meta;
|
|
||||||
}
|
|
||||||
return { instanceSchema, instanceGroups };
|
|
||||||
}
|
|
||||||
|
|
||||||
const COLOURS = ['runningColor', 'warningColor', 'overrunColor', 'idleColor'];
|
|
||||||
/** What a timer card can show or hide of itself (which timers appear is the header menu's job). */
|
|
||||||
const TIMER_ELEMENTS = ['showNames', 'showStatus', 'showDetail', 'showSeconds', 'leadingZeros'];
|
|
||||||
const CLOCK_ELEMENTS = ['clock24h', 'clockSeconds', 'clockDate', 'dateShortDay', 'dateShortMonth'];
|
|
||||||
/** With one card there is little else to see: the colours start unfolded. */
|
|
||||||
const OPEN_COLOURS = { groups: { Colours: { collapsed: false } } };
|
|
||||||
|
|
||||||
const GEAR = {
|
|
||||||
/** Every card's elements, the headings, how the clock reads, the tones. */
|
|
||||||
all: { instanceSchema: FULL_SCHEMA, instanceGroups: FULL_GROUPS },
|
|
||||||
/** One ProPresenter or module timer: its elements, and the tone its digits take per state. */
|
|
||||||
timer: gearOf([...TIMER_ELEMENTS, ...COLOURS], OPEN_COLOURS),
|
|
||||||
/** The timecode: running, no signal (warning) and stopped (idle) — it never overruns, and frames are frames. */
|
|
||||||
ltc: gearOf(['showNames', 'showStatus', 'runningColor', 'warningColor', 'idleColor'], OPEN_COLOURS),
|
|
||||||
/** The time of day: plain digits, so only how they read. */
|
|
||||||
clock: gearOf(CLOCK_ELEMENTS),
|
|
||||||
/** A clock another module offers: read like the clock, muted while it isn't running. */
|
|
||||||
providerClock: gearOf(['showNames', 'showStatus', 'clock24h', 'clockSeconds', 'idleColor'], OPEN_COLOURS),
|
|
||||||
};
|
|
||||||
|
|
||||||
/** The gear a solo card of this timer gets — by what the card can show. */
|
|
||||||
function gearFor(source, timer) {
|
|
||||||
if (source.kind === 'ltc') return GEAR.ltc;
|
|
||||||
if (source.kind === 'clock') return GEAR.clock;
|
|
||||||
if (timer.kind === 'clock') return GEAR.providerClock;
|
|
||||||
return GEAR.timer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── the module ───────────────────────────────────────────────────────── */
|
/* ── the module ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
/** Sort order of the sources in the tile: fixed ones first, then PCO Plan, then other modules by name. */
|
/** Sort order of the sources in the tile: fixed ones first, then PCO Plan, then other modules by name. */
|
||||||
@@ -1123,25 +1058,19 @@ module.exports = {
|
|||||||
* The Add-tile picker's entries: "All timers" plus one solo card per known
|
* The Add-tile picker's entries: "All timers" plus one solo card per known
|
||||||
* timer from any source (moduleApi.variant carries the entry id — 'all',
|
* timer from any source (moduleApi.variant carries the entry id — 'all',
|
||||||
* or 'solo:<source>:<timer id>'; the older 'timer:<uuid>' / 'ltc' ids keep
|
* or 'solo:<source>:<timer id>'; the older 'timer:<uuid>' / 'ltc' ids keep
|
||||||
* working). Each entry carries the gear settings that fit its tile (see
|
* working). Answered from the merged state, never from upstream — the
|
||||||
* GEAR above); descriptions are the picker's tooltips, one sentence each.
|
* picker must stay instant and a dead upstream must not stall it.
|
||||||
* Answered from the merged state, never from upstream — the picker must
|
|
||||||
* stay instant and a dead upstream must not stall it.
|
|
||||||
*/
|
*/
|
||||||
tiles() {
|
tiles() {
|
||||||
const state = current ? current.getState() : null;
|
const state = current ? current.getState() : null;
|
||||||
if (!state) return [];
|
if (!state) return [];
|
||||||
const KIND_WORD = { countdown: 'countdown', elapsed: 'elapsed timer', clock: 'clock' };
|
const KIND_LABEL = { countdown: 'Countdown', elapsed: 'Elapsed timer', clock: 'Clock' };
|
||||||
const oneCard = { defaultSize: { w: 3, h: 2 }, minSize: { w: 2, h: 1 } };
|
const oneCard = { defaultSize: { w: 3, h: 2 }, minSize: { w: 2, h: 1 } };
|
||||||
const out = [{ id: 'all', name: 'All timers', description: 'Every timer and clock, grouped by source', ...GEAR.all }];
|
const out = [{ id: 'all', name: 'All timers', description: 'Every timer, clock and timecode — choose which per tile' }];
|
||||||
for (const src of state.sources) {
|
for (const src of state.sources) {
|
||||||
for (const t of src.timers) {
|
for (const t of src.timers) {
|
||||||
const description = src.kind === 'ltc' ? 'The incoming LTC timecode on its own'
|
const what = src.id === 'ltc' ? 'Incoming timecode (HH:MM:SS:FF)' : src.id === 'clock' ? 'Time of day' : KIND_LABEL[t.kind] || 'Timer';
|
||||||
: src.kind === 'clock' ? 'The time of day on its own'
|
out.push({ id: `solo:${src.id}:${t.id}`, name: t.label, description: `${src.label} · ${what}`, ...oneCard });
|
||||||
: `This ${src.label} ${KIND_WORD[t.kind] || 'timer'} on its own`;
|
|
||||||
// Layouts from before 1.3.0 hold 'timer:<uuid>' / 'ltc' — the same tiles.
|
|
||||||
const aliases = src.id === 'propresenter' ? [`timer:${t.id}`] : src.kind === 'ltc' ? ['ltc'] : [];
|
|
||||||
out.push({ id: `solo:${src.id}:${t.id}`, name: t.label, description, ...oneCard, ...gearFor(src, t), ...(aliases.length ? { aliases } : {}) });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
|
|||||||
@@ -39,7 +39,6 @@
|
|||||||
visually, per group: the LTC card keeps ticking when only ProPresenter
|
visually, per group: the LTC card keeps ticking when only ProPresenter
|
||||||
is unreachable. When the ProdDash feed is down everything is old. */
|
is unreachable. When the ProdDash feed is down everything is old. */
|
||||||
[data-module="propresenter-timers"] .tm-group.is-stale .tm-card,
|
[data-module="propresenter-timers"] .tm-group.is-stale .tm-card,
|
||||||
[data-module="propresenter-timers"] .tm-card.is-stale,
|
|
||||||
[data-module="propresenter-timers"] .tm-wrap.is-feed-down .tm-card { opacity: 0.45; }
|
[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 {
|
||||||
@@ -127,31 +126,13 @@
|
|||||||
}
|
}
|
||||||
[data-module="propresenter-timers"] .tm-grid:empty { display: none; }
|
[data-module="propresenter-timers"] .tm-grid:empty { display: none; }
|
||||||
|
|
||||||
/* Grouping off: one grid for every timer, filled row by row. A wrapping
|
|
||||||
flex row instead of a CSS grid, so the last row's cards widen to use the
|
|
||||||
whole width — nothing is left empty. Rows share the height equally. */
|
|
||||||
[data-module="propresenter-timers"] .tm-grid.is-flow {
|
|
||||||
display: flex;
|
|
||||||
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
|
/* 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
|
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. */
|
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-status:not(.is-note),
|
||||||
[data-module="propresenter-timers"] .tm-compact .tm-detail,
|
[data-module="propresenter-timers"] .tm-compact .tm-detail,
|
||||||
[data-module="propresenter-timers"] .tm-nostatus .tm-status:not(.is-note),
|
[data-module="propresenter-timers"] .tm-nostatus .tm-status:not(.is-note),
|
||||||
[data-module="propresenter-timers"] .tm-nodetail .tm-detail { display: none; }
|
[data-module="propresenter-timers"] .tm-nostatus .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 ── */
|
||||||
|
|
||||||
@@ -215,7 +196,7 @@
|
|||||||
[data-module="propresenter-timers"] .tm-detail {
|
[data-module="propresenter-timers"] .tm-detail {
|
||||||
flex: none;
|
flex: none;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
font-size: calc(var(--tm-meta-size) * 0.8); /* smaller than the title; fit() budgets the same share */
|
font-size: var(--tm-meta-size);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -240,18 +221,6 @@
|
|||||||
[data-module="propresenter-timers"] .tm-status[data-tone="danger"] { color: var(--tm-danger); }
|
[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); }
|
[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; }
|
|
||||||
|
|
||||||
/* Explanatory notes read as sentences, not badges. */
|
/* Explanatory notes read as sentences, not badges. */
|
||||||
[data-module="propresenter-timers"] .tm-status.is-note {
|
[data-module="propresenter-timers"] .tm-status.is-note {
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
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
@@ -1,129 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "slack",
|
|
||||||
"name": "Slack",
|
|
||||||
"version": "1.2.1",
|
|
||||||
"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 until one is clicked, to the first channel here whose transcript is on that dashboard: with a single transcript up, that one. A send never goes to a channel the dashboard is not showing. 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; until a transcript is clicked, sends go to the first channel here that the dashboard is actually showing. 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
@@ -1,677 +0,0 @@
|
|||||||
/* 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; }
|
|
||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "proddash",
|
"name": "proddash",
|
||||||
"version": "1.5.0",
|
"version": "1.4.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
|
"description": "Modular production dashboard for Waters Church — zero-dependency Node server + vanilla client",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ Edit [config.json](config.json):
|
|||||||
|
|
||||||
| Key | Meaning |
|
| Key | Meaning |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `prodcomUrl` | Where the ProdCom API lives — set it per site, e.g. `http://<prodcom-host>:24480` |
|
| `prodcomUrl` | Where the ProdCom API lives (default `http://10.3.11.152:24480`) |
|
||||||
| `port` | Port this listener serves on (default `24481`) |
|
| `port` | Port this listener serves on (default `24481`) |
|
||||||
| `apiKey` | Only needed if PSK auth is enabled in ProdCom settings |
|
| `apiKey` | Only needed if PSK auth is enabled in ProdCom settings |
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"prodcomUrl": "",
|
"prodcomUrl": "http://10.3.11.152:24480",
|
||||||
"port": 24481,
|
"port": 24481,
|
||||||
"apiKey": ""
|
"apiKey": ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,15 +23,10 @@ try {
|
|||||||
console.warn('config.json not found or invalid, using defaults/env vars');
|
console.warn('config.json not found or invalid, using defaults/env vars');
|
||||||
}
|
}
|
||||||
|
|
||||||
const PRODCOM_URL = process.env.PRODCOM_URL || cfg.prodcomUrl || '';
|
const PRODCOM_URL = process.env.PRODCOM_URL || cfg.prodcomUrl || 'http://10.3.11.152:24480';
|
||||||
const PORT = Number(process.env.PORT || cfg.port || 24481);
|
const PORT = Number(process.env.PORT || cfg.port || 24481);
|
||||||
const API_KEY = process.env.PRODCOM_API_KEY || cfg.apiKey || '';
|
const API_KEY = process.env.PRODCOM_API_KEY || cfg.apiKey || '';
|
||||||
|
|
||||||
if (!PRODCOM_URL) {
|
|
||||||
console.error('No ProdCom API configured. Set prodcomUrl in config.json (or the PRODCOM_URL');
|
|
||||||
console.error('environment variable) to this site\'s ProdCom, e.g. http://<prodcom-host>:24480');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
const target = new URL(PRODCOM_URL);
|
const target = new URL(PRODCOM_URL);
|
||||||
const PUBLIC_DIR = path.join(__dirname, 'public');
|
const PUBLIC_DIR = path.join(__dirname, 'public');
|
||||||
|
|
||||||
|
|||||||
@@ -135,33 +135,11 @@ 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`) */
|
||||||
|
|||||||
@@ -228,73 +228,6 @@ 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');
|
||||||
@@ -306,17 +239,6 @@ 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;
|
||||||
|
|||||||
+8
-7
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
|
|
||||||
<button class="tb-btn icon" id="fs-btn" title="Fullscreen" aria-label="Fullscreen">
|
<button class="tb-btn icon" id="fs-btn" title="Fullscreen — auto-fits the layout to the screen" 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,14 +27,12 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="dropdown" id="add-dd">
|
<div class="dropdown" id="add-dd">
|
||||||
<button class="tb-btn icon" id="add-btn" title="Add tile" aria-label="Add tile">
|
<button class="tb-btn icon" id="add-btn" title="Add a module tile" aria-label="Add tile">+</button>
|
||||||
<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="Layouts" aria-label="Layouts">
|
<button class="tb-btn icon" id="layout-btn" title="Save, load or reset layouts" aria-label="Layouts">
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
<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"/>
|
||||||
@@ -44,8 +42,11 @@
|
|||||||
<div class="menu" id="layout-menu" hidden></div>
|
<div class="menu" id="layout-menu" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a class="tb-btn icon" id="admin-link" href="/admin/" title="Admin" aria-label="Admin">
|
<a class="tb-btn" id="admin-link" href="/admin/" title="Configure modules (server-wide)">
|
||||||
<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>
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm8.4-2.1.1-1.4-.1-1.4 1.7-1.3-1.6-2.8-2 .7a7.9 7.9 0 0 0-2.4-1.4l-.3-2.1H11l-.3 2.1a7.9 7.9 0 0 0-2.4 1.4l-2-.7L4.7 9.3 6.4 10.6l-.1 1.4.1 1.4-1.7 1.3 1.6 2.8 2-.7a7.9 7.9 0 0 0 2.4 1.4l.3 2.1h2.8l.3-2.1a7.9 7.9 0 0 0 2.4-1.4l2 .7 1.6-2.8-1.7-1.3Z"/>
|
||||||
|
</svg>
|
||||||
|
Admin
|
||||||
</a>
|
</a>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
+130
-569
@@ -108,11 +108,9 @@ function wireDropdown(btnId, menuId, rebuild) {
|
|||||||
return menu;
|
return menu;
|
||||||
}
|
}
|
||||||
|
|
||||||
function menuItem(label, onClick, { sub = '', danger = false, disabled = false, title = '' } = {}) {
|
function menuItem(label, onClick, { sub = '', danger = false } = {}) {
|
||||||
const item = document.createElement('button');
|
const item = document.createElement('button');
|
||||||
item.className = 'menu-item' + (danger ? ' danger' : '');
|
item.className = 'menu-item' + (danger ? ' danger' : '');
|
||||||
item.disabled = disabled;
|
|
||||||
if (title) item.title = title;
|
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.textContent = label;
|
span.textContent = label;
|
||||||
if (sub) {
|
if (sub) {
|
||||||
@@ -251,8 +249,7 @@ function minSizeOf(tile) {
|
|||||||
|
|
||||||
/* ── tile DOM ───────────────────────────────────────────────────────── */
|
/* ── tile DOM ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
const CLOSE_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>';
|
const GEAR_SVG = '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm8.4-2.1.1-1.4-.1-1.4 1.7-1.3-1.6-2.8-2 .7a7.9 7.9 0 0 0-2.4-1.4l-.3-2.1H11l-.3 2.1a7.9 7.9 0 0 0-2.4 1.4l-2-.7L4.7 9.3 6.4 10.6l-.1 1.4.1 1.4-1.7 1.3 1.6 2.8 2-.7a7.9 7.9 0 0 0 2.4 1.4l.3 2.1h2.8l.3-2.1a7.9 7.9 0 0 0 2.4-1.4l2 .7 1.6-2.8-1.7-1.3Z"/></svg>';
|
||||||
const GEAR_SVG = '<svg 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>';
|
|
||||||
|
|
||||||
function buildTile(tile) {
|
function buildTile(tile) {
|
||||||
const man = registry.get(tile.module);
|
const man = registry.get(tile.module);
|
||||||
@@ -261,7 +258,6 @@ function buildTile(tile) {
|
|||||||
el.className = 'tile';
|
el.className = 'tile';
|
||||||
el.dataset.tileId = tile.id;
|
el.dataset.tileId = tile.id;
|
||||||
if (man) el.dataset.module = man.id;
|
if (man) el.dataset.module = man.id;
|
||||||
applyTint(tile, el);
|
|
||||||
|
|
||||||
const head = document.createElement('div');
|
const head = document.createElement('div');
|
||||||
head.className = 'tile-head';
|
head.className = 'tile-head';
|
||||||
@@ -280,24 +276,23 @@ function buildTile(tile) {
|
|||||||
|
|
||||||
head.append(dot, title, controls);
|
head.append(dot, title, controls);
|
||||||
|
|
||||||
// Every tile has something to set: its own settings when the module
|
const hasSettings = man && man.instanceSchema && Object.keys(man.instanceSchema).length;
|
||||||
// offers any, and the tint either way.
|
if (hasSettings) {
|
||||||
if (man) {
|
|
||||||
const gear = document.createElement('button');
|
const gear = document.createElement('button');
|
||||||
gear.className = 'tile-btn';
|
gear.className = 'tile-btn';
|
||||||
gear.title = 'Settings';
|
gear.title = 'Tile settings (this tile only)';
|
||||||
gear.innerHTML = GEAR_SVG;
|
gear.innerHTML = GEAR_SVG;
|
||||||
gear.addEventListener('click', (e) => {
|
gear.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
toggleSettingsPopover(tile, el, gear);
|
toggleSettingsPopover(tile, el);
|
||||||
});
|
});
|
||||||
head.appendChild(gear);
|
head.appendChild(gear);
|
||||||
}
|
}
|
||||||
|
|
||||||
const close = document.createElement('button');
|
const close = document.createElement('button');
|
||||||
close.className = 'tile-btn close';
|
close.className = 'tile-btn close';
|
||||||
close.title = 'Remove';
|
close.title = 'Remove this tile';
|
||||||
close.innerHTML = CLOSE_SVG; // a drawn cross, the same size in every browser (the ✕ glyph varies)
|
close.textContent = '✕';
|
||||||
close.addEventListener('click', (e) => {
|
close.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
removeTile(tile.id);
|
removeTile(tile.id);
|
||||||
@@ -711,10 +706,6 @@ function buildDivider(L, R, top, bottom) {
|
|||||||
*/
|
*/
|
||||||
function addTile(moduleId, entry = null) {
|
function addTile(moduleId, entry = null) {
|
||||||
const man = registry.get(moduleId);
|
const man = registry.get(moduleId);
|
||||||
if (entry?.single && tiles.some((t) => t.module === moduleId && t.variant === String(entry.id))) {
|
|
||||||
toast(`${entry.name} is already on this dashboard`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const defaultSize = entry?.defaultSize || man?.defaultSize;
|
const defaultSize = entry?.defaultSize || man?.defaultSize;
|
||||||
const size = {
|
const size = {
|
||||||
w: Math.max(1, Math.min(COLS, defaultSize?.w || 4)),
|
w: Math.max(1, Math.min(COLS, defaultSize?.w || 4)),
|
||||||
@@ -722,8 +713,7 @@ function addTile(moduleId, entry = null) {
|
|||||||
};
|
};
|
||||||
const spot = findSpot(size.w, size.h);
|
const spot = findSpot(size.w, size.h);
|
||||||
const settings = {};
|
const settings = {};
|
||||||
const { schema: addSchema } = tileSchema({ variant: entry ? String(entry.id) : '' }, man);
|
for (const [key, spec] of Object.entries(man?.instanceSchema || {})) {
|
||||||
for (const [key, spec] of Object.entries(addSchema)) {
|
|
||||||
if (spec && 'default' in spec) settings[key] = spec.default;
|
if (spec && 'default' in spec) settings[key] = spec.default;
|
||||||
}
|
}
|
||||||
Object.assign(settings, entry?.settings || {});
|
Object.assign(settings, entry?.settings || {});
|
||||||
@@ -796,130 +786,80 @@ function tileMessage(entry, text) {
|
|||||||
entry.body.appendChild(msg);
|
entry.body.appendChild(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── one stream per page ─────────────────────────────────────────────── */
|
/* ── shared event streams ───────────────────────────────────────────── */
|
||||||
|
|
||||||
/* Browsers allow about six HTTP/1.1 connections to one host, and an open
|
/* Browsers allow about six HTTP/1.1 connections to one host, and an open
|
||||||
EventSource holds one of them for as long as it lives. Sharing one
|
EventSource holds one of them for as long as it lives. One stream per
|
||||||
stream per URL was not enough: a dashboard with six modules still ran
|
tile plus the shell's own /api/events reaches that ceiling at five or six
|
||||||
out (Safari first), and from then on every further request — a remounted
|
tiles, and from then on every further request — the + picker's module
|
||||||
tile's /state, an avatar, the + picker's list — sat in the browser's
|
list, a new tile's /state, its stream — sits in the browser's queue
|
||||||
queue waiting for a connection that was never handed back. So the page
|
waiting for a connection that is never handed back. So tiles that ask
|
||||||
holds ONE EventSource, GET /api/stream?s=<keys>, and the server relays
|
for the same stream share one connection: the pool keeps one EventSource
|
||||||
every stream named in it (a module's, or `shell:` for the shell's own)
|
per URL, fans its events out to every subscriber, and reconnects it (on
|
||||||
with the key prefixed to each event name. Subscribers still see the
|
the same 3-second timer the per-tile version used) while anyone is still
|
||||||
events they asked for by their own names. Adding or dropping a stream
|
listening. A tile joining an already-open stream is told `open` straight
|
||||||
remakes the connection with the new list, a moment later so several
|
away, so its backfill runs exactly as if the connection were its own. */
|
||||||
tiles mounting together cause one reconnect; every stream announces
|
|
||||||
`open` again after it, so backfills run as before. */
|
|
||||||
|
|
||||||
const muxSubs = new Map(); // key -> { subs: Set<{ handlers }> }
|
const ssePool = new Map(); // url -> shared source
|
||||||
let muxEs = null; // the page's one EventSource
|
|
||||||
let muxKeys = ''; // the key list it was opened with
|
|
||||||
let muxTimer = null; // the pending (re)connect
|
|
||||||
let muxRetry = null; // retry after the connection gave up
|
|
||||||
const muxListened = new Set(); // "key|name" pairs with a listener on muxEs
|
|
||||||
|
|
||||||
/** The stream key for a URL: `/api/modules/<id><path>` → `<id>:<path>`, `/api/events` → `shell:`. */
|
|
||||||
function muxKeyFor(url) {
|
|
||||||
if (url === '/api/events') return 'shell:';
|
|
||||||
const m = /^\/api\/modules\/([^/?#]+)(\/[^?#]*)?$/.exec(url);
|
|
||||||
return m ? `${decodeURIComponent(m[1])}:${m[2] || '/'}` : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function muxFanOut(key, kind, e) {
|
|
||||||
for (const sub of [...(muxSubs.get(key)?.subs || [])]) {
|
|
||||||
const fn = kind === 'event' ? sub.handlers.events?.[e.type] : sub.handlers[kind];
|
|
||||||
try { fn?.(e); } catch (err) { console.error('[shell] stream handler failed:', err); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Listen for `<key>|<name>` on the open connection and hand it to that key's subscribers as `name`. */
|
|
||||||
function muxListen(key, name) {
|
|
||||||
const full = `${key}|${name}`;
|
|
||||||
if (!muxEs || muxListened.has(full)) return;
|
|
||||||
muxListened.add(full);
|
|
||||||
muxEs.addEventListener(full, (e) => {
|
|
||||||
if (name === '__open') return muxFanOut(key, 'open', new Event('open'));
|
|
||||||
if (name === '__down') return muxFanOut(key, 'error', new Event('error'));
|
|
||||||
const ev = { type: name, data: e.data, lastEventId: e.lastEventId };
|
|
||||||
muxFanOut(key, name === 'message' ? 'message' : 'event', ev);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function muxConnect() {
|
|
||||||
clearTimeout(muxTimer);
|
|
||||||
muxTimer = null;
|
|
||||||
clearTimeout(muxRetry);
|
|
||||||
muxRetry = null;
|
|
||||||
const keys = [...muxSubs.keys()].sort();
|
|
||||||
const wanted = keys.join(',');
|
|
||||||
if (!keys.length) {
|
|
||||||
try { muxEs?.close(); } catch { /* not open */ }
|
|
||||||
muxEs = null;
|
|
||||||
muxKeys = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (muxEs && muxKeys === wanted && muxEs.readyState !== EventSource.CLOSED) return;
|
|
||||||
try { muxEs?.close(); } catch { /* not open */ }
|
|
||||||
muxListened.clear();
|
|
||||||
muxKeys = wanted;
|
|
||||||
const es = new EventSource(`/api/stream?s=${encodeURIComponent(wanted)}`);
|
|
||||||
muxEs = es;
|
|
||||||
// Each stream says `open` for itself (the server's __open), so nothing to do on the connection's own open.
|
|
||||||
es.onerror = () => {
|
|
||||||
if (es !== muxEs) return;
|
|
||||||
for (const key of muxSubs.keys()) muxFanOut(key, 'error', new Event('error'));
|
|
||||||
// EventSource retries transient drops itself but gives up for good on a
|
|
||||||
// completed non-SSE response (a 502 while the server restarts).
|
|
||||||
if (es.readyState === EventSource.CLOSED && muxSubs.size) {
|
|
||||||
clearTimeout(muxRetry);
|
|
||||||
muxRetry = setTimeout(muxConnect, 3000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
for (const [key, entry] of muxSubs) {
|
|
||||||
muxListen(key, '__open');
|
|
||||||
muxListen(key, '__down');
|
|
||||||
muxListen(key, 'message');
|
|
||||||
for (const sub of entry.subs) for (const name of Object.keys(sub.handlers.events || {})) muxListen(key, name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sseSubscribe(url, handlers = {}) {
|
function sseSubscribe(url, handlers = {}) {
|
||||||
const key = muxKeyFor(url);
|
let shared = ssePool.get(url);
|
||||||
if (!key) {
|
if (!shared) {
|
||||||
console.warn('[shell] not a stream this page can carry:', url);
|
shared = { subs: new Set(), es: null, retryTimer: null, names: new Set() };
|
||||||
return { close() {} };
|
const fanOut = (kind, e) => {
|
||||||
}
|
for (const sub of [...shared.subs]) {
|
||||||
let entry = muxSubs.get(key);
|
const fn = kind === 'event' ? sub.handlers.events?.[e.type] : sub.handlers[kind];
|
||||||
if (!entry) {
|
try { fn?.(e); } catch (err) { console.error('[shell] stream handler failed:', err); }
|
||||||
entry = { subs: new Set() };
|
}
|
||||||
muxSubs.set(key, entry);
|
};
|
||||||
|
shared.listen = (name) => shared.es.addEventListener(name, (e) => fanOut('event', e));
|
||||||
|
shared.connect = () => {
|
||||||
|
clearTimeout(shared.retryTimer);
|
||||||
|
shared.retryTimer = null;
|
||||||
|
try { shared.es?.close(); } catch { /* already closed */ }
|
||||||
|
const es = new EventSource(url);
|
||||||
|
shared.es = es;
|
||||||
|
es.onopen = (e) => fanOut('open', e);
|
||||||
|
es.onmessage = (e) => fanOut('message', e);
|
||||||
|
es.onerror = (e) => {
|
||||||
|
fanOut('error', e);
|
||||||
|
// EventSource retries transient drops itself but gives up for good on
|
||||||
|
// a completed non-SSE response (a 502 while the upstream is down).
|
||||||
|
if (es.readyState === EventSource.CLOSED && shared.subs.size) {
|
||||||
|
clearTimeout(shared.retryTimer);
|
||||||
|
shared.retryTimer = setTimeout(shared.connect, 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const name of shared.names) shared.listen(name);
|
||||||
|
};
|
||||||
|
ssePool.set(url, shared);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sub = { handlers };
|
const sub = { handlers };
|
||||||
entry.subs.add(sub);
|
shared.subs.add(sub);
|
||||||
const carried = Boolean(muxEs) && muxKeys.split(',').includes(key) && muxEs.readyState !== EventSource.CLOSED;
|
for (const name of Object.keys(handlers.events || {})) {
|
||||||
if (carried) {
|
if (shared.names.has(name)) continue;
|
||||||
// Joining a stream the connection already carries: listeners for any
|
shared.names.add(name);
|
||||||
// new event names, and `open` a moment later so the caller's own setup
|
if (shared.es) shared.listen(name);
|
||||||
// finishes first — exactly as a fresh connection would have reported it.
|
|
||||||
for (const name of Object.keys(handlers.events || {})) muxListen(key, name);
|
|
||||||
if (muxEs.readyState === EventSource.OPEN) {
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!entry.subs.has(sub)) return;
|
|
||||||
try { handlers.open?.(new Event('open')); } catch (err) { console.error('[shell] stream handler failed:', err); }
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
clearTimeout(muxTimer);
|
|
||||||
muxTimer = setTimeout(muxConnect, 20);
|
|
||||||
}
|
}
|
||||||
|
if (!shared.es || shared.es.readyState === EventSource.CLOSED) {
|
||||||
|
// first subscriber, or the stream had given up while nobody listened
|
||||||
|
shared.connect();
|
||||||
|
} else if (shared.es.readyState === EventSource.OPEN) {
|
||||||
|
// Joining mid-flight: report the open the way a fresh connection would
|
||||||
|
// have, a moment later, so the caller's own setup finishes first.
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!shared.subs.has(sub)) return;
|
||||||
|
try { handlers.open?.(new Event('open')); } catch (err) { console.error('[shell] stream handler failed:', err); }
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
close() {
|
close() {
|
||||||
if (!entry.subs.delete(sub) || entry.subs.size) return;
|
if (!shared.subs.delete(sub) || shared.subs.size) return;
|
||||||
muxSubs.delete(key);
|
clearTimeout(shared.retryTimer);
|
||||||
// A remount closes and reopens within milliseconds: wait before remaking the connection.
|
try { shared.es?.close(); } catch { /* already closed */ }
|
||||||
clearTimeout(muxTimer);
|
ssePool.delete(url);
|
||||||
muxTimer = setTimeout(muxConnect, 500);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -950,7 +890,7 @@ function buildModuleApi(tile, man, entry) {
|
|||||||
/** Per-tile settings: schema defaults overlaid with what this tile saved. */
|
/** Per-tile settings: schema defaults overlaid with what this tile saved. */
|
||||||
get instanceSettings() {
|
get instanceSettings() {
|
||||||
const out = {};
|
const out = {};
|
||||||
for (const [key, spec] of Object.entries(tileSchema(tile, man).schema)) {
|
for (const [key, spec] of Object.entries(man.instanceSchema || {})) {
|
||||||
if (spec && 'default' in spec) out[key] = spec.default;
|
if (spec && 'default' in spec) out[key] = spec.default;
|
||||||
}
|
}
|
||||||
return Object.assign(out, tile.settings || {});
|
return Object.assign(out, tile.settings || {});
|
||||||
@@ -1135,23 +1075,6 @@ function unmountModule(tile) {
|
|||||||
/* ── per-tile settings popover ──────────────────────────────────────── */
|
/* ── per-tile settings popover ──────────────────────────────────────── */
|
||||||
|
|
||||||
/** `<input type="color">` only accepts #rrggbb — anything else falls back. */
|
/** `<input type="color">` only accepts #rrggbb — anything else falls back. */
|
||||||
/* ── the tile tint ───────────────────────────────────────────────────
|
|
||||||
Every tile can be washed in a colour of its own — a way to tell one
|
|
||||||
screen's tiles apart at a glance (this campus green, that one amber).
|
|
||||||
It is the shell's, not a module's: the key is reserved, it is offered in
|
|
||||||
every gear window, and it saves with the layout like any other tile
|
|
||||||
setting. A tile with no tint looks exactly as before. */
|
|
||||||
const TINT_KEY = '__tint';
|
|
||||||
|
|
||||||
/** Paint (or clear) a tile's tint on its element. */
|
|
||||||
function applyTint(tile, el = tileEls.get(tile.id)?.el) {
|
|
||||||
if (!el) return;
|
|
||||||
const hex = colorHex(tile.settings?.[TINT_KEY], '');
|
|
||||||
if (hex) el.style.setProperty('--tile-tint', hex);
|
|
||||||
else el.style.removeProperty('--tile-tint');
|
|
||||||
el.classList.toggle('is-tinted', Boolean(hex));
|
|
||||||
}
|
|
||||||
|
|
||||||
function colorHex(value, fallback) {
|
function colorHex(value, fallback) {
|
||||||
const s = String(value ?? '').trim();
|
const s = String(value ?? '').trim();
|
||||||
if (/^#[0-9a-fA-F]{6}$/.test(s)) return s.toLowerCase();
|
if (/^#[0-9a-fA-F]{6}$/.test(s)) return s.toLowerCase();
|
||||||
@@ -1159,165 +1082,28 @@ function colorHex(value, fallback) {
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The settings a tile offers: its picker entry's own schema when it has one
|
|
||||||
— a solo timer wants none of a full tile's switches — else the module's.
|
|
||||||
Same for the groups that arrange them. */
|
|
||||||
function tileSchema(tile, man) {
|
|
||||||
const own = (v) => (v && typeof v === 'object' ? v : null);
|
|
||||||
const variant = String(tile?.variant || '');
|
|
||||||
const entry = variant && Array.isArray(man?.tiles)
|
|
||||||
? man.tiles.find((e) => String(e.id) === variant)
|
|
||||||
|| man.tiles.find((e) => Array.isArray(e.aliases) && e.aliases.includes(variant))
|
|
||||||
: null;
|
|
||||||
return {
|
|
||||||
schema: own(entry?.instanceSchema) || own(man?.instanceSchema) || {},
|
|
||||||
groups: own(entry?.instanceGroups) || own(man?.instanceGroups) || {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── tooltips: at once on hover, short, one for the whole page ──────────
|
|
||||||
Every header and top-bar button's `title` becomes a tip the shell shows
|
|
||||||
the moment the pointer arrives — the browser's own waits about a second
|
|
||||||
and looks different in each browser. The text moves from `title` to
|
|
||||||
data-tip so the native one never appears; a module that sets `title`
|
|
||||||
again later is picked up on the next hover. */
|
|
||||||
let tipEl = null;
|
|
||||||
const TIP_TARGETS = '.tile-btn, .tb-btn, [data-tip]';
|
|
||||||
|
|
||||||
function tipTextFor(el) {
|
|
||||||
if (el.title) {
|
|
||||||
el.dataset.tip = el.title;
|
|
||||||
if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', el.title);
|
|
||||||
el.removeAttribute('title');
|
|
||||||
}
|
|
||||||
return el.dataset.tip || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function showTip(el) {
|
|
||||||
const text = tipTextFor(el);
|
|
||||||
if (!text) return;
|
|
||||||
if (!tipEl) {
|
|
||||||
tipEl = document.createElement('div');
|
|
||||||
tipEl.id = 'tip';
|
|
||||||
tipEl.setAttribute('role', 'tooltip');
|
|
||||||
document.body.appendChild(tipEl);
|
|
||||||
}
|
|
||||||
tipEl.textContent = text;
|
|
||||||
tipEl.hidden = false;
|
|
||||||
const r = el.getBoundingClientRect();
|
|
||||||
const w = tipEl.offsetWidth;
|
|
||||||
const h = tipEl.offsetHeight;
|
|
||||||
const left = Math.max(6, Math.min(r.left + r.width / 2 - w / 2, window.innerWidth - w - 6));
|
|
||||||
let top = r.bottom + 6;
|
|
||||||
if (top + h > window.innerHeight - 6) top = r.top - h - 6;
|
|
||||||
tipEl.style.left = `${Math.round(left)}px`;
|
|
||||||
tipEl.style.top = `${Math.round(top)}px`;
|
|
||||||
tipEl._for = el;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideTip(el = null) {
|
|
||||||
if (!tipEl || tipEl.hidden) return;
|
|
||||||
if (el && tipEl._for !== el) return;
|
|
||||||
tipEl.hidden = true;
|
|
||||||
tipEl._for = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('pointerover', (e) => {
|
|
||||||
const el = e.target?.closest?.(TIP_TARGETS);
|
|
||||||
if (!el || (tipEl && tipEl._for === el && !tipEl.hidden)) return;
|
|
||||||
showTip(el);
|
|
||||||
});
|
|
||||||
document.addEventListener('pointerout', (e) => {
|
|
||||||
const el = e.target?.closest?.(TIP_TARGETS);
|
|
||||||
if (!el || (e.relatedTarget && el.contains(e.relatedTarget))) return;
|
|
||||||
hideTip(el);
|
|
||||||
});
|
|
||||||
document.addEventListener('pointerdown', () => hideTip(), true);
|
|
||||||
document.addEventListener('focusin', (e) => {
|
|
||||||
const el = e.target?.closest?.(TIP_TARGETS);
|
|
||||||
if (el) showTip(el);
|
|
||||||
});
|
|
||||||
document.addEventListener('focusout', () => hideTip());
|
|
||||||
window.addEventListener('scroll', () => hideTip(), true);
|
|
||||||
window.addEventListener('resize', () => hideTip());
|
|
||||||
|
|
||||||
function closeSettingsPopovers() {
|
function closeSettingsPopovers() {
|
||||||
document.querySelectorAll('.tile-settings').forEach((p) => {
|
document.querySelectorAll('.tile-settings').forEach((p) => {
|
||||||
p._cleanup?.();
|
p._cleanup?.();
|
||||||
p._button?.classList.remove('is-open');
|
|
||||||
p.remove();
|
p.remove();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── the per-tile settings window ───────────────────────────────────── */
|
function toggleSettingsPopover(tile, el) {
|
||||||
|
const existing = el.querySelector('.tile-settings');
|
||||||
/* It opens beside its tile — to the right when there is room, else the
|
|
||||||
left, else over it — and always inside the viewport. A window rather than
|
|
||||||
something inside the tile, because a tile can be smaller than its own
|
|
||||||
settings, and because the point of live settings is watching the tile
|
|
||||||
change while you change them: the window must not sit on top of it. */
|
|
||||||
function placePopover(pop, anchor) {
|
|
||||||
const vw = window.innerWidth;
|
|
||||||
const vh = window.innerHeight;
|
|
||||||
const pad = 8;
|
|
||||||
const w = pop.offsetWidth;
|
|
||||||
const h = pop.offsetHeight;
|
|
||||||
let left = anchor.right + pad;
|
|
||||||
if (left + w > vw - pad) left = anchor.left - w - pad;
|
|
||||||
if (left < pad) left = Math.min(Math.max(pad, anchor.left), Math.max(pad, vw - w - pad));
|
|
||||||
let top = anchor.top;
|
|
||||||
if (top + h > vh - pad) top = vh - h - pad;
|
|
||||||
if (top < pad) top = pad;
|
|
||||||
pop.style.left = Math.round(left) + 'px';
|
|
||||||
pop.style.top = Math.round(top) + 'px';
|
|
||||||
}
|
|
||||||
|
|
||||||
function clampPopover(pop) {
|
|
||||||
const pad = 8;
|
|
||||||
const maxLeft = Math.max(pad, window.innerWidth - pop.offsetWidth - pad);
|
|
||||||
const maxTop = Math.max(pad, window.innerHeight - pop.offsetHeight - pad);
|
|
||||||
pop.style.left = Math.min(Math.max(pad, pop.offsetLeft), maxLeft) + 'px';
|
|
||||||
pop.style.top = Math.min(Math.max(pad, pop.offsetTop), maxTop) + 'px';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Open (or close) the settings window for `tile`, beside its element `el`; `button` is the gear that asked. */
|
|
||||||
function toggleSettingsPopover(tile, el, button = null) {
|
|
||||||
const existing = document.querySelector(`.tile-settings[data-tile-id="${tile.id}"]`);
|
|
||||||
closeSettingsPopovers();
|
closeSettingsPopovers();
|
||||||
if (existing) return;
|
if (existing) return;
|
||||||
const man = registry.get(tile.module);
|
const man = registry.get(tile.module);
|
||||||
if (!man) return;
|
if (!man || !man.instanceSchema) return;
|
||||||
const { schema, groups: groupMeta } = tileSchema(tile, man);
|
|
||||||
|
|
||||||
const pop = document.createElement('div');
|
const pop = document.createElement('div');
|
||||||
pop.className = 'tile-settings';
|
pop.className = 'tile-settings';
|
||||||
pop.dataset.tileId = tile.id;
|
|
||||||
pop.setAttribute('role', 'dialog');
|
|
||||||
pop.setAttribute('aria-label', `${tile.title || man.name} settings`);
|
|
||||||
// The gear that opened it wears the window's colour while it is open.
|
|
||||||
if (button) {
|
|
||||||
button.classList.add('is-open');
|
|
||||||
pop._button = button;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A slim bar to move it by — nothing else. No title (the window sits beside
|
|
||||||
// the tile it belongs to) and no close button: a click anywhere else, or
|
|
||||||
// Escape, closes it.
|
|
||||||
const bar = document.createElement('div');
|
|
||||||
bar.className = 'tile-settings-bar';
|
|
||||||
bar.title = 'Drag to move';
|
|
||||||
const body = document.createElement('div');
|
|
||||||
body.className = 'tile-settings-body';
|
|
||||||
pop.append(bar, body);
|
|
||||||
|
|
||||||
const inputs = new Map();
|
const inputs = new Map();
|
||||||
const shownWhen = []; // fields that appear only while another has a given value
|
|
||||||
|
|
||||||
/* Display choices apply as they change and save with the layout — nothing
|
/* These are display choices — a text size, a filter, a colour — so they
|
||||||
to confirm, so no Apply. Typing is debounced because applying means
|
apply as they are changed and save with the layout. There is nothing to
|
||||||
remounting the module, which shouldn't happen per keystroke. */
|
confirm, so there is no Apply. Typing is debounced because applying
|
||||||
const same = (a, b) => a === b
|
means remounting the module, which shouldn't happen per keystroke. */
|
||||||
|| (Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((v, i) => v === b[i]));
|
|
||||||
let applyTimer = null;
|
let applyTimer = null;
|
||||||
const commit = (delay) => {
|
const commit = (delay) => {
|
||||||
clearTimeout(applyTimer);
|
clearTimeout(applyTimer);
|
||||||
@@ -1325,11 +1111,11 @@ function toggleSettingsPopover(tile, el, button = null) {
|
|||||||
applyTimer = null;
|
applyTimer = null;
|
||||||
// Only when something really moved: a text field fires `input` and then
|
// Only when something really moved: a text field fires `input` and then
|
||||||
// `change` on the way out, and a module shouldn't be torn down twice
|
// `change` on the way out, and a module shouldn't be torn down twice
|
||||||
// over for one edit. A reader answering undefined has nothing to say.
|
// over for one edit.
|
||||||
let changed = false;
|
let changed = false;
|
||||||
for (const [key, read] of inputs) {
|
for (const [key, read] of inputs) {
|
||||||
const value = read();
|
const value = read();
|
||||||
if (value === undefined || same(tile.settings[key], value)) continue;
|
if (tile.settings[key] === value) continue;
|
||||||
tile.settings[key] = value;
|
tile.settings[key] = value;
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
@@ -1341,31 +1127,19 @@ function toggleSettingsPopover(tile, el, button = null) {
|
|||||||
const live = (input, event = 'change', delay = 0) => {
|
const live = (input, event = 'change', delay = 0) => {
|
||||||
input.addEventListener(event, () => commit(delay));
|
input.addEventListener(event, () => commit(delay));
|
||||||
};
|
};
|
||||||
/** Choices a module only knows at runtime, from one of its own routes. */
|
|
||||||
const fetchOptions = async (route) => {
|
|
||||||
try {
|
|
||||||
const res = await fetchWithTimeout(`/api/modules/${encodeURIComponent(man.id)}${route}`, 8000);
|
|
||||||
const data = res.ok ? await res.json() : null;
|
|
||||||
return Array.isArray(data?.options) ? data.options : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const optValue = (opt) => String(typeof opt === 'object' ? opt.value : opt);
|
|
||||||
const optLabel = (opt) => String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt);
|
|
||||||
|
|
||||||
// Fields render in schema order; a field's `group` starts a labeled
|
// Fields render in schema order; a field's `group` starts a labeled
|
||||||
// subsection that consecutive same-group fields share, and the groups map
|
// subsection that consecutive same-group fields share, and the manifest's
|
||||||
// adds { collapsed, columns, help, toggle } — the same conventions as the
|
// optional `instanceGroups[name]` adds { collapsed, columns, help } —
|
||||||
// admin page's config form.
|
// the same conventions as the admin page's config form.
|
||||||
|
const groupMeta = man.instanceGroups && typeof man.instanceGroups === 'object' ? man.instanceGroups : {};
|
||||||
let groupName = null;
|
let groupName = null;
|
||||||
let container = body;
|
let container = pop;
|
||||||
let current = null; // the open group: { el, title, toggleKey }
|
|
||||||
|
|
||||||
for (const [key, spec] of Object.entries(schema)) {
|
for (const [key, spec] of Object.entries(man.instanceSchema)) {
|
||||||
const type = spec?.type || 'string';
|
const type = spec?.type || 'string';
|
||||||
const label = spec?.label || key;
|
const label = spec?.label || key;
|
||||||
const value = key in (tile.settings || {}) ? tile.settings[key] : spec?.default;
|
const current = key in (tile.settings || {}) ? tile.settings[key] : spec?.default;
|
||||||
|
|
||||||
const g = spec?.group || '';
|
const g = spec?.group || '';
|
||||||
if (g !== groupName) {
|
if (g !== groupName) {
|
||||||
@@ -1378,41 +1152,33 @@ function toggleSettingsPopover(tile, el, button = null) {
|
|||||||
if (folds) group.open = !meta.collapsed;
|
if (folds) group.open = !meta.collapsed;
|
||||||
const title = document.createElement(folds ? 'summary' : 'div');
|
const title = document.createElement(folds ? 'summary' : 'div');
|
||||||
title.className = 'field-group-title';
|
title.className = 'field-group-title';
|
||||||
const titleText = document.createElement('span');
|
title.textContent = g;
|
||||||
titleText.className = 'field-group-name';
|
|
||||||
titleText.textContent = g;
|
|
||||||
title.appendChild(titleText);
|
|
||||||
group.appendChild(title);
|
group.appendChild(title);
|
||||||
const groupBody = document.createElement('div');
|
const body = document.createElement('div');
|
||||||
groupBody.className = 'field-group-body';
|
body.className = 'field-group-body';
|
||||||
const cols = Math.min(4, Math.trunc(Number(meta.columns)) || 0);
|
const cols = Math.min(4, Math.trunc(Number(meta.columns)) || 0);
|
||||||
if (cols > 1) {
|
if (cols > 1) {
|
||||||
groupBody.classList.add('is-columns');
|
body.classList.add('is-columns');
|
||||||
groupBody.style.setProperty('--cols', String(cols));
|
body.style.setProperty('--cols', String(cols));
|
||||||
}
|
}
|
||||||
if (meta.help) {
|
if (meta.help) {
|
||||||
const help = document.createElement('div');
|
const help = document.createElement('div');
|
||||||
help.className = 'field-group-help';
|
help.className = 'field-group-help';
|
||||||
help.textContent = meta.help;
|
help.textContent = meta.help;
|
||||||
groupBody.appendChild(help);
|
body.appendChild(help);
|
||||||
}
|
}
|
||||||
group.appendChild(groupBody);
|
group.appendChild(body);
|
||||||
body.appendChild(group);
|
pop.appendChild(group);
|
||||||
pop.classList.add('has-groups');
|
pop.classList.add('has-groups');
|
||||||
container = groupBody;
|
|
||||||
current = { el: group, title, toggleKey: typeof meta.toggle === 'string' ? meta.toggle : '' };
|
|
||||||
} else {
|
|
||||||
container = body;
|
container = body;
|
||||||
current = null;
|
} else {
|
||||||
|
container = pop;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSwitch = type === 'boolean' || type === 'switch';
|
const isSwitch = type === 'boolean' || type === 'switch';
|
||||||
// A multiselect holds several controls, so it can't be a <label>: a click
|
const field = document.createElement('label');
|
||||||
// anywhere in one would flip the first control.
|
|
||||||
const field = document.createElement(type === 'multiselect' ? 'div' : 'label');
|
|
||||||
field.className = 'field' + (isSwitch ? ' check' : '');
|
field.className = 'field' + (isSwitch ? ' check' : '');
|
||||||
let switchInput = null;
|
|
||||||
|
|
||||||
if (isSwitch) {
|
if (isSwitch) {
|
||||||
// A switch: on is shown/enabled, off is hidden/disabled — the label
|
// A switch: on is shown/enabled, off is hidden/disabled — the label
|
||||||
@@ -1425,104 +1191,27 @@ function toggleSettingsPopover(tile, el, button = null) {
|
|||||||
toggle.className = 'switch';
|
toggle.className = 'switch';
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
input.type = 'checkbox';
|
input.type = 'checkbox';
|
||||||
input.checked = Boolean(value);
|
input.checked = Boolean(current);
|
||||||
input.setAttribute('aria-label', label);
|
|
||||||
const track = document.createElement('span');
|
const track = document.createElement('span');
|
||||||
track.className = 'track';
|
track.className = 'track';
|
||||||
toggle.append(input, track);
|
toggle.append(input, track);
|
||||||
field.append(text, toggle);
|
field.append(text, toggle);
|
||||||
inputs.set(key, () => input.checked);
|
inputs.set(key, () => input.checked);
|
||||||
live(input);
|
live(input);
|
||||||
switchInput = input;
|
} else if (type === 'select' && Array.isArray(spec.options)) {
|
||||||
} else if (type === 'select' && (Array.isArray(spec.options) || spec.optionsRoute)) {
|
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.textContent = label;
|
span.textContent = label;
|
||||||
const select = document.createElement('select');
|
const select = document.createElement('select');
|
||||||
const saved = value === undefined || value === null ? '' : String(value);
|
for (const opt of spec.options) {
|
||||||
const fill = (options) => {
|
const o = document.createElement('option');
|
||||||
const wanted = select.options.length ? select.value : saved;
|
o.value = String(typeof opt === 'object' ? opt.value : opt);
|
||||||
select.innerHTML = '';
|
o.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt);
|
||||||
// What this tile saved stays choosable even when the live list has
|
select.appendChild(o);
|
||||||
// moved on (a source that is off right now, say).
|
}
|
||||||
if (saved && !options.some((o) => optValue(o) === saved)) {
|
select.value = String(current ?? '');
|
||||||
options = [{ value: saved, label: `${saved} (saved)` }, ...options];
|
|
||||||
}
|
|
||||||
for (const opt of options) {
|
|
||||||
const o = document.createElement('option');
|
|
||||||
o.value = optValue(opt);
|
|
||||||
o.textContent = optLabel(opt);
|
|
||||||
select.appendChild(o);
|
|
||||||
}
|
|
||||||
select.value = options.some((o) => optValue(o) === wanted) ? wanted : saved;
|
|
||||||
};
|
|
||||||
fill(Array.isArray(spec.options) ? spec.options : []);
|
|
||||||
if (spec.optionsRoute) fetchOptions(spec.optionsRoute).then((opts) => { if (opts) fill(opts); });
|
|
||||||
field.append(span, select);
|
field.append(span, select);
|
||||||
inputs.set(key, () => select.value);
|
inputs.set(key, () => select.value);
|
||||||
live(select);
|
live(select);
|
||||||
} else if (type === 'multiselect' && (Array.isArray(spec.options) || spec.optionsRoute)) {
|
|
||||||
// Several picks from a list, one switch each, stored as an array. With
|
|
||||||
// `allByDefault`, 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, until there is an explicit list.
|
|
||||||
field.classList.add('multiselect-field');
|
|
||||||
const span = document.createElement('span');
|
|
||||||
span.className = 'field-label';
|
|
||||||
span.textContent = label;
|
|
||||||
const list = document.createElement('div');
|
|
||||||
list.className = 'multiselect';
|
|
||||||
const cols = Math.min(4, Math.trunc(Number(spec.columns)) || 0);
|
|
||||||
if (cols > 1) {
|
|
||||||
list.classList.add('is-columns');
|
|
||||||
list.style.setProperty('--cols', String(cols));
|
|
||||||
}
|
|
||||||
const saved = Array.isArray(value) ? value.map(String) : null; // null: never chosen
|
|
||||||
let touched = false;
|
|
||||||
const chosen = new Set(saved || []);
|
|
||||||
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((o) => optValue(o) === v));
|
|
||||||
const all = [...options, ...missing.map((v) => ({ value: v, label: `${v} (saved — not listed now)` }))];
|
|
||||||
for (const opt of all) {
|
|
||||||
const row = document.createElement('label');
|
|
||||||
row.className = 'multiselect-row';
|
|
||||||
const text = document.createElement('span');
|
|
||||||
text.className = 'check-label';
|
|
||||||
text.textContent = optLabel(opt);
|
|
||||||
const toggle = document.createElement('span');
|
|
||||||
toggle.className = 'switch';
|
|
||||||
const input = document.createElement('input');
|
|
||||||
input.type = 'checkbox';
|
|
||||||
input.value = optValue(opt);
|
|
||||||
input.checked = saved === null && !touched && spec.allByDefault ? true : chosen.has(input.value);
|
|
||||||
if (input.checked) chosen.add(input.value);
|
|
||||||
const track = document.createElement('span');
|
|
||||||
track.className = 'track';
|
|
||||||
toggle.append(input, track);
|
|
||||||
row.append(text, toggle);
|
|
||||||
input.addEventListener('change', () => {
|
|
||||||
touched = true;
|
|
||||||
commit(0);
|
|
||||||
});
|
|
||||||
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) fetchOptions(spec.optionsRoute).then((opts) => { if (opts) fill(opts); });
|
|
||||||
field.append(span, list);
|
|
||||||
inputs.set(key, () => (saved === null && !touched
|
|
||||||
? undefined
|
|
||||||
: [...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');
|
||||||
@@ -1530,54 +1219,16 @@ function toggleSettingsPopover(tile, el, button = null) {
|
|||||||
span.textContent = label;
|
span.textContent = label;
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
input.type = 'color';
|
input.type = 'color';
|
||||||
input.value = colorHex(value, colorHex(spec?.default, '#2ee59a'));
|
input.value = colorHex(current, colorHex(spec?.default, '#2ee59a'));
|
||||||
field.append(span, input);
|
field.append(span, input);
|
||||||
inputs.set(key, () => input.value);
|
inputs.set(key, () => input.value);
|
||||||
live(input, 'input', 150); // dragging the picker fires continuously
|
live(input, 'input', 150); // dragging the picker fires continuously
|
||||||
} else if (type === 'template') {
|
|
||||||
// Text with placeholders, built by dragging (or clicking) the variables
|
|
||||||
// the module offers into it — nobody should have to remember the names.
|
|
||||||
field.classList.add('template-field');
|
|
||||||
const span = document.createElement('span');
|
|
||||||
span.textContent = label;
|
|
||||||
const input = document.createElement('input');
|
|
||||||
input.type = 'text';
|
|
||||||
input.spellcheck = false;
|
|
||||||
input.value = value === undefined || value === null ? '' : String(value);
|
|
||||||
const vars = document.createElement('div');
|
|
||||||
vars.className = 'template-vars';
|
|
||||||
for (const v of Array.isArray(spec.variables) ? spec.variables : []) {
|
|
||||||
const chip = document.createElement('button');
|
|
||||||
chip.type = 'button';
|
|
||||||
chip.className = 'template-var';
|
|
||||||
chip.draggable = true;
|
|
||||||
const token = optValue(v);
|
|
||||||
chip.textContent = optLabel(v);
|
|
||||||
chip.title = `Drag or click to insert ${token}`;
|
|
||||||
chip.addEventListener('dragstart', (e) => {
|
|
||||||
e.dataTransfer.setData('text/plain', token);
|
|
||||||
e.dataTransfer.effectAllowed = 'copy';
|
|
||||||
});
|
|
||||||
chip.addEventListener('click', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const start = input.selectionStart ?? input.value.length;
|
|
||||||
const end = input.selectionEnd ?? start;
|
|
||||||
input.setRangeText(token, start, end, 'end');
|
|
||||||
input.focus();
|
|
||||||
commit(0);
|
|
||||||
});
|
|
||||||
vars.appendChild(chip);
|
|
||||||
}
|
|
||||||
field.append(span, input, vars);
|
|
||||||
inputs.set(key, () => input.value);
|
|
||||||
live(input, 'input', 400);
|
|
||||||
live(input, 'change');
|
|
||||||
} else {
|
} else {
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.textContent = label;
|
span.textContent = label;
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
input.type = type === 'number' ? 'number' : (type === 'password' ? 'password' : 'text');
|
input.type = type === 'number' ? 'number' : (type === 'password' ? 'password' : 'text');
|
||||||
input.value = value === undefined || value === null ? '' : String(value);
|
input.value = current === undefined || current === null ? '' : String(current);
|
||||||
field.append(span, input);
|
field.append(span, input);
|
||||||
inputs.set(key, () => (type === 'number' ? Number(input.value) : input.value));
|
inputs.set(key, () => (type === 'number' ? Number(input.value) : input.value));
|
||||||
live(input, 'input', 400); // mid-word is not the moment to remount
|
live(input, 'input', 400); // mid-word is not the moment to remount
|
||||||
@@ -1589,115 +1240,23 @@ function toggleSettingsPopover(tile, el, button = null) {
|
|||||||
help.textContent = spec.help;
|
help.textContent = spec.help;
|
||||||
field.appendChild(help);
|
field.appendChild(help);
|
||||||
}
|
}
|
||||||
|
container.appendChild(field);
|
||||||
if (typeof spec?.showWhen === 'string' && spec.showWhen) shownWhen.push({ field, key: spec.showWhen });
|
|
||||||
|
|
||||||
if (current && switchInput && key === current.toggleKey) {
|
|
||||||
// The group's on/off lives in its title row, never in the body, and a
|
|
||||||
// click on it must not fold a <details>.
|
|
||||||
const grp = current;
|
|
||||||
field.classList.add('group-toggle');
|
|
||||||
field.addEventListener('click', (e) => e.stopPropagation());
|
|
||||||
const sync = () => grp.el.classList.toggle('is-off', !switchInput.checked);
|
|
||||||
switchInput.addEventListener('change', sync);
|
|
||||||
sync();
|
|
||||||
grp.title.appendChild(field);
|
|
||||||
} else {
|
|
||||||
container.appendChild(field);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The tint sits on its own at the foot of the window, under a hairline —
|
// Nothing to press: click away, press Escape, or click the gear again.
|
||||||
it is the shell's setting, not one of the module's. Unlike those, a
|
|
||||||
change needs no remount: nothing but the tile's own chrome reads it. */
|
|
||||||
{
|
|
||||||
const field = document.createElement('div');
|
|
||||||
field.className = 'field color-field tint-field'; // the swatch reads like a module's colour field
|
|
||||||
const text = document.createElement('span');
|
|
||||||
text.textContent = 'Tile tint';
|
|
||||||
const input = document.createElement('input');
|
|
||||||
input.type = 'color';
|
|
||||||
input.setAttribute('aria-label', 'Tile tint');
|
|
||||||
const clear = document.createElement('button');
|
|
||||||
clear.type = 'button';
|
|
||||||
clear.className = 'tint-clear';
|
|
||||||
clear.textContent = 'Clear';
|
|
||||||
clear.title = 'No tint';
|
|
||||||
const paint = () => {
|
|
||||||
const hex = colorHex(tile.settings[TINT_KEY], '');
|
|
||||||
// With no tint the swatch opens on the panel colour, so the picker
|
|
||||||
// starts somewhere sane rather than at black.
|
|
||||||
input.value = hex || colorHex(getComputedStyle(document.documentElement).getPropertyValue('--panel'), '#1a1d21');
|
|
||||||
clear.hidden = !hex;
|
|
||||||
};
|
|
||||||
const set = (hex) => {
|
|
||||||
if (hex) tile.settings[TINT_KEY] = hex;
|
|
||||||
else delete tile.settings[TINT_KEY];
|
|
||||||
applyTint(tile);
|
|
||||||
paint();
|
|
||||||
saveLayout();
|
|
||||||
};
|
|
||||||
input.addEventListener('input', () => set(colorHex(input.value, '')));
|
|
||||||
clear.addEventListener('click', () => set(''));
|
|
||||||
paint();
|
|
||||||
field.append(text, clear, input);
|
|
||||||
body.appendChild(field);
|
|
||||||
}
|
|
||||||
|
|
||||||
// "Short day" appears once "Date" is on: a field with showWhen:"K" follows
|
|
||||||
// K's switch, live — the same rule the admin page applies.
|
|
||||||
if (shownWhen.length) {
|
|
||||||
const refresh = () => {
|
|
||||||
for (const rule of shownWhen) {
|
|
||||||
const read = inputs.get(rule.key);
|
|
||||||
rule.field.hidden = read ? !read() : false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
refresh();
|
|
||||||
body.addEventListener('change', refresh);
|
|
||||||
body.addEventListener('input', refresh);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nothing to press: click away, press Escape, or the gear again.
|
|
||||||
// (The gear's own click stops propagating, so it toggles rather than
|
// (The gear's own click stops propagating, so it toggles rather than
|
||||||
// closing and reopening.)
|
// closing and reopening.)
|
||||||
const closeOnClick = (e) => { if (!pop.contains(e.target)) closeSettingsPopovers(); };
|
const closeOnClick = (e) => { if (!pop.contains(e.target)) closeSettingsPopovers(); };
|
||||||
const closeOnEscape = (e) => { if (e.key === 'Escape') closeSettingsPopovers(); };
|
const closeOnEscape = (e) => { if (e.key === 'Escape') closeSettingsPopovers(); };
|
||||||
const keepInView = () => clampPopover(pop);
|
|
||||||
pop._cleanup = () => {
|
pop._cleanup = () => {
|
||||||
document.removeEventListener('click', closeOnClick);
|
document.removeEventListener('click', closeOnClick);
|
||||||
document.removeEventListener('keydown', closeOnEscape);
|
document.removeEventListener('keydown', closeOnEscape);
|
||||||
window.removeEventListener('resize', keepInView);
|
|
||||||
};
|
};
|
||||||
document.addEventListener('click', closeOnClick);
|
document.addEventListener('click', closeOnClick);
|
||||||
document.addEventListener('keydown', closeOnEscape);
|
document.addEventListener('keydown', closeOnEscape);
|
||||||
window.addEventListener('resize', keepInView);
|
|
||||||
|
|
||||||
// Moved by its bar.
|
|
||||||
bar.addEventListener('pointerdown', (e) => {
|
|
||||||
if (e.button !== 0 && e.pointerType === 'mouse') return;
|
|
||||||
e.preventDefault();
|
|
||||||
bar.setPointerCapture(e.pointerId);
|
|
||||||
const dx = e.clientX - pop.offsetLeft;
|
|
||||||
const dy = e.clientY - pop.offsetTop;
|
|
||||||
const onMove = (ev) => {
|
|
||||||
pop.style.left = (ev.clientX - dx) + 'px';
|
|
||||||
pop.style.top = (ev.clientY - dy) + 'px';
|
|
||||||
clampPopover(pop);
|
|
||||||
};
|
|
||||||
const onUp = () => {
|
|
||||||
bar.removeEventListener('pointermove', onMove);
|
|
||||||
bar.removeEventListener('pointerup', onUp);
|
|
||||||
bar.removeEventListener('pointercancel', onUp);
|
|
||||||
};
|
|
||||||
bar.addEventListener('pointermove', onMove);
|
|
||||||
bar.addEventListener('pointerup', onUp);
|
|
||||||
bar.addEventListener('pointercancel', onUp);
|
|
||||||
});
|
|
||||||
|
|
||||||
pop.addEventListener('pointerdown', (e) => e.stopPropagation());
|
pop.addEventListener('pointerdown', (e) => e.stopPropagation());
|
||||||
document.body.appendChild(pop);
|
el.appendChild(pop);
|
||||||
placePopover(pop, el.getBoundingClientRect());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tear a tile's module down and start it again (settings changed). */
|
/** Tear a tile's module down and start it again (settings changed). */
|
||||||
@@ -1757,20 +1316,16 @@ function renderAddMenu(menu) {
|
|||||||
title.textContent = man.name;
|
title.textContent = man.name;
|
||||||
menu.appendChild(title);
|
menu.appendChild(title);
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
// An entry that allows one tile at most is offered greyed out once
|
|
||||||
// that tile exists — a second Send box would only confuse.
|
|
||||||
const placed = entry.single && tiles.some((t) => t.module === man.id && t.variant === String(entry.id));
|
|
||||||
// Descriptions ride along as tooltips: the list stays a list.
|
|
||||||
menu.appendChild(menuItem(entry.name, () => {
|
menu.appendChild(menuItem(entry.name, () => {
|
||||||
menu.hidden = true;
|
menu.hidden = true;
|
||||||
addTile(man.id, entry);
|
addTile(man.id, entry);
|
||||||
}, { disabled: placed, title: placed ? 'Already on this dashboard' : (entry.description || '') }));
|
}, { sub: entry.description || '' }));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
menu.appendChild(menuItem(man.name, () => {
|
menu.appendChild(menuItem(man.name, () => {
|
||||||
menu.hidden = true;
|
menu.hidden = true;
|
||||||
addTile(man.id);
|
addTile(man.id);
|
||||||
}, { title: man.description || '' }));
|
}, { sub: man.description || '' }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2065,14 +1620,20 @@ new ResizeObserver(() => {
|
|||||||
config or enabled flags change. Tiles of a changed module get
|
config or enabled flags change. Tiles of a changed module get
|
||||||
onConfigChange(cfg) if they implement it, otherwise a clean remount. */
|
onConfigChange(cfg) if they implement it, otherwise a clean remount. */
|
||||||
|
|
||||||
|
let eventsSource = null;
|
||||||
|
let eventsRetry = null;
|
||||||
|
|
||||||
function subscribeEvents() {
|
function subscribeEvents() {
|
||||||
// Rides on the page's one stream connection (see sseSubscribe) as `shell:`.
|
try { eventsSource?.close(); } catch { /* not open */ }
|
||||||
sseSubscribe('/api/events', {
|
eventsSource = new EventSource('/api/events');
|
||||||
events: {
|
eventsSource.addEventListener('modules-changed', () => handleModulesChanged());
|
||||||
'modules-changed': () => handleModulesChanged(),
|
eventsSource.addEventListener('theme', handleThemeEvent);
|
||||||
theme: handleThemeEvent,
|
eventsSource.onerror = () => {
|
||||||
},
|
if (eventsSource.readyState === EventSource.CLOSED) {
|
||||||
});
|
clearTimeout(eventsRetry);
|
||||||
|
eventsRetry = setTimeout(subscribeEvents, 4000);
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleModulesChanged() {
|
async function handleModulesChanged() {
|
||||||
|
|||||||
+22
-147
@@ -170,7 +170,6 @@ 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;
|
||||||
@@ -234,18 +233,6 @@ 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;
|
||||||
@@ -318,26 +305,6 @@ 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) */
|
||||||
@@ -566,122 +533,28 @@ 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: fixed;
|
position: absolute;
|
||||||
z-index: 80;
|
top: 36px;
|
||||||
display: flex;
|
right: 6px;
|
||||||
flex-direction: column;
|
z-index: 45;
|
||||||
/* as wide as its content wants and no wider: a couple of switches make a
|
min-width: 220px;
|
||||||
small window, a three-column checklist a wide one */
|
max-width: calc(100% - 12px);
|
||||||
width: max-content;
|
max-height: calc(100% - 40px);
|
||||||
min-width: 150px;
|
overflow-y: auto;
|
||||||
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;
|
||||||
@@ -719,7 +592,7 @@ body.fullscreen #grid { padding-bottom: 0; }
|
|||||||
.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
|
/* 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. */
|
bottom margin would only pad the popover's own padding a second time. */
|
||||||
.tile-settings-body > :last-child { margin-bottom: 0; }
|
.tile-settings > :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));
|
||||||
@@ -727,10 +600,12 @@ body.fullscreen #grid { padding-bottom: 0; }
|
|||||||
}
|
}
|
||||||
.tile-settings .field-group-body.is-columns .field { margin-bottom: 0; }
|
.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; }
|
||||||
/* a list of switches or a template with its chips is a row of its own, however
|
@container (max-width: 420px) {
|
||||||
many columns the group lays its other fields out in */
|
.tile-settings .field-group-body.is-columns { grid-template-columns: 1fr 1fr; }
|
||||||
.tile-settings .field-group-body.is-columns .multiselect-field,
|
}
|
||||||
.tile-settings .field-group-body.is-columns .template-field { grid-column: 1 / -1; }
|
@container (max-width: 260px) {
|
||||||
|
.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;
|
||||||
|
|||||||
@@ -13,8 +13,6 @@
|
|||||||
* proddash.json in the data directory (this machine's overrides), or env vars:
|
* proddash.json in the data directory (this machine's overrides), or env vars:
|
||||||
* PORT (default: value in proddash.json, else 24500)
|
* PORT (default: value in proddash.json, else 24500)
|
||||||
* PRODDASH_PASSCODE (overrides adminPasscode in proddash.json)
|
* PRODDASH_PASSCODE (overrides adminPasscode in proddash.json)
|
||||||
* PRODDASH_FORGE (overrides `forge`: "github", or a Gitea/Forgejo base
|
|
||||||
* URL such as http://10.3.8.90:3000)
|
|
||||||
* PRODDASH_DATA_DIR (where module config + layouts are kept; see below)
|
* PRODDASH_DATA_DIR (where module config + layouts are kept; see below)
|
||||||
*/
|
*/
|
||||||
'use strict';
|
'use strict';
|
||||||
@@ -465,20 +463,7 @@ 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) {
|
||||||
@@ -561,142 +546,6 @@ 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 ─────────────────────────────────────────────────────────── */
|
/* ── themes ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
/* Preset palettes for the whole dashboard, chosen in Admin → Theme. The
|
/* Preset palettes for the whole dashboard, chosen in Admin → Theme. The
|
||||||
@@ -766,53 +615,15 @@ function parseRepoSetting(value, fallbackBranch) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const REPO = parseRepoSetting(process.env.PRODDASH_REPO || shellConfig.repo, shellConfig.branch)
|
const REPO = parseRepoSetting(process.env.PRODDASH_REPO || shellConfig.repo, shellConfig.branch)
|
||||||
|| parseRepoSetting('michael/ProdDash', 'main');
|
|| parseRepoSetting('michaelmcgary2008/ProdDash', 'main');
|
||||||
|
|
||||||
/**
|
|
||||||
* Which server the code comes from. GitHub unless `forge` names another —
|
|
||||||
* give it a Gitea (or Forgejo) base URL, "http://10.3.8.90:3000", and updates
|
|
||||||
* and module installs come from there instead. The two hosts differ only in
|
|
||||||
* how an archive URL is spelled: the tarball itself is the same, pax header
|
|
||||||
* and commit sha included, so everything downstream is untouched.
|
|
||||||
*/
|
|
||||||
function parseForge(value) {
|
|
||||||
const s = String(value || '').trim().replace(/\/+$/, '');
|
|
||||||
if (!s || s.toLowerCase() === 'github') {
|
|
||||||
return { kind: 'github', base: 'https://github.com', archiveBase: 'https://codeload.github.com' };
|
|
||||||
}
|
|
||||||
if (!/^https?:\/\//i.test(s)) return null;
|
|
||||||
// A GitHub URL still means codeload for archives.
|
|
||||||
if (/^https?:\/\/(www\.)?github\.com$/i.test(s)) {
|
|
||||||
return { kind: 'github', base: s, archiveBase: 'https://codeload.github.com' };
|
|
||||||
}
|
|
||||||
return { kind: 'gitea', base: s, archiveBase: s };
|
|
||||||
}
|
|
||||||
|
|
||||||
const FORGE = parseForge(process.env.PRODDASH_FORGE || shellConfig.forge) || parseForge('github');
|
|
||||||
/** Where branch archives come from; tests point this at a local mock. */
|
/** Where branch archives come from; tests point this at a local mock. */
|
||||||
const ARCHIVE_BASE = String(process.env.PRODDASH_ARCHIVE_BASE || FORGE.archiveBase).replace(/\/+$/, '');
|
const ARCHIVE_BASE = String(process.env.PRODDASH_ARCHIVE_BASE || 'https://codeload.github.com').replace(/\/+$/, '');
|
||||||
const REMOTE_TTL_MS = 10 * 60 * 1000;
|
const REMOTE_TTL_MS = 10 * 60 * 1000;
|
||||||
const REMOTE_CHECK_MS = 6 * 60 * 60 * 1000;
|
const REMOTE_CHECK_MS = 6 * 60 * 60 * 1000;
|
||||||
const MAX_ARCHIVE_BYTES = 80 * 1024 * 1024;
|
const MAX_ARCHIVE_BYTES = 80 * 1024 * 1024;
|
||||||
|
|
||||||
function tarballUrl() {
|
function tarballUrl() {
|
||||||
const branch = encodeURIComponent(REPO.branch);
|
return `${ARCHIVE_BASE}/${REPO.owner}/${REPO.repo}/tar.gz/${encodeURIComponent(REPO.branch)}`;
|
||||||
return FORGE.kind === 'gitea'
|
|
||||||
? `${ARCHIVE_BASE}/${REPO.owner}/${REPO.repo}/archive/${branch}.tar.gz`
|
|
||||||
: `${ARCHIVE_BASE}/${REPO.owner}/${REPO.repo}/tar.gz/${branch}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The page a human should read to see what changed between two commits. */
|
|
||||||
function forgeCompareUrl(from, to) {
|
|
||||||
return `${FORGE.base}/${REPO.owner}/${REPO.repo}/compare/${from}...${to}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The commit list for the branch we follow. */
|
|
||||||
function forgeCommitsUrl() {
|
|
||||||
const branch = encodeURIComponent(REPO.branch);
|
|
||||||
return FORGE.kind === 'gitea'
|
|
||||||
? `${FORGE.base}/${REPO.owner}/${REPO.repo}/commits/branch/${branch}`
|
|
||||||
: `${FORGE.base}/${REPO.owner}/${REPO.repo}/commits/${branch}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function httpError(status, message) {
|
function httpError(status, message) {
|
||||||
@@ -969,11 +780,11 @@ function shellStatus() {
|
|||||||
else if (local.branch !== REPO.branch) blocker = `this checkout is on branch "${local.branch}", not "${REPO.branch}"`;
|
else if (local.branch !== REPO.branch) blocker = `this checkout is on branch "${local.branch}", not "${REPO.branch}"`;
|
||||||
else if (local.dirty) blocker = 'this checkout has local changes — commit or discard them first';
|
else if (local.dirty) blocker = 'this checkout has local changes — commit or discard them first';
|
||||||
}
|
}
|
||||||
|
const gh = `https://github.com/${REPO.owner}/${REPO.repo}`;
|
||||||
return {
|
return {
|
||||||
version: SHELL_VERSION,
|
version: SHELL_VERSION,
|
||||||
repo: `${REPO.owner}/${REPO.repo}`,
|
repo: `${REPO.owner}/${REPO.repo}`,
|
||||||
branch: REPO.branch,
|
branch: REPO.branch,
|
||||||
forge: FORGE.base,
|
|
||||||
local: { isGit: local.isGit, sha: local.sha, branch: local.branch, dirty: local.dirty },
|
local: { isGit: local.isGit, sha: local.sha, branch: local.branch, dirty: local.dirty },
|
||||||
remote: { version: remote.version, sha: remote.sha, checkedAt: remote.checkedAt, error: remote.error },
|
remote: { version: remote.version, sha: remote.sha, checkedAt: remote.checkedAt, error: remote.error },
|
||||||
updateAvailable: versionBehind || commitsBehind,
|
updateAvailable: versionBehind || commitsBehind,
|
||||||
@@ -981,8 +792,8 @@ function shellStatus() {
|
|||||||
method: local.isGit ? 'git' : 'archive',
|
method: local.isGit ? 'git' : 'archive',
|
||||||
updateBlocker: blocker,
|
updateBlocker: blocker,
|
||||||
changesUrl: local.sha && remote.sha
|
changesUrl: local.sha && remote.sha
|
||||||
? forgeCompareUrl(local.sha.slice(0, 12), remote.sha.slice(0, 12))
|
? `${gh}/compare/${local.sha.slice(0, 12)}...${remote.sha.slice(0, 12)}`
|
||||||
: forgeCommitsUrl(),
|
: `${gh}/commits/${REPO.branch}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1244,9 +1055,6 @@ 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 = {
|
||||||
@@ -1624,11 +1432,6 @@ function handleRequest(req, res) {
|
|||||||
return sendJson(res, 200, themeView());
|
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, {
|
||||||
@@ -1733,7 +1536,7 @@ server.on('listening', () => {
|
|||||||
? ' Modules : ' + ids.map((id) => id + (isEnabled(id) ? '' : ' (disabled)') + (manifests.get(id).incompatible ? ' (needs newer ProdDash)' : '')).join(', ')
|
? ' Modules : ' + ids.map((id) => id + (isEnabled(id) ? '' : ' (disabled)') + (manifests.get(id).incompatible ? ' (needs newer ProdDash)' : '')).join(', ')
|
||||||
: ' Modules : none installed');
|
: ' Modules : none installed');
|
||||||
console.log(' Settings : ' + DATA_DIR + (DATA_DIR === REPO_CONFIG_DIR ? ' (inside the app folder)' : ''));
|
console.log(' Settings : ' + DATA_DIR + (DATA_DIR === REPO_CONFIG_DIR ? ' (inside the app folder)' : ''));
|
||||||
console.log(' Repo : ' + REPO.owner + '/' + REPO.repo + '@' + REPO.branch + ' (' + FORGE.base + ')');
|
console.log(' Repo : ' + REPO.owner + '/' + REPO.repo + '@' + REPO.branch);
|
||||||
});
|
});
|
||||||
|
|
||||||
// A restart hands the port over from the exiting ProdDash: wait for it.
|
// A restart hands the port over from the exiting ProdDash: wait for it.
|
||||||
|
|||||||
@@ -11,10 +11,6 @@
|
|||||||
* 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
|
||||||
@@ -133,22 +129,6 @@ 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 = [];
|
||||||
@@ -266,9 +246,6 @@ 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) {
|
||||||
|
|||||||
@@ -1,417 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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