Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ca65a845f | ||
|
|
e383070a9e | ||
|
|
2af7ef27ba | ||
|
|
5107b06f8c | ||
|
|
5fb90190ae | ||
|
|
684f06abed | ||
|
|
3435a0625e | ||
|
|
a7e40ee1a8 | ||
|
|
c075655817 | ||
|
|
48d0f5a04a | ||
|
|
12f6e22187 | ||
|
|
87929bfd0b | ||
|
|
46d44d494b | ||
|
|
44fd0b10ba | ||
|
|
543795f9fd | ||
|
|
d2d4207fe4 | ||
|
|
e90ae51495 | ||
|
|
188d1c878d | ||
|
|
5025e12b71 | ||
|
|
3009b39453 | ||
|
|
41f64f3a13 | ||
|
|
12f0b20e5f | ||
|
|
f72170b4e1 | ||
|
|
ea1eac1c28 | ||
|
|
ea8be2ac76 | ||
|
|
82e6386c73 | ||
|
|
f5eb611872 | ||
|
|
d52cfcc48e | ||
|
|
7d46cfbd8f | ||
|
|
7a5b57dc56 | ||
|
|
7f50fa0ba9 | ||
|
|
68f0ba806d | ||
|
|
8e579ecb34 | ||
|
|
c379025d30 | ||
|
|
f0be37796b | ||
|
|
52d700df3d | ||
|
|
6cca6d4d1a | ||
|
|
1be5cbef31 | ||
|
|
7690ae1fad | ||
|
|
1c8cd63cd0 | ||
|
|
6dabd9d8a1 |
@@ -14,3 +14,6 @@ modules/propresenter-timers/ltc-capture
|
||||
# the launcher's build output, and its cache of the Node runtime it embeds
|
||||
launcher/macos/build/
|
||||
launcher/macos/.cache/
|
||||
|
||||
# Compiled per-machine, source sits next to it (see spl-capture.swift).
|
||||
modules/spl-meter/spl-capture
|
||||
|
||||
@@ -32,12 +32,19 @@ title-bar controls). The rules that matter most:
|
||||
and bump the module's `version` on every change.
|
||||
- Browsers never call upstream services directly; the module's server routes
|
||||
proxy everything (no CORS, secrets stay server-side).
|
||||
- **Keep the interface clean** (guide rules 5-11): no `help` and no label
|
||||
over three words in a tile's gear; server-side settings in `configSchema`
|
||||
only; a tile setting changes that tile alone; hide-toggles ungrouped when
|
||||
there are four or fewer, in a folded **Show** group when there are five or
|
||||
more; title-bar controls are icons with tooltips, never labels, and never
|
||||
repeat something the gear already offers.
|
||||
- Style with the shell's CSS variables only, scoped under
|
||||
`[data-module="<id>"]`; survive upstream loss (reconnect + `setStatus`).
|
||||
- Verify against the guide's testing section and resilience checklist. Mocks
|
||||
for the existing upstreams: `node tools/prodcom-mock.js` (ProdCom) and
|
||||
`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`).
|
||||
`node tools/pco-band-mock.js 24710` (Planning Center for Band Lineup; run ProdDash with `BAND_LINEUP_API_BASE=http://127.0.0.1:24710`).
|
||||
|
||||
## Reference code (read-only forks — don't develop these here)
|
||||
|
||||
|
||||
@@ -99,9 +99,18 @@ with this machine's shell settings — the same keys as the checked-in
|
||||
| Key | Meaning |
|
||||
| --- | --- |
|
||||
| `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. |
|
||||
| `adminPin` | The admin PIN, as a scrypt hash. **Set it in the admin page's Admin PIN section**, not by hand — 6 digits, and only the hash is stored. Absent means admin is open to anyone on the network. |
|
||||
| `adminPasscode` | An older free-text passcode. Still honoured, so an existing install keeps working; new installs should use `adminPin`. |
|
||||
| `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`, `daylight`, `midnight`, `moss`, `plum`, `sepia`, `blackout`, `paper` or `iron`. The admin page's **Theme** section writes this one — see [Themes](#themes). |
|
||||
|
||||
**Locked out?** Delete `adminPin` from the data directory's `proddash.json`
|
||||
and restart, or start the server with `PRODDASH_PASSCODE` set — that passcode
|
||||
overrides the PIN and always gets you in.
|
||||
|
||||
Only `/admin` and the admin API are behind the PIN. Dashboards stay open, so a
|
||||
wall display needs no PIN, and it is unrelated to a module's own PIN (Slack's
|
||||
4–8 digit send PIN, say), which guards that module's actions only.
|
||||
|
||||
**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
|
||||
@@ -131,12 +140,26 @@ ignored from then on.
|
||||
|
||||
The whole dashboard — every tile, on every browser — draws with one palette,
|
||||
chosen in the admin page's **Theme** section and switched live: pick a swatch
|
||||
and open dashboards change without a reload. Five presets ship: **Booth**
|
||||
(the default dark, mint accent), **Harbor** (deep navy, cyan), **Graphite**
|
||||
(neutral charcoal, electric blue), **Ember** (warm broadcast black, orange)
|
||||
and **Daylight**, a light palette for a front-of-house desk in daytime. Each
|
||||
keeps the status colours apart — the accent is never near the danger red or
|
||||
the warning amber — and muted text readable in a dark booth. The choice is
|
||||
and open dashboards change without a reload. The section starts folded —
|
||||
a building picks its palette once — and holds twelve presets:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Booth** | the default dark, mint accent |
|
||||
| **Harbor** | deep navy, cyan |
|
||||
| **Graphite** | neutral charcoal, electric blue |
|
||||
| **Ember** | warm broadcast black, orange |
|
||||
| **Daylight** | a cool light palette, for a front-of-house desk in daytime |
|
||||
| **Midnight** | indigo night, violet |
|
||||
| **Moss** | forest floor, lime |
|
||||
| **Plum** | aubergine, purple |
|
||||
| **Sepia** | warm brown room, cool teal — Ember's arrangement reversed |
|
||||
| **Blackout** | true black, cyan, for a dark room where any glow carries |
|
||||
| **Paper** | the warm light one, indigo accent |
|
||||
| **Iron** | no colour of its own: a near-white accent, so the only hues on screen are a module's own and the red and amber that mean something |
|
||||
|
||||
Each keeps the status colours apart — the accent is never near the danger red
|
||||
or the warning amber — and muted text readable in a dark booth. The choice is
|
||||
stored as `theme` in the data directory's `proddash.json` (table above), so
|
||||
it holds across restarts and updates; a browser remembers the last theme it
|
||||
saw and applies it before the server answers, so nothing flashes. Modules
|
||||
@@ -145,10 +168,11 @@ set on a tile itself (a PCO Plan header colour, say) is deliberately kept.
|
||||
|
||||
## Installing modules and updating
|
||||
|
||||
Everything below happens in the **admin page**; ProdDash talks to its GitHub
|
||||
repository (`michaelmcgary2008/ProdDash`, branch `main` — change with
|
||||
`repo` / `branch` in `proddash.json` or the `PRODDASH_REPO` variable,
|
||||
`owner/repo#branch`). It downloads one small archive of the branch, at most
|
||||
Everything below happens in the **admin page**; ProdDash talks to its git
|
||||
repository — `michael/ProdDash` on the church's own Gitea at
|
||||
`chronicles-ab.waterschurch.org:3000`, branch `main`. Change it with `forge`,
|
||||
`repo` and `branch` in `proddash.json`, or the `PRODDASH_FORGE` and
|
||||
`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
|
||||
below. Nothing is installed without someone clicking.
|
||||
|
||||
@@ -176,7 +200,18 @@ below. Nothing is installed without someone clicking.
|
||||
no local changes — otherwise the banner says why and you update with git).
|
||||
A plain folder (no `.git`) has the repo archive unpacked over it; files
|
||||
removed upstream are left behind but harmless. Settings and layouts are
|
||||
never touched. **What changed** opens the commit comparison on GitHub.
|
||||
never touched — nothing inside the data directory is written, even on an
|
||||
install where that directory *is* the app's own `config/`. **What changed**
|
||||
opens the commit comparison on the forge.
|
||||
- **If an update goes wrong**: the download has to parse before anything is
|
||||
swapped in, so a truncated or mangled `server.js` is refused with the
|
||||
reason and the running copy is left exactly as it was. Past that, the copy
|
||||
being replaced is set aside as `app.previous` next to the app folder, and
|
||||
dropped once the new version has stayed up for ninety seconds — so that
|
||||
folder existing means "the last update has not proven itself yet". The
|
||||
macOS app watches for a version that will not stay up (four stops in two
|
||||
minutes) and puts the old one back by itself, once, with a notification.
|
||||
Running some other way, the way back is a rename: `mv app.previous app`.
|
||||
- **Restarting**: after an update the server exits with code 75 and starts
|
||||
its own replacement, which waits for the port to free up. If you supervise
|
||||
ProdDash with your own launcher, set `PRODDASH_LAUNCHER=1` so the server
|
||||
@@ -188,13 +223,17 @@ below. Nothing is installed without someone clicking.
|
||||
|
||||
- **+** — add any enabled module; multiple tiles of the same module are
|
||||
fine (e.g. two transcripts filtered to different channels).
|
||||
- Drag a tile by its **title bar** to move it; drag either **bottom
|
||||
corner** to resize; **✕** removes it. Hover a tile and a small pill
|
||||
appears on its **bottom edge** — drag it to change just the height. If
|
||||
another tile sits directly below, the pill moves the shared edge instead,
|
||||
trading rows between the two so the column keeps its total height. Tiles
|
||||
sit flush — when two share a vertical edge, hover the seam and the same
|
||||
kind of pill appears; drag it to trade width (macOS split-view style).
|
||||
- Drag a tile by its **title bar** to move it; **✕** removes it.
|
||||
- **Resize from any edge or bottom corner**, the way a window resizes:
|
||||
nothing is drawn on a tile to mark the spots — the cursor over an edge is
|
||||
what says it can be dragged. An edge moves only itself, so the tile grows
|
||||
or shrinks into the space beside it while the opposite edge stays put,
|
||||
stopping at a neighbour or at the module's own minimum size. Two
|
||||
exceptions, both of which move a *shared* edge instead: with another tile
|
||||
directly below, the bottom edge trades rows between the two so the column
|
||||
keeps its total height; and where two tiles meet side by side, the seam
|
||||
between them trades width (macOS split-view style), showing itself only
|
||||
while you drag it.
|
||||
- **Fullscreen** (the ⛶ button) is a viewing mode: the menu bar hides (its
|
||||
green lip at the top-left brings it back, minus the Admin button), the
|
||||
occupied columns stretch to the full width, and every column runs to the
|
||||
@@ -204,9 +243,21 @@ below. Nothing is installed without someone clicking.
|
||||
gap; alone in its columns it stays as it is. Nothing is saved — the layout
|
||||
reverts when you leave fullscreen. A layout deeper than the screen keeps
|
||||
its normal row height and scrolls.
|
||||
- A tile's actions (menus, text size, clear…) live in its **title bar**;
|
||||
the small **notch** on the bar's bottom edge hides/shows the whole bar
|
||||
for a clean wall-display look. The menu bar's handle is the **ProdDash
|
||||
- A tile's actions (menus, text size, clear…) live in its **title bar**.
|
||||
- **Auto-hide bars** (the button left of ⛶) folds every title bar away, for
|
||||
a windowed screen that should read like a wall display. A folded bar is
|
||||
not a gone bar: point at the tile's top edge and it slides back in over
|
||||
the content, where its buttons click and its width drags the tile exactly
|
||||
as before — so a dashboard can be arranged with every bar out of the way.
|
||||
The switch is off on a fresh dashboard and saves with the layout —
|
||||
including a named layout on the server, so a booth loads its screen with
|
||||
the bars already tucked away. Fullscreen folds every bar regardless, and
|
||||
the same hover brings one back there too.
|
||||
- The **pin** beside a tile's title keeps *that* tile's bar showing while
|
||||
auto-hide is on — for the one or two tiles whose controls are wanted at
|
||||
a glance. It turns the accent colour when it is on and saves with the
|
||||
layout. Fullscreen still folds a pinned bar with the rest.
|
||||
- The menu bar's own handle is the **ProdDash
|
||||
name itself**, wrapped in a translucent green pill at the left — click it
|
||||
to hide the bar; the slim green lip it leaves at the top-left edge brings
|
||||
the bar back. It stays at the left so it never sits on top of a tile's
|
||||
@@ -215,7 +266,21 @@ below. Nothing is installed without someone clicking.
|
||||
display options) — these belong to this browser's layout, not the server.
|
||||
- The **grid icon** — save the current arrangement to the server under a
|
||||
name, load a named layout (it becomes a local copy — browsers are never
|
||||
linked), or reset this browser's dashboard.
|
||||
linked), or reset this browser's dashboard. Loading says what it did —
|
||||
*Loaded "FOH": 9 tiles, 2 not shown — Service, #ops aren't offered right
|
||||
now* — with **Undo** in the message to get back the arrangement you had.
|
||||
A tile whose module or picker entry isn't offered at the moment keeps its
|
||||
place and shows nothing, then appears when it is; it is never replaced by
|
||||
the module's default tile. Saving over an existing name asks first, and
|
||||
the server keeps the copy it replaced: **Restore previous** in Admin →
|
||||
Saved layouts swaps it back (and swapping again undoes that). The menu's
|
||||
**Grid Size** stepper sets how many columns this browser cuts the board into —
|
||||
8 to 24, 12 by default. Finer lets tiles be smaller so more fit across (a
|
||||
4K wall), coarser suits a small screen read close up; row height follows,
|
||||
so tiles keep their shape and simply change size. Going coarser, a tile
|
||||
that no longer fits where it was moves to the first free spot rather than
|
||||
onto another tile. It saves with the layout, so a named layout comes back
|
||||
at the density it was arranged on.
|
||||
- Every tile has a **status dot**: green = live, amber = connecting,
|
||||
red = upstream unreachable (hover it for details). Tiles reconnect
|
||||
automatically.
|
||||
@@ -230,7 +295,13 @@ below. Nothing is installed without someone clicking.
|
||||
Tile settings: channel icons, direction and timestamps.
|
||||
- **ProPresenter** — what's live and what's next, with slide progress,
|
||||
driven by the shared `propresenter-core` client (ProPresenter 7/20).
|
||||
The + picker offers two tiles: **Now / Next**, the full card, and **Now**,
|
||||
the live item alone with the gradient filling the tile edge to edge — a
|
||||
wall panel that says one thing.
|
||||
Admin config: ProPresenter endpoint (host : port), password.
|
||||
Tile settings: a **Show** group of switches for the playlist name, live
|
||||
status, NOW and NEXT labels, slide count, progress bar, the next list and
|
||||
its item tags, plus tidy titles.
|
||||
- **Timers** — live ProPresenter timers (countdown, countdown-to-time,
|
||||
elapsed) and LTC timecode as auto-sized cards. The + picker lists every
|
||||
discovered timer (and the LTC card) as its own tile, or add "All timers"
|
||||
@@ -264,6 +335,38 @@ below. Nothing is installed without someone clicking.
|
||||
(gear menu): the top-bar text (placeholders such as
|
||||
`{series} • {part} | {serviceType} • {date}`), colors, text size, and a
|
||||
show/hide checklist for every detail.
|
||||
- **Slack** — live transcripts of this location's Slack channels, plus
|
||||
PIN-protected sending as the location. The + picker lists one transcript
|
||||
tile per configured channel, titled `#channel`, and two send surfaces (one
|
||||
of each per dashboard): **Send message**, a free-text box with the admin's
|
||||
quick emojis one press away, and **Quick replies**, a grid of preset
|
||||
buttons. Both post to the channel of whichever transcript was clicked last
|
||||
— its bottom edge lights up in the accent color — and to the first
|
||||
configured channel until one is clicked. Messages arrive with avatars,
|
||||
names and times, Slack's inline formatting and the workspace's custom
|
||||
emoji; a right-click on one opens the reaction menu, and text size and
|
||||
jump-to-latest sit in the title bar. Nothing leaves the machine until the
|
||||
PIN is entered: one PIN unlocks every Slack tile on that dashboard page
|
||||
until the auto-lock idles out, a reload asks again, and the lock button on
|
||||
a send tile's title bar ends it early.
|
||||
Admin config is in four groups: **Slack account** (the location account's
|
||||
User OAuth Token — `xoxp-…`, so messages are literally from the campus —
|
||||
or a bot token plus "Send as"), **Channels** (the joined channels to
|
||||
follow, one transcript tile each), **Transcript** (refresh interval,
|
||||
messages kept, thread replies) and **Sending** (the switch on the group's
|
||||
title row, a 4–8 digit PIN, auto-lock minutes, quick replies one per line
|
||||
as `Label | Message` — add `| #channel` to bind one to a channel — and up
|
||||
to eight quick emojis).
|
||||
Tile settings: avatars, timestamps, names and channel notices.
|
||||
- **Band Lineup** — the backstage stage board: everyone scheduled on the
|
||||
band, vocal and production teams for the upcoming Planning Center plan,
|
||||
placed into their mic slot with photo, name and mic/pack label; birthdays
|
||||
in the service week get confetti. Guests and MCs not on the plan are pinned
|
||||
per slot from the title bar's Edit button. Admin config: Planning Center
|
||||
credentials, service type and plan, the teams to include, the stage slots
|
||||
(one mic/pack label per line, in stage order), default columns, and a
|
||||
Google Drive folder for manual photos. Tile settings: names, mic labels,
|
||||
parallax, and the birthday confetti with its intensity.
|
||||
- **Clock** — a big booth clock; also the smallest possible module and the
|
||||
reference for module authors.
|
||||
|
||||
@@ -271,10 +374,15 @@ To develop modules against mocks instead of real gear:
|
||||
`node tools/prodcom-mock.js` (ProdCom on `127.0.0.1:24480`),
|
||||
`node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter slides on
|
||||
`127.0.0.1:1599`), `node tools/pp-timers-mock.js 1600` (ProPresenter
|
||||
timers + timecode on `127.0.0.1:1600`) and `node tools/pco-mock.js`
|
||||
timers + timecode on `127.0.0.1:1600`), `node tools/pco-mock.js`
|
||||
(Planning Center Services on `127.0.0.1:24700` — run ProdDash with
|
||||
`PCO_PLAN_API_BASE=http://127.0.0.1:24700`). See the testing section of the
|
||||
[module guide](docs/MODULE-GUIDE.md).
|
||||
`PCO_PLAN_API_BASE=http://127.0.0.1:24700`) and
|
||||
`node tools/slack-mock.js 24716` (the Slack Web API on `127.0.0.1:24716` —
|
||||
run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`) and
|
||||
`node tools/pco-band-mock.js 24710` (Planning Center for the Band Lineup
|
||||
board — a plan, its team members, birthdays and photos — run ProdDash with
|
||||
`BAND_LINEUP_API_BASE=http://127.0.0.1:24710`). See the testing section of
|
||||
the [module guide](docs/MODULE-GUIDE.md).
|
||||
|
||||
## Install as a desktop web app
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"port": 24500,
|
||||
"adminPasscode": "",
|
||||
"forge": "github",
|
||||
"repo": "michaelmcgary2008/ProdDash",
|
||||
"forge": "http://chronicles-ab.waterschurch.org:3000",
|
||||
"repo": "michael/ProdDash",
|
||||
"branch": "main"
|
||||
}
|
||||
|
||||
+78
-24
@@ -34,6 +34,46 @@ people running the booth depend on them.
|
||||
The admin page refuses to install a module whose requirement the running
|
||||
shell doesn't meet, and it offers updates by comparing versions.
|
||||
|
||||
### Keeping the interface clean
|
||||
|
||||
A dashboard is read across a booth, at a glance, by someone doing three
|
||||
other things. These rules are what keep every tile looking like one product
|
||||
instead of six. They are as binding as the four above.
|
||||
|
||||
5. **No explanatory text in a tile's gear.** No `help` on an
|
||||
`instanceSchema` field or an `instanceGroups` entry. Labels are **three
|
||||
words at most, one or two preferred** — "Slide count", "Item tags",
|
||||
"Running clock". A control that cannot be understood in three words is
|
||||
not a display choice; it belongs in `configSchema`, where `help` is
|
||||
welcome and expected. *One exception:* a `template` field may keep a
|
||||
single `help` line naming its placeholders, which nobody can guess.
|
||||
6. **Server-side settings are admin-only.** Anything the module's server
|
||||
acts on — a host, a credential, what to fetch, how often to poll — is
|
||||
`configSchema`. `instanceSchema` is display choices and nothing else:
|
||||
what this tile shows, and how big.
|
||||
7. **A tile setting changes that tile and nothing else.** Never another
|
||||
tile, never the module as a whole, never the server. If two tiles must
|
||||
agree on something, it is either admin config or page state the shell
|
||||
owns — not a setting each tile keeps its own copy of.
|
||||
8. **Offer toggles for the elements you draw.** Every tile should let the
|
||||
operator switch off the parts it does not need. **One to four toggles:**
|
||||
give them no `group` at all, so they sit unlabelled at the top of the
|
||||
gear. **Five or more:** one group named **Show**, `collapsed: true`,
|
||||
`columns: 2`. Nothing else goes in that group — colours, formats and
|
||||
text belong in groups of their own.
|
||||
9. **Title-bar controls are icons.** No `label` on `addButton` or
|
||||
`addMenu`; the tooltip carries the words, and `docs` above covers drawing
|
||||
the icon. A small state indicator may ride alongside an icon — ProdCom's
|
||||
channel button shows a count while a filter is on — but a control is
|
||||
never named in the bar.
|
||||
10. **If a control needs words to explain it, it is not a title-bar
|
||||
control.** Put it in the gear. The bar is for actions whose icon and
|
||||
tooltip say everything: refresh, clear, jump to latest, bigger text.
|
||||
11. **One control per action.** Whatever has a dedicated title-bar button is
|
||||
not offered again in the gear, and the reverse. Text size is A− / A+ up
|
||||
there, never a "Text size" field; a timestamps button in the bar means
|
||||
no timestamps switch below it.
|
||||
|
||||
## The shape of a module
|
||||
|
||||
A module is one folder dropped into `modules/`:
|
||||
@@ -96,7 +136,7 @@ the app); its settings are kept for a later reinstall.
|
||||
| `client` | yes | Client entry file, loaded as an ES module. |
|
||||
| `server` | no | Server entry file (CommonJS), loaded with `require()`. |
|
||||
| `style` | no | A stylesheet the shell injects once per module. |
|
||||
| `minSize`, `defaultSize` | no | Tile size in grid cells (12 columns wide; rows are ~72 px). Defaults: min 1×1, default 4×3. |
|
||||
| `minSize`, `defaultSize` | no | Tile size in grid cells, declared for the default grid (12 columns, 72 px rows). Defaults: min 1×1, default 4×3. A dashboard may cut its board finer or coarser (8–24 columns, from the Layouts menu; row height follows). `defaultSize` is scaled to that density when a tile is added, so a new tile lands at about the size you meant; `minSize` is enforced in cells as declared, so on a finer grid a tile can be made smaller than it could at 12. |
|
||||
| `configSchema` | no | **Admin config** — server-wide settings (URLs, ports, keys) edited once at `/admin` for everyone. |
|
||||
| `instanceSchema` | no | **Per-tile settings** — chosen in each tile's gear menu and stored with that browser's layout (filters, text sizes, display options). They apply as they are changed, so keep them to display choices. |
|
||||
| `tiles` | no | Fixed multi-tile list for client-only modules; server modules export a dynamic `tiles()` instead. See **Presenting multiple tiles**. |
|
||||
@@ -177,14 +217,18 @@ the group name:
|
||||
|
||||
`help` on a field puts one line of guidance under its control — allowed
|
||||
placeholders, where a value comes from. Keep labels short and put the
|
||||
explanation there.
|
||||
explanation there. **This is for `configSchema` only**: a tile's gear
|
||||
carries no explanations at all (rule 5), so a setting that needs one is
|
||||
telling you it belongs in admin.
|
||||
|
||||
`group` and `help` work on `instanceSchema` fields too, and a manifest
|
||||
`group` works on `instanceSchema` fields too, and a manifest
|
||||
`instanceGroups` map plays the `configGroups` role for the tile's gear
|
||||
popover (`collapsed: true | false`, `columns`, `help`, `toggle`). That is how
|
||||
a module with a long show/hide checklist keeps the popover usable — see
|
||||
`pco-plan`. Display choices belong there, per tile; admin config is for
|
||||
connections. The popover itself is a small window beside the tile — sized to
|
||||
popover (`collapsed: true | false`, `columns`, `toggle` — but not `help`,
|
||||
per rule 5). That is how a module with a long show/hide checklist keeps the
|
||||
popover usable: five or more hide-toggles go in one **Show** group, folded
|
||||
by default, and fewer than five go in no group at all — see `pco-plan` for
|
||||
the long form and `slack` for the short one. Display choices belong there,
|
||||
per tile; admin config is for connections. The popover itself is a small window beside the tile — sized to
|
||||
its content, moved by the slim bar on top, closed by clicking anywhere else
|
||||
or pressing Escape — so a tile never has to be big enough to hold its own
|
||||
settings, and a change can be watched as it lands. Under everything you
|
||||
@@ -354,22 +398,25 @@ menu.setLabel('Channels (2/4) ▾'); // update the button text any time
|
||||
```
|
||||
|
||||
Everything you add is removed automatically when your instance stops, so a
|
||||
remount never duplicates controls. Keep them compact (icon buttons, short
|
||||
labels): the title bar is 32 px tall and shared with the tile's name. Draw
|
||||
icons as inline SVG rather than characters — a glyph such as ✕ or + takes
|
||||
its size from whatever font each browser picks; text-size buttons are a
|
||||
drawn − and +.
|
||||
remount never duplicates controls. **Icons only, no labels** (rule 9): the
|
||||
title bar is 32 px tall and shared with the tile's name, and the tooltip
|
||||
says what the icon means. Draw icons as inline SVG rather than characters —
|
||||
a glyph such as ✕ or + takes its size from whatever font each browser
|
||||
picks; text-size buttons are a drawn − and +. A small state indicator may
|
||||
ride alongside an icon (ProdCom's channel button carries a count while a
|
||||
filter is on); a name may not.
|
||||
|
||||
**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
|
||||
**One control per action** (rule 11). Whatever has a dedicated header button
|
||||
is not offered again in the gear popover: text size is − / + 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
|
||||
small notch on its bottom edge (a clean-view mode for wall displays). Your
|
||||
controls disappear with it, so nothing essential — status, reconnects,
|
||||
One more thing to know: the whole title bar folds away when the dashboard
|
||||
auto-hides it, and always in fullscreen (a clean-view mode for wall
|
||||
displays) — it comes back only while the pointer is at the tile's top edge.
|
||||
Your controls fold away with it, so nothing essential — status, reconnects,
|
||||
live data — may exist *only* as a header control.
|
||||
|
||||
`sse(path, handlers)` handlers:
|
||||
@@ -545,10 +592,17 @@ module.exports = {
|
||||
- When the user adds an entry, its `settings` overlay your `instanceSchema`
|
||||
defaults in that tile's instance settings, the entry's `name` becomes the
|
||||
tile's title, and `moduleApi.variant` carries its `id`.
|
||||
- The tile keeps its variant id and title even if the entry later vanishes
|
||||
from your list (a timer deleted in ProPresenter, a page removed in admin).
|
||||
**Your client decides what that means** — show a clear "no longer
|
||||
configured" state rather than erroring.
|
||||
- **A tile is shown only while its entry is offered.** The shell mounts a
|
||||
tile added from an entry only when that entry (or one of its `aliases`) is
|
||||
in your list right now; otherwise the tile keeps its place in the layout,
|
||||
draws nothing, and appears the moment your list offers it again — a
|
||||
Service timer waits for PCO Plan, a transcript for its channel. So your
|
||||
client is never asked to render an entry you did not offer at mount time.
|
||||
One case is still yours: an entry that vanishes *after* the tile mounted
|
||||
(a timer deleted in ProPresenter mid-service). Render nothing for it — no
|
||||
placeholder, no error — and the shell folds the tile away at its next
|
||||
registry refresh. Never fall back to your default view in its place: five
|
||||
different timers turning into five "All timers" is worse than five gaps.
|
||||
- A client-only module can declare a fixed list as a `tiles` array in
|
||||
`module.json` instead (same entry shape).
|
||||
- An empty list (or no `tiles` at all) keeps today's behavior: one picker
|
||||
|
||||
@@ -103,6 +103,45 @@ enum ProdDash {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Go back to the copy ProdDash set aside before its last update.
|
||||
|
||||
A version that will not stay up is the one state this app cannot talk its
|
||||
way out of: the admin page went down with it, and installing a fresh
|
||||
`.app` does nothing, because seeding only replaces an *older* copy and a
|
||||
broken one carries a higher version. So ProdDash writes `app.previous`
|
||||
before it overwrites itself and drops it again once the new version has
|
||||
stayed up — meaning that folder exists only while an update is still
|
||||
unproven. This puts it back.
|
||||
|
||||
Returns the version restored, or nil when there is nothing to go back to.
|
||||
*/
|
||||
@discardableResult
|
||||
static func restorePrevious(root: URL, log: (String) -> Void) -> String? {
|
||||
let fm = FileManager.default
|
||||
let previous = URL(fileURLWithPath: root.path + ".previous")
|
||||
guard isRoot(previous) else { return nil }
|
||||
let version = shellVersion(root: previous)
|
||||
let named = version.isEmpty ? "(unknown version)" : version
|
||||
let broken = URL(fileURLWithPath: root.path + ".broken")
|
||||
do {
|
||||
try? fm.removeItem(at: broken)
|
||||
if fm.fileExists(atPath: root.path) { try fm.moveItem(at: root, to: broken) }
|
||||
try fm.moveItem(at: previous, to: root)
|
||||
makeWritable(root)
|
||||
try? fm.removeItem(at: broken)
|
||||
log("rolled ProdDash back to \(named) — the copy it replaced")
|
||||
return named
|
||||
} catch {
|
||||
log("could not roll back to the previous copy: \(error.localizedDescription)")
|
||||
// Leave something runnable behind rather than nothing at all.
|
||||
if !fm.fileExists(atPath: root.path), fm.fileExists(atPath: broken.path) {
|
||||
try? fm.moveItem(at: broken, to: root)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Files copied out of a bundle can arrive read-only, and this copy is
|
||||
/// exactly where ProdDash writes its own updates.
|
||||
private static func makeWritable(_ root: URL) {
|
||||
|
||||
@@ -65,6 +65,8 @@ final class ServerController: ObservableObject {
|
||||
private var restartAfterExit = false
|
||||
private var startedAt: Date?
|
||||
private var crashTimes: [Date] = []
|
||||
/// One roll-back per bad update: a second would just swap copies forever.
|
||||
private var rolledBack = false
|
||||
private var pendingRestart: DispatchWorkItem?
|
||||
private var healthTimer: Timer?
|
||||
private var healthMisses = 0
|
||||
@@ -299,7 +301,7 @@ final class ServerController: ObservableObject {
|
||||
child = nil
|
||||
healthy = false
|
||||
let ranFor = startedAt.map { Date().timeIntervalSince($0) } ?? 0
|
||||
if ranFor > 60 { crashTimes.removeAll() } // a long healthy run clears the slate
|
||||
if ranFor > 60 { crashTimes.removeAll(); rolledBack = false } // a long healthy run clears the slate
|
||||
|
||||
if restartAfterExit {
|
||||
restartAfterExit = false
|
||||
@@ -328,6 +330,22 @@ final class ServerController: ObservableObject {
|
||||
crashTimes = crashTimes.filter { Date().timeIntervalSince($0) < 120 }
|
||||
|
||||
guard settings.restartOnCrash, crashTimes.count < 4 else {
|
||||
// Before giving up: if an update is still unproven, the copy it
|
||||
// replaced is sitting right there. Going back beats sitting dark,
|
||||
// and only once — if the old one will not run either, the problem
|
||||
// is not the update.
|
||||
if settings.restartOnCrash, !rolledBack, let root = rootURL,
|
||||
let restored = ProdDash.restorePrevious(root: root, log: { [log] in log.launcher($0) }) {
|
||||
rolledBack = true
|
||||
crashTimes.removeAll()
|
||||
state = .stopped
|
||||
message = "Rolled back to ProdDash \(restored) — starting…"
|
||||
Notifier.shared.post(title: "ProdDash rolled back",
|
||||
body: "The new version would not stay up. Back on \(restored).")
|
||||
refreshEnvironment()
|
||||
start()
|
||||
return
|
||||
}
|
||||
let why = settings.restartOnCrash
|
||||
? "ProdDash stopped \(crashTimes.count) times in two minutes — leaving it stopped."
|
||||
: "ProdDash \(how)."
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Band Lineup module
|
||||
|
||||
The backstage stage board as a ProdDash tile: everyone scheduled on the band /
|
||||
vocal / production teams for the upcoming Planning Center plan, placed into their
|
||||
mic slot with photo, name and mic/pack label. People whose birthday falls in the
|
||||
current service week (Wednesday→Tuesday) get raining confetti and a gold glow.
|
||||
|
||||
This is the display half of the standalone **Band Lineup** Electron app, ported
|
||||
to ProdDash. (The ProPresenter Now/Next half of that app is the separate
|
||||
`propresenter-now-next` module.)
|
||||
|
||||
## How it fills the board
|
||||
|
||||
1. **Slots** are the mic/pack labels you write in admin, one per line, in the
|
||||
order they sit on stage — e.g. `[VOX 2] MIX 2`, `[DRUMS]`, `[MC] MC1`. A
|
||||
blank line is an empty spot.
|
||||
2. Each refresh the server reads the upcoming plan's **team members** (any team
|
||||
whose name matches a line in *Teams to include*), and matches each person to
|
||||
a slot by their Planning Center **position** — the same loose matching the
|
||||
Electron app uses (`matching.js`; `[VOX 2] MIX 2` takes whoever is on *Vox
|
||||
2*). A numbered role only matches the same number, and the `MIX n` part is
|
||||
ignored, so Vox 2 and Vox 3 never swap.
|
||||
3. **Photos and birthdays** come from Planning Center (birthdays via the People
|
||||
API; only month + day is kept). Photos are cached on disk and served by the
|
||||
module; birthdays drive the confetti.
|
||||
|
||||
## Manual photos (people not on the plan)
|
||||
|
||||
Guest speakers, MCs, stage managers — anyone not scheduled in Planning Center —
|
||||
are pinned per slot from the tile's **Edit** button (title bar): set a name,
|
||||
optionally upload a photo, optionally a `MM-DD` birthday. A manual pin overrides
|
||||
the PCO match for that slot and is kept until you Clear it. Uploaded photos live
|
||||
in the ProdDash data directory, never in the repo.
|
||||
|
||||
## Settings
|
||||
|
||||
- **Admin** (server-wide): Planning Center credentials, service type, plan, the
|
||||
teams to include, the stage slots, columns, refresh interval.
|
||||
- **Per tile** (gear): birthday confetti on/off + intensity, a force-birthday
|
||||
test picker, parallax, and show/hide for names and mic labels.
|
||||
|
||||
## Testing without Planning Center
|
||||
|
||||
```bash
|
||||
node tools/pco-band-mock.js 24710 # mock PCO
|
||||
BAND_LINEUP_API_BASE=http://127.0.0.1:24710 node server.js # point the module at it
|
||||
node modules/band-lineup/matching.test.js # slot-matching unit tests
|
||||
```
|
||||
|
||||
Then in `/admin` under Band Lineup enter any Client ID + Secret, pick "Sunday
|
||||
Service", and the board fills — one person (on Drums) has a birthday this week,
|
||||
so their tile rains confetti.
|
||||
@@ -0,0 +1,299 @@
|
||||
// Band Lineup — client. Renders the shared stage board from /state + /stream.
|
||||
// The Edit button opens an editor: an uploaded-photo library you drag (or click)
|
||||
// into slots, plus rename / clear / upload / delete. Edits go to the server and
|
||||
// come back over SSE, so every Band Lineup screen stays in sync. Birthday week
|
||||
// (Wed→Tue) rains confetti.
|
||||
|
||||
const CONFETTI_COLORS = ['#f5c451', '#ff6b81', '#4fd1c5', '#a78bfa', '#ffffff'];
|
||||
|
||||
export default function create({ root, moduleApi }) {
|
||||
let stream = null;
|
||||
let state = null;
|
||||
let editing = false;
|
||||
let choirBtn = null;
|
||||
let selectedLibId = ''; // click-to-place fallback for touch
|
||||
const canvases = new Set();
|
||||
let raf = null;
|
||||
// Auto-scroll the editor while dragging a photo (native DnD won't do it reliably).
|
||||
let dragScrolling = false;
|
||||
let dragY = 0;
|
||||
let dragBody = null;
|
||||
let dragRaf = null;
|
||||
function startDragAutoScroll() {
|
||||
if (dragRaf) return;
|
||||
const tick = () => {
|
||||
if (!dragScrolling || !dragBody || !dragBody.isConnected) { dragRaf = null; return; }
|
||||
const r = dragBody.getBoundingClientRect();
|
||||
const zone = 90; // generous edge band so you don't have to be pixel-accurate
|
||||
let dy = 0;
|
||||
if (dragY < r.top + zone) dy = -Math.ceil((r.top + zone - dragY) / 4) - 3;
|
||||
else if (dragY > r.bottom - zone) dy = Math.ceil((dragY - (r.bottom - zone)) / 4) + 3;
|
||||
if (dy) dragBody.scrollTop += dy;
|
||||
dragRaf = requestAnimationFrame(tick);
|
||||
};
|
||||
dragRaf = requestAnimationFrame(tick);
|
||||
}
|
||||
function stopDragAutoScroll() { dragScrolling = false; if (dragRaf) { cancelAnimationFrame(dragRaf); dragRaf = null; } }
|
||||
|
||||
/* ---------- confetti (always animates) ---------- */
|
||||
function makeParticle(w, h, intensity, seedY) {
|
||||
const ribbon = Math.random() < 0.55;
|
||||
return {
|
||||
x: Math.random() * w, y: seedY != null ? seedY : Math.random() * h,
|
||||
w: ribbon ? 4 + Math.random() * 4 : 5 + Math.random() * 5, h: ribbon ? 9 + Math.random() * 8 : 0, ribbon,
|
||||
color: CONFETTI_COLORS[(Math.random() * CONFETTI_COLORS.length) | 0],
|
||||
rot: Math.random() * Math.PI * 2, vr: -0.14 + Math.random() * 0.28,
|
||||
sway: 0.6 + Math.random() * 1.1, phase: Math.random() * Math.PI * 2,
|
||||
vy: (intensity === 'subtle' ? 0.6 : 1.0) * (1.1 + Math.random() * 1.5),
|
||||
};
|
||||
}
|
||||
function seed(c, w, h) { const base = c._intensity === 'subtle' ? 26 : 64; const n = Math.max(10, Math.round((base * (w * h)) / (300 * 500))); c._parts = Array.from({ length: n }, () => makeParticle(w, h, c._intensity)); }
|
||||
function step(c) {
|
||||
const w = c.clientWidth, h = c.clientHeight; if (!w || !h) return;
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
if (c._w !== w || c._h !== h) { c.width = w * dpr; c.height = h * dpr; c._ctx = c.getContext('2d'); c._ctx.setTransform(dpr, 0, 0, dpr, 0, 0); c._w = w; c._h = h; seed(c, w, h); }
|
||||
const ctx = c._ctx; ctx.clearRect(0, 0, w, h);
|
||||
for (const p of c._parts) {
|
||||
p.phase += 0.03; p.y += p.vy; p.x += Math.sin(p.phase) * p.sway; p.rot += p.vr;
|
||||
if (p.y > h + 16) Object.assign(p, makeParticle(w, h, c._intensity, -16));
|
||||
ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.rot); ctx.fillStyle = p.color;
|
||||
if (p.ribbon) ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h); else { ctx.beginPath(); ctx.arc(0, 0, p.w / 2, 0, Math.PI * 2); ctx.fill(); }
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
function loop() { for (const c of [...canvases]) { if (!c.isConnected) { canvases.delete(c); continue; } step(c); } raf = canvases.size ? requestAnimationFrame(loop) : null; }
|
||||
function addConfetti(host, intensity) { const c = document.createElement('canvas'); c.className = 'bl-confetti'; c._intensity = intensity; host.appendChild(c); canvases.add(c); if (!raf) raf = requestAnimationFrame(loop); }
|
||||
|
||||
/* ---------- helpers ---------- */
|
||||
function initials(name) { const p = String(name || '').trim().split(/\s+/).filter(Boolean).slice(0, 2); return p.length ? p.map((x) => x[0]).join('').toUpperCase() : ''; }
|
||||
// Per-tile Columns wins; 0 falls back to the admin default.
|
||||
function resolveCols() { const per = Number(moduleApi.instanceSettings.columns) || 0; return Math.max(1, per > 0 ? per : (Number(state && state.columns) || 7)); }
|
||||
function el(tag, cls, text) { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
|
||||
function photoNode(person, cls) {
|
||||
if (person && person.photo) { const img = el('img', cls || 'bl-img'); img.alt = ''; img.src = person.photo; img.loading = 'lazy'; return img; }
|
||||
return el('div', 'bl-initials', initials(person && person.name));
|
||||
}
|
||||
async function api(pathStr, opts) { try { return await moduleApi.fetch(pathStr, opts); } catch { return null; } }
|
||||
|
||||
/* ---------- display board ---------- */
|
||||
function stopConfetti() { canvases.clear(); if (raf) { cancelAnimationFrame(raf); raf = null; } }
|
||||
|
||||
function render() {
|
||||
stopConfetti();
|
||||
stopParallax();
|
||||
root.innerHTML = '';
|
||||
if (choirBtn) choirBtn.classList.toggle('active', !!(state && state.choir));
|
||||
if (editing) { renderEditor(); return; }
|
||||
|
||||
const board = el('div', 'bl-board');
|
||||
if (!state) { board.classList.add('bl-message'); board.textContent = 'Connecting…'; root.appendChild(board); return; }
|
||||
|
||||
const s = moduleApi.instanceSettings;
|
||||
const birthdays = s.birthdays !== false;
|
||||
const intensity = s.birthdayIntensity === 'full' ? 'full' : 'subtle';
|
||||
const testIndex = birthdays ? String(s.birthdayTest || '') : '';
|
||||
const cols = resolveCols();
|
||||
const rows = Math.max(1, Math.ceil((state.slots ? state.slots.length : 1) / cols));
|
||||
board.style.setProperty('--bl-cols', cols);
|
||||
board.style.setProperty('--bl-rows', rows);
|
||||
|
||||
for (const slot of state.slots || []) {
|
||||
const cell = el('div', 'bl-slot');
|
||||
const person = slot.person;
|
||||
if (person) {
|
||||
const tile = el('div', 'bl-tile');
|
||||
const photo = el('div', 'bl-photo');
|
||||
photo.appendChild(photoNode(person));
|
||||
const celebrating = birthdays && (person.celebrating || (testIndex !== '' && testIndex === String(slot.index)));
|
||||
if (celebrating) { tile.classList.add('bl-birthday', `bl-birthday-${intensity}`); addConfetti(photo, intensity); }
|
||||
if (s.showNames !== false && person.name) photo.appendChild(el('div', 'bl-name', person.name));
|
||||
tile.appendChild(photo);
|
||||
if (s.showLabels !== false && slot.label) tile.appendChild(el('div', 'bl-label', slot.label));
|
||||
cell.appendChild(tile);
|
||||
} else {
|
||||
const empty = el('div', 'bl-tile bl-empty');
|
||||
empty.appendChild(el('div', 'bl-label bl-label-empty', slot.label || `Slot ${slot.index + 1}`));
|
||||
cell.appendChild(empty);
|
||||
}
|
||||
board.appendChild(cell);
|
||||
}
|
||||
root.appendChild(board);
|
||||
applyParallax(board, s.parallax !== false);
|
||||
reportStatus();
|
||||
}
|
||||
|
||||
/* ---------- parallax: each photo drifts on its own, automatically ---------- */
|
||||
let parallaxRaf = null;
|
||||
let parallaxImgs = [];
|
||||
function stopParallax() { if (parallaxRaf) { cancelAnimationFrame(parallaxRaf); parallaxRaf = null; } parallaxImgs = []; }
|
||||
function applyParallax(board, on) {
|
||||
stopParallax();
|
||||
const imgs = [...board.querySelectorAll('.bl-img')];
|
||||
if (!on) { imgs.forEach((i) => { i.style.transform = ''; }); return; }
|
||||
// Give each image its own gentle, endless drift — different amplitude, speed and phase,
|
||||
// so the wall never looks frozen and no two photos move together. No mouse needed.
|
||||
parallaxImgs = imgs.map((img) => ({
|
||||
img,
|
||||
ax: 4 + Math.random() * 5, ay: 4 + Math.random() * 5,
|
||||
// Very slow: a full drift cycle takes roughly a minute, so the motion only
|
||||
// registers if you actually watch for it.
|
||||
sx: 0.03 + Math.random() * 0.05, sy: 0.03 + Math.random() * 0.05,
|
||||
px: Math.random() * Math.PI * 2, py: Math.random() * Math.PI * 2,
|
||||
}));
|
||||
const tick = () => {
|
||||
const t = performance.now() / 1000;
|
||||
for (const p of parallaxImgs) {
|
||||
if (!p.img.isConnected) continue;
|
||||
p.img.style.transform = `scale(1.08) translate(${(Math.sin(t * p.sx + p.px) * p.ax).toFixed(2)}px, ${(Math.cos(t * p.sy + p.py) * p.ay).toFixed(2)}px)`;
|
||||
}
|
||||
parallaxRaf = requestAnimationFrame(tick);
|
||||
};
|
||||
parallaxRaf = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function reportStatus() {
|
||||
if (!state) return moduleApi.setStatus('connecting', 'Connecting…');
|
||||
if (state.configured && !state.reachable) return moduleApi.setStatus('error', state.lastError || 'Planning Center unreachable');
|
||||
return moduleApi.setStatus('ok', state.plan?.dates ? `Plan: ${state.plan.dates}` : 'Ready');
|
||||
}
|
||||
|
||||
/* ---------- editor ---------- */
|
||||
function renderEditor() {
|
||||
// No top bar: the photo source is managed in Admin (Google Drive link). Edit mode is just the
|
||||
// board on top and the picture picker (library) below it.
|
||||
const wrap = el('div', 'bl-editor bl-editor-nobar');
|
||||
const body = el('div', 'bl-editor-body');
|
||||
// Feed the drag auto-scroller the pointer position anywhere over the editor.
|
||||
body.addEventListener('dragover', (e) => { dragY = e.clientY; });
|
||||
|
||||
// Slots (drop targets)
|
||||
const boardEl = el('div', 'bl-edit-board');
|
||||
boardEl.style.setProperty('--bl-cols', resolveCols());
|
||||
for (const slot of state?.slots || []) {
|
||||
const cell = el('div', 'bl-edit-slot');
|
||||
cell.dataset.index = String(slot.index);
|
||||
const photo = el('div', 'bl-edit-photo');
|
||||
photo.appendChild(slot.person ? photoNode(slot.person) : el('div', 'bl-edit-drop', '+'));
|
||||
cell.appendChild(photo);
|
||||
// An occupied card is a drag SOURCE too — move/swap it to another slot.
|
||||
if (slot.person) {
|
||||
cell.draggable = true;
|
||||
cell.addEventListener('dragstart', (e) => {
|
||||
e.dataTransfer.setData('text/plain', 'slot:' + slot.index); e.dataTransfer.effectAllowed = 'move';
|
||||
dragBody = body; dragScrolling = true; startDragAutoScroll();
|
||||
});
|
||||
cell.addEventListener('dragend', stopDragAutoScroll);
|
||||
}
|
||||
const nameRow = el('div', 'bl-edit-namerow');
|
||||
const nameBtn = el('button', 'bl-edit-name', (slot.person && slot.person.name) || 'Name…');
|
||||
nameBtn.addEventListener('click', () => renameSlot(slot.index, slot.person ? slot.person.name : ''));
|
||||
nameRow.appendChild(nameBtn);
|
||||
if (slot.person) { const clr = el('button', 'bl-edit-clear', '×'); clr.title = 'Clear'; clr.addEventListener('click', () => api(`/slot/${slot.index}/clear`, { method: 'POST' })); nameRow.appendChild(clr); }
|
||||
cell.appendChild(nameRow);
|
||||
cell.appendChild(el('div', 'bl-edit-label', slot.label || `Slot ${slot.index + 1}`));
|
||||
// drop
|
||||
cell.addEventListener('dragover', (e) => { e.preventDefault(); cell.classList.add('bl-drop-over'); });
|
||||
cell.addEventListener('dragleave', () => cell.classList.remove('bl-drop-over'));
|
||||
cell.addEventListener('drop', (e) => {
|
||||
e.preventDefault(); stopDragAutoScroll(); cell.classList.remove('bl-drop-over');
|
||||
const data = e.dataTransfer.getData('text/plain') || '';
|
||||
if (data.startsWith('lib:')) assign(slot.index, data.slice(4));
|
||||
else if (data.startsWith('slot:')) { const from = data.slice(5); if (from !== String(slot.index)) api(`/move?from=${from}&to=${slot.index}`, { method: 'POST' }); }
|
||||
});
|
||||
cell.addEventListener('click', (e) => { if (e.target.closest('.bl-edit-name') || e.target.closest('.bl-edit-clear')) return; if (selectedLibId) assign(slot.index, selectedLibId); });
|
||||
boardEl.appendChild(cell);
|
||||
}
|
||||
body.appendChild(boardEl);
|
||||
|
||||
// Library
|
||||
const lib = el('div', 'bl-library');
|
||||
const list = state?.library || [];
|
||||
if (!list.length) lib.appendChild(el('div', 'bl-lib-empty', 'No photos yet — set a Google Drive folder link in Admin, or hit Sync. Then drag a photo onto a slot.'));
|
||||
for (const person of list) {
|
||||
const item = el('div', 'bl-lib-item'); item.draggable = true; item.dataset.id = person.id;
|
||||
if (person.id === selectedLibId) item.classList.add('bl-lib-selected');
|
||||
const ph = el('div', 'bl-lib-photo'); ph.appendChild(photoNode(person, 'bl-lib-img')); item.appendChild(ph);
|
||||
const nm = el('button', 'bl-lib-name', person.name || 'Name…');
|
||||
nm.addEventListener('click', (e) => { e.stopPropagation(); renameLibrary(person.id, person.name); });
|
||||
item.appendChild(nm);
|
||||
const del = el('button', 'bl-lib-del', '×'); del.title = 'Delete photo';
|
||||
del.addEventListener('click', (e) => { e.stopPropagation(); if (confirm(`Delete ${person.name || 'this photo'} from the library?`)) api(`/library/${person.id}/delete`, { method: 'POST' }); });
|
||||
item.appendChild(del);
|
||||
item.addEventListener('dragstart', (e) => {
|
||||
e.dataTransfer.setData('text/plain', 'lib:' + person.id); e.dataTransfer.effectAllowed = 'copy';
|
||||
dragBody = body; dragScrolling = true; startDragAutoScroll();
|
||||
});
|
||||
item.addEventListener('dragend', stopDragAutoScroll);
|
||||
item.addEventListener('click', () => { selectedLibId = selectedLibId === person.id ? '' : person.id; renderEditor(); });
|
||||
lib.appendChild(item);
|
||||
}
|
||||
body.appendChild(lib);
|
||||
wrap.appendChild(body);
|
||||
|
||||
root.innerHTML = '';
|
||||
root.appendChild(wrap);
|
||||
}
|
||||
|
||||
function assign(slotIndex, personId) {
|
||||
selectedLibId = '';
|
||||
api(`/assign?slot=${slotIndex}&personId=${encodeURIComponent(personId)}`, { method: 'POST' });
|
||||
}
|
||||
function renameSlot(slotIndex, currentName) {
|
||||
const name = prompt('Name for this spot (leave blank to remove a name-only spot):', currentName || '');
|
||||
if (name === null) return;
|
||||
api(`/slot/${slotIndex}/name?name=${encodeURIComponent(name.trim())}`, { method: 'POST' });
|
||||
}
|
||||
function renameLibrary(id, currentName) {
|
||||
const name = prompt('Rename this photo:', currentName || '');
|
||||
if (name === null) return;
|
||||
api(`/library/${id}?name=${encodeURIComponent(name.trim())}`, { method: 'POST' });
|
||||
}
|
||||
|
||||
/* ---------- lifecycle ---------- */
|
||||
return {
|
||||
start() {
|
||||
moduleApi.setStatus('connecting', 'Connecting…');
|
||||
const SYNC_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.5 9a9 9 0 0 1 14.9-3.4L23 10M1 14l4.6 4.4A9 9 0 0 0 20.5 15"/></svg>';
|
||||
const EDIT_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>';
|
||||
moduleApi.header.addButton({
|
||||
icon: SYNC_ICON,
|
||||
title: 'Sync',
|
||||
onClick() { moduleApi.setStatus('connecting', 'Syncing with Planning Center…'); api('/sync', { method: 'POST' }); },
|
||||
});
|
||||
const editBtn = moduleApi.header.addButton({ icon: EDIT_ICON, title: 'Edit board', onClick() { editing = !editing; editBtn.classList.toggle('active', editing); render(); } });
|
||||
// Columns −/+ (touch-friendly; replaces the gear number field).
|
||||
const MINUS_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M5 12h14"/></svg>';
|
||||
const PLUS_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>';
|
||||
const bumpCols = (d) => {
|
||||
const max = Math.max(1, state && state.slots ? state.slots.length : 12);
|
||||
const next = Math.max(1, Math.min(max, resolveCols() + d));
|
||||
moduleApi.saveInstanceSettings({ columns: next });
|
||||
render();
|
||||
};
|
||||
moduleApi.header.addButton({ icon: MINUS_ICON, title: 'Fewer columns', onClick() { bumpCols(-1); } });
|
||||
moduleApi.header.addButton({ icon: PLUS_ICON, title: 'More columns', onClick() { bumpCols(1); } });
|
||||
const CHOIR_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="18" r="2.5"/><circle cx="17" cy="16" r="2.5"/><path d="M8.5 18V5l11-2v13"/><path d="M8.5 8.5l11-2"/></svg>';
|
||||
choirBtn = moduleApi.header.addButton({ icon: CHOIR_ICON, title: 'Choir', onClick() { api('/choir', { method: 'POST' }); } });
|
||||
moduleApi.fetch('/state').then((r) => r.json()).then((d) => { state = d.state; render(); }).catch(() => {});
|
||||
stream = moduleApi.sse('/stream', {
|
||||
error() { reportStatus(); },
|
||||
events: {
|
||||
state(e) {
|
||||
try { state = JSON.parse(e.data); } catch { return; }
|
||||
// While editing, don't yank a rename prompt out from under the user:
|
||||
// re-render the editor only (state carries board + library).
|
||||
render();
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
onResize() {},
|
||||
stop() {
|
||||
if (stream) { stream.close(); stream = null; }
|
||||
stopConfetti();
|
||||
stopParallax();
|
||||
stopDragAutoScroll();
|
||||
root.innerHTML = '';
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Slot ↔ Planning Center position matching for the Band Lineup module.
|
||||
*
|
||||
* A "slot" is a mic/pack label the operator wrote for a spot on stage, e.g.
|
||||
* "[LEADER, VOX 1, ACOUSTIC] MIX 1". A scheduled person carries their PCO
|
||||
* team-position name, e.g. "Vox 1" or "Acoustic / Worship Leader". We assign
|
||||
* people to slots by loose token overlap — the same logic the backstage
|
||||
* Electron app used, lifted here unchanged so the board matches what the booth
|
||||
* is used to. Pure functions only: no I/O, so this file is unit-testable.
|
||||
*/
|
||||
|
||||
function normalizeTeamName(value) {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
/** Does a person's PCO team count as a band/stage team we display? `keywords` comes from admin config. */
|
||||
function isAllowedTeamName(value, keywords) {
|
||||
const normalized = normalizeTeamName(value);
|
||||
if (!normalized) return false;
|
||||
return (keywords || []).some((kw) => {
|
||||
const k = normalizeTeamName(kw);
|
||||
return k && (normalized === k || normalized.includes(k));
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeLabel(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Fold a single token to a canonical instrument/role word (drops mic/pack noise, pluralises down). */
|
||||
function normalizeToken(token) {
|
||||
const normalized = String(token || '').trim().toLowerCase();
|
||||
if (!normalized) return '';
|
||||
if (/^\d+$/.test(normalized)) return '';
|
||||
if (['mix', 'mic', 'slot', 'channel', 'ch', 'pack', 'wireless', 'input', 'headset'].includes(normalized)) return '';
|
||||
if (['vox', 'vocal', 'vocalist', 'vocalists', 'vocals', 'singer', 'singers'].includes(normalized)) return 'vocals';
|
||||
if (['keys', 'keyboard', 'keyboards'].includes(normalized)) return 'keys';
|
||||
if (['drum', 'drums', 'drummer', 'drummers'].includes(normalized)) return 'drums';
|
||||
if (['perc', 'percussionist', 'percussionists'].includes(normalized)) return 'percussion';
|
||||
if (['gtr', 'guitar', 'guitars'].includes(normalized)) return 'guitar';
|
||||
if (['electric', 'electricguitar'].includes(normalized)) return 'electric';
|
||||
if (['acoustic', 'acousticguitar'].includes(normalized)) return 'acoustic';
|
||||
if (['b', 'bandleader', 'leader', 'lead'].includes(normalized)) return normalized === 'b' ? '' : 'leader';
|
||||
if (['worship', 'calltoworship'].includes(normalized)) return normalized === 'calltoworship' ? 'call' : normalized;
|
||||
if (normalized.endsWith('s') && normalized.length > 4) return normalized.slice(0, -1);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function labelTokens(value) {
|
||||
const normalized = normalizeLabel(value);
|
||||
if (!normalized) return [];
|
||||
const tokens = normalized.split(' ').map((t) => normalizeToken(t)).filter(Boolean);
|
||||
if (!tokens.length && normalized) return [normalized];
|
||||
return [...new Set(tokens)];
|
||||
}
|
||||
|
||||
/**
|
||||
* The role part of a slot label to match on: the bracketed content when present
|
||||
* ("[VOX 2] MIX 2" → "VOX 2, "), else the whole label ("STAGE MANAGER"). This
|
||||
* drops the "MIX n / MC n / HEADSET" mic-assignment noise so a mic number can't
|
||||
* be mistaken for a position number.
|
||||
*/
|
||||
function slotRoleText(label) {
|
||||
const brackets = String(label || '').match(/\[([^\]]*)\]/g);
|
||||
if (brackets && brackets.length) return brackets.map((b) => b.slice(1, -1)).join(', ');
|
||||
return String(label || '');
|
||||
}
|
||||
|
||||
/** The set of standalone numbers in a label ("vox 2" → {"2"}). */
|
||||
function numbersIn(value) {
|
||||
return new Set(normalizeLabel(value).split(' ').filter((t) => /^\d+$/.test(t)));
|
||||
}
|
||||
|
||||
/** 0–~140 similarity between a slot label and a PCO position name; higher is a better fit. */
|
||||
function scoreMatch(slotLabel, positionLabel) {
|
||||
const slotNormalized = normalizeLabel(slotLabel);
|
||||
const positionNormalized = normalizeLabel(positionLabel);
|
||||
if (!slotNormalized || !positionNormalized) return 0;
|
||||
// A numbered role only matches the same number: "Vox 3" must not take "Vox 2".
|
||||
const slotNums = numbersIn(slotLabel);
|
||||
const posNums = numbersIn(positionLabel);
|
||||
if (slotNums.size && posNums.size && ![...posNums].some((n) => slotNums.has(n))) return 0;
|
||||
if (slotNormalized === positionNormalized) return 120;
|
||||
if (slotNormalized.includes(positionNormalized) || positionNormalized.includes(slotNormalized)) return 95;
|
||||
|
||||
const slotTokens = labelTokens(slotLabel);
|
||||
const positionTokens = labelTokens(positionLabel);
|
||||
if (!slotTokens.length || !positionTokens.length) return 0;
|
||||
|
||||
const positionSet = new Set(positionTokens);
|
||||
const slotSet = new Set(slotTokens);
|
||||
const overlap = slotTokens.filter((t) => positionSet.has(t));
|
||||
if (!overlap.length) return 0;
|
||||
|
||||
const overlapCount = overlap.length;
|
||||
const slotCoverage = overlapCount / slotSet.size;
|
||||
const positionCoverage = overlapCount / positionSet.size;
|
||||
const sharedPhrase = overlap.join(' ');
|
||||
|
||||
let score = Math.round(slotCoverage * 70 + positionCoverage * 20 + overlapCount * 4);
|
||||
if (slotCoverage === 1) score += 18;
|
||||
if (positionCoverage === 1) score += 10;
|
||||
if (sharedPhrase && slotNormalized.includes(sharedPhrase) && positionNormalized.includes(sharedPhrase)) score += 8;
|
||||
return score;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign scheduled members to the ordered slot list. Greedy by best score, each
|
||||
* member used once. Returns an array aligned to `slots`, each entry the chosen
|
||||
* member or null. `MIN_SCORE` keeps a weak coincidence from claiming a slot.
|
||||
*/
|
||||
const MIN_SCORE = 60;
|
||||
function assignMembersToSlots(slots, members) {
|
||||
const pool = members.slice();
|
||||
return slots.map((slotLabel) => {
|
||||
const role = slotRoleText(slotLabel);
|
||||
let bestIndex = -1;
|
||||
let bestScore = MIN_SCORE - 1;
|
||||
for (let i = 0; i < pool.length; i += 1) {
|
||||
const score = scoreMatch(role, pool[i].label);
|
||||
if (score > bestScore) { bestScore = score; bestIndex = i; }
|
||||
}
|
||||
if (bestIndex < 0) return null;
|
||||
return pool.splice(bestIndex, 1)[0];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Best library photo for a Planning Center person, matched by name so Sync can use the folder
|
||||
* photo instead of the PCO headshot. Handles full names, "First L" and first-name-only files,
|
||||
* preferring the most specific. Returns the entry, or null when there's no confident, UNAMBIGUOUS
|
||||
* match (e.g. two people share a first name and the photo is first-name-only) — then PCO's photo is used.
|
||||
* `entries` is [{ ...whatever, name }].
|
||||
*/
|
||||
function bestLibraryMatch(pcoName, entries) {
|
||||
const target = normalizeLabel(pcoName);
|
||||
if (!target) return null;
|
||||
const T = target.split(' ').filter(Boolean);
|
||||
const scoreOf = (name) => {
|
||||
const ln = normalizeLabel(name);
|
||||
if (!ln) return 0;
|
||||
if (ln === target) return 100; // exact full name
|
||||
const L = ln.split(' ').filter(Boolean);
|
||||
if (!L.length || !T.length) return 0;
|
||||
// Align the library name against a leading run of the person's name, token by token.
|
||||
// Every library token must equal the person's token in that position — except the LAST
|
||||
// library token, which may be an initial/prefix ("m" of "Moniz"). This makes "Brian M"
|
||||
// match "Brian Moniz" and "Alex" match "Alex Gaitan", but NOT "Alex" → "Alexander Zinni".
|
||||
let ok = true; let exact = 0; let usedPrefix = false;
|
||||
for (let k = 0; k < L.length; k += 1) {
|
||||
const lt = L[k]; const tt = T[k];
|
||||
if (!tt) { ok = false; break; }
|
||||
if (lt === tt) { exact += 1; continue; }
|
||||
if (k === L.length - 1 && tt.startsWith(lt)) { usedPrefix = true; continue; }
|
||||
ok = false; break;
|
||||
}
|
||||
if (ok) {
|
||||
if (L.length >= 2) return usedPrefix ? 86 : 92; // "brian m" / "alex gaitan"
|
||||
if (exact === 1) return 90; // first name matches exactly ("alex")
|
||||
if (usedPrefix) return 45; // "alex" vs "alexander" — too ambiguous
|
||||
}
|
||||
// Otherwise: a library surname that appears anywhere in the person's name.
|
||||
const last = L[L.length - 1];
|
||||
if (last && last.length >= 3 && T.includes(last)) return 78;
|
||||
return 0;
|
||||
};
|
||||
let best = null;
|
||||
let bestScore = 0;
|
||||
let tie = false;
|
||||
for (const e of entries) {
|
||||
const s = scoreOf(e.name);
|
||||
if (s > bestScore) { bestScore = s; best = e; tie = false; }
|
||||
else if (s === bestScore && s > 0) tie = true;
|
||||
}
|
||||
if (bestScore < 60 || tie) return null; // no confident / unambiguous winner → PCO photo
|
||||
return best;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizeTeamName,
|
||||
isAllowedTeamName,
|
||||
bestLibraryMatch,
|
||||
normalizeLabel,
|
||||
normalizeToken,
|
||||
labelTokens,
|
||||
slotRoleText,
|
||||
numbersIn,
|
||||
scoreMatch,
|
||||
assignMembersToSlots,
|
||||
MIN_SCORE,
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
'use strict';
|
||||
const assert = require('assert');
|
||||
const { assignMembersToSlots, isAllowedTeamName, slotRoleText, scoreMatch } = require('./matching');
|
||||
|
||||
const SLOTS = [
|
||||
'[LEADER, VOX 1, ACOUSTIC] MIX 1', '[VOX 2] MIX 2', '[VOX 3] MIX 3', '[VOX 4] MIX 4',
|
||||
'[GUITAR, LEAD] MIX 5', '[BASS] MIX 6', '[VOX 5] MIX 7', '[PIANO]', '[DRUMS]',
|
||||
'[KEYS 2]', '[CALL TO WORSHIP] MC2', 'STAGE MANAGER', '[MC] MC1', '[SPEAKER] HEADSET',
|
||||
];
|
||||
const M = (label) => ({ label, name: label });
|
||||
const members = [
|
||||
M('Vox 3'), M('Vox 2'), M('Vox 1'), M('Guitar / Lead'), M('Bass'), M('Piano'), M('Drums'), M('MC'), M('Keys 2'),
|
||||
];
|
||||
const out = assignMembersToSlots(SLOTS, members).map((m) => (m ? m.label : null));
|
||||
|
||||
// Numbered vocals land in the RIGHT slot (this is the swap bug we fixed).
|
||||
assert.strictEqual(out[0], 'Vox 1', 'slot 0 → Vox 1');
|
||||
assert.strictEqual(out[1], 'Vox 2', 'slot 1 → Vox 2');
|
||||
assert.strictEqual(out[2], 'Vox 3', 'slot 2 → Vox 3');
|
||||
assert.strictEqual(out[3], null, 'slot 3 (Vox 4) unfilled — nobody scheduled');
|
||||
assert.strictEqual(out[4], 'Guitar / Lead', 'slot 4 → Guitar/Lead');
|
||||
assert.strictEqual(out[5], 'Bass', 'slot 5 → Bass');
|
||||
assert.strictEqual(out[6], null, 'slot 6 (Vox 5) unfilled');
|
||||
assert.strictEqual(out[7], 'Piano', 'slot 7 → Piano');
|
||||
assert.strictEqual(out[8], 'Drums', 'slot 8 → Drums');
|
||||
assert.strictEqual(out[9], 'Keys 2', 'slot 9 → Keys 2');
|
||||
assert.strictEqual(out[12], 'MC', 'slot 12 → MC');
|
||||
|
||||
// A different number never matches.
|
||||
assert.strictEqual(scoreMatch(slotRoleText('[VOX 2] MIX 2'), 'Vox 3'), 0, 'Vox 3 must not match Vox 2 slot');
|
||||
// Mic number no longer pollutes: Vox 7 must not grab "[VOX 5] MIX 7".
|
||||
assert.strictEqual(scoreMatch(slotRoleText('[VOX 5] MIX 7'), 'Vox 7'), 0, 'Vox 7 must not match the MIX 7 slot');
|
||||
|
||||
// Team allow-list (singular stems match plurals/variants via substring).
|
||||
assert.ok(isAllowedTeamName('Band', ['band', 'vocal']));
|
||||
assert.ok(isAllowedTeamName('Vocals', ['band', 'vocal']));
|
||||
assert.ok(isAllowedTeamName('Vocal Team', ['band', 'vocal']));
|
||||
assert.ok(!isAllowedTeamName('Video', ['band', 'vocal', 'production']));
|
||||
|
||||
console.log('matching.test.js: all assertions passed ✓');
|
||||
console.log('assignment:', JSON.stringify(out));
|
||||
|
||||
// --- bestLibraryMatch: folder-photo → PCO person by name ---
|
||||
{
|
||||
const { bestLibraryMatch } = require('./matching');
|
||||
const name = (n) => (e) => (e ? e.name : null);
|
||||
const lib = [
|
||||
{ id: 'a', name: 'Alex' }, { id: 'g', name: 'AlexG' }, { id: 'bm', name: 'Brian M' },
|
||||
{ id: 'c', name: 'Courtney' }, { id: 'gn', name: 'Gaitan' }, { id: 'wes', name: 'Wes' },
|
||||
];
|
||||
const pick = (n, list = lib) => { const e = bestLibraryMatch(n, list); return e ? e.name : null; };
|
||||
assert.strictEqual(pick('Alex Gaitan'), 'Alex', 'first name matches "Alex"');
|
||||
assert.strictEqual(pick('Brian Moniz'), 'Brian M', '"Brian M" matches "Brian Moniz"');
|
||||
assert.strictEqual(pick('Courtney Maranda'), 'Courtney', 'first name matches');
|
||||
assert.strictEqual(pick('Alexander Zinni'), null, '"Alex" must NOT grab "Alexander Zinni"');
|
||||
assert.strictEqual(pick('Wes Carter'), 'Wes', 'first name matches "Wes"');
|
||||
// surname-named photo
|
||||
assert.strictEqual(pick('Someone Gaitan'), 'Gaitan', 'surname photo matches by last name');
|
||||
// two people share a first name + only a first-name photo → ambiguous only if two matches tie;
|
||||
// here a single "Alex" photo still maps to whoever's first name is exactly Alex.
|
||||
assert.strictEqual(pick('Alex Johnson'), 'Alex', 'first-name photo maps to any exact-first-name person');
|
||||
// exact full name beats a first-name photo
|
||||
assert.strictEqual(pick('Alex Gaitan', [{ id: 'x', name: 'Alex Gaitan' }, { id: 'a', name: 'Alex' }]), 'Alex Gaitan', 'exact full name wins');
|
||||
// tie → no match (two identical-scoring photos)
|
||||
assert.strictEqual(bestLibraryMatch('Chris Smith', [{ id: '1', name: 'Chris' }, { id: '2', name: 'Chris' }]), null, 'ambiguous tie → null');
|
||||
console.log('bestLibraryMatch: all assertions passed ✓');
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
{
|
||||
"id": "band-lineup",
|
||||
"name": "Band Lineup",
|
||||
"version": "1.11.0",
|
||||
"proddash": ">=1.4.0",
|
||||
"description": "Stage lineup photos with mic/pack labels — a drag-in photo library plus optional Planning Center auto-fill",
|
||||
"client": "client.js",
|
||||
"server": "server.js",
|
||||
"style": "style.css",
|
||||
"minSize": {
|
||||
"w": 4,
|
||||
"h": 3
|
||||
},
|
||||
"defaultSize": {
|
||||
"w": 8,
|
||||
"h": 5
|
||||
},
|
||||
"configGroups": {
|
||||
"Planning Center credentials": {
|
||||
"collapsed": "whenSet",
|
||||
"help": "Create a Personal Access Token at api.planningcenteronline.com and paste its Client ID + Secret here, or paste an OAuth 2.0 access token instead. Reading photos and birthdays needs People access on the token. Saved secrets never reach browsers."
|
||||
},
|
||||
"Stage": {
|
||||
"help": "One mic/pack label per line, in the order they sit on stage. Scheduled people are matched into these by their Planning Center position (e.g. a line '[VOX 2] MIX 2' takes whoever is scheduled on Vox 2). Blank lines are empty spots."
|
||||
},
|
||||
"Photo library": {
|
||||
"help": "Paste a Google Drive folder share link (set the folder to 'Anyone with the link → Viewer'). Every image is imported (file name → person's name) and kept in sync — add or remove photos in Drive and the board follows. A local folder path also works. If Drive can't be read, the board falls back to Planning Center photos."
|
||||
}
|
||||
},
|
||||
"configSchema": {
|
||||
"appId": {
|
||||
"type": "string",
|
||||
"label": "Client ID",
|
||||
"default": "",
|
||||
"group": "Planning Center credentials"
|
||||
},
|
||||
"secret": {
|
||||
"type": "password",
|
||||
"label": "Secret",
|
||||
"default": "",
|
||||
"group": "Planning Center credentials"
|
||||
},
|
||||
"accessToken": {
|
||||
"type": "password",
|
||||
"label": "OAuth 2.0 access token (instead of Client ID + Secret)",
|
||||
"default": "",
|
||||
"group": "Planning Center credentials"
|
||||
},
|
||||
"serviceTypeId": {
|
||||
"type": "select",
|
||||
"label": "Service type",
|
||||
"default": "",
|
||||
"optionsRoute": "/service-types"
|
||||
},
|
||||
"planId": {
|
||||
"type": "select",
|
||||
"label": "Plan",
|
||||
"default": "",
|
||||
"options": [
|
||||
{
|
||||
"value": "",
|
||||
"label": "Automatic — today's plan, else the next upcoming"
|
||||
}
|
||||
],
|
||||
"optionsRoute": "/plans",
|
||||
"optionsDependsOn": "serviceTypeId"
|
||||
},
|
||||
"teams": {
|
||||
"type": "text",
|
||||
"label": "Teams to include",
|
||||
"default": "Band\nWorship\nVocal\nProduction\nCommunication",
|
||||
"help": "One team name (or keyword) per line. A person on any matching Planning Center team is eligible for the board."
|
||||
},
|
||||
"slots": {
|
||||
"type": "text",
|
||||
"label": "Stage slots",
|
||||
"default": "[LEADER, VOX 1, ACOUSTIC] MIX 1\n[VOX 2] MIX 2\n[VOX 3] MIX 3\n[VOX 4] MIX 4\n[GUITAR, LEAD] MIX 5\n[BASS] MIX 6\n[VOX 5] MIX 7\n[PIANO]\n[DRUMS]\n[KEYS 2]\n[CALL TO WORSHIP] MC2\nSTAGE MANAGER\n[MC] MC1\n[SPEAKER] HEADSET",
|
||||
"group": "Stage"
|
||||
},
|
||||
"columns": {
|
||||
"type": "number",
|
||||
"label": "Columns",
|
||||
"default": 7,
|
||||
"group": "Stage",
|
||||
"help": "Default slots per row (each screen can still adjust with the − / + buttons in its title bar)."
|
||||
},
|
||||
"photoFolder": {
|
||||
"type": "string",
|
||||
"label": "Google Drive folder link",
|
||||
"default": "",
|
||||
"group": "Photo library",
|
||||
"help": "e.g. https://drive.google.com/drive/folders/… (a local folder path also works). Leave blank to use Planning Center photos only."
|
||||
}
|
||||
},
|
||||
"instanceGroups": {
|
||||
"Birthday": {
|
||||
"toggle": "birthdays"
|
||||
}
|
||||
},
|
||||
"instanceSchema": {
|
||||
"showNames": {
|
||||
"type": "boolean",
|
||||
"label": "Names",
|
||||
"default": true
|
||||
},
|
||||
"showLabels": {
|
||||
"type": "boolean",
|
||||
"label": "Mic labels",
|
||||
"default": true
|
||||
},
|
||||
"parallax": {
|
||||
"type": "boolean",
|
||||
"label": "Parallax motion",
|
||||
"default": true
|
||||
},
|
||||
"birthdays": {
|
||||
"type": "switch",
|
||||
"label": "Birthday confetti",
|
||||
"default": true,
|
||||
"group": "Birthday"
|
||||
},
|
||||
"birthdayIntensity": {
|
||||
"type": "select",
|
||||
"label": "Intensity",
|
||||
"default": "subtle",
|
||||
"group": "Birthday",
|
||||
"showWhen": "birthdays",
|
||||
"options": [
|
||||
{
|
||||
"value": "subtle",
|
||||
"label": "Subtle"
|
||||
},
|
||||
{
|
||||
"value": "full",
|
||||
"label": "Full"
|
||||
}
|
||||
]
|
||||
},
|
||||
"birthdayTest": {
|
||||
"type": "select",
|
||||
"label": "Force on (test)",
|
||||
"default": "",
|
||||
"group": "Birthday",
|
||||
"showWhen": "birthdays",
|
||||
"options": [
|
||||
{
|
||||
"value": "",
|
||||
"label": "Off"
|
||||
}
|
||||
],
|
||||
"optionsRoute": "/people"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,764 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Band Lineup — server part.
|
||||
*
|
||||
* Owns the shared stage board and the uploaded-photo library, plus the one
|
||||
* Planning Center connection. The board is server-side and global: whoever
|
||||
* edits it (drag a library photo onto a slot, rename, clear) changes it for
|
||||
* every Band Lineup tile on every screen, over SSE. Planning Center sync is an
|
||||
* optional auto-fill — it only fills slots nobody has placed by hand. Browsers
|
||||
* never talk to Planning Center directly.
|
||||
*
|
||||
* GET /state → { state } (board + library; instant paint)
|
||||
* GET /stream → SSE `state` events (change-detected, heartbeat)
|
||||
* GET /library → { people } (the drag-in photo library)
|
||||
* GET /service-types → { options } (admin "Service type" select)
|
||||
* GET /plans → { options } (admin "Plan" select)
|
||||
* GET /people → { options } (gear "Force birthday" select)
|
||||
* GET /photo/<file> → image bytes (library + cached PCO photos)
|
||||
* POST /library/<id> → rename / set birthday / replace photo (raw image body; ?name=&birthday=)
|
||||
* POST /library/<id>/delete→ remove from the library and any slot
|
||||
* POST /assign → ?slot=&personId= place a library person in a slot
|
||||
* POST /move → ?from=&to= move or swap one slot's occupant
|
||||
* POST /slot/<index>/name → ?name= rename the occupant (or make a name-only spot)
|
||||
* POST /slot/<index>/clear → empty the slot
|
||||
* POST /choir → ?on=1|0 toggle the extra CHOIR slot (shared)
|
||||
* POST /sync → run a Planning Center + photo-folder sync now
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const { isAllowedTeamName, assignMembersToSlots, bestLibraryMatch } = require('./matching');
|
||||
|
||||
const DEFAULT_API_BASE = 'https://api.planningcenteronline.com';
|
||||
const API_BASE = String(process.env.BAND_LINEUP_API_BASE || process.env.PCO_PLAN_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
|
||||
const SERVICES = '/services/v2';
|
||||
const PEOPLE = '/people/v2';
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 12000;
|
||||
const PHOTO_TIMEOUT_MS = 15000;
|
||||
const MIN_POLL_S = 10;
|
||||
const RETRY_MS = 15000;
|
||||
const HEARTBEAT_MS = 15000;
|
||||
const MODULE_ID = 'band-lineup';
|
||||
const MAX_UPLOAD = 20 * 1024 * 1024;
|
||||
|
||||
let current = null; // set by init(), read by routes; rebuilt together on remount
|
||||
|
||||
/* ── data directory (library + board + photo cache live here, never in the repo) ── */
|
||||
|
||||
function resolveDataDir() {
|
||||
const home = os.homedir();
|
||||
const fromEnv = String(process.env.PRODDASH_DATA_DIR || '').trim();
|
||||
const root = path.join(__dirname, '..', '..');
|
||||
if (fromEnv) return path.resolve(root, fromEnv);
|
||||
if (process.platform === 'win32') return path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'ProdDash');
|
||||
if (process.platform === 'darwin') return path.join(home, 'Library', 'Application Support', 'ProdDash');
|
||||
return path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'proddash');
|
||||
}
|
||||
|
||||
/* ── small helpers ─────────────────────────────────────────────────── */
|
||||
|
||||
function lines(text) { return String(text || '').split('\n').map((s) => s.trim()); }
|
||||
function nonEmptyLines(text) { return lines(text).filter(Boolean); }
|
||||
function localYmd(d) { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; }
|
||||
function birthdayMmdd(raw) {
|
||||
const s = String(raw || '').trim();
|
||||
const full = s.match(/^\d{4}-(\d{2})-(\d{2})$/);
|
||||
if (full) return `${full[1]}-${full[2]}`;
|
||||
return /^\d{2}-\d{2}$/.test(s) ? s : '';
|
||||
}
|
||||
function birthdayWeekSet(now = new Date()) {
|
||||
const daysSinceWed = (now.getDay() - 3 + 7) % 7;
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - daysSinceWed);
|
||||
const set = new Set();
|
||||
for (let i = 0; i < 7; i += 1) {
|
||||
const d = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i);
|
||||
set.add(`${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
function extFromContentType(ct) {
|
||||
const t = String(ct || '').toLowerCase();
|
||||
if (t.includes('png')) return 'png';
|
||||
if (t.includes('webp')) return 'webp';
|
||||
if (t.includes('gif')) return 'gif';
|
||||
return 'jpg';
|
||||
}
|
||||
function contentTypeForFile(file) {
|
||||
const e = path.extname(file).toLowerCase();
|
||||
if (e === '.png') return 'image/png';
|
||||
if (e === '.webp') return 'image/webp';
|
||||
if (e === '.gif') return 'image/gif';
|
||||
return 'image/jpeg';
|
||||
}
|
||||
function json(res, status, body) {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
function readJsonFile(file, fallback) { try { return JSON.parse(fs.readFileSync(file, 'utf8')) || fallback; } catch { return fallback; } }
|
||||
function newId() { return crypto.randomBytes(6).toString('hex'); }
|
||||
function photoUrlFor(file) { return file ? `/api/modules/${MODULE_ID}/photo/${encodeURIComponent(file)}` : ''; }
|
||||
|
||||
/* ── Planning Center client ────────────────────────────────────────── */
|
||||
|
||||
function createPcoClient(config) {
|
||||
const token = String(config.accessToken || '').trim();
|
||||
const appId = String(config.appId || '').trim();
|
||||
const secret = String(config.secret || '');
|
||||
let auth = '';
|
||||
if (token) auth = `Bearer ${token}`;
|
||||
else if (appId && secret) auth = 'Basic ' + Buffer.from(`${appId}:${secret}`).toString('base64');
|
||||
const configured = Boolean(auth);
|
||||
|
||||
async function get(target, params) {
|
||||
const url = new URL(/^https?:\/\//.test(target) ? target : API_BASE + target);
|
||||
for (const [k, v] of Object.entries(params || {})) url.searchParams.set(k, String(v));
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url, { headers: { Authorization: auth, Accept: 'application/json' }, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
|
||||
} catch (err) {
|
||||
const e = new Error(`Planning Center unreachable: ${err?.message || err}`); e.network = true; throw e;
|
||||
}
|
||||
if (!response.ok) { const e = new Error(`Planning Center ${response.status}`); e.status = response.status; throw e; }
|
||||
const text = await response.text();
|
||||
return text ? JSON.parse(text) : {};
|
||||
}
|
||||
async function getAll(target, params) {
|
||||
const data = []; const included = []; let next = null;
|
||||
let query = { per_page: 100, ...(params || {}) }; let guard = 0;
|
||||
do {
|
||||
const body = next ? await get(next) : await get(target, query);
|
||||
if (Array.isArray(body.data)) data.push(...body.data);
|
||||
if (Array.isArray(body.included)) included.push(...body.included);
|
||||
next = body?.links?.next || null; query = null; guard += 1;
|
||||
} while (next && guard < 50);
|
||||
return { data, included };
|
||||
}
|
||||
return { configured, get, getAll };
|
||||
}
|
||||
|
||||
/* ── Google Drive (public "anyone with the link" folder, no API key) ── */
|
||||
|
||||
const DRIVE_TIMEOUT_MS = 30000;
|
||||
const DRIVE_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/537.36';
|
||||
|
||||
/** Pull the folder id out of a Drive share link (or '' if it isn't a Drive folder link). */
|
||||
function parseDriveFolderId(s) {
|
||||
const str = String(s || '');
|
||||
if (!/drive\.google\.com/i.test(str)) return '';
|
||||
const m = /\/folders\/([A-Za-z0-9_-]{10,})/.exec(str) || /[?&]id=([A-Za-z0-9_-]{10,})/.exec(str);
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
|
||||
/** List a public folder's files via Drive's embedded folder view — [{ id, name }]. */
|
||||
async function driveList(folderId) {
|
||||
const resp = await fetch(`https://drive.google.com/embeddedfolderview?id=${encodeURIComponent(folderId)}#list`, {
|
||||
headers: { 'User-Agent': DRIVE_UA }, redirect: 'follow', signal: AbortSignal.timeout(DRIVE_TIMEOUT_MS),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`Drive folder list ${resp.status}`);
|
||||
const html = await resp.text();
|
||||
const out = []; const seen = new Set();
|
||||
const re = /id="entry-([A-Za-z0-9_-]{20,})"[\s\S]*?flip-entry-title[^>]*>([^<]+)</g;
|
||||
let m;
|
||||
while ((m = re.exec(html))) {
|
||||
const id = m[1]; const name = m[2].trim();
|
||||
if (id && !seen.has(id)) { seen.add(id); out.push({ id, name }); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Download one public Drive file at full resolution (falls back to a large thumbnail). */
|
||||
async function driveFetchImage(fileId) {
|
||||
const base = 'https://drive.usercontent.google.com/download';
|
||||
const opts = { headers: { 'User-Agent': DRIVE_UA }, redirect: 'follow', signal: AbortSignal.timeout(DRIVE_TIMEOUT_MS) };
|
||||
let resp = await fetch(`${base}?id=${fileId}&export=download`, opts);
|
||||
let ct = String(resp.headers.get('content-type') || '');
|
||||
if (resp.ok && ct.startsWith('image/')) return { buf: Buffer.from(await resp.arrayBuffer()), ext: extFromContentType(ct) };
|
||||
// Large-file virus-scan interstitial: grab the confirm token and retry.
|
||||
if (ct.includes('text/html')) {
|
||||
const html = await resp.text();
|
||||
const conf = /name="confirm"\s+value="([^"]+)"/.exec(html) || /[?&]confirm=([0-9A-Za-z_-]+)/.exec(html);
|
||||
const uuid = /name="uuid"\s+value="([^"]+)"/.exec(html);
|
||||
if (conf) {
|
||||
resp = await fetch(`${base}?id=${fileId}&export=download&confirm=${conf[1]}${uuid ? `&uuid=${uuid[1]}` : ''}`, opts);
|
||||
ct = String(resp.headers.get('content-type') || '');
|
||||
if (resp.ok && ct.startsWith('image/')) return { buf: Buffer.from(await resp.arrayBuffer()), ext: extFromContentType(ct) };
|
||||
}
|
||||
}
|
||||
const th = await fetch(`https://drive.google.com/thumbnail?id=${fileId}&sz=w2000`, opts);
|
||||
const tct = String(th.headers.get('content-type') || '');
|
||||
if (th.ok && tct.startsWith('image/')) return { buf: Buffer.from(await th.arrayBuffer()), ext: extFromContentType(tct) };
|
||||
throw new Error('not an image');
|
||||
}
|
||||
|
||||
/* ── the module ────────────────────────────────────────────────────── */
|
||||
|
||||
module.exports = {
|
||||
init({ config, log }) {
|
||||
const streams = new Set();
|
||||
const dataDir = path.join(resolveDataDir(), MODULE_ID);
|
||||
const photoDir = path.join(dataDir, 'photos');
|
||||
const libraryPath = path.join(dataDir, 'library.json');
|
||||
const boardPath = path.join(dataDir, 'board.json');
|
||||
try { fs.mkdirSync(photoDir, { recursive: true }); } catch (err) { log(`cannot create ${photoDir}: ${err?.message || err}`); }
|
||||
|
||||
const slots = nonEmptyLines(config.slots).length ? lines(config.slots) : [];
|
||||
const teamKeywords = nonEmptyLines(config.teams).length ? nonEmptyLines(config.teams) : ['band', 'worship', 'vocal', 'production', 'communication'];
|
||||
const columns = Math.max(1, Number(config.columns) || 7);
|
||||
const serviceTypeId = String(config.serviceTypeId || '').trim();
|
||||
const planIdCfg = String(config.planId || '').trim();
|
||||
// Photos folder: the admin "Photos folder" field wins when set; otherwise it's whatever the
|
||||
// tile's "Choose folder…" picker saved into module state. Admin can't host a native picker
|
||||
// (the shell renders plain fields), so the picker lives in the tile as the no-typing option.
|
||||
const settingsPath = path.join(dataDir, 'settings.json');
|
||||
const settings = readJsonFile(settingsPath, {}) || {};
|
||||
const photoFolder = String(config.photoFolder || settings.photoFolder || '').trim(); // admin field (Drive link or local path)
|
||||
let choirOn = Boolean(settings.choir); // optional extra "CHOIR" slot, toggled from the tile
|
||||
function saveSettings() { try { fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); } catch (err) { log(`save settings failed: ${err?.message || err}`); } }
|
||||
|
||||
const CHOIR_LABEL = 'CHOIR';
|
||||
// The board's slots plus an extra CHOIR spot when the toggle is on. Its index is slots.length,
|
||||
// so its assignment persists in board.json across toggles.
|
||||
const effectiveSlots = () => (choirOn ? slots.concat([CHOIR_LABEL]) : slots);
|
||||
|
||||
const pco = createPcoClient(config);
|
||||
|
||||
// Persisted, shared editor state.
|
||||
let library = readJsonFile(libraryPath, {}); // { id: { name, file, birthday } }
|
||||
let board = readJsonFile(boardPath, {}); // { slotIndex: { personId, name } }
|
||||
function saveLibrary() { try { fs.writeFileSync(libraryPath, JSON.stringify(library, null, 2)); } catch (err) { log(`save library failed: ${err?.message || err}`); } }
|
||||
function saveBoard() { try { fs.writeFileSync(boardPath, JSON.stringify(board, null, 2)); } catch (err) { log(`save board failed: ${err?.message || err}`); } }
|
||||
|
||||
let latest = clearedState();
|
||||
let lastFrame = '';
|
||||
let celebrateTimer = null;
|
||||
let drivePollTimer = null;
|
||||
let watcher = null;
|
||||
let watchDebounce = null;
|
||||
let stopped = false;
|
||||
let syncing = false;
|
||||
|
||||
function clearedState() {
|
||||
return {
|
||||
configured: pco.configured, serviceTypeSet: Boolean(serviceTypeId),
|
||||
reachable: false, lastError: '', lastFetched: 0, columns,
|
||||
plan: null,
|
||||
slots: effectiveSlots().map((label, index) => ({ index, label, person: null })),
|
||||
library: [], choir: choirOn,
|
||||
};
|
||||
}
|
||||
|
||||
function libraryList() {
|
||||
return Object.entries(library)
|
||||
.map(([id, v]) => ({ id, name: v.name || '', photo: photoUrlFor(v.file || ''), birthday: v.birthday || '' }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
|
||||
}
|
||||
|
||||
function broadcast() {
|
||||
if (!streams.size) return;
|
||||
let frame; try { frame = JSON.stringify(latest); } catch { return; }
|
||||
if (frame === lastFrame) return;
|
||||
lastFrame = frame;
|
||||
const payload = `event: state\ndata: ${frame}\n\n`;
|
||||
for (const res of streams) { try { res.write(payload); } catch { streams.delete(res); } }
|
||||
}
|
||||
|
||||
async function listPlans(stId) {
|
||||
const [future, past] = await Promise.all([
|
||||
pco.get(`${SERVICES}/service_types/${encodeURIComponent(stId)}/plans`, { filter: 'future', order: 'sort_date', per_page: 6 }),
|
||||
pco.get(`${SERVICES}/service_types/${encodeURIComponent(stId)}/plans`, { filter: 'past', order: '-sort_date', per_page: 2 }),
|
||||
]);
|
||||
const rows = [...(future.data || []), ...(past.data || [])];
|
||||
return rows.map((rec) => ({
|
||||
id: String(rec.id), sortDate: Date.parse(rec.attributes?.sort_date || '') || 0,
|
||||
title: String(rec.attributes?.title || rec.attributes?.series_title || '').trim(),
|
||||
dates: String(rec.attributes?.dates || rec.attributes?.short_dates || '').trim(),
|
||||
}));
|
||||
}
|
||||
function pickPlan(plans) {
|
||||
if (planIdCfg) return plans.find((p) => p.id === planIdCfg) || { id: planIdCfg, title: '', dates: '' };
|
||||
const todayY = localYmd(new Date());
|
||||
const today = plans.find((p) => p.sortDate && localYmd(new Date(p.sortDate)) === todayY);
|
||||
if (today) return today;
|
||||
const now = Date.now();
|
||||
const upcoming = plans.filter((p) => p.sortDate >= now - 12 * 3600000).sort((a, b) => a.sortDate - b.sortDate);
|
||||
return upcoming[0] || plans.sort((a, b) => b.sortDate - a.sortDate)[0] || null;
|
||||
}
|
||||
|
||||
async function cachePcoPhoto(personId, url) {
|
||||
if (!url) return '';
|
||||
const hash = crypto.createHash('sha1').update(url).digest('hex').slice(0, 8);
|
||||
const existing = fs.existsSync(photoDir) ? fs.readdirSync(photoDir).find((f) => f.startsWith(`pco-${personId}-${hash}.`)) : null;
|
||||
if (existing) return existing;
|
||||
try {
|
||||
const resp = await fetch(url, { signal: AbortSignal.timeout(PHOTO_TIMEOUT_MS) });
|
||||
if (!resp.ok) return '';
|
||||
const buf = Buffer.from(await resp.arrayBuffer());
|
||||
if (!buf.length) return '';
|
||||
const file = `pco-${personId}-${hash}.${extFromContentType(resp.headers.get('content-type'))}`;
|
||||
fs.writeFileSync(path.join(photoDir, file), buf);
|
||||
return file;
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a folder of photos into the library and keep it in sync: new files are added
|
||||
* (filename → name), changed files refreshed, and files removed from the folder dropped
|
||||
* from the library (and any slot they filled). Manually-uploaded photos (no folderKey) are
|
||||
* left alone, so a folder and hand-uploads can coexist. Returns true if anything changed.
|
||||
*/
|
||||
const IMG_RE = /\.(jpe?g|png|webp|gif)$/i;
|
||||
const cleanNameFromFile = (f) => f.replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').trim();
|
||||
function extOf(f) { const m = f.match(IMG_RE); const e = (m ? m[1] : 'jpg').toLowerCase(); return e === 'jpeg' ? 'jpg' : e; }
|
||||
function scanFolder() {
|
||||
if (!photoFolder) return false;
|
||||
let names; try { names = fs.readdirSync(photoFolder); } catch { return false; } // folder gone/unreadable
|
||||
const files = names.filter((n) => IMG_RE.test(n));
|
||||
const present = new Set(files);
|
||||
const byKey = new Map();
|
||||
for (const [id, v] of Object.entries(library)) if (v.folderKey) byKey.set(v.folderKey, id);
|
||||
let changed = false;
|
||||
for (const f of files) {
|
||||
let mtime = 0; try { mtime = fs.statSync(path.join(photoFolder, f)).mtimeMs; } catch { continue; }
|
||||
const id = byKey.get(f);
|
||||
try {
|
||||
if (id) {
|
||||
const v = library[id];
|
||||
if (v.mtime === mtime && v.file) continue; // unchanged
|
||||
const dest = `lib-${id}.${extOf(f)}`;
|
||||
fs.copyFileSync(path.join(photoFolder, f), path.join(photoDir, dest));
|
||||
if (v.file && v.file !== dest) { try { fs.unlinkSync(path.join(photoDir, v.file)); } catch { /* ignore */ } }
|
||||
v.file = dest; v.mtime = mtime; changed = true; // keep any in-app rename/birthday
|
||||
} else {
|
||||
const nid = newId();
|
||||
const dest = `lib-${nid}.${extOf(f)}`;
|
||||
fs.copyFileSync(path.join(photoFolder, f), path.join(photoDir, dest));
|
||||
library[nid] = { name: cleanNameFromFile(f), file: dest, birthday: '', folderKey: f, mtime };
|
||||
changed = true;
|
||||
}
|
||||
} catch (err) { log(`folder import of ${f} failed: ${err?.message || err}`); }
|
||||
}
|
||||
for (const [id, v] of Object.entries(library)) {
|
||||
if (v.folderKey && !present.has(v.folderKey)) {
|
||||
if (v.file) { try { fs.unlinkSync(path.join(photoDir, v.file)); } catch { /* ignore */ } }
|
||||
delete library[id];
|
||||
for (const [slot, b] of Object.entries(board)) if (b && b.personId === id) delete board[slot];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
// De-duplicate: a hand-uploaded photo that shares a name with a folder photo is dropped
|
||||
// (the folder wins), and any slot it filled repoints to the folder photo. Fixes the case
|
||||
// where someone uploaded people by hand and then pointed at a folder of the same people.
|
||||
const folderByName = new Map();
|
||||
for (const [id, v] of Object.entries(library)) if (v.folderKey && v.name) folderByName.set(normName(v.name), id);
|
||||
for (const [id, v] of Object.entries(library)) {
|
||||
if (v.folderKey || !v.name) continue;
|
||||
const keepId = folderByName.get(normName(v.name));
|
||||
if (!keepId) continue;
|
||||
if (v.file) { try { fs.unlinkSync(path.join(photoDir, v.file)); } catch { /* ignore */ } }
|
||||
for (const [slot, b] of Object.entries(board)) if (b && b.personId === id) b.personId = keepId;
|
||||
delete library[id];
|
||||
changed = true;
|
||||
}
|
||||
if (changed) { saveLibrary(); saveBoard(); }
|
||||
return changed;
|
||||
}
|
||||
|
||||
// Import a public Google Drive folder into the library (file name → name), keeping it in sync.
|
||||
// Keyed by Drive file id (folderKey "drive:<id>"). On a list failure we keep the last-good library.
|
||||
async function scanDrive(folderId) {
|
||||
let files;
|
||||
try { files = await driveList(folderId); } catch (err) { log(`Drive list failed: ${err?.message || err}`); return false; }
|
||||
files = files.filter((f) => IMG_RE.test(f.name));
|
||||
const present = new Set(files.map((f) => `drive:${f.id}`));
|
||||
const have = new Set();
|
||||
for (const v of Object.values(library)) if (v.folderKey && v.folderKey.startsWith('drive:')) have.add(v.folderKey);
|
||||
let changed = false;
|
||||
for (const f of files) {
|
||||
const key = `drive:${f.id}`;
|
||||
if (have.has(key)) continue; // already imported (by Drive file id)
|
||||
let img;
|
||||
try { img = await driveFetchImage(f.id); } catch (err) { log(`Drive image ${f.name} failed: ${err?.message || err}`); continue; }
|
||||
const nid = newId();
|
||||
const dest = `lib-${nid}.${img.ext}`;
|
||||
try { fs.writeFileSync(path.join(photoDir, dest), img.buf); library[nid] = { name: cleanNameFromFile(f.name), file: dest, birthday: '', folderKey: key }; changed = true; }
|
||||
catch (err) { log(`write ${dest} failed: ${err?.message || err}`); }
|
||||
}
|
||||
for (const [id, v] of Object.entries(library)) {
|
||||
if (v.folderKey && v.folderKey.startsWith('drive:') && !present.has(v.folderKey)) {
|
||||
if (v.file) { try { fs.unlinkSync(path.join(photoDir, v.file)); } catch { /* ignore */ } }
|
||||
delete library[id];
|
||||
for (const [slot, b] of Object.entries(board)) if (b && b.personId === id) delete board[slot];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
// Drive photo wins over a same-named hand-upload (repoint any slot to it).
|
||||
const driveByName = new Map();
|
||||
for (const [id, v] of Object.entries(library)) if (v.folderKey && v.folderKey.startsWith('drive:') && v.name) driveByName.set(normName(v.name), id);
|
||||
for (const [id, v] of Object.entries(library)) {
|
||||
if ((v.folderKey && v.folderKey.startsWith('drive:')) || !v.name) continue;
|
||||
const keepId = driveByName.get(normName(v.name));
|
||||
if (!keepId) continue;
|
||||
if (v.file) { try { fs.unlinkSync(path.join(photoDir, v.file)); } catch { /* ignore */ } }
|
||||
for (const [slot, b] of Object.entries(board)) if (b && b.personId === id) b.personId = keepId;
|
||||
delete library[id]; changed = true;
|
||||
}
|
||||
if (changed) { saveLibrary(); saveBoard(); }
|
||||
return changed;
|
||||
}
|
||||
|
||||
// Import the configured photo source — a Drive folder link, or a local folder path.
|
||||
async function syncPhotos() {
|
||||
const fid = parseDriveFolderId(photoFolder);
|
||||
if (fid) return scanDrive(fid);
|
||||
if (photoFolder) return scanFolder();
|
||||
return false;
|
||||
}
|
||||
|
||||
function startWatch() {
|
||||
if (parseDriveFolderId(photoFolder)) return; // Drive folders are polled, not fs.watched
|
||||
if (!photoFolder) return;
|
||||
try {
|
||||
watcher = fs.watch(photoFolder, { persistent: false }, () => {
|
||||
clearTimeout(watchDebounce);
|
||||
watchDebounce = setTimeout(() => { if (!stopped && scanFolder()) rebuildLocal(); }, 800);
|
||||
});
|
||||
log(`watching photo folder ${photoFolder}`);
|
||||
} catch (err) { log(`cannot watch photo folder: ${err?.message || err}`); }
|
||||
}
|
||||
function setChoir(on) { choirOn = Boolean(on); settings.choir = choirOn; saveSettings(); rebuildLocal(); }
|
||||
|
||||
// Remove cached PCO photos no longer referenced. Library files (lib-*) are never pruned here.
|
||||
function prunePcoPhotos(keep) {
|
||||
let files; try { files = fs.readdirSync(photoDir); } catch { return; }
|
||||
for (const f of files) { if (f.startsWith('pco-') && !keep.has(f)) { try { fs.unlinkSync(path.join(photoDir, f)); } catch { /* ignore */ } } }
|
||||
}
|
||||
|
||||
const normName = (s) => String(s || '').trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
|
||||
// Resolve one board slot to a display person (or null). The board is the source of truth —
|
||||
// there is no live PCO overlay, so clearing a slot stays cleared until the next Sync. A PCO
|
||||
// entry prefers a same-named library photo, so uploading a photo updates the board without a re-sync.
|
||||
function resolveSlot(i, weekSet, keep) {
|
||||
const e = board[String(i)];
|
||||
if (!e) return null;
|
||||
let name = '', file = '', birthday = '', manual = true, personId = '';
|
||||
if (e.pco) {
|
||||
// The photo (folder photo, or PCO headshot) was chosen at Sync time — use it as-is.
|
||||
manual = false; personId = e.pco.personId || '';
|
||||
name = String(e.pco.name || '').trim();
|
||||
file = e.pco.file || '';
|
||||
birthday = e.pco.birthday || '';
|
||||
} else if (e.personId) {
|
||||
const lib = library[e.personId];
|
||||
if (!lib) return null; // its library photo was deleted → slot is empty
|
||||
personId = e.personId;
|
||||
name = String(e.name || lib.name || '').trim();
|
||||
file = lib.file || ''; birthday = lib.birthday || '';
|
||||
} else if (e.name) {
|
||||
name = String(e.name).trim();
|
||||
} else return null;
|
||||
if (file) keep.add(file);
|
||||
return { name, photo: photoUrlFor(file), birthday, celebrating: Boolean(birthday) && weekSet.has(birthday), manual, personId };
|
||||
}
|
||||
|
||||
// Repaint the display from the board (no Planning Center calls). Cheap — used after every edit
|
||||
// and on a slow timer so a birthday week rolling over lights up on its own.
|
||||
function renderBoard() {
|
||||
const weekSet = birthdayWeekSet();
|
||||
const keep = new Set();
|
||||
const outSlots = effectiveSlots().map((label, i) => ({ index: i, label, person: resolveSlot(i, weekSet, keep) }));
|
||||
for (const v of Object.values(library)) if (v.file) keep.add(v.file);
|
||||
prunePcoPhotos(keep);
|
||||
latest = {
|
||||
configured: pco.configured, serviceTypeSet: Boolean(serviceTypeId),
|
||||
reachable: latest.reachable, lastError: latest.lastError || '', lastFetched: latest.lastFetched || 0,
|
||||
columns, plan: latest.plan || null, slots: outSlots, library: libraryList(), choir: choirOn,
|
||||
};
|
||||
}
|
||||
|
||||
function rebuildLocal() { renderBoard(); broadcast(); }
|
||||
|
||||
// Pull the current plan from Planning Center and write matched people INTO the board. Fills only
|
||||
// slots nobody placed by hand; drops PCO entries no longer scheduled; leaves manual placements be.
|
||||
async function syncNow() {
|
||||
if (syncing) return;
|
||||
syncing = true;
|
||||
try {
|
||||
await syncPhotos(); // import the photo source (Drive folder or local path) on every sync
|
||||
if (pco.configured && serviceTypeId) {
|
||||
const plan = pickPlan(await listPlans(serviceTypeId));
|
||||
latest.plan = plan ? { title: plan.title, dates: plan.dates } : null;
|
||||
if (!plan) { latest.reachable = true; latest.lastError = 'No plan found for that service type.'; }
|
||||
else {
|
||||
|
||||
const tm = await pco.getAll(`${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}/plans/${encodeURIComponent(plan.id)}/team_members`, { include: 'person,team' });
|
||||
const byKey = new Map((tm.included || []).map((e) => [`${e.type}:${e.id}`, e]));
|
||||
const members = []; const seen = new Set();
|
||||
for (const m of tm.data || []) {
|
||||
const status = String(m.attributes?.status || '').trim();
|
||||
if (status === 'D' || /^declined$/i.test(status)) continue;
|
||||
const personId = String(m.relationships?.person?.data?.id || '');
|
||||
const team = byKey.get(`Team:${String(m.relationships?.team?.data?.id || '')}`);
|
||||
if (!isAllowedTeamName(String(team?.attributes?.name || ''), teamKeywords)) continue;
|
||||
const person = byKey.get(`Person:${personId}`);
|
||||
members.push({
|
||||
personId,
|
||||
name: String(person?.attributes?.full_name || person?.attributes?.name || m.attributes?.name || 'Unknown').trim() || 'Unknown',
|
||||
label: String(m.attributes?.team_position_name || '').trim(),
|
||||
photoUrl: String(person?.attributes?.photo_url || person?.attributes?.photo_thumbnail_url || '').trim(),
|
||||
});
|
||||
if (personId) seen.add(personId);
|
||||
}
|
||||
const bdays = new Map();
|
||||
for (const id of seen) { try { const b = await pco.get(`${PEOPLE}/people/${encodeURIComponent(id)}`, { 'fields[Person]': 'birthdate' }); bdays.set(id, birthdayMmdd(b?.data?.attributes?.birthdate)); } catch { bdays.set(id, ''); } }
|
||||
for (const m of members) m.birthday = bdays.get(m.personId) || '';
|
||||
|
||||
const libEntries = Object.entries(library).map(([id, v]) => ({ id, name: v.name, file: v.file }));
|
||||
|
||||
// Slots a person is sitting in by hand — don't fill them, and don't place that same person twice.
|
||||
const eff = effectiveSlots();
|
||||
const openIdx = []; const manualNames = new Set();
|
||||
for (let i = 0; i < eff.length; i += 1) {
|
||||
const e = board[String(i)];
|
||||
if (e && (e.personId || e.name)) {
|
||||
const nm = e.personId ? (library[e.personId] && library[e.personId].name) : e.name;
|
||||
if (nm) manualNames.add(normName(nm));
|
||||
} else openIdx.push(i);
|
||||
}
|
||||
const pool = members.filter((m) => !manualNames.has(normName(m.name)));
|
||||
const assigned = assignMembersToSlots(openIdx.map((i) => eff[i]), pool);
|
||||
|
||||
for (let k = 0; k < openIdx.length; k += 1) {
|
||||
const i = openIdx[k]; const m = assigned[k];
|
||||
if (m) {
|
||||
const lib = bestLibraryMatch(m.name, libEntries); // folder photo wins over the PCO headshot
|
||||
const file = lib && lib.file ? lib.file : await cachePcoPhoto(m.personId, m.photoUrl);
|
||||
board[String(i)] = { pco: { name: m.name, file, birthday: m.birthday || '', personId: m.personId } };
|
||||
} else if (board[String(i)] && board[String(i)].pco) {
|
||||
delete board[String(i)]; // was synced, no longer on the plan
|
||||
}
|
||||
}
|
||||
saveBoard();
|
||||
latest.reachable = true; latest.lastError = ''; latest.lastFetched = Date.now();
|
||||
log(`synced ${members.length} scheduled from ${plan.dates || plan.id}`);
|
||||
} // end else (plan found)
|
||||
} // end if (Planning Center configured)
|
||||
} catch (err) {
|
||||
latest.reachable = false; latest.lastError = err?.message || String(err);
|
||||
log(`sync failed: ${err?.message || err}`);
|
||||
} finally {
|
||||
syncing = false;
|
||||
renderBoard(); broadcast();
|
||||
}
|
||||
}
|
||||
|
||||
const heartbeat = setInterval(() => { for (const res of streams) { try { res.write(': ping\n\n'); } catch { streams.delete(res); } } }, HEARTBEAT_MS);
|
||||
heartbeat.unref?.();
|
||||
// Repaint on a slow timer so a birthday week (Wed→Tue) rolling over lights up unattended.
|
||||
celebrateTimer = setInterval(() => { if (!stopped) rebuildLocal(); }, 15 * 60 * 1000);
|
||||
celebrateTimer.unref?.();
|
||||
// A Drive folder can't be fs.watched — re-import it on a slow timer so photos added/removed
|
||||
// in Drive show up without a manual Sync.
|
||||
drivePollTimer = setInterval(() => {
|
||||
if (stopped || !parseDriveFolderId(photoFolder)) return;
|
||||
syncPhotos().then((ch) => { if (ch) rebuildLocal(); }).catch(() => {});
|
||||
}, 5 * 60 * 1000);
|
||||
drivePollTimer.unref?.();
|
||||
|
||||
if (pco.configured && serviceTypeId) log('ready — press Sync to load the plan from Planning Center');
|
||||
else log('manual board (add Planning Center credentials + a service type in /admin to enable Sync)');
|
||||
rebuildLocal(); // paint the saved board immediately
|
||||
startWatch(); // local folders keep in sync via fs.watch (Drive folders are polled)
|
||||
syncPhotos().then((ch) => { if (ch) rebuildLocal(); }).catch((err) => log(`initial photo import failed: ${err?.message || err}`));
|
||||
|
||||
current = {
|
||||
streams, getLatest: () => latest, pco, listPlans, photoDir,
|
||||
library, board, saveLibrary, saveBoard, rebuildLocal, refresh: () => syncNow(), slots,
|
||||
photoUrlFor, libraryList,
|
||||
setChoir, choirOn: () => choirOn,
|
||||
};
|
||||
|
||||
return {
|
||||
stop() {
|
||||
stopped = true; if (celebrateTimer) clearInterval(celebrateTimer); if (drivePollTimer) clearInterval(drivePollTimer); clearInterval(heartbeat);
|
||||
if (watcher) { try { watcher.close(); } catch { /* gone */ } watcher = null; }
|
||||
if (watchDebounce) clearTimeout(watchDebounce);
|
||||
for (const res of streams) { try { res.end(); } catch { /* gone */ } }
|
||||
streams.clear(); if (current && current.streams === streams) current = null;
|
||||
},
|
||||
health() {
|
||||
if (!pco.configured || !serviceTypeId) return { status: 'ok', message: 'Manual board (add Planning Center to enable Sync)' };
|
||||
if (latest.lastError) return { status: 'error', message: latest.lastError };
|
||||
if (latest.reachable) return { status: 'ok', message: latest.plan?.dates ? `Synced: ${latest.plan.dates}` : 'Synced' };
|
||||
return { status: 'ok', message: 'Ready — press Sync to load the plan' };
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
routes({ log }) {
|
||||
// Read an image request body (raw bytes) with a size cap.
|
||||
function readBody(req, cb) {
|
||||
const chunks = []; let size = 0; let bad = false;
|
||||
req.on('data', (c) => { size += c.length; if (size > MAX_UPLOAD) { bad = true; req.destroy(); } else chunks.push(c); });
|
||||
req.on('end', () => cb(bad ? null : Buffer.concat(chunks)));
|
||||
req.on('error', () => cb(null));
|
||||
}
|
||||
|
||||
return {
|
||||
'GET /state': (req, res) => json(res, 200, { state: current ? current.getLatest() : null }),
|
||||
|
||||
'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');
|
||||
res.write(`event: state\ndata: ${JSON.stringify(current ? current.getLatest() : null)}\n\n`);
|
||||
if (!current) return void res.end();
|
||||
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
|
||||
current.streams.add(res);
|
||||
req.on('close', () => current && current.streams.delete(res));
|
||||
},
|
||||
|
||||
'GET /library': (req, res) => json(res, 200, { people: current ? current.libraryList() : [] }),
|
||||
|
||||
// Update a library entry: ?name=&birthday= and/or a replacement image body. /library/<id>/delete removes it.
|
||||
'POST /library/*': (req, res) => {
|
||||
if (!current) return json(res, 503, { error: 'Module not running.' });
|
||||
const rest = decodeURIComponent(req.wildcard || '').replace(/^\/+/, '');
|
||||
const del = /\/delete$/.test(rest);
|
||||
const id = rest.replace(/\/delete$/, '').trim();
|
||||
const entry = current.library[id];
|
||||
if (!entry) return json(res, 404, { error: 'No such library photo.' });
|
||||
if (del) {
|
||||
if (entry.file) { try { fs.unlinkSync(path.join(current.photoDir, entry.file)); } catch { /* ignore */ } }
|
||||
delete current.library[id];
|
||||
for (const [slot, v] of Object.entries(current.board)) if (v && v.personId === id) delete current.board[slot];
|
||||
current.saveLibrary(); current.saveBoard(); current.rebuildLocal();
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
const q = new URLSearchParams(req.search || '');
|
||||
readBody(req, (buf) => {
|
||||
if (q.has('name')) entry.name = String(q.get('name') || '').trim();
|
||||
if (q.has('birthday')) entry.birthday = birthdayMmdd(q.get('birthday'));
|
||||
if (buf && buf.length) {
|
||||
if (entry.file) { try { fs.unlinkSync(path.join(current.photoDir, entry.file)); } catch { /* ignore */ } }
|
||||
const file = `lib-${id}.${extFromContentType(req.headers['content-type'])}`;
|
||||
try { fs.writeFileSync(path.join(current.photoDir, file), buf); entry.file = file; } catch (err) { return json(res, 500, { error: `write failed: ${err?.message || err}` }); }
|
||||
}
|
||||
current.saveLibrary(); current.rebuildLocal();
|
||||
json(res, 200, { ok: true });
|
||||
});
|
||||
},
|
||||
|
||||
// Toggle the extra CHOIR slot on/off (?on=1|0, or omit to flip). Shared across all screens.
|
||||
'POST /choir': (req, res) => {
|
||||
if (!current) return json(res, 503, { error: 'Module not running.' });
|
||||
const p = new URLSearchParams(req.search || '');
|
||||
const on = p.has('on') ? (p.get('on') === '1' || p.get('on') === 'true') : !current.choirOn();
|
||||
current.setChoir(on);
|
||||
json(res, 200, { ok: true, choir: on });
|
||||
},
|
||||
|
||||
// Move (or swap) one slot's occupant to another slot: ?from=<i>&to=<j>.
|
||||
'POST /move': (req, res) => {
|
||||
if (!current) return json(res, 503, { error: 'Module not running.' });
|
||||
const p = new URLSearchParams(req.search || '');
|
||||
const from = String(p.get('from') || ''); const to = String(p.get('to') || '');
|
||||
if (!/^\d+$/.test(from) || !/^\d+$/.test(to) || from === to) return json(res, 400, { error: 'Bad move.' });
|
||||
const a = current.board[from]; const b = current.board[to];
|
||||
if (a === undefined) delete current.board[to]; else current.board[to] = a;
|
||||
if (b === undefined) delete current.board[from]; else current.board[from] = b;
|
||||
current.saveBoard(); current.rebuildLocal();
|
||||
json(res, 200, { ok: true });
|
||||
},
|
||||
|
||||
// Place a library person in a slot: ?slot=<index>&personId=<id> (empty personId clears the slot).
|
||||
'POST /assign': (req, res) => {
|
||||
if (!current) return json(res, 503, { error: 'Module not running.' });
|
||||
const q = new URLSearchParams(req.search || '');
|
||||
const slot = String(q.get('slot') || '').trim();
|
||||
const personId = String(q.get('personId') || '').trim();
|
||||
if (!/^\d+$/.test(slot)) return json(res, 400, { error: 'Bad slot.' });
|
||||
if (personId && !current.library[personId]) return json(res, 404, { error: 'No such library photo.' });
|
||||
if (personId) current.board[slot] = { personId, name: '' };
|
||||
else delete current.board[slot];
|
||||
current.saveBoard(); current.rebuildLocal();
|
||||
json(res, 200, { ok: true });
|
||||
},
|
||||
|
||||
// Rename a slot's occupant, or make a name-only spot (no photo). /slot/<index>/clear empties it.
|
||||
'POST /slot/*': (req, res) => {
|
||||
if (!current) return json(res, 503, { error: 'Module not running.' });
|
||||
const rest = decodeURIComponent(req.wildcard || '').replace(/^\/+/, '');
|
||||
const clearing = /\/clear$/.test(rest);
|
||||
const index = rest.replace(/\/(clear|name)$/, '').trim();
|
||||
if (!/^\d+$/.test(index)) return json(res, 400, { error: 'Bad slot.' });
|
||||
if (clearing) { delete current.board[index]; current.saveBoard(); current.rebuildLocal(); return json(res, 200, { ok: true }); }
|
||||
const q = new URLSearchParams(req.search || '');
|
||||
const name = String(q.get('name') || '').trim();
|
||||
const existing = current.board[index] || { personId: '', name: '' };
|
||||
existing.name = name;
|
||||
if (!existing.personId && !name) delete current.board[index];
|
||||
else current.board[index] = existing;
|
||||
current.saveBoard(); current.rebuildLocal();
|
||||
json(res, 200, { ok: true });
|
||||
},
|
||||
|
||||
// Force an immediate Planning Center refresh (the "Sync" button). Auto-fill also
|
||||
// runs on its own timer; this just does it now.
|
||||
'POST /sync': (req, res) => {
|
||||
if (!current) return json(res, 503, { error: 'Module not running.' });
|
||||
current.refresh(); // scans the photos folder + pulls the plan if Planning Center is configured
|
||||
json(res, 200, { ok: true });
|
||||
},
|
||||
|
||||
'GET /people': (req, res) => {
|
||||
const state = current ? current.getLatest() : null;
|
||||
const options = [{ value: '', label: 'Off' }];
|
||||
for (const slot of state?.slots || []) if (slot.person && slot.person.name) options.push({ value: String(slot.index), label: slot.person.name });
|
||||
json(res, 200, { options });
|
||||
},
|
||||
|
||||
'GET /photo/*': (req, res) => {
|
||||
if (!current) return void res.writeHead(404).end();
|
||||
const name = path.basename(decodeURIComponent(req.wildcard || '').replace(/^\/+/, ''));
|
||||
if (!name || name.includes('..')) return void res.writeHead(400).end();
|
||||
fs.readFile(path.join(current.photoDir, name), (err, buf) => {
|
||||
if (err) return void res.writeHead(404).end();
|
||||
res.writeHead(200, { 'Content-Type': contentTypeForFile(name), 'Cache-Control': 'public, max-age=60' });
|
||||
res.end(buf);
|
||||
});
|
||||
},
|
||||
|
||||
'GET /service-types': async (req, res) => {
|
||||
if (!current) return json(res, 503, { options: [], error: 'Module not running.' });
|
||||
if (!current.pco.configured) return json(res, 200, { options: [], error: 'No Planning Center credentials yet.' });
|
||||
try {
|
||||
const [folderBody, stBody] = await Promise.all([current.pco.getAll(`${SERVICES}/folders`), current.pco.getAll(`${SERVICES}/service_types`)]);
|
||||
const folders = new Map();
|
||||
for (const f of folderBody.data || []) folders.set(String(f.id), { name: String(f.attributes?.name || 'Folder'), parentId: f.relationships?.parent?.data?.id ? String(f.relationships.parent.data.id) : '' });
|
||||
const pathOf = (parentId) => { const names = []; let id = parentId; const seen = new Set(); while (id && folders.has(id) && !seen.has(id)) { seen.add(id); names.unshift(folders.get(id).name); id = folders.get(id).parentId; } return names.join(' › '); };
|
||||
const options = (stBody.data || []).map((st) => ({ value: String(st.id), label: String(st.attributes?.name || 'Service type'), group: pathOf(st.relationships?.parent?.data?.id ? String(st.relationships.parent.data.id) : '') })).sort((a, b) => a.group.localeCompare(b.group) || a.label.localeCompare(b.label));
|
||||
json(res, 200, { options });
|
||||
} catch (err) { log(`service type list failed: ${err?.message || err}`); json(res, err?.status === 401 ? 401 : 502, { options: [], error: err?.message || String(err) }); }
|
||||
},
|
||||
|
||||
'GET /plans': async (req, res) => {
|
||||
if (!current) return json(res, 503, { options: [] });
|
||||
const params = new URLSearchParams(req.search || '');
|
||||
const stId = String(params.get('serviceTypeId') || '').trim();
|
||||
const auto = { value: '', label: 'Automatic — today\'s plan, else the next upcoming' };
|
||||
if (!current.pco.configured || !stId) return json(res, 200, { options: [auto] });
|
||||
try {
|
||||
const plans = await current.listPlans(stId); const now = Date.now();
|
||||
const ordered = [...plans.filter((p) => p.sortDate >= now - 12 * 3600000).sort((a, b) => a.sortDate - b.sortDate), ...plans.filter((p) => p.sortDate < now - 12 * 3600000).sort((a, b) => b.sortDate - a.sortDate)];
|
||||
json(res, 200, { options: [auto, ...ordered.map((p) => ({ value: p.id, label: [p.dates, p.title].filter(Boolean).join(' — ') || p.id }))] });
|
||||
} catch (err) { json(res, 502, { options: [auto], error: err?.message || String(err) }); }
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,232 @@
|
||||
/* Band Lineup — scoped to the tile, themed only through the shell's variables. */
|
||||
|
||||
[data-module="band-lineup"] .bl-board {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--bl-cols, 7), minmax(0, 1fr));
|
||||
grid-template-rows: repeat(var(--bl-rows, 2), minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
[data-module="band-lineup"] .bl-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
padding: 16px;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
[data-module="band-lineup"] .bl-slot {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
[data-module="band-lineup"] .bl-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-module="band-lineup"] .bl-photo {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: color-mix(in srgb, var(--bg) 70%, black);
|
||||
}
|
||||
|
||||
[data-module="band-lineup"] .bl-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center top;
|
||||
display: block;
|
||||
transition: transform 0.12s ease-out;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
[data-module="band-lineup"] .bl-initials {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2.4em;
|
||||
font-weight: 800;
|
||||
color: color-mix(in srgb, var(--muted) 70%, transparent);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
[data-module="band-lineup"] .bl-name {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
right: 6px;
|
||||
max-width: calc(100% - 12px);
|
||||
padding: 3px 7px;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--bg) 82%, transparent);
|
||||
color: var(--text);
|
||||
font-size: 0.74em;
|
||||
font-weight: 700;
|
||||
line-height: 1.12;
|
||||
z-index: 5;
|
||||
/* Show the whole name — wrap onto more lines rather than clipping with an ellipsis. */
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
hyphens: auto;
|
||||
}
|
||||
|
||||
[data-module="band-lineup"] .bl-label {
|
||||
padding: 5px 6px;
|
||||
font-size: 0.72em;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
color: var(--text);
|
||||
background: color-mix(in srgb, var(--bg) 60%, transparent);
|
||||
border-top: 1px solid var(--border);
|
||||
min-height: 1.6em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
[data-module="band-lineup"] .bl-empty {
|
||||
border-style: dashed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
[data-module="band-lineup"] .bl-empty .bl-label {
|
||||
background: transparent;
|
||||
border-top: none;
|
||||
margin: auto;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* ---- Birthday effects (confetti canvas + gold glow ring) ---- */
|
||||
[data-module="band-lineup"] .bl-confetti {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
}
|
||||
[data-module="band-lineup"] .bl-birthday-subtle {
|
||||
animation: bl-glow-subtle 3.4s ease-in-out infinite;
|
||||
}
|
||||
[data-module="band-lineup"] .bl-birthday-full {
|
||||
animation: bl-glow-full 2.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes bl-glow-subtle {
|
||||
0%, 100% { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent) 55%, transparent), 0 0 14px color-mix(in srgb, var(--accent) 20%, transparent); }
|
||||
50% { box-shadow: inset 0 0 0 2px var(--accent), 0 0 20px color-mix(in srgb, var(--accent) 34%, transparent); }
|
||||
}
|
||||
@keyframes bl-glow-full {
|
||||
0%, 100% { box-shadow: inset 0 0 0 3px color-mix(in srgb, var(--accent) 80%, transparent), 0 0 20px color-mix(in srgb, var(--accent) 42%, transparent); }
|
||||
50% { box-shadow: inset 0 0 0 3px var(--accent), 0 0 34px color-mix(in srgb, var(--accent) 66%, transparent); }
|
||||
}
|
||||
|
||||
/* ---- Editor (Edit button): board drop-targets + uploaded-photo library ---- */
|
||||
[data-module="band-lineup"] .bl-btn {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
padding: 5px 11px;
|
||||
font: inherit;
|
||||
font-size: 0.82em;
|
||||
cursor: pointer;
|
||||
}
|
||||
[data-module="band-lineup"] .bl-btn.primary { background: var(--accent); color: var(--accent-text); border-color: transparent; }
|
||||
[data-module="band-lineup"] .bl-btn:hover { border-color: var(--accent); }
|
||||
|
||||
[data-module="band-lineup"] .bl-editor {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg);
|
||||
}
|
||||
[data-module="band-lineup"] .bl-editor-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
[data-module="band-lineup"] .bl-editor-bar b { color: var(--text); }
|
||||
[data-module="band-lineup"] .bl-editor-spacer { flex: 1; }
|
||||
[data-module="band-lineup"] .bl-folder { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
||||
[data-module="band-lineup"] .bl-folder-path { font-size: 0.72em; color: var(--muted); max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
[data-module="band-lineup"] .bl-editor-body { flex: 1; min-height: 0; overflow: auto; padding: 10px; }
|
||||
|
||||
[data-module="band-lineup"] .bl-edit-board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--bl-cols, 7), minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
[data-module="band-lineup"] .bl-edit-slot {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--panel);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
[data-module="band-lineup"] .bl-edit-slot.bl-drop-over { border-color: var(--accent); box-shadow: inset 0 0 0 2px var(--accent); }
|
||||
[data-module="band-lineup"] .bl-edit-photo { position: relative; aspect-ratio: 3 / 4; background: color-mix(in srgb, var(--bg) 70%, black); overflow: hidden; }
|
||||
[data-module="band-lineup"] .bl-edit-photo .bl-img { width: 100%; height: 100%; object-fit: cover; object-position: center top; }
|
||||
[data-module="band-lineup"] .bl-edit-drop {
|
||||
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
||||
color: var(--muted); font-size: 1.6em; border: 1px dashed var(--border);
|
||||
}
|
||||
[data-module="band-lineup"] .bl-edit-namerow { display: flex; align-items: center; gap: 2px; padding: 2px; }
|
||||
[data-module="band-lineup"] .bl-edit-name {
|
||||
flex: 1; min-width: 0; text-align: left; background: transparent; border: 0;
|
||||
color: var(--text); font: inherit; font-size: 0.72em; font-weight: 600; padding: 3px 4px;
|
||||
cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; border-radius: 4px;
|
||||
}
|
||||
[data-module="band-lineup"] .bl-edit-name:hover { background: color-mix(in srgb, var(--accent) 16%, transparent); }
|
||||
[data-module="band-lineup"] .bl-edit-clear {
|
||||
flex: 0 0 auto; width: 20px; height: 20px; border: 0; border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--danger) 18%, transparent); color: var(--danger); cursor: pointer; font-size: 0.9em; line-height: 1;
|
||||
}
|
||||
[data-module="band-lineup"] .bl-edit-label { padding: 3px 5px 5px; font-size: 0.64em; color: var(--muted); text-align: center; border-top: 1px solid var(--border); word-break: break-word; }
|
||||
|
||||
[data-module="band-lineup"] .bl-library {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(78px, 1fr));
|
||||
gap: 6px;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 10px;
|
||||
}
|
||||
[data-module="band-lineup"] .bl-lib-empty { grid-column: 1 / -1; color: var(--muted); font-size: 0.85em; padding: 10px 2px; }
|
||||
[data-module="band-lineup"] .bl-lib-item {
|
||||
position: relative; border: 1px solid var(--border); border-radius: 8px; overflow: hidden;
|
||||
background: var(--panel); cursor: grab; display: flex; flex-direction: column;
|
||||
}
|
||||
[data-module="band-lineup"] .bl-lib-item:active { cursor: grabbing; }
|
||||
[data-module="band-lineup"] .bl-lib-selected { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent); }
|
||||
[data-module="band-lineup"] .bl-lib-photo { position: relative; aspect-ratio: 3 / 4; background: color-mix(in srgb, var(--bg) 70%, black); overflow: hidden; }
|
||||
[data-module="band-lineup"] .bl-lib-img { width: 100%; height: 100%; object-fit: cover; object-position: center top; pointer-events: none; }
|
||||
[data-module="band-lineup"] .bl-lib-name {
|
||||
border: 0; background: transparent; color: var(--text); font: inherit; font-size: 0.68em; font-weight: 600;
|
||||
padding: 3px 4px; text-align: center; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
[data-module="band-lineup"] .bl-lib-del {
|
||||
position: absolute; top: 3px; right: 3px; width: 18px; height: 18px; border: 0; border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--bg) 60%, transparent); color: var(--text); cursor: pointer; font-size: 0.85em; line-height: 1;
|
||||
}
|
||||
[data-module="band-lineup"] .bl-lib-del:hover { background: var(--danger); color: #fff; }
|
||||
+245
-49
@@ -1,15 +1,23 @@
|
||||
{
|
||||
"id": "pco-plan",
|
||||
"name": "PCO Plan",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"proddash": ">=1.4.0",
|
||||
"description": "Order of service from Planning Center Services",
|
||||
"provides": ["timers"],
|
||||
"provides": [
|
||||
"timers"
|
||||
],
|
||||
"client": "client.js",
|
||||
"server": "server.js",
|
||||
"style": "style.css",
|
||||
"minSize": { "w": 3, "h": 3 },
|
||||
"defaultSize": { "w": 5, "h": 6 },
|
||||
"minSize": {
|
||||
"w": 3,
|
||||
"h": 3
|
||||
},
|
||||
"defaultSize": {
|
||||
"w": 5,
|
||||
"h": 6
|
||||
},
|
||||
"configGroups": {
|
||||
"Planning Center credentials": {
|
||||
"collapsed": "whenSet",
|
||||
@@ -48,7 +56,12 @@
|
||||
"type": "select",
|
||||
"label": "Plan",
|
||||
"default": "",
|
||||
"options": [{ "value": "", "label": "Automatic — today's plan, else the next upcoming" }],
|
||||
"options": [
|
||||
{
|
||||
"value": "",
|
||||
"label": "Automatic — today's plan, else the next upcoming"
|
||||
}
|
||||
],
|
||||
"optionsRoute": "/plans",
|
||||
"optionsDependsOn": "serviceTypeId"
|
||||
},
|
||||
@@ -82,8 +95,14 @@
|
||||
"group": "Service timing",
|
||||
"showWhen": "useDocumentTriggers",
|
||||
"options": [
|
||||
{ "value": "schedule", "label": "Scheduled service time" },
|
||||
{ "value": "document", "label": "ProPresenter document" }
|
||||
{
|
||||
"value": "schedule",
|
||||
"label": "Scheduled service time"
|
||||
},
|
||||
{
|
||||
"value": "document",
|
||||
"label": "ProPresenter document"
|
||||
}
|
||||
]
|
||||
},
|
||||
"serviceStartDocument": {
|
||||
@@ -124,37 +143,94 @@
|
||||
"Item notes": {
|
||||
"toggle": "showNotes"
|
||||
},
|
||||
"Show / hide": {
|
||||
"Show": {
|
||||
"collapsed": true,
|
||||
"columns": 3,
|
||||
"help": "Switch off anything this tile should not show."
|
||||
"columns": 2
|
||||
}
|
||||
},
|
||||
"instanceSchema": {
|
||||
"autoScroll": { "type": "boolean", "label": "Keep the current item in view", "default": true },
|
||||
"autoScroll": {
|
||||
"type": "boolean",
|
||||
"label": "Auto-scroll",
|
||||
"default": true
|
||||
},
|
||||
"headerTemplate": {
|
||||
"type": "template",
|
||||
"label": "Top bar text",
|
||||
"default": "{series} • {part} | {serviceType} • {date}",
|
||||
"group": "Top bar",
|
||||
"variables": [
|
||||
{ "value": "{series}", "label": "Series" },
|
||||
{ "value": "{part}", "label": "Part / plan title" },
|
||||
{ "value": "{serviceType}", "label": "Service type" },
|
||||
{ "value": "{date}", "label": "Date" },
|
||||
{ "value": "{shortDate}", "label": "Short date" },
|
||||
{ "value": "{time}", "label": "Service time" },
|
||||
{ "value": "{length}", "label": "Planned length" },
|
||||
{ "value": "{items}", "label": "Item count" }
|
||||
{
|
||||
"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" },
|
||||
"headerColor": { "type": "color", "label": "Section headers", "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" },
|
||||
"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" },
|
||||
"keyColor": {
|
||||
"type": "color",
|
||||
"label": "Song key",
|
||||
"default": "#4ea1ff",
|
||||
"group": "Colors"
|
||||
},
|
||||
"headerColor": {
|
||||
"type": "color",
|
||||
"label": "Section headers",
|
||||
"default": "#2ee59a",
|
||||
"group": "Colors"
|
||||
},
|
||||
"currentColor": {
|
||||
"type": "color",
|
||||
"label": "Current item",
|
||||
"default": "#2ee59a",
|
||||
"group": "Colors"
|
||||
},
|
||||
"headerGradient": {
|
||||
"type": "boolean",
|
||||
"label": "Header fade",
|
||||
"default": true,
|
||||
"group": "Colors"
|
||||
},
|
||||
"tintByType": {
|
||||
"type": "boolean",
|
||||
"label": "Tint by type",
|
||||
"default": false,
|
||||
"group": "Colors"
|
||||
},
|
||||
"showNotes": {
|
||||
"type": "switch",
|
||||
"label": "Item notes",
|
||||
"default": true,
|
||||
"group": "Item notes"
|
||||
},
|
||||
"noteCategories": {
|
||||
"type": "multiselect",
|
||||
"label": "Categories",
|
||||
@@ -163,29 +239,149 @@
|
||||
"columns": 3,
|
||||
"allByDefault": true
|
||||
},
|
||||
"showTopBar": { "type": "boolean", "label": "Top bar", "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" },
|
||||
"showServiceType": { "type": "boolean", "label": "Service type", "default": true, "group": "Show / hide" },
|
||||
"showDate": { "type": "boolean", "label": "Plan date", "default": true, "group": "Show / hide" },
|
||||
"showServiceTime": { "type": "boolean", "label": "Service time", "default": true, "group": "Show / hide" },
|
||||
"showTotalLength": { "type": "boolean", "label": "Planned total length", "default": true, "group": "Show / hide" },
|
||||
"showRunningClock": { "type": "boolean", "label": "Running clock (ahead / behind)", "default": true, "group": "Show / hide" },
|
||||
"showCountdown": { "type": "boolean", "label": "Countdown to service start", "default": true, "group": "Show / hide" },
|
||||
"showHeaders": { "type": "boolean", "label": "Section headers", "default": true, "group": "Show / hide" },
|
||||
"showItemNumbers": { "type": "boolean", "label": "Item numbers", "default": false, "group": "Show / hide" },
|
||||
"showStartTimes": { "type": "boolean", "label": "Scheduled start times", "default": true, "group": "Show / hide" },
|
||||
"showLength": { "type": "boolean", "label": "Item length", "default": true, "group": "Show / hide" },
|
||||
"showItemType": { "type": "boolean", "label": "Item type badge", "default": true, "group": "Show / hide" },
|
||||
"showKey": { "type": "boolean", "label": "Song key", "default": true, "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" },
|
||||
"showDescription": { "type": "boolean", "label": "Item description", "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" },
|
||||
"showNowIndicator": { "type": "boolean", "label": "Now indicator", "default": true, "group": "Show / hide" },
|
||||
"showItemRuntime": { "type": "boolean", "label": "Item runtime (elapsed vs planned)", "default": true, "group": "Show / hide" },
|
||||
"dimCompleted": { "type": "boolean", "label": "Dim completed items", "default": true, "group": "Show / hide" },
|
||||
"showLiveFooter": { "type": "boolean", "label": "ProPresenter match line", "default": true, "group": "Show / hide" }
|
||||
"showTopBar": {
|
||||
"type": "boolean",
|
||||
"label": "Top bar",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showSeries": {
|
||||
"type": "boolean",
|
||||
"label": "Series name",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showPart": {
|
||||
"type": "boolean",
|
||||
"label": "Part / title",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showServiceType": {
|
||||
"type": "boolean",
|
||||
"label": "Service type",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showDate": {
|
||||
"type": "boolean",
|
||||
"label": "Plan date",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showServiceTime": {
|
||||
"type": "boolean",
|
||||
"label": "Service time",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showTotalLength": {
|
||||
"type": "boolean",
|
||||
"label": "Total length",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showRunningClock": {
|
||||
"type": "boolean",
|
||||
"label": "Running clock",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showCountdown": {
|
||||
"type": "boolean",
|
||||
"label": "Countdown",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showHeaders": {
|
||||
"type": "boolean",
|
||||
"label": "Section headers",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showItemNumbers": {
|
||||
"type": "boolean",
|
||||
"label": "Item numbers",
|
||||
"default": false,
|
||||
"group": "Show"
|
||||
},
|
||||
"showStartTimes": {
|
||||
"type": "boolean",
|
||||
"label": "Start times",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showLength": {
|
||||
"type": "boolean",
|
||||
"label": "Item length",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showItemType": {
|
||||
"type": "boolean",
|
||||
"label": "Type badge",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showKey": {
|
||||
"type": "boolean",
|
||||
"label": "Song key",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showArrangement": {
|
||||
"type": "boolean",
|
||||
"label": "Arrangement",
|
||||
"default": false,
|
||||
"group": "Show"
|
||||
},
|
||||
"showSongAuthor": {
|
||||
"type": "boolean",
|
||||
"label": "Author / CCLI",
|
||||
"default": false,
|
||||
"group": "Show"
|
||||
},
|
||||
"showDescription": {
|
||||
"type": "boolean",
|
||||
"label": "Item description",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showPreService": {
|
||||
"type": "boolean",
|
||||
"label": "Pre-service",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showPostService": {
|
||||
"type": "boolean",
|
||||
"label": "Post-service",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showNowIndicator": {
|
||||
"type": "boolean",
|
||||
"label": "Now indicator",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showItemRuntime": {
|
||||
"type": "boolean",
|
||||
"label": "Item runtime",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"dimCompleted": {
|
||||
"type": "boolean",
|
||||
"label": "Dim completed",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showLiveFooter": {
|
||||
"type": "boolean",
|
||||
"label": "Match line",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "prodcom-transcript",
|
||||
"name": "ProdCom",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.2",
|
||||
"proddash": ">=1.1.0",
|
||||
"description": "Live ProdCom transcript",
|
||||
"permissions": [
|
||||
@@ -40,7 +40,7 @@
|
||||
"instanceSchema": {
|
||||
"showAvatars": {
|
||||
"type": "boolean",
|
||||
"label": "Show channel icons",
|
||||
"label": "Channel icons",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
in /admin; the tile only renders. */
|
||||
|
||||
export default function create({ root, moduleApi }) {
|
||||
/* "now" is the live item on its own, filling the tile; "full" is that plus
|
||||
the rest of the playlist. The picker presets `view`; a tile placed before
|
||||
this module had a picker has neither, and gets the full card. */
|
||||
const view = String(moduleApi.instanceSettings.view || moduleApi.variant || 'full');
|
||||
const nowOnly = view === 'now';
|
||||
|
||||
let state = null;
|
||||
let feedOffline = false;
|
||||
let stream = null;
|
||||
@@ -33,11 +39,13 @@ export default function create({ root, moduleApi }) {
|
||||
</div>`;
|
||||
|
||||
const screenEl = root.querySelector('.nn-screen');
|
||||
if (nowOnly) screenEl.classList.add('nn-only-now');
|
||||
const topRow = root.querySelector('.nn-top');
|
||||
const playlistNameEl = root.querySelector('.nn-playlist');
|
||||
const liveEl = root.querySelector('.nn-live');
|
||||
const liveTextEl = root.querySelector('.nn-live-text');
|
||||
const nowEl = root.querySelector('.nn-now');
|
||||
const nowLabelEl = root.querySelector('.nn-label');
|
||||
const nowTitleBox = root.querySelector('.nn-title-box');
|
||||
let nowTitleEl = root.querySelector('.nn-title');
|
||||
const nowMetaEl = root.querySelector('.nn-meta');
|
||||
@@ -47,9 +55,11 @@ export default function create({ root, moduleApi }) {
|
||||
const nextListEl = root.querySelector('.nn-next-list');
|
||||
const nextMoreEl = root.querySelector('.nn-next-more');
|
||||
|
||||
/** A setting, defaulting to on — a Now tile's schema omits the Next ones. */
|
||||
function prefs() {
|
||||
return moduleApi.instanceSettings; // showPlaylist / showProgress / cleanTitles
|
||||
return moduleApi.instanceSettings;
|
||||
}
|
||||
const on = (key) => prefs()[key] !== false;
|
||||
|
||||
/* ── title tidying (same rule as propresenter-core.cleanItemName) ─ */
|
||||
|
||||
@@ -65,7 +75,7 @@ export default function create({ root, moduleApi }) {
|
||||
}
|
||||
|
||||
function displayName(raw) {
|
||||
return prefs().cleanTitles ? cleanItemName(raw) : String(raw || '').trim();
|
||||
return on('cleanTitles') ? cleanItemName(raw) : String(raw || '').trim();
|
||||
}
|
||||
|
||||
function itemTypeTag(type) {
|
||||
@@ -307,7 +317,9 @@ export default function create({ root, moduleApi }) {
|
||||
|
||||
const total = state.currentSlideTotal || activeItem?.slideCount || 0;
|
||||
const current = state.currentSlide || 0;
|
||||
if (total > 0 && current > 0) {
|
||||
if (!on('showSlideCount')) {
|
||||
nowMetaEl.textContent = '';
|
||||
} else if (total > 0 && current > 0) {
|
||||
const remaining = Math.max(0, total - current);
|
||||
nowMetaEl.textContent = `Slide ${current} / ${total} · ${remaining} left`;
|
||||
} else if (total > 0) {
|
||||
@@ -318,7 +330,7 @@ export default function create({ root, moduleApi }) {
|
||||
nowMetaEl.textContent = '';
|
||||
}
|
||||
|
||||
if (prefs().showProgress && total > 0) {
|
||||
if (on('showProgress') && total > 0) {
|
||||
progressEl.hidden = false;
|
||||
const pct = current > 0 ? Math.max(0, Math.min(100, (current / total) * 100)) : 0;
|
||||
progressFillEl.style.width = `${pct}%`;
|
||||
@@ -349,6 +361,10 @@ export default function create({ root, moduleApi }) {
|
||||
nameEl.textContent = displayName(item.name) || '(untitled)';
|
||||
row.appendChild(nameEl);
|
||||
|
||||
if (!on('showTags')) {
|
||||
nextListEl.appendChild(row);
|
||||
continue;
|
||||
}
|
||||
if (item.type === 'presentation' && Number.isInteger(item.slideCount)) {
|
||||
const chip = document.createElement('div');
|
||||
chip.className = 'nn-count-chip';
|
||||
@@ -366,7 +382,7 @@ export default function create({ root, moduleApi }) {
|
||||
nextListEl.appendChild(row);
|
||||
}
|
||||
|
||||
nextLabelEl.hidden = upcoming.length === 0;
|
||||
nextLabelEl.hidden = upcoming.length === 0 || !on('showNextLabel');
|
||||
nextMoreEl.hidden = true;
|
||||
markNextOverflow();
|
||||
requestAnimationFrame(markNextOverflow);
|
||||
@@ -374,12 +390,22 @@ export default function create({ root, moduleApi }) {
|
||||
|
||||
function render() {
|
||||
screenEl.classList.toggle('is-offline', feedOffline);
|
||||
const showTop = prefs().showPlaylist || feedOffline || !state?.reachable || !state?.enabled;
|
||||
topRow.classList.toggle('hidden-row', !showTop);
|
||||
playlistNameEl.textContent = prefs().showPlaylist ? state?.playlistName || '' : '';
|
||||
// Trouble always shows, whatever is switched off: a tile that has stopped
|
||||
// updating must never look like one with nothing live.
|
||||
const trouble = feedOffline || !state?.reachable || !state?.enabled;
|
||||
const wantPlaylist = on('showPlaylist');
|
||||
const wantLive = on('showLive') || trouble;
|
||||
topRow.classList.toggle('hidden-row', !wantPlaylist && !wantLive);
|
||||
liveEl.hidden = !wantLive;
|
||||
// Hidden, not just emptied: it is flex:1, so an emptied one still holds
|
||||
// the row open and pushes the live dot off the right edge.
|
||||
playlistNameEl.hidden = !wantPlaylist;
|
||||
playlistNameEl.textContent = state?.playlistName || '';
|
||||
nowLabelEl.hidden = !on('showNowLabel');
|
||||
screenEl.classList.toggle('nn-no-next', nowOnly || !on('showNext'));
|
||||
renderLive();
|
||||
renderNow();
|
||||
renderNext();
|
||||
if (!nowOnly && on('showNext')) renderNext();
|
||||
}
|
||||
|
||||
/* ── sizing: --unit scales the whole card to the tile ───────────── */
|
||||
@@ -387,6 +413,10 @@ export default function create({ root, moduleApi }) {
|
||||
function applyUnit() {
|
||||
const unit = Math.max(6.5, Math.min(20, root.clientHeight * 0.032));
|
||||
screenEl.style.setProperty('--unit', unit.toFixed(2) + 'px');
|
||||
// On a Now tile the top row floats over the gradient, so at the shortest
|
||||
// sizes it would sit on the title. Below the height it needs, it goes —
|
||||
// the header dot and the title itself still report trouble.
|
||||
screenEl.classList.toggle('nn-tight', nowOnly && root.clientHeight < unit * 9);
|
||||
}
|
||||
|
||||
/* ── live feed ──────────────────────────────────────────────────── */
|
||||
|
||||
@@ -1,32 +1,100 @@
|
||||
{
|
||||
"id": "propresenter-now-next",
|
||||
"name": "ProPresenter",
|
||||
"version": "1.0.1",
|
||||
"proddash": ">=1.1.0",
|
||||
"version": "1.1.1",
|
||||
"proddash": ">=1.5.0",
|
||||
"description": "What is live in ProPresenter and what comes next",
|
||||
"permissions": [
|
||||
{ "kind": "local-network", "reason": "Reaching ProPresenter on this network" }
|
||||
{
|
||||
"kind": "local-network",
|
||||
"reason": "Reaching ProPresenter on this network"
|
||||
}
|
||||
],
|
||||
"client": "client.js",
|
||||
"server": "server.js",
|
||||
"style": "style.css",
|
||||
"minSize": { "w": 3, "h": 3 },
|
||||
"defaultSize": { "w": 4, "h": 5 },
|
||||
"minSize": {
|
||||
"w": 3,
|
||||
"h": 3
|
||||
},
|
||||
"defaultSize": {
|
||||
"w": 4,
|
||||
"h": 5
|
||||
},
|
||||
"configSchema": {
|
||||
"propresenter": {
|
||||
"type": "endpoint",
|
||||
"label": "ProPresenter",
|
||||
"default": { "host": "", "port": 1025 }
|
||||
"default": {
|
||||
"host": "",
|
||||
"port": 1025
|
||||
}
|
||||
},
|
||||
"password": {
|
||||
"type": "password",
|
||||
"label": "API password (only if set in ProPresenter)",
|
||||
"label": "Password",
|
||||
"default": ""
|
||||
}
|
||||
},
|
||||
"instanceSchema": {
|
||||
"showPlaylist": { "type": "boolean", "label": "Show the playlist name", "default": true },
|
||||
"showProgress": { "type": "boolean", "label": "Show the slide progress bar", "default": true },
|
||||
"cleanTitles": { "type": "boolean", "label": "Tidy titles (hide [ Full ] tags and date codes)", "default": true }
|
||||
"cleanTitles": {
|
||||
"type": "switch",
|
||||
"label": "Tidy titles",
|
||||
"default": true
|
||||
},
|
||||
"showPlaylist": {
|
||||
"type": "switch",
|
||||
"label": "Playlist",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showLive": {
|
||||
"type": "switch",
|
||||
"label": "Live status",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showNowLabel": {
|
||||
"type": "switch",
|
||||
"label": "Now label",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showSlideCount": {
|
||||
"type": "switch",
|
||||
"label": "Slide count",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showProgress": {
|
||||
"type": "switch",
|
||||
"label": "Progress bar",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showNext": {
|
||||
"type": "switch",
|
||||
"label": "Next list",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showNextLabel": {
|
||||
"type": "switch",
|
||||
"label": "Next label",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showTags": {
|
||||
"type": "switch",
|
||||
"label": "Item tags",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
}
|
||||
},
|
||||
"instanceGroups": {
|
||||
"Show": {
|
||||
"collapsed": true,
|
||||
"columns": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,43 @@ module.exports = {
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Two ways to place this module. "Now / Next" is the full card — what is
|
||||
* live, then the rest of the playlist. "Now" is the same live item alone,
|
||||
* edge to edge with the gradient filling the tile: a wall display that says
|
||||
* one thing. The Now entry offers only the settings it can act on, so its
|
||||
* gear carries no Next switches.
|
||||
*/
|
||||
tiles() {
|
||||
return [
|
||||
{
|
||||
id: 'full',
|
||||
name: 'Now / Next',
|
||||
description: 'What is live, and the rest of the playlist',
|
||||
defaultSize: { w: 4, h: 5 },
|
||||
minSize: { w: 3, h: 3 },
|
||||
settings: { view: 'full' },
|
||||
},
|
||||
{
|
||||
id: 'now',
|
||||
name: 'Now',
|
||||
description: 'Only what is live, filling the tile',
|
||||
defaultSize: { w: 4, h: 2 },
|
||||
minSize: { w: 2, h: 1 },
|
||||
settings: { view: 'now' },
|
||||
instanceSchema: {
|
||||
cleanTitles: { type: 'switch', label: 'Tidy titles', default: true },
|
||||
showPlaylist: { type: 'switch', label: 'Playlist', default: true, group: 'Show' },
|
||||
showLive: { type: 'switch', label: 'Live status', default: true, group: 'Show' },
|
||||
showNowLabel: { type: 'switch', label: 'Now label', default: true, group: 'Show' },
|
||||
showSlideCount: { type: 'switch', label: 'Slide count', default: true, group: 'Show' },
|
||||
showProgress: { type: 'switch', label: 'Progress bar', default: true, group: 'Show' },
|
||||
},
|
||||
instanceGroups: { Show: { collapsed: true, columns: 2 } },
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
routes() {
|
||||
return {
|
||||
'GET /state': (req, res) => {
|
||||
|
||||
@@ -21,6 +21,51 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* NEXT switched off, or a Now tile: the list goes and the card takes the room. */
|
||||
[data-module="propresenter-now-next"] .nn-screen.nn-no-next .nn-next-wrap { display: none; }
|
||||
[data-module="propresenter-now-next"] .nn-screen.nn-no-next { justify-content: center; }
|
||||
|
||||
/* ── the "Now" tile ──
|
||||
Only what is live, edge to edge: the gradient moves off the card and onto
|
||||
the whole tile, the card loses its border, radius and box, and the title
|
||||
sits in the middle of everything. Nothing is inset, so a wall of these
|
||||
reads as panels of colour rather than cards on a background. */
|
||||
[data-module="propresenter-now-next"] .nn-screen.nn-only-now {
|
||||
gap: 0;
|
||||
background: linear-gradient(
|
||||
160deg,
|
||||
color-mix(in srgb, var(--accent) 14%, transparent),
|
||||
color-mix(in srgb, var(--bg) 70%, transparent)
|
||||
);
|
||||
}
|
||||
[data-module="propresenter-now-next"] .nn-only-now .nn-now {
|
||||
flex: 1;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
/* The live dot and playlist ride over the gradient rather than above it. */
|
||||
[data-module="propresenter-now-next"] .nn-only-now .nn-top {
|
||||
position: absolute;
|
||||
inset: var(--nn-pad) var(--nn-pad) auto;
|
||||
pointer-events: none;
|
||||
}
|
||||
[data-module="propresenter-now-next"] .nn-only-now .nn-title-box { margin-top: 0; }
|
||||
/* The overlay floats, so it takes no room — this keeps the title clear of it
|
||||
and still centred, by holding back as much at the bottom as at the top. */
|
||||
[data-module="propresenter-now-next"] .nn-only-now:not(.nn-tight) .nn-now {
|
||||
padding: calc(var(--unit) * 1.4) 0;
|
||||
}
|
||||
/* Too short for the overlay: the title gets the whole panel to itself. */
|
||||
[data-module="propresenter-now-next"] .nn-only-now.nn-tight .nn-top,
|
||||
[data-module="propresenter-now-next"] .nn-only-now.nn-tight.is-offline .nn-top {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ── top row ── */
|
||||
|
||||
[data-module="propresenter-now-next"] .nn-top {
|
||||
@@ -59,6 +104,7 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
[data-module="propresenter-now-next"] .nn-playlist[hidden] { display: none; }
|
||||
|
||||
[data-module="propresenter-now-next"] .nn-live {
|
||||
display: flex;
|
||||
@@ -71,6 +117,7 @@
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
[data-module="propresenter-now-next"] .nn-live[hidden] { display: none; }
|
||||
|
||||
[data-module="propresenter-now-next"] .nn-live-dot {
|
||||
width: calc(var(--unit) * 0.6);
|
||||
@@ -122,6 +169,7 @@
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
}
|
||||
[data-module="propresenter-now-next"] .nn-label[hidden] { display: none; }
|
||||
|
||||
[data-module="propresenter-now-next"] .nn-title-box {
|
||||
flex: 1;
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
Per-tile choices: the gear popover's static switches (whole sources,
|
||||
headings, status lines, the clock's format, the state colours) and the
|
||||
"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
|
||||
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
|
||||
@@ -196,6 +196,10 @@ function defaultStatus(timer, value, tone) {
|
||||
|
||||
/* ── the tile ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/* The header menu's icon: a stopwatch, for the list of timers behind it.
|
||||
Title-bar controls carry no words (module guide, rule 9). */
|
||||
const TIMERS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 2h4"/><path d="M12 14V9"/><circle cx="12" cy="14" r="8"/></svg>';
|
||||
|
||||
export default function create({ root, moduleApi }) {
|
||||
let state = null;
|
||||
let feedOffline = false;
|
||||
@@ -227,7 +231,7 @@ export default function create({ root, moduleApi }) {
|
||||
}
|
||||
|
||||
/* Which picker entry this tile was added as (the module's tiles() list):
|
||||
'' or 'all' = every timer, selectable via the Timers ▾ menu;
|
||||
'' or 'all' = every timer, selectable via the timers menu;
|
||||
'solo:<source>:<id>' = exactly one card, no menu, no headings. The
|
||||
earlier entries 'timer:<uuid>' and 'ltc' map onto the new keys. */
|
||||
const variant = String(moduleApi.variant || '');
|
||||
@@ -235,8 +239,6 @@ export default function create({ root, moduleApi }) {
|
||||
: variant.startsWith('timer:') ? `propresenter:${variant.slice('timer:'.length)}`
|
||||
: variant.startsWith('solo:') ? variant.slice('solo:'.length)
|
||||
: '';
|
||||
/** Last label seen for a solo timer, so its card stays labeled if the timer is later deleted upstream. */
|
||||
let lastSoloLabel = '';
|
||||
|
||||
/* ── DOM skeleton ───────────────────────────────────────────────── */
|
||||
|
||||
@@ -283,8 +285,8 @@ export default function create({ root, moduleApi }) {
|
||||
|
||||
let menuEl = null; // the open menu's element (filled on each open)
|
||||
const itemsMenu = solo ? null : moduleApi.header.addMenu({
|
||||
label: 'Timers ▾',
|
||||
title: 'Choose timers',
|
||||
icon: TIMERS_SVG,
|
||||
title: 'Timers',
|
||||
build(menu) {
|
||||
// the shell hands over an emptied element on every open
|
||||
menu.classList.add('tm-menu');
|
||||
@@ -292,6 +294,12 @@ export default function create({ root, moduleApi }) {
|
||||
rebuildMenu(true);
|
||||
},
|
||||
});
|
||||
// The icon carries the name; a count rides alongside it while some timers
|
||||
// are hidden, so the bar says "filtered" without saying it in words.
|
||||
const itemsCountEl = document.createElement('span');
|
||||
itemsCountEl.className = 'tm-count';
|
||||
itemsCountEl.hidden = true;
|
||||
if (itemsMenu) itemsMenu.button.appendChild(itemsCountEl);
|
||||
|
||||
function menuRows() {
|
||||
const rows = [];
|
||||
@@ -319,7 +327,9 @@ export default function create({ root, moduleApi }) {
|
||||
if (!itemsMenu) return;
|
||||
const rows = menuRows();
|
||||
const visible = rows.filter((r) => !hidden.has(r.key)).length;
|
||||
itemsMenu.setLabel(visible === rows.length ? 'Timers ▾' : `Timers (${visible}/${rows.length}) ▾`);
|
||||
const filtering = visible !== rows.length;
|
||||
itemsCountEl.textContent = filtering ? `${visible}/${rows.length}` : '';
|
||||
itemsCountEl.hidden = !filtering;
|
||||
}
|
||||
|
||||
let menuSignature = '';
|
||||
@@ -364,22 +374,21 @@ export default function create({ root, moduleApi }) {
|
||||
|
||||
/* ── state → what this tile shows ───────────────────────────────── */
|
||||
|
||||
/** [{ id, source, items: [{ key, source, timer } | { key, missing }] }] */
|
||||
/** [{ id, source, items: [{ key, source, timer }] }] */
|
||||
function visibleGroups() {
|
||||
if (!state) return [];
|
||||
if (solo) {
|
||||
for (const source of state.sources) {
|
||||
for (const timer of source.timers) {
|
||||
if (keyOf(source, timer) !== solo) continue;
|
||||
lastSoloLabel = timer.label;
|
||||
return [{ id: source.id, source, items: [{ key: solo, source, timer }] }];
|
||||
}
|
||||
}
|
||||
// The timer this tile was added for isn't offered (yet, or any more) —
|
||||
// keep a clearly-labeled card up rather than erroring.
|
||||
const sourceId = solo.slice(0, solo.indexOf(':'));
|
||||
const source = state.sources.find((s) => s.id === sourceId) || null;
|
||||
return [{ id: 'missing', source, items: [{ key: solo, missing: true, source }] }];
|
||||
// The timer this tile was added for is not offered right now. Nothing is
|
||||
// drawn for it — no placeholder that reads like a timer, and never the
|
||||
// full list in its place. The shell folds the tile away on its next
|
||||
// registry refresh (module guide, Presenting multiple tiles).
|
||||
return [];
|
||||
}
|
||||
const groups = [];
|
||||
for (const source of state.sources) {
|
||||
@@ -439,7 +448,7 @@ export default function create({ root, moduleApi }) {
|
||||
/** The parts of a card that only change with the snapshot (label, kind). */
|
||||
function updateCard(card, item) {
|
||||
card.item = item;
|
||||
card.isClock = !item.missing && item.source?.kind === 'clock';
|
||||
card.isClock = item.source?.kind === 'clock';
|
||||
const isLtc = item.source?.kind === 'ltc';
|
||||
const badge = card.nameEl.querySelector('.tm-ltc-badge');
|
||||
if (isLtc && !badge) {
|
||||
@@ -450,29 +459,13 @@ export default function create({ root, moduleApi }) {
|
||||
} else if (!isLtc && badge) {
|
||||
badge.remove();
|
||||
}
|
||||
const label = item.missing ? (lastSoloLabel || 'Timer') : card.isClock ? '' : item.timer.label;
|
||||
const label = card.isClock ? '' : item.timer.label;
|
||||
if (card.labelEl.textContent !== label) {
|
||||
setText(card.labelEl, label);
|
||||
needFit = true; // the title's size follows its length
|
||||
}
|
||||
}
|
||||
|
||||
function updateMissingCard(card) {
|
||||
const source = card.item.source;
|
||||
setText(card.timeEl, '—');
|
||||
card.chars = 5;
|
||||
let note;
|
||||
if (!source) note = 'Source not available — check Admin';
|
||||
else if (source.status === 'ok') note = `Not offered by ${source.label}`;
|
||||
else note = '—';
|
||||
setText(card.statusEl, note);
|
||||
card.statusEl.hidden = false;
|
||||
card.statusEl.className = 'tm-status is-note';
|
||||
card.statusEl.dataset.tone = source && source.status === 'ok' ? 'warn' : 'muted';
|
||||
card.detailEl.hidden = true;
|
||||
setCardClass(card, 'tm-card is-muted is-missing');
|
||||
}
|
||||
|
||||
/* ── clocks ─────────────────────────────────────────────────────── */
|
||||
|
||||
function clockOptions() {
|
||||
@@ -621,10 +614,6 @@ export default function create({ root, moduleApi }) {
|
||||
let charsChanged = false;
|
||||
for (const card of cards.values()) {
|
||||
const { item } = card;
|
||||
if (item.missing) {
|
||||
updateMissingCard(card);
|
||||
continue;
|
||||
}
|
||||
const { source, timer } = item;
|
||||
let text;
|
||||
let tone;
|
||||
@@ -723,8 +712,9 @@ export default function create({ root, moduleApi }) {
|
||||
moduleApi.setStatus('connecting', say(waiting));
|
||||
return;
|
||||
}
|
||||
const n = groups.reduce((a, g) => a + g.items.filter((i) => !i.missing).length, 0);
|
||||
moduleApi.setStatus('ok', `${n} timer${n === 1 ? '' : 's'} from ${shown.length} source${shown.length === 1 ? '' : 's'}`);
|
||||
const n = groups.reduce((a, g) => a + g.items.length, 0);
|
||||
if (solo && n === 0) moduleApi.setStatus('connecting', 'Not offered right now');
|
||||
else moduleApi.setStatus('ok', `${n} timer${n === 1 ? '' : 's'} from ${shown.length} source${shown.length === 1 ? '' : 's'}`);
|
||||
}
|
||||
|
||||
/* ── main render: diff groups and cards, update in place ────────── */
|
||||
@@ -779,7 +769,7 @@ export default function create({ root, moduleApi }) {
|
||||
let hint = '';
|
||||
const groups = visibleGroups();
|
||||
if (state && !state.sources.length) hint = 'Every source is switched off — turn one on in Admin.';
|
||||
else if (state && !groups.length) hint = 'Nothing selected — switch a source on in this tile’s settings, or pick timers in the Timers ▾ menu.';
|
||||
else if (state && !groups.length && !solo) hint = 'Nothing selected — switch a source on in this tile’s settings, or pick timers in the title bar’s timers menu.';
|
||||
hintEl.hidden = !hint;
|
||||
hintEl.textContent = hint;
|
||||
groupsEl.hidden = Boolean(hint);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "propresenter-timers",
|
||||
"name": "Timers",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.1",
|
||||
"proddash": ">=1.4.0",
|
||||
"description": "Timers, Clocks, and LTC From configured Modules",
|
||||
"provides": [
|
||||
@@ -112,7 +112,7 @@
|
||||
},
|
||||
"instanceGroups": {
|
||||
"Show": {
|
||||
"collapsed": false,
|
||||
"collapsed": true,
|
||||
"columns": 2
|
||||
},
|
||||
"Clock": {
|
||||
@@ -121,8 +121,7 @@
|
||||
},
|
||||
"Colours": {
|
||||
"collapsed": true,
|
||||
"columns": 2,
|
||||
"help": "The time's colour per state. Left at the default, a colour follows the dashboard theme; one you change here wins over it."
|
||||
"columns": 2
|
||||
}
|
||||
},
|
||||
"instanceSchema": {
|
||||
|
||||
@@ -295,7 +295,6 @@
|
||||
}
|
||||
|
||||
/* A solo tile whose timer is no longer offered. */
|
||||
[data-module="propresenter-timers"] .tm-card.is-missing { border-style: dashed; }
|
||||
|
||||
/* ── the LTC card: visually its own thing ── */
|
||||
|
||||
@@ -321,7 +320,7 @@
|
||||
|
||||
@keyframes tm-nosignal-blink { 50% { opacity: 0.35; } }
|
||||
|
||||
/* ── the "Timers ▾" menu (container styled/positioned by the shell) ── */
|
||||
/* ── the timers menu (container styled/positioned by the shell) ── */
|
||||
|
||||
[data-module="propresenter-timers"] .tm-menu-item {
|
||||
display: flex;
|
||||
@@ -383,3 +382,11 @@
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* the count beside the title-bar timers icon, while some are hidden */
|
||||
[data-module="propresenter-timers"] .tm-count {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
[data-module="propresenter-timers"] .tm-count[hidden] { display: none; }
|
||||
|
||||
+23
-12
@@ -141,13 +141,17 @@ const sendTarget = (() => {
|
||||
/**
|
||||
* Where a send goes, given the configured channels in admin order: the
|
||||
* selection while it is still configured and its transcript is on the
|
||||
* page; otherwise the first configured channel.
|
||||
* page, else the first configured channel that IS on this page — with one
|
||||
* transcript up, that one — and only if the page shows none, the first
|
||||
* configured channel. A send must never quietly go to a channel the
|
||||
* operator cannot see.
|
||||
*/
|
||||
resolve(configured) {
|
||||
const list = Array.isArray(configured) ? configured : [];
|
||||
if (!list.length) return '';
|
||||
if (id && list.includes(id) && this.hasTranscript(id)) return id;
|
||||
return list[0];
|
||||
const onPage = list.find((c) => this.hasTranscript(c));
|
||||
return onPage || list[0];
|
||||
},
|
||||
on(fn) {
|
||||
bus.addEventListener('change', fn);
|
||||
@@ -1100,7 +1104,7 @@ function createSendTile({ root, moduleApi, mode }) {
|
||||
if (stopped || !state) return;
|
||||
if (token) {
|
||||
if (composeEls) {
|
||||
composeEls.line.textContent = sendingLine();
|
||||
if (composeEls.line) composeEls.line.textContent = sendingLine();
|
||||
if (composeEls.input) composeEls.input.placeholder = placeholder();
|
||||
}
|
||||
} else renderGate();
|
||||
@@ -1177,14 +1181,21 @@ function createSendTile({ root, moduleApi, mode }) {
|
||||
let composeEls = null;
|
||||
function buildCompose() {
|
||||
compose.textContent = '';
|
||||
const head = el('div', 'sl-compose-head');
|
||||
const line = el('span', 'sl-sending', sendingLine());
|
||||
const lockLink = el('button', 'sl-lock-link', 'Lock');
|
||||
lockLink.type = 'button';
|
||||
lockLink.title = 'Lock now';
|
||||
lockLink.addEventListener('click', () => lock('Locked'));
|
||||
head.append(line, lockLink);
|
||||
compose.appendChild(head);
|
||||
// Send message names the channel in the box's own placeholder and locks
|
||||
// from the title bar, so a line above the field would only say both
|
||||
// again. Quick replies has no placeholder — there it is the only place
|
||||
// the target is named, so it stays.
|
||||
let line = null;
|
||||
if (mode !== 'send') {
|
||||
const head = el('div', 'sl-compose-head');
|
||||
line = el('span', 'sl-sending', sendingLine());
|
||||
const lockLink = el('button', 'sl-lock-link', 'Lock');
|
||||
lockLink.type = 'button';
|
||||
lockLink.title = 'Lock now';
|
||||
lockLink.addEventListener('click', () => lock('Locked'));
|
||||
head.append(line, lockLink);
|
||||
compose.appendChild(head);
|
||||
}
|
||||
|
||||
const foot = el('div', 'sl-compose-foot');
|
||||
const msg = el('div', 'sl-msg');
|
||||
@@ -1462,7 +1473,7 @@ function createSendTile({ root, moduleApi, mode }) {
|
||||
if (!s.sending?.enabled || !s.sending?.pinSet) return void lock('');
|
||||
if (!composeEls) return void showCompose(); // the page was unlocked before this tile appeared
|
||||
if (composeEls) {
|
||||
composeEls.line.textContent = sendingLine();
|
||||
if (composeEls.line) composeEls.line.textContent = sendingLine();
|
||||
if (composeEls.input) composeEls.input.placeholder = placeholder();
|
||||
}
|
||||
} else renderGate();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "slack",
|
||||
"name": "Slack",
|
||||
"version": "1.2.0",
|
||||
"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",
|
||||
@@ -21,7 +21,7 @@
|
||||
"help": "Create a Slack app in the workspace (api.slack.com/apps → Create New App → From scratch) and, under OAuth & Permissions → User Token Scopes, add channels:history, channels:read, groups:history, groups:read, chat:write, users:read, reactions:write (emoji reactions) and emoji:read (the workspace's custom emoji). Sign in to Slack as this location's account — the one named for the campus — open the app's OAuth & Permissions page and press Install to Workspace (or Reinstall to Workspace), then copy the User OAuth Token (xoxp-…) it shows and paste it here: messages from this machine are then literally from the location. A bot token (xoxb-…) works too; it posts as the app, with \"Send as\" naming the location."
|
||||
},
|
||||
"Channels": {
|
||||
"help": "Every channel picked here becomes its own transcript tile in the + picker, titled with the channel's name. The Send message and Quick replies tiles post to whichever transcript was clicked last — its bottom edge lights up in the accent colour — and to the first channel here until one is clicked. Only channels the account has joined are listed; join one in Slack first. Each channel is read on its own schedule, all of them together kept inside Slack's rate limit."
|
||||
"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",
|
||||
@@ -49,7 +49,7 @@
|
||||
"default": [],
|
||||
"group": "Channels",
|
||||
"optionsRoute": "/channels",
|
||||
"help": "One transcript tile per channel; the first one is where sends go until a transcript is clicked. Only channels the account has joined are listed — join one in Slack first."
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
/**
|
||||
* SPL Meter tiles.
|
||||
*
|
||||
* Four kinds, all fed by one SSE stream from the module's server:
|
||||
*
|
||||
* readout the number, big
|
||||
* bars one level bar per channel, with peak hold
|
||||
* rta spectrum now, folded to the banding this tile wants
|
||||
* spectrogram the same bands over time, either axis
|
||||
*
|
||||
* Nothing here measures anything. The capture child already sent every
|
||||
* averaging and the finest banding, and each tile folds that down to the
|
||||
* view it was configured for. That is the whole design: two dashboards can
|
||||
* watch one room through different settings without either disturbing the
|
||||
* measurement or the other dashboard.
|
||||
*/
|
||||
|
||||
const FLOOR = -200;
|
||||
|
||||
/** Retention per frame for the RTA's smoothing setting. */
|
||||
const SMOOTHING = { off: 0, light: 0.45, medium: 0.72, heavy: 0.9 };
|
||||
|
||||
export default function create({ root, moduleApi }) {
|
||||
const variant = moduleApi.variant || 'readout';
|
||||
let stream = null;
|
||||
let latest = null;
|
||||
let raf = 0;
|
||||
|
||||
root.classList.add('spl');
|
||||
root.dataset.variant = variant;
|
||||
|
||||
const view = buildView(variant, root, moduleApi);
|
||||
|
||||
function onState(payload) {
|
||||
latest = payload;
|
||||
if (payload.error) {
|
||||
moduleApi.setStatus('error', payload.error);
|
||||
} else if (!payload.frame) {
|
||||
moduleApi.setStatus('connecting', 'waiting for the first reading');
|
||||
} else if (!payload.calibrated) {
|
||||
moduleApi.setStatus('ok', 'Measuring, but uncalibrated — these are dBFS, not dB SPL');
|
||||
} else {
|
||||
moduleApi.setStatus('ok', '');
|
||||
}
|
||||
// Frames land 20×/s; paint on a frame the browser was going to draw
|
||||
// anyway rather than fighting it for every one.
|
||||
if (!raf) raf = requestAnimationFrame(() => { raf = 0; view.render(latest); });
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
moduleApi.setStatus('connecting', 'connecting');
|
||||
moduleApi.fetch('/state')
|
||||
.then((r) => r.json())
|
||||
.then(onState)
|
||||
.catch(() => { /* the stream will bring it */ });
|
||||
// Named events go under `events` — a top-level `state(ev)` is silently
|
||||
// never registered, and the tile just sits on whatever /state gave it.
|
||||
stream = moduleApi.sse('/stream', {
|
||||
events: {
|
||||
state(ev) {
|
||||
try { onState(JSON.parse(ev.data)); } catch { /* keep the last good frame */ }
|
||||
},
|
||||
},
|
||||
error() { moduleApi.setStatus('connecting', 'reconnecting'); },
|
||||
});
|
||||
},
|
||||
stop() {
|
||||
if (stream) { stream.close(); stream = null; }
|
||||
if (raf) { cancelAnimationFrame(raf); raf = 0; }
|
||||
view.destroy();
|
||||
},
|
||||
onResize() { view.resize(); },
|
||||
};
|
||||
}
|
||||
|
||||
/* ── shared helpers ─────────────────────────────────────────────────── */
|
||||
|
||||
function pickChannel(payload, settings) {
|
||||
if (!payload || !payload.frame) return null;
|
||||
const want = Number(settings.channel);
|
||||
return payload.frame.ch.find((c) => c.i === want) || payload.frame.ch[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The level this tile asked for: an averaging, or one of the fixed metrics.
|
||||
*
|
||||
* Tiles saved by an earlier version carry metrics that no longer exist —
|
||||
* "spl" became an averaging, "leq" became Inf. A layout on a wall somewhere
|
||||
* must not start reading dashes because a setting was renamed, so those map
|
||||
* forward instead of falling through to undefined.
|
||||
*/
|
||||
const LEGACY_METRIC = { spl: 'level', leq: 'level' };
|
||||
const LEGACY_AVERAGING = { leq: 'inf' };
|
||||
|
||||
function readValue(ch, settings) {
|
||||
const raw = settings.metric || 'level';
|
||||
const metric = LEGACY_METRIC[raw] || raw;
|
||||
if (metric !== 'level') {
|
||||
return metric in ch ? ch[metric] : ch.fast;
|
||||
}
|
||||
const avg = LEGACY_AVERAGING[raw] || settings.averaging || 'fast';
|
||||
return avg in ch ? ch[avg] : ch.fast;
|
||||
}
|
||||
|
||||
function fmt(db) {
|
||||
if (!Number.isFinite(db) || db <= FLOOR) return '––.–';
|
||||
return db.toFixed(1);
|
||||
}
|
||||
|
||||
function themeColor(el, name, fallback) {
|
||||
const v = getComputedStyle(el).getPropertyValue(name).trim();
|
||||
return v || fallback;
|
||||
}
|
||||
|
||||
function hzLabel(f) {
|
||||
if (f >= 1000) {
|
||||
const k = f / 1000;
|
||||
return (k >= 10 ? Math.round(k) : Math.round(k * 10) / 10) + 'k';
|
||||
}
|
||||
return String(f >= 100 ? Math.round(f) : Math.round(f * 10) / 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold the 1/48-octave bands on the wire down to this tile's banding.
|
||||
*
|
||||
* Every banding offered divides 48, so a coarser band is an exact sum of
|
||||
* finer ones in the power domain — no interpolation, no resampling. Groups
|
||||
* are keyed on the 1 kHz band (n = 0), which is what keeps folded centres on
|
||||
* the values an RTA is expected to show.
|
||||
*
|
||||
* @returns {{freq:number[], db:number[]}}
|
||||
*/
|
||||
function foldBands(bands, bandN0, perOctave, denom) {
|
||||
const g = Math.max(1, Math.round(perOctave / denom));
|
||||
const power = new Map();
|
||||
for (let i = 0; i < bands.length; i++) {
|
||||
const n = bandN0 + i;
|
||||
const k = Math.round(n / g);
|
||||
const p = 10 ** (bands[i] / 10);
|
||||
power.set(k, (power.get(k) || 0) + p);
|
||||
}
|
||||
const keys = [...power.keys()].sort((a, b) => a - b);
|
||||
return {
|
||||
freq: keys.map((k) => 1000 * 2 ** ((k * g) / perOctave)),
|
||||
db: keys.map((k) => 10 * Math.log10(Math.max(power.get(k), 1e-20))),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Which frequency labels fit, most important first.
|
||||
*
|
||||
* Decades earn their place before octaves, octaves before thirds, and
|
||||
* anything that would collide with a label already placed is dropped. That
|
||||
* is what makes the axis thin out as a tile shrinks instead of turning into
|
||||
* a smear.
|
||||
*/
|
||||
const LABEL_TIERS = [
|
||||
[100, 1000, 10000],
|
||||
[31.5, 63, 250, 500, 2000, 4000, 8000, 20000],
|
||||
[20, 40, 50, 80, 125, 160, 200, 315, 400, 630, 800, 1250, 1600, 2500, 3150, 5000, 6300, 12500, 16000],
|
||||
];
|
||||
|
||||
function chooseLabels(freqs, positionOf, extent, minGapPx) {
|
||||
const placed = [];
|
||||
for (const tier of LABEL_TIERS) {
|
||||
for (const f of tier) {
|
||||
if (f < freqs[0] * 0.92 || f > freqs[freqs.length - 1] * 1.08) continue;
|
||||
const pos = positionOf(f);
|
||||
if (pos < 0 || pos > extent) continue;
|
||||
if (placed.some((p) => Math.abs(p.pos - pos) < minGapPx)) continue;
|
||||
placed.push({ f, pos });
|
||||
}
|
||||
}
|
||||
return placed.sort((a, b) => a.pos - b.pos);
|
||||
}
|
||||
|
||||
/* ── views ──────────────────────────────────────────────────────────── */
|
||||
|
||||
function buildView(variant, root, moduleApi) {
|
||||
switch (variant) {
|
||||
case 'bars': return barsView(root, moduleApi);
|
||||
case 'rta': return spectrumView(root, moduleApi, false);
|
||||
case 'spectrogram': return spectrumView(root, moduleApi, true);
|
||||
default: return readoutView(root, moduleApi);
|
||||
}
|
||||
}
|
||||
|
||||
function readoutView(root, moduleApi) {
|
||||
root.innerHTML = `
|
||||
<div class="spl-readout">
|
||||
<div class="spl-value"><span class="spl-num">––.–</span><span class="spl-unit">dB</span></div>
|
||||
<div class="spl-sub"></div>
|
||||
</div>`;
|
||||
const num = root.querySelector('.spl-num');
|
||||
const unit = root.querySelector('.spl-unit');
|
||||
const sub = root.querySelector('.spl-sub');
|
||||
const box = root.querySelector('.spl-readout');
|
||||
const value = root.querySelector('.spl-value');
|
||||
let fitKey = '';
|
||||
|
||||
/**
|
||||
* Grow the number until it fills the tile, measuring the text we actually
|
||||
* have rather than assuming a digit count.
|
||||
*
|
||||
* The CSS can only guess at width — it has to pick a cqw coefficient for
|
||||
* some number of characters, and then "-100.5" overflows the tile that
|
||||
* "-45.9" merely filled. Measuring costs one layout read, and only when
|
||||
* the tile size or the length of the reading changes.
|
||||
*/
|
||||
function fit() {
|
||||
const w = root.clientWidth, h = root.clientHeight;
|
||||
const key = `${w}x${h}:${num.textContent.length}:${unit.textContent}:${sub.hidden}`;
|
||||
if (key === fitKey || w < 2 || h < 2) return;
|
||||
fitKey = key;
|
||||
|
||||
const TRIAL = 100;
|
||||
num.style.fontSize = `${TRIAL}px`;
|
||||
unit.style.fontSize = `${TRIAL * 0.26}px`;
|
||||
const natural = value.scrollWidth || 1;
|
||||
|
||||
// Leave the caption its room when it is showing, and a little air either
|
||||
// way so descenders and the tile's padding never clip.
|
||||
const subH = sub.offsetParent ? sub.getBoundingClientRect().height + 6 : 0;
|
||||
const byHeight = (h - subH) * 0.92;
|
||||
const byWidth = ((w - 8) * TRIAL) / natural;
|
||||
const size = Math.max(12, Math.min(byHeight, byWidth));
|
||||
num.style.fontSize = `${size}px`;
|
||||
unit.style.fontSize = `${size * 0.26}px`;
|
||||
}
|
||||
|
||||
return {
|
||||
render(payload) {
|
||||
const s = moduleApi.instanceSettings;
|
||||
const ch = pickChannel(payload, s);
|
||||
if (!payload || payload.error || !ch) {
|
||||
num.textContent = '––.–';
|
||||
sub.textContent = payload && payload.error ? payload.error : '';
|
||||
box.dataset.level = 'idle';
|
||||
fit();
|
||||
return;
|
||||
}
|
||||
const value = readValue(ch, s);
|
||||
num.textContent = fmt(value);
|
||||
unit.textContent = payload.calibrated ? 'dB' : 'dBFS';
|
||||
|
||||
const warn = Number(s.warnAt), alarm = Number(s.alarmAt);
|
||||
box.dataset.level = !payload.calibrated ? 'idle'
|
||||
: value >= alarm ? 'alarm' : value >= warn ? 'warn' : 'ok';
|
||||
|
||||
const raw = s.metric || 'level';
|
||||
const metric = LEGACY_METRIC[raw] || raw;
|
||||
const avg = LEGACY_AVERAGING[raw] || s.averaging || 'fast';
|
||||
const label = metric === 'level'
|
||||
? ({ fast: 'Fast', slow: 'Slow', inf: 'Inf', none: 'No avg' }[avg] || 'Fast')
|
||||
: ({ leq1m: 'Leq 1 min', leq10m: 'Leq 10 min', peakC: 'Peak C' }[metric] || metric);
|
||||
const parts = [`${payload.weighting || '?'} · ${label}`, `ch ${ch.i}`];
|
||||
if (ch.clip) parts.push('CLIP');
|
||||
if (!payload.calibrated) parts.push('uncalibrated');
|
||||
sub.hidden = s.caption === false;
|
||||
sub.textContent = parts.join(' · ');
|
||||
fit();
|
||||
},
|
||||
resize() { fitKey = ''; fit(); },
|
||||
destroy() { root.innerHTML = ''; },
|
||||
};
|
||||
}
|
||||
|
||||
function barsView(root, moduleApi) {
|
||||
root.innerHTML = '<div class="spl-bars"></div>';
|
||||
const host = root.querySelector('.spl-bars');
|
||||
let built = '';
|
||||
|
||||
return {
|
||||
render(payload) {
|
||||
if (!payload || !payload.frame) { host.textContent = payload?.error || ''; built = ''; return; }
|
||||
const chans = payload.frame.ch;
|
||||
const key = chans.map((c) => c.i).join(',');
|
||||
if (key !== built) {
|
||||
built = key;
|
||||
host.innerHTML = chans.map((c) => `
|
||||
<div class="spl-bar" data-ch="${c.i}">
|
||||
<div class="spl-bar-label">${c.i}</div>
|
||||
<div class="spl-bar-track"><div class="spl-bar-fill"></div><div class="spl-bar-peak"></div></div>
|
||||
<div class="spl-bar-value">––.–</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
const s = moduleApi.instanceSettings;
|
||||
const warn = Number(s.warnAt), alarm = Number(s.alarmAt);
|
||||
const showPeak = s.peakHold !== false;
|
||||
const lo = payload.calibrated ? 40 : -80;
|
||||
const hi = payload.calibrated ? 110 : 0;
|
||||
for (const c of chans) {
|
||||
const el = host.querySelector(`.spl-bar[data-ch="${c.i}"]`);
|
||||
if (!el) continue;
|
||||
const value = readValue(c, { averaging: s.averaging });
|
||||
const pct = (v) => Math.max(0, Math.min(100, ((v - lo) / (hi - lo)) * 100));
|
||||
el.querySelector('.spl-bar-fill').style.width = `${pct(value)}%`;
|
||||
const peak = el.querySelector('.spl-bar-peak');
|
||||
peak.style.display = showPeak ? '' : 'none';
|
||||
if (showPeak) peak.style.left = `${pct(c.peakC)}%`;
|
||||
el.querySelector('.spl-bar-value').textContent = fmt(value);
|
||||
el.dataset.level = !payload.calibrated ? 'idle'
|
||||
: value >= alarm ? 'alarm' : value >= warn ? 'warn' : 'ok';
|
||||
el.classList.toggle('clip', !!c.clip);
|
||||
}
|
||||
},
|
||||
resize() {},
|
||||
destroy() { root.innerHTML = ''; },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* RTA bars, or the same folded bands scrolling as a spectrogram.
|
||||
*
|
||||
* Canvas for both, because both redraw wholesale many times a second and a
|
||||
* few hundred animating DOM nodes is how a dashboard starts dropping frames
|
||||
* on the machine that is also running the service.
|
||||
*/
|
||||
function spectrumView(root, moduleApi, scrolling) {
|
||||
root.innerHTML = '<canvas class="spl-canvas"></canvas>';
|
||||
const canvas = root.querySelector('.spl-canvas');
|
||||
const ctx = canvas.getContext('2d', { alpha: false });
|
||||
let painted = false; // spectrogram: has the background been laid down
|
||||
let smoothed = null; // RTA: displayed values, damped toward the real ones
|
||||
let smoothedFor = '';
|
||||
let axisBtn = null;
|
||||
let axisOverride = null; // set by the title-bar toggle, which never remounts us
|
||||
|
||||
function axis() {
|
||||
return axisOverride || moduleApi.instanceSettings.axis || 'x';
|
||||
}
|
||||
|
||||
if (scrolling) {
|
||||
// The axis lives here and nowhere else (rule 11): a tile does not offer
|
||||
// the same action twice. Icon only, tooltip carries the words (rule 9).
|
||||
// saveInstanceSettings deliberately does not remount us, so the new
|
||||
// value is held here as well and read back by axis().
|
||||
const swapIcon = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" '
|
||||
+ 'stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">'
|
||||
+ '<path d="M2 5h9M8.5 2 11.5 5 8.5 8"/><path d="M14 11H5M7.5 8 4.5 11 7.5 14"/></svg>';
|
||||
const describe = () => (axis() === 'x' ? 'Time left to right' : 'Time top to bottom');
|
||||
axisBtn = moduleApi.header.addButton({
|
||||
icon: swapIcon,
|
||||
title: describe(),
|
||||
onClick() {
|
||||
axisOverride = axis() === 'x' ? 'y' : 'x';
|
||||
moduleApi.saveInstanceSettings({ axis: axisOverride });
|
||||
painted = false; // the picture means nothing in the other orientation
|
||||
axisBtn.title = describe();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function sizeCanvas() {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = Math.max(1, Math.floor(root.clientWidth));
|
||||
const h = Math.max(1, Math.floor(root.clientHeight));
|
||||
if (canvas.width === Math.floor(w * dpr) && canvas.height === Math.floor(h * dpr)) return false;
|
||||
canvas.width = Math.floor(w * dpr);
|
||||
canvas.height = Math.floor(h * dpr);
|
||||
canvas.style.width = `${w}px`;
|
||||
canvas.style.height = `${h}px`;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
painted = false;
|
||||
return true;
|
||||
}
|
||||
sizeCanvas();
|
||||
|
||||
function norm(db, calibrated) {
|
||||
const lo = calibrated ? 30 : -100;
|
||||
const hi = calibrated ? 110 : -10;
|
||||
return Math.max(0, Math.min(1, (db - lo) / (hi - lo)));
|
||||
}
|
||||
|
||||
function heat(v) {
|
||||
const stops = [[10, 12, 20], [30, 90, 150], [90, 180, 140], [230, 190, 80], [220, 80, 60]];
|
||||
const x = Math.max(0, Math.min(0.999, v)) * (stops.length - 1);
|
||||
const i = Math.floor(x), t = x - i;
|
||||
const a = stops[i], b = stops[i + 1] || stops[i];
|
||||
return `rgb(${Math.round(a[0] + (b[0] - a[0]) * t)},${Math.round(a[1] + (b[1] - a[1]) * t)},${Math.round(a[2] + (b[2] - a[2]) * t)})`;
|
||||
}
|
||||
|
||||
return {
|
||||
render(payload) {
|
||||
sizeCanvas();
|
||||
const w = canvas.clientWidth, h = canvas.clientHeight;
|
||||
const bg = themeColor(root, '--panel', '#141414');
|
||||
const muted = themeColor(root, '--muted', '#888');
|
||||
const border = themeColor(root, '--border', '#333');
|
||||
|
||||
if (!payload || !payload.frame || !payload.frame.bands) {
|
||||
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
|
||||
ctx.fillStyle = muted; ctx.font = '12px system-ui'; ctx.textAlign = 'center';
|
||||
ctx.fillText(payload?.error || 'Spectrum analysis is off', w / 2, h / 2);
|
||||
painted = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const s = moduleApi.instanceSettings;
|
||||
const want = Number(s.channel);
|
||||
const idx = Math.max(0, payload.frame.ch.findIndex((c) => c.i === want));
|
||||
const raw = payload.frame.bands[idx] || payload.frame.bands[0] || [];
|
||||
if (!raw.length) return;
|
||||
|
||||
const denom = Number(s.banding) || 3;
|
||||
const folded = foldBands(raw, payload.bandN0 || 0, payload.bandsPerOctave || 48, denom);
|
||||
const freqs = folded.freq;
|
||||
let values = folded.db;
|
||||
const grid = s.gridlines === true;
|
||||
const showLabels = s.labels !== false;
|
||||
const axisPad = showLabels || grid ? 13 : 0;
|
||||
|
||||
if (!scrolling) {
|
||||
// Damp the bars toward the measurement. Cosmetic: `values` is what
|
||||
// gets drawn, never what gets reported.
|
||||
const keep = SMOOTHING[s.smoothing] ?? SMOOTHING.medium;
|
||||
const key = `${denom}:${values.length}`;
|
||||
if (smoothedFor !== key || !smoothed || smoothed.length !== values.length) {
|
||||
smoothed = values.slice();
|
||||
smoothedFor = key;
|
||||
} else if (keep > 0) {
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
smoothed[i] = smoothed[i] * keep + values[i] * (1 - keep);
|
||||
}
|
||||
} else {
|
||||
smoothed = values.slice();
|
||||
}
|
||||
values = smoothed;
|
||||
|
||||
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
|
||||
const plotH = h - axisPad;
|
||||
const bw = w / values.length;
|
||||
|
||||
const xOf = (f) => (Math.log2(f / freqs[0]) / Math.log2(freqs[freqs.length - 1] / freqs[0])) * w;
|
||||
const labels = chooseLabels(freqs, xOf, w, 34);
|
||||
|
||||
if (grid) {
|
||||
ctx.strokeStyle = border; ctx.lineWidth = 1; ctx.globalAlpha = 0.5;
|
||||
ctx.beginPath();
|
||||
for (const l of labels) {
|
||||
const x = Math.round(l.pos) + 0.5;
|
||||
ctx.moveTo(x, 0); ctx.lineTo(x, plotH);
|
||||
}
|
||||
for (let g = 0.2; g < 1; g += 0.2) {
|
||||
const y = Math.round(plotH * g) + 0.5;
|
||||
ctx.moveTo(0, y); ctx.lineTo(w, y);
|
||||
}
|
||||
ctx.stroke(); ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
const v = norm(values[i], payload.calibrated);
|
||||
const bh = v * plotH;
|
||||
ctx.fillStyle = heat(v);
|
||||
ctx.fillRect(i * bw, plotH - bh, Math.max(1, bw - (bw > 3 ? 1 : 0)), bh);
|
||||
}
|
||||
|
||||
if (showLabels) {
|
||||
ctx.fillStyle = muted; ctx.font = '9px system-ui'; ctx.textAlign = 'center';
|
||||
for (const l of labels) ctx.fillText(hzLabel(l.f), l.pos, h - 3);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── spectrogram ──
|
||||
const timeX = axis() === 'x';
|
||||
const colW = 2;
|
||||
const gutter = showLabels ? axisPad : 0;
|
||||
// Frequency occupies whichever axis time does not.
|
||||
const freqExtent = timeX ? h - gutter : w - gutter;
|
||||
const timeExtent = timeX ? w : h;
|
||||
|
||||
if (!painted) { ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h); painted = true; }
|
||||
|
||||
// Source rect in device pixels, destination in CSS pixels: the context
|
||||
// carries a devicePixelRatio transform but the canvas as an image
|
||||
// source does not, and letting drawImage infer them scales the picture
|
||||
// up and pushes every column off the canvas.
|
||||
if (timeX) {
|
||||
ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, -colW, 0, w, h);
|
||||
} else {
|
||||
ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, 0, -colW, w, h);
|
||||
}
|
||||
|
||||
const bandExtent = freqExtent / values.length;
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
ctx.fillStyle = heat(norm(values[i], payload.calibrated));
|
||||
if (timeX) {
|
||||
// Low frequencies at the bottom, as every spectrogram has them.
|
||||
ctx.fillRect(timeExtent - colW, freqExtent - (i + 1) * bandExtent,
|
||||
colW, Math.ceil(bandExtent) + 1);
|
||||
} else {
|
||||
// Low frequencies at the left.
|
||||
ctx.fillRect(gutter + i * bandExtent, timeExtent - colW,
|
||||
Math.ceil(bandExtent) + 1, colW);
|
||||
}
|
||||
}
|
||||
|
||||
if (grid || showLabels) {
|
||||
const posOf = (f) => {
|
||||
const t = Math.log2(f / freqs[0]) / Math.log2(freqs[freqs.length - 1] / freqs[0]);
|
||||
return timeX ? freqExtent - t * freqExtent : gutter + t * freqExtent;
|
||||
};
|
||||
const labels = chooseLabels(freqs, posOf, timeX ? freqExtent : w, timeX ? 16 : 34);
|
||||
ctx.fillStyle = bg;
|
||||
if (timeX) ctx.fillRect(0, h - gutter, w, gutter);
|
||||
else ctx.fillRect(0, 0, gutter, h);
|
||||
ctx.fillStyle = muted; ctx.font = '9px system-ui';
|
||||
ctx.strokeStyle = border; ctx.globalAlpha = grid ? 0.35 : 0; ctx.beginPath();
|
||||
for (const l of labels) {
|
||||
if (timeX) {
|
||||
const y = Math.round(l.pos) + 0.5;
|
||||
ctx.moveTo(0, y); ctx.lineTo(w, y);
|
||||
} else {
|
||||
const x = Math.round(l.pos) + 0.5;
|
||||
ctx.moveTo(x, 0); ctx.lineTo(x, h);
|
||||
}
|
||||
}
|
||||
ctx.stroke(); ctx.globalAlpha = 1;
|
||||
ctx.textAlign = timeX ? 'left' : 'center';
|
||||
for (const l of showLabels ? labels : []) {
|
||||
if (timeX) ctx.fillText(hzLabel(l.f), 2, Math.min(h - gutter - 2, l.pos + 3));
|
||||
else ctx.fillText(hzLabel(l.f), l.pos, h - 3);
|
||||
}
|
||||
}
|
||||
},
|
||||
resize() { sizeCanvas(); },
|
||||
destroy() { root.innerHTML = ''; },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"id": "spl-meter",
|
||||
"name": "SPL Meter",
|
||||
"version": "0.3.1",
|
||||
"proddash": ">=1.5.0",
|
||||
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
|
||||
"permissions": [
|
||||
{
|
||||
"kind": "microphone",
|
||||
"reason": "Measuring sound level from an audio input on this machine"
|
||||
}
|
||||
],
|
||||
"client": "client.js",
|
||||
"server": "server.js",
|
||||
"style": "style.css",
|
||||
"minSize": {
|
||||
"w": 2,
|
||||
"h": 1
|
||||
},
|
||||
"defaultSize": {
|
||||
"w": 3,
|
||||
"h": 2
|
||||
},
|
||||
"configGroups": {
|
||||
"Input": {
|
||||
"toggle": "enabled"
|
||||
},
|
||||
"Measurement": {
|
||||
"columns": 2
|
||||
},
|
||||
"Calibration": {
|
||||
"collapsed": "whenSet",
|
||||
"help": "A sound level calibrator fits over the microphone capsule and emits a known level, almost always 94 dB at 1 kHz. Put it on, press Measure beside the channel, and copy the offset it reports into the box. Without this the meter still works, but it reads in dB relative to full scale and every tile says so."
|
||||
}
|
||||
},
|
||||
"configSchema": {
|
||||
"enabled": {
|
||||
"type": "switch",
|
||||
"label": "Sound level measurement",
|
||||
"group": "Input",
|
||||
"default": false
|
||||
},
|
||||
"device": {
|
||||
"type": "select",
|
||||
"label": "Audio device",
|
||||
"group": "Input",
|
||||
"showWhen": "enabled",
|
||||
"default": "",
|
||||
"optionsRoute": "/devices"
|
||||
},
|
||||
"channels": {
|
||||
"type": "string",
|
||||
"label": "Channels on that device (1-based, comma separated)",
|
||||
"group": "Input",
|
||||
"showWhen": "enabled",
|
||||
"default": "1",
|
||||
"help": "One meter per channel — \"1\" for a single measurement mic, \"1,2\" for a stereo pair."
|
||||
},
|
||||
"weighting": {
|
||||
"type": "select",
|
||||
"label": "Weighting",
|
||||
"group": "Measurement",
|
||||
"default": "A",
|
||||
"options": [
|
||||
{
|
||||
"value": "A",
|
||||
"label": "A — how loud it sounds"
|
||||
},
|
||||
{
|
||||
"value": "C",
|
||||
"label": "C — keeps the low end"
|
||||
},
|
||||
{
|
||||
"value": "Z",
|
||||
"label": "Z — no weighting at all"
|
||||
}
|
||||
]
|
||||
},
|
||||
"bands": {
|
||||
"type": "switch",
|
||||
"label": "Spectrum analysis",
|
||||
"group": "Measurement",
|
||||
"default": true,
|
||||
"help": "1/48-octave bands for the RTA and spectrogram tiles, which each fold them to their own banding. Turn it off if you only ever show the number."
|
||||
},
|
||||
"calibration": {
|
||||
"type": "string",
|
||||
"label": "Offsets in dB, one per channel",
|
||||
"group": "Calibration",
|
||||
"default": "",
|
||||
"help": "Leave empty and every reading is dB relative to full scale, labelled dBFS so nobody mistakes it for real SPL."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* SPL Meter server entry.
|
||||
*
|
||||
* Owns one capture child (spl-listener.js) and fans its frames out to every
|
||||
* tile over SSE. Browsers never see the audio device, only the numbers.
|
||||
*
|
||||
* GET /stream → SSE, `state` events at the capture rate
|
||||
* GET /state → the latest frame, for an instant first paint
|
||||
* GET /devices → input devices, for the admin page's picker
|
||||
* GET /channels → configured channels, for the tile gear picker
|
||||
* POST /reset → clear Leq and peak hold
|
||||
* GET /measure → what a calibrator is reading right now, so the
|
||||
* admin page can tell the operator what to type
|
||||
*
|
||||
* Frames arrive 20×/s. That is the right rate for a meter to feel live and
|
||||
* the wrong rate to re-broadcast blindly, so the SSE fan-out drops a frame
|
||||
* whose numbers round to what the last one said.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const { createSplListener } = require('./spl-listener');
|
||||
|
||||
/** Reference level of a sound level calibrator. Effectively always 94 dB. */
|
||||
const CALIBRATOR_DB = 94;
|
||||
/** How long /measure averages for. Long enough to ride out a wobble, short
|
||||
enough that nobody standing at the microphone gets bored. */
|
||||
const MEASURE_MS = 3000;
|
||||
/** SSE comment ping — keeps idle connections alive through sleepy Wi-Fi. */
|
||||
const PING_MS = 15000;
|
||||
|
||||
/** Set by init(), read by routes() — both are rebuilt together on re-init. */
|
||||
let state = null;
|
||||
|
||||
/* ── shared tile settings ───────────────────────────────────────────── */
|
||||
|
||||
const CHANNEL_FIELD = {
|
||||
type: 'select', label: 'Channel', default: '', optionsRoute: '/channels',
|
||||
};
|
||||
|
||||
/**
|
||||
* Averaging is a view choice, not a measurement one: the capture child
|
||||
* computes all four every frame and sends all four, so one dashboard on Fast
|
||||
* and another on Inf are reading the same measurement two ways.
|
||||
*/
|
||||
function averagingField() {
|
||||
return {
|
||||
type: 'select', label: 'Averaging', default: 'fast',
|
||||
options: [
|
||||
{ value: 'fast', label: 'Fast · 125 ms' },
|
||||
{ value: 'slow', label: 'Slow · 1 s' },
|
||||
{ value: 'inf', label: 'Inf · since reset' },
|
||||
{ value: 'none', label: 'None' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Banding folds the 1/48-octave bands on the wire down to something
|
||||
* readable. Coarser is an exact sum of finer, so this costs nothing and
|
||||
* disturbs no other tile.
|
||||
*/
|
||||
const BANDING_FIELD = {
|
||||
type: 'select', label: 'Banding', default: '3',
|
||||
options: [
|
||||
{ value: '3', label: '1/3 octave' },
|
||||
{ value: '6', label: '1/6 octave' },
|
||||
{ value: '12', label: '1/12 octave' },
|
||||
{ value: '24', label: '1/24 octave' },
|
||||
{ value: '48', label: '1/48 octave' },
|
||||
],
|
||||
};
|
||||
|
||||
function parseChannels(raw) {
|
||||
return String(raw || '1')
|
||||
.split(',')
|
||||
.map((s) => Number(s.trim()))
|
||||
.filter((n) => Number.isInteger(n) && n >= 1 && n <= 64);
|
||||
}
|
||||
|
||||
function parseCalibration(raw) {
|
||||
if (!String(raw || '').trim()) return [];
|
||||
return String(raw)
|
||||
.split(',')
|
||||
.map((s) => Number(s.trim()))
|
||||
.filter((n) => Number.isFinite(n));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init({ config, log }) {
|
||||
const channels = parseChannels(config.channels);
|
||||
const calibration = parseCalibration(config.calibration);
|
||||
const moduleDir = __dirname;
|
||||
|
||||
state = {
|
||||
channels,
|
||||
calibrated: calibration.length === channels.length && channels.length > 0,
|
||||
frame: null,
|
||||
meta: null,
|
||||
error: '',
|
||||
streams: new Set(),
|
||||
listener: null,
|
||||
lastSent: '',
|
||||
pinger: null,
|
||||
};
|
||||
|
||||
if (!config.enabled) {
|
||||
state.error = 'Sound level measurement is off — turn it on in ProdDash settings';
|
||||
return { stop() { teardown(); }, health: () => ({ status: 'error', message: state.error }) };
|
||||
}
|
||||
if (!config.device) {
|
||||
state.error = 'No audio device chosen yet';
|
||||
return { stop() { teardown(); }, health: () => ({ status: 'error', message: state.error }) };
|
||||
}
|
||||
if (!channels.length) {
|
||||
state.error = `Cannot read "${config.channels}" as a channel list — try 1, or 1,2`;
|
||||
return { stop() { teardown(); }, health: () => ({ status: 'error', message: state.error }) };
|
||||
}
|
||||
if (calibration.length && calibration.length !== channels.length) {
|
||||
state.error = `${channels.length} channel(s) but ${calibration.length} calibration offset(s) — `
|
||||
+ 'give one per channel, or leave the offsets empty';
|
||||
return { stop() { teardown(); }, health: () => ({ status: 'error', message: state.error }) };
|
||||
}
|
||||
|
||||
state.listener = createSplListener({
|
||||
device: config.device,
|
||||
channels,
|
||||
weighting: config.weighting || 'A',
|
||||
bands: config.bands !== false,
|
||||
calibration,
|
||||
moduleDir,
|
||||
log,
|
||||
onUpdate(update) {
|
||||
if (!state) return; // a frame in flight when the module unmounted
|
||||
if (update.error) {
|
||||
if (update.error !== state.error) log(update.error);
|
||||
state.error = update.error;
|
||||
state.frame = null;
|
||||
broadcast();
|
||||
return;
|
||||
}
|
||||
state.error = '';
|
||||
state.frame = update.frame;
|
||||
state.meta = update.meta;
|
||||
broadcast();
|
||||
},
|
||||
});
|
||||
|
||||
const mineStreams = state.streams;
|
||||
state.pinger = setInterval(() => {
|
||||
for (const res of mineStreams) {
|
||||
try { res.write(': ping\n\n'); } catch { mineStreams.delete(res); }
|
||||
}
|
||||
}, PING_MS);
|
||||
|
||||
return {
|
||||
stop() { teardown(); },
|
||||
health() {
|
||||
if (state.error) return { status: 'error', message: state.error };
|
||||
if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' };
|
||||
const n = state.channels.length;
|
||||
const what = `${n} channel${n === 1 ? '' : 's'} on ${config.device}`;
|
||||
return state.calibrated
|
||||
? { status: 'ok', message: what }
|
||||
: { status: 'ok', message: `${what} — uncalibrated, reading dBFS` };
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Four tile kinds, each with only the settings that do something to it.
|
||||
*
|
||||
* An entry's own instanceSchema replaces the module's, which is the point:
|
||||
* an RTA has no use for an alarm threshold and a readout has no use for a
|
||||
* banding, and offering either is how a gear popover stops being read.
|
||||
*
|
||||
* Every setting here is a view choice. None of them reach the capture
|
||||
* child — it already sends every averaging and the finest banding, so two
|
||||
* dashboards can disagree about how to draw the same room.
|
||||
*/
|
||||
tiles() {
|
||||
return [
|
||||
{
|
||||
id: 'readout', name: 'SPL', description: 'The level, big',
|
||||
defaultSize: { w: 2, h: 1 }, minSize: { w: 1, h: 1 },
|
||||
instanceGroups: { Thresholds: { columns: 2 } },
|
||||
instanceSchema: {
|
||||
channel: CHANNEL_FIELD,
|
||||
averaging: averagingField(),
|
||||
metric: {
|
||||
type: 'select', label: 'Show', default: 'level',
|
||||
options: [
|
||||
{ value: 'level', label: 'Level' },
|
||||
{ value: 'leq1m', label: 'Leq 1 min' },
|
||||
{ value: 'leq10m', label: 'Leq 10 min' },
|
||||
{ value: 'peakC', label: 'Peak C' },
|
||||
],
|
||||
},
|
||||
caption: { type: 'switch', label: 'Caption', default: true },
|
||||
warnAt: { type: 'number', label: 'Amber above', group: 'Thresholds', default: 95 },
|
||||
alarmAt: { type: 'number', label: 'Red above', group: 'Thresholds', default: 100 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'bars', name: 'SPL Levels', description: 'One bar per channel, with peak hold',
|
||||
defaultSize: { w: 3, h: 2 }, minSize: { w: 2, h: 1 },
|
||||
instanceGroups: { Thresholds: { columns: 2 } },
|
||||
instanceSchema: {
|
||||
averaging: averagingField(),
|
||||
peakHold: { type: 'switch', label: 'Peak hold', default: true },
|
||||
warnAt: { type: 'number', label: 'Amber above', group: 'Thresholds', default: 95 },
|
||||
alarmAt: { type: 'number', label: 'Red above', group: 'Thresholds', default: 100 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'rta', name: 'RTA', description: 'Spectrum now, at your choice of banding',
|
||||
defaultSize: { w: 5, h: 3 }, minSize: { w: 3, h: 2 },
|
||||
instanceSchema: {
|
||||
channel: CHANNEL_FIELD,
|
||||
averaging: averagingField(),
|
||||
banding: BANDING_FIELD,
|
||||
smoothing: {
|
||||
type: 'select', label: 'Smoothing', default: 'medium',
|
||||
options: [
|
||||
{ value: 'off', label: 'Off' },
|
||||
{ value: 'light', label: 'Light' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'heavy', label: 'Heavy' },
|
||||
],
|
||||
},
|
||||
labels: { type: 'switch', label: 'Labels', default: true },
|
||||
gridlines: { type: 'switch', label: 'Gridlines', default: false },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'spectrogram', name: 'Spectrogram', description: 'Spectrum over time',
|
||||
defaultSize: { w: 5, h: 3 }, minSize: { w: 3, h: 2 },
|
||||
instanceSchema: {
|
||||
channel: CHANNEL_FIELD,
|
||||
banding: BANDING_FIELD,
|
||||
labels: { type: 'switch', label: 'Labels', default: true },
|
||||
gridlines: { type: 'switch', label: 'Gridlines', default: false },
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
routes({ config, log }) {
|
||||
return {
|
||||
'GET /state': (req, res) => {
|
||||
json(res, 200, 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',
|
||||
});
|
||||
res.write('retry: 3000\n\n');
|
||||
try {
|
||||
res.write(`event: state\ndata: ${JSON.stringify(snapshot())}\n\n`);
|
||||
} catch { return; }
|
||||
// Hold this mount's set, not `state`. teardown() nulls `state` and
|
||||
// then ends every stream, which fires these close handlers — a
|
||||
// handler that reached through `state` threw on null and took the
|
||||
// whole server down with it, on any re-init with a browser
|
||||
// connected. Deleting from a set nobody reads any more is harmless.
|
||||
const streams = state.streams;
|
||||
streams.add(res);
|
||||
req.on('close', () => streams.delete(res));
|
||||
},
|
||||
|
||||
/** Device list for the admin page. Runs the capture tool's --list,
|
||||
which is the only thing that knows what CoreAudio can see. */
|
||||
'GET /devices': async (req, res) => {
|
||||
try {
|
||||
const devices = await listDevices();
|
||||
json(res, 200, {
|
||||
options: devices.map((d) => ({
|
||||
value: d.name,
|
||||
label: `${d.name} — ${d.channels} ch @ ${Math.round(d.sampleRate)} Hz`,
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
log(`device list failed: ${err.message}`);
|
||||
json(res, 200, { options: [] }); // the saved value stays selectable
|
||||
}
|
||||
},
|
||||
|
||||
/** Channel list for a tile's gear popover. */
|
||||
'GET /channels': (req, res) => {
|
||||
json(res, 200, {
|
||||
options: state.channels.map((c) => ({ value: String(c), label: `Channel ${c}` })),
|
||||
});
|
||||
},
|
||||
|
||||
'POST /reset': (req, res) => {
|
||||
if (state.listener) state.listener.reset();
|
||||
json(res, 200, { ok: true });
|
||||
},
|
||||
|
||||
/**
|
||||
* What the microphone is hearing right now, unweighted and
|
||||
* uncalibrated, plus the offset that would make it read 94 dB.
|
||||
*
|
||||
* The module cannot write its own admin config, so this does not save
|
||||
* anything — it hands back a number for the operator to paste into the
|
||||
* Calibration box. Honest, if one step longer than it should be.
|
||||
*/
|
||||
'GET /measure': (req, res) => {
|
||||
if (!state.listener || state.error) {
|
||||
json(res, 409, { error: state.error || 'not measuring' });
|
||||
return;
|
||||
}
|
||||
const wanted = Number(new URLSearchParams(req.search || '').get('channel'))
|
||||
|| state.channels[0];
|
||||
const samples = [];
|
||||
const started = Date.now();
|
||||
const tick = setInterval(() => {
|
||||
if (!state) { clearInterval(tick); return; } // module unmounted mid-measure
|
||||
const ch = state.frame && state.frame.ch.find((c) => c.i === wanted);
|
||||
if (ch && Number.isFinite(ch.dbfs) && ch.dbfs > -200) samples.push(ch.dbfs);
|
||||
if (Date.now() - started < MEASURE_MS) return;
|
||||
clearInterval(tick);
|
||||
if (samples.length < 5) {
|
||||
json(res, 200, {
|
||||
channel: wanted,
|
||||
error: 'No steady tone on that channel — check the calibrator is on and seated',
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Mean of the linear powers, not of the decibels: averaging dB
|
||||
// would quietly under-read a signal that wobbles at all.
|
||||
const power = samples.reduce((s, db) => s + 10 ** (db / 10), 0) / samples.length;
|
||||
const measured = 10 * Math.log10(power);
|
||||
json(res, 200, {
|
||||
channel: wanted,
|
||||
measured: Math.round(measured * 100) / 100,
|
||||
reference: CALIBRATOR_DB,
|
||||
offset: Math.round((CALIBRATOR_DB - measured) * 100) / 100,
|
||||
samples: samples.length,
|
||||
});
|
||||
}, 100);
|
||||
req.on('close', () => clearInterval(tick));
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/* ── helpers ────────────────────────────────────────────────────────── */
|
||||
|
||||
function snapshot() {
|
||||
if (!state) return { error: 'not measuring', calibrated: false, channels: [], frame: null };
|
||||
return {
|
||||
error: state.error,
|
||||
calibrated: state.calibrated,
|
||||
channels: state.channels,
|
||||
weighting: state.meta ? state.meta.weighting : '',
|
||||
bandCentres: state.meta ? state.meta.bands : [],
|
||||
bandN0: state.meta ? state.meta.bandN0 : 0,
|
||||
bandsPerOctave: state.meta ? state.meta.bandsPerOctave : 48,
|
||||
frame: state.frame,
|
||||
};
|
||||
}
|
||||
|
||||
function broadcast() {
|
||||
if (!state) return; // a listener callback landing after teardown
|
||||
const payload = snapshot();
|
||||
const line = JSON.stringify(payload);
|
||||
if (line === state.lastSent) return; // nothing moved since last frame
|
||||
state.lastSent = line;
|
||||
for (const res of state.streams) {
|
||||
try { res.write(`event: state\ndata: ${line}\n\n`); } catch { state.streams.delete(res); }
|
||||
}
|
||||
}
|
||||
|
||||
function teardown() {
|
||||
if (!state) return;
|
||||
if (state.listener) state.listener.stop();
|
||||
clearInterval(state.pinger);
|
||||
for (const res of state.streams) { try { res.end(); } catch { /* already gone */ } }
|
||||
state.streams.clear();
|
||||
state = null;
|
||||
}
|
||||
|
||||
function listDevices() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { execFile } = require('child_process');
|
||||
execFile(path.join(__dirname, 'spl-capture'), ['--list'], { timeout: 10000 },
|
||||
(err, stdout) => {
|
||||
if (err && !stdout) { reject(err); return; }
|
||||
try { resolve(JSON.parse(stdout).devices || []); }
|
||||
catch (e) { reject(e); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function json(res, code, body) {
|
||||
const text = JSON.stringify(body);
|
||||
res.writeHead(code, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Length': Buffer.byteLength(text),
|
||||
});
|
||||
res.end(text);
|
||||
}
|
||||
@@ -0,0 +1,735 @@
|
||||
// spl-capture — calibrated sound-level measurement from a CoreAudio input.
|
||||
//
|
||||
// Sibling of the Timers module's ltc-capture: same device enumeration, same
|
||||
// "stdout is data, stderr is talk" contract, same one-file-no-dependencies
|
||||
// rule. The difference is where the work happens. LTC ships raw samples to
|
||||
// Node because the decode is cheap and single-channel; sound level is
|
||||
// filtering and FFTs over every channel at the device's rate, so the
|
||||
// measurement is done here and only the answers cross the pipe.
|
||||
//
|
||||
// Everything below uses Accelerate, which ships with macOS. No third-party
|
||||
// code, no package manager, ~2 s to build:
|
||||
//
|
||||
// swiftc -O -o modules/spl-meter/spl-capture \
|
||||
// modules/spl-meter/spl-capture.swift
|
||||
//
|
||||
// spl-capture --list
|
||||
// every input-capable device: name, input channels, sample rate
|
||||
// spl-capture --device "Dante Via 16 Channel" --channels 1,2 [--cal 0,0]
|
||||
// measures those 1-based channels and writes one JSON line per frame
|
||||
// to stdout, 20×/s. --cal takes one dB offset per channel, in the same
|
||||
// order, from the calibration route (94 dB reference minus what we
|
||||
// measured). With no --cal the numbers are dBFS-relative and the line
|
||||
// says calibrated:false, so nothing downstream can mistake them for
|
||||
// real SPL.
|
||||
//
|
||||
// Per channel, per frame: the level at EVERY averaging a dashboard might
|
||||
// ask for — fast (125 ms), slow (1 s), inf (Leq since reset) and none (this
|
||||
// block alone) — plus rolling 1- and 10-minute Leq, C- and Z-weighted peak,
|
||||
// raw dBFS and a clip flag. Optionally a 1/48-octave band set for the RTA
|
||||
// and spectrogram tiles, only when --bands is passed.
|
||||
//
|
||||
// Everything is sent every frame on purpose. Two dashboards watching the
|
||||
// same room may want different averaging and different banding, and neither
|
||||
// choice may disturb the other or restart capture. So the measurement is
|
||||
// made once, at the finest resolution anyone asked for, and each tile folds
|
||||
// it down to the view it wants. Nothing here is per-viewer state.
|
||||
//
|
||||
// Weighting filters are IEC 61672 pole/zero prototypes, bilinear-transformed
|
||||
// with the top section prewarped, then normalised to exactly 0 dB at 1 kHz.
|
||||
// Verified against the standard's table: within 0.15 dB from 31.5 Hz to
|
||||
// 4 kHz. The top octave is where the bilinear transform runs out of room —
|
||||
// at 48 kHz the 16 kHz point sits about 3 dB low. That is a rounding error
|
||||
// on a broadband programme number (A-weighting has already thrown 6.6 dB
|
||||
// away up there) but it is a real limit, and the fix if it ever matters is
|
||||
// to run the weighting at 96 kHz. `--selftest` prints the table so this
|
||||
// claim can be checked rather than believed.
|
||||
//
|
||||
// macOS asks for microphone permission on first use, and the prompt belongs
|
||||
// to whatever launched this — the ProdDash launcher, or Terminal. A server
|
||||
// started over SSH gets SILENT ZEROS from every input with no error at all,
|
||||
// which shows up downstream as a meter pinned at the floor.
|
||||
|
||||
import AVFoundation
|
||||
import Accelerate
|
||||
import CoreAudio
|
||||
import Foundation
|
||||
|
||||
func fail(_ msg: String) -> Never {
|
||||
FileHandle.standardError.write(("spl-capture: " + msg + "\n").data(using: .utf8)!)
|
||||
exit(2)
|
||||
}
|
||||
|
||||
func note(_ msg: String) {
|
||||
FileHandle.standardError.write(("spl-capture: " + msg + "\n").data(using: .utf8)!)
|
||||
}
|
||||
|
||||
/* ── CoreAudio device enumeration ───────────────────────────────────── */
|
||||
|
||||
struct InputDevice {
|
||||
let id: AudioDeviceID
|
||||
let name: String
|
||||
let uid: String
|
||||
let channels: Int
|
||||
let sampleRate: Double
|
||||
}
|
||||
|
||||
func stringProperty(_ id: AudioObjectID, _ selector: AudioObjectPropertySelector) -> String {
|
||||
var addr = AudioObjectPropertyAddress(
|
||||
mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
var ref: Unmanaged<CFString>? = nil
|
||||
var size = UInt32(MemoryLayout<Unmanaged<CFString>?>.size)
|
||||
let err = withUnsafeMutablePointer(to: &ref) {
|
||||
AudioObjectGetPropertyData(id, &addr, 0, nil, &size, $0)
|
||||
}
|
||||
guard err == noErr, let value = ref?.takeRetainedValue() else { return "" }
|
||||
return value as String
|
||||
}
|
||||
|
||||
func sampleRateProperty(_ id: AudioObjectID) -> Double {
|
||||
var addr = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioDevicePropertyNominalSampleRate,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
var rate: Float64 = 0
|
||||
var size = UInt32(MemoryLayout<Float64>.size)
|
||||
guard AudioObjectGetPropertyData(id, &addr, 0, nil, &size, &rate) == noErr else { return 0 }
|
||||
return rate
|
||||
}
|
||||
|
||||
func inputChannelCount(_ id: AudioDeviceID) -> Int {
|
||||
var addr = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioDevicePropertyStreamConfiguration,
|
||||
mScope: kAudioDevicePropertyScopeInput,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
var size: UInt32 = 0
|
||||
guard AudioObjectGetPropertyDataSize(id, &addr, 0, nil, &size) == noErr, size > 0 else { return 0 }
|
||||
let listPtr = UnsafeMutableRawPointer.allocate(
|
||||
byteCount: Int(size), alignment: MemoryLayout<AudioBufferList>.alignment)
|
||||
defer { listPtr.deallocate() }
|
||||
guard AudioObjectGetPropertyData(id, &addr, 0, nil, &size, listPtr) == noErr else { return 0 }
|
||||
let buffers = UnsafeMutableAudioBufferListPointer(
|
||||
listPtr.assumingMemoryBound(to: AudioBufferList.self))
|
||||
return buffers.reduce(0) { $0 + Int($1.mNumberChannels) }
|
||||
}
|
||||
|
||||
func listInputDevices() -> [InputDevice] {
|
||||
var addr = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyDevices,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
var size: UInt32 = 0
|
||||
guard AudioObjectGetPropertyDataSize(
|
||||
AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size) == noErr else { return [] }
|
||||
var ids = [AudioDeviceID](repeating: 0, count: Int(size) / MemoryLayout<AudioDeviceID>.size)
|
||||
guard AudioObjectGetPropertyData(
|
||||
AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size, &ids) == noErr else { return [] }
|
||||
|
||||
var out: [InputDevice] = []
|
||||
for id in ids {
|
||||
let channels = inputChannelCount(id)
|
||||
if channels == 0 { continue }
|
||||
out.append(InputDevice(id: id,
|
||||
name: stringProperty(id, kAudioObjectPropertyName),
|
||||
uid: stringProperty(id, kAudioDevicePropertyDeviceUID),
|
||||
channels: channels,
|
||||
sampleRate: sampleRateProperty(id)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/* ── frequency weighting (IEC 61672) ────────────────────────────────── */
|
||||
|
||||
/// Pole frequencies from the standard.
|
||||
private let wf1 = 2 * Double.pi * 20.598997
|
||||
private let wf2 = 2 * Double.pi * 107.65265
|
||||
private let wf3 = 2 * Double.pi * 737.86223
|
||||
private let wf4 = 2 * Double.pi * 12194.217
|
||||
|
||||
/// Bilinear-transform one analogue biquad into [b0, b1, b2, a1, a2].
|
||||
///
|
||||
/// `warpAt` prewarps the transform so that section's critical frequency
|
||||
/// lands where the analogue prototype put it. Without it the plain
|
||||
/// `c = 2·fs` mapping squeezes the top octave badly — it is worth about
|
||||
/// 3 dB at 16 kHz for the 12.2 kHz pole pair.
|
||||
func bilinear(_ b: (Double, Double, Double), _ a: (Double, Double, Double),
|
||||
fs: Double, warpAt: Double = 0) -> [Double] {
|
||||
let c = warpAt > 0 ? warpAt / tan(warpAt / (2 * fs)) : 2 * fs
|
||||
let c2 = c * c
|
||||
let B0 = b.0 * c2 + b.1 * c + b.2
|
||||
let B1 = 2 * b.2 - 2 * b.0 * c2
|
||||
let B2 = b.0 * c2 - b.1 * c + b.2
|
||||
let A0 = a.0 * c2 + a.1 * c + a.2
|
||||
let A1 = 2 * a.2 - 2 * a.0 * c2
|
||||
let A2 = a.0 * c2 - a.1 * c + a.2
|
||||
return [B0 / A0, B1 / A0, B2 / A0, A1 / A0, A2 / A0]
|
||||
}
|
||||
|
||||
/// |H(f)| of a cascade of biquad sections, used to normalise gain at 1 kHz
|
||||
/// and to print the verification table.
|
||||
func cascadeResponse(_ secs: [Double], _ f: Double, fs: Double) -> Double {
|
||||
let th = 2 * Double.pi * f / fs
|
||||
var reT = 1.0, imT = 0.0
|
||||
for s in stride(from: 0, to: secs.count, by: 5) {
|
||||
let (b0, b1, b2, a1, a2) = (secs[s], secs[s+1], secs[s+2], secs[s+3], secs[s+4])
|
||||
let nr = b0 + b1 * cos(th) + b2 * cos(2 * th)
|
||||
let ni = -(b1 * sin(th) + b2 * sin(2 * th))
|
||||
let dr = 1 + a1 * cos(th) + a2 * cos(2 * th)
|
||||
let di = -(a1 * sin(th) + a2 * sin(2 * th))
|
||||
let dd = dr * dr + di * di
|
||||
guard dd > 0 else { return 0 }
|
||||
let qr = (nr * dr + ni * di) / dd, qi = (ni * dr - nr * di) / dd
|
||||
let t = reT * qr - imT * qi
|
||||
imT = reT * qi + imT * qr
|
||||
reT = t
|
||||
}
|
||||
return (reT * reT + imT * imT).squareRoot()
|
||||
}
|
||||
|
||||
enum Weighting: String {
|
||||
case a = "A", c = "C", z = "Z"
|
||||
|
||||
/// Biquad sections for this weighting at `fs`, normalised to 0 dB at 1 kHz.
|
||||
func sections(fs: Double) -> [Double] {
|
||||
var s: [Double]
|
||||
switch self {
|
||||
case .a:
|
||||
s = bilinear((1, 0, 0), (1, 2 * wf1, wf1 * wf1), fs: fs)
|
||||
+ bilinear((1, 0, 0), (1, wf2 + wf3, wf2 * wf3), fs: fs)
|
||||
+ bilinear((0, 0, 1), (1, 2 * wf4, wf4 * wf4), fs: fs, warpAt: wf4)
|
||||
case .c:
|
||||
s = bilinear((1, 0, 0), (1, 2 * wf1, wf1 * wf1), fs: fs)
|
||||
+ bilinear((0, 0, 1), (1, 2 * wf4, wf4 * wf4), fs: fs, warpAt: wf4)
|
||||
case .z:
|
||||
return [1, 0, 0, 0, 0] // flat, one pass-through section
|
||||
}
|
||||
let g = 1.0 / cascadeResponse(s, 1000.0, fs: fs)
|
||||
s[0] *= g; s[1] *= g; s[2] *= g
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
/// A weighting filter bound to one channel. The coefficients could be shared
|
||||
/// but the delay line cannot, and a setup per channel costs a few hundred
|
||||
/// bytes — not worth the aliasing risk of sharing one.
|
||||
final class WeightingFilter {
|
||||
private let setup: vDSP_biquad_Setup
|
||||
private var delay: [Float]
|
||||
private let sections: vDSP_Length
|
||||
|
||||
init(_ weighting: Weighting, fs: Double) {
|
||||
let coeffs = weighting.sections(fs: fs)
|
||||
sections = vDSP_Length(coeffs.count / 5)
|
||||
guard let s = vDSP_biquad_CreateSetup(coeffs, sections) else {
|
||||
fail("cannot build \(weighting.rawValue)-weighting filter")
|
||||
}
|
||||
setup = s
|
||||
delay = [Float](repeating: 0, count: Int(sections) * 2 + 2)
|
||||
}
|
||||
|
||||
deinit { vDSP_biquad_DestroySetup(setup) }
|
||||
|
||||
func process(_ input: UnsafePointer<Float>, _ output: inout [Float], _ n: Int) {
|
||||
vDSP_biquad(setup, &delay, input, 1, &output, 1, vDSP_Length(n))
|
||||
}
|
||||
}
|
||||
|
||||
/* ── third-octave band plan for the RTA ─────────────────────────────── */
|
||||
|
||||
/// The wire always carries 1/48-octave bands, and a tile folds them down to
|
||||
/// whatever banding it wants to draw.
|
||||
///
|
||||
/// That works exactly because every banding the dashboard offers divides 48:
|
||||
/// 1/3 is sixteen of these summed, 1/6 is eight, 1/12 four, 1/24 two. Powers
|
||||
/// are additive, so a fold is a sum and nothing is approximated. The
|
||||
/// alternative — the capture re-banding on request — would mean one dashboard
|
||||
/// choosing 1/6 changes what every other dashboard sees.
|
||||
///
|
||||
/// Band n is centred on 1000 · 2^(n/48); n = 0 is 1 kHz, which is what keeps
|
||||
/// the folded centres on the standard values.
|
||||
let bandN0 = -271 // ≈ 20 Hz
|
||||
let bandN1 = 207 // ≈ 20 kHz
|
||||
let bandCentres: [Double] = (bandN0...bandN1).map { 1000 * pow(2, Double($0) / 48) }
|
||||
|
||||
/// FFT bin ranges per band, clamped to what the transform can actually
|
||||
/// resolve.
|
||||
///
|
||||
/// This is the one caveat a reader of the RTA needs and the gear is not
|
||||
/// allowed to carry (module rule 5, no explanatory text in a tile's
|
||||
/// settings): below roughly 200 Hz a 1/48-octave band is narrower than one
|
||||
/// bin, so neighbouring bands start sharing bins and the low end reads
|
||||
/// smoother than it really is. That is the window length talking, not the
|
||||
/// room. Folding to 1/6 or 1/3 puts it right, and a multi-resolution
|
||||
/// transform is the real fix if the bottom octaves ever have to be read at
|
||||
/// full resolution.
|
||||
func bandBins(fs: Double, fftSize: Int) -> [(Int, Int)] {
|
||||
let binHz = fs / Double(fftSize)
|
||||
let maxBin = fftSize / 2 - 1
|
||||
return bandCentres.map { fc in
|
||||
let lo = Int((fc * pow(2, -1.0 / 96.0) / binHz).rounded())
|
||||
let hi = Int((fc * pow(2, 1.0 / 96.0) / binHz).rounded())
|
||||
return (max(1, min(lo, maxBin)), max(1, min(max(hi, lo), maxBin)))
|
||||
}
|
||||
}
|
||||
|
||||
/* ── per-channel measurement state ──────────────────────────────────── */
|
||||
|
||||
/// Everything the standard asks for on one channel.
|
||||
///
|
||||
/// SPL is an exponential moving average of weighted mean square — Fast is a
|
||||
/// 125 ms time constant, Slow 1 s, exactly as IEC 61672 defines them. Leq is
|
||||
/// a plain energy average with no time constant at all, which is why it is
|
||||
/// accumulated separately rather than read off the integrator.
|
||||
final class ChannelMeter {
|
||||
let index: Int // 1-based, as the operator counts them
|
||||
private let fs: Double
|
||||
private let calibration: Double // dB added to turn dBFS into dB SPL
|
||||
|
||||
private let weightFilter: WeightingFilter
|
||||
private let cFilter: WeightingFilter
|
||||
private var weighted: [Float]
|
||||
private var cWeighted: [Float]
|
||||
|
||||
// One integrator per averaging the dashboard might ask for. They all run
|
||||
// all the time and all go on the wire: a tile choosing "Slow" must not
|
||||
// restart capture, because the next tile wants "Fast" off the same
|
||||
// stream and a third wants Inf. Four extra doubles per channel is a
|
||||
// rounding error next to the filtering; re-measuring per viewer is not.
|
||||
private var msFast: Double = 0
|
||||
private var msSlow: Double = 0
|
||||
private var msNone: Double = 0 // this block only, no time constant
|
||||
private var flatMeanSquare: Double = 0 // unweighted, Fast — for dBFS
|
||||
private let alphaFast: Double
|
||||
private let alphaSlow: Double
|
||||
|
||||
// Leq accumulators: running since reset, plus rolling 1 and 10 minute.
|
||||
private var leqSum: Double = 0
|
||||
private var leqCount: Double = 0
|
||||
private var ring: [Double] = [] // one mean-square per emitted frame
|
||||
private let ringCapacity: Int
|
||||
|
||||
private var peakC: Double = 0 // linear, since reset
|
||||
private var peakZ: Double = 0
|
||||
private var clipped = false
|
||||
|
||||
init(index: Int, fs: Double, weighting: Weighting, calibration: Double,
|
||||
framesPerSecond: Double) {
|
||||
self.index = index
|
||||
self.fs = fs
|
||||
self.calibration = calibration
|
||||
weightFilter = WeightingFilter(weighting, fs: fs)
|
||||
cFilter = WeightingFilter(.c, fs: fs)
|
||||
weighted = []
|
||||
cWeighted = []
|
||||
// The time constants IEC 61672 names: Fast is 125 ms, Slow is 1 s.
|
||||
alphaFast = 1.0 - exp(-1.0 / (0.125 * fs))
|
||||
alphaSlow = 1.0 - exp(-1.0 / (1.0 * fs))
|
||||
ringCapacity = Int((600.0 * framesPerSecond).rounded()) // 10 minutes
|
||||
}
|
||||
|
||||
/// Feed one tap buffer for this channel.
|
||||
func push(_ samples: UnsafePointer<Float>, _ n: Int) {
|
||||
if weighted.count < n {
|
||||
weighted = [Float](repeating: 0, count: n)
|
||||
cWeighted = [Float](repeating: 0, count: n)
|
||||
}
|
||||
weightFilter.process(samples, &weighted, n)
|
||||
cFilter.process(samples, &cWeighted, n)
|
||||
|
||||
// Exponential integration over the weighted signal, sample by sample
|
||||
// in the power domain. vDSP has no one-pole on squares, and at 48 kHz
|
||||
// this is a few hundred thousand multiply-adds a second — noise next
|
||||
// to the filtering above.
|
||||
var fast = msFast, slow = msSlow, flat = flatMeanSquare
|
||||
weighted.withUnsafeBufferPointer { w in
|
||||
for i in 0..<n {
|
||||
let v = Double(w[i]) * Double(w[i])
|
||||
fast += alphaFast * (v - fast)
|
||||
slow += alphaSlow * (v - slow)
|
||||
let r = Double(samples[i])
|
||||
flat += alphaFast * (r * r - flat)
|
||||
}
|
||||
}
|
||||
msFast = fast; msSlow = slow; flatMeanSquare = flat
|
||||
|
||||
// Mean square of just this block — "None", the reading with no time
|
||||
// constant at all. Also what Leq accumulates, since Leq is a plain
|
||||
// energy average and has no time constant either.
|
||||
var sumSq: Float = 0
|
||||
weighted.withUnsafeBufferPointer { w in
|
||||
vDSP_measqv(w.baseAddress!, 1, &sumSq, vDSP_Length(n))
|
||||
}
|
||||
msNone = Double(sumSq)
|
||||
leqSum += Double(sumSq) * Double(n)
|
||||
leqCount += Double(n)
|
||||
|
||||
var cPeak: Float = 0, zPeak: Float = 0
|
||||
cWeighted.withUnsafeBufferPointer { c in
|
||||
vDSP_maxmgv(c.baseAddress!, 1, &cPeak, vDSP_Length(n))
|
||||
}
|
||||
vDSP_maxmgv(samples, 1, &zPeak, vDSP_Length(n))
|
||||
peakC = max(peakC, Double(cPeak))
|
||||
peakZ = max(peakZ, Double(zPeak))
|
||||
if zPeak >= 0.999 { clipped = true }
|
||||
}
|
||||
|
||||
/// dB SPL from a linear mean-square, with the calibration offset applied.
|
||||
private func db(_ power: Double) -> Double {
|
||||
20 * log10(max(power, 1e-20).squareRoot()) + calibration
|
||||
}
|
||||
|
||||
/// Called once per emitted frame; also advances the rolling Leq window.
|
||||
///
|
||||
/// Returns a JSON object built by hand. JSONSerialization renders a
|
||||
/// rounded Double as `-70.120000000000005`, which is both ugly on the
|
||||
/// wire and noise in a diff — `%.2f` says what a meter can actually
|
||||
/// resolve and nothing more.
|
||||
func snapshot() -> String {
|
||||
ring.append(msNone)
|
||||
if ring.count > ringCapacity { ring.removeFirst(ring.count - ringCapacity) }
|
||||
|
||||
let fps = Double(ringCapacity) / 600.0
|
||||
func rollingLeq(seconds: Double) -> Double {
|
||||
let want = max(1, Int((seconds * fps).rounded()))
|
||||
let slice = ring.suffix(want)
|
||||
guard !slice.isEmpty else { return -200 }
|
||||
return db(slice.reduce(0, +) / Double(slice.count))
|
||||
}
|
||||
|
||||
return "{\"i\":\(index)"
|
||||
+ f("fast", db(msFast))
|
||||
+ f("slow", db(msSlow))
|
||||
+ f("inf", leqCount > 0 ? db(leqSum / leqCount) : -200)
|
||||
+ f("none", db(msNone))
|
||||
+ f("leq1m", rollingLeq(seconds: 60))
|
||||
+ f("leq10m", rollingLeq(seconds: 600))
|
||||
+ f("peakC", db(peakC * peakC))
|
||||
+ f("peakZ", db(peakZ * peakZ))
|
||||
+ f("dbfs", 20 * log10(max(flatMeanSquare, 1e-20).squareRoot()))
|
||||
+ ",\"clip\":\(clipped)}"
|
||||
}
|
||||
|
||||
/// Clears Leq, peaks and the clip flag. The exponential integrator is
|
||||
/// deliberately left alone — it is a live reading, not an accumulation,
|
||||
/// and zeroing it would just make the meter dive for a moment.
|
||||
func reset() {
|
||||
leqSum = 0; leqCount = 0; ring.removeAll(keepingCapacity: true)
|
||||
peakC = 0; peakZ = 0; clipped = false
|
||||
}
|
||||
}
|
||||
|
||||
/// `,"key":-12.34` — two decimals, and a floor instead of -inf or NaN so a
|
||||
/// silent input can never put a non-number on the wire.
|
||||
func f(_ key: String, _ v: Double) -> String {
|
||||
",\"\(key)\":" + num(v)
|
||||
}
|
||||
|
||||
func num(_ v: Double) -> String {
|
||||
guard v.isFinite else { return "-200.00" }
|
||||
return String(format: "%.2f", max(v, -200))
|
||||
}
|
||||
|
||||
/// One decimal, for band arrays. 479 bands per channel per frame is the
|
||||
/// bulk of the wire; a second decimal on a bar nobody can read that finely
|
||||
/// is 15% of the bandwidth for nothing.
|
||||
func num1(_ v: Double) -> String {
|
||||
guard v.isFinite else { return "-200" }
|
||||
return String(format: "%.1f", max(v, -200))
|
||||
}
|
||||
|
||||
/* ── spectrum for the RTA / spectrograph tiles ──────────────────────── */
|
||||
|
||||
final class Spectrum {
|
||||
private let fftSize: Int
|
||||
private let log2n: vDSP_Length
|
||||
private let setup: FFTSetup
|
||||
private var window: [Float]
|
||||
private var accum: [Float] // sliding input buffer
|
||||
private var filled = 0
|
||||
private let bins: [(Int, Int)]
|
||||
private let windowPower: Double
|
||||
|
||||
init(fs: Double, fftSize: Int) {
|
||||
self.fftSize = fftSize
|
||||
log2n = vDSP_Length(log2(Double(fftSize)).rounded())
|
||||
guard let s = vDSP_create_fftsetup(log2n, FFTRadix(kFFTRadix2)) else {
|
||||
fail("cannot create FFT setup")
|
||||
}
|
||||
setup = s
|
||||
window = [Float](repeating: 0, count: fftSize)
|
||||
vDSP_hann_window(&window, vDSP_Length(fftSize), Int32(vDSP_HANN_DENORM))
|
||||
accum = [Float](repeating: 0, count: fftSize)
|
||||
bins = bandBins(fs: fs, fftSize: fftSize)
|
||||
// Coherent gain of the Hann window, so band levels are not 6 dB low.
|
||||
windowPower = window.reduce(0.0) { $0 + Double($1) * Double($1) } / Double(fftSize)
|
||||
}
|
||||
|
||||
deinit { vDSP_destroy_fftsetup(setup) }
|
||||
|
||||
/// Keep the most recent `fftSize` samples, shifting the window along.
|
||||
/// Buffers larger than the window just overwrite it outright.
|
||||
func push(_ samples: UnsafePointer<Float>, _ n: Int) {
|
||||
if n >= fftSize {
|
||||
accum.withUnsafeMutableBufferPointer { a in
|
||||
a.baseAddress!.update(from: samples + (n - fftSize), count: fftSize)
|
||||
}
|
||||
filled = fftSize
|
||||
return
|
||||
}
|
||||
accum.withUnsafeMutableBufferPointer { a in
|
||||
let base = a.baseAddress!
|
||||
// Slide the old tail down, then append the new block.
|
||||
base.update(from: base + n, count: fftSize - n)
|
||||
(base + fftSize - n).update(from: samples, count: n)
|
||||
}
|
||||
filled = min(fftSize, filled + n)
|
||||
}
|
||||
|
||||
/// Third-octave band levels in dB relative to full scale, plus the
|
||||
/// calibration offset applied by the caller.
|
||||
func bands(calibration: Double) -> [Double] {
|
||||
guard filled >= fftSize else { return bins.map { _ in -200 } }
|
||||
var windowed = [Float](repeating: 0, count: fftSize)
|
||||
vDSP_vmul(accum, 1, window, 1, &windowed, 1, vDSP_Length(fftSize))
|
||||
|
||||
let half = fftSize / 2
|
||||
var re = [Float](repeating: 0, count: half)
|
||||
var im = [Float](repeating: 0, count: half)
|
||||
var mag = [Float](repeating: 0, count: half)
|
||||
re.withUnsafeMutableBufferPointer { rp in
|
||||
im.withUnsafeMutableBufferPointer { ip in
|
||||
var split = DSPSplitComplex(realp: rp.baseAddress!, imagp: ip.baseAddress!)
|
||||
windowed.withUnsafeBytes { raw in
|
||||
vDSP_ctoz(raw.bindMemory(to: DSPComplex.self).baseAddress!, 2,
|
||||
&split, 1, vDSP_Length(half))
|
||||
}
|
||||
vDSP_fft_zrip(setup, &split, 1, log2n, FFTDirection(FFT_FORWARD))
|
||||
vDSP_zvmags(&split, 1, &mag, 1, vDSP_Length(half))
|
||||
}
|
||||
}
|
||||
// vDSP's real FFT carries a factor of 2, and zvmags squares it.
|
||||
let scale = 1.0 / (4.0 * Double(fftSize) * Double(fftSize) * windowPower)
|
||||
return bins.map { (lo, hi) in
|
||||
var power = 0.0
|
||||
for b in lo...hi { power += Double(mag[b]) }
|
||||
power *= scale * 2.0 // one-sided spectrum
|
||||
return 10 * log10(max(power, 1e-20)) + calibration
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── args ───────────────────────────────────────────────────────────── */
|
||||
|
||||
var wantList = false
|
||||
var wantSelftest = false
|
||||
var wantDevice: String? = nil
|
||||
var wantChannels: [Int] = [1]
|
||||
var wantCal: [Double] = []
|
||||
var wantWeighting = Weighting.a
|
||||
var wantBands = false
|
||||
var framesPerSecond = 20.0
|
||||
|
||||
let argv = CommandLine.arguments
|
||||
var argIdx = 1
|
||||
func nextArg(_ flag: String) -> String {
|
||||
argIdx += 1
|
||||
guard argIdx < argv.count else { fail("\(flag) needs a value") }
|
||||
return argv[argIdx]
|
||||
}
|
||||
while argIdx < argv.count {
|
||||
switch argv[argIdx] {
|
||||
case "--list": wantList = true
|
||||
case "--selftest": wantSelftest = true
|
||||
case "--device": wantDevice = nextArg("--device")
|
||||
case "--channels":
|
||||
wantChannels = nextArg("--channels").split(separator: ",").compactMap { Int($0.trimmingCharacters(in: .whitespaces)) }
|
||||
if wantChannels.isEmpty { fail("--channels needs at least one 1-based channel number") }
|
||||
case "--cal":
|
||||
wantCal = nextArg("--cal").split(separator: ",").compactMap { Double($0.trimmingCharacters(in: .whitespaces)) }
|
||||
case "--weighting":
|
||||
let raw = nextArg("--weighting").uppercased()
|
||||
guard let w = Weighting(rawValue: raw) else { fail("--weighting must be A, C or Z") }
|
||||
wantWeighting = w
|
||||
case "--bands": wantBands = true
|
||||
case "--rate":
|
||||
guard let r = Double(nextArg("--rate")), r >= 1, r <= 50 else { fail("--rate must be 1-50 (frames per second)") }
|
||||
framesPerSecond = r
|
||||
default: fail("unknown flag \(argv[argIdx]) — try --list")
|
||||
}
|
||||
argIdx += 1
|
||||
}
|
||||
|
||||
/* ── selftest: prove the weighting curves before trusting a reading ─── */
|
||||
|
||||
if wantSelftest {
|
||||
// IEC 61672-1 table 3, nominal A- and C-weighting.
|
||||
let table: [(Double, Double, Double)] = [
|
||||
(31.5, -39.4, -3.0), (63, -26.2, -0.8), (125, -16.1, -0.2), (250, -8.6, 0.0),
|
||||
(500, -3.2, 0.0), (1000, 0.0, 0.0), (2000, 1.2, -0.2), (4000, 1.0, -0.8),
|
||||
(8000, -1.1, -3.0), (16000, -6.6, -8.5),
|
||||
]
|
||||
for fs in [48000.0, 96000.0] {
|
||||
let a = Weighting.a.sections(fs: fs), c = Weighting.c.sections(fs: fs)
|
||||
print("— \(Int(fs)) Hz —")
|
||||
print(" Hz A IEC err C IEC err")
|
||||
var worstA = 0.0, worstC = 0.0
|
||||
for (f, wantA, wantC) in table where f < fs / 2 {
|
||||
let gotA = 20 * log10(cascadeResponse(a, f, fs: fs))
|
||||
let gotC = 20 * log10(cascadeResponse(c, f, fs: fs))
|
||||
worstA = max(worstA, abs(gotA - wantA)); worstC = max(worstC, abs(gotC - wantC))
|
||||
print(String(format: "%7.0f %+7.2f %+7.2f %+6.2f %+7.2f %+7.2f %+6.2f",
|
||||
f, gotA, wantA, gotA - wantA, gotC, wantC, gotC - wantC))
|
||||
}
|
||||
print(String(format: " worst: A %.2f dB, C %.2f dB\n", worstA, worstC))
|
||||
}
|
||||
exit(0)
|
||||
}
|
||||
|
||||
/* ── device selection ───────────────────────────────────────────────── */
|
||||
|
||||
let devices = listInputDevices()
|
||||
|
||||
if wantList || wantDevice == nil {
|
||||
if devices.isEmpty { fail("no input-capable audio devices found") }
|
||||
// stdout, as JSON: the module's /devices route feeds this straight to
|
||||
// the admin page's device picker.
|
||||
let payload = devices.map { ["name": $0.name, "uid": $0.uid,
|
||||
"channels": $0.channels, "sampleRate": $0.sampleRate] }
|
||||
let data = try! JSONSerialization.data(withJSONObject: ["devices": payload])
|
||||
FileHandle.standardOutput.write(data)
|
||||
FileHandle.standardOutput.write("\n".data(using: .utf8)!)
|
||||
if wantDevice == nil && !wantList { note("pick one with --device <name substring>") }
|
||||
exit(wantList ? 0 : 2)
|
||||
}
|
||||
|
||||
let query = wantDevice!.lowercased()
|
||||
let exact = devices.filter { $0.name.lowercased() == query || $0.uid.lowercased() == query }
|
||||
let matches = exact.count == 1 ? exact
|
||||
: devices.filter { $0.name.lowercased().contains(query) || $0.uid.lowercased().contains(query) }
|
||||
guard matches.count == 1 else {
|
||||
if matches.isEmpty { fail("no input device matches \"\(wantDevice!)\" — see --list") }
|
||||
fail("\"\(wantDevice!)\" is ambiguous: " + matches.map { $0.name }.joined(separator: ", "))
|
||||
}
|
||||
let device = matches[0]
|
||||
|
||||
/* ── capture ────────────────────────────────────────────────────────── */
|
||||
|
||||
let engine = AVAudioEngine()
|
||||
guard let unit = engine.inputNode.audioUnit else { fail("no input audio unit") }
|
||||
var deviceID = device.id
|
||||
let setErr = AudioUnitSetProperty(unit, kAudioOutputUnitProperty_CurrentDevice,
|
||||
kAudioUnitScope_Global, 0, &deviceID,
|
||||
UInt32(MemoryLayout<AudioDeviceID>.size))
|
||||
guard setErr == noErr else { fail("cannot select \"\(device.name)\" (error \(setErr))") }
|
||||
|
||||
let format = engine.inputNode.inputFormat(forBus: 0)
|
||||
let channelCount = Int(format.channelCount)
|
||||
let sampleRate = format.sampleRate
|
||||
guard channelCount > 0, sampleRate > 0 else {
|
||||
fail("\"\(device.name)\" reports no usable input format — microphone access may be denied "
|
||||
+ "(System Settings → Privacy & Security → Microphone)")
|
||||
}
|
||||
for ch in wantChannels where ch < 1 || ch > channelCount {
|
||||
fail("channel \(ch) is out of range — \"\(device.name)\" has \(channelCount)")
|
||||
}
|
||||
|
||||
let calibrated = !wantCal.isEmpty
|
||||
if calibrated && wantCal.count != wantChannels.count {
|
||||
fail("--cal needs one offset per channel (\(wantChannels.count) given \(wantCal.count))")
|
||||
}
|
||||
|
||||
let meters = wantChannels.enumerated().map { (i, ch) in
|
||||
ChannelMeter(index: ch, fs: sampleRate, weighting: wantWeighting,
|
||||
calibration: calibrated ? wantCal[i] : 0, framesPerSecond: framesPerSecond)
|
||||
}
|
||||
let spectra = wantBands ? wantChannels.map { _ in Spectrum(fs: sampleRate, fftSize: 16384) } : []
|
||||
|
||||
note("capturing \"\(device.name)\": \(channelCount) channels @ \(Int(sampleRate)) Hz, "
|
||||
+ "measuring \(wantChannels.map(String.init).joined(separator: ",")) "
|
||||
+ "\(wantWeighting.rawValue)-weighted"
|
||||
+ (calibrated ? "" : ", UNCALIBRATED"))
|
||||
|
||||
// Announced once so the reader knows the band plan without hardcoding it.
|
||||
let hello: [String: Any] = [
|
||||
"hello": 1,
|
||||
"device": device.name,
|
||||
"sampleRate": sampleRate,
|
||||
"deviceChannels": channelCount,
|
||||
"channels": wantChannels,
|
||||
"weighting": wantWeighting.rawValue,
|
||||
"calibrated": calibrated,
|
||||
// Centres and the index of the 1 kHz band, so a tile can fold to any
|
||||
// banding without hardcoding the plan.
|
||||
"bands": wantBands ? bandCentres.map { round($0 * 100) / 100 } : [],
|
||||
"bandN0": bandN0,
|
||||
"bandsPerOctave": 48,
|
||||
]
|
||||
let stdoutHandle = FileHandle.standardOutput
|
||||
func emitLine(_ json: String) {
|
||||
do {
|
||||
try stdoutHandle.write(contentsOf: Data((json + "\n").utf8))
|
||||
} catch { exit(0) } // downstream pipe closed — nothing to say
|
||||
}
|
||||
if let data = try? JSONSerialization.data(withJSONObject: hello),
|
||||
let line = String(data: data, encoding: .utf8) {
|
||||
emitLine(line)
|
||||
}
|
||||
|
||||
// A reset is asked for on stdin (one line, "reset") rather than a signal, so
|
||||
// the listener can clear Leq and peaks without restarting capture and losing
|
||||
// the filters' settling.
|
||||
let resetQueue = DispatchQueue(label: "spl-capture.reset")
|
||||
var resetRequested = false
|
||||
DispatchQueue.global().async {
|
||||
while let line = readLine(strippingNewline: true) {
|
||||
if line.trimmingCharacters(in: .whitespaces).lowercased() == "reset" {
|
||||
resetQueue.sync { resetRequested = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let tapQueue = DispatchQueue(label: "spl-capture.tap")
|
||||
engine.inputNode.installTap(onBus: 0, bufferSize: 2048, format: format) { buffer, _ in
|
||||
let frames = Int(buffer.frameLength)
|
||||
guard frames > 0, let floats = buffer.floatChannelData else { return }
|
||||
tapQueue.sync {
|
||||
for (i, ch) in wantChannels.enumerated() {
|
||||
let src = floats[ch - 1] // tap data is per-channel, not interleaved
|
||||
meters[i].push(src, frames)
|
||||
if wantBands { spectra[i].push(src, frames) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
do { try engine.start() } catch {
|
||||
fail("engine start failed: \(error.localizedDescription) — if this is a permission problem, "
|
||||
+ "approve microphone access for the launching app and retry")
|
||||
}
|
||||
|
||||
let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.global())
|
||||
timer.schedule(deadline: .now() + 1.0 / framesPerSecond,
|
||||
repeating: 1.0 / framesPerSecond)
|
||||
timer.setEventHandler {
|
||||
var wantReset = false
|
||||
resetQueue.sync { wantReset = resetRequested; resetRequested = false }
|
||||
|
||||
var line = "{\"t\":\(Int(Date().timeIntervalSince1970 * 1000))"
|
||||
tapQueue.sync {
|
||||
if wantReset { meters.forEach { $0.reset() } }
|
||||
line += ",\"ch\":[" + meters.map { $0.snapshot() }.joined(separator: ",") + "]"
|
||||
if wantBands {
|
||||
let sets = spectra.enumerated().map { (i, s) -> String in
|
||||
"[" + s.bands(calibration: calibrated ? wantCal[i] : 0)
|
||||
.map(num1).joined(separator: ",") + "]"
|
||||
}
|
||||
line += ",\"bands\":[" + sets.joined(separator: ",") + "]"
|
||||
}
|
||||
}
|
||||
emitLine(line + "}")
|
||||
}
|
||||
timer.resume()
|
||||
|
||||
signal(SIGINT) { _ in exit(0) }
|
||||
signal(SIGTERM) { _ in exit(0) }
|
||||
RunLoop.main.run()
|
||||
@@ -0,0 +1,239 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Sound level listener — owns the native capture child.
|
||||
*
|
||||
* Spawns spl-capture (compiled from spl-capture.swift beside it) for the
|
||||
* admin-configured device and channels, reads its JSON lines, and hands the
|
||||
* latest frame upward. All measurement happens in the child; this file is
|
||||
* plumbing and supervision, nothing more.
|
||||
*
|
||||
* Reports via onUpdate:
|
||||
*
|
||||
* { frame: {...}, meta: {...} } measuring
|
||||
* { error: '<what is wrong and how to fix it>' } not measuring
|
||||
*
|
||||
* The capture binary is per-machine and gitignored, exactly like the Timers
|
||||
* module's ltc-capture. It normally arrives already built — compiled beside
|
||||
* the app, or copied from any Mac of the same architecture. When it is
|
||||
* missing this will build it, but in the background and never on the event
|
||||
* loop: see ensureBinary for what that cost the first time it was wrong.
|
||||
*
|
||||
* The same macOS gotcha applies as for LTC, and is worth repeating because
|
||||
* it costs an afternoon every time: a ProdDash launched over SSH or from a
|
||||
* bare launchd job gets SILENT ZEROS from every input, with no error
|
||||
* anywhere. Here that shows up as a meter pinned at -200. Launch from a
|
||||
* logged-in GUI context and approve the microphone prompt once.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const RESTART_MS = 5000;
|
||||
/** An immediate exit is config-shaped (bad device, channel out of range) —
|
||||
retrying every 5 s would only fill the log with the same sentence. */
|
||||
const BAD_CONFIG_RETRY_MS = 30000;
|
||||
/** No frame for this long means the child is alive but not delivering. */
|
||||
const STALL_MS = 3000;
|
||||
|
||||
/**
|
||||
* @param {{device:string, channels:number[], weighting:string,
|
||||
* bands:boolean, calibration:number[], moduleDir:string,
|
||||
* log:Function, onUpdate:(u:object)=>void}} opts
|
||||
* @returns {{stop:()=>void, reset:()=>void}}
|
||||
*/
|
||||
function createSplListener(opts) {
|
||||
const { device, channels, weighting, bands, calibration, moduleDir, log, onUpdate } = opts;
|
||||
const bin = path.join(moduleDir, 'spl-capture');
|
||||
|
||||
let child = null;
|
||||
let stopped = false;
|
||||
let restartTimer = null;
|
||||
let stallTimer = null;
|
||||
let startedAt = 0;
|
||||
let meta = null;
|
||||
let building = false;
|
||||
let buildFailed = false;
|
||||
let lastFrameWall = 0;
|
||||
|
||||
const BUILD_HELP = 'Sound level capture tool missing — build it on any Apple Silicon Mac with '
|
||||
+ '`swiftc -O -o modules/spl-meter/spl-capture modules/spl-meter/spl-capture.swift` '
|
||||
+ 'and copy it next to spl-capture.swift';
|
||||
|
||||
/**
|
||||
* Build the capture tool if it is missing, WITHOUT blocking.
|
||||
*
|
||||
* This used to be a spawnSync, copied from the LTC listener where the
|
||||
* source is a third the size and builds in a few seconds. Here it took
|
||||
* nearly two minutes on the production Mac and froze the entire ProdDash
|
||||
* server for every second of it — one module's optional helper took the
|
||||
* whole dashboard down, then did it again on every retry. Nothing that
|
||||
* runs while a service might be on is allowed to hold the event loop.
|
||||
*
|
||||
* The build is a fallback for a module installed from the catalog on a
|
||||
* machine with a Swift toolchain. The normal path is a binary built
|
||||
* alongside the app, or copied from any same-architecture Mac.
|
||||
*
|
||||
* @returns {string} '' when the binary is ready, otherwise what to say.
|
||||
*/
|
||||
function ensureBinary() {
|
||||
if (fs.existsSync(bin)) return '';
|
||||
const src = bin + '.swift';
|
||||
if (!fs.existsSync(src)) return 'spl-capture.swift is missing from the module folder';
|
||||
if (building) return 'Building the capture tool — this takes a minute or two';
|
||||
if (buildFailed) return BUILD_HELP;
|
||||
|
||||
building = true;
|
||||
log('spl-capture binary missing — building it in the background');
|
||||
const build = spawn('swiftc', ['-O', '-o', bin, src], { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
let buildErr = '';
|
||||
build.stderr.setEncoding('utf8');
|
||||
build.stderr.on('data', (t) => { buildErr = (buildErr + t).slice(-800); });
|
||||
const giveUp = setTimeout(() => {
|
||||
try { build.kill('SIGKILL'); } catch { /* already gone */ }
|
||||
}, 300000);
|
||||
build.on('error', (err) => {
|
||||
clearTimeout(giveUp);
|
||||
building = false; buildFailed = true;
|
||||
log(`swiftc could not be run: ${err.message}`);
|
||||
});
|
||||
build.on('exit', (code) => {
|
||||
clearTimeout(giveUp);
|
||||
building = false;
|
||||
if (code === 0 && fs.existsSync(bin)) {
|
||||
log('spl-capture built');
|
||||
buildFailed = false;
|
||||
if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); }
|
||||
return;
|
||||
}
|
||||
buildFailed = true;
|
||||
log(`spl-capture build failed (exit ${code})` + (buildErr ? `: ${buildErr.trim().split('\n').pop()}` : ''));
|
||||
});
|
||||
return 'Building the capture tool — this takes a minute or two';
|
||||
}
|
||||
|
||||
function scheduleRestart(ms) {
|
||||
if (stopped || restartTimer) return;
|
||||
restartTimer = setTimeout(() => { restartTimer = null; start(); }, ms);
|
||||
}
|
||||
|
||||
function armStallWatch() {
|
||||
clearTimeout(stallTimer);
|
||||
stallTimer = setTimeout(() => {
|
||||
if (stopped || !child) return;
|
||||
if (Date.now() - lastFrameWall >= STALL_MS) {
|
||||
log('no frames for 3 s — restarting capture');
|
||||
try { child.kill('SIGTERM'); } catch { /* already gone */ }
|
||||
}
|
||||
}, STALL_MS);
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (stopped) return;
|
||||
|
||||
const missing = ensureBinary();
|
||||
if (missing) {
|
||||
onUpdate({ error: missing });
|
||||
// A build in flight calls start() itself when it lands; anything else
|
||||
// is config-shaped and should back off hard rather than spin.
|
||||
if (!building) scheduleRestart(BAD_CONFIG_RETRY_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
// No averaging or banding flag: the child computes every averaging and
|
||||
// the finest banding regardless, so tiles can differ without a restart.
|
||||
const args = ['--device', device, '--channels', channels.join(','),
|
||||
'--weighting', weighting, '--rate', '20'];
|
||||
if (bands) args.push('--bands');
|
||||
if (calibration.length === channels.length) args.push('--cal', calibration.join(','));
|
||||
|
||||
startedAt = Date.now();
|
||||
meta = null;
|
||||
let stderrTail = '';
|
||||
let buf = '';
|
||||
|
||||
child = spawn(bin, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stderr.on('data', (text) => {
|
||||
stderrTail = (stderrTail + text).slice(-500);
|
||||
});
|
||||
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stdout.on('data', (text) => {
|
||||
buf += text;
|
||||
let i;
|
||||
while ((i = buf.indexOf('\n')) >= 0) {
|
||||
const line = buf.slice(0, i).trim();
|
||||
buf = buf.slice(i + 1);
|
||||
if (!line) continue;
|
||||
let obj;
|
||||
try { obj = JSON.parse(line); } catch { continue; }
|
||||
if (obj.hello) {
|
||||
meta = obj;
|
||||
log(`measuring ${obj.device} ch ${obj.channels.join(',')} `
|
||||
+ `${obj.weighting}-weighted`
|
||||
+ (obj.calibrated ? '' : ' (uncalibrated)'));
|
||||
continue;
|
||||
}
|
||||
if (!obj.ch) continue;
|
||||
lastFrameWall = Date.now();
|
||||
armStallWatch();
|
||||
onUpdate({ frame: obj, meta });
|
||||
}
|
||||
// A run of junk with no newline would grow forever; the child never
|
||||
// emits a line this long, so anything that does is a bug upstream.
|
||||
if (buf.length > 65536) buf = '';
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
onUpdate({ error: `cannot run spl-capture: ${err.message}` });
|
||||
child = null;
|
||||
scheduleRestart(BAD_CONFIG_RETRY_MS);
|
||||
});
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
child = null;
|
||||
clearTimeout(stallTimer);
|
||||
if (stopped) return;
|
||||
const ranFor = Date.now() - startedAt;
|
||||
const why = stderrTail.trim().split('\n').pop() || `exit ${code}${signal ? ' ' + signal : ''}`;
|
||||
// Exiting almost immediately means it never got as far as capturing —
|
||||
// an unknown device, an out-of-range channel, a denied microphone.
|
||||
// Those do not fix themselves, so back off hard and say why.
|
||||
if (ranFor < 2000) {
|
||||
onUpdate({ error: why });
|
||||
scheduleRestart(BAD_CONFIG_RETRY_MS);
|
||||
} else {
|
||||
log(`capture exited after ${Math.round(ranFor / 1000)}s: ${why}`);
|
||||
scheduleRestart(RESTART_MS);
|
||||
}
|
||||
});
|
||||
|
||||
armStallWatch();
|
||||
}
|
||||
|
||||
start();
|
||||
|
||||
return {
|
||||
/** Clear Leq and peak hold without restarting capture — the filters keep
|
||||
their settling, so the live number never dips. */
|
||||
reset() {
|
||||
if (child && child.stdin.writable) {
|
||||
try { child.stdin.write('reset\n'); } catch { /* child going away */ }
|
||||
}
|
||||
},
|
||||
stop() {
|
||||
stopped = true;
|
||||
clearTimeout(restartTimer); restartTimer = null;
|
||||
clearTimeout(stallTimer); stallTimer = null;
|
||||
if (child) {
|
||||
try { child.kill('SIGTERM'); } catch { /* already gone */ }
|
||||
child = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createSplListener };
|
||||
@@ -0,0 +1,148 @@
|
||||
/* SPL Meter — every colour comes from the shell's variables so themes and
|
||||
per-tile tints reach in. Nothing here is hardcoded except the canvas
|
||||
heat ramp, which client.js owns because it has to interpolate. */
|
||||
|
||||
[data-module="spl-meter"] .spl {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ── readout ──────────────────────────────────────────────────────── */
|
||||
|
||||
[data-module="spl-meter"] .spl-readout {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25em;
|
||||
min-height: 0;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
[data-module="spl-meter"] .spl-value {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.2em;
|
||||
line-height: 1;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* A first-paint size only. client.js measures the text and sets the real
|
||||
one inline, because CSS has to assume a digit count and gets it wrong the
|
||||
moment the reading needs a sixth character: the coefficient that fills a
|
||||
tile for "-45.9" pushes "-100.5" straight out through the side. */
|
||||
[data-module="spl-meter"] .spl-num {
|
||||
font-size: clamp(1.25rem, min(26cqw, 60cqh), 100rem);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
[data-module="spl-meter"] .spl-unit {
|
||||
font-size: clamp(0.65rem, min(7cqw, 15cqh), 26rem);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
[data-module="spl-meter"] .spl-sub {
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
[data-module="spl-meter"] .spl-readout[data-level="warn"] .spl-num { color: var(--warn); }
|
||||
[data-module="spl-meter"] .spl-readout[data-level="alarm"] .spl-num { color: var(--danger); }
|
||||
[data-module="spl-meter"] .spl-readout[data-level="idle"] .spl-num { color: var(--muted); }
|
||||
|
||||
/* ── level bars ───────────────────────────────────────────────────── */
|
||||
|
||||
[data-module="spl-meter"] .spl-bars {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.4rem;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
[data-module="spl-meter"] .spl-bar {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4em 1fr 3.4em;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
[data-module="spl-meter"] .spl-bar-label { color: var(--muted); text-align: right; }
|
||||
|
||||
[data-module="spl-meter"] .spl-bar-track {
|
||||
position: relative;
|
||||
height: 0.65rem;
|
||||
border-radius: 0.35rem;
|
||||
background: color-mix(in srgb, var(--border) 60%, transparent);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-module="spl-meter"] .spl-bar-fill {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 0;
|
||||
border-radius: inherit;
|
||||
background: var(--accent);
|
||||
transition: width 60ms linear;
|
||||
}
|
||||
|
||||
[data-module="spl-meter"] .spl-bar[data-level="warn"] .spl-bar-fill { background: var(--warn); }
|
||||
[data-module="spl-meter"] .spl-bar[data-level="alarm"] .spl-bar-fill { background: var(--danger); }
|
||||
[data-module="spl-meter"] .spl-bar[data-level="idle"] .spl-bar-fill { background: var(--muted); }
|
||||
|
||||
/* Peak hold sits on the track rather than inside the fill, so it stays
|
||||
visible when the bar falls away underneath it. */
|
||||
[data-module="spl-meter"] .spl-bar-peak {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--text);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
[data-module="spl-meter"] .spl-bar-value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
[data-module="spl-meter"] .spl-bar.clip .spl-bar-value { color: var(--danger); }
|
||||
|
||||
/* ── canvas tiles ─────────────────────────────────────────────────── */
|
||||
|
||||
[data-module="spl-meter"] .spl-canvas {
|
||||
flex: 1;
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
/* The readout scales off both of the tile's dimensions, so the container
|
||||
has to be sized on both axes — inline-size alone leaves cqh undefined. */
|
||||
[data-module="spl-meter"] .spl { container-type: size; }
|
||||
|
||||
/* The sub-line is the first thing to go when the tile gets short: the
|
||||
number is the point, and a clipped caption under it is worse than none. */
|
||||
[data-module="spl-meter"] .spl-sub {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@container (max-height: 90px) {
|
||||
[data-module="spl-meter"] .spl-sub { display: none; }
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "proddash",
|
||||
"version": "1.5.0",
|
||||
"version": "1.9.0",
|
||||
"private": true,
|
||||
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
|
||||
"license": "UNLICENSED",
|
||||
|
||||
@@ -451,6 +451,36 @@ a.btn { text-decoration: none; }
|
||||
|
||||
/* one swatch per preset: a miniature dashboard (page, a tile on it, the
|
||||
accent as a status dot) and the name; the current one wears the accent */
|
||||
/* ── a collapsible admin section ─────────────────────────────────────
|
||||
The pattern for any section of this page that folds. The markup is a
|
||||
<details class="admin-fold"> holding the section's contents, headed by a
|
||||
<summary class="admin-h1 admin-fold-title">: the title keeps the same
|
||||
type as every other section heading — a <summary> would otherwise come
|
||||
out at the normal weight — and the caret follows the words, so a folded
|
||||
section still reads as a heading first. The whole row is the hit target. */
|
||||
details.admin-fold > summary.admin-fold-title {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 700; /* matches h1.admin-h1; summary defaults to normal */
|
||||
user-select: none;
|
||||
}
|
||||
details.admin-fold > summary.admin-fold-title::-webkit-details-marker { display: none; }
|
||||
/* after the title, not before it — the heading reads first, the caret says
|
||||
there is more behind it */
|
||||
details.admin-fold > summary.admin-fold-title::after {
|
||||
content: "";
|
||||
width: 0; height: 0;
|
||||
border-left: 6px solid var(--muted);
|
||||
border-top: 5px solid transparent;
|
||||
border-bottom: 5px solid transparent;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
details.admin-fold[open] > summary.admin-fold-title::after { transform: rotate(90deg); }
|
||||
details.admin-fold > summary.admin-fold-title:hover { color: var(--accent); }
|
||||
|
||||
.theme-swatches {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(116px, 1fr));
|
||||
@@ -512,3 +542,41 @@ a.btn { text-decoration: none; }
|
||||
color: var(--accent);
|
||||
}
|
||||
#theme-status { margin-top: 4px; }
|
||||
|
||||
/* ── the admin PIN ── */
|
||||
|
||||
/* Digits, evenly spaced, so six of them read as a PIN rather than a word. */
|
||||
.pin-input {
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.35em;
|
||||
text-align: center;
|
||||
max-width: 9em;
|
||||
}
|
||||
.pin-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
.pin-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.pin-field[hidden] { display: none; }
|
||||
.pin-field input {
|
||||
padding: 8px 10px;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
}
|
||||
.pin-field input:focus { outline: none; border-color: var(--accent); }
|
||||
.pin-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
+110
-3
@@ -2,8 +2,10 @@
|
||||
|
||||
Everything here is server-wide: module enable/disable and admin config
|
||||
(written to config/modules.json, module re-inited on save), plus named
|
||||
layout management. Guarded by the shared passcode when one is set. */
|
||||
layout management. Behind the admin PIN when one is set — a shell setting of
|
||||
its own, nothing to do with a module's PIN (Slack's send PIN, say). */
|
||||
|
||||
const securitySection = document.getElementById('security-section');
|
||||
const loginCard = document.getElementById('login-card');
|
||||
const loginStatus = document.getElementById('login-status');
|
||||
const passcodeInput = document.getElementById('passcode');
|
||||
@@ -62,20 +64,30 @@ function showLogin() {
|
||||
layoutsSection.hidden = true;
|
||||
updateCard.hidden = true;
|
||||
themeSection.hidden = true;
|
||||
securitySection.hidden = true;
|
||||
passcodeInput.value = '';
|
||||
passcodeInput.focus();
|
||||
}
|
||||
|
||||
let loggingIn = false;
|
||||
|
||||
async function login() {
|
||||
if (loggingIn) return;
|
||||
loggingIn = true;
|
||||
loginStatus.textContent = '';
|
||||
loginStatus.className = 'admin-status';
|
||||
try {
|
||||
await api('/api/admin/login', { method: 'POST', body: JSON.stringify({ passcode: passcodeInput.value }) });
|
||||
await api('/api/admin/login', { method: 'POST', body: JSON.stringify({ pin: passcodeInput.value }) });
|
||||
passcodeInput.value = '';
|
||||
loginCard.hidden = true;
|
||||
await loadState();
|
||||
} catch (e) {
|
||||
passcodeInput.value = '';
|
||||
loginStatus.textContent = e.message;
|
||||
loginStatus.className = 'admin-status error';
|
||||
passcodeInput.focus();
|
||||
} finally {
|
||||
loggingIn = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +95,82 @@ document.getElementById('login-btn').addEventListener('click', login);
|
||||
passcodeInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') login();
|
||||
});
|
||||
// Six digits is the whole PIN, so there is nothing left to press.
|
||||
passcodeInput.addEventListener('input', () => {
|
||||
passcodeInput.value = passcodeInput.value.replace(/\D/g, '').slice(0, 6);
|
||||
if (passcodeInput.value.length === 6) login();
|
||||
});
|
||||
|
||||
/* ── the admin PIN ───────────────────────────────────────────────────── */
|
||||
|
||||
const pinCurrentField = document.getElementById('pin-current-field');
|
||||
const pinCurrent = document.getElementById('pin-current');
|
||||
const pinNew = document.getElementById('pin-new');
|
||||
const pinRepeat = document.getElementById('pin-repeat');
|
||||
const pinSaveBtn = document.getElementById('pin-save');
|
||||
const pinClearBtn = document.getElementById('pin-clear');
|
||||
const pinStatus = document.getElementById('pin-status');
|
||||
const pinStateEl = document.getElementById('pin-state');
|
||||
|
||||
for (const input of [pinCurrent, pinNew, pinRepeat]) {
|
||||
input.addEventListener('input', () => {
|
||||
input.value = input.value.replace(/\D/g, '').slice(0, 6);
|
||||
});
|
||||
}
|
||||
|
||||
/** Paint the section for "no PIN yet" or "one is set". */
|
||||
function renderPin(state) {
|
||||
const set = Boolean(state.pinSet);
|
||||
const fromEnv = Boolean(state.pinFromEnv);
|
||||
securitySection.hidden = false;
|
||||
pinCurrentField.hidden = !set;
|
||||
pinClearBtn.hidden = !set;
|
||||
document.getElementById('pin-new-label').textContent = set ? 'New PIN' : 'PIN';
|
||||
pinSaveBtn.textContent = set ? 'Change PIN' : 'Set PIN';
|
||||
pinStateEl.textContent = fromEnv
|
||||
? 'A passcode is set in this server’s environment (PRODDASH_PASSCODE) and takes precedence. Anything set here applies once that is removed.'
|
||||
: set
|
||||
? 'Admin is locked. This page asks for the PIN, and so does every admin request.'
|
||||
: 'Admin is open to anyone on the network. Set a 6-digit PIN to lock it.';
|
||||
}
|
||||
|
||||
function pinMessage(text, bad = false) {
|
||||
pinStatus.textContent = text;
|
||||
pinStatus.className = 'admin-status' + (bad ? ' error' : ' ok');
|
||||
}
|
||||
|
||||
async function savePin(clearing = false) {
|
||||
pinStatus.textContent = '';
|
||||
pinStatus.className = 'admin-status';
|
||||
const current = pinCurrent.value;
|
||||
const next = clearing ? '' : pinNew.value;
|
||||
if (!clearing) {
|
||||
if (!/^\d{6}$/.test(next)) return pinMessage('The PIN must be exactly 6 digits.', true);
|
||||
if (next !== pinRepeat.value) return pinMessage('The two PINs are not the same.', true);
|
||||
}
|
||||
if (!pinCurrentField.hidden && !/^\d{6}$/.test(current)) {
|
||||
return pinMessage('Enter the current PIN to change it.', true);
|
||||
}
|
||||
pinSaveBtn.disabled = true;
|
||||
pinClearBtn.disabled = true;
|
||||
try {
|
||||
const body = await api('/api/admin/pin', { method: 'POST', body: JSON.stringify({ pin: next, current }) });
|
||||
for (const i of [pinCurrent, pinNew, pinRepeat]) i.value = '';
|
||||
pinMessage(body.pinSet ? 'PIN saved. It is asked for from the next visit.' : 'PIN removed — admin is open again.');
|
||||
await loadState();
|
||||
} catch (e) {
|
||||
pinMessage(e.message, true);
|
||||
} finally {
|
||||
pinSaveBtn.disabled = false;
|
||||
pinClearBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
pinSaveBtn.addEventListener('click', () => savePin(false));
|
||||
pinClearBtn.addEventListener('click', () => {
|
||||
if (confirm('Turn the admin PIN off? Anyone on the network could then change this server’s settings.')) savePin(true);
|
||||
});
|
||||
pinRepeat.addEventListener('keydown', (e) => { if (e.key === 'Enter') savePin(false); });
|
||||
|
||||
/* ── module cards ───────────────────────────────────────────────────── */
|
||||
|
||||
@@ -821,7 +909,25 @@ async function loadLayouts() {
|
||||
else toast(e.message, true);
|
||||
}
|
||||
});
|
||||
row.append(name, meta, rename, del);
|
||||
// The copy the last save replaced, one back. Restoring swaps the two,
|
||||
// so a restore is itself one restore away from being undone.
|
||||
const restore = document.createElement('button');
|
||||
restore.className = 'btn';
|
||||
restore.textContent = 'Restore previous';
|
||||
restore.title = 'Put back the copy the last save replaced';
|
||||
restore.hidden = !layout.hasPrevious;
|
||||
restore.addEventListener('click', async () => {
|
||||
if (!confirm(`Go back to the previous version of “${layout.name}”? The current one becomes its previous version, so this can be undone the same way.`)) return;
|
||||
try {
|
||||
await api(`/api/layouts/${encodeURIComponent(layout.name)}/restore`, { method: 'POST', body: '{}' });
|
||||
toast('Previous version restored');
|
||||
loadLayouts();
|
||||
} catch (e) {
|
||||
if (e.authRequired) showLogin();
|
||||
else toast(e.message, true);
|
||||
}
|
||||
});
|
||||
row.append(name, meta, restore, rename, del);
|
||||
layoutsList.appendChild(row);
|
||||
}
|
||||
}
|
||||
@@ -856,6 +962,7 @@ async function loadState() {
|
||||
for (const mod of state.modules || []) {
|
||||
modulesList.appendChild(buildModuleCard(mod));
|
||||
}
|
||||
renderPin(state);
|
||||
loadTheme();
|
||||
loadLayouts();
|
||||
loadCatalog(false);
|
||||
|
||||
+37
-5
@@ -18,10 +18,11 @@
|
||||
|
||||
<main id="admin-main">
|
||||
<section class="admin-card" id="login-card" hidden>
|
||||
<h2>Admin passcode</h2>
|
||||
<h2>Admin PIN</h2>
|
||||
<p class="admin-copy">This page changes the server-wide configuration for every dashboard in the building.</p>
|
||||
<div class="login-row">
|
||||
<input type="password" id="passcode" placeholder="Passcode" autocomplete="current-password">
|
||||
<input type="password" id="passcode" class="pin-input" inputmode="numeric" autocomplete="off"
|
||||
maxlength="6" placeholder="••••••" aria-label="Admin PIN">
|
||||
<button class="btn primary" id="login-btn">Unlock</button>
|
||||
</div>
|
||||
<div class="admin-status" id="login-status"></div>
|
||||
@@ -40,9 +41,11 @@
|
||||
</section>
|
||||
|
||||
<section id="theme-section" hidden>
|
||||
<h1 class="admin-h1">Theme</h1>
|
||||
<div class="theme-swatches" id="theme-swatches" role="radiogroup" aria-label="Theme"></div>
|
||||
<div class="admin-status" id="theme-status"></div>
|
||||
<details class="admin-fold">
|
||||
<summary class="admin-h1 admin-fold-title">Theme</summary>
|
||||
<div class="theme-swatches" id="theme-swatches" role="radiogroup" aria-label="Theme"></div>
|
||||
<div class="admin-status" id="theme-status"></div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section id="modules-section" hidden>
|
||||
@@ -65,6 +68,35 @@
|
||||
<h1 class="admin-h1">Saved layouts</h1>
|
||||
<div id="layouts-list"></div>
|
||||
</section>
|
||||
|
||||
<section id="security-section" hidden>
|
||||
<h1 class="admin-h1">Admin PIN</h1>
|
||||
<p class="admin-copy" id="pin-state"></p>
|
||||
<div class="admin-card">
|
||||
<div class="pin-form">
|
||||
<label class="pin-field" id="pin-current-field" hidden>
|
||||
<span>Current PIN</span>
|
||||
<input type="password" id="pin-current" class="pin-input" inputmode="numeric"
|
||||
autocomplete="off" maxlength="6" placeholder="••••••">
|
||||
</label>
|
||||
<label class="pin-field">
|
||||
<span id="pin-new-label">New PIN</span>
|
||||
<input type="password" id="pin-new" class="pin-input" inputmode="numeric"
|
||||
autocomplete="off" maxlength="6" placeholder="6 digits">
|
||||
</label>
|
||||
<label class="pin-field">
|
||||
<span>Repeat</span>
|
||||
<input type="password" id="pin-repeat" class="pin-input" inputmode="numeric"
|
||||
autocomplete="off" maxlength="6" placeholder="6 digits">
|
||||
</label>
|
||||
<div class="pin-actions">
|
||||
<button class="btn primary" id="pin-save">Set PIN</button>
|
||||
<button class="btn" id="pin-clear" hidden>Turn off</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-status" id="pin-status"></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="toast" hidden></div>
|
||||
|
||||
@@ -19,6 +19,14 @@
|
||||
|
||||
<span class="spacer"></span>
|
||||
|
||||
<button class="tb-btn icon" id="autohide-btn" title="Auto-hide bars" aria-label="Auto-hide tile title bars" aria-pressed="false">
|
||||
<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="M9 6l3-3 3 3"/>
|
||||
<rect x="4" y="10" width="16" height="10" rx="1.5"/>
|
||||
<path d="M4 14h16"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button class="tb-btn icon" id="fs-btn" title="Fullscreen" aria-label="Fullscreen">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M4 9V5a1 1 0 0 1 1-1h4"/><path d="M20 9V5a1 1 0 0 0-1-1h-4"/>
|
||||
|
||||
+519
-105
@@ -5,7 +5,18 @@
|
||||
settings) autosaves to this browser's localStorage — every browser is fully
|
||||
independent. Modules render inside tile bodies and never touch the shell. */
|
||||
|
||||
const COLS = 12;
|
||||
/* ── grid density ─────────────────────────────────────────────────────
|
||||
How many columns the board is cut into. Twelve is the default; a finer
|
||||
grid lets tiles be smaller and more of them fit across (a 4K wall), a
|
||||
coarser one suits a small screen read close up. It is this browser's
|
||||
choice — two displays at one location may want different densities — so
|
||||
it saves with the layout, local and named, and a named layout comes back
|
||||
at the density it was arranged on. Row height follows the column width, so
|
||||
cells keep their shape: a 3×2 tile is still a 3×2 tile, only smaller. */
|
||||
const GRID_STEPS = [8, 10, 12, 16, 20, 24];
|
||||
const GRID_DEFAULT = 12;
|
||||
const CELL_H_DEFAULT = 72; // px per row at the default density
|
||||
let COLS = GRID_DEFAULT;
|
||||
const LS_LAYOUT = 'proddash:layout';
|
||||
|
||||
const grid = document.getElementById('grid');
|
||||
@@ -16,14 +27,27 @@ const toastEl = document.getElementById('toast');
|
||||
/* ── tiny helpers ───────────────────────────────────────────────────── */
|
||||
|
||||
let toastTimer = null;
|
||||
function toast(msg, isError = false) {
|
||||
function toast(msg, isError = false, { action = null, ms } = {}) {
|
||||
toastEl.textContent = msg;
|
||||
if (action) {
|
||||
// One button, one verb — "Undo" — for a change worth taking back.
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.textContent = action.label;
|
||||
btn.addEventListener('click', () => {
|
||||
clearTimeout(toastTimer);
|
||||
toastEl.hidden = true;
|
||||
action.onClick();
|
||||
});
|
||||
toastEl.appendChild(btn);
|
||||
}
|
||||
toastEl.classList.toggle('error', isError);
|
||||
toastEl.hidden = false;
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => { toastEl.hidden = true; }, 3200);
|
||||
toastTimer = setTimeout(() => { toastEl.hidden = true; }, ms ?? (action ? 8000 : 3200));
|
||||
}
|
||||
|
||||
const autoHideBtn = document.getElementById('autohide-btn');
|
||||
const modalBackdrop = document.getElementById('modal-backdrop');
|
||||
const modalEl = document.getElementById('modal');
|
||||
|
||||
@@ -69,6 +93,41 @@ function promptModal({ title, placeholder = '', value = '', okLabel = 'Save' })
|
||||
});
|
||||
}
|
||||
|
||||
/** A yes-or-no question in the same window: resolves true for the OK button. */
|
||||
function confirmModal({ title, body = '', okLabel = 'OK' }) {
|
||||
return new Promise((resolve) => {
|
||||
modalEl.innerHTML = '';
|
||||
const h = document.createElement('h2');
|
||||
h.textContent = title;
|
||||
const copy = document.createElement('p');
|
||||
copy.className = 'modal-copy';
|
||||
copy.textContent = body;
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'actions';
|
||||
const cancel = document.createElement('button');
|
||||
cancel.className = 'btn';
|
||||
cancel.textContent = 'Cancel';
|
||||
const ok = document.createElement('button');
|
||||
ok.className = 'btn primary';
|
||||
ok.textContent = okLabel;
|
||||
actions.append(cancel, ok);
|
||||
modalEl.append(h, copy, actions);
|
||||
modalBackdrop.hidden = false;
|
||||
ok.focus();
|
||||
const done = (result) => {
|
||||
modalBackdrop.hidden = true;
|
||||
modalEl.innerHTML = '';
|
||||
resolve(result);
|
||||
};
|
||||
cancel.addEventListener('click', () => done(false));
|
||||
ok.addEventListener('click', () => done(true));
|
||||
modalEl.addEventListener('keydown', (e) => { if (e.key === 'Escape') done(false); }, { once: true });
|
||||
modalBackdrop.addEventListener('click', (e) => {
|
||||
if (e.target === modalBackdrop) done(false);
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/* ── dropdown menus ─────────────────────────────────────────────────── */
|
||||
|
||||
/* A menu opens the moment its button is clicked, drawn from what the page
|
||||
@@ -161,18 +220,43 @@ async function loadRegistry() {
|
||||
let tiles = [];
|
||||
const tileEls = new Map(); // tile id -> { el, body, dot, titleEl, instance }
|
||||
|
||||
/* Whether tile title bars fold away until you point at them. It is part of
|
||||
how a dashboard is arranged rather than a preference of this browser, so
|
||||
it saves with the layout — including a named layout on the server — and a
|
||||
fresh dashboard starts with the bars showing. Fullscreen folds every bar
|
||||
away regardless; this is the windowed equivalent, asked for by hand. */
|
||||
let autoHideHeads = false;
|
||||
|
||||
/* Beside the layout itself: the copy it replaced (restored if the layout
|
||||
ever fails to parse) and, after loading a named layout, the arrangement
|
||||
that was on screen before (what Undo goes back to). */
|
||||
const LS_LAYOUT_BACKUP = LS_LAYOUT + '.backup';
|
||||
const LS_LAYOUT_PREVIOUS = LS_LAYOUT + '.previous';
|
||||
|
||||
function saveLayout() {
|
||||
try {
|
||||
localStorage.setItem(LS_LAYOUT, JSON.stringify({ v: 1, tiles }));
|
||||
const next = JSON.stringify({ v: 1, autoHideHeads, gridCols: COLS, tiles });
|
||||
const current = localStorage.getItem(LS_LAYOUT);
|
||||
if (current && current !== next) localStorage.setItem(LS_LAYOUT_BACKUP, current);
|
||||
localStorage.setItem(LS_LAYOUT, next);
|
||||
} catch { /* private mode / full — layout just won't persist */ }
|
||||
}
|
||||
|
||||
function loadLayoutFromStorage() {
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem(LS_LAYOUT) || 'null');
|
||||
if (raw && Array.isArray(raw.tiles)) return raw.tiles;
|
||||
} catch { /* corrupted — start fresh */ }
|
||||
return [];
|
||||
for (const key of [LS_LAYOUT, LS_LAYOUT_BACKUP]) {
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem(key) || 'null');
|
||||
if (raw && Array.isArray(raw.tiles)) {
|
||||
// A layout that would not parse is not a reason for an empty wall.
|
||||
if (key === LS_LAYOUT_BACKUP) {
|
||||
toast('This browser’s layout was unreadable — the last good copy is back', true);
|
||||
try { localStorage.setItem(LS_LAYOUT, JSON.stringify(raw)); } catch { /* fine */ }
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
} catch { /* unreadable — try the copy behind it */ }
|
||||
}
|
||||
return { tiles: [] };
|
||||
}
|
||||
|
||||
function sanitizeTile(t) {
|
||||
@@ -185,7 +269,7 @@ function sanitizeTile(t) {
|
||||
w,
|
||||
h: Math.max(1, Number(t.h) || 2),
|
||||
settings: (t.settings && typeof t.settings === 'object') ? t.settings : {},
|
||||
headHidden: Boolean(t.headHidden),
|
||||
headPinned: Boolean(t.headPinned),
|
||||
// set when the tile was added from a module's tile list (multi-tile modules)
|
||||
variant: t.variant ? String(t.variant) : '',
|
||||
title: t.title ? String(t.title) : '',
|
||||
@@ -202,6 +286,87 @@ function newId() {
|
||||
|
||||
/* ── grid geometry & collision ──────────────────────────────────────── */
|
||||
|
||||
function normalizeGridCols(value) {
|
||||
const n = Number(value);
|
||||
return GRID_STEPS.includes(n) ? n : GRID_DEFAULT;
|
||||
}
|
||||
|
||||
function cellHeightFor(cols) {
|
||||
return Math.round((CELL_H_DEFAULT * GRID_DEFAULT) / cols);
|
||||
}
|
||||
|
||||
/** Cut the board into `cols` columns. Tiles keep their grid units — the
|
||||
board gets denser or coarser and everything on it changes size with it.
|
||||
A tile now past the right edge is pulled back in, and one that would then
|
||||
sit on another moves to the first free spot instead. */
|
||||
function setGridCols(value, { save = true } = {}) {
|
||||
const next = normalizeGridCols(value);
|
||||
const changed = next !== COLS;
|
||||
COLS = next;
|
||||
grid.style.gridTemplateColumns = `repeat(${COLS}, minmax(0, 1fr))`;
|
||||
document.documentElement.style.setProperty('--cell-h', cellHeightFor(COLS) + 'px');
|
||||
if (changed) {
|
||||
// A coarser board may not have room for every tile where it was. Settle
|
||||
// them in reading order: pull a tile past the right edge back in, and
|
||||
// give one that would then land on a tile already settled the first free
|
||||
// spot instead — tiles never overlap here, any more than under a drag.
|
||||
const settled = [];
|
||||
for (const tile of tiles.slice().sort((a, b) => a.y - b.y || a.x - b.x)) {
|
||||
tile.w = Math.max(1, Math.min(COLS, tile.w));
|
||||
tile.x = Math.max(0, Math.min(COLS - tile.w, tile.x));
|
||||
if (settled.some((s) => overlaps(tile, s))) Object.assign(tile, spotAmong(tile.w, tile.h, settled));
|
||||
settled.push(tile);
|
||||
applyRect(tile);
|
||||
notifyResize(tile);
|
||||
}
|
||||
rebuildDividers();
|
||||
if (document.body.classList.contains('fullscreen')) applyFullscreenFit();
|
||||
}
|
||||
if (save) saveLayout();
|
||||
}
|
||||
|
||||
const STEP_MINUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14"/></svg>';
|
||||
const STEP_PLUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>';
|
||||
|
||||
/** The Layouts menu's density row — Grid Size − 12 + — which steps without
|
||||
closing the menu, so the board can be watched changing behind it. */
|
||||
function gridStepper() {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'menu-stepper';
|
||||
const label = document.createElement('span');
|
||||
label.className = 'menu-stepper-label';
|
||||
label.textContent = 'Grid Size';
|
||||
const minus = document.createElement('button');
|
||||
minus.type = 'button';
|
||||
minus.className = 'menu-stepper-btn';
|
||||
minus.setAttribute('data-tip', 'Coarser');
|
||||
minus.innerHTML = STEP_MINUS_SVG;
|
||||
const value = document.createElement('span');
|
||||
value.className = 'menu-stepper-value';
|
||||
const plus = document.createElement('button');
|
||||
plus.type = 'button';
|
||||
plus.className = 'menu-stepper-btn';
|
||||
plus.setAttribute('data-tip', 'Finer');
|
||||
plus.innerHTML = STEP_PLUS_SVG;
|
||||
const paint = () => {
|
||||
const i = GRID_STEPS.indexOf(COLS);
|
||||
value.textContent = String(COLS);
|
||||
minus.disabled = i <= 0;
|
||||
plus.disabled = i >= GRID_STEPS.length - 1;
|
||||
};
|
||||
const step = (dir) => {
|
||||
const i = GRID_STEPS.indexOf(COLS) + dir;
|
||||
if (i < 0 || i >= GRID_STEPS.length) return;
|
||||
setGridCols(GRID_STEPS[i]);
|
||||
paint();
|
||||
};
|
||||
minus.addEventListener('click', () => step(-1));
|
||||
plus.addEventListener('click', () => step(1));
|
||||
paint();
|
||||
row.append(label, minus, value, plus);
|
||||
return row;
|
||||
}
|
||||
|
||||
function cellMetrics() {
|
||||
const style = getComputedStyle(grid);
|
||||
const gapRaw = parseFloat(style.columnGap);
|
||||
@@ -224,6 +389,17 @@ function isFree(rect, excludeId) {
|
||||
return isFreeExcept(rect, [excludeId]);
|
||||
}
|
||||
|
||||
/** First spot, scanning top-to-bottom, left-to-right, clear of these tiles. */
|
||||
function spotAmong(w, h, others) {
|
||||
for (let y = 0; y < 400; y += 1) {
|
||||
for (let x = 0; x <= COLS - w; x += 1) {
|
||||
const rect = { x, y, w, h };
|
||||
if (!others.some((o) => overlaps(rect, o))) return { x, y };
|
||||
}
|
||||
}
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
/** First free spot scanning top-to-bottom, left-to-right. */
|
||||
function findSpot(w, h) {
|
||||
for (let y = 0; y < 200; y += 1) {
|
||||
@@ -249,11 +425,116 @@ function minSizeOf(tile) {
|
||||
};
|
||||
}
|
||||
|
||||
/* ── title bars that fold away ──────────────────────────────────────── */
|
||||
|
||||
/* A bar is "off" when it is folded out of sight: the dashboard has auto-hide
|
||||
on and this tile is not pinned, or the screen is in fullscreen, which folds
|
||||
every bar for watching whatever the tiles say. Off is never gone — the top
|
||||
edge of the tile brings it back for as long as the pointer is there. */
|
||||
|
||||
/** How close to the tile's top edge the pointer opens a folded-away bar. */
|
||||
const HEAD_REVEAL_PX = 22;
|
||||
/** How far down it may wander before that bar folds away again (bar + a little). */
|
||||
const HEAD_HOLD_PX = 38;
|
||||
|
||||
function headIsOff(tile) {
|
||||
if (document.body.classList.contains('fullscreen')) return true;
|
||||
return autoHideHeads && !tile.headPinned;
|
||||
}
|
||||
|
||||
function applyHeadState(tile, el = tileEls.get(tile.id)?.el) {
|
||||
if (!el) return;
|
||||
const off = headIsOff(tile);
|
||||
el.classList.toggle('head-off', off);
|
||||
if (!off) el.classList.remove('head-peek');
|
||||
}
|
||||
|
||||
/** Re-read every tile's bar — after the toggle, or after fullscreen changes. */
|
||||
function applyAllHeadStates() {
|
||||
for (const tile of tiles) applyHeadState(tile);
|
||||
}
|
||||
|
||||
function setAutoHideHeads(on, { save = true } = {}) {
|
||||
autoHideHeads = Boolean(on);
|
||||
document.body.classList.toggle('autohide-heads', autoHideHeads);
|
||||
autoHideBtn?.classList.toggle('is-on', autoHideHeads);
|
||||
autoHideBtn?.setAttribute('aria-pressed', autoHideHeads ? 'true' : 'false');
|
||||
applyAllHeadStates();
|
||||
if (save) saveLayout();
|
||||
}
|
||||
|
||||
/* ── 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 PIN_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 16.5V22"/><path class="pin-head" d="M8.5 3h7l-1.2 7 3.2 3.2H6.5L9.7 10 8.5 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>';
|
||||
|
||||
/* ── what a saved tile can be shown as ─────────────────────────────────
|
||||
A layout keeps every tile it was given; the board shows the ones whose
|
||||
module is installed and enabled and — for a tile added from a picker
|
||||
entry — whose entry (or an alias of it) the module offers right now. The
|
||||
rest keep their place and draw nothing, and appear the moment the
|
||||
registry offers them again: a Service timer waits for PCO Plan, a
|
||||
transcript for its channel. Never the module's default in their place —
|
||||
a wall of "All timers" where five different timers were is worse than
|
||||
five gaps that fill themselves in. */
|
||||
|
||||
function tileIsOffered(tile) {
|
||||
const man = registry.get(tile.module);
|
||||
if (!man) return false;
|
||||
const variant = String(tile.variant || '');
|
||||
if (!variant) return true; // the plain module tile — the module says what that means
|
||||
const entries = Array.isArray(man.tiles) ? man.tiles : [];
|
||||
// No list at all is a module whose tiles() did not answer in time (the
|
||||
// server falls back to the classic single entry): mount, and let it sort
|
||||
// its own variant out rather than blank the board over a slow answer.
|
||||
if (!entries.length) return true;
|
||||
return entries.some((e) => String(e.id) === variant || (Array.isArray(e.aliases) && e.aliases.includes(variant)));
|
||||
}
|
||||
|
||||
/** Show tiles that have become offered; fold away tiles that have stopped being. */
|
||||
function reconcileOfferedTiles() {
|
||||
let changed = false;
|
||||
for (const tile of tiles) {
|
||||
const shown = tileEls.has(tile.id);
|
||||
const offered = tileIsOffered(tile);
|
||||
if (offered && !shown) {
|
||||
buildTile(tile);
|
||||
changed = true;
|
||||
} else if (!offered && shown) {
|
||||
unmountModule(tile);
|
||||
tileEls.get(tile.id)?.el.remove();
|
||||
tileEls.delete(tile.id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) rebuildDividers();
|
||||
scheduleOfferedRecheck();
|
||||
}
|
||||
|
||||
/* A module's entry list can change without anyone saving admin config: PCO
|
||||
Plan comes back and, a few seconds later, the timers module is offering
|
||||
its timers again. No event marks that moment, so while any tile is folded
|
||||
away the registry is re-read every few seconds until nothing is. It costs
|
||||
one GET while something is hidden and nothing at all otherwise. */
|
||||
const OFFERED_RECHECK_MS = 6000;
|
||||
let offeredRecheck = 0;
|
||||
function scheduleOfferedRecheck() {
|
||||
clearTimeout(offeredRecheck);
|
||||
if (!tiles.some((t) => !tileEls.has(t.id))) return;
|
||||
offeredRecheck = setTimeout(async () => {
|
||||
offeredRecheck = 0;
|
||||
if (await loadRegistry()) reconcileOfferedTiles();
|
||||
else scheduleOfferedRecheck();
|
||||
}, OFFERED_RECHECK_MS);
|
||||
}
|
||||
|
||||
/** The tiles a layout holds that the board is not showing, by name. */
|
||||
function unshownTileNames() {
|
||||
return tiles.filter((t) => !tileEls.has(t.id))
|
||||
.map((t) => t.title || registry.get(t.module)?.name || t.module);
|
||||
}
|
||||
|
||||
function buildTile(tile) {
|
||||
const man = registry.get(tile.module);
|
||||
|
||||
@@ -278,7 +559,34 @@ function buildTile(tile) {
|
||||
const controls = document.createElement('span');
|
||||
controls.className = 'tile-controls';
|
||||
|
||||
head.append(dot, title, controls);
|
||||
/* Pinned tiles keep their bar through auto-hide. The switch sits right
|
||||
beside the label rather than out with the actions, because it is about
|
||||
this bar rather than about the tile's contents. */
|
||||
const pin = document.createElement('button');
|
||||
pin.className = 'tile-btn pin';
|
||||
pin.innerHTML = PIN_SVG;
|
||||
const paintPin = () => {
|
||||
const on = Boolean(tile.headPinned);
|
||||
pin.classList.toggle('is-pinned', on);
|
||||
pin.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||
// Instant tooltips read `title` the first time and `data-tip` after.
|
||||
pin.title = on ? 'Unpin bar' : 'Pin bar';
|
||||
pin.setAttribute('data-tip', pin.title);
|
||||
};
|
||||
paintPin();
|
||||
pin.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
tile.headPinned = !tile.headPinned;
|
||||
paintPin();
|
||||
applyHeadState(tile, el);
|
||||
saveLayout();
|
||||
});
|
||||
|
||||
// Keeps the label hugging the pin however wide the tile gets.
|
||||
const gap = document.createElement('span');
|
||||
gap.className = 'tile-head-gap';
|
||||
|
||||
head.append(dot, title, pin, gap, controls);
|
||||
|
||||
// Every tile has something to set: its own settings when the module
|
||||
// offers any, and the tint either way.
|
||||
@@ -307,45 +615,47 @@ function buildTile(tile) {
|
||||
const body = document.createElement('div');
|
||||
body.className = 'tile-body';
|
||||
|
||||
const resize = document.createElement('div');
|
||||
resize.className = 'tile-resize';
|
||||
resize.title = 'Drag to resize';
|
||||
/* The rim: both bottom corners resize in two directions, the bottom edge
|
||||
moves that edge (trading rows with tiles below), and the other three
|
||||
move only themselves — the top edge changes where the tile starts, the
|
||||
sides how wide it is — each stopping where a neighbour or the tile's own
|
||||
minimum says it must. None of them draw anything: the cursor over an
|
||||
edge is what says it can be dragged, as on a window. */
|
||||
const grip = (cls) => {
|
||||
const gripEl = document.createElement('div');
|
||||
gripEl.className = cls;
|
||||
return gripEl;
|
||||
};
|
||||
const resize = grip('tile-resize');
|
||||
const resizeLeft = grip('tile-resize-left');
|
||||
const resizeBottom = grip('tile-resize-bottom');
|
||||
const edgeTop = grip('tile-resize-top');
|
||||
const edgeRight = grip('tile-resize-right');
|
||||
const edgeLeft = grip('tile-resize-side');
|
||||
|
||||
const resizeLeft = document.createElement('div');
|
||||
resizeLeft.className = 'tile-resize-left';
|
||||
resizeLeft.title = 'Drag to resize';
|
||||
|
||||
// pill on the bottom edge: moves that edge (trading rows with tiles below)
|
||||
const resizeBottom = document.createElement('div');
|
||||
resizeBottom.className = 'tile-resize-bottom';
|
||||
resizeBottom.title = 'Drag to adjust height';
|
||||
|
||||
// notch on the title bar's bottom edge: hides/shows the bar
|
||||
const notch = document.createElement('button');
|
||||
notch.className = 'tile-notch';
|
||||
notch.title = 'Hide / show the title bar';
|
||||
notch.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
// In fullscreen the bars are all folded away for watching; the notch
|
||||
// peeks one open for a moment without touching what this tile saved.
|
||||
if (document.body.classList.contains('fullscreen')) {
|
||||
peekHead(tile, el, !el.classList.contains('head-peek'));
|
||||
return;
|
||||
}
|
||||
tile.headHidden = !tile.headHidden;
|
||||
el.classList.toggle('head-hidden', tile.headHidden);
|
||||
saveLayout();
|
||||
// A folded-away bar is still reachable: point at the tile's top edge and it
|
||||
// slides in over the content, where its buttons click and its whole width
|
||||
// drags the tile exactly as when it is showing.
|
||||
el.addEventListener('pointermove', (ev) => {
|
||||
if (!headIsOff(tile)) return;
|
||||
// Mid-drag the tile moves under the pointer, and mid-resize the edges do
|
||||
// — neither is a moment to take the bar away.
|
||||
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
|
||||
const y = ev.clientY - el.getBoundingClientRect().top;
|
||||
const shown = el.classList.contains('head-peek');
|
||||
// It takes less to hold the bar open than to open it, so a bar that
|
||||
// appears under the pointer cannot flicker straight back out.
|
||||
if (!shown && y <= HEAD_REVEAL_PX) el.classList.add('head-peek');
|
||||
else if (shown && y > HEAD_HOLD_PX) el.classList.remove('head-peek');
|
||||
});
|
||||
|
||||
// A peeked title bar stays while the pointer is on its tile, and starts
|
||||
// counting down as soon as it leaves.
|
||||
el.addEventListener('pointerenter', () => clearPeekTimer(tile.id));
|
||||
el.addEventListener('pointerleave', () => {
|
||||
if (el.classList.contains('head-peek')) schedulePeekHide(tile.id, el);
|
||||
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
|
||||
el.classList.remove('head-peek');
|
||||
});
|
||||
|
||||
el.append(head, body, resize, resizeLeft, resizeBottom, notch);
|
||||
el.classList.toggle('head-hidden', Boolean(tile.headHidden));
|
||||
el.append(head, body, resize, resizeLeft, resizeBottom, edgeTop, edgeRight, edgeLeft);
|
||||
applyHeadState(tile, el);
|
||||
grid.appendChild(el);
|
||||
|
||||
const entry = { el, body, dot, titleEl: title, controls, instance: null };
|
||||
@@ -356,6 +666,9 @@ function buildTile(tile) {
|
||||
wireResize(tile, el, resize, false);
|
||||
wireResize(tile, el, resizeLeft, true);
|
||||
wireBottomResize(tile, el, resizeBottom);
|
||||
wireEdgeResize(tile, el, edgeTop, 'n');
|
||||
wireEdgeResize(tile, el, edgeRight, 'e');
|
||||
wireEdgeResize(tile, el, edgeLeft, 'w');
|
||||
|
||||
mountModule(tile);
|
||||
return entry;
|
||||
@@ -369,7 +682,7 @@ function refreshEmptyState() {
|
||||
|
||||
function wireDrag(tile, el, handle) {
|
||||
handle.addEventListener('pointerdown', (ev) => {
|
||||
if (ev.target.closest('.tile-btn') || ev.target.closest('.tile-menu') || ev.target.closest('.tile-notch')) return;
|
||||
if (ev.target.closest('.tile-btn') || ev.target.closest('.tile-menu')) return;
|
||||
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
|
||||
if (document.body.classList.contains('fullscreen')) return;
|
||||
ev.preventDefault();
|
||||
@@ -486,6 +799,80 @@ function wireResize(tile, el, handle, fromLeft) {
|
||||
});
|
||||
}
|
||||
|
||||
/* ── drag the top, left or right edge ───────────────────────────────── */
|
||||
|
||||
/* One edge moves and the opposite one stays where it is, so the tile grows
|
||||
or shrinks into the space beside it. Unlike the bottom-edge pill these do
|
||||
not trade rows or columns with a neighbour: the edge simply stops when it
|
||||
meets one, which is what "resize this tile" should mean. */
|
||||
|
||||
function wireEdgeResize(tile, el, handle, edge) {
|
||||
handle.addEventListener('pointerdown', (ev) => {
|
||||
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
|
||||
if (document.body.classList.contains('fullscreen')) return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
closeSettingsPopovers();
|
||||
handle.setPointerCapture(ev.pointerId);
|
||||
const m = cellMetrics();
|
||||
const sx = ev.clientX;
|
||||
const sy = ev.clientY;
|
||||
const startX = tile.x;
|
||||
const startY = tile.y;
|
||||
const startW = tile.w;
|
||||
const startH = tile.h;
|
||||
const min = minSizeOf(tile);
|
||||
el.classList.add('resizing');
|
||||
grid.classList.add('no-dividers');
|
||||
let changed = false;
|
||||
|
||||
const onMove = (e) => {
|
||||
const dCols = Math.round((e.clientX - sx) / (m.cw + m.gap));
|
||||
const dRows = Math.round((e.clientY - sy) / (m.ch + m.gap));
|
||||
let cand;
|
||||
if (edge === 'n') {
|
||||
// the top edge moves; the bottom edge stays put
|
||||
const y = Math.max(0, Math.min(startY + startH - min.h, startY + dRows));
|
||||
cand = { x: tile.x, y, w: tile.w, h: startY + startH - y };
|
||||
} else if (edge === 'w') {
|
||||
// the left edge moves; the right edge stays put
|
||||
const x = Math.max(0, Math.min(startX + startW - min.w, startX + dCols));
|
||||
cand = { x, y: tile.y, w: startX + startW - x, h: tile.h };
|
||||
} else {
|
||||
cand = {
|
||||
x: tile.x,
|
||||
y: tile.y,
|
||||
w: Math.max(min.w, Math.min(COLS - tile.x, startW + dCols)),
|
||||
h: tile.h,
|
||||
};
|
||||
}
|
||||
if (cand.x === tile.x && cand.y === tile.y && cand.w === tile.w && cand.h === tile.h) return;
|
||||
if (!isFree(cand, tile.id)) return;
|
||||
tile.x = cand.x;
|
||||
tile.y = cand.y;
|
||||
tile.w = cand.w;
|
||||
tile.h = cand.h;
|
||||
applyRect(tile);
|
||||
changed = true;
|
||||
notifyResize(tile);
|
||||
};
|
||||
const onUp = () => {
|
||||
handle.removeEventListener('pointermove', onMove);
|
||||
handle.removeEventListener('pointerup', onUp);
|
||||
handle.removeEventListener('pointercancel', onUp);
|
||||
el.classList.remove('resizing');
|
||||
grid.classList.remove('no-dividers');
|
||||
if (changed) {
|
||||
saveLayout();
|
||||
rebuildDividers();
|
||||
}
|
||||
};
|
||||
handle.addEventListener('pointermove', onMove);
|
||||
handle.addEventListener('pointerup', onUp);
|
||||
handle.addEventListener('pointercancel', onUp);
|
||||
});
|
||||
}
|
||||
|
||||
/* ── drag the bottom edge: height only ──────────────────────────────── */
|
||||
|
||||
/* The pill on a tile's bottom edge moves that edge. When other tiles sit
|
||||
@@ -588,6 +975,7 @@ function rebuildDividers() {
|
||||
for (const L of tiles) {
|
||||
for (const R of tiles) {
|
||||
if (L === R || L.x + L.w !== R.x) continue;
|
||||
if (!tileEls.has(L.id) || !tileEls.has(R.id)) continue;
|
||||
const top = Math.max(L.y, R.y);
|
||||
const bottom = Math.min(L.y + L.h, R.y + R.h);
|
||||
if (bottom <= top) continue;
|
||||
@@ -613,7 +1001,6 @@ function seamPairsAt(boundary) {
|
||||
function buildDivider(L, R, top, bottom) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'tile-divider';
|
||||
el.title = 'Drag to resize this column';
|
||||
|
||||
const place = () => {
|
||||
const m = cellMetrics();
|
||||
@@ -626,14 +1013,6 @@ function buildDivider(L, R, top, bottom) {
|
||||
|
||||
const boundary = () => L.x + L.w;
|
||||
const groupEls = () => dividers.filter((d) => d.boundary() === boundary()).map((d) => d.el);
|
||||
const unlink = () => { for (const d of dividers) d.el.classList.remove('linked'); };
|
||||
|
||||
// Before anything is dragged, show which seams travel with this one.
|
||||
el.addEventListener('pointerenter', () => {
|
||||
if (document.body.classList.contains('fullscreen')) return;
|
||||
for (const other of groupEls()) other.classList.add('linked');
|
||||
});
|
||||
el.addEventListener('pointerleave', unlink);
|
||||
|
||||
el.addEventListener('pointerdown', (ev) => {
|
||||
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
|
||||
@@ -689,7 +1068,6 @@ function buildDivider(L, R, top, bottom) {
|
||||
el.removeEventListener('pointerup', onUp);
|
||||
el.removeEventListener('pointercancel', onUp);
|
||||
for (const other of group) other.classList.remove('active');
|
||||
unlink();
|
||||
if (changed) saveLayout();
|
||||
rebuildDividers(); // adjacencies may have changed
|
||||
};
|
||||
@@ -715,10 +1093,14 @@ function addTile(moduleId, entry = null) {
|
||||
toast(`${entry.name} is already on this dashboard`);
|
||||
return;
|
||||
}
|
||||
// Sizes are declared for the default twelve-column grid; scaled so a new
|
||||
// tile lands at about the size its author meant, whatever the density here.
|
||||
const scale = COLS / GRID_DEFAULT;
|
||||
const defaultSize = entry?.defaultSize || man?.defaultSize;
|
||||
const floor = minSizeOf({ module: moduleId, minSize: entry?.minSize });
|
||||
const size = {
|
||||
w: Math.max(1, Math.min(COLS, defaultSize?.w || 4)),
|
||||
h: Math.max(1, defaultSize?.h || 3),
|
||||
w: Math.max(floor.w, 1, Math.min(COLS, Math.round((defaultSize?.w || 4) * scale))),
|
||||
h: Math.max(floor.h, 1, Math.round((defaultSize?.h || 3) * scale)),
|
||||
};
|
||||
const spot = findSpot(size.w, size.h);
|
||||
const settings = {};
|
||||
@@ -727,7 +1109,7 @@ function addTile(moduleId, entry = null) {
|
||||
if (spec && 'default' in spec) settings[key] = spec.default;
|
||||
}
|
||||
Object.assign(settings, entry?.settings || {});
|
||||
const tile = { id: newId(), module: moduleId, ...spot, ...size, settings };
|
||||
const tile = { id: newId(), module: moduleId, ...spot, ...size, settings, headPinned: false };
|
||||
if (entry) {
|
||||
tile.variant = String(entry.id);
|
||||
tile.title = String(entry.name || '');
|
||||
@@ -743,7 +1125,6 @@ function addTile(moduleId, entry = null) {
|
||||
function removeTile(id) {
|
||||
const idx = tiles.findIndex((t) => t.id === id);
|
||||
if (idx < 0) return;
|
||||
clearPeekTimer(id);
|
||||
unmountModule(tiles[idx]);
|
||||
tiles.splice(idx, 1);
|
||||
const entry = tileEls.get(id);
|
||||
@@ -997,7 +1378,7 @@ function buildModuleApi(tile, man, entry) {
|
||||
/**
|
||||
* The tile's title bar. Controls added here live next to the gear/close
|
||||
* buttons and are removed automatically when the instance stops. The
|
||||
* whole bar can be hidden by the user via the notch, so nothing
|
||||
* whole bar folds away when the dashboard auto-hides it, so nothing
|
||||
* essential should exist ONLY as a header control.
|
||||
*/
|
||||
header: {
|
||||
@@ -1712,10 +2093,17 @@ function remountTile(tile) {
|
||||
|
||||
/* ── layout apply / reset ───────────────────────────────────────────── */
|
||||
|
||||
function renderLayout(list) {
|
||||
function renderLayout(layout) {
|
||||
// Callers pass a whole layout; an array on its own is still understood so
|
||||
// "reset" can simply hand over no tiles.
|
||||
const list = Array.isArray(layout) ? layout : (layout?.tiles || []);
|
||||
clearAllTiles();
|
||||
// Density first: the tiles are clamped and placed against it.
|
||||
if (!Array.isArray(layout)) setGridCols(layout?.gridCols ?? GRID_DEFAULT, { save: false });
|
||||
tiles = list.map(sanitizeTile);
|
||||
for (const tile of tiles) buildTile(tile);
|
||||
if (!Array.isArray(layout)) setAutoHideHeads(Boolean(layout?.autoHideHeads), { save: false });
|
||||
for (const tile of tiles) if (tileIsOffered(tile)) buildTile(tile);
|
||||
scheduleOfferedRecheck();
|
||||
refreshEmptyState();
|
||||
rebuildDividers();
|
||||
if (document.body.classList.contains('fullscreen')) applyFullscreenFit();
|
||||
@@ -1778,6 +2166,13 @@ function renderAddMenu(menu) {
|
||||
wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
|
||||
menu.innerHTML = '';
|
||||
|
||||
// Density first: the one thing in here that is not a layout but the board
|
||||
// the layouts are arranged on.
|
||||
menu.appendChild(gridStepper());
|
||||
const stepperDivider = document.createElement('div');
|
||||
stepperDivider.className = 'menu-divider';
|
||||
menu.appendChild(stepperDivider);
|
||||
|
||||
menu.appendChild(menuItem('Save as named layout…', async () => {
|
||||
menu.hidden = true;
|
||||
if (!tiles.length) {
|
||||
@@ -1791,14 +2186,31 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
|
||||
});
|
||||
if (!name) return;
|
||||
try {
|
||||
// Saving over a name is asked about first — and the copy it replaces
|
||||
// is kept one back on the server, so a yes is not the end of it either.
|
||||
const existing = await fetch('/api/layouts')
|
||||
.then((r) => (r.ok ? r.json() : { layouts: [] }))
|
||||
.then((b) => (b.layouts || []).find((l) => l.name === name))
|
||||
.catch(() => null);
|
||||
if (existing) {
|
||||
const when = existing.updated ? new Date(existing.updated).toLocaleString() : 'earlier';
|
||||
const go = await confirmModal({
|
||||
title: `Replace “${name}”?`,
|
||||
body: `It was saved ${when} with ${existing.tiles} tile${existing.tiles === 1 ? '' : 's'}. The copy it replaces is kept as its previous version.`,
|
||||
okLabel: 'Replace',
|
||||
});
|
||||
if (!go) return;
|
||||
}
|
||||
const res = await fetch('/api/layouts/' + encodeURIComponent(name), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ layout: { tiles } }),
|
||||
body: JSON.stringify({ layout: { autoHideHeads, gridCols: COLS, tiles } }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(body.error || 'Save failed (' + res.status + ')');
|
||||
toast(`Saved “${name}” — any browser can load it now`);
|
||||
toast(body.replaced
|
||||
? `Replaced “${name}” — the earlier copy is kept as its previous version`
|
||||
: `Saved “${name}” — any browser can load it now`);
|
||||
} catch (e) {
|
||||
toast(e.message, true);
|
||||
}
|
||||
@@ -1838,9 +2250,33 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
|
||||
if (!res.ok) throw new Error(body.error || 'Load failed (' + res.status + ')');
|
||||
// Loading copies the layout into this browser's own state — it
|
||||
// does not live-link browsers together.
|
||||
renderLayout(body.layout.tiles || []);
|
||||
let outgoing = null;
|
||||
try { outgoing = localStorage.getItem(LS_LAYOUT); } catch { /* fine */ }
|
||||
const gridBefore = COLS;
|
||||
renderLayout(body.layout);
|
||||
saveLayout();
|
||||
toast(`Loaded “${l.name}”`);
|
||||
try { if (outgoing) localStorage.setItem(LS_LAYOUT_PREVIOUS, outgoing); } catch { /* fine */ }
|
||||
const unshown = unshownTileNames();
|
||||
const count = `${tiles.length} tile${tiles.length === 1 ? '' : 's'}`;
|
||||
const gridNote = COLS !== gridBefore ? ` on a ${COLS}-column grid` : '';
|
||||
const note = unshown.length
|
||||
? `, ${unshown.length} not shown — ${unshown.join(', ')} ${unshown.length === 1 ? 'isn’t' : 'aren’t'} offered right now`
|
||||
: '';
|
||||
toast(`Loaded “${l.name}”: ${count}${gridNote}${note}`, false, {
|
||||
ms: unshown.length ? 10000 : 7000,
|
||||
action: outgoing ? {
|
||||
label: 'Undo',
|
||||
onClick() {
|
||||
try {
|
||||
renderLayout(JSON.parse(outgoing));
|
||||
saveLayout();
|
||||
toast('Back to the arrangement you had');
|
||||
} catch {
|
||||
toast('Could not go back', true);
|
||||
}
|
||||
},
|
||||
} : null,
|
||||
});
|
||||
} catch (e) {
|
||||
toast(e.message, true);
|
||||
}
|
||||
@@ -1850,7 +2286,7 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
|
||||
menu.insertBefore(block, reset);
|
||||
});
|
||||
|
||||
/* ── menu bar show/hide (notch, like the tile title bars) ───────────── */
|
||||
/* ── menu bar show/hide (its own notch, at the top-left) ────────────── */
|
||||
|
||||
const topbar = document.getElementById('topbar');
|
||||
const topbarNotch = document.getElementById('topbar-notch');
|
||||
@@ -1877,41 +2313,6 @@ try {
|
||||
if (localStorage.getItem(LS_MENU) === '1') setMenuHidden(true, { persist: false });
|
||||
} catch { /* fine */ }
|
||||
|
||||
/* ── fullscreen: peeking at a tile's title bar ──────────────────────── */
|
||||
|
||||
/** How long a peeked title bar stays after the pointer leaves its tile. */
|
||||
const PEEK_HIDE_MS = 10000;
|
||||
const peekTimers = new Map(); // tile id → timeout
|
||||
|
||||
function clearPeekTimer(id) {
|
||||
const timer = peekTimers.get(id);
|
||||
if (timer === undefined) return;
|
||||
clearTimeout(timer);
|
||||
peekTimers.delete(id);
|
||||
}
|
||||
|
||||
function schedulePeekHide(id, el) {
|
||||
clearPeekTimer(id);
|
||||
peekTimers.set(id, setTimeout(() => {
|
||||
peekTimers.delete(id);
|
||||
el.classList.remove('head-peek');
|
||||
}, PEEK_HIDE_MS));
|
||||
}
|
||||
|
||||
function peekHead(tile, el, on) {
|
||||
el.classList.toggle('head-peek', on);
|
||||
clearPeekTimer(tile.id);
|
||||
// Opened from a click the pointer is on the tile, so the countdown waits
|
||||
// for it to leave — unless it already has (a keyboard or touch open).
|
||||
if (on && !el.matches(':hover')) schedulePeekHide(tile.id, el);
|
||||
}
|
||||
|
||||
/** Back to a plain screen: no peeks left open, no timers left running. */
|
||||
function clearPeeks() {
|
||||
for (const id of [...peekTimers.keys()]) clearPeekTimer(id);
|
||||
document.querySelectorAll('.tile.head-peek').forEach((el) => el.classList.remove('head-peek'));
|
||||
}
|
||||
|
||||
/* ── fullscreen: auto-fit the layout to the screen ──────────────────── */
|
||||
|
||||
/* Fullscreen is a viewing mode. The occupied columns stretch proportionally
|
||||
@@ -2017,7 +2418,8 @@ function applyFullscreenFit() {
|
||||
}
|
||||
|
||||
function clearFullscreenFit() {
|
||||
grid.style.gridTemplateColumns = '';
|
||||
// Back to this browser's density, not the stylesheet's twelve.
|
||||
grid.style.gridTemplateColumns = `repeat(${COLS}, minmax(0, 1fr))`;
|
||||
grid.style.gridAutoRows = '';
|
||||
gridwrap.classList.remove('no-scroll');
|
||||
for (const tile of tiles) {
|
||||
@@ -2026,6 +2428,13 @@ function clearFullscreenFit() {
|
||||
}
|
||||
}
|
||||
|
||||
autoHideBtn?.addEventListener('click', () => {
|
||||
setAutoHideHeads(!autoHideHeads);
|
||||
toast(autoHideHeads
|
||||
? 'Title bars hide until you point at a tile\u2019s top edge'
|
||||
: 'Title bars stay put');
|
||||
});
|
||||
|
||||
fsBtn.addEventListener('click', () => {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen?.().catch(() => {});
|
||||
@@ -2039,14 +2448,15 @@ document.addEventListener('fullscreenchange', () => {
|
||||
document.body.classList.toggle('fullscreen', active);
|
||||
if (active) {
|
||||
closeSettingsPopovers();
|
||||
applyAllHeadStates(); // fullscreen has its own rule for the bars
|
||||
menuHiddenBeforeFs = document.body.classList.contains('menu-hidden');
|
||||
setMenuHidden(true, { persist: false }); // the menu auto-hides; its notch brings it back
|
||||
// let the collapsed menubar's space settle before measuring
|
||||
requestAnimationFrame(() => requestAnimationFrame(applyFullscreenFit));
|
||||
} else {
|
||||
setMenuHidden(menuHiddenBeforeFs, { persist: false });
|
||||
clearPeeks();
|
||||
clearFullscreenFit();
|
||||
applyAllHeadStates();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2069,7 +2479,7 @@ function subscribeEvents() {
|
||||
// Rides on the page's one stream connection (see sseSubscribe) as `shell:`.
|
||||
sseSubscribe('/api/events', {
|
||||
events: {
|
||||
'modules-changed': () => handleModulesChanged(),
|
||||
'modules-changed': () => handleModulesChanged().then(reconcileOfferedTiles),
|
||||
theme: handleThemeEvent,
|
||||
},
|
||||
});
|
||||
@@ -2151,6 +2561,10 @@ async function boot() {
|
||||
}
|
||||
renderLayout(loadLayoutFromStorage());
|
||||
subscribeEvents();
|
||||
// Entries a module builds at runtime (a timer per discovered source) can
|
||||
// land a beat after the first registry read; look once more shortly after
|
||||
// boot so a tile hidden only by that timing appears without a nudge.
|
||||
setTimeout(async () => { if (await loadRegistry()) reconcileOfferedTiles(); }, 5000);
|
||||
}
|
||||
|
||||
boot();
|
||||
|
||||
+264
-120
@@ -126,6 +126,14 @@ body.menu-hidden #topbar {
|
||||
line-height: 1;
|
||||
}
|
||||
.tb-btn.icon svg { display: block; }
|
||||
/* A top-bar button that is a switch rather than an action lights up the same
|
||||
way an open tile button does, so "on" reads the same everywhere. */
|
||||
.tb-btn.is-on,
|
||||
.tb-btn.is-on:hover {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-text);
|
||||
}
|
||||
|
||||
/* ── dropdown menus ────────────────────────────────────────────────── */
|
||||
|
||||
@@ -179,6 +187,36 @@ body.menu-hidden #topbar {
|
||||
white-space: normal;
|
||||
}
|
||||
.menu-divider { height: 1px; background: var(--border); margin: 5px 4px; }
|
||||
|
||||
/* the Layouts menu's grid-density row: Grid Size − 12 + */
|
||||
.menu-stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.menu-stepper-label { flex: 1; }
|
||||
.menu-stepper-btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.menu-stepper-btn:hover { border-color: var(--muted); }
|
||||
.menu-stepper-btn[disabled] { opacity: 0.35; cursor: default; }
|
||||
.menu-stepper-btn svg { width: 12px; height: 12px; display: block; }
|
||||
.menu-stepper-value {
|
||||
min-width: 28px;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.menu-title {
|
||||
padding: 6px 10px 3px;
|
||||
color: var(--muted);
|
||||
@@ -270,11 +308,33 @@ body.menu-hidden #topbar {
|
||||
transition: height 0.22s ease, border-bottom-width 0.22s ease;
|
||||
}
|
||||
.tile.dragging .tile-head { cursor: grabbing; }
|
||||
/* hidden = collapsed, not removed, so the notch toggle animates */
|
||||
.tile.head-hidden .tile-head {
|
||||
height: 0;
|
||||
border-bottom-width: 0;
|
||||
overflow: hidden;
|
||||
|
||||
/* Folded away: the bar leaves the flow and parks just above the tile, so the
|
||||
module keeps the whole body and nothing reflows as the bar comes and goes.
|
||||
Pointing at the tile's top edge slides it back in over the content
|
||||
(shell.js adds .head-peek) — and because it is the same element, its
|
||||
buttons click and its width drags the tile just as they always did. */
|
||||
.tile.head-off > .tile-head {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 7;
|
||||
background: color-mix(in srgb, var(--panel) 90%, transparent);
|
||||
-webkit-backdrop-filter: blur(7px);
|
||||
backdrop-filter: blur(7px);
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: transform 0.18s ease, opacity 0.18s ease;
|
||||
}
|
||||
.tile.head-off.head-peek > .tile-head {
|
||||
transform: none;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.tile.is-tinted.head-off > .tile-head {
|
||||
background: color-mix(in srgb, var(--tile-tint) 20%, var(--panel));
|
||||
}
|
||||
|
||||
.tile-head .dot {
|
||||
@@ -290,7 +350,7 @@ body.menu-hidden #topbar {
|
||||
.tile-head .dot.error { background: var(--danger); }
|
||||
|
||||
.tile-title {
|
||||
flex: 1;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
@@ -321,6 +381,33 @@ body.menu-hidden #topbar {
|
||||
.tile-btn.is-open,
|
||||
.tile-btn.is-open:hover { background: var(--accent); color: var(--accent-text); }
|
||||
|
||||
/* The pin: a state, not an action, so it stays quiet until it is on and
|
||||
sits closer to the label than to the buttons at the other end. It fades
|
||||
in with the rest of the tile's chrome so a wall of unpinned tiles is not
|
||||
a wall of pins. */
|
||||
.tile-btn.pin {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-left: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.tile-btn.pin svg { width: 12px; height: 12px; }
|
||||
.tile:hover .tile-btn.pin,
|
||||
.tile-btn.pin:focus-visible { opacity: 0.75; }
|
||||
.tile-btn.pin:hover { opacity: 1; }
|
||||
/* Pinned reads at a glance, whether or not anything is being pointed at. */
|
||||
.tile-btn.pin.is-pinned,
|
||||
.tile:hover .tile-btn.pin.is-pinned {
|
||||
opacity: 1;
|
||||
color: var(--accent);
|
||||
}
|
||||
.tile-btn.pin.is-pinned .pin-head { fill: currentColor; }
|
||||
.tile-btn.pin.is-pinned:hover { background: var(--border); }
|
||||
|
||||
/* Keeps the label hugging its pin however wide the tile is. */
|
||||
.tile-head-gap { flex: 1 1 auto; min-width: 0; }
|
||||
|
||||
/* the shell's tooltip: shown at once under the button the pointer is on */
|
||||
#tip {
|
||||
position: fixed;
|
||||
@@ -374,55 +461,6 @@ body.menu-hidden #topbar {
|
||||
}
|
||||
.tile-menu[hidden] { display: none; }
|
||||
|
||||
/* notch on the title bar's bottom edge: click to hide/show the bar
|
||||
(same idea as the ProdCom Listener's pull-down menubar handle) */
|
||||
.tile-notch {
|
||||
position: absolute;
|
||||
top: 32px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 46px;
|
||||
height: 13px;
|
||||
padding: 0;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-top: none;
|
||||
border-radius: 0 0 10px 10px;
|
||||
cursor: pointer;
|
||||
z-index: 6;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease, top 0.22s ease;
|
||||
}
|
||||
.tile:hover .tile-notch { opacity: 0.6; }
|
||||
.tile .tile-notch:hover { opacity: 1; }
|
||||
.tile.head-hidden .tile-notch { top: 0; }
|
||||
.tile-notch::after {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 18px;
|
||||
height: 3px;
|
||||
margin: 4px auto 0;
|
||||
border-radius: 999px;
|
||||
background: var(--muted);
|
||||
}
|
||||
.tile-notch:hover::after { background: var(--text); }
|
||||
|
||||
/* Fullscreen is for watching, not arranging: every title bar folds away, and
|
||||
a tile's own hidden/shown preference is left untouched for when the screen
|
||||
comes back. The notch still peeks one open, and it folds back once the
|
||||
pointer has been off that tile for a while (PEEK_HIDE_MS in shell.js). */
|
||||
body.fullscreen .tile:not(.head-peek) .tile-head {
|
||||
height: 0;
|
||||
border-bottom-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
body.fullscreen .tile:not(.head-peek) .tile-notch { top: 0; }
|
||||
body.fullscreen .tile.head-peek .tile-head {
|
||||
height: 32px;
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
body.fullscreen .tile.head-peek .tile-notch { top: 32px; }
|
||||
|
||||
.tile-body {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
@@ -431,80 +469,67 @@ body.fullscreen .tile.head-peek .tile-notch { top: 32px; }
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── resizing: the whole rim of a tile, drawn nowhere ────────────────
|
||||
Every edge and both bottom corners resize, so there is nothing to point
|
||||
at — the grips are invisible and the cursor says what each one does, the
|
||||
way a window's own edges do. Together they tile the tile's outer 8px
|
||||
without a gap: the top strip runs corner to corner, the sides run between
|
||||
it and the bottom corners, and the bottom strip fills between those. */
|
||||
|
||||
.tile-resize,
|
||||
.tile-resize-left,
|
||||
.tile-resize-bottom,
|
||||
.tile-resize-top,
|
||||
.tile-resize-right,
|
||||
.tile-resize-side {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/* the two bottom corners: both directions at once */
|
||||
.tile-resize,
|
||||
.tile-resize-left {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
touch-action: none;
|
||||
z-index: 5;
|
||||
color: var(--muted);
|
||||
opacity: 0; /* corner indicators only show while hovering the tile */
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.tile-resize { right: 0; cursor: nwse-resize; }
|
||||
.tile-resize-left { left: 0; cursor: nesw-resize; }
|
||||
.tile:hover .tile-resize,
|
||||
.tile:hover .tile-resize-left { opacity: 0.6; }
|
||||
.tile .tile-resize:hover,
|
||||
.tile .tile-resize-left:hover { opacity: 1; color: var(--text); }
|
||||
.tile-resize::after,
|
||||
.tile-resize-left::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-bottom: 2px solid currentColor;
|
||||
}
|
||||
.tile-resize::after {
|
||||
right: 4px;
|
||||
border-right: 2px solid currentColor;
|
||||
border-radius: 0 0 3px 0;
|
||||
}
|
||||
.tile-resize-left::after {
|
||||
left: 4px;
|
||||
border-left: 2px solid currentColor;
|
||||
border-radius: 0 0 0 3px;
|
||||
}
|
||||
|
||||
/* bottom edge: drag to move the tile's bottom edge (height only). Drawn as
|
||||
the same 2px line as the corner handles it sits between, on the same
|
||||
4px inset, so the three read as one set of marks along the bottom. */
|
||||
/* top and bottom: height only */
|
||||
.tile-resize-top,
|
||||
.tile-resize-bottom {
|
||||
position: absolute;
|
||||
left: 24px;
|
||||
right: 24px;
|
||||
bottom: 0;
|
||||
height: 10px;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
height: 8px;
|
||||
cursor: ns-resize;
|
||||
touch-action: none;
|
||||
color: var(--muted);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.tile-resize-bottom::after {
|
||||
content: "";
|
||||
width: 44px;
|
||||
max-width: 60%;
|
||||
height: 0;
|
||||
margin-bottom: 4px;
|
||||
border-bottom: 2px solid currentColor;
|
||||
.tile-resize-top {
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
/* Above a revealed title bar: the bar slides in under the pointer here,
|
||||
and this strip has to stay the top edge rather than become part of it. */
|
||||
z-index: 8;
|
||||
}
|
||||
.tile:hover .tile-resize-bottom { opacity: 0.6; }
|
||||
.tile .tile-resize-bottom:hover,
|
||||
.tile.resizing .tile-resize-bottom { opacity: 1; color: var(--text); }
|
||||
.tile-resize-bottom { bottom: 0; left: 20px; right: 20px; }
|
||||
|
||||
/* left and right: width only, between the top strip and the bottom corners */
|
||||
.tile-resize-right,
|
||||
.tile-resize-side {
|
||||
top: 8px;
|
||||
bottom: 20px;
|
||||
width: 8px;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
.tile-resize-right { right: 0; }
|
||||
.tile-resize-side { left: 0; }
|
||||
|
||||
/* ── seam divider between adjacent tiles ───────────────────────────── */
|
||||
|
||||
/* When two tiles share a vertical edge, an invisible strip sits on the seam;
|
||||
hovering reveals a small macOS-split-view-style grab pill. Dragging it
|
||||
trades width between the two tiles. */
|
||||
/* When two tiles share a vertical edge, an invisible strip sits on the seam.
|
||||
Dragging it trades width between the two tiles rather than resizing one,
|
||||
so it sits above their own edge grips. Like them it draws nothing at rest
|
||||
— only while it is being dragged, to show which seam is moving. */
|
||||
.tile-divider {
|
||||
position: absolute;
|
||||
width: 14px;
|
||||
@@ -528,22 +553,24 @@ body.fullscreen .tile.head-peek .tile-notch { top: 32px; }
|
||||
transition: opacity 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.tile-divider:hover::after,
|
||||
.tile-divider.linked::after { opacity: 0.7; }
|
||||
.tile-divider.active::after { opacity: 1; background: var(--text); }
|
||||
|
||||
/* Hide the pills while a tile itself is being moved or corner-resized —
|
||||
they're rebuilt for the new geometry on release. */
|
||||
/* Drop the seams while a tile itself is being moved or resized — they're
|
||||
rebuilt for the new geometry on release. */
|
||||
#grid.no-dividers .tile-divider { display: none; }
|
||||
|
||||
/* ── fullscreen mode ───────────────────────────────────────────────── */
|
||||
|
||||
/* Fullscreen is a viewing mode: the layout auto-fits the screen, so the
|
||||
editing affordances (corner handles, seam pills) are parked. */
|
||||
editing affordances (the rim grips and the seams) are parked. */
|
||||
body.fullscreen .tile-resize,
|
||||
body.fullscreen .tile-resize-left,
|
||||
body.fullscreen .tile-resize-bottom,
|
||||
body.fullscreen .tile-resize-top,
|
||||
body.fullscreen .tile-resize-right,
|
||||
body.fullscreen .tile-resize-side,
|
||||
body.fullscreen .tile-divider,
|
||||
body.fullscreen #autohide-btn,
|
||||
body.fullscreen #admin-link { display: none; }
|
||||
body.fullscreen #grid { padding-bottom: 0; }
|
||||
#gridwrap.no-scroll { overflow-y: hidden; }
|
||||
@@ -909,6 +936,24 @@ body.fullscreen #grid { padding-bottom: 0; }
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
#toast[hidden] { display: none; }
|
||||
#toast button {
|
||||
margin-left: 12px;
|
||||
padding: 3px 10px;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 5px;
|
||||
background: none;
|
||||
color: var(--accent);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
#toast button:hover { background: var(--accent); color: var(--accent-text); }
|
||||
#modal .modal-copy {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
margin: 6px 0 14px;
|
||||
}
|
||||
#toast.error { border-color: var(--danger); }
|
||||
|
||||
/* ── scrollbars ────────────────────────────────────────────────────── */
|
||||
@@ -944,8 +989,10 @@ body.fullscreen #grid { padding-bottom: 0; }
|
||||
every shell element follows, and a colour a tile is configured with (set
|
||||
on the tile itself) still wins. The :root block at the top of this file is
|
||||
"booth", the default; the list of ids and names lives in server.js
|
||||
(GET /api/theme). All four are booth-grade: high contrast, the accent
|
||||
nowhere near the danger red or the warning amber, muted still readable. */
|
||||
(GET /api/theme). Every one is booth-grade: high contrast, the accent
|
||||
nowhere near the danger red or the warning amber, muted still readable.
|
||||
Each sets --accent-text too when the default (a near-black green, for
|
||||
booth's mint) would look wrong against its own accent. */
|
||||
|
||||
/* Harbor — deep navy, cyan accent */
|
||||
html[data-theme="harbor"] {
|
||||
@@ -996,3 +1043,100 @@ html[data-theme="daylight"] {
|
||||
--danger: #d1242f;
|
||||
--warn: #b26a00;
|
||||
}
|
||||
|
||||
/* Midnight — indigo night, violet accent */
|
||||
html[data-theme="midnight"] {
|
||||
--bg: #0a0b18;
|
||||
--panel: #14162b;
|
||||
--border: #272a4a;
|
||||
--text: #e8e9fb;
|
||||
--muted: #9a9cc8;
|
||||
--accent: #9d8cff;
|
||||
--accent-text: #12103a;
|
||||
--danger: #ff5a72;
|
||||
--warn: #f5b93a;
|
||||
}
|
||||
|
||||
/* Moss — forest floor, lime accent (warn pushed orange so the two greens
|
||||
and the amber never read as one another) */
|
||||
html[data-theme="moss"] {
|
||||
--bg: #08120c;
|
||||
--panel: #101e15;
|
||||
--border: #1f3728;
|
||||
--text: #e9f4ea;
|
||||
--muted: #8fb09a;
|
||||
--accent: #8fdb3f;
|
||||
--accent-text: #15250a;
|
||||
--danger: #ff5a52;
|
||||
--warn: #ff9f1c;
|
||||
}
|
||||
|
||||
/* Plum — aubergine, purple accent */
|
||||
html[data-theme="plum"] {
|
||||
--bg: #110a17;
|
||||
--panel: #1d1228;
|
||||
--border: #332042;
|
||||
--text: #f2e9f8;
|
||||
--muted: #b195c2;
|
||||
--accent: #c084fc;
|
||||
--accent-text: #260a37;
|
||||
--danger: #ff4d4f;
|
||||
--warn: #f7b53b;
|
||||
}
|
||||
|
||||
/* Sepia — warm brown room, cool teal accent: the opposite arrangement to
|
||||
Ember, which is warm throughout */
|
||||
html[data-theme="sepia"] {
|
||||
--bg: #12100b;
|
||||
--panel: #1d1913;
|
||||
--border: #33291d;
|
||||
--text: #f4ede0;
|
||||
--muted: #b3a48b;
|
||||
--accent: #5eead4;
|
||||
--accent-text: #06231e;
|
||||
--danger: #ff5a52;
|
||||
--warn: #ffc247;
|
||||
}
|
||||
|
||||
/* Blackout — true black for a dark room, where any glow off the screen
|
||||
carries; everything else is as bright as it can be against it */
|
||||
html[data-theme="blackout"] {
|
||||
--bg: #000000;
|
||||
--panel: #0a0a0a;
|
||||
--border: #1f1f1f;
|
||||
--text: #ffffff;
|
||||
--muted: #9e9e9e;
|
||||
--accent: #00e5ff;
|
||||
--accent-text: #00212a;
|
||||
--danger: #ff3b30;
|
||||
--warn: #ffcc00;
|
||||
}
|
||||
|
||||
/* Paper — the warm light one, next to Daylight's cool white. Danger and warn
|
||||
darken, as they must on a light background, and the indigo accent takes
|
||||
white text. */
|
||||
html[data-theme="paper"] {
|
||||
--bg: #f5f1e8;
|
||||
--panel: #fffdf8;
|
||||
--border: #ddd5c4;
|
||||
--text: #1c1a15;
|
||||
--muted: #6b6455;
|
||||
--accent: #4f46e5;
|
||||
--accent-text: #ffffff;
|
||||
--danger: #c8102e;
|
||||
--warn: #9a6700;
|
||||
}
|
||||
|
||||
/* Iron — no colour of its own: the accent is near-white, so the only hues on
|
||||
screen are a module's own and the red/amber that mean something */
|
||||
html[data-theme="iron"] {
|
||||
--bg: #0c0c0d;
|
||||
--panel: #161617;
|
||||
--border: #2b2b2d;
|
||||
--text: #f2f2f3;
|
||||
--muted: #9a9a9d;
|
||||
--accent: #d8d8dc;
|
||||
--accent-text: #131314;
|
||||
--danger: #ff4d4f;
|
||||
--warn: #ffb224;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
* Configure via config/proddash.json (defaults, in the repo), an optional
|
||||
* proddash.json in the data directory (this machine's overrides), or env vars:
|
||||
* PORT (default: value in proddash.json, else 24500)
|
||||
* PRODDASH_PASSCODE (overrides adminPasscode in proddash.json)
|
||||
* PRODDASH_PASSCODE (a passcode that overrides the stored admin PIN —
|
||||
* also the way back in if the PIN is ever forgotten)
|
||||
* 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)
|
||||
@@ -711,6 +712,13 @@ const THEMES = [
|
||||
{ id: 'graphite', name: 'Graphite', colors: { bg: '#0f1012', panel: '#17191d', accent: '#6cb4ff' } },
|
||||
{ id: 'ember', name: 'Ember', colors: { bg: '#120c0a', panel: '#1d1411', accent: '#ff8f3a' } },
|
||||
{ id: 'daylight', name: 'Daylight', colors: { bg: '#eef1f5', panel: '#ffffff', accent: '#0b8a5f' } },
|
||||
{ id: 'midnight', name: 'Midnight', colors: { bg: '#0a0b18', panel: '#14162b', accent: '#9d8cff' } },
|
||||
{ id: 'moss', name: 'Moss', colors: { bg: '#08120c', panel: '#101e15', accent: '#8fdb3f' } },
|
||||
{ id: 'plum', name: 'Plum', colors: { bg: '#110a17', panel: '#1d1228', accent: '#c084fc' } },
|
||||
{ id: 'sepia', name: 'Sepia', colors: { bg: '#12100b', panel: '#1d1913', accent: '#5eead4' } },
|
||||
{ id: 'blackout', name: 'Blackout', colors: { bg: '#000000', panel: '#0a0a0a', accent: '#00e5ff' } },
|
||||
{ id: 'paper', name: 'Paper', colors: { bg: '#f5f1e8', panel: '#fffdf8', accent: '#4f46e5' } },
|
||||
{ id: 'iron', name: 'Iron', colors: { bg: '#0c0c0d', panel: '#161617', accent: '#d8d8dc' } },
|
||||
];
|
||||
const DEFAULT_THEME = THEMES[0].id;
|
||||
|
||||
@@ -766,7 +774,7 @@ function parseRepoSetting(value, fallbackBranch) {
|
||||
}
|
||||
|
||||
const REPO = parseRepoSetting(process.env.PRODDASH_REPO || shellConfig.repo, shellConfig.branch)
|
||||
|| parseRepoSetting('michaelmcgary2008/ProdDash', 'main');
|
||||
|| parseRepoSetting('michael/ProdDash', 'main');
|
||||
|
||||
/**
|
||||
* Which server the code comes from. GitHub unless `forge` names another —
|
||||
@@ -1096,12 +1104,13 @@ function uninstallModule(id) {
|
||||
|
||||
/* — updating the shell itself — */
|
||||
|
||||
function copyTree(src, dst) {
|
||||
function copyTree(src, dst, include = null) {
|
||||
fs.mkdirSync(dst, { recursive: true });
|
||||
for (const e of fs.readdirSync(src, { withFileTypes: true })) {
|
||||
if (include && !include(e.name)) continue;
|
||||
const s = path.join(src, e.name);
|
||||
const d = path.join(dst, e.name);
|
||||
if (e.isDirectory()) copyTree(s, d);
|
||||
if (e.isDirectory()) copyTree(s, d, include);
|
||||
else fs.copyFileSync(s, d);
|
||||
}
|
||||
}
|
||||
@@ -1121,25 +1130,83 @@ async function updateShell() {
|
||||
const out = git(['pull', '--ff-only', 'origin', REPO.branch], 120000);
|
||||
console.log('[proddash] git pull:', out.split('\n').filter(Boolean).slice(-2).join(' | '));
|
||||
} else {
|
||||
const keep = (rel) => rel === 'config/modules.json' || rel.startsWith('config/layouts/') || rel.startsWith('.git/');
|
||||
const staging = path.join(ROOT, '.proddash-update');
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
for (const [rel, data] of remote.files) {
|
||||
if (!safeRelPath(rel) || keep(rel)) continue;
|
||||
if (!safeRelPath(rel) || isKeptOnUpdate(rel)) continue;
|
||||
const target = path.join(staging, rel);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, data);
|
||||
}
|
||||
if (!fs.existsSync(path.join(staging, 'server.js'))) {
|
||||
const newServer = path.join(staging, 'server.js');
|
||||
if (!fs.existsSync(newServer)) {
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
throw httpError(502, 'The downloaded archive does not look like ProdDash (no server.js).');
|
||||
}
|
||||
// Nothing is swapped in until the new server at least parses. A file that
|
||||
// arrived truncated or mangled would otherwise be found only by a process
|
||||
// that cannot start — the one state this app cannot talk its way out of.
|
||||
try {
|
||||
execFileSync(process.execPath, ['--check', newServer], { timeout: 30000, stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
} catch (err) {
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
// node --check leads with the staging path and a caret diagram; the
|
||||
// line that names the fault is the one worth repeating.
|
||||
const lines = String(err?.stderr || err?.message || err).split('\n').map((l) => l.trim());
|
||||
const why = lines.find((l) => /^[A-Za-z]*Error\b/.test(l)) || 'it does not parse';
|
||||
throw httpError(502, `The downloaded ProdDash will not run — nothing was changed (${why}).`);
|
||||
}
|
||||
keepPreviousCopy();
|
||||
copyTree(staging, ROOT);
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
}
|
||||
return { from: SHELL_VERSION, to: remote.version || '', method: status.method };
|
||||
}
|
||||
|
||||
/* ── what an update must not touch ───────────────────────────────────
|
||||
Anything inside the data directory. Normally it sits well outside the app
|
||||
folder and none of this applies, but when it cannot (an unwritable
|
||||
Application Support, or PRODDASH_DATA_DIR aimed inside the app) the data
|
||||
directory IS the app's config/ — and an update would then write the repo's
|
||||
shipped defaults over this machine's own proddash.json, taking the admin
|
||||
PIN, the theme and the forge with it. The two legacy names stay listed for
|
||||
an install whose state never moved out. */
|
||||
|
||||
const DATA_DIR_INSIDE_ROOT = (() => {
|
||||
const rel = path.relative(ROOT, DATA_DIR);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return '';
|
||||
return rel.split(path.sep).join('/') + '/';
|
||||
})();
|
||||
|
||||
function isKeptOnUpdate(rel) {
|
||||
if (rel.startsWith('.git/')) return true;
|
||||
if (rel === 'config/modules.json' || rel.startsWith('config/layouts/')) return true;
|
||||
return Boolean(DATA_DIR_INSIDE_ROOT) && `${rel}/`.startsWith(DATA_DIR_INSIDE_ROOT);
|
||||
}
|
||||
|
||||
/* ── the way back ────────────────────────────────────────────────────
|
||||
The copy being replaced is set aside first, so there is always one known
|
||||
version to return to. The launcher restores it if the new one will not
|
||||
stay up (see ServerController); by hand it is a rename. It is replaced by
|
||||
the next update, never accumulated. */
|
||||
|
||||
function previousCopyPath() {
|
||||
return `${ROOT}.previous`;
|
||||
}
|
||||
|
||||
function keepPreviousCopy() {
|
||||
const previous = previousCopyPath();
|
||||
try {
|
||||
fs.rmSync(previous, { recursive: true, force: true });
|
||||
copyTree(ROOT, previous, (rel) => rel !== '.proddash-update' && rel !== '.git');
|
||||
fs.writeFileSync(path.join(previous, '.proddash-version'), SHELL_VERSION);
|
||||
console.log(`[proddash] kept ${SHELL_VERSION} at ${previous} in case the new one will not run`);
|
||||
} catch (err) {
|
||||
// Worth saying, not worth refusing the update over.
|
||||
console.warn('[proddash] could not set the current copy aside:', err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit so the new code runs. Exit code 75 tells a launcher loop to start us
|
||||
* again; when nothing is supervising (PRODDASH_LAUNCHER unset) we start our
|
||||
@@ -1170,12 +1237,109 @@ function restartServer(reason) {
|
||||
|
||||
/* ── admin API ──────────────────────────────────────────────────────── */
|
||||
|
||||
/* Admin is behind a 6-digit PIN, set in the admin page and kept in this
|
||||
machine's proddash.json as a scrypt hash — never in the clear, because that
|
||||
file gets copied between machines. A dashboard needs no PIN; only /admin
|
||||
and the admin API do. Two ways back in if it is ever forgotten: set
|
||||
PRODDASH_PASSCODE in the environment, or delete "adminPin" from
|
||||
proddash.json in the data directory and restart. `adminPasscode` (the older
|
||||
free-text setting) still works so an existing install keeps running. */
|
||||
const PIN_RE = /^\d{6}$/;
|
||||
const PIN_MAX_WRONG = 5;
|
||||
const PIN_LOCKOUT_MS = 30000;
|
||||
const ADMIN_PASSCODE = String(process.env.PRODDASH_PASSCODE || shellConfig.adminPasscode || '');
|
||||
/** Session token: regenerating it every boot is fine — admins just re-enter the passcode. */
|
||||
/** Session token: regenerating it every boot is fine — admins just re-enter the PIN. */
|
||||
const adminToken = crypto.randomBytes(24).toString('hex');
|
||||
/** The stored PIN, as "scrypt$<salt>$<hash>"; '' when admin is open. */
|
||||
let adminPinHash = String(shellConfig.adminPin || '');
|
||||
const pinAttempts = new Map(); // ip → { count, lockedUntil }
|
||||
|
||||
function hashPin(pin, salt = crypto.randomBytes(16).toString('hex')) {
|
||||
return `scrypt$${salt}$${crypto.scryptSync(String(pin), salt, 32).toString('hex')}`;
|
||||
}
|
||||
|
||||
/** Constant-time check of a PIN against the stored hash. */
|
||||
function pinMatches(given, stored) {
|
||||
const parts = String(stored || '').split('$');
|
||||
if (parts.length !== 3 || parts[0] !== 'scrypt') return false;
|
||||
const expected = Buffer.from(parts[2], 'hex');
|
||||
const actual = crypto.scryptSync(String(given), parts[1], expected.length);
|
||||
return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
|
||||
}
|
||||
|
||||
/** Is a credential required at all? */
|
||||
function adminLocked() {
|
||||
return Boolean(ADMIN_PASSCODE || adminPinHash);
|
||||
}
|
||||
|
||||
/** True when `given` is the credential in force (the env passcode, or the PIN). */
|
||||
function credentialOk(given) {
|
||||
const s = String(given ?? '');
|
||||
if (!s) return false;
|
||||
if (ADMIN_PASSCODE) {
|
||||
const a = Buffer.from(s);
|
||||
const b = Buffer.from(ADMIN_PASSCODE);
|
||||
if (a.length === b.length && crypto.timingSafeEqual(a, b)) return true;
|
||||
}
|
||||
return adminPinHash ? pinMatches(s, adminPinHash) : false;
|
||||
}
|
||||
|
||||
function clientIp(req) {
|
||||
return String(req.socket?.remoteAddress || 'unknown');
|
||||
}
|
||||
|
||||
/** Seconds of lockout left for this address, or 0. */
|
||||
function lockoutLeft(ip) {
|
||||
const a = pinAttempts.get(ip);
|
||||
if (!a || !a.lockedUntil) return 0;
|
||||
const left = a.lockedUntil - Date.now();
|
||||
if (left <= 0) {
|
||||
pinAttempts.delete(ip);
|
||||
return 0;
|
||||
}
|
||||
return Math.ceil(left / 1000);
|
||||
}
|
||||
|
||||
function noteWrongPin(ip) {
|
||||
const a = pinAttempts.get(ip) || { count: 0, lockedUntil: 0 };
|
||||
a.count += 1;
|
||||
if (a.count >= PIN_MAX_WRONG) {
|
||||
a.count = 0;
|
||||
a.lockedUntil = Date.now() + PIN_LOCKOUT_MS;
|
||||
console.warn(`[proddash] ${PIN_MAX_WRONG} wrong admin PINs from ${ip} — locked out for ${PIN_LOCKOUT_MS / 1000} s`);
|
||||
}
|
||||
pinAttempts.set(ip, a);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new PIN (6 digits), or '' to open admin up again. Keeps every other
|
||||
* key in this machine's proddash.json. Throws an httpError the route reports.
|
||||
*/
|
||||
function setAdminPin(pin) {
|
||||
const next = String(pin ?? '');
|
||||
if (next && !PIN_RE.test(next)) throw httpError(400, 'The PIN must be exactly 6 digits.');
|
||||
const file = path.join(DATA_DIR, 'proddash.json');
|
||||
let existing = {};
|
||||
if (fs.existsSync(file)) {
|
||||
existing = readJson(file, null);
|
||||
if (!existing || typeof existing !== 'object' || Array.isArray(existing)) {
|
||||
throw httpError(500, `${file} is not a JSON object — fix it by hand before changing the PIN.`);
|
||||
}
|
||||
}
|
||||
const hash = next ? hashPin(next) : '';
|
||||
const out = { ...existing };
|
||||
if (hash) out.adminPin = hash;
|
||||
else delete out.adminPin;
|
||||
writeJson(file, out);
|
||||
adminPinHash = hash;
|
||||
shellConfig.adminPin = hash;
|
||||
pinAttempts.clear();
|
||||
console.log(`[proddash] admin PIN ${hash ? 'set' : 'removed — admin is open again'}`);
|
||||
return { pinSet: Boolean(hash) };
|
||||
}
|
||||
|
||||
function isAuthed(req) {
|
||||
if (!ADMIN_PASSCODE) return true;
|
||||
if (!adminLocked()) return true;
|
||||
const cookies = String(req.headers.cookie || '');
|
||||
return cookies.split(';').some((c) => c.trim() === 'proddash_admin=' + adminToken);
|
||||
}
|
||||
@@ -1183,7 +1347,7 @@ function isAuthed(req) {
|
||||
/** Guard a state-changing admin route. Returns true when the request was refused. */
|
||||
function refuseAdminWrite(req, res) {
|
||||
if (!isAuthed(req)) {
|
||||
sendJson(res, 401, { error: 'Enter the admin passcode first.', authRequired: true });
|
||||
sendJson(res, 401, { error: 'Enter the admin PIN first.', authRequired: true });
|
||||
return true;
|
||||
}
|
||||
// Requiring application/json forces a CORS preflight that a hostile page
|
||||
@@ -1268,10 +1432,19 @@ async function handleAdminApi(req, res, urlPath) {
|
||||
} catch {
|
||||
return sendJson(res, 400, { error: 'Malformed request.' });
|
||||
}
|
||||
if (!ADMIN_PASSCODE) return sendJson(res, 200, { ok: true, authRequired: false });
|
||||
if (String(body.passcode || '') !== ADMIN_PASSCODE) {
|
||||
return sendJson(res, 403, { error: 'Wrong passcode.' });
|
||||
if (!adminLocked()) return sendJson(res, 200, { ok: true, authRequired: false });
|
||||
const ip = clientIp(req);
|
||||
const left = lockoutLeft(ip);
|
||||
if (left) return sendJson(res, 429, { error: `Too many wrong PINs — try again in ${left} s.`, retryAfter: left });
|
||||
// `passcode` is what the older admin page sent; `pin` is what this one does.
|
||||
if (!credentialOk(body.pin ?? body.passcode)) {
|
||||
noteWrongPin(ip);
|
||||
const now = lockoutLeft(ip);
|
||||
return sendJson(res, 403, now
|
||||
? { error: `Too many wrong PINs — try again in ${now} s.`, retryAfter: now }
|
||||
: { error: 'Wrong PIN.' });
|
||||
}
|
||||
pinAttempts.delete(ip);
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Set-Cookie': `proddash_admin=${adminToken}; Path=/; SameSite=Lax; HttpOnly`,
|
||||
@@ -1280,12 +1453,50 @@ async function handleAdminApi(req, res, urlPath) {
|
||||
return res.end(JSON.stringify({ ok: true }));
|
||||
}
|
||||
|
||||
if (urlPath === '/api/admin/pin' && req.method === 'POST') {
|
||||
if (refuseAdminWrite(req, res)) return;
|
||||
let body = {};
|
||||
try {
|
||||
body = JSON.parse((await readBody(req)) || '{}');
|
||||
} catch {
|
||||
return sendJson(res, 400, { error: 'Malformed request.' });
|
||||
}
|
||||
// Changing a credential re-asserts it, so a browser left open on the
|
||||
// admin page can't be used to take the machine over.
|
||||
if (adminPinHash) {
|
||||
const ip = clientIp(req);
|
||||
const left = lockoutLeft(ip);
|
||||
if (left) return sendJson(res, 429, { error: `Too many wrong PINs — try again in ${left} s.`, retryAfter: left });
|
||||
if (!pinMatches(String(body.current ?? ''), adminPinHash)) {
|
||||
noteWrongPin(ip);
|
||||
return sendJson(res, 403, { error: 'That is not the current PIN.' });
|
||||
}
|
||||
pinAttempts.delete(ip);
|
||||
}
|
||||
try {
|
||||
const result = setAdminPin(body.pin);
|
||||
// Whoever just set it has proved they are allowed, so hand them the
|
||||
// session rather than bouncing them to the unlock card they just built.
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Set-Cookie': `proddash_admin=${adminToken}; Path=/; SameSite=Lax; HttpOnly`,
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
return res.end(JSON.stringify(result));
|
||||
} catch (err) {
|
||||
return sendJson(res, err.status || 500, { error: err.message || 'Could not save the PIN.' });
|
||||
}
|
||||
}
|
||||
|
||||
if (urlPath === '/api/admin/state' && req.method === 'GET') {
|
||||
if (!isAuthed(req)) {
|
||||
return sendJson(res, 401, { authRequired: true, authed: false });
|
||||
}
|
||||
return sendJson(res, 200, {
|
||||
authRequired: Boolean(ADMIN_PASSCODE),
|
||||
authRequired: adminLocked(),
|
||||
// The env passcode can't be changed from here; the page says so.
|
||||
pinSet: Boolean(adminPinHash),
|
||||
pinFromEnv: Boolean(ADMIN_PASSCODE),
|
||||
authed: true,
|
||||
version: SHELL_VERSION,
|
||||
dataDir: DATA_DIR,
|
||||
@@ -1390,19 +1601,76 @@ function layoutPath(name) {
|
||||
return path.join(LAYOUTS_DIR, name + '.json');
|
||||
}
|
||||
|
||||
/** Keep only the fields a layout is made of; everything else is dropped. */
|
||||
/** The copy a save replaced — one back, kept until the next save replaces it.
|
||||
Names cannot contain a dot (layoutNameOk), so this can never be a real
|
||||
layout's own file. */
|
||||
function previousLayoutPath(name) {
|
||||
return path.join(LAYOUTS_DIR, name + '.previous.json');
|
||||
}
|
||||
|
||||
/* A saved layout is `{ v, autoHideHeads, tiles }`. The fields the shell knows
|
||||
are checked for shape; anything else a tile carries rides along untouched.
|
||||
That second part is the lesson of a bug: this function used to rebuild each
|
||||
tile from the fields it knew and drop the rest, and for seventeen days it
|
||||
dropped `variant` — every solo timer saved to a server came back as "All
|
||||
timers". The server is the part of ProdDash that lags (a booth updates
|
||||
weeks after the dev machine), so it must never be the one deciding which
|
||||
tile fields exist. Retired fields are the one exception: they are dropped
|
||||
so an old file does not haunt a new client. */
|
||||
|
||||
const LAYOUT_VERSION = 2;
|
||||
const GRID_STEPS = [8, 10, 12, 16, 20, 24]; // the shell's grid densities
|
||||
const MAX_LAYOUT_TILES = 100;
|
||||
const KNOWN_TILE_FIELDS = new Set(['id', 'module', 'x', 'y', 'w', 'h', 'settings', 'variant', 'title', 'headPinned', 'minSize']);
|
||||
const RETIRED_TILE_FIELDS = new Set(['headHidden']);
|
||||
|
||||
/** An unknown tile field is kept when it is a sane key holding small, plain JSON. */
|
||||
function keepUnknownTileField(key, value) {
|
||||
if (!/^[A-Za-z][A-Za-z0-9_]{0,39}$/.test(key)) return false;
|
||||
switch (typeof value) {
|
||||
case 'string': return value.length <= 2000;
|
||||
case 'number': return Number.isFinite(value);
|
||||
case 'boolean': return true;
|
||||
case 'object':
|
||||
if (value === null) return false;
|
||||
try { return JSON.stringify(value).length <= 20000; } catch { return false; }
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeLayout(raw) {
|
||||
const tiles = Array.isArray(raw?.tiles) ? raw.tiles.slice(0, 100) : [];
|
||||
const tiles = Array.isArray(raw?.tiles) ? raw.tiles : [];
|
||||
return {
|
||||
tiles: tiles.map((t) => ({
|
||||
id: String(t?.id || ''),
|
||||
module: String(t?.module || ''),
|
||||
x: Number(t?.x) || 0,
|
||||
y: Number(t?.y) || 0,
|
||||
w: Number(t?.w) || 1,
|
||||
h: Number(t?.h) || 1,
|
||||
settings: t?.settings && typeof t.settings === 'object' ? t.settings : {},
|
||||
})),
|
||||
v: LAYOUT_VERSION,
|
||||
// Whether tile title bars auto-hide is part of how a dashboard is
|
||||
// arranged, so it travels with the layout like the tiles themselves.
|
||||
autoHideHeads: Boolean(raw?.autoHideHeads),
|
||||
// The grid density the layout was arranged on, when it says and it is one
|
||||
// the shell knows; a browser loading it cuts its board the same way.
|
||||
...(GRID_STEPS.includes(Number(raw?.gridCols)) ? { gridCols: Number(raw.gridCols) } : {}),
|
||||
tiles: tiles.map((t) => {
|
||||
const tile = {
|
||||
id: String(t?.id || ''),
|
||||
module: String(t?.module || ''),
|
||||
x: Number(t?.x) || 0,
|
||||
y: Number(t?.y) || 0,
|
||||
w: Number(t?.w) || 1,
|
||||
h: Number(t?.h) || 1,
|
||||
settings: t?.settings && typeof t.settings === 'object' ? t.settings : {},
|
||||
// Which picker entry the tile came from, and what it was called.
|
||||
variant: String(t?.variant || ''),
|
||||
title: String(t?.title || ''),
|
||||
headPinned: Boolean(t?.headPinned),
|
||||
};
|
||||
if (Number(t?.minSize?.w) > 0 && Number(t?.minSize?.h) > 0) {
|
||||
tile.minSize = { w: Number(t.minSize.w), h: Number(t.minSize.h) };
|
||||
}
|
||||
for (const [key, value] of Object.entries(t && typeof t === 'object' ? t : {})) {
|
||||
if (KNOWN_TILE_FIELDS.has(key) || RETIRED_TILE_FIELDS.has(key)) continue;
|
||||
if (keepUnknownTileField(key, value)) tile[key] = value;
|
||||
}
|
||||
return tile;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1414,17 +1682,23 @@ async function handleLayoutsApi(req, res, urlPath) {
|
||||
} catch { /* no layouts dir yet */ }
|
||||
const layouts = [];
|
||||
for (const file of files.sort()) {
|
||||
if (file.endsWith('.previous.json')) continue; // a save's one-back copy, listed on its layout
|
||||
const name = file.slice(0, -5);
|
||||
const data = readJson(path.join(LAYOUTS_DIR, file), null);
|
||||
if (!data) continue;
|
||||
let updated = null;
|
||||
try { updated = fs.statSync(path.join(LAYOUTS_DIR, file)).mtime.toISOString(); } catch { /* fine */ }
|
||||
layouts.push({ name, tiles: Array.isArray(data.tiles) ? data.tiles.length : 0, updated });
|
||||
layouts.push({
|
||||
name,
|
||||
tiles: Array.isArray(data.tiles) ? data.tiles.length : 0,
|
||||
updated,
|
||||
hasPrevious: fs.existsSync(previousLayoutPath(name)),
|
||||
});
|
||||
}
|
||||
return sendJson(res, 200, { layouts });
|
||||
}
|
||||
|
||||
const m = urlPath.match(/^\/api\/layouts\/([^/]+)(\/rename)?$/);
|
||||
const m = urlPath.match(/^\/api\/layouts\/([^/]+)(\/rename|\/restore)?$/);
|
||||
if (!m) return sendJson(res, 404, { error: 'Unknown endpoint.' });
|
||||
let name;
|
||||
try {
|
||||
@@ -1457,25 +1731,34 @@ async function handleLayoutsApi(req, res, urlPath) {
|
||||
}
|
||||
const layout = sanitizeLayout(body.layout);
|
||||
if (!layout.tiles.length) return sendJson(res, 400, { error: 'Refusing to save an empty layout.' });
|
||||
if (layout.tiles.length > MAX_LAYOUT_TILES) {
|
||||
// Saying no beats quietly saving the first hundred and losing the rest.
|
||||
return sendJson(res, 400, { error: `A layout can hold ${MAX_LAYOUT_TILES} tiles; this one has ${layout.tiles.length}.` });
|
||||
}
|
||||
const replaced = fs.existsSync(layoutPath(name));
|
||||
try {
|
||||
// The copy being replaced is kept one back, so a save over the wrong
|
||||
// name is a restore away rather than gone.
|
||||
if (replaced) fs.copyFileSync(layoutPath(name), previousLayoutPath(name));
|
||||
writeJson(layoutPath(name), layout);
|
||||
} catch (err) {
|
||||
return sendJson(res, 500, { error: `Could not save ${layoutPath(name)}: ${err?.message || err}` });
|
||||
}
|
||||
return sendJson(res, 200, { ok: true, name });
|
||||
return sendJson(res, 200, { ok: true, name, replaced });
|
||||
}
|
||||
|
||||
if (!m[2] && req.method === 'DELETE') {
|
||||
if (!isAuthed(req)) return sendJson(res, 401, { error: 'Enter the admin passcode first.', authRequired: true });
|
||||
if (!isAuthed(req)) return sendJson(res, 401, { error: 'Enter the admin PIN first.', authRequired: true });
|
||||
try {
|
||||
fs.unlinkSync(layoutPath(name));
|
||||
} catch {
|
||||
return sendJson(res, 404, { error: `No layout named "${name}".` });
|
||||
}
|
||||
fs.rmSync(previousLayoutPath(name), { force: true });
|
||||
return sendJson(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
if (m[2] && req.method === 'POST') {
|
||||
if (m[2] === '/rename' && req.method === 'POST') {
|
||||
if (refuseAdminWrite(req, res)) return;
|
||||
let body = {};
|
||||
try {
|
||||
@@ -1499,6 +1782,25 @@ async function handleLayoutsApi(req, res, urlPath) {
|
||||
return sendJson(res, 200, { ok: true, name: next });
|
||||
}
|
||||
|
||||
// Put back the copy the last save replaced. The two swap, so a restore is
|
||||
// itself one restore away from being undone.
|
||||
if (m[2] === '/restore' && req.method === 'POST') {
|
||||
if (refuseAdminWrite(req, res)) return;
|
||||
const current = layoutPath(name);
|
||||
const previous = previousLayoutPath(name);
|
||||
if (!fs.existsSync(previous)) return sendJson(res, 404, { error: `"${name}" has no previous version to go back to.` });
|
||||
try {
|
||||
const swapTmp = current + '.swap';
|
||||
const hadCurrent = fs.existsSync(current);
|
||||
if (hadCurrent) fs.renameSync(current, swapTmp);
|
||||
fs.renameSync(previous, current);
|
||||
if (hadCurrent) fs.renameSync(swapTmp, previous);
|
||||
} catch (err) {
|
||||
return sendJson(res, 500, { error: 'Restore failed: ' + (err?.message || err) });
|
||||
}
|
||||
return sendJson(res, 200, { ok: true, name });
|
||||
}
|
||||
|
||||
sendJson(res, 405, { error: 'Method not allowed.' });
|
||||
}
|
||||
|
||||
@@ -1751,6 +2053,22 @@ server.on('error', (err) => {
|
||||
|
||||
server.listen(PORT, '0.0.0.0');
|
||||
|
||||
/* An update proves itself by staying up. Once this version has, the copy it
|
||||
replaced is dropped — so `app.previous` existing always means "the last
|
||||
update has not proven itself yet", which is exactly what the launcher
|
||||
should roll back to and nothing more. */
|
||||
const UPDATE_PROVEN_AFTER_MS = 90000;
|
||||
setTimeout(() => {
|
||||
const previous = previousCopyPath();
|
||||
if (!fs.existsSync(previous)) return;
|
||||
try {
|
||||
fs.rmSync(previous, { recursive: true, force: true });
|
||||
console.log(`[proddash] ${SHELL_VERSION} has stayed up — dropped the copy it replaced`);
|
||||
} catch (err) {
|
||||
console.warn('[proddash] could not drop the previous copy:', err?.message || err);
|
||||
}
|
||||
}, UPDATE_PROVEN_AFTER_MS).unref();
|
||||
|
||||
// Update check: shortly after boot, then every few hours (the admin page
|
||||
// shows the result; nothing is installed without someone clicking).
|
||||
if (!process.env.PRODDASH_NO_REMOTE_CHECK) {
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Round-trips a layout through the server's layouts API and checks that
|
||||
* nothing is lost on the way: a fully-populated tile, a tile field the server
|
||||
* has never heard of, the one-back copy a save leaves behind, restore, the
|
||||
* tile cap and delete.
|
||||
*
|
||||
* node tools/layout-roundtrip.test.js
|
||||
*
|
||||
* Plain Node, no dependencies. Starts server.js on a spare port with a
|
||||
* temporary data directory, talks to it over HTTP, exits non-zero on the
|
||||
* first failure and always stops the server it started.
|
||||
*
|
||||
* It exists because of one bug: for seventeen days the server rebuilt each
|
||||
* saved tile from the fields it knew and dropped the rest — including
|
||||
* `variant`, so every solo timer saved to a booth came back as "All timers".
|
||||
* A field added on the client must survive a server that has not heard of
|
||||
* it, and this is where that is checked.
|
||||
*/
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawn } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const net = require('node:net');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
|
||||
function freePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const probe = net.createServer();
|
||||
probe.listen(0, '127.0.0.1', () => {
|
||||
const { port } = probe.address();
|
||||
probe.close(() => resolve(port));
|
||||
});
|
||||
probe.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitFor(url, ms = 15000) {
|
||||
const deadline = Date.now() + ms;
|
||||
let last = '';
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const r = await fetch(url);
|
||||
if (r.ok) return;
|
||||
last = 'HTTP ' + r.status;
|
||||
} catch (err) {
|
||||
last = err.message;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
throw new Error('server did not come up: ' + last);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'proddash-layout-test-'));
|
||||
const port = await freePort();
|
||||
const child = spawn(process.execPath, ['server.js'], {
|
||||
cwd: ROOT,
|
||||
env: { ...process.env, PRODDASH_DATA_DIR: dataDir, PORT: String(port), PRODDASH_NO_REMOTE_CHECK: '1', PRODDASH_LAUNCHER: '1' },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let log = '';
|
||||
child.stdout.on('data', (d) => { log += d; });
|
||||
child.stderr.on('data', (d) => { log += d; });
|
||||
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
const api = async (p, init = {}) => {
|
||||
const r = await fetch(base + p, { headers: { 'Content-Type': 'application/json' }, ...init });
|
||||
return { status: r.status, body: await r.json().catch(() => ({})) };
|
||||
};
|
||||
const layoutsDir = path.join(dataDir, 'layouts');
|
||||
let failed = false;
|
||||
|
||||
try {
|
||||
await waitFor(base + '/api/theme');
|
||||
|
||||
// Every field the shell writes today, one it will write tomorrow, and one
|
||||
// it has stopped writing.
|
||||
const tile = {
|
||||
id: 't1', module: 'propresenter-timers', x: 4, y: 2, w: 3, h: 2,
|
||||
settings: { showNames: false, __tint: '#112233' },
|
||||
variant: 'solo:pco-plan:service', title: 'Service', headPinned: true, minSize: { w: 2, h: 1 },
|
||||
futureField: 'kept', futureFlag: true, futureBox: { a: 1 },
|
||||
headHidden: true,
|
||||
};
|
||||
|
||||
let r = await api('/api/layouts/Booth%20A', { method: 'PUT', body: JSON.stringify({ layout: { autoHideHeads: true, gridCols: 16, tiles: [tile] } }) });
|
||||
assert.equal(r.status, 200, 'save: ' + JSON.stringify(r.body));
|
||||
assert.equal(r.body.replaced, false, 'a first save replaces nothing');
|
||||
|
||||
r = await api('/api/layouts/Booth%20A');
|
||||
assert.equal(r.status, 200);
|
||||
const got = r.body.layout;
|
||||
assert.equal(got.v, 2, 'the file carries a schema version');
|
||||
assert.equal(got.autoHideHeads, true);
|
||||
assert.equal(got.gridCols, 16, 'grid density travels with the layout');
|
||||
const t = got.tiles[0];
|
||||
for (const k of ['id', 'module', 'x', 'y', 'w', 'h', 'variant', 'title', 'headPinned']) assert.deepEqual(t[k], tile[k], k);
|
||||
assert.deepEqual(t.settings, tile.settings, 'settings');
|
||||
assert.deepEqual(t.minSize, tile.minSize, 'minSize');
|
||||
assert.equal(t.futureField, 'kept', 'an unknown string field survives');
|
||||
assert.equal(t.futureFlag, true, 'an unknown boolean survives');
|
||||
assert.deepEqual(t.futureBox, { a: 1 }, 'an unknown object survives');
|
||||
assert.equal('headHidden' in t, false, 'a retired field is dropped');
|
||||
|
||||
// Saving over the name keeps the copy it replaced.
|
||||
r = await api('/api/layouts/Booth%20A', { method: 'PUT', body: JSON.stringify({ layout: { gridCols: 13, tiles: [{ ...tile, title: 'Service v2' }] } }) });
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.replaced, true, 'the second save reports a replacement');
|
||||
assert.ok(fs.existsSync(path.join(layoutsDir, 'Booth A.previous.json')), 'the replaced copy is kept');
|
||||
r = await api('/api/layouts');
|
||||
assert.deepEqual(r.body.layouts.map((l) => [l.name, l.hasPrevious]), [['Booth A', true]],
|
||||
'the listing shows one layout with a previous copy — not two layouts');
|
||||
|
||||
// Restore swaps them, so a second restore undoes the first. Admin is open
|
||||
// here (no PIN), which is what a fresh data directory gives.
|
||||
r = await api('/api/layouts/Booth%20A/restore', { method: 'POST', body: '{}' });
|
||||
assert.equal(r.status, 200, 'restore: ' + JSON.stringify(r.body));
|
||||
r = await api('/api/layouts/Booth%20A');
|
||||
assert.equal(r.body.layout.tiles[0].title, 'Service', 'restore brings the earlier save back');
|
||||
r = await api('/api/layouts/Booth%20A/restore', { method: 'POST', body: '{}' });
|
||||
assert.equal(r.status, 200);
|
||||
r = await api('/api/layouts/Booth%20A');
|
||||
assert.equal(r.body.layout.tiles[0].title, 'Service v2', 'restoring again goes forward again');
|
||||
assert.equal('gridCols' in r.body.layout, false, 'a density the shell does not offer is dropped, not stored');
|
||||
r = await api('/api/layouts/Never%20Saved/restore', { method: 'POST', body: '{}' });
|
||||
assert.equal(r.status, 404, 'nothing to restore is said, not faked');
|
||||
|
||||
// Too many tiles is refused, not trimmed.
|
||||
const many = Array.from({ length: 101 }, (_, i) => ({ ...tile, id: 't' + i }));
|
||||
r = await api('/api/layouts/Too%20Big', { method: 'PUT', body: JSON.stringify({ layout: { tiles: many } }) });
|
||||
assert.equal(r.status, 400);
|
||||
assert.match(String(r.body.error || ''), /100 tiles/, 'the refusal names the cap');
|
||||
assert.equal(fs.existsSync(path.join(layoutsDir, 'Too Big.json')), false, 'nothing was written');
|
||||
|
||||
// Delete takes the previous copy with it.
|
||||
r = await api('/api/layouts/Booth%20A', { method: 'DELETE' });
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(fs.existsSync(path.join(layoutsDir, 'Booth A.json')), false);
|
||||
assert.equal(fs.existsSync(path.join(layoutsDir, 'Booth A.previous.json')), false, 'the previous copy is gone too');
|
||||
r = await api('/api/layouts');
|
||||
assert.deepEqual(r.body.layouts, [], 'nothing left listed');
|
||||
|
||||
console.log('layout round-trip: every check passed');
|
||||
} catch (err) {
|
||||
failed = true;
|
||||
console.error('FAIL:', err.message);
|
||||
console.error('--- last of the server log ---\n' + log.slice(-2000));
|
||||
} finally {
|
||||
child.kill('SIGTERM');
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
process.exit(failed ? 1 : 0);
|
||||
})();
|
||||
@@ -0,0 +1,49 @@
|
||||
// Minimal Planning Center mock for the Band Lineup module: service types, one
|
||||
// plan today, its team_members (with person+team includes), People birthdates,
|
||||
// and avatar bytes. Enough to exercise the whole board build.
|
||||
const http = require('http');
|
||||
const PORT = Number(process.argv[2] || 24710);
|
||||
const JPEG = Buffer.from('/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////wAALCAABAAEBAREA/8QAFAABAAAAAAAAAAAAAAAAAAAAAP/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AfwD/2Q==', 'base64');
|
||||
const TODAY = new Date();
|
||||
const iso = (d) => d.toISOString();
|
||||
// One person's birthday lands in this Wed–Tue week (today is a Wed in the test).
|
||||
const inWeek = new Date(TODAY.getFullYear(), TODAY.getMonth(), TODAY.getDate() + 2);
|
||||
const bdayInWeek = `1994-${String(inWeek.getMonth()+1).padStart(2,'0')}-${String(inWeek.getDate()).padStart(2,'0')}`;
|
||||
|
||||
const PEOPLE = {
|
||||
p1: { name: 'Lemuel Maldonado', pos: 'Vox 1', team: 'Vocals', bd: '1990-03-04' },
|
||||
p2: { name: 'Wes Carter', pos: 'Vox 2', team: 'Vocals', bd: null },
|
||||
p3: { name: 'Jessica Proulx', pos: 'Vox 3', team: 'Vocals', bd: '1992-11-01' },
|
||||
p4: { name: 'Jeremy Cruz', pos: 'Guitar / Lead', team: 'Band', bd: null },
|
||||
p5: { name: 'Alex Gaitan', pos: 'Drums', team: 'Band', bd: bdayInWeek }, // celebrating
|
||||
p6: { name: 'Rosaliz Gomez', pos: 'Piano', team: 'Band', bd: null },
|
||||
p7: { name: 'George Acosta', pos: 'MC', team: 'Production', bd: null },
|
||||
p8: { name: 'Not Shown', pos: 'Camera 2', team: 'Video', bd: null }, // team not allowed → filtered out
|
||||
};
|
||||
const TEAMS = { Vocals: 't1', Band: 't2', Production: 't3', Video: 't4' };
|
||||
|
||||
function send(res, obj) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); }
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const u = new URL(req.url, 'http://x'); const p = u.pathname;
|
||||
if (p.startsWith('/avatar/')) { res.writeHead(200, { 'Content-Type': 'image/jpeg' }); return res.end(JPEG); }
|
||||
if (p === '/services/v2/folders') return send(res, { data: [] });
|
||||
if (p === '/services/v2/service_types') return send(res, { data: [{ id: '1', type: 'ServiceType', attributes: { name: 'Sunday Service' }, relationships: { parent: { data: null } } }] });
|
||||
if (/^\/services\/v2\/service_types\/1\/plans$/.test(p)) {
|
||||
if (u.searchParams.get('filter') === 'past') return send(res, { data: [] });
|
||||
return send(res, { data: [{ id: '100', type: 'Plan', attributes: { sort_date: iso(TODAY), title: 'Taking Thoughts Captive - Part 4', dates: 'September 20, 2026' } }] });
|
||||
}
|
||||
if (/^\/services\/v2\/service_types\/1\/plans\/100\/team_members$/.test(p)) {
|
||||
const data = []; const included = [];
|
||||
for (const [id, v] of Object.entries(PEOPLE)) {
|
||||
data.push({ id: `tm-${id}`, type: 'PlanPerson', attributes: { status: 'C', team_position_name: v.pos }, relationships: { person: { data: { type: 'Person', id } }, team: { data: { type: 'Team', id: TEAMS[v.team] } } } });
|
||||
included.push({ id, type: 'Person', attributes: { full_name: v.name, photo_url: `http://127.0.0.1:${PORT}/avatar/${id}` } });
|
||||
}
|
||||
for (const [name, id] of Object.entries(TEAMS)) included.push({ id, type: 'Team', attributes: { name } });
|
||||
return send(res, { data, included, links: {} });
|
||||
}
|
||||
const person = p.match(/^\/people\/v2\/people\/([^/]+)$/);
|
||||
if (person) { const v = PEOPLE[person[1]]; return send(res, { data: { id: person[1], type: 'Person', attributes: { birthdate: v ? v.bd : null } } }); }
|
||||
res.writeHead(404); res.end('{}');
|
||||
});
|
||||
server.listen(PORT, '127.0.0.1', () => console.log(`[pco-band-mock] on http://127.0.0.1:${PORT}`));
|
||||
Reference in New Issue
Block a user