Compare commits

...
Author SHA1 Message Date
Waters ChurchandClaude Opus 5 af6322d079 Shell 1.5.0: updates can come from your own git server
The forge setting is a new capability, so the version moves — which is
also how a machine already running 1.4.0 is offered the update at all
(an archive install compares versions, having no commit of its own).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 18:24:57 -04:00
Michael McGary 168f717398 Merge pull request #19 from michaelmcgary2008/claude/self-hosted-forge
Updates can come from your own git server
2026-09-16 18:23:38 -04:00
Waters ChurchandClaude Opus 5 67bcd3640a Updates can come from your own git server
ProdDash fetched one tar.gz of the branch from GitHub's codeload host, with
that URL shape hardcoded. A `forge` setting now names where the code comes
from: absent or "github" behaves exactly as before, and a Gitea or Forgejo
base URL such as http://10.3.8.90:3000 fetches from a server of your own.

Gitea spells an archive URL differently — /archive/<branch>.tar.gz rather
than /tar.gz/<branch> — and that is the only real difference. Its tarball
carries the same pax header with the same commit hash, so the archive
parser, update detection and module installs are untouched. The "what
changed" link follows the forge too, using each one's own compare and
commits paths.

Verified against both: the same commit and version come back from GitHub
and from a self-hosted Gitea, and a module installs from each.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 18:23:20 -04:00
Michael McGary 095fe66118 Merge pull request #17 from michaelmcgary2008/claude/no-campus-defaults
No campus's address ships in the repo
2026-09-16 10:38:32 -04:00
Waters ChurchandClaude Opus 5 a3e1282354 No campus's address ships in the repo
prodcom-transcript's endpoint defaulted to 10.3.11.152:24480 — Apollo
Beach's ProdCom. A fresh install anywhere therefore came up reading AB's
comms and reported itself healthy doing it: that is exactly what the new
North Attleboro install did. The default is now blank, so a new install
says "No ProdCom server configured" (in red, as the ProPresenter modules
already do for a missing host) until someone points it at the ProdCom for
that location. Setting a host still connects as before.

The same address is out of SPEC.md's worked example, CLAUDE.md's note
about the reference app, and the prodcom-listener snapshot's config,
README and fallback — that fallback now exits with a line telling the
operator what to set, rather than throwing on an empty URL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 10:37:56 -04:00
Michael McGary 6417c3aa54 Merge pull request #16 from michaelmcgary2008/claude/slack-module
Slack module: channel transcripts, PIN-gated sending as the location
2026-09-15 15:25:24 -04:00
Michael McGary c1d9fa0527 Merge pull request #14 from michaelmcgary2008/claude/proddash-app-tweaks
Shell: quieter menu handle, calmer fullscreen, live tile settings
2026-09-15 15:24:27 -04:00
Waters ChurchandClaude Opus 5 720e5bb590 Shell: every tile can be tinted from its gear
A colour picker at the foot of every gear window washes that tile's
chrome — panel, border and title bar — in a colour of the operator's
choosing, so one screen's tiles can be told apart at a glance. "Clear"
takes it off again. The value saves with the layout under the reserved
key __tint; it is the shell's, not a module's, so picking a colour paints
the tile without remounting the module, and every tile now has a gear
even when its module offers no settings of its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 14:39:35 -04:00
Waters ChurchandClaude Fable 5.1 f29e43373b Timers: cards keep their text when grouped by source
The sizing budget counted rows as if every card sat in one grid unless
headings were showing, so a grouped tile without headings got digits
sized for fewer rows than it had and the lines below were cut off. Rows
now follow the grouping itself; each group's share of the height is the
exact height its rows, gaps and heading take, so every row is the same
height; and after the layout is applied each card's real height is
measured and digits that would still spill are shrunk to fit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 13:25:37 -04:00
Waters ChurchandClaude Fable 5.1 dde9a00ee1 Timers: "Group by source" on the All timers tile, off by default
Off, every timer sits in one grid filled row by row — a wrapping flex row
rather than a CSS grid, so the last row's cards widen to use the whole
width and nothing is left empty; a source in trouble is named in one line
above the cards, and its own cards dim. On, the sections per source come
back, and "Source headings" appears (showWhen) to hide their titles.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 12:56:22 -04:00
Waters ChurchandClaude Fable 5.1 96eff1a5c5 ProdCom 1.3.0: one connection to ProdCom, shared by every dashboard
The module used to proxy each browser's transcript stream to ProdCom one
to one, so N screens meant N upstream connections and N history reads.
It now follows ProdCom once: it keeps the channel list, the groups and
the last 300 entries, and gives every browser a snapshot on connect
(`state`) plus each changed entry as it happens (`entry`), with `clear`
and `status` frames as the link changes. Reconnects back off from 2 s to
15 s, re-read the lists and history, and re-send the snapshot. The tile
no longer loads anything itself; the admin readout says how many channels
and entries the server holds. The /prodcom/* proxy is gone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 12:43:08 -04:00
Waters ChurchandClaude Fable 5.1 ec178e9ccd Guide: streams reach the page over one shared connection
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 11:04:18 -04:00
Waters ChurchandClaude Fable 5.1 680a34ac7a Shell: one stream connection per page
Browsers allow about six HTTP/1.1 connections to a host and an open
EventSource holds one. Sharing a stream per URL was not enough: a
dashboard with six modules still ran out — Safari first — and every later
request (a remounted tile's /state, an avatar) waited in the browser's
queue for ever; a Timers clock tile went blank after any settings change.

The page now holds ONE EventSource, GET /api/stream?s=<keys>, naming the
streams it wants (`slack:/stream`, `shell:` for the shell's own events).
The server relays each module stream into it over loopback — module
routes are untouched and still serve curl — prefixing every event name
with its key, and brackets each stream's life with `<key>|__open` and
`<key>|__down` (re-opened after a pause). sseSubscribe keeps its shape:
subscribers see their events by their own names, a stream joining the
connection is remade with the new list a moment later, and every stream
announces `open` again after it so backfills run as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 11:02:36 -04:00
Waters ChurchandClaude Fable 5.1 818def822d Shell: tooltips at once, one or two words; text-size buttons are drawn − and +
Every header and top-bar button's title becomes a tip the shell shows the
moment the pointer arrives (the browser's own waits a second and looks
different everywhere); it moves from `title` to data-tip so the native one
never appears, and a module that sets `title` again is picked up on the
next hover. The shell's own labels are "Settings" and "Remove"; the top
bar's are "Fullscreen", "Add tile", "Layouts", "Admin"; Slack, PCO Plan,
ProdCom and Timers shorten theirs to match. A− / A+ are a drawn − and +
in Slack, PCO Plan and ProdCom, sized like every other header icon.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 10:49:58 -04:00
Waters ChurchandClaude Fable 5.1 1930fb142d Shell: the Admin button is the cog, icon-only with a tooltip; the + is drawn
Every top-bar button is now an icon with a hover title and no text. The
Admin link uses the same cogwheel as the tile gears; the add button's +
glyph (font-dependent, uneven across browsers) is an SVG plus.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 10:35:29 -04:00
Waters ChurchandClaude Fable 5.1 eb71bd7b52 Slack: no help line under the Channel notices switch
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 10:25:27 -04:00
Waters ChurchandClaude Fable 5.1 e37060afff Shell: the tile close button is a drawn cross
The ✕ glyph came from whatever font the browser chose — smaller in
Safari than in Chromium. An inline SVG at the icon size is the same
everywhere and still takes the button's colour on hover.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 10:24:44 -04:00
Waters ChurchandClaude Fable 5.1 11de9cd150 Slack: an Avatars switch in the transcript's gear
Off, the picture square goes and each row starts at the left edge — the
same behaviour as ProdCom's channel-icon switch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 10:23:02 -04:00
Waters ChurchandClaude Fable 5.1 857a53fec8 Shell: the tile gear is a cogwheel
The settings button's icon is the cogwheel the user supplied, drawn in
the button's colour so it follows hover and the open state.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 10:19:31 -04:00
Waters ChurchandClaude Fable 5.1 fe04627493 PCO Plan: "Starts in" counts days past 24 hours
A service more than a day away reads "2 Days, 05:12:44" (or "1 Day, …")
in the top bar's countdown instead of a growing hour count; under a day
it reads as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 09:58:47 -04:00
Waters ChurchandClaude Fable 5.1 efbcf960dd Timers: the gear chooses what a card shows, not which timers; a 24-hour switch; a date that fits
Which timers appear is the header's Timers ▾ menu's job, so the gear's
source switches (ProPresenter / LTC / module timers, the Clock on/off)
are gone. In their place the Show group offers the card's elements —
Names, Status lines, Detail lines, Seconds, Leading zeros, Source
headings; without seconds a countdown reads in whole minutes, rounded
up. The clock's format select is a 24-hour switch (an older layout's
"12h" choice is honoured); the clock card has no CLOCK label; Short day
and Short month appear once Date is on, and the date fits its card — one
line when it can, else a break after the day and a size that fits the
longer half. Solo tiles get the same element switches.

Shell: the settings window's grab bar is the accent colour and the gear
that opened it wears the same colour while it is open; showWhen works in
a tile's gear as it does on the admin page.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 09:53:41 -04:00
Waters ChurchandClaude Fable 5.1 1db6c2c89f Timers: "to 9:30a" under a countdown-to-time, smaller status lines, titles that fit
A ProPresenter count_down_to_time timer's detail line reads "to 9:30a"
(or "to 21:30" on a 24-hour clock) instead of a sentence. The status and
detail lines under the digits are drawn at 0.8 of the title's size, and
fit() budgets them at that share. A title that would be cut off shrinks
to fit its card (measured off-screen, down to 8 px) instead of ending in
an ellipsis; a changed label re-fits.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 09:05:56 -04:00
Waters ChurchandClaude Fable 5.1 0acaf47ac7 Slack: transcript rows get their own class; Names switch works with the new rows; notices switch renamed
The row shared its class with the Send tile's status line, so every
transcript row was drawn at 0.8em, muted and nowrap; rows are .sl-row now
and read at the tile's text size. The Names switch targeted the old
markup (name line directly in the row) and hid nothing once the rows
gained a body. "System lines" reads "Channel notices", with a help line
naming what those are.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 17:10:36 -04:00
Waters ChurchandClaude Fable 5.1 cc148926be Slack: log the token's scopes, and which optional ones are missing
Slack names a token's scopes in a header on every answer; the module
now logs them once the token is checked, and says plainly when
reactions:write or emoji:read is absent and how to add it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 16:58:35 -04:00
Waters ChurchandClaude Fable 5.1 ca696a3931 Slack: a tighter PIN pad, so a three-row tile keeps its keypad
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 16:50:47 -04:00
Waters ChurchandClaude Fable 5.1 20d4e0f524 Shell: settings window sized to its content, with a slim grab bar
No title and no ✕ — the window sits beside its tile and closes on a click
anywhere else or Escape — and it is as wide as its fields want, so a
couple of switches make a small window. Help lines wrap to the fields'
width instead of widening it.

Guide: one control per action — whatever has a header button is not
offered again in the gear. ProdCom 1.2.2 drops its timestamps and
newest-first gear switches (both are header buttons).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 16:31:02 -04:00
Waters ChurchandClaude Fable 5.1 5dd1b734aa Slack 1.2.0: emoji everywhere, reactions from the transcript, quick emojis, one PIN per page
Emoji: every standard :code: in a message becomes its character (the full
Slack table ships as emoji.json, from iamcal/emoji-data, MIT); the
workspace's custom emoji come from emoji.list and draw as images proxied
through the module; skin tones work on both.

Reactions: messages show their reactions as pills (the location's own in
the accent). A right-click on a message opens a menu — the admin's quick
emojis, then a search over every emoji — and a click on a pill adds or
takes back the location's reaction. It goes through the same gate as a
send (reactions:write on the app).

Rows follow the ProdCom transcript's layout: a square profile image or
bot icon (initials on a hue tint when there is none), the name line with
its time, the text, the reactions. The accent bar on the location's own
messages is gone.

Send message: the admin's quick emojis sit left of Send, one press sends
one. The PIN pad submits by itself once the PIN's digits are in (the
server sends the length, never the PIN) — no ✓ key; in a tile too short
for the keypad the dots and a hint remain and the keyboard still works.
One PIN unlocks every Slack tile on the page (Send message, Quick replies,
reactions) until auto-lock; a remount keeps it, a reload asks again.

The transcript's gear lists its three switches without a group. The mock
serves reactions.add/remove, emoji.list and profile images.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 16:31:02 -04:00
Waters ChurchandClaude Fable 5.1 70134d6dfc Shell: a picker entry can name the older variant ids it stands for
Layouts saved before Timers 1.3.0 hold 'timer:<uuid>' and 'ltc' tiles.
An entry's aliases let those tiles get the entry's slim gear settings
instead of falling back to the module's full schema.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:40:36 -04:00
Waters ChurchandClaude Fable 5.1 3ceac9599b Merge Timers: each picker entry carries the gear settings that fit its tile
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:39:38 -04:00
Waters ChurchandClaude Fable 5.1 a18d3d35ea Shell: wide fields take the whole row inside a column group
A multiselect's switch grid or a template's chips squashed into one
cell when their group also laid its fields out in columns; they now
span the group's full width.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:37:36 -04:00
Waters ChurchandClaude Fable 5.1 f7a2856267 Merge PCO Plan: note categories as switches, top-bar variable chips, no text-size field
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:37:13 -04:00
Waters ChurchandClaude Fable 5.1 b817e230d4 Timers: each picker entry carries the gear settings that fit its tile
An "All timers" tile keeps the full set — sources, headings, status lines,
the clock's format (now behind the Clock group's title-row switch), the
colours. A solo tile's entry carries only what its one card can use: a
ProPresenter or module timer gets Status lines and the four colours; the
LTC timecode Status lines and the three tones it takes (running, no
signal, stopped); the time-of-day clock its format, seconds and date
alone; a clock another module offers its format plus the idle tone.

Every field is still defined once, in module.json; server.js composes the
subsets from it. The client resolves an absent setting from its own
DEFAULTS rather than from whatever schema the tile has, so a slimmer
schema changes nothing about what a tile shows. Entry descriptions are
one-sentence picker tooltips now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:36:41 -04:00
Waters ChurchandClaude Fable 5.1 0c4828eb26 pco-plan: note categories as switches, top-bar variables as chips, no text-size field
Item notes get their own gear group, after Colors, with the existing
showNotes switch as the group's on/off in its title row. Its categories
come from Planning Center (GET /note-categories → the service type's
item_note_categories, by name, cached 10 min, an empty list rather than
an error when unconfigured or unreachable) and render as switches, three
across, all on until someone flips one; the tile reads an unset value as
every category, an array as exactly those, and a layout's old
comma-separated string as that list (blank = all).

The top bar template is a `template` field: one chip per placeholder
({series} {part} {serviceType} {date} {shortDate} {time} {length}
{items}) to drag or click into the text, with the help down to the
joining rules. Rendering is unchanged.

Text size leaves the gear window: the title bar's A− / A+ already size
the text and save it with the tile; the client falls back to 14 px when
nothing is saved.

tools/pco-mock.js serves item_note_categories (the categories its notes
use plus "Stage", which none does yet) so the route can be exercised.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:35:47 -04:00
Waters ChurchandClaude Fable 5.1 444698c907 Guide: text size is a header control, never a gear field
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:28:58 -04:00
Waters ChurchandClaude Fable 5.1 03d13758a9 Slack: text size is set from the tile header, not the gear popover
The tile header's A− / A+ buttons already size the transcript and
persist through saveInstanceSettings; the gear popover no longer
duplicates that as a number field.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:27:49 -04:00
Waters ChurchandClaude Fable 5.1 7a44b25d4b Shell: tile settings in a window beside the tile; schemas per tile kind
The gear popover lived inside its tile, so a tile could be too small for its
own settings, and the settings sat on top of the thing they change. It is now
a small window: placed beside the tile (right when there is room, else left,
else over it), clamped to the viewport, moved by a grab bar, closed by
clicking away, Escape, the ✕ or the gear. Settings still apply as they
change — that is the point of putting the window beside the tile.

With it, four pieces of contract the module work needs:

- A picker entry may carry its own `instanceSchema` / `instanceGroups`:
  tiles added from it get settings that fit that tile, nothing else. A tile
  showing one ProPresenter timer has no use for a clock's format switches.
- `instanceGroups[name].toggle` puts a group's on/off switch in its title row
  and dims the body while it is off — the admin page's convention, in the
  popover.
- `select` and `multiselect` in tile settings may take `optionsRoute`;
  `multiselect` also `columns` and `allByDefault` (nothing saved until
  someone flips a switch, so options that turn up later are on too).
- `template`: a text with placeholders built by dragging or clicking the
  variables a module offers into it.

And the + picker's descriptions are tooltips now — the list stays a list.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:20:38 -04:00
Waters ChurchandClaude Fable 5.1 b9e895d324 Slack module: several channels, send tiles follow the transcript clicked last
One Slack tile per channel instead of one channel per machine. Admin picks
any number of the account's channels (`channels`, a multiselect fed by
/channels; a `channel` string saved by 1.0.0 is read as a one-item list, the
module never rewrites admin config). tiles() offers one transcript entry per
channel — id "transcript:<id>", named "#<channel>" so the shell titles the
tile with it, settings { view, channel } — plus Send message and Quick
replies, each `single: true`. A transcript tile from 1.0.0 (no channel in its
settings) shows the first configured channel.

Server: one poller per channel, each with its own window, thread cache,
membership and error, sharing the token, the name caches and the SSE fan-out.
Slack allows an internal app ~50+ conversations.history calls a minute per
token, so the module keeps itself under 40:

  interval per channel = max(pollSeconds, ceil(channels × 60 / 40)) s
  1 → 2 s, 2 → 3 s, 3 → 5 s, 5 → 8 s

with pollers phase-locked across that interval so they never fire in a
burst (first reads staggered 250 ms apart after boot), one auth.test shared
by all of them, and a 429 backing every poller off together. /state returns
`channels` keyed by id (name, isMember, messages, pollAge, error, status)
plus `channelOrder`; /stream stays one `state` snapshot per change. The
health line lists the channels and names any with a problem:
"Apollo Beach · #production, #ops · every 3 s (rate budget) · sending on".
/send and /quick take `channel`, which must be one of the configured ids
(400 otherwise) — a PIN-unlocked browser chooses among the admin's channels,
never an arbitrary one; a quick reply's own "| #channel" wins over it.

Client: a click anywhere in a transcript's body makes its channel the send
target; the chosen transcript carries a 3px accent stripe along the bottom
of the tile body (the chrome is the shell's), the others hint at it on hover.
The selection is one fact about the whole page, so it lives in a deliberate
module-level store in client.js (an EventTarget plus the id, mirrored in
localStorage under proddash:slack:sendTarget so a reload keeps it) — the
comment there says why this is not the per-instance state the guide forbids.
Send message and Quick replies show "Sending as Apollo Beach → #ops" and
follow the selection live; when the selected channel is no longer configured
or its transcript tile is gone, sends fall back to the first configured
channel, and the headers say so. PIN pad, lockout, unlock tokens, Enter to
send, auto-lock, the transcript rendering and the per-tile settings are as
they were.

The mock gains a quiet #ops trickle so two transcripts visibly move at their
own pace; a post to either channel already lands in that channel's history.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 13:28:19 -04:00
Waters ChurchandClaude Fable 5.1 7d34604f2f Shell: single-instance tile entries, and a multiselect admin field
Two small pieces of contract for modules that offer several tiles from live
data:

- A `tiles()` entry may say `single: true`: at most one such tile per
  dashboard. The + picker greys the entry out once it is placed ("Already on
  this dashboard") and `addTile` refuses a second — for a control surface
  that would only confuse in duplicate, a send box say.
- Admin config gains `multiselect`: several picks from one list, the same
  `options` / `optionsRoute` as `select`, one switch per option, stored as an
  array of values. A saved value the list no longer offers stays checked and
  marked rather than being dropped in silence. The wrapper is a <label>, so
  it is told to label nothing — otherwise a click anywhere would flip the
  first switch.

Both documented in the module guide.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 12:56:10 -04:00
Waters ChurchandClaude Fable 5.1 babe8263eb Slack module: channel transcript + PIN-gated sending as the location
Each campus signs its booth machine into Slack as the account named for
the location, so a message from ProdDash reads as "Apollo Beach" rather
than as whoever is at the desk. modules/slack (1.0.0) is three tiles from
one module: the live transcript of one channel, a free-text Send tile and
a Quick-replies grid — the last two behind a PIN pad, auto-locking after
a configurable idle time.

Server: one user (xoxp-) or bot (xoxb-) token, identified with auth.test;
conversations.history polled every N seconds (thread replies via
conversations.replies, capped per poll), diffed and fanned out as one
`state` SSE snapshot; mrkdwn rendered to safe text on the server; sends
through chat.postMessage (username/icon for a bot with "Send as"); PIN
compared in constant time with a 5-wrong → 30 s lockout, 32-byte unlock
tokens with a sliding expiry, 1 message/s, every send logged; readable
messages for invalid_auth / token_revoked / missing_scope /
not_in_channel / ratelimited; health reflects token → channel → poll →
sending. SLACK_API_BASE points it at tools/slack-mock.js (new).

Shell: the admin config form gains a multi-line field type `text` (a
textarea; one item per line by convention) — used for the quick replies.
Documented in the module guide; the mock is listed in CLAUDE.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 11:49:40 -04:00
33 changed files with 6483 additions and 503 deletions
+2 -1
View File
@@ -37,13 +37,14 @@ title-bar controls). The rules that matter most:
- Verify against the guide's testing section and resilience checklist. Mocks - Verify against the guide's testing section and resilience checklist. Mocks
for the existing upstreams: `node tools/prodcom-mock.js` (ProdCom) and for the existing upstreams: `node tools/prodcom-mock.js` (ProdCom) and
`node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter). `node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter).
`node tools/slack-mock.js 24716` (Slack; run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`).
## Reference code (read-only forks — don't develop these here) ## Reference code (read-only forks — don't develop these here)
- `prodcom-listener/` — live ProdCom transcript web app. Zero-dependency Node - `prodcom-listener/` — live ProdCom transcript web app. Zero-dependency Node
`server.js` that serves `public/` and proxies `/prodcom/*` (including the SSE `server.js` that serves `public/` and proxies `/prodcom/*` (including the SSE
transcript stream) to the ProdCom API at `config.json → prodcomUrl` transcript stream) to the ProdCom API at `config.json → prodcomUrl`
(default `http://10.3.11.152:24480`). Good reference for: SSE handling, (set per site; each campus runs its own). Good reference for: SSE handling,
reconnect logic, channel colors/filtering, PWA install. reconnect logic, channel colors/filtering, PWA install.
- `band-lineup-display/` — backstage Electron app (stage lineup photos with - `band-lineup-display/` — backstage Electron app (stage lineup photos with
mic/pack labels) plus ProPresenter Now/Next: mic/pack labels) plus ProPresenter Now/Next:
+19 -4
View File
@@ -15,9 +15,10 @@ touching the shell (see [docs/MODULE-GUIDE.md](docs/MODULE-GUIDE.md)).
2. Discovers modules in `modules/` and serves each one's client at 2. Discovers modules in `modules/` and serves each one's client at
`/modules/<id>/`. `/modules/<id>/`.
3. Mounts each enabled module's server routes under `/api/modules/<id>/` — 3. Mounts each enabled module's server routes under `/api/modules/<id>/` —
that's where module servers proxy ProdCom, poll ProPresenter, and stream that's where module servers follow ProdCom, poll ProPresenter, and stream
live updates over SSE, so browsers never talk to production gear live updates over SSE — one upstream connection per module, however many
directly (no CORS, and API keys never leave the server). dashboards are open — so browsers never talk to production gear directly
(no CORS, and API keys never leave the server).
4. Stores server-wide module config and named layouts in a per-machine data 4. Stores server-wide module config and named layouts in a per-machine data
directory outside the app folder, so updating ProdDash never loses them. directory outside the app folder, so updating ProdDash never loses them.
@@ -99,10 +100,24 @@ with this machine's shell settings — the same keys as the checked-in
| --- | --- | | --- | --- |
| `port` | Port ProdDash serves on (default `24500`). The macOS launcher writes this one, so the port it is set to holds however ProdDash is started. | | `port` | Port ProdDash serves on (default `24500`). The macOS launcher writes this one, so the port it is set to holds however ProdDash is started. |
| `adminPasscode` | If set, the admin page asks for this shared passcode. Leave `""` for none — it's a plain-HTTP LAN tool. | | `adminPasscode` | If set, the admin page asks for this shared passcode. Leave `""` for none — it's a plain-HTTP LAN tool. |
| `forge` | Where updates and module installs are fetched from. Absent or `"github"` uses GitHub; a Gitea or Forgejo base URL such as `"http://10.3.8.90:3000"` uses a server of your own. Set it per machine in the data directory, alongside `repo`. |
| `theme` | The dashboard palette: `booth` (default), `harbor`, `graphite`, `ember` or `daylight`. The admin page's **Theme** section writes this one — see [Themes](#themes). | | `theme` | The dashboard palette: `booth` (default), `harbor`, `graphite`, `ember` or `daylight`. The admin page's **Theme** section writes this one — see [Themes](#themes). |
**Updating from your own git server.** ProdDash fetches one `tar.gz` of the
branch — nothing else — so any Gitea or Forgejo works. Point `forge` at its
base URL and `repo` at the owner/repo there:
```json
{ "forge": "http://10.3.8.90:3000", "repo": "michael/ProdDash", "branch": "main" }
```
Gitea spells an archive URL differently from GitHub, which is the only thing
that changes; the tarball itself is identical, commit hash included, so update
detection and module installs behave exactly as before. Make the repository
readable without signing in, or ProdDash cannot fetch it.
Environment variables override both files: `PORT`, `PRODDASH_PASSCODE`, and Environment variables override both files: `PORT`, `PRODDASH_PASSCODE`, and
`PRODDASH_DATA_DIR` to put the data directory somewhere else (a relative path `PRODDASH_FORGE`, and `PRODDASH_DATA_DIR` to put the data directory somewhere else (a relative path
is taken from the app folder; `PRODDASH_DATA_DIR=config` restores the old is taken from the app folder; `PRODDASH_DATA_DIR=config` restores the old
in-repo location). If the default directory can't be created, the server says in-repo location). If the default directory can't be created, the server says
so and falls back to the app's `config/` folder. so and falls back to the app's `config/` folder.
+1 -1
View File
@@ -129,7 +129,7 @@ modules/<id>/
"minSize": { "w": 2, "h": 2 }, "minSize": { "w": 2, "h": 2 },
"defaultSize": { "w": 4, "h": 3 }, "defaultSize": { "w": 4, "h": 3 },
"configSchema": { "configSchema": {
"prodcomUrl": { "type": "string", "label": "ProdCom URL", "default": "http://10.3.11.152:24480" }, "prodcomUrl": { "type": "string", "label": "ProdCom URL", "default": "" },
"apiKey": { "type": "password", "label": "API key (if PSK auth enabled)", "default": "" } "apiKey": { "type": "password", "label": "API key (if PSK auth enabled)", "default": "" }
}, },
"instanceSchema": { "instanceSchema": {
+1
View File
@@ -1,6 +1,7 @@
{ {
"port": 24500, "port": 24500,
"adminPasscode": "", "adminPasscode": "",
"forge": "github",
"repo": "michaelmcgary2008/ProdDash", "repo": "michaelmcgary2008/ProdDash",
"branch": "main" "branch": "main"
} }
+79 -19
View File
@@ -104,7 +104,7 @@ the app); its settings are kept for a later reinstall.
Both schemas map field names to specs: Both schemas map field names to specs:
``` ```
{ "type": "string" | "number" | "boolean" | "switch" | "select" | "color" | "password" | "endpoint", { "type": "string" | "text" | "number" | "boolean" | "switch" | "select" | "multiselect" | "template" | "color" | "password" | "endpoint",
"label": "Shown next to the field", "label": "Shown next to the field",
"default": <value>, "default": <value>,
"group": "Section heading", // optional; see below "group": "Section heading", // optional; see below
@@ -125,6 +125,24 @@ shown or enabled, off means hidden or disabled, and the label shouldn't say
so twice. so twice.
`color` renders a swatch picker and stores a `#rrggbb` string (give it a hex `color` renders a swatch picker and stores a `#rrggbb` string (give it a hex
default; the tile applies it as a CSS custom property — see `pco-plan`). default; the tile applies it as a CSS custom property — see `pco-plan`).
`multiselect` is several picks from one list — the same `options` /
`optionsRoute` as `select`, rendered as one switch per option and stored as
an array of values. A saved value the list no longer offers stays checked and
marked, so nothing is dropped behind anyone's back. In a tile's gear popover
it also takes `columns` (2–4) and `allByDefault: true`: a tile that has never
chosen shows every option on and saves nothing until someone flips one, so
options that turn up later are on as well (give it no `default` then).
`select` and `multiselect` may use `optionsRoute` in `instanceSchema` too —
the popover GETs `/api/modules/<id><optionsRoute>` like the admin page does.
`template` (tile settings) is a text with placeholders, built by dragging or
clicking the variables the module offers into it: `"variables": [ { "value":
"{series}", "label": "Series" }, … ]`. Keep `help` to the joining rules; the
chips carry the names.
`text` is a multi-line string — a `<textarea>` in the admin page, stored with
its newlines; one item per line is the convention (the `slack` module's quick
replies). Admin config only.
`group` gathers consecutive fields under a labeled subsection in the admin `group` gathers consecutive fields under a labeled subsection in the admin
page, so a module with several distinct concerns (e.g. a ProPresenter page, so a module with several distinct concerns (e.g. a ProPresenter
@@ -163,13 +181,27 @@ explanation there.
`group` and `help` work on `instanceSchema` fields too, and a manifest `group` and `help` work on `instanceSchema` fields too, and a manifest
`instanceGroups` map plays the `configGroups` role for the tile's gear `instanceGroups` map plays the `configGroups` role for the tile's gear
popover (`collapsed: true | false`, `columns`, `help`). That is how a module popover (`collapsed: true | false`, `columns`, `help`, `toggle`). That is how
with a long show/hide checklist keeps the popover usable — see `pco-plan`. a module with a long show/hide checklist keeps the popover usable — see
Display choices belong there, per tile; admin config is for connections. `pco-plan`. Display choices belong there, per tile; admin config is for
connections. The popover itself is a small window beside the tile — sized to
its content, moved by the slim bar on top, closed by clicking anywhere else
or pressing Escape — so a tile never has to be big enough to hold its own
settings, and a change can be watched as it lands. Under everything you
offer, the shell adds **Tile tint** — a colour the operator can wash that
tile's chrome in, saved with the layout. It is not yours to declare and it
never remounts you; don't offer one of your own.
A picker entry (see **Presenting multiple tiles**) may carry its own
`instanceSchema` and `instanceGroups`: tiles added from that entry get those
instead of the module's. A tile that shows one ProPresenter timer has no use
for a clock's format switches — give it a schema of what applies.
`showWhen: "<key>"` hides a field until the boolean/`switch` field named `showWhen: "<key>"` hides a field until the boolean/`switch` field named
`<key>` is on — e.g. the audio device and channel appear only once the LTC `<key>` is on — e.g. the audio device and channel appear only once the LTC
listener switch is enabled. The hidden field still keeps its stored value. listener switch is enabled. The hidden field still keeps its stored value.
It works the same in a tile's gear (`instanceSchema`): "Short day" and
"Short month" appear once the clock's "Date" is on.
A `select` whose choices are only knowable at runtime (audio devices, serial A `select` whose choices are only knowable at runtime (audio devices, serial
ports, discovered sources) sets `optionsRoute` instead of — or as a fallback ports, discovered sources) sets `optionsRoute` instead of — or as a fallback
@@ -286,7 +318,7 @@ export default function create({ root, moduleApi }) {
| `instanceId` | Unique per tile — two tiles of the same module get different ids. Handy for keying anything per-tile. | | `instanceId` | Unique per tile — two tiles of the same module get different ids. Handy for keying anything per-tile. |
| `variant` | For multi-tile modules: the id of the tile-list entry this tile was added as, `''` otherwise (see **Presenting multiple tiles**). | | `variant` | For multi-tile modules: the id of the tile-list entry this tile was added as, `''` otherwise (see **Presenting multiple tiles**). |
| `config` | Admin (server-wide) config, read-only, always current. Password fields are absent — they never reach the client. | | `config` | Admin (server-wide) config, read-only, always current. Password fields are absent — they never reach the client. |
| `instanceSettings` | This tile's settings: your `instanceSchema` defaults overlaid with whatever this tile has saved. Read it fresh whenever you render — don't cache it. | | `instanceSettings` | This tile's settings: your `instanceSchema` defaults overlaid with whatever this tile has saved. Read it fresh whenever you render — don't cache it. Keys beginning `__` are the shell's own (`__tint`, the tile's colour) — never use that prefix, and ignore the ones you find. |
| `saveInstanceSettings(patch)` | Merge `patch` into this tile's settings and persist them with the layout. No restart happens — you made the change, you already know. Use it for state the user sets *inside* your tile (a toggled filter, a chosen tab). | | `saveInstanceSettings(patch)` | Merge `patch` into this tile's settings and persist them with the layout. No restart happens — you made the change, you already know. Use it for state the user sets *inside* your tile (a toggled filter, a chosen tab). |
| `fetch(path, opts)` | `fetch` scoped to your server routes: `fetch('/state')` hits `/api/modules/<id>/state`. | | `fetch(path, opts)` | `fetch` scoped to your server routes: `fetch('/state')` hits `/api/modules/<id>/state`. |
| `sse(path, handlers)` | An `EventSource` scoped the same way, **with auto-reconnect** (see below). Returns a handle with `close()`. | | `sse(path, handlers)` | An `EventSource` scoped the same way, **with auto-reconnect** (see below). Returns a handle with `close()`. |
@@ -301,16 +333,17 @@ bar, next to the shell's gear/close buttons:
```js ```js
const btn = moduleApi.header.addButton({ const btn = moduleApi.header.addButton({
icon: '<svg …>…</svg>', // inline SVG string, and/or icon: '<svg …>…</svg>', // inline SVG string (13 px), and/or
label: 'A+', // short text label: 'Timers ▾', // short text
title: 'Larger text', // hover tooltip title: 'Larger text', // tooltip — one or two words; the shell shows
// it the moment the pointer arrives
onClick() { … }, onClick() { … },
}); });
btn.classList.add('active'); // toggle styling for on/off buttons btn.classList.add('active'); // toggle styling for on/off buttons
const menu = moduleApi.header.addMenu({ const menu = moduleApi.header.addMenu({
label: 'Channels ▾', label: 'Channels ▾',
title: 'Choose visible channels', title: 'Channels',
build(menuEl) { build(menuEl) {
// called on every open with an emptied menu element — fill it with // called on every open with an emptied menu element — fill it with
// buttons (give them your scoped classes; the container is styled and // buttons (give them your scoped classes; the container is styled and
@@ -322,7 +355,17 @@ menu.setLabel('Channels (2/4) ▾'); // update the button text any time
Everything you add is removed automatically when your instance stops, so a Everything you add is removed automatically when your instance stops, so a
remount never duplicates controls. Keep them compact (icon buttons, short remount never duplicates controls. Keep them compact (icon buttons, short
labels): the title bar is 32 px tall and shared with the tile's name. labels): the title bar is 32 px tall and shared with the tile's name. Draw
icons as inline SVG rather than characters — a glyph such as ✕ or + takes
its size from whatever font each browser picks; text-size buttons are a
drawn − and +.
**One control per action.** Whatever has a dedicated header button is not
offered again in the gear popover: text size is A− / A+ up here (persisted
through `saveInstanceSettings({ textSize })`, read back with a sane
fallback), never a "Text size" field in `instanceSchema`; a timestamps or
newest-first toggle in the header means no such switch in the gear. The
gear is for display choices that have no button of their own.
One more thing to know: the user can hide the whole title bar with the One more thing to know: the user can hide the whole title bar with the
small notch on its bottom edge (a clean-view mode for wall displays). Your small notch on its bottom edge (a clean-view mode for wall displays). Your
@@ -341,14 +384,20 @@ const stream = moduleApi.sse('/stream', {
// later: stream.close() // later: stream.close()
``` ```
Browsers' `EventSource` retries transient drops itself but gives up for good Your route serves a plain SSE stream; how it reaches the page is the
when a retry gets a completed non-SSE response — which is what a proxy's 502 shell's business. A dashboard holds **one** connection for every stream on
looks like while the upstream is down. The shell's `sse()` recreates closed it (`GET /api/stream`, which the server feeds from your route over
streams on a 3-second timer until they work again, so **reconnect logic is loopback) — browsers allow about six connections to a host and a tile per
free**: handle `open` (refresh/backfill your state; you may have missed stream ran out of them — so `open` also fires whenever that shared
events) and `error` (report degraded status), and you're resilient. Streams connection is remade (a tile was added or removed) and whenever your route
you forget to close are force-closed when the tile unmounts — but close your comes back after a drop. Browsers' `EventSource` retries transient drops
own in `stop()` anyway. itself but gives up for good when a retry gets a completed non-SSE
response — which is what a proxy's 502 looks like while the upstream is
down; the shell recreates a closed connection on a 3-second timer until it
works again, so **reconnect logic is free**: handle `open` (refresh/backfill
your state; you may have missed events) and `error` (report degraded
status), and you're resilient. Streams you forget to close are force-closed
when the tile unmounts — but close your own in `stop()` anyway.
### Lifecycle you must expect ### Lifecycle you must expect
@@ -469,7 +518,7 @@ module.exports = {
return config.pages.map((page) => ({ return config.pages.map((page) => ({
id: page.name, // stable id, stored with the tile id: page.name, // stable id, stored with the tile
name: page.name, // picker label AND the tile's title name: page.name, // picker label AND the tile's title
description: page.url, // optional line under the label description: page.url, // optional; the picker's tooltip
settings: { page: page.name }, // preset instance settings settings: { page: page.name }, // preset instance settings
defaultSize: { w: 4, h: 3 }, // optional per-entry size overrides defaultSize: { w: 4, h: 3 }, // optional per-entry size overrides
minSize: { w: 2, h: 2 }, minSize: { w: 2, h: 2 },
@@ -482,6 +531,17 @@ module.exports = {
keep it fast (answer from state you already hold; don't fetch upstream on keep it fast (answer from state you already hold; don't fetch upstream on
demand). A throw or a hang (>2 s) falls back to the classic single entry, demand). A throw or a hang (>2 s) falls back to the classic single entry,
so a dead upstream can't break the picker. so a dead upstream can't break the picker.
- `instanceSchema` / `instanceGroups` on an entry replace the module's for
tiles added from it — settings that fit that tile, nothing else.
- `aliases: ['old-id', …]` on an entry names earlier variant ids it stands
for: a tile added under one of them (a layout saved by an older version of
your module) gets this entry's settings too. A tile whose variant matches
no entry and no alias falls back to the module's full `instanceSchema`.
- Entry `description`s are tooltips in the + picker now, not visible lines —
one short sentence.
- `single: true` on an entry allows at most one such tile per dashboard: the
picker greys the entry out once it is placed. Use it for a control surface
that would only confuse in duplicate — a send box, a master switch.
- When the user adds an entry, its `settings` overlay your `instanceSchema` - When the user adds an entry, its `settings` overlay your `instanceSchema`
defaults in that tile's instance settings, the entry's `name` becomes the defaults in that tile's instance settings, the entry's `name` becomes the
tile's title, and `moduleApi.variant` carries its `id`. tile's title, and `moduleApi.variant` carries its `id`.
+33 -8
View File
@@ -4,10 +4,16 @@
ProPresenter module's live item; this tile only renders the plan it is ProPresenter module's live item; this tile only renders the plan it is
streamed. What is shown is this tile's own choice (gear menu → streamed. What is shown is this tile's own choice (gear menu →
moduleApi.instanceSettings: show/hide checklist, top-bar template, moduleApi.instanceSettings: show/hide checklist, top-bar template,
colors, text size); /admin only holds the connection settings. */ colors, item-note categories); the text size is the title bar's A− / A+
(saved with the tile, 14 px until someone presses one); /admin only holds
the connection settings. */
const REFRESH_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>'; const REFRESH_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>';
/* the text-size buttons: a drawn − and + at the icon size, like every other header icon */
const MINUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14"/></svg>';
const PLUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>';
export default function create({ root, moduleApi }) { export default function create({ root, moduleApi }) {
let state = null; let state = null;
let skew = 0; // server clock − local clock, ms let skew = 0; // server clock − local clock, ms
@@ -53,7 +59,7 @@ export default function create({ root, moduleApi }) {
const refreshBtn = moduleApi.header.addButton({ const refreshBtn = moduleApi.header.addButton({
icon: REFRESH_SVG, icon: REFRESH_SVG,
title: 'Re-read the plan from Planning Center now', title: 'Refresh',
onClick() { onClick() {
refreshBtn.classList.add('active'); refreshBtn.classList.add('active');
moduleApi.fetch('/refresh', { method: 'POST' }) moduleApi.fetch('/refresh', { method: 'POST' })
@@ -67,8 +73,8 @@ export default function create({ root, moduleApi }) {
moduleApi.saveInstanceSettings({ textSize: Math.min(40, Math.max(9, size + delta)) }); moduleApi.saveInstanceSettings({ textSize: Math.min(40, Math.max(9, size + delta)) });
applyPrefs(); applyPrefs();
} }
moduleApi.header.addButton({ label: 'A−', title: 'Smaller text', onClick: () => bumpTextSize(-1) }); moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-1) });
moduleApi.header.addButton({ label: 'A+', title: 'Larger text', onClick: () => bumpTextSize(1) }); moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(1) });
/* ── formatting ─────────────────────────────────────────────────── */ /* ── formatting ─────────────────────────────────────────────────── */
@@ -87,6 +93,16 @@ export default function create({ root, moduleApi }) {
return `${sign}${m}:${String(sec).padStart(2, '0')}`; return `${sign}${m}:${String(sec).padStart(2, '0')}`;
} }
/** "Starts in": under a day like fmtDur; a day or more reads "2 Days, 05:12:44". */
function fmtStartsIn(totalSec) {
const s = Math.max(0, Math.round(totalSec));
const days = Math.floor(s / 86400);
if (!days) return fmtDur(s);
const rest = s % 86400;
const pad = (v) => String(v).padStart(2, '0');
return `${days} ${days === 1 ? 'Day' : 'Days'}, ${pad(Math.floor(rest / 3600))}:${pad(Math.floor((rest % 3600) / 60))}:${pad(rest % 60)}`;
}
function fmtMinutes(totalSec) { function fmtMinutes(totalSec) {
const m = Math.round(totalSec / 60); const m = Math.round(totalSec / 60);
if (m < 60) return `${m} min`; if (m < 60) return `${m} min`;
@@ -121,10 +137,19 @@ export default function create({ root, moduleApi }) {
}); });
} }
/**
* Which note categories this tile shows. Unset (never chosen in the gear
* window) means every category, including ones Planning Center gains
* later; an array is exactly those, by name. A layout saved before the
* categories were a list holds a comma-separated string — read as that
* list, blank as unset.
*/
function noteFilter() { function noteFilter() {
const raw = String(cfg().noteCategories || ''); const raw = cfg().noteCategories;
const wanted = raw.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean); const names = Array.isArray(raw) ? raw : String(raw ?? '').split(',');
return wanted.length ? (note) => wanted.includes(String(note.category || '').toLowerCase()) : () => true; const wanted = new Set(names.map((s) => String(s).trim().toLowerCase()).filter(Boolean));
if (!Array.isArray(raw) && !wanted.size) return () => true;
return (note) => wanted.has(String(note.category || '').trim().toLowerCase());
} }
/* ── status dot ─────────────────────────────────────────────────── */ /* ── status dot ─────────────────────────────────────────────────── */
@@ -250,7 +275,7 @@ export default function create({ root, moduleApi }) {
const startsAt = svc.startsAt || plan.serviceStartsAt || 0; const startsAt = svc.startsAt || plan.serviceStartsAt || 0;
if (on('showCountdown') && startsAt > now) { if (on('showCountdown') && startsAt > now) {
clockLabelEl.textContent = 'Starts in'; clockLabelEl.textContent = 'Starts in';
clockValueEl.textContent = fmtDur((startsAt - now) / 1000); clockValueEl.textContent = fmtStartsIn((startsAt - now) / 1000);
return; return;
} }
if (on('showRunningClock') && startsAt) { if (on('showRunningClock') && startsAt) {
+24 -9
View File
@@ -121,6 +121,9 @@
"collapsed": false, "collapsed": false,
"columns": 2 "columns": 2
}, },
"Item notes": {
"toggle": "showNotes"
},
"Show / hide": { "Show / hide": {
"collapsed": true, "collapsed": true,
"columns": 3, "columns": 3,
@@ -128,25 +131,38 @@
} }
}, },
"instanceSchema": { "instanceSchema": {
"textSize": { "type": "number", "label": "Text size (px)", "default": 14 },
"autoScroll": { "type": "boolean", "label": "Keep the current item in view", "default": true }, "autoScroll": { "type": "boolean", "label": "Keep the current item in view", "default": true },
"noteCategories": {
"type": "string",
"label": "Item note categories to show (comma-separated, blank = all)",
"default": ""
},
"headerTemplate": { "headerTemplate": {
"type": "string", "type": "template",
"label": "Top bar text", "label": "Top bar text",
"default": "{series} • {part} | {serviceType} • {date}", "default": "{series} • {part} | {serviceType} • {date}",
"group": "Top bar", "group": "Top bar",
"help": "Placeholders: {series} series name · {part} plan title / part number · {serviceType} · {date} · {shortDate} · {time} service start time · {length} planned length · {items} item count. Use • between items (an item whose placeholders are empty or hidden is dropped) and | to start a second line." "variables": [
{ "value": "{series}", "label": "Series" },
{ "value": "{part}", "label": "Part / plan title" },
{ "value": "{serviceType}", "label": "Service type" },
{ "value": "{date}", "label": "Date" },
{ "value": "{shortDate}", "label": "Short date" },
{ "value": "{time}", "label": "Service time" },
{ "value": "{length}", "label": "Planned length" },
{ "value": "{items}", "label": "Item count" }
],
"help": "Use • between items and | to start a second line."
}, },
"keyColor": { "type": "color", "label": "Song key", "default": "#4ea1ff", "group": "Colors" }, "keyColor": { "type": "color", "label": "Song key", "default": "#4ea1ff", "group": "Colors" },
"headerColor": { "type": "color", "label": "Section headers", "default": "#2ee59a", "group": "Colors" }, "headerColor": { "type": "color", "label": "Section headers", "default": "#2ee59a", "group": "Colors" },
"currentColor": { "type": "color", "label": "Current item", "default": "#2ee59a", "group": "Colors" }, "currentColor": { "type": "color", "label": "Current item", "default": "#2ee59a", "group": "Colors" },
"headerGradient": { "type": "boolean", "label": "Fade header color to the right", "default": true, "group": "Colors" }, "headerGradient": { "type": "boolean", "label": "Fade header color to the right", "default": true, "group": "Colors" },
"tintByType": { "type": "boolean", "label": "Tint items by type (song / media / other)", "default": false, "group": "Colors" }, "tintByType": { "type": "boolean", "label": "Tint items by type (song / media / other)", "default": false, "group": "Colors" },
"showNotes": { "type": "switch", "label": "Item notes", "default": true, "group": "Item notes" },
"noteCategories": {
"type": "multiselect",
"label": "Categories",
"group": "Item notes",
"optionsRoute": "/note-categories",
"columns": 3,
"allByDefault": true
},
"showTopBar": { "type": "boolean", "label": "Top bar", "default": true, "group": "Show / hide" }, "showTopBar": { "type": "boolean", "label": "Top bar", "default": true, "group": "Show / hide" },
"showSeries": { "type": "boolean", "label": "Series name", "default": true, "group": "Show / hide" }, "showSeries": { "type": "boolean", "label": "Series name", "default": true, "group": "Show / hide" },
"showPart": { "type": "boolean", "label": "Part # / plan title", "default": true, "group": "Show / hide" }, "showPart": { "type": "boolean", "label": "Part # / plan title", "default": true, "group": "Show / hide" },
@@ -165,7 +181,6 @@
"showArrangement": { "type": "boolean", "label": "Song arrangement", "default": false, "group": "Show / hide" }, "showArrangement": { "type": "boolean", "label": "Song arrangement", "default": false, "group": "Show / hide" },
"showSongAuthor": { "type": "boolean", "label": "Song author / CCLI", "default": false, "group": "Show / hide" }, "showSongAuthor": { "type": "boolean", "label": "Song author / CCLI", "default": false, "group": "Show / hide" },
"showDescription": { "type": "boolean", "label": "Item description", "default": true, "group": "Show / hide" }, "showDescription": { "type": "boolean", "label": "Item description", "default": true, "group": "Show / hide" },
"showNotes": { "type": "boolean", "label": "Item notes", "default": true, "group": "Show / hide" },
"showPreService": { "type": "boolean", "label": "Pre-service items", "default": true, "group": "Show / hide" }, "showPreService": { "type": "boolean", "label": "Pre-service items", "default": true, "group": "Show / hide" },
"showPostService": { "type": "boolean", "label": "Post-service items", "default": true, "group": "Show / hide" }, "showPostService": { "type": "boolean", "label": "Post-service items", "default": true, "group": "Show / hide" },
"showNowIndicator": { "type": "boolean", "label": "Now indicator", "default": true, "group": "Show / hide" }, "showNowIndicator": { "type": "boolean", "label": "Now indicator", "default": true, "group": "Show / hide" },
+61
View File
@@ -15,6 +15,8 @@
* GET /plans?serviceTypeId=… → { options } for the admin "Plan" select * GET /plans?serviceTypeId=… → { options } for the admin "Plan" select
* GET /documents → { options } for the admin start / end document selects * GET /documents → { options } for the admin start / end document selects
* (documents ProPresenter has shown or lists — see below) * (documents ProPresenter has shown or lists — see below)
* GET /note-categories → { options } for a tile's "Item notes" categories:
* the service type's item note categories, by name
* GET /timers → snapshot for the Timers module (guide: "Offering * GET /timers → snapshot for the Timers module (guide: "Offering
* GET /timers/stream → SSE, `timers` events timers to the Timers module") * GET /timers/stream → SSE, `timers` events timers to the Timers module")
* *
@@ -26,6 +28,7 @@
* GET /service_types/{st}/plans/{id} (title, dates…) * GET /service_types/{st}/plans/{id} (title, dates…)
* GET …/plans/{id}/plan_times (service start times) * GET …/plans/{id}/plan_times (service start times)
* GET …/plans/{id}/items?include=song,arrangement,item_notes * GET …/plans/{id}/items?include=song,arrangement,item_notes
* GET /service_types/{st}/item_note_categories (note category names)
* *
* Following ProPresenter: instead of talking to ProPresenter a second time, * Following ProPresenter: instead of talking to ProPresenter a second time,
* this module reads the propresenter-now-next module's own SSE feed over * this module reads the propresenter-now-next module's own SSE feed over
@@ -78,6 +81,8 @@ const NOW_NEXT_RETRY_MS = 3000;
const NOW_NEXT_MISSING_RETRY_MS = 15000; const NOW_NEXT_MISSING_RETRY_MS = 15000;
/** How many distinct documents seen live are remembered for the admin selects. */ /** How many distinct documents seen live are remembered for the admin selects. */
const SEEN_DOCUMENTS_MAX = 200; const SEEN_DOCUMENTS_MAX = 200;
/** How long a service type's item note categories are believed before Planning Center is asked again. */
const NOTE_CATEGORIES_TTL_MS = 10 * MINUTE;
/** /**
* Service timer tone (the Timers module colours by it): running behind the * Service timer tone (the Timers module colours by it): running behind the
* plan — completed items over their planned length, plus the current item's * plan — completed items over their planned length, plus the current item's
@@ -591,6 +596,44 @@ module.exports = {
syncServiceSafe(Date.now(), null); syncServiceSafe(Date.now(), null);
} }
/* — item note categories (a tile's "Item notes" → Categories switches) — */
let noteCategories = { at: 0, options: [] }; // the last good answer from Planning Center
let noteCategoriesInFlight = null;
/**
* The service type's item note categories, by name — notes are matched
* to categories by name, so the names are the values. Believed for
* NOTE_CATEGORIES_TTL_MS, read once however many tiles ask at the same
* moment; a failed re-read keeps serving the last good list. A module
* that cannot ask (no credentials, no service type) answers an empty
* list — the gear window then says there is nothing to choose from yet.
*/
async function noteCategoryOptions() {
if (!pco.configured || !serviceTypeId) return [];
if (Date.now() - noteCategories.at < NOTE_CATEGORIES_TTL_MS) return noteCategories.options;
if (!noteCategoriesInFlight) {
noteCategoriesInFlight = (async () => {
const body = await pco.getAll(`${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}/item_note_categories`);
const seen = new Set();
const options = (body.data || [])
.filter((c) => !c?.attributes?.deleted_at)
.map((c) => ({ name: String(c.attributes?.name || '').trim(), sequence: Number(c.attributes?.sequence) || 0 }))
.sort((a, b) => a.sequence - b.sequence)
.filter((c) => c.name && !seen.has(c.name.toLowerCase()) && seen.add(c.name.toLowerCase()))
.map((c) => ({ value: c.name, label: c.name }));
noteCategories = { at: Date.now(), options };
return options;
})().finally(() => { noteCategoriesInFlight = null; });
}
try {
return await noteCategoriesInFlight;
} catch (err) {
log(`item note categories read failed: ${err?.message || err}`);
return noteCategories.options;
}
}
function schedulePoll(ms) { function schedulePoll(ms) {
if (stopped) return; if (stopped) return;
clearTimeout(pollTimer); clearTimeout(pollTimer);
@@ -1214,6 +1257,7 @@ module.exports = {
pickPlan, pickPlan,
timersSnapshot, timersSnapshot,
documentOptions, documentOptions,
noteCategoryOptions,
}; };
return { return {
@@ -1312,6 +1356,23 @@ module.exports = {
json(res, 200, current.documentOptions()); json(res, 200, current.documentOptions());
}, },
/**
* Options for a tile's "Item notes" → Categories switches: the
* configured service type's item note categories, by name. Always a
* 200 with a list — empty while the module is unconfigured, not
* running or cannot reach Planning Center — because the gear window
* has nothing better to do with an error than show an empty list.
*/
'GET /note-categories': async (req, res) => {
let options = [];
try {
if (current) options = await current.noteCategoryOptions();
} catch (err) {
log(`note categories: ${err?.message || err}`);
}
json(res, 200, { options });
},
/** /**
* Options for the admin page's "Service type" select: every service * Options for the admin page's "Service type" select: every service
* type, grouped by its Planning Center folder path — so the folders * type, grouped by its Planning Center folder path — so the folders
+53 -105
View File
@@ -3,8 +3,13 @@
Ported from prodcom-listener/public/app.js into the ProdDash module Ported from prodcom-listener/public/app.js into the ProdDash module
contract: everything lives inside the tile's root, per-tile state contract: everything lives inside the tile's root, per-tile state
(hidden channels, channel icons, timestamps, text size, flow direction) (hidden channels, channel icons, timestamps, text size, flow direction)
persists in instance settings, and all network traffic goes through persists in instance settings, and the data comes from the module's own
moduleApi (the module's server proxy at /prodcom/*). */ server, which keeps one connection to ProdCom for every dashboard: a
snapshot on connect (`state`), then each changed entry (`entry`). */
/* the text-size buttons: a drawn − and + at the icon size, like every other header icon */
const MINUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14"/></svg>';
const PLUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>';
export default function create({ root, moduleApi }) { export default function create({ root, moduleApi }) {
/* ── per-instance state ─────────────────────────────────────────── */ /* ── per-instance state ─────────────────────────────────────────── */
@@ -15,7 +20,6 @@ export default function create({ root, moduleApi }) {
let pinnedToLatest = true; let pinnedToLatest = true;
let hiddenChannels = new Set(moduleApi.instanceSettings.hiddenChannels || []); let hiddenChannels = new Set(moduleApi.instanceSettings.hiddenChannels || []);
let stream = null; // moduleApi.sse handle let stream = null; // moduleApi.sse handle
let bootTimer = null;
let stopped = false; let stopped = false;
/* ── DOM skeleton ───────────────────────────────────────────────── */ /* ── DOM skeleton ───────────────────────────────────────────────── */
@@ -48,7 +52,7 @@ export default function create({ root, moduleApi }) {
let channelMenuEl = null; // the open menu's element (filled on each open) let channelMenuEl = null; // the open menu's element (filled on each open)
const channelsMenu = moduleApi.header.addMenu({ const channelsMenu = moduleApi.header.addMenu({
icon: CHANNELS_SVG, icon: CHANNELS_SVG,
title: 'Choose visible channels', title: 'Channels',
build(menu) { build(menu) {
menu.classList.add('pt-menu'); menu.classList.add('pt-menu');
channelMenuEl = menu; channelMenuEl = menu;
@@ -65,7 +69,7 @@ export default function create({ root, moduleApi }) {
let timeBtn = null; let timeBtn = null;
timeBtn = moduleApi.header.addButton({ timeBtn = moduleApi.header.addButton({
icon: CLOCK_SVG, icon: CLOCK_SVG,
title: 'Show / hide timestamps', title: 'Timestamps',
onClick() { onClick() {
moduleApi.saveInstanceSettings({ showTimes: !moduleApi.instanceSettings.showTimes }); moduleApi.saveInstanceSettings({ showTimes: !moduleApi.instanceSettings.showTimes });
applyInstanceSettings(); applyInstanceSettings();
@@ -77,8 +81,8 @@ export default function create({ root, moduleApi }) {
moduleApi.saveInstanceSettings({ textSize: Math.min(48, Math.max(10, current + delta)) }); moduleApi.saveInstanceSettings({ textSize: Math.min(48, Math.max(10, current + delta)) });
applyInstanceSettings(); applyInstanceSettings();
} }
moduleApi.header.addButton({ label: 'A−', title: 'Smaller text', onClick: () => bumpTextSize(-2) }); moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-2) });
moduleApi.header.addButton({ label: 'A+', title: 'Larger text', onClick: () => bumpTextSize(2) }); moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(2) });
// icon and tooltip track the current direction — applyInstanceSettings sets both // icon and tooltip track the current direction — applyInstanceSettings sets both
const flowBtn = moduleApi.header.addButton({ const flowBtn = moduleApi.header.addButton({
@@ -93,7 +97,7 @@ export default function create({ root, moduleApi }) {
moduleApi.header.addButton({ moduleApi.header.addButton({
icon: JUMP_SVG, icon: JUMP_SVG,
title: 'Jump to latest', title: 'Latest',
onClick() { onClick() {
pinnedToLatest = true; pinnedToLatest = true;
jumpBtn.hidden = true; jumpBtn.hidden = true;
@@ -103,7 +107,7 @@ export default function create({ root, moduleApi }) {
const clearBtn = moduleApi.header.addButton({ const clearBtn = moduleApi.header.addButton({
icon: CLEAR_SVG, icon: CLEAR_SVG,
title: 'Clear this tile (does not affect ProdCom)', title: 'Clear',
onClick: clearView, onClick: clearView,
}); });
clearBtn.classList.add('pt-clear'); clearBtn.classList.add('pt-clear');
@@ -120,9 +124,7 @@ export default function create({ root, moduleApi }) {
wrap.classList.toggle('newest-first', up); wrap.classList.toggle('newest-first', up);
flowBtn.innerHTML = up ? FLOW_UP_SVG : FLOW_DOWN_SVG; flowBtn.innerHTML = up ? FLOW_UP_SVG : FLOW_DOWN_SVG;
flowBtn.classList.toggle('active', up); flowBtn.classList.toggle('active', up);
flowBtn.title = up flowBtn.title = up ? 'Newest first' : 'Oldest first';
? 'Newest at top — click for newest at bottom'
: 'Newest at bottom — click for newest at top';
jumpBtn.textContent = (up ? '↑' : '↓') + ' New messages'; jumpBtn.textContent = (up ? '↑' : '↓') + ' New messages';
} }
applyInstanceSettings(); applyInstanceSettings();
@@ -174,9 +176,7 @@ export default function create({ root, moduleApi }) {
channelCountEl.textContent = filtering ? `${visibleCount}/${channels.size}` : ''; channelCountEl.textContent = filtering ? `${visibleCount}/${channels.size}` : '';
channelCountEl.hidden = !filtering; channelCountEl.hidden = !filtering;
channelsMenu.button.classList.toggle('active', filtering); channelsMenu.button.classList.toggle('active', filtering);
channelsMenu.button.title = filtering channelsMenu.button.title = filtering ? `Channels ${visibleCount}/${channels.size}` : 'Channels';
? `Channels — showing ${visibleCount} of ${channels.size}`
: 'Choose visible channels';
} }
function rebuildChannelMenu() { function rebuildChannelMenu() {
@@ -353,120 +353,68 @@ export default function create({ root, moduleApi }) {
/* The stream sends JSON objects for new/updated/completed transcript /* The stream sends JSON objects for new/updated/completed transcript
entries. Be liberal about the envelope shape. */ entries. Be liberal about the envelope shape. */
function extractEntries(obj) { /* ── the module's feed: one snapshot, then each change ─────────── */
if (!obj || typeof obj !== 'object') return [];
if (Array.isArray(obj)) return obj.flatMap(extractEntries); function applyStatus(st) {
if (typeof obj.text === 'string' && (obj.id || obj.channelId)) return [obj]; if (!st || typeof st !== 'object') return;
for (const key of ['data', 'payload', 'entry', 'entries', 'transcript']) { if (st.state === 'ok') moduleApi.setStatus('ok', st.message || 'Live');
if (obj[key] && typeof obj[key] === 'object') { else moduleApi.setStatus(st.state === 'error' ? 'error' : 'connecting', st.message || 'Connecting to ProdCom…');
const found = extractEntries(obj[key]);
if (found.length) return found;
}
}
return [];
} }
function handleEventData(raw) { function applySnapshot(snap) {
let obj; if (!snap || typeof snap !== 'object') return;
try { obj = JSON.parse(raw); } catch { return; }
const kind = String(obj.type || obj.event || '').toLowerCase();
if (kind.includes('clear')) {
entryEls.forEach((el) => el.remove());
entryEls.clear();
emptyEl.hidden = false;
return;
}
extractEntries(obj).forEach(upsertEntry);
}
/* ── data loading (all via the module's proxy) ──────────────────── */
async function loadChannels() {
const res = await moduleApi.fetch('/prodcom/api/v1/channels');
if (!res.ok) throw new Error('channels ' + res.status);
const body = await res.json();
channels.clear(); channels.clear();
for (const ch of body.data || []) { for (const ch of snap.channels || []) channels.set(String(ch.id), { name: ch.name, color: ch.color || '#8b98a5' });
channels.set(ch.id, { name: ch.name, color: ch.color || '#8b98a5' });
}
rebuildChannelMenu();
}
async function loadGroups() {
const res = await moduleApi.fetch('/prodcom/api/v1/groups');
if (!res.ok) throw new Error('groups ' + res.status);
const body = await res.json();
groups.clear(); groups.clear();
for (const g of body.data || []) { for (const g of snap.groups || []) groups.set(String(g.id), { name: g.name, channelIds: g.channelIds || [] });
groups.set(g.id, { name: g.name, channelIds: g.channelIds || [] });
}
rebuildChannelMenu(); rebuildChannelMenu();
const list = (snap.entries || []).slice().sort((a, b) => new Date(a.date) - new Date(b.date));
list.forEach(upsertEntry); // dedupes by id, so a snapshot after a reconnect is harmless
applyStatus(snap.status);
} }
async function loadHistory() { async function loadState() {
const res = await moduleApi.fetch('/prodcom/api/v1/transcript?limit=100'); try {
if (!res.ok) throw new Error('transcript ' + res.status); const res = await moduleApi.fetch('/state');
const body = await res.json(); if (!res.ok) throw new Error(String(res.status));
const entries = (body.data || []).slice(); applySnapshot(await res.json());
entries.sort((a, b) => new Date(a.date) - new Date(b.date)); } catch { /* the stream's own snapshot covers it */ }
entries.forEach(upsertEntry);
} }
/* ── live stream ────────────────────────────────────────────────── */
const SSE_EVENT_NAMES = [
'transcript', 'transcript.new', 'transcript.updated', 'transcript.update',
'transcript.completed', 'transcript.complete', 'entry', 'new', 'update',
'updated', 'complete', 'completed',
];
function connect() { function connect() {
const onEvent = (ev) => handleEventData(ev.data); stream = moduleApi.sse('/stream', {
const events = {};
for (const name of SSE_EVENT_NAMES) events[name] = onEvent;
stream = moduleApi.sse('/prodcom/api/v1/transcript/stream', {
open() { open() {
moduleApi.setStatus('ok', 'Live'); loadState(); // a late joiner, or a reconnect: catch up on what was missed
// refresh channels/groups and backfill entries missed while
// disconnected (upsertEntry dedupes by id, so this is harmless)
loadChannels().catch(() => {});
loadGroups().catch(() => {});
loadHistory().catch(() => {});
}, },
error() { error() {
moduleApi.setStatus('error', 'ProdCom unreachable — reconnecting…'); moduleApi.setStatus('error', 'ProdDash ProdCom module unreachable — reconnecting…');
},
events: {
state(ev) {
try { applySnapshot(JSON.parse(ev.data)); } catch { /* a bad frame is skipped */ }
},
entry(ev) {
try { upsertEntry(JSON.parse(ev.data)); } catch { /* skipped */ }
},
clear() {
clearView();
},
status(ev) {
try { applyStatus(JSON.parse(ev.data)); } catch { /* skipped */ }
},
}, },
message: onEvent,
events,
}); });
} }
/* ── lifecycle ──────────────────────────────────────────────────── */ /* ── lifecycle ──────────────────────────────────────────────────── */
async function boot() {
if (stopped) return;
moduleApi.setStatus('connecting', 'Connecting to ProdCom…');
try {
await loadChannels();
await loadGroups().catch(() => {}); // groups are optional
await loadHistory();
} catch {
moduleApi.setStatus('error', 'ProdCom unreachable — retrying…');
bootTimer = setTimeout(boot, 5000);
return;
}
scrollToLatest();
connect();
}
return { return {
start() { start() {
boot(); moduleApi.setStatus('connecting', 'Connecting to ProdCom…');
connect();
}, },
stop() { stop() {
stopped = true; stopped = true;
clearTimeout(bootTimer);
stream?.close(); stream?.close();
root.innerHTML = ''; // header controls are removed by the shell root.innerHTML = ''; // header controls are removed by the shell
}, },
+19 -16
View File
@@ -1,22 +1,35 @@
{ {
"id": "prodcom-transcript", "id": "prodcom-transcript",
"name": "ProdCom", "name": "ProdCom",
"version": "1.2.1", "version": "1.3.1",
"proddash": ">=1.1.0", "proddash": ">=1.1.0",
"description": "Live ProdCom transcript stream", "description": "Live ProdCom transcript",
"permissions": [ "permissions": [
{ "kind": "local-network", "reason": "Reaching the ProdCom API on this network" } {
"kind": "local-network",
"reason": "Reaching the ProdCom API on this network"
}
], ],
"client": "client.js", "client": "client.js",
"server": "server.js", "server": "server.js",
"style": "style.css", "style": "style.css",
"minSize": { "w": 3, "h": 2 }, "minSize": {
"defaultSize": { "w": 4, "h": 4 }, "w": 3,
"h": 2
},
"defaultSize": {
"w": 4,
"h": 4
},
"configSchema": { "configSchema": {
"prodcom": { "prodcom": {
"type": "endpoint", "type": "endpoint",
"label": "ProdCom server", "label": "ProdCom server",
"default": { "host": "10.3.11.152", "port": 24480 } "default": {
"host": "",
"port": 24480
},
"help": "The ProdCom server at THIS location — every campus has its own. Blank until you set it, so a new install never reads another campus's comms."
}, },
"apiKey": { "apiKey": {
"type": "password", "type": "password",
@@ -29,16 +42,6 @@
"type": "boolean", "type": "boolean",
"label": "Show channel icons", "label": "Show channel icons",
"default": true "default": true
},
"newestFirst": {
"type": "boolean",
"label": "Newest first (transcript reads bottom-up)",
"default": false
},
"showTimes": {
"type": "boolean",
"label": "Show timestamps",
"default": false
} }
} }
} }
+297 -75
View File
@@ -3,73 +3,34 @@
/** /**
* ProdCom Transcript — server part. * ProdCom Transcript — server part.
* *
* One wildcard route that proxies /prodcom/* (including the SSE transcript * One connection to ProdCom for the whole server, however many dashboards
* stream) to the ProdCom API configured in the admin page, so browsers never * show the transcript. The module holds the channel list, the groups and
* talk to ProdCom directly (no CORS, and the API key never reaches clients). * the last entries, follows ProdCom's SSE stream, and hands every browser a
* Ported from prodcom-listener/server.js. * snapshot on connect plus each change as it happens. (It used to proxy each
* browser's stream to ProdCom one to one — N screens meant N upstream
* connections; ProdCom now sees one.)
*
* Routes (under /api/modules/prodcom-transcript):
* GET /state → { status, channels, groups, entries } (entries by date)
* GET /stream → SSE: `state` (the snapshot, first), then `entry` for each
* changed entry, `clear`, and `status` when the link changes
*
* ProdCom's API, as the reference listener speaks it:
* GET /api/v1/channels, /api/v1/groups, /api/v1/transcript?limit=N and the
* SSE /api/v1/transcript/stream, whose frames carry an entry, or a
* { type, entry | data } wrapper, in a few historical shapes (see
* extractEntries). A bearer API key is sent when one is configured.
*/ */
const http = require('http'); const http = require('http');
// Hop-by-hop headers that must not be forwarded const HISTORY_LIMIT = 100; // entries asked for on (re)connect
const HOP_HEADERS = new Set([ const ENTRIES_MAX = 300; // entries kept in memory
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', const RETRY_MIN_MS = 2000;
'te', 'trailer', 'transfer-encoding', 'upgrade', const RETRY_MAX_MS = 15000;
]); const REQUEST_TIMEOUT_MS = 8000;
const REFRESH_LISTS_MS = 5 * 60 * 1000; // channels and groups re-read
/** Health shown in the admin page: outcome of the most recent proxied request. */ const HEARTBEAT_MS = 15000;
let health = { status: 'connecting', message: 'No traffic yet' };
function proxy(req, res, target, apiKey) {
const upstreamPath = (req.wildcard || '/') + (req.search || '');
const headers = {};
for (const [k, v] of Object.entries(req.headers)) {
if (!HOP_HEADERS.has(k.toLowerCase())) headers[k] = v;
}
headers.host = target.host;
delete headers['accept-encoding']; // keep SSE unbuffered and simple
if (apiKey) headers.authorization = 'Bearer ' + apiKey;
const preq = http.request(
{
hostname: target.hostname,
port: target.port || 80,
path: upstreamPath,
method: req.method,
headers,
},
(pres) => {
health = { status: 'ok', message: `ProdCom reachable (last: HTTP ${pres.statusCode})` };
const outHeaders = {};
for (const [k, v] of Object.entries(pres.headers)) {
if (!HOP_HEADERS.has(k.toLowerCase())) outHeaders[k] = v;
}
res.writeHead(pres.statusCode, outHeaders);
// Flush immediately — SSE streams may send no bytes for a while, and
// the browser needs the headers to consider the stream open.
res.flushHeaders();
pres.pipe(res);
// If ProdCom dies mid-stream, pipe() unpipes on error without ending
// the browser's response, leaving SSE clients hanging on a dead
// stream. Destroy it so they notice immediately and reconnect.
pres.on('close', () => {
if (!res.writableEnded) res.destroy();
});
}
);
preq.on('error', (e) => {
health = { status: 'error', message: 'ProdCom unreachable: ' + (e.code || String(e)) };
if (!res.headersSent) {
res.writeHead(502, { 'Content-Type': 'application/json' });
}
res.end(JSON.stringify({ error: 'ProdCom unreachable', detail: e.code || String(e) }));
});
// Tear down the upstream connection when the browser disconnects
res.on('close', () => preq.destroy());
req.pipe(preq);
}
/** The ProdCom base URL from the endpoint config. A legacy prodcomUrl (saved /** The ProdCom base URL from the endpoint config. A legacy prodcomUrl (saved
before the endpoint field existed) can only still be present until the before the endpoint field existed) can only still be present until the
@@ -82,23 +43,284 @@ function resolveTarget(config) {
return null; return null;
} }
module.exports = { /** Entries inside whatever ProdCom sent: a bare entry, or one wrapped in data / payload / entry / entries / transcript. */
init({ config }) { function extractEntries(obj) {
health = resolveTarget(config) if (!obj || typeof obj !== 'object') return [];
? { status: 'connecting', message: 'No traffic yet' } if (Array.isArray(obj)) return obj.flatMap(extractEntries);
: { status: 'error', message: 'No ProdCom server configured' }; if (typeof obj.text === 'string' && (obj.id || obj.channelId)) return [obj];
for (const key of ['data', 'payload', 'entry', 'entries', 'transcript']) {
if (obj[key] && typeof obj[key] === 'object') {
const found = extractEntries(obj[key]);
if (found.length) return found;
}
}
return [];
}
/** One entry as the tiles read it; an entry without an id is keyed by channel and time. */
function normalizeEntry(e) {
const id = e.id !== undefined && e.id !== null && e.id !== '' ? String(e.id) : `${e.channelId || ''}|${e.date || ''}`;
return { return {
stop() { /* nothing persistent to tear down — streams die with their sockets */ }, id,
health: () => health, channelId: e.channelId === undefined || e.channelId === null ? '' : String(e.channelId),
channelName: e.channelName ? String(e.channelName) : '',
text: String(e.text ?? ''),
date: e.date ? String(e.date) : new Date().toISOString(),
inProgress: Boolean(e.inProgress),
};
}
let current = null; // the running instance, for routes()
module.exports = {
init({ config, log }) {
const target = resolveTarget(config);
const apiKey = String(config.apiKey || '');
const channels = [];
const groups = [];
const entries = new Map(); // id → entry, in arrival order
const streams = new Set(); // browsers' SSE responses
let status = { state: 'connecting', message: target ? 'Connecting to ProdCom…' : 'No ProdCom server configured' };
let stopped = false;
let upstream = null;
let generation = 0; // callbacks from a superseded connection are ignored
let retryTimer = null;
let retryMs = RETRY_MIN_MS;
const authHeaders = () => (apiKey ? { authorization: `Bearer ${apiKey}` } : {});
async function getJson(pathname) {
const res = await fetch(new URL(pathname, target), {
headers: { accept: 'application/json', ...authHeaders() },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!res.ok) throw new Error(`HTTP ${res.status} for ${pathname}`);
return res.json();
}
function sortedEntries() {
return [...entries.values()].sort((a, b) => new Date(a.date) - new Date(b.date));
}
function snapshot() {
return { status, channels, groups, entries: sortedEntries() };
}
function broadcast(event, data) {
if (!streams.size) return;
const frame = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
for (const res of streams) {
try {
res.write(frame);
} catch {
streams.delete(res);
}
}
}
function setStatus(state, message) {
if (status.state === state && status.message === message) return;
status = { state, message };
broadcast('status', status);
}
function trim() {
while (entries.size > ENTRIES_MAX) entries.delete(entries.keys().next().value);
}
/** A changed entry: kept, and told to every browser. */
function upsert(raw) {
const e = normalizeEntry(raw);
const prev = entries.get(e.id);
if (prev && prev.text === e.text && prev.inProgress === e.inProgress && prev.channelId === e.channelId) return;
entries.set(e.id, e);
trim();
broadcast('entry', e);
}
function clearAll() {
entries.clear();
broadcast('clear', {});
}
async function loadLists() {
const [ch, gr] = await Promise.all([
getJson('/api/v1/channels'),
getJson('/api/v1/groups').catch(() => ({ data: [] })), // groups are optional in ProdCom
]);
channels.splice(0, channels.length, ...(ch.data || []).map((c) => ({ id: String(c.id), name: String(c.name || c.id), color: c.color || '#8b98a5' })));
groups.splice(0, groups.length, ...(gr.data || []).map((g) => ({ id: String(g.id), name: String(g.name || g.id), channelIds: Array.isArray(g.channelIds) ? g.channelIds.map(String) : [] })));
}
async function loadHistory() {
const body = await getJson(`/api/v1/transcript?limit=${HISTORY_LIMIT}`);
for (const e of extractEntries(body).map(normalizeEntry)) entries.set(e.id, e);
trim();
}
function handleFrame(block) {
const data = [];
for (const line of block.split('\n')) {
if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, ''));
}
if (!data.length) return;
let obj;
try {
obj = JSON.parse(data.join('\n'));
} catch {
return;
}
const kind = String(obj?.type || obj?.event || '').toLowerCase();
if (kind.includes('clear')) return clearAll();
extractEntries(obj).forEach(upsert);
}
function scheduleRetry() {
if (stopped) return;
clearTimeout(retryTimer);
retryTimer = setTimeout(connect, retryMs);
retryMs = Math.min(RETRY_MAX_MS, retryMs * 2);
}
function fail(gen, why) {
if (stopped || gen !== generation) return;
generation += 1; // whatever else this connection reports is stale now
try { upstream?.destroy(); } catch { /* already gone */ }
upstream = null;
setStatus('error', `ProdCom unreachable (${why}) — retrying`);
log(`ProdCom ${target.origin}: ${why} — retrying in ${retryMs / 1000} s`);
scheduleRetry();
}
/** Lists and history first (a fresh snapshot for the tiles), then the live stream. */
async function connect() {
if (stopped || !target) return;
const gen = ++generation;
try {
await loadLists();
await loadHistory();
} catch (err) {
if (gen !== generation || stopped) return;
return fail(gen, err.cause?.code || err.code || err.message);
}
if (gen !== generation || stopped) return;
broadcast('state', snapshot());
const url = new URL('/api/v1/transcript/stream', target);
const req = http.request({
hostname: url.hostname,
port: url.port || 80,
path: url.pathname,
method: 'GET',
headers: { accept: 'text/event-stream', ...authHeaders() },
}, (res) => {
if (gen !== generation) return void res.destroy();
if (res.statusCode !== 200) {
res.resume();
return fail(gen, `HTTP ${res.statusCode} from the transcript stream`);
}
retryMs = RETRY_MIN_MS;
setStatus('ok', 'Live');
log(`following ${target.origin} — ${channels.length} channels, ${entries.size} entries`);
let buf = '';
res.setEncoding('utf8');
res.on('data', (chunk) => {
if (gen !== generation) return;
buf += chunk;
let i;
while ((i = buf.indexOf('\n\n')) >= 0) {
handleFrame(buf.slice(0, i));
buf = buf.slice(i + 2);
}
if (buf.length > 1e6) buf = ''; // a frame that never ends is dropped
});
res.on('end', () => fail(gen, 'stream ended'));
res.on('error', (err) => fail(gen, err.code || err.message));
});
req.on('error', (err) => fail(gen, err.code || err.message));
req.end();
upstream = req;
}
const listsTimer = setInterval(() => {
if (stopped || status.state !== 'ok') return;
loadLists().then(() => broadcast('state', snapshot())).catch(() => { /* the next reconnect re-reads them */ });
}, REFRESH_LISTS_MS);
listsTimer.unref?.();
const heartbeat = setInterval(() => {
for (const res of streams) {
try {
res.write(': ping\n\n');
} catch {
streams.delete(res);
}
}
}, HEARTBEAT_MS);
heartbeat.unref?.();
if (target) {
log(`one connection to ProdCom at ${target.origin}, shared by every dashboard`);
connect();
} else {
log('no ProdCom server configured — set the endpoint in /admin');
}
current = {
snapshot,
streams,
health() {
if (status.state === 'ok') return { status: 'ok', message: `Live — ${channels.length} channel${channels.length === 1 ? '' : 's'}, ${entries.size} entries` };
// Nothing to connect to is a setting that is missing, not a link that
// is coming up — it reads red, like the other modules say it.
if (!target) return { status: 'error', message: status.message };
return { status: status.state === 'error' ? 'error' : 'connecting', message: status.message };
},
};
return {
stop() {
stopped = true;
generation += 1;
clearTimeout(retryTimer);
clearInterval(listsTimer);
clearInterval(heartbeat);
try { upstream?.destroy(); } catch { /* already gone */ }
for (const res of streams) {
try { res.end(); } catch { /* gone */ }
}
streams.clear();
if (current?.streams === streams) current = null;
},
health: () => (current ? current.health() : { status: 'connecting', message: 'Restarting…' }),
}; };
}, },
routes({ config, log }) { routes() {
// An invalid host/URL throws here, which surfaces as a mount error in /admin. const sendJson = (res, code, body) => {
const target = resolveTarget(config) || new URL('http://10.3.11.152:24480'); res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
log(`proxying /prodcom/* → ${target.origin}`); res.end(JSON.stringify(body));
};
return { return {
'* /prodcom/*': (req, res) => proxy(req, res, target, config.apiKey || ''), 'GET /state': (req, res) => {
if (!current) return sendJson(res, 503, { error: 'ProdCom module is restarting — try again' });
sendJson(res, 200, current.snapshot());
},
'GET /stream': (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
});
res.write('retry: 3000\n\n');
if (!current) {
res.write(`event: state\ndata: ${JSON.stringify({ status: { state: 'connecting', message: 'ProdCom module is restarting…' }, channels: [], groups: [], entries: [] })}\n\n`);
return void res.end();
}
res.write(`event: state\ndata: ${JSON.stringify(current.snapshot())}\n\n`);
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
current.streams.add(res);
req.on('close', () => current && current.streams.delete(res));
},
}; };
}, },
}; };
+224 -52
View File
@@ -18,6 +18,10 @@
"Timers ▾" header menu, which hides any single timer — keyed `source:id` "Timers ▾" header menu, which hides any single timer — keyed `source:id`
and saved with the layout via saveInstanceSettings. The header can be and saved with the layout via saveInstanceSettings. The header can be
hidden by the user, so the static switches stay the primary controls. hidden by the user, so the static switches stay the primary controls.
A solo tile's gear carries only the settings its one card can use (the
picker entry's own schema — see tiles() in server.js), so a setting may
simply be absent here: every read goes through pref(), which falls back
to DEFAULTS rather than to whatever schema the tile happens to have.
Feedback — the state colours, and the thresholds behind them: Feedback — the state colours, and the thresholds behind them:
running elapsed, no planned length running running elapsed, no planned length running
@@ -37,8 +41,6 @@
theme; left at their defaults they follow the theme's variables. */ theme; left at their defaults they follow the theme's variables. */
/** Which instance switch governs a source kind. */ /** Which instance switch governs a source kind. */
const SOURCE_SWITCH = { propresenter: 'showProPresenter', ltc: 'showLtc', clock: 'showClock', module: 'showModules' };
/** Colour settings → custom property, and the schema default (module.json) that means "follow the theme". */ /** Colour settings → custom property, and the schema default (module.json) that means "follow the theme". */
const COLOR_SETTINGS = [ const COLOR_SETTINGS = [
['runningColor', '--tm-ok', '#2ee59a'], ['runningColor', '--tm-ok', '#2ee59a'],
@@ -56,6 +58,25 @@ const PAINT_MS = 200;
const LTC_KEY = 'ltc:ltc'; const LTC_KEY = 'ltc:ltc';
/** What the tile assumes for a setting its schema doesn't carry (a solo
tile offers only what applies to its card) or that was never saved. The
switches default to on, the clock to 24-hour with seconds and no date —
the same values module.json gives the full "All timers" schema. */
const DEFAULTS = {
showNames: true,
groupBySource: false,
showHeadings: true,
showStatus: true,
showDetail: true,
showSeconds: true,
leadingZeros: false,
clock24h: true,
clockSeconds: true,
clockDate: false,
dateShortDay: false,
dateShortMonth: false,
};
/* ── SMPTE timecode math (module-scope, exported for tests) ───────── /* ── SMPTE timecode math (module-scope, exported for tests) ─────────
The server anchors us ~4×/s with { time, fps, df, ageMs }; the card The server anchors us ~4×/s with { time, fps, df, ageMs }; the card
counts every frame in between locally. That needs exact frame↔timecode counts every frame in between locally. That needs exact frame↔timecode
@@ -120,14 +141,26 @@ export function valueAt(timer, sourceUpdatedAt, now) {
} }
/** Signed ms → "M:SS" / "H:MM:SS" (whole seconds, floor of the magnitude). */ /** Signed ms → "M:SS" / "H:MM:SS" (whole seconds, floor of the magnitude). */
export function fmtDuration(ms) { /**
* A duration as digits. `seconds: false` shows minutes only — a countdown
* rounds up (3:59 left reads "4", 0:30 over reads "-1"), an elapsed timer
* rounds down; `zeros` pads the leading field to two digits ("05:07").
*/
export function fmtDuration(ms, { seconds = true, zeros = false, up = false } = {}) {
const sign = ms < 0 ? '-' : ''; const sign = ms < 0 ? '-' : '';
const s = Math.floor(Math.abs(ms) / 1000); const total = Math.abs(ms) / 1000;
const pad = (v) => String(v).padStart(2, '0');
if (!seconds) {
const mins = up ? Math.ceil(total / 60) : Math.floor(total / 60);
const h = Math.floor(mins / 60);
const m = mins % 60;
return h ? `${sign}${zeros ? pad(h) : h}:${pad(m)}` : `${sign}${zeros ? pad(m) : m}`;
}
const s = Math.floor(total);
const h = Math.floor(s / 3600); const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60); const m = Math.floor((s % 3600) / 60);
const sec = s % 60; const sec = s % 60;
const pad = (v) => String(v).padStart(2, '0'); return h ? `${sign}${zeros ? pad(h) : h}:${pad(m)}:${pad(sec)}` : `${sign}${zeros ? pad(m) : m}:${pad(sec)}`;
return h ? `${sign}${h}:${pad(m)}:${pad(sec)}` : `${sign}${m}:${pad(sec)}`;
} }
function warnWindow(targetMs) { function warnWindow(targetMs) {
@@ -174,6 +207,15 @@ export default function create({ root, moduleApi }) {
let needFit = true; let needFit = true;
const settings = () => moduleApi.instanceSettings; const settings = () => moduleApi.instanceSettings;
/** One setting, resolved here: what this tile saved, else DEFAULTS — never
whatever its (possibly slimmer) schema would or wouldn't default. */
const pref = (key) => {
const v = settings()[key];
if (v !== undefined && v !== null) return v;
// A layout from before the 24-hour switch chose a "clockFormat".
if (key === 'clock24h' && settings().clockFormat === '12h') return false;
return DEFAULTS[key];
};
const keyOf = (source, timer) => `${source.id}:${timer.id}`; const keyOf = (source, timer) => `${source.id}:${timer.id}`;
/* Per-tile hidden timers, keyed source:id. Layouts saved by the earlier /* Per-tile hidden timers, keyed source:id. Layouts saved by the earlier
@@ -242,7 +284,7 @@ export default function create({ root, moduleApi }) {
let menuEl = null; // the open menu's element (filled on each open) let menuEl = null; // the open menu's element (filled on each open)
const itemsMenu = solo ? null : moduleApi.header.addMenu({ const itemsMenu = solo ? null : moduleApi.header.addMenu({
label: 'Timers ▾', label: 'Timers ▾',
title: 'Choose which timers this tile shows', title: 'Choose timers',
build(menu) { build(menu) {
// the shell hands over an emptied element on every open // the shell hands over an emptied element on every open
menu.classList.add('tm-menu'); menu.classList.add('tm-menu');
@@ -322,11 +364,6 @@ export default function create({ root, moduleApi }) {
/* ── state → what this tile shows ───────────────────────────────── */ /* ── state → what this tile shows ───────────────────────────────── */
function sourceShown(source) {
const key = SOURCE_SWITCH[source.kind] || 'showModules';
return settings()[key] !== false;
}
/** [{ id, source, items: [{ key, source, timer } | { key, missing }] }] */ /** [{ id, source, items: [{ key, source, timer } | { key, missing }] }] */
function visibleGroups() { function visibleGroups() {
if (!state) return []; if (!state) return [];
@@ -346,7 +383,6 @@ export default function create({ root, moduleApi }) {
} }
const groups = []; const groups = [];
for (const source of state.sources) { for (const source of state.sources) {
if (!sourceShown(source)) continue;
const items = []; const items = [];
for (const timer of source.timers) { for (const timer of source.timers) {
const key = keyOf(source, timer); const key = keyOf(source, timer);
@@ -356,7 +392,18 @@ export default function create({ root, moduleApi }) {
if (!items.length && source.timers.length) continue; if (!items.length && source.timers.length) continue;
groups.push({ id: source.id, source, items }); groups.push({ id: source.id, source, items });
} }
return groups; if (pref('groupBySource') === true) return groups;
// Grouping off: every timer in one grid, filled row by row. A source in
// trouble is still named, in one line above the cards.
const trouble = groups.filter((g) => g.source && g.source.status !== 'ok');
return [{
id: 'all',
source: null,
flat: true,
items: groups.flatMap((g) => g.items),
status: trouble.length ? 'error' : 'ok',
message: trouble.map((g) => `${g.source.label}: ${g.source.message || 'unavailable'}`).join(' · '),
}];
} }
/* ── cards ──────────────────────────────────────────────────────── */ /* ── cards ──────────────────────────────────────────────────────── */
@@ -392,6 +439,7 @@ export default function create({ root, moduleApi }) {
/** The parts of a card that only change with the snapshot (label, kind). */ /** The parts of a card that only change with the snapshot (label, kind). */
function updateCard(card, item) { function updateCard(card, item) {
card.item = item; card.item = item;
card.isClock = !item.missing && item.source?.kind === 'clock';
const isLtc = item.source?.kind === 'ltc'; const isLtc = item.source?.kind === 'ltc';
const badge = card.nameEl.querySelector('.tm-ltc-badge'); const badge = card.nameEl.querySelector('.tm-ltc-badge');
if (isLtc && !badge) { if (isLtc && !badge) {
@@ -402,7 +450,11 @@ export default function create({ root, moduleApi }) {
} else if (!isLtc && badge) { } else if (!isLtc && badge) {
badge.remove(); badge.remove();
} }
setText(card.labelEl, item.missing ? (lastSoloLabel || 'Timer') : item.timer.label); const label = item.missing ? (lastSoloLabel || 'Timer') : 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) { function updateMissingCard(card) {
@@ -424,13 +476,12 @@ export default function create({ root, moduleApi }) {
/* ── clocks ─────────────────────────────────────────────────────── */ /* ── clocks ─────────────────────────────────────────────────────── */
function clockOptions() { function clockOptions() {
const s = settings(); const h12 = pref('clock24h') === false;
const h12 = s.clockFormat === '12h';
return { return {
hour12: h12, hour12: h12,
hour: h12 ? 'numeric' : '2-digit', hour: h12 ? 'numeric' : '2-digit',
minute: '2-digit', minute: '2-digit',
...(s.clockSeconds !== false ? { second: '2-digit' } : {}), ...(pref('clockSeconds') !== false ? { second: '2-digit' } : {}),
}; };
} }
@@ -438,8 +489,43 @@ export default function create({ root, moduleApi }) {
return new Date().toLocaleTimeString([], clockOptions()); return new Date().toLocaleTimeString([], clockOptions());
} }
function dateText() { /** The date as the day and the rest — the break, when one is needed, falls between them. */
return new Date().toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' }); function dateParts() {
const d = new Date();
return {
day: d.toLocaleDateString([], { weekday: pref('dateShortDay') ? 'short' : 'long' }),
rest: d.toLocaleDateString([], { month: pref('dateShortMonth') ? 'short' : 'long', day: 'numeric' }),
};
}
/**
* The clock's date under its digits: one line when it fits the card,
* otherwise a line break after the day and a size that fits the longer
* half. Re-done only when the text or the card width changes.
*/
function renderDate(card, d) {
const cw = lastFit.cw;
const lineSize = lastFit.meta * LINE_SCALE;
const whole = `${d.day}, ${d.rest}`;
const key = `${whole}|${Math.round(cw)}|${lineSize}`;
if (card.dateKey === key) return;
card.dateKey = key;
let px = lineSize;
let wrapIt = false;
if (textWidth(whole, lineSize, 0.1) > cw) {
wrapIt = true;
const widest = Math.max(textWidth(`${d.day},`, lineSize, 0.1), textWidth(d.rest, lineSize, 0.1));
px = Math.max(8, Math.floor(lineSize * Math.min(1, cw / widest)));
}
const el = card.statusEl;
el.textContent = '';
const a = document.createElement('span');
a.textContent = `${d.day},`;
const b = document.createElement('span');
b.textContent = d.rest;
el.append(a, document.createTextNode(' '), b);
el.style.fontSize = px === lineSize ? '' : `${px}px`;
card.dateCls = wrapIt ? ' is-date is-wrap' : ' is-date';
} }
/** A provider's kind:"clock" timer carries an ISO time in status.text as of its snapshot. */ /** A provider's kind:"clock" timer carries an ISO time in status.text as of its snapshot. */
@@ -529,7 +615,9 @@ export default function create({ root, moduleApi }) {
function paint() { function paint() {
if (!state) return; if (!state) return;
const now = serverNow(); const now = serverNow();
const showStatus = settings().showStatus !== false; const showStatus = pref('showStatus') !== false;
const showDetail = pref('showDetail') !== false;
const digits = { seconds: pref('showSeconds') !== false, zeros: pref('leadingZeros') === true };
let charsChanged = false; let charsChanged = false;
for (const card of cards.values()) { for (const card of cards.values()) {
const { item } = card; const { item } = card;
@@ -544,6 +632,7 @@ export default function create({ root, moduleApi }) {
let statusTone = ''; let statusTone = '';
let detailText = ''; let detailText = '';
let extra = ''; let extra = '';
let date = null;
if (timer.kind === 'clock') { if (timer.kind === 'clock') {
if (source.kind === 'ltc') { if (source.kind === 'ltc') {
const l = timer.ltc || {}; const l = timer.ltc || {};
@@ -559,7 +648,8 @@ export default function create({ root, moduleApi }) {
} else if (source.kind === 'clock') { } else if (source.kind === 'clock') {
text = wallClockText(); text = wallClockText();
tone = 'plain'; tone = 'plain';
statusText = settings().clockDate ? dateText() : ''; date = pref('clockDate') ? dateParts() : null;
statusText = date ? `${date.day}, ${date.rest}` : '';
statusTone = 'muted'; statusTone = 'muted';
} else { } else {
text = providerClockText(timer, source, now); text = providerClockText(timer, source, now);
@@ -569,7 +659,7 @@ export default function create({ root, moduleApi }) {
} }
} else { } else {
const value = valueAt(timer, source.updatedAt, now); const value = valueAt(timer, source.updatedAt, now);
text = value === null ? '—' : fmtDuration(value); text = value === null ? '—' : fmtDuration(value, { ...digits, up: timer.kind === 'countdown' });
tone = toneFor(timer, value); tone = toneFor(timer, value);
statusText = timer.status?.text || defaultStatus(timer, value, tone); statusText = timer.status?.text || defaultStatus(timer, value, tone);
statusTone = timer.status?.tone || tone; statusTone = timer.status?.tone || tone;
@@ -586,14 +676,21 @@ export default function create({ root, moduleApi }) {
} }
// Notes that must survive the compact look: an unavailable LTC input. // Notes that must survive the compact look: an unavailable LTC input.
const isNote = extra === ' is-unavailable'; const isNote = extra === ' is-unavailable';
setText(card.statusEl, statusText); setText(card.statusEl, statusText); // a no-op over the date's two spans: same text
card.statusEl.hidden = !statusText || (!showStatus && !isNote); card.statusEl.hidden = !statusText || (!showStatus && !isNote);
card.statusEl.className = 'tm-status' + (isNote ? ' is-note' : ''); if (date && !card.statusEl.hidden) renderDate(card, date);
else if (card.dateCls) {
card.dateCls = '';
card.dateKey = '';
card.statusEl.style.fontSize = '';
}
card.statusEl.className = 'tm-status' + (isNote ? ' is-note' : '') + (card.dateCls || '');
card.statusEl.dataset.tone = statusTone; card.statusEl.dataset.tone = statusTone;
setText(card.detailEl, detailText); setText(card.detailEl, detailText);
card.detailEl.hidden = !detailText || !showStatus; card.detailEl.hidden = !detailText || !showDetail;
const kindCls = source.kind === 'ltc' ? ' tm-card-ltc' : source.kind === 'clock' ? ' tm-card-clock' : ''; const kindCls = source.kind === 'ltc' ? ' tm-card-ltc' : source.kind === 'clock' ? ' tm-card-clock' : '';
setCardClass(card, `tm-card is-${tone}${extra}${kindCls}`); const stale = source.status === 'error' ? ' is-stale' : '';
setCardClass(card, `tm-card is-${tone}${extra}${kindCls}${stale}`);
} }
if (charsChanged) fit(); if (charsChanged) fit();
} }
@@ -664,11 +761,20 @@ export default function create({ root, moduleApi }) {
wrap.classList.toggle('tm-mono', mono); wrap.classList.toggle('tm-mono', mono);
needFit = true; needFit = true;
} }
const s = settings(); // Headings belong to sections; without grouping there is one grid and no heading.
const showHeadings = !solo && s.showHeadings !== false; const showHeadings = !solo && pref('groupBySource') === true && pref('showHeadings') !== false;
const showStatus = s.showStatus !== false;
wrap.classList.toggle('tm-headings', showHeadings); wrap.classList.toggle('tm-headings', showHeadings);
wrap.classList.toggle('tm-nostatus', !showStatus); for (const [cls, on] of [
['tm-grouped', !solo && pref('groupBySource') === true],
['tm-nostatus', pref('showStatus') === false],
['tm-nodetail', pref('showDetail') === false],
['tm-nonames', pref('showNames') === false],
]) {
if (wrap.classList.contains(cls) !== on) {
wrap.classList.toggle(cls, on);
needFit = true;
}
}
let hint = ''; let hint = '';
const groups = visibleGroups(); const groups = visibleGroups();
@@ -690,18 +796,24 @@ export default function create({ root, moduleApi }) {
structureChanged = true; structureChanged = true;
} }
const source = g.source; const source = g.source;
const status = source ? source.status : 'ok'; const status = g.status ?? (source ? source.status : 'ok');
const message = source ? source.message : ''; const message = g.message ?? (source ? source.message : '');
ge.headEl.hidden = !showHeadings; ge.headEl.hidden = !showHeadings;
setText(ge.titleEl, source ? source.label : 'Timer'); setText(ge.titleEl, source ? source.label : 'Timers');
setText(ge.noteEl, status !== 'ok' ? message : g.items.length ? '' : 'no timers'); setText(ge.noteEl, status !== 'ok' ? message : g.items.length ? '' : 'no timers');
// Without headings the source's trouble still has to be said somewhere. // Without headings the source's trouble still has to be said somewhere.
const emptyText = status !== 'ok' ? message : 'No timers'; const emptyText = status !== 'ok' ? message : 'No timers';
const showEmpty = !showHeadings && (status !== 'ok' || !g.items.length); const showEmpty = !showHeadings && (status !== 'ok' || !g.items.length);
ge.emptyEl.hidden = !showEmpty; ge.emptyEl.hidden = !showEmpty;
setText(ge.emptyEl, showEmpty ? emptyText : ''); setText(ge.emptyEl, showEmpty ? emptyText : '');
ge.el.classList.toggle('is-down', status !== 'ok'); ge.emptyEl.dataset.tone = showEmpty && status !== 'ok' ? 'warn' : '';
ge.el.classList.toggle('is-stale', status === 'error' && g.items.length > 0); // In the flat grid a card dims for its own source (paint), not the whole grid.
ge.el.classList.toggle('is-down', !g.flat && status !== 'ok');
ge.el.classList.toggle('is-stale', !g.flat && status === 'error' && g.items.length > 0);
if (ge.gridEl.classList.contains('is-flow') !== Boolean(g.flat)) {
ge.gridEl.classList.toggle('is-flow', Boolean(g.flat));
structureChanged = true;
}
const els = []; const els = [];
for (const item of g.items) { for (const item of g.items) {
@@ -748,6 +860,30 @@ export default function create({ root, moduleApi }) {
/* ── sizing: the column count that gives the biggest digits ─────── */ /* ── sizing: the column count that gives the biggest digits ─────── */
/** Status and detail lines are drawn at this share of the label's size (style.css agrees). */
const LINE_SCALE = 0.8;
/* Text measured off-screen the way the CSS draws it (bold, uppercase,
letter-spaced), so a title or the date can shrink before the card
would cut it off. */
const measurer = document.createElement('canvas').getContext('2d');
let textFamily = '';
function textWidth(text, px, spacingEm) {
if (!textFamily) textFamily = getComputedStyle(root).fontFamily || 'sans-serif';
measurer.font = `700 ${px}px ${textFamily}`;
const t = String(text || '').toUpperCase();
return measurer.measureText(t).width + spacingEm * px * Math.max(0, t.length - 1);
}
/** The last fit's card width and label size — what the date is fitted against. */
let lastFit = { cw: 200, meta: 12 };
function fitLabel(card, meta, avail) {
if (card.isClock) return; // the clock has no label line
const room = Math.max(24, avail - (card.nameEl.querySelector('.tm-ltc-badge') ? 34 : 0));
const natural = textWidth(card.labelEl.textContent, meta, 0.08);
const px = natural > room ? Math.max(8, Math.floor((meta * room) / natural)) : meta;
card.labelEl.style.fontSize = px === meta ? '' : `${px}px`;
}
function fit() { function fit() {
const list = [...cards.values()]; const list = [...cards.values()];
const n = list.length; const n = list.length;
@@ -759,54 +895,90 @@ export default function create({ root, moduleApi }) {
const CARD_PAD = 12; // a card's own vertical padding + borders const CARD_PAD = 12; // a card's own vertical padding + borders
const HEAD_H = 20; // a source heading line, margin included const HEAD_H = 20; // a source heading line, margin included
const groups = [...groupEls.values()].filter((g) => g.el.isConnected); const groups = [...groupEls.values()].filter((g) => g.el.isConnected);
// Grouped: each source's grid wraps on its own, so its rows add up; flat:
// one grid. Headings only add a line each — they never decide the rows.
const grouped = wrap.classList.contains('tm-grouped') && groups.length > 1;
const headings = wrap.classList.contains('tm-headings') ? groups.length : 0; const headings = wrap.classList.contains('tm-headings') ? groups.length : 0;
const showStatus = !wrap.classList.contains('tm-nostatus'); const showStatus = !wrap.classList.contains('tm-nostatus');
// Lines under the digits besides the label: status, and detail if any card has one. const showDetail = !wrap.classList.contains('tm-nodetail');
const metaLines = showStatus ? 1 + (list.some((c) => !c.detailEl.hidden) ? 1 : 0) : 0; const namesOn = !wrap.classList.contains('tm-nonames');
// Lines under the digits: status, and detail if any card has one.
const metaLines = (showStatus ? 1 : 0) + (showDetail && list.some((c) => !c.detailEl.hidden) ? 1 : 0);
const date = showStatus && pref('clockDate') && list.some((c) => c.isClock) ? dateParts() : null;
const dateWhole = date ? `${date.day}, ${date.rest}` : '';
// For each column count, budget a card's full stack (label, digits, // For each column count, budget a card's full stack (label, digits,
// lines) and score by the digit size it affords — the SMALLEST card's, // lines) and score by the digit size it affords — the SMALLEST card's,
// so no timer ends up unreadable. Cards too short for the full stack // so no timer ends up unreadable. Cards too short for the full stack
// drop to a compact look (label + digits, the colour carries the // drop to a compact look (label + digits, the colour carries the
// state), but any layout with a readable full stack beats every // state), but any layout with a readable full stack beats every
// compact one. // compact one. The clock has no label line, and its date may take two.
const budgetFor = (c, ch, meta, mode, dateWrap) => {
const nameLine = namesOn && !c.isClock ? 1.3 * meta : 0;
if (mode === 'compact') return ch - nameLine;
const lines = metaLines + (c.isClock && dateWrap ? 1 : 0);
return ch - nameLine - lines * 1.3 * meta * LINE_SCALE;
};
const rowsFor = (cols) => (grouped
? groups.reduce((a, g) => a + Math.max(1, Math.ceil(g.count / cols)), 0)
: Math.ceil(n / cols));
let best = null; let best = null;
for (let cols = 1; cols <= n; cols += 1) { for (let cols = 1; cols <= n; cols += 1) {
const rows = headings const rows = rowsFor(cols);
? groups.reduce((a, g) => a + Math.max(1, Math.ceil(g.count / cols)), 0)
: Math.ceil(n / cols);
const cw = (W - PAD * 2 - GAP * (cols - 1)) / cols - 18; const cw = (W - PAD * 2 - GAP * (cols - 1)) / cols - 18;
const ch = (H - PAD * 2 - headings * HEAD_H - GAP * (rows - 1) - (headings ? (headings - 1) * GAP : 0)) / rows - CARD_PAD; // Every row the same height: padding, one heading line per group, and
// a gap between any two rows (between groups too) come off the top.
const rowH = (H - PAD * 2 - headings * HEAD_H - GAP * (rows - 1)) / rows;
const ch = rowH - CARD_PAD;
if (cw <= 0 || ch <= 0) continue; if (cw <= 0 || ch <= 0) continue;
const meta = Math.max(9, Math.min(22, Math.floor(ch * 0.16))); const meta = Math.max(9, Math.min(22, Math.floor(ch * 0.16)));
const fullBudget = ch - (1 + metaLines) * 1.3 * meta; const dateWrap = Boolean(dateWhole) && textWidth(dateWhole, meta * LINE_SCALE, 0.1) > cw;
const compactBudget = ch - 1.3 * meta;
let full = Infinity; let full = Infinity;
let compact = Infinity; let compact = Infinity;
for (const c of list) { for (const c of list) {
const byWidth = (cw * 1.55) / c.chars; const byWidth = (cw * 1.55) / c.chars;
full = Math.min(full, byWidth, fullBudget); full = Math.min(full, byWidth, budgetFor(c, ch, meta, 'full', dateWrap));
compact = Math.min(compact, byWidth, compactBudget); compact = Math.min(compact, byWidth, budgetFor(c, ch, meta, 'compact', dateWrap));
} }
const mode = full >= 14 ? 'full' : 'compact'; const mode = full >= 14 ? 'full' : 'compact';
const size = mode === 'full' ? full : compact; const size = mode === 'full' ? full : compact;
const score = (mode === 'full' ? 1000 : 0) + size; const score = (mode === 'full' ? 1000 : 0) + size;
if (!best || score > best.score) best = { cols, meta, mode, cw, budget: mode === 'full' ? fullBudget : compactBudget, score }; if (!best || score > best.score) best = { cols, meta, mode, cw, ch, rowH, dateWrap, score };
} }
if (!best) return; if (!best) return;
for (const g of groups) { for (const g of groups) {
g.gridEl.style.gridTemplateColumns = `repeat(${best.cols}, 1fr)`; g.gridEl.style.gridTemplateColumns = `repeat(${best.cols}, 1fr)`;
// Space is shared in proportion to each group's rows, so a card is g.gridEl.style.setProperty('--tm-cols', String(best.cols));
// the same height under every heading. // Each group takes exactly the height its rows, gaps and heading need,
g.el.style.flexGrow = String(Math.max(1, Math.ceil(g.count / best.cols))); // so a card is the same height under every heading.
const rows = Math.max(1, Math.ceil(g.count / best.cols));
const head = headings ? HEAD_H : 0;
g.el.style.flexGrow = String(Math.max(1, Math.round(head + rows * best.rowH + (rows - 1) * GAP)));
} }
wrap.style.setProperty('--tm-pad', PAD + 'px'); wrap.style.setProperty('--tm-pad', PAD + 'px');
wrap.style.setProperty('--tm-gap', GAP + 'px'); wrap.style.setProperty('--tm-gap', GAP + 'px');
wrap.style.setProperty('--tm-meta-size', best.meta + 'px'); wrap.style.setProperty('--tm-meta-size', best.meta + 'px');
wrap.classList.toggle('tm-compact', best.mode === 'compact'); wrap.classList.toggle('tm-compact', best.mode === 'compact');
lastFit = { cw: best.cw, meta: best.meta };
for (const c of list) { for (const c of list) {
const px = Math.max(11, Math.floor(Math.min((best.cw * 1.55) / c.chars, best.budget))); const budget = budgetFor(c, best.ch, best.meta, best.mode, best.dateWrap);
const px = Math.max(11, Math.floor(Math.min((best.cw * 1.55) / c.chars, budget)));
c.timeEl.style.fontSize = px + 'px'; c.timeEl.style.fontSize = px + 'px';
fitLabel(c, best.meta, best.cw);
c.dateKey = ''; // the date re-fits to the new card width on the next paint
}
// Then a look at the real thing: the layout is applied, so each card's
// height is known. Digits that would still spill past it — an estimate
// off by a line, a heading taller than budgeted — shrink to what is
// there. Only ever smaller; the estimate above sets the ceiling.
for (const c of list) {
const box = c.el.clientHeight;
if (!box) continue;
const parts = [c.nameEl, c.timeEl, c.statusEl, c.detailEl].filter((el) => !el.hidden && getComputedStyle(el).display !== 'none');
const others = parts.filter((el) => el !== c.timeEl).reduce((a, el) => a + el.getBoundingClientRect().height, 0);
const room = box - CARD_PAD - others - 0.02 * box * Math.max(0, parts.length - 1); // .tm-card's 2% gaps
const px = parseFloat(c.timeEl.style.fontSize) || 0;
if (room > 0 && px * 1.05 > room) c.timeEl.style.fontSize = `${Math.max(11, Math.floor(room / 1.05))}px`;
} }
} }
+137 -31
View File
@@ -4,16 +4,30 @@
"version": "1.3.0", "version": "1.3.0",
"proddash": ">=1.4.0", "proddash": ">=1.4.0",
"description": "Timers, Clocks, and LTC From configured Modules", "description": "Timers, Clocks, and LTC From configured Modules",
"provides": ["timers"], "provides": [
"timers"
],
"permissions": [ "permissions": [
{ "kind": "microphone", "reason": "Decoding SMPTE LTC timecode from an audio input on this machine" }, {
{ "kind": "local-network", "reason": "Reaching ProPresenter on this network" } "kind": "microphone",
"reason": "Decoding SMPTE LTC timecode from an audio input on this machine"
},
{
"kind": "local-network",
"reason": "Reaching ProPresenter on this network"
}
], ],
"client": "client.js", "client": "client.js",
"server": "server.js", "server": "server.js",
"style": "style.css", "style": "style.css",
"minSize": { "w": 2, "h": 1 }, "minSize": {
"defaultSize": { "w": 4, "h": 3 }, "w": 2,
"h": 1
},
"defaultSize": {
"w": 4,
"h": 3
},
"configGroups": { "configGroups": {
"ProPresenter": { "ProPresenter": {
"toggle": "ppEnabled", "toggle": "ppEnabled",
@@ -39,7 +53,10 @@
"type": "endpoint", "type": "endpoint",
"label": "ProPresenter", "label": "ProPresenter",
"group": "ProPresenter", "group": "ProPresenter",
"default": { "host": "", "port": 1025 } "default": {
"host": "",
"port": 1025
}
}, },
"password": { "password": {
"type": "password", "type": "password",
@@ -82,14 +99,26 @@
"group": "Display", "group": "Display",
"default": "default", "default": "default",
"options": [ "options": [
{ "value": "default", "label": "Default (system)" }, {
{ "value": "monospace", "label": "Monospace — steadier changing digits" } "value": "default",
"label": "Default (system)"
},
{
"value": "monospace",
"label": "Monospace — steadier changing digits"
}
] ]
} }
}, },
"instanceGroups": { "instanceGroups": {
"Show": { "collapsed": false, "columns": 2 }, "Show": {
"Clock": { "collapsed": false }, "collapsed": false,
"columns": 2
},
"Clock": {
"collapsed": false,
"columns": 2
},
"Colours": { "Colours": {
"collapsed": true, "collapsed": true,
"columns": 2, "columns": 2,
@@ -97,27 +126,104 @@
} }
}, },
"instanceSchema": { "instanceSchema": {
"showClock": { "type": "boolean", "label": "Clock", "default": true, "group": "Show" }, "showNames": {
"showProPresenter": { "type": "boolean", "label": "ProPresenter timers", "default": true, "group": "Show" }, "type": "switch",
"showLtc": { "type": "boolean", "label": "LTC timecode", "default": true, "group": "Show" }, "label": "Names",
"showModules": { "type": "boolean", "label": "Module timers", "default": true, "group": "Show" }, "default": true,
"showHeadings": { "type": "boolean", "label": "Source headings", "default": true, "group": "Show" }, "group": "Show"
"showStatus": { "type": "boolean", "label": "Status lines", "default": true, "group": "Show" },
"clockFormat": {
"type": "select",
"label": "Clock format",
"default": "24h",
"group": "Clock",
"options": [
{ "value": "24h", "label": "24-hour" },
{ "value": "12h", "label": "12-hour" }
]
}, },
"clockSeconds": { "type": "boolean", "label": "Seconds", "default": true, "group": "Clock" }, "showStatus": {
"clockDate": { "type": "boolean", "label": "Date", "default": false, "group": "Clock" }, "type": "switch",
"runningColor": { "type": "color", "label": "Running", "default": "#2ee59a", "group": "Colours" }, "label": "Status lines",
"warningColor": { "type": "color", "label": "Warning", "default": "#f0b429", "group": "Colours" }, "default": true,
"overrunColor": { "type": "color", "label": "Overrun", "default": "#f4433c", "group": "Colours" }, "group": "Show"
"idleColor": { "type": "color", "label": "Idle", "default": "#8b98a5", "group": "Colours" } },
"showDetail": {
"type": "switch",
"label": "Detail lines",
"default": true,
"group": "Show"
},
"showSeconds": {
"type": "switch",
"label": "Seconds",
"default": true,
"group": "Show"
},
"leadingZeros": {
"type": "switch",
"label": "Leading zeros",
"default": false,
"group": "Show"
},
"groupBySource": {
"type": "switch",
"label": "Group by source",
"default": false,
"group": "Show"
},
"showHeadings": {
"type": "switch",
"label": "Source headings",
"default": true,
"group": "Show",
"showWhen": "groupBySource"
},
"clock24h": {
"type": "switch",
"label": "24-hour",
"default": true,
"group": "Clock"
},
"clockSeconds": {
"type": "switch",
"label": "Seconds",
"default": true,
"group": "Clock"
},
"clockDate": {
"type": "switch",
"label": "Date",
"default": false,
"group": "Clock"
},
"dateShortDay": {
"type": "switch",
"label": "Short day",
"default": false,
"group": "Clock",
"showWhen": "clockDate"
},
"dateShortMonth": {
"type": "switch",
"label": "Short month",
"default": false,
"group": "Clock",
"showWhen": "clockDate"
},
"runningColor": {
"type": "color",
"label": "Running",
"default": "#2ee59a",
"group": "Colours"
},
"warningColor": {
"type": "color",
"label": "Warning",
"default": "#f0b429",
"group": "Colours"
},
"overrunColor": {
"type": "color",
"label": "Overrun",
"default": "#f4433c",
"group": "Colours"
},
"idleColor": {
"type": "color",
"label": "Idle",
"default": "#8b98a5",
"group": "Colours"
}
} }
} }
+80 -9
View File
@@ -82,6 +82,7 @@ const http = require('http');
const path = require('path'); const path = require('path');
const { spawn } = require('child_process'); const { spawn } = require('child_process');
const { createLtcListener } = require('./ltc-listener'); const { createLtcListener } = require('./ltc-listener');
const manifest = require('./module.json');
/** This module's id — left out of provider discovery (we don't consume ourselves). */ /** This module's id — left out of provider discovery (we don't consume ourselves). */
const SELF_ID = 'propresenter-timers'; const SELF_ID = 'propresenter-timers';
@@ -170,14 +171,14 @@ function fmtMs(ms) {
return h ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`; return h ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`;
} }
/** ProPresenter's count_down_to_time target: seconds since midnight + am/pm/24h. */ /** ProPresenter's count_down_to_time target, short: "9:30a", "12:05p", or "21:30" for a 24-hour clock. */
function timeOfDayText(seconds, period) { function timeOfDayText(seconds, period) {
const s = Number(seconds); const s = Number(seconds);
if (!Number.isFinite(s)) return ''; if (!Number.isFinite(s)) return '';
const h = Math.floor(s / 3600) % 24; const h = Math.floor(s / 3600) % 24;
const m = Math.floor((s % 3600) / 60); const m = Math.floor((s % 3600) / 60);
const p = String(period || '').toLowerCase(); const p = String(period || '').toLowerCase();
if (p === 'am' || p === 'pm') return `${h % 12 || 12}:${String(m).padStart(2, '0')} ${p}`; if (p === 'am' || p === 'pm') return `${h % 12 || 12}:${String(m).padStart(2, '0')}${p[0]}`;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
} }
@@ -277,7 +278,7 @@ function createProPresenterSource({ config, log }) {
} }
if (entry.count_down_to_time) { if (entry.count_down_to_time) {
const at = timeOfDayText(entry.count_down_to_time.time_of_day, entry.count_down_to_time.period); const at = timeOfDayText(entry.count_down_to_time.time_of_day, entry.count_down_to_time.period);
return { type: 'countdown_to_time', targetMs: null, detail: at ? `Counts down to ${at}` : 'Counts down to a time of day' }; return { type: 'countdown_to_time', targetMs: null, detail: at ? `to ${at}` : 'to a time of day' };
} }
if (entry.elapsed) { if (entry.elapsed) {
const end = Number(entry.elapsed.end_time); const end = Number(entry.elapsed.end_time);
@@ -873,6 +874,70 @@ function createModuleDiscovery({ shellPort, log, flags, onAdd, onRemove }) {
}; };
} }
/* ── what each tile's gear offers ─────────────────────────────────────
module.json's instanceSchema is the full set — what an "All timers" tile
offers, and what the shell falls back to for a tile with no picker entry
(one added before the picker listed timers). A solo tile shows one card,
so its picker entry carries a schema of just what that card can use, and
the shell renders that instead (module guide, "Presenting multiple
tiles"): a Message Timer has no use for the clock's format switches.
Every field is drawn from the manifest so it is defined once; the client
falls back to its own defaults for keys a slimmer schema leaves out. */
const FULL_SCHEMA = manifest.instanceSchema || {};
const FULL_GROUPS = manifest.instanceGroups || {};
/**
* A gear schema of just `keys` (manifest order kept) with the groups they
* use. `loose` names keys shown outside their group; `groups` overrides a
* group's meta. A group toggle whose switch isn't among the keys is dropped.
*/
function gearOf(keys, { loose = [], groups = {} } = {}) {
const instanceSchema = {};
for (const key of Object.keys(FULL_SCHEMA)) {
if (!keys.includes(key)) continue;
const spec = { ...FULL_SCHEMA[key] };
if (loose.includes(key)) delete spec.group;
instanceSchema[key] = spec;
}
const instanceGroups = {};
for (const spec of Object.values(instanceSchema)) {
if (!spec.group || instanceGroups[spec.group]) continue;
const meta = { ...(FULL_GROUPS[spec.group] || {}), ...(groups[spec.group] || {}) };
if (meta.toggle && !(meta.toggle in instanceSchema)) delete meta.toggle;
instanceGroups[spec.group] = meta;
}
return { instanceSchema, instanceGroups };
}
const COLOURS = ['runningColor', 'warningColor', 'overrunColor', 'idleColor'];
/** What a timer card can show or hide of itself (which timers appear is the header menu's job). */
const TIMER_ELEMENTS = ['showNames', 'showStatus', 'showDetail', 'showSeconds', 'leadingZeros'];
const CLOCK_ELEMENTS = ['clock24h', 'clockSeconds', 'clockDate', 'dateShortDay', 'dateShortMonth'];
/** With one card there is little else to see: the colours start unfolded. */
const OPEN_COLOURS = { groups: { Colours: { collapsed: false } } };
const GEAR = {
/** Every card's elements, the headings, how the clock reads, the tones. */
all: { instanceSchema: FULL_SCHEMA, instanceGroups: FULL_GROUPS },
/** One ProPresenter or module timer: its elements, and the tone its digits take per state. */
timer: gearOf([...TIMER_ELEMENTS, ...COLOURS], OPEN_COLOURS),
/** The timecode: running, no signal (warning) and stopped (idle) — it never overruns, and frames are frames. */
ltc: gearOf(['showNames', 'showStatus', 'runningColor', 'warningColor', 'idleColor'], OPEN_COLOURS),
/** The time of day: plain digits, so only how they read. */
clock: gearOf(CLOCK_ELEMENTS),
/** A clock another module offers: read like the clock, muted while it isn't running. */
providerClock: gearOf(['showNames', 'showStatus', 'clock24h', 'clockSeconds', 'idleColor'], OPEN_COLOURS),
};
/** The gear a solo card of this timer gets — by what the card can show. */
function gearFor(source, timer) {
if (source.kind === 'ltc') return GEAR.ltc;
if (source.kind === 'clock') return GEAR.clock;
if (timer.kind === 'clock') return GEAR.providerClock;
return GEAR.timer;
}
/* ── the module ───────────────────────────────────────────────────────── */ /* ── the module ───────────────────────────────────────────────────────── */
/** Sort order of the sources in the tile: fixed ones first, then PCO Plan, then other modules by name. */ /** Sort order of the sources in the tile: fixed ones first, then PCO Plan, then other modules by name. */
@@ -1058,19 +1123,25 @@ module.exports = {
* The Add-tile picker's entries: "All timers" plus one solo card per known * The Add-tile picker's entries: "All timers" plus one solo card per known
* timer from any source (moduleApi.variant carries the entry id — 'all', * timer from any source (moduleApi.variant carries the entry id — 'all',
* or 'solo:<source>:<timer id>'; the older 'timer:<uuid>' / 'ltc' ids keep * or 'solo:<source>:<timer id>'; the older 'timer:<uuid>' / 'ltc' ids keep
* working). Answered from the merged state, never from upstream — the * working). Each entry carries the gear settings that fit its tile (see
* picker must stay instant and a dead upstream must not stall it. * GEAR above); descriptions are the picker's tooltips, one sentence each.
* Answered from the merged state, never from upstream — the picker must
* stay instant and a dead upstream must not stall it.
*/ */
tiles() { tiles() {
const state = current ? current.getState() : null; const state = current ? current.getState() : null;
if (!state) return []; if (!state) return [];
const KIND_LABEL = { countdown: 'Countdown', elapsed: 'Elapsed timer', clock: 'Clock' }; const KIND_WORD = { countdown: 'countdown', elapsed: 'elapsed timer', clock: 'clock' };
const oneCard = { defaultSize: { w: 3, h: 2 }, minSize: { w: 2, h: 1 } }; const oneCard = { defaultSize: { w: 3, h: 2 }, minSize: { w: 2, h: 1 } };
const out = [{ id: 'all', name: 'All timers', description: 'Every timer, clock and timecode — choose which per tile' }]; const out = [{ id: 'all', name: 'All timers', description: 'Every timer and clock, grouped by source', ...GEAR.all }];
for (const src of state.sources) { for (const src of state.sources) {
for (const t of src.timers) { for (const t of src.timers) {
const what = src.id === 'ltc' ? 'Incoming timecode (HH:MM:SS:FF)' : src.id === 'clock' ? 'Time of day' : KIND_LABEL[t.kind] || 'Timer'; const description = src.kind === 'ltc' ? 'The incoming LTC timecode on its own'
out.push({ id: `solo:${src.id}:${t.id}`, name: t.label, description: `${src.label} · ${what}`, ...oneCard }); : src.kind === 'clock' ? 'The time of day on its own'
: `This ${src.label} ${KIND_WORD[t.kind] || 'timer'} on its own`;
// Layouts from before 1.3.0 hold 'timer:<uuid>' / 'ltc' — the same tiles.
const aliases = src.id === 'propresenter' ? [`timer:${t.id}`] : src.kind === 'ltc' ? ['ltc'] : [];
out.push({ id: `solo:${src.id}:${t.id}`, name: t.label, description, ...oneCard, ...gearFor(src, t), ...(aliases.length ? { aliases } : {}) });
} }
} }
return out; return out;
+33 -2
View File
@@ -39,6 +39,7 @@
visually, per group: the LTC card keeps ticking when only ProPresenter visually, per group: the LTC card keeps ticking when only ProPresenter
is unreachable. When the ProdDash feed is down everything is old. */ is unreachable. When the ProdDash feed is down everything is old. */
[data-module="propresenter-timers"] .tm-group.is-stale .tm-card, [data-module="propresenter-timers"] .tm-group.is-stale .tm-card,
[data-module="propresenter-timers"] .tm-card.is-stale,
[data-module="propresenter-timers"] .tm-wrap.is-feed-down .tm-card { opacity: 0.45; } [data-module="propresenter-timers"] .tm-wrap.is-feed-down .tm-card { opacity: 0.45; }
[data-module="propresenter-timers"] .tm-hint { [data-module="propresenter-timers"] .tm-hint {
@@ -126,13 +127,31 @@
} }
[data-module="propresenter-timers"] .tm-grid:empty { display: none; } [data-module="propresenter-timers"] .tm-grid:empty { display: none; }
/* Grouping off: one grid for every timer, filled row by row. A wrapping
flex row instead of a CSS grid, so the last row's cards widen to use the
whole width — nothing is left empty. Rows share the height equally. */
[data-module="propresenter-timers"] .tm-grid.is-flow {
display: flex;
flex-wrap: wrap;
align-content: stretch;
align-items: stretch;
}
[data-module="propresenter-timers"] .tm-grid.is-flow .tm-card {
flex: 1 1 calc((100% - var(--tm-gap, 8px) * (var(--tm-cols, 1) - 1)) / var(--tm-cols, 1));
min-width: 0;
}
[data-module="propresenter-timers"] .tm-empty[data-tone="warn"] { color: var(--tm-warn); }
/* Compact cards (short tiles) and "Status lines" off: the lines under the /* Compact cards (short tiles) and "Status lines" off: the lines under the
digits go, the colour stays. A note that explains a missing value (no digits go, the colour stays. A note that explains a missing value (no
LTC input, a solo timer no longer offered) must never be hidden. */ LTC input, a solo timer no longer offered) must never be hidden. */
[data-module="propresenter-timers"] .tm-compact .tm-status:not(.is-note), [data-module="propresenter-timers"] .tm-compact .tm-status:not(.is-note),
[data-module="propresenter-timers"] .tm-compact .tm-detail, [data-module="propresenter-timers"] .tm-compact .tm-detail,
[data-module="propresenter-timers"] .tm-nostatus .tm-status:not(.is-note), [data-module="propresenter-timers"] .tm-nostatus .tm-status:not(.is-note),
[data-module="propresenter-timers"] .tm-nostatus .tm-detail { display: none; } [data-module="propresenter-timers"] .tm-nodetail .tm-detail { display: none; }
/* "Names" off hides every title; the clock never carries one — its digits say what it is */
[data-module="propresenter-timers"] .tm-nonames .tm-name,
[data-module="propresenter-timers"] .tm-card-clock .tm-name { display: none; }
/* ── one timer card ── */ /* ── one timer card ── */
@@ -196,7 +215,7 @@
[data-module="propresenter-timers"] .tm-detail { [data-module="propresenter-timers"] .tm-detail {
flex: none; flex: none;
max-width: 100%; max-width: 100%;
font-size: var(--tm-meta-size); font-size: calc(var(--tm-meta-size) * 0.8); /* smaller than the title; fit() budgets the same share */
font-weight: 700; font-weight: 700;
letter-spacing: 0.1em; letter-spacing: 0.1em;
text-transform: uppercase; text-transform: uppercase;
@@ -221,6 +240,18 @@
[data-module="propresenter-timers"] .tm-status[data-tone="danger"] { color: var(--tm-danger); } [data-module="propresenter-timers"] .tm-status[data-tone="danger"] { color: var(--tm-danger); }
[data-module="propresenter-timers"] .tm-status[data-tone="muted"] { color: var(--tm-muted); } [data-module="propresenter-timers"] .tm-status[data-tone="muted"] { color: var(--tm-muted); }
/* The clock's date: one line when it fits, else the day on its own line
(renderDate() sizes it to the longer half). */
[data-module="propresenter-timers"] .tm-status.is-date {
white-space: normal;
overflow: visible;
text-overflow: clip;
text-align: center;
line-height: 1.15;
}
[data-module="propresenter-timers"] .tm-status.is-date span { white-space: nowrap; }
[data-module="propresenter-timers"] .tm-status.is-date.is-wrap span { display: block; }
/* Explanatory notes read as sentences, not badges. */ /* Explanatory notes read as sentences, not badges. */
[data-module="propresenter-timers"] .tm-status.is-note { [data-module="propresenter-timers"] .tm-status.is-note {
text-transform: none; text-transform: none;
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2013 Cal Henderson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+129
View File
@@ -0,0 +1,129 @@
{
"id": "slack",
"name": "Slack",
"version": "1.2.0",
"proddash": ">=1.4.0",
"description": "Live transcripts of this location's Slack channels, plus PIN-protected sending as the location",
"client": "client.js",
"server": "server.js",
"style": "style.css",
"minSize": {
"w": 2,
"h": 2
},
"defaultSize": {
"w": 4,
"h": 4
},
"configGroups": {
"Slack account": {
"collapsed": "whenSet",
"help": "Create a Slack app in the workspace (api.slack.com/apps → Create New App → From scratch) and, under OAuth & Permissions → User Token Scopes, add channels:history, channels:read, groups:history, groups:read, chat:write, users:read, reactions:write (emoji reactions) and emoji:read (the workspace's custom emoji). Sign in to Slack as this location's account — the one named for the campus — open the app's OAuth & Permissions page and press Install to Workspace (or Reinstall to Workspace), then copy the User OAuth Token (xoxp-…) it shows and paste it here: messages from this machine are then literally from the location. A bot token (xoxb-…) works too; it posts as the app, with \"Send as\" naming the location."
},
"Channels": {
"help": "Every channel picked here becomes its own transcript tile in the + picker, titled with the channel's name. The Send message and Quick replies tiles post to whichever transcript was clicked last — its bottom edge lights up in the accent colour — and to the first channel here until one is clicked. Only channels the account has joined are listed; join one in Slack first. Each channel is read on its own schedule, all of them together kept inside Slack's rate limit."
},
"Sending": {
"toggle": "sendEnabled",
"help": "Messages and emoji reactions leave this machine only after the PIN is entered — on a Send or Quick replies tile, or in the reaction menu a right-click on a transcript message opens. One PIN unlocks every Slack tile on that dashboard page until Auto-lock minutes pass without a send; a reload asks again. Sending stays refused until a 4–8 digit PIN is set here. A message goes to the channel of the transcript clicked last. Quick emojis are sent with one press from the Send message tile and offered first when reacting. Quick replies are one per line, Label | Message, with an optional | #channel-name to bind that one to a channel: a bound reply always goes there, whichever transcript is selected."
}
},
"configSchema": {
"token": {
"type": "password",
"label": "Token",
"default": "",
"group": "Slack account",
"help": "The location account's User OAuth Token (xoxp-…, recommended) or a Bot User OAuth Token (xoxb-…)."
},
"sendAs": {
"type": "string",
"label": "Send as",
"default": "",
"group": "Slack account",
"help": "Only used with a bot token — a user token always sends as its own account; needs the chat:write.customize scope."
},
"channels": {
"type": "multiselect",
"label": "Channels",
"default": [],
"group": "Channels",
"optionsRoute": "/channels",
"help": "One transcript tile per channel; the first one is where sends go until a transcript is clicked. Only channels the account has joined are listed — join one in Slack first."
},
"pollSeconds": {
"type": "number",
"label": "Refresh interval (s)",
"default": 2,
"group": "Transcript",
"help": "How often each channel is re-read (1 s at the fastest). With several channels the interval stretches so the module stays inside Slack's rate limit — at least 1.5 s × the number of channels; the health line shows the interval in use."
},
"historyCount": {
"type": "number",
"label": "Messages kept",
"default": 40,
"group": "Transcript"
},
"includeReplies": {
"type": "switch",
"label": "Thread replies",
"default": false,
"group": "Transcript"
},
"sendEnabled": {
"type": "switch",
"label": "Sending",
"default": false,
"group": "Sending"
},
"pin": {
"type": "password",
"label": "PIN",
"default": "",
"group": "Sending",
"help": "4–8 digits; the pad unlocks by itself once they are all in."
},
"pinIdleMinutes": {
"type": "number",
"label": "Auto-lock (min)",
"default": 10,
"group": "Sending"
},
"quickReplies": {
"type": "text",
"label": "Quick replies",
"default": "",
"group": "Sending",
"help": "One per line: Label | Message. Add | #channel-name to bind one to a channel — it then ignores which transcript is selected."
},
"quickEmojis": {
"type": "string",
"label": "Quick emojis",
"default": "👍 🙏 ✅ 👀 🎉",
"group": "Sending",
"help": "Emoji or :codes: separated by spaces, eight at most. One press sends one from the Send message tile; they also head the reaction menu."
}
},
"instanceSchema": {
"showAvatars": {
"type": "switch",
"label": "Avatars",
"default": true
},
"showTimes": {
"type": "switch",
"label": "Timestamps",
"default": true
},
"showNames": {
"type": "switch",
"label": "Names",
"default": true
},
"showSystem": {
"type": "switch",
"label": "Channel notices",
"default": true
}
}
}
File diff suppressed because it is too large Load Diff
+677
View File
@@ -0,0 +1,677 @@
/* Slack module — scoped to its tiles, shell variables only.
--sl-size (per tile, from the header's A− / A+) drives every font size.
Transcript rows follow the ProdCom transcript's layout: a square picture,
then a name line with the time, the text, the reactions. */
[data-module="slack"] .sl-wrap {
--sl-size: 14px;
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
container-type: size;
font-size: var(--sl-size);
color: var(--text);
}
[data-module="slack"] .sl-wrap:focus { outline: none; }
[data-module="slack"] [hidden] { display: none !important; }
/* ── degraded banner (transcript) ── */
[data-module="slack"] .sl-banner {
flex: none;
padding: 4px 10px;
font-size: 0.8em;
color: var(--warn);
background: color-mix(in srgb, var(--warn) 12%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--warn) 35%, var(--border));
}
[data-module="slack"] .sl-banner.is-error {
color: var(--danger);
background: color-mix(in srgb, var(--danger) 12%, transparent);
border-bottom-color: color-mix(in srgb, var(--danger) 35%, var(--border));
}
/* ── the send target ── */
/* A click anywhere in a transcript's body makes its channel the one Send
message and Quick replies post to. The chosen one carries a 3px accent
stripe along the bottom edge of the tile body — drawn just inside our
root, since the tile chrome around it belongs to the shell. The others
hint at it faintly on hover. */
[data-module="slack"] .sl-transcript::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 3px;
background: var(--accent);
opacity: 0;
pointer-events: none;
z-index: 3;
transition: opacity 0.15s ease;
}
[data-module="slack"] .sl-transcript:hover::after { opacity: 0.3; }
[data-module="slack"] .sl-transcript.is-target::after { opacity: 1; }
/* ── transcript stream ── */
[data-module="slack"] .sl-stream {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 6px 10px 10px;
overscroll-behavior: contain;
}
[data-module="slack"] .sl-empty {
color: var(--muted);
font-size: 0.9em;
text-align: center;
padding: 14% 10px 0;
line-height: 1.45;
}
[data-module="slack"] .sl-empty.is-error { color: var(--danger); }
[data-module="slack"] .sl-entry {
padding: 5px 6px;
border-radius: 8px;
line-height: 1.45;
}
[data-module="slack"] .sl-entry:hover { background: var(--bg); }
[data-module="slack"] .sl-entry.sl-new { animation: sl-fade-in 0.25s ease-out; }
@keyframes sl-fade-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: none; }
}
/* one row: the picture, then everything else (.sl-row — .sl-msg is the
send tile's status line, a different thing) */
[data-module="slack"] .sl-row,
[data-module="slack"] .sl-reply {
display: flex;
align-items: flex-start;
gap: 8px;
min-width: 0;
}
[data-module="slack"] .sl-body { flex: 1; min-width: 0; }
/* the square: a profile image, a bot's icon or emoji, or initials on a tint
of a hue the name hashes to — the same size and corner as ProdCom's */
[data-module="slack"] .sl-avatar {
flex: none;
width: calc(var(--sl-size) * 1.9);
height: calc(var(--sl-size) * 1.9);
border-radius: 26%;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: hsl(var(--sl-hue, 150) 45% 50% / 0.2);
color: hsl(var(--sl-hue, 150) 55% 60%);
font-size: 0.72em;
font-weight: 700;
letter-spacing: 0.02em;
user-select: none;
-webkit-user-select: none;
}
/* "Avatars" off: the square goes and the row starts at the left edge, as in ProdCom */
[data-module="slack"] .sl-wrap.hide-avatars .sl-avatar { display: none; }
[data-module="slack"] .sl-avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
[data-module="slack"] .sl-avatar-emoji { font-size: 1.25em; line-height: 1; }
[data-module="slack"] .sl-reply .sl-avatar {
width: calc(var(--sl-size) * 1.5);
height: calc(var(--sl-size) * 1.5);
font-size: 0.6em;
}
[data-module="slack"] .sl-meta {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
margin-bottom: 2px;
}
[data-module="slack"] .sl-name {
font-weight: 700;
font-size: 0.82em;
letter-spacing: 0.01em;
color: var(--accent);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-module="slack"] .sl-bot .sl-name { color: var(--muted); }
[data-module="slack"] .sl-time {
flex: none;
font-size: 0.72em;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
[data-module="slack"] .sl-wrap:not(.show-times) .sl-time { display: none; }
[data-module="slack"] .sl-wrap:not(.show-names) .sl-body > .sl-meta { display: none; }
[data-module="slack"] .sl-wrap.show-times:not(.show-names) .sl-body > .sl-meta { display: flex; }
[data-module="slack"] .sl-wrap.show-times:not(.show-names) .sl-name { display: none; }
[data-module="slack"] .sl-wrap:not(.show-system) .sl-entry.sl-system { display: none; }
[data-module="slack"] .sl-text {
white-space: pre-wrap;
overflow-wrap: anywhere;
color: var(--text);
}
[data-module="slack"] .sl-text b { font-weight: 700; }
[data-module="slack"] .sl-text i { font-style: italic; }
[data-module="slack"] .sl-text s { color: var(--muted); }
[data-module="slack"] .sl-code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.9em;
padding: 0 4px;
border-radius: 4px;
background: color-mix(in srgb, var(--text) 8%, transparent);
border: 1px solid var(--border);
}
[data-module="slack"] .sl-code.sl-block {
display: block;
padding: 4px 8px;
margin: 3px 0;
white-space: pre-wrap;
}
[data-module="slack"] .sl-edited {
font-size: 0.75em;
color: var(--muted);
white-space: nowrap;
}
/* a custom emoji is an image the size of a character */
[data-module="slack"] .sl-emoji {
display: inline-block;
width: 1.25em;
height: 1.25em;
object-fit: contain;
vertical-align: -0.28em;
}
/* reactions under a message: pills with the emoji and its count; the
location's own carry the accent — a click adds yours or takes it back */
[data-module="slack"] .sl-reactions {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
}
[data-module="slack"] .sl-reaction {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 1px 7px 1px 5px;
border: 1px solid var(--border);
border-radius: 999px;
background: color-mix(in srgb, var(--text) 5%, transparent);
color: var(--text);
font: inherit;
font-size: 0.78em;
line-height: 1.4;
cursor: pointer;
transition: background 0.1s, border-color 0.1s;
}
[data-module="slack"] .sl-reaction:hover { border-color: var(--accent); }
[data-module="slack"] .sl-reaction.is-me {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 16%, transparent);
}
[data-module="slack"] .sl-reaction .sl-emoji,
[data-module="slack"] .sl-reaction .sl-emoji-char { font-size: 1.15em; line-height: 1; vertical-align: middle; }
[data-module="slack"] .sl-reaction-count {
font-variant-numeric: tabular-nums;
font-weight: 600;
color: var(--muted);
}
[data-module="slack"] .sl-reaction.is-me .sl-reaction-count { color: var(--text); }
/* thread replies nest under their parent; without them, a muted count */
[data-module="slack"] .sl-replies {
margin: 4px 0 0 10px;
padding-left: 10px;
border-left: 2px solid var(--border);
display: flex;
flex-direction: column;
gap: 3px;
}
[data-module="slack"] .sl-reply { font-size: 0.92em; }
[data-module="slack"] .sl-replycount {
margin-top: 2px;
font-size: 0.75em;
color: var(--muted);
}
/* system lines: joins, leaves, topic changes */
[data-module="slack"] .sl-sysline {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 0.82em;
color: var(--muted);
font-style: italic;
}
[data-module="slack"] .sl-sysline .sl-time { font-style: normal; }
[data-module="slack"] .sl-jump {
position: absolute;
left: 50%;
bottom: 10px;
transform: translateX(-50%);
padding: 5px 12px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--accent);
color: var(--accent-text);
font: inherit;
font-size: 0.8em;
font-weight: 700;
cursor: pointer;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
}
/* ── send / quick replies: the gate ── */
[data-module="slack"] .sl-gate,
[data-module="slack"] .sl-compose {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding: 6px 10px;
overflow: auto;
}
[data-module="slack"] .sl-note {
margin: auto;
padding: 10px;
text-align: center;
color: var(--muted);
font-size: 0.9em;
line-height: 1.45;
}
[data-module="slack"] .sl-note.is-error { color: var(--danger); }
/* The pad never shrinks below its content (min-height stays `auto` on it
and on the key grid) — a shrunken grid would spill its rows under the
message line, which then swallows the taps. A tile too short for it
scrolls instead (.sl-gate has overflow: auto). */
[data-module="slack"] .sl-pad {
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: 4px;
max-width: 340px;
width: 100%;
margin: 0 auto;
}
[data-module="slack"] .sl-pad.sl-shake { animation: sl-shake 0.4s ease; }
@keyframes sl-shake {
10%, 90% { transform: translateX(-2px); }
20%, 80% { transform: translateX(3px); }
30%, 50%, 70% { transform: translateX(-5px); }
40%, 60% { transform: translateX(5px); }
}
/* one line: "Enter PIN · sending as Apollo Beach → #production" */
[data-module="slack"] .sl-pad-head {
flex: none;
text-align: center;
font-size: 0.8em;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-module="slack"] .sl-pad-head b { font-weight: 700; color: var(--text); }
[data-module="slack"] .sl-dots {
flex: none;
display: flex;
justify-content: center;
gap: 8px;
height: 14px;
}
[data-module="slack"] .sl-dot {
width: 11px;
height: 11px;
border-radius: 50%;
border: 2px solid var(--muted);
transition: background 0.12s, border-color 0.12s;
}
[data-module="slack"] .sl-dot.on { background: var(--accent); border-color: var(--accent); }
[data-module="slack"] .sl-keys {
flex: 1 1 auto;
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(4, minmax(24px, 1fr));
gap: 4px;
}
[data-module="slack"] .sl-key {
min-width: 0;
min-height: 24px;
border: 1px solid var(--border);
border-radius: 10px;
background: color-mix(in srgb, var(--text) 5%, var(--panel));
color: var(--text);
font: inherit;
font-size: clamp(13px, 9cqh, 26px);
font-weight: 600;
font-variant-numeric: tabular-nums;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
transition: background 0.1s;
}
[data-module="slack"] .sl-key:hover { background: var(--border); }
[data-module="slack"] .sl-key:active { background: color-mix(in srgb, var(--accent) 30%, var(--panel)); }
[data-module="slack"] .sl-key svg { width: 1.1em; height: 1.1em; }
[data-module="slack"] .sl-key-back { color: var(--muted); }
[data-module="slack"] .sl-key-blank { visibility: hidden; }
[data-module="slack"] .sl-pad.is-busy .sl-keys { opacity: 0.6; pointer-events: none; }
[data-module="slack"] .sl-pad-msg {
flex: none;
min-height: 1.1em;
text-align: center;
font-size: 0.78em;
line-height: 1.1;
color: var(--muted);
}
[data-module="slack"] .sl-pad-msg.is-error { color: var(--danger); font-weight: 600; }
/* A tile too short for the keypad (the smallest Send message / Quick replies
tiles) shows the dots and a hint instead — digits typed on the keyboard
still land, and a taller tile brings the keys back. .sl-wrap is the size
container the height is measured against. */
[data-module="slack"] .sl-pad-hint {
display: none;
flex: none;
text-align: center;
font-size: 0.78em;
line-height: 1.3;
color: var(--muted);
}
@container (max-height: 176px) {
[data-module="slack"] .sl-pad .sl-keys { display: none; }
[data-module="slack"] .sl-pad .sl-pad-hint { display: block; }
[data-module="slack"] .sl-pad { justify-content: center; }
}
/* ── send / quick replies: unlocked ── */
[data-module="slack"] .sl-compose { gap: 6px; }
[data-module="slack"] .sl-compose-head {
flex: none;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
font-size: 0.8em;
color: var(--muted);
}
[data-module="slack"] .sl-sending {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-module="slack"] .sl-lock-link {
flex: none;
padding: 1px 8px;
border: 1px solid var(--border);
border-radius: 999px;
background: none;
color: var(--muted);
font: inherit;
font-size: 0.9em;
cursor: pointer;
}
[data-module="slack"] .sl-lock-link:hover { color: var(--text); background: var(--border); }
[data-module="slack"] .sl-input {
flex: 1;
min-height: 2.6em;
width: 100%;
resize: none;
padding: 7px 9px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
color: var(--text);
font: inherit;
line-height: 1.4;
}
[data-module="slack"] .sl-input:focus { outline: none; border-color: var(--accent); }
[data-module="slack"] .sl-compose-foot {
flex: none;
display: flex;
align-items: center;
gap: 8px;
min-height: 1.6em;
}
[data-module="slack"] .sl-msg {
flex: 1;
min-width: 0;
font-size: 0.8em;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-module="slack"] .sl-msg.is-flash { color: var(--accent); font-weight: 700; }
[data-module="slack"] .sl-msg.is-error { color: var(--danger); white-space: normal; font-weight: 600; }
[data-module="slack"] .sl-countdown {
flex: none;
font-size: 0.75em;
color: var(--warn);
font-variant-numeric: tabular-nums;
}
[data-module="slack"] .sl-sendbtn {
flex: none;
padding: 6px 16px;
border: none;
border-radius: 8px;
background: var(--accent);
color: var(--accent-text);
font: inherit;
font-weight: 700;
cursor: pointer;
}
[data-module="slack"] .sl-sendbtn:hover { background: color-mix(in srgb, var(--accent) 85%, var(--text)); }
[data-module="slack"] .sl-sendbtn:disabled { opacity: 0.6; cursor: default; }
/* quick emojis: one press each sends it as a message of its own */
[data-module="slack"] .sl-emoji-sendrow {
flex: none;
display: flex;
gap: 4px;
min-width: 0;
overflow: hidden;
}
[data-module="slack"] .sl-emoji-send {
flex: none;
width: 2.1em;
height: 2.1em;
padding: 0;
border: 1px solid var(--border);
border-radius: 8px;
background: color-mix(in srgb, var(--text) 5%, var(--panel));
color: var(--text);
font: inherit;
font-size: 1.1em;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
transition: background 0.1s, border-color 0.1s;
}
[data-module="slack"] .sl-emoji-send:hover { border-color: var(--accent); background: var(--border); }
[data-module="slack"] .sl-emoji-send:active { background: color-mix(in srgb, var(--accent) 30%, var(--panel)); }
[data-module="slack"] .sl-emoji-send.is-busy { opacity: 0.6; }
[data-module="slack"] .sl-emoji-send.is-sent { background: var(--accent); border-color: transparent; }
[data-module="slack"] .sl-emoji-send .sl-emoji { width: 1.2em; height: 1.2em; vertical-align: middle; }
/* the preset grid: big targets, labels wrap */
[data-module="slack"] .sl-grid {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(150px, 100%), 1fr));
grid-auto-rows: minmax(44px, auto);
gap: 6px;
align-content: start;
overflow-y: auto;
}
[data-module="slack"] .sl-grid .sl-note { grid-column: 1 / -1; }
[data-module="slack"] .sl-quick {
min-width: 0;
min-height: 44px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 10px;
background: color-mix(in srgb, var(--accent) 12%, var(--panel));
color: var(--text);
font: inherit;
font-weight: 600;
line-height: 1.25;
text-align: center;
cursor: pointer;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
transition: background 0.1s;
}
[data-module="slack"] .sl-quick:hover { background: color-mix(in srgb, var(--accent) 22%, var(--panel)); }
[data-module="slack"] .sl-quick:active { background: color-mix(in srgb, var(--accent) 40%, var(--panel)); }
[data-module="slack"] .sl-quick.is-busy { opacity: 0.7; }
[data-module="slack"] .sl-quick.is-sent {
background: var(--accent);
border-color: transparent;
color: var(--accent-text);
}
[data-module="slack"] .sl-quick-label { overflow-wrap: anywhere; }
[data-module="slack"] .sl-quick-chan {
font-size: 0.72em;
font-weight: 500;
color: var(--muted);
}
[data-module="slack"] .sl-quick.is-sent .sl-quick-chan { color: inherit; }
/* ── the reaction menu (on document.body, so nothing clips it) ── */
[data-module="slack"].sl-emoji-menu {
--sl-size: 14px;
position: fixed;
z-index: 90;
width: 292px;
max-width: calc(100vw - 16px);
max-height: calc(100vh - 16px);
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--panel);
color: var(--text);
font-size: var(--sl-size);
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.55);
overflow: hidden;
}
[data-module="slack"].sl-emoji-menu:focus { outline: none; }
[data-module="slack"].sl-emoji-menu.is-busy { opacity: 0.7; pointer-events: none; }
[data-module="slack"] .sl-emoji-head {
font-size: 0.8em;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-module="slack"] .sl-emoji-quickrow {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
[data-module="slack"] .sl-emoji-quickrow:empty { display: none; }
[data-module="slack"] .sl-emoji-btn {
width: 34px;
height: 34px;
padding: 0;
border: 1px solid transparent;
border-radius: 8px;
background: none;
color: var(--text);
font: inherit;
font-size: 20px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.1s, border-color 0.1s;
}
[data-module="slack"] .sl-emoji-quickrow .sl-emoji-btn { font-size: 22px; width: 38px; height: 38px; background: color-mix(in srgb, var(--text) 5%, var(--panel)); border-color: var(--border); }
[data-module="slack"] .sl-emoji-btn:hover { background: var(--border); border-color: var(--accent); }
[data-module="slack"] .sl-emoji-btn .sl-emoji { width: 1.1em; height: 1.1em; vertical-align: middle; }
[data-module="slack"] .sl-emoji-search {
width: 100%;
box-sizing: border-box;
padding: 6px 9px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
color: var(--text);
font: inherit;
font-size: 0.9em;
}
[data-module="slack"] .sl-emoji-search:focus { outline: none; border-color: var(--accent); }
[data-module="slack"] .sl-emoji-results {
display: flex;
flex-wrap: wrap;
gap: 2px;
max-height: 150px;
overflow-y: auto;
min-height: 34px;
}
[data-module="slack"] .sl-emoji-none {
width: 100%;
padding: 8px 4px;
text-align: center;
color: var(--muted);
font-size: 0.8em;
}
[data-module="slack"] .sl-emoji-msg {
min-height: 1em;
font-size: 0.78em;
color: var(--muted);
}
[data-module="slack"] .sl-emoji-msg:empty { display: none; }
[data-module="slack"] .sl-emoji-msg.is-error { color: var(--danger); font-weight: 600; }
/* the PIN pad inside the menu: fixed sizes, no tile to scale with */
[data-module="slack"] .sl-emoji-menu .sl-pad { max-width: 100%; gap: 6px; }
[data-module="slack"] .sl-emoji-menu .sl-keys { grid-template-rows: repeat(4, 40px); }
[data-module="slack"] .sl-emoji-menu .sl-key { font-size: 17px; }
+2 -2
View File
@@ -1,8 +1,8 @@
{ {
"name": "proddash", "name": "proddash",
"version": "1.4.0", "version": "1.5.0",
"private": true, "private": true,
"description": "Modular production dashboard for Waters Church — zero-dependency Node server + vanilla client", "description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
"license": "UNLICENSED", "license": "UNLICENSED",
"engines": { "engines": {
"node": ">=18" "node": ">=18"
+1 -1
View File
@@ -38,7 +38,7 @@ Edit [config.json](config.json):
| Key | Meaning | | Key | Meaning |
| --- | --- | | --- | --- |
| `prodcomUrl` | Where the ProdCom API lives (default `http://10.3.11.152:24480`) | | `prodcomUrl` | Where the ProdCom API lives — set it per site, e.g. `http://<prodcom-host>:24480` |
| `port` | Port this listener serves on (default `24481`) | | `port` | Port this listener serves on (default `24481`) |
| `apiKey` | Only needed if PSK auth is enabled in ProdCom settings | | `apiKey` | Only needed if PSK auth is enabled in ProdCom settings |
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"prodcomUrl": "http://10.3.11.152:24480", "prodcomUrl": "",
"port": 24481, "port": 24481,
"apiKey": "" "apiKey": ""
} }
+6 -1
View File
@@ -23,10 +23,15 @@ try {
console.warn('config.json not found or invalid, using defaults/env vars'); console.warn('config.json not found or invalid, using defaults/env vars');
} }
const PRODCOM_URL = process.env.PRODCOM_URL || cfg.prodcomUrl || 'http://10.3.11.152:24480'; const PRODCOM_URL = process.env.PRODCOM_URL || cfg.prodcomUrl || '';
const PORT = Number(process.env.PORT || cfg.port || 24481); const PORT = Number(process.env.PORT || cfg.port || 24481);
const API_KEY = process.env.PRODCOM_API_KEY || cfg.apiKey || ''; const API_KEY = process.env.PRODCOM_API_KEY || cfg.apiKey || '';
if (!PRODCOM_URL) {
console.error('No ProdCom API configured. Set prodcomUrl in config.json (or the PRODCOM_URL');
console.error('environment variable) to this site\'s ProdCom, e.g. http://<prodcom-host>:24480');
process.exit(1);
}
const target = new URL(PRODCOM_URL); const target = new URL(PRODCOM_URL);
const PUBLIC_DIR = path.join(__dirname, 'public'); const PUBLIC_DIR = path.join(__dirname, 'public');
+22
View File
@@ -135,11 +135,33 @@ body { overflow: auto; }
.module-form { margin-top: 12px; border-top: 1px solid var(--border); padding-top: 12px; } .module-form { margin-top: 12px; border-top: 1px solid var(--border); padding-top: 12px; }
.module-form .field { max-width: 420px; } .module-form .field { max-width: 420px; }
/* a multi-line string (manifest type `text`): the same box as the other
inputs (those rules live in the shell's style.css), a few lines tall */
.field textarea {
padding: 7px 9px;
font-size: 13px;
font-family: inherit;
line-height: 1.4;
color: var(--text);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 7px;
resize: vertical;
min-height: 72px;
}
.field textarea:focus { outline: none; border-color: var(--accent); }
/* endpoint fields: every module's upstream is entered the same way */ /* endpoint fields: every module's upstream is entered the same way */
.field .endpoint-row { display: flex; align-items: center; gap: 7px; } .field .endpoint-row { display: flex; align-items: center; gap: 7px; }
.endpoint-row .ep-host { flex: 1; min-width: 0; } .endpoint-row .ep-host { flex: 1; min-width: 0; }
.endpoint-row .ep-port { width: 92px; flex: none; } .endpoint-row .ep-port { width: 92px; flex: none; }
.endpoint-row .ep-sep { color: var(--muted); font-weight: 600; } .endpoint-row .ep-sep { color: var(--muted); font-weight: 600; }
/* several picks from a list (manifest type `multiselect`) */
.field.multiselect-field { cursor: default; }
.field.multiselect-field .multiselect { display: flex; flex-direction: column; gap: 6px; }
.multiselect-row { display: flex; align-items: center; gap: 8px; cursor: pointer; }
.multiselect-empty { color: var(--muted); font-size: 12px; }
.module-form-empty { color: var(--muted); font-size: 12px; margin-top: 10px; } .module-form-empty { color: var(--muted); font-size: 12px; margin-top: 10px; }
/* labeled config subsections (manifest field `group`) */ /* labeled config subsections (manifest field `group`) */
+78
View File
@@ -228,6 +228,73 @@ function fieldFor(key, spec, value, passwordSet, moduleId) {
field.append(span, select); field.append(span, select);
read = () => select.value; read = () => select.value;
selectEl = select; selectEl = select;
} else if (type === 'multiselect' && (Array.isArray(spec.options) || spec.optionsRoute)) {
// Several picks from one list — a switch per option, stored as an array
// of values. A saved value the list no longer offers stays checked and
// marked, so a channel the account has left isn't dropped in silence.
field.classList.add('multiselect-field');
// The wrapper is a <label>; with several controls inside, a click on the
// wrapper would activate the FIRST one. `for=""` makes it label nothing.
field.htmlFor = '';
const span = document.createElement('span');
span.textContent = label;
const list = document.createElement('div');
list.className = 'multiselect';
const chosen = new Set((Array.isArray(value) ? value : []).map(String).filter(Boolean));
const optValue = (opt) => String(typeof opt === 'object' ? opt.value : opt);
const fill = (options) => {
for (const input of list.querySelectorAll('input')) {
if (input.checked) chosen.add(input.value);
else chosen.delete(input.value);
}
list.innerHTML = '';
const missing = [...chosen].filter((v) => !options.some((opt) => optValue(opt) === v));
const all = [...options, ...missing.map((v) => ({ value: v, label: `${v} (saved — not listed now)` }))];
for (const opt of all) {
const row = document.createElement('div');
row.className = 'multiselect-row';
const toggle = document.createElement('span');
toggle.className = 'switch';
const input = document.createElement('input');
input.type = 'checkbox';
input.value = optValue(opt);
input.checked = chosen.has(input.value);
const track = document.createElement('span');
track.className = 'track';
toggle.append(input, track);
const text = document.createElement('span');
text.className = 'switch-label';
text.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt);
row.append(toggle, text);
row.addEventListener('click', (e) => {
if (e.target === input) return; // the box toggles itself
input.checked = !input.checked;
input.dispatchEvent(new Event('change', { bubbles: true }));
});
list.appendChild(row);
}
if (!all.length) {
const none = document.createElement('div');
none.className = 'multiselect-empty';
none.textContent = 'Nothing to choose from yet.';
list.appendChild(none);
}
};
fill(Array.isArray(spec.options) ? spec.options : []);
if (spec.optionsRoute && moduleId) {
refetch = (query) => {
const qs = query && Object.keys(query).length ? `?${new URLSearchParams(query)}` : '';
return fetch(`/api/modules/${encodeURIComponent(moduleId)}${spec.optionsRoute}${qs}`)
.then((res) => (res.ok ? res.json() : null))
.then((body) => {
if (Array.isArray(body?.options)) fill(body.options);
})
.catch(() => { /* keep the static/saved options */ });
};
if (!spec.optionsDependsOn) refetch();
}
field.append(span, list);
read = () => [...list.querySelectorAll('input:checked')].map((input) => input.value);
} else if (type === 'color') { } else if (type === 'color') {
// A swatch picker; the value is always a #rrggbb string. // A swatch picker; the value is always a #rrggbb string.
field.classList.add('color-field'); field.classList.add('color-field');
@@ -239,6 +306,17 @@ function fieldFor(key, spec, value, passwordSet, moduleId) {
input.value = hex(value, hex(spec?.default, '#2ee59a')); input.value = hex(value, hex(spec?.default, '#2ee59a'));
field.append(span, input); field.append(span, input);
read = () => input.value; read = () => input.value;
} else if (type === 'text') {
// A multi-line string — one item per line by convention (a list of
// presets, say). Stored as-is, newlines included.
const span = document.createElement('span');
span.textContent = label;
const textarea = document.createElement('textarea');
textarea.rows = 5;
textarea.spellcheck = false;
textarea.value = value === undefined || value === null ? '' : String(value);
field.append(span, textarea);
read = () => textarea.value;
} else { } else {
const span = document.createElement('span'); const span = document.createElement('span');
span.textContent = label; span.textContent = label;
+7 -8
View File
@@ -19,7 +19,7 @@
<span class="spacer"></span> <span class="spacer"></span>
<button class="tb-btn icon" id="fs-btn" title="Fullscreen — auto-fits the layout to the screen" aria-label="Fullscreen"> <button class="tb-btn icon" id="fs-btn" title="Fullscreen" aria-label="Fullscreen">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 9V5a1 1 0 0 1 1-1h4"/><path d="M20 9V5a1 1 0 0 0-1-1h-4"/> <path d="M4 9V5a1 1 0 0 1 1-1h4"/><path d="M20 9V5a1 1 0 0 0-1-1h-4"/>
<path d="M4 15v4a1 1 0 0 0 1 1h4"/><path d="M20 15v4a1 1 0 0 1-1 1h-4"/> <path d="M4 15v4a1 1 0 0 0 1 1h4"/><path d="M20 15v4a1 1 0 0 1-1 1h-4"/>
@@ -27,12 +27,14 @@
</button> </button>
<div class="dropdown" id="add-dd"> <div class="dropdown" id="add-dd">
<button class="tb-btn icon" id="add-btn" title="Add a module tile" aria-label="Add tile">+</button> <button class="tb-btn icon" id="add-btn" title="Add tile" aria-label="Add tile">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>
</button>
<div class="menu" id="add-menu" hidden></div> <div class="menu" id="add-menu" hidden></div>
</div> </div>
<div class="dropdown" id="layout-dd"> <div class="dropdown" id="layout-dd">
<button class="tb-btn icon" id="layout-btn" title="Save, load or reset layouts" aria-label="Layouts"> <button class="tb-btn icon" id="layout-btn" title="Layouts" aria-label="Layouts">
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"> <svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<rect x="3" y="3" width="8" height="8" rx="1.5"/> <rect x="3" y="3" width="8" height="8" rx="1.5"/>
<rect x="3" y="13" width="8" height="8" rx="1.5"/> <rect x="3" y="13" width="8" height="8" rx="1.5"/>
@@ -42,11 +44,8 @@
<div class="menu" id="layout-menu" hidden></div> <div class="menu" id="layout-menu" hidden></div>
</div> </div>
<a class="tb-btn" id="admin-link" href="/admin/" title="Configure modules (server-wide)"> <a class="tb-btn icon" id="admin-link" href="/admin/" title="Admin" aria-label="Admin">
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"> <svg width="14" height="14" viewBox="0 0 90 90" fill="currentColor" aria-hidden="true"><path d="M 82.94 32.538 c -2.685 0 -5.093 -1.545 -6.327 -3.929 c -0.245 -0.476 -0.504 -0.948 -0.774 -1.416 c -0.27 -0.468 -0.55 -0.928 -0.84 -1.378 c -1.448 -2.26 -1.582 -5.117 -0.239 -7.443 c 1.951 -3.378 0.792 -7.696 -2.584 -9.645 l -9.352 -5.4 c -3.378 -1.951 -7.696 -0.792 -9.645 2.584 l -0.002 0.003 c -1.322 2.291 -3.815 3.641 -6.456 3.512 c -1.149 -0.056 -2.301 -0.057 -3.455 0 c -2.638 0.128 -5.124 -1.226 -6.444 -3.513 l -0.002 -0.002 c -1.951 -3.378 -6.269 -4.535 -9.645 -2.584 l -9.352 5.4 c -3.378 1.951 -4.535 6.269 -2.584 9.645 l 0.002 0.002 c 1.321 2.286 1.25 5.117 -0.18 7.336 c -0.625 0.972 -1.201 1.97 -1.726 2.993 c -1.209 2.353 -3.625 3.836 -6.269 3.836 H 7.063 c -3.9 0 -7.061 3.162 -7.061 7.061 v 10.801 c 0 3.9 3.162 7.061 7.061 7.061 l 0 0 c 2.685 0 5.093 1.545 6.327 3.929 c 0.245 0.475 0.504 0.948 0.774 1.416 c 0.27 0.468 0.55 0.928 0.84 1.378 c 1.448 2.26 1.582 5.117 0.239 7.443 c -1.951 3.378 -0.792 7.696 2.584 9.645 l 9.352 5.4 c 3.378 1.951 7.696 0.792 9.645 -2.584 l 0.002 -0.003 c 1.322 -2.291 3.815 -3.641 6.456 -3.512 c 1.149 0.056 2.301 0.057 3.455 0 c 2.638 -0.128 5.124 1.226 6.444 3.513 l 0.002 0.002 c 1.951 3.378 6.268 4.535 9.645 2.584 l 9.352 -5.4 c 3.378 -1.949 4.535 -6.268 2.584 -9.645 l -0.002 -0.002 c -1.321 -2.286 -1.25 -5.117 0.18 -7.336 c 0.625 -0.972 1.201 -1.97 1.726 -2.993 c 1.209 -2.353 3.625 -3.836 6.269 -3.836 h 0.003 c 3.9 0 7.061 -3.162 7.061 -7.061 V 39.599 C 90.001 35.7 86.84 32.538 82.94 32.538 z M 54.998 62.318 c -9.565 5.523 -21.796 2.245 -27.317 -7.32 c -5.523 -9.565 -2.245 -21.796 7.32 -27.317 s 21.796 -2.245 27.317 7.32 C 67.841 44.566 64.563 56.797 54.998 62.318 z"/></svg>
<path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm8.4-2.1.1-1.4-.1-1.4 1.7-1.3-1.6-2.8-2 .7a7.9 7.9 0 0 0-2.4-1.4l-.3-2.1H11l-.3 2.1a7.9 7.9 0 0 0-2.4 1.4l-2-.7L4.7 9.3 6.4 10.6l-.1 1.4.1 1.4-1.7 1.3 1.6 2.8 2-.7a7.9 7.9 0 0 0 2.4 1.4l.3 2.1h2.8l.3-2.1a7.9 7.9 0 0 0 2.4-1.4l2 .7 1.6-2.8-1.7-1.3Z"/>
</svg>
Admin
</a> </a>
</header> </header>
+561 -122
View File
@@ -108,9 +108,11 @@ function wireDropdown(btnId, menuId, rebuild) {
return menu; return menu;
} }
function menuItem(label, onClick, { sub = '', danger = false } = {}) { function menuItem(label, onClick, { sub = '', danger = false, disabled = false, title = '' } = {}) {
const item = document.createElement('button'); const item = document.createElement('button');
item.className = 'menu-item' + (danger ? ' danger' : ''); item.className = 'menu-item' + (danger ? ' danger' : '');
item.disabled = disabled;
if (title) item.title = title;
const span = document.createElement('span'); const span = document.createElement('span');
span.textContent = label; span.textContent = label;
if (sub) { if (sub) {
@@ -249,7 +251,8 @@ function minSizeOf(tile) {
/* ── tile DOM ───────────────────────────────────────────────────────── */ /* ── tile DOM ───────────────────────────────────────────────────────── */
const GEAR_SVG = '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm8.4-2.1.1-1.4-.1-1.4 1.7-1.3-1.6-2.8-2 .7a7.9 7.9 0 0 0-2.4-1.4l-.3-2.1H11l-.3 2.1a7.9 7.9 0 0 0-2.4 1.4l-2-.7L4.7 9.3 6.4 10.6l-.1 1.4.1 1.4-1.7 1.3 1.6 2.8 2-.7a7.9 7.9 0 0 0 2.4 1.4l.3 2.1h2.8l.3-2.1a7.9 7.9 0 0 0 2.4-1.4l2 .7 1.6-2.8-1.7-1.3Z"/></svg>'; const CLOSE_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>';
const GEAR_SVG = '<svg viewBox="0 0 90 90" fill="currentColor" aria-hidden="true"><path d="M 82.94 32.538 c -2.685 0 -5.093 -1.545 -6.327 -3.929 c -0.245 -0.476 -0.504 -0.948 -0.774 -1.416 c -0.27 -0.468 -0.55 -0.928 -0.84 -1.378 c -1.448 -2.26 -1.582 -5.117 -0.239 -7.443 c 1.951 -3.378 0.792 -7.696 -2.584 -9.645 l -9.352 -5.4 c -3.378 -1.951 -7.696 -0.792 -9.645 2.584 l -0.002 0.003 c -1.322 2.291 -3.815 3.641 -6.456 3.512 c -1.149 -0.056 -2.301 -0.057 -3.455 0 c -2.638 0.128 -5.124 -1.226 -6.444 -3.513 l -0.002 -0.002 c -1.951 -3.378 -6.269 -4.535 -9.645 -2.584 l -9.352 5.4 c -3.378 1.951 -4.535 6.269 -2.584 9.645 l 0.002 0.002 c 1.321 2.286 1.25 5.117 -0.18 7.336 c -0.625 0.972 -1.201 1.97 -1.726 2.993 c -1.209 2.353 -3.625 3.836 -6.269 3.836 H 7.063 c -3.9 0 -7.061 3.162 -7.061 7.061 v 10.801 c 0 3.9 3.162 7.061 7.061 7.061 l 0 0 c 2.685 0 5.093 1.545 6.327 3.929 c 0.245 0.475 0.504 0.948 0.774 1.416 c 0.27 0.468 0.55 0.928 0.84 1.378 c 1.448 2.26 1.582 5.117 0.239 7.443 c -1.951 3.378 -0.792 7.696 2.584 9.645 l 9.352 5.4 c 3.378 1.951 7.696 0.792 9.645 -2.584 l 0.002 -0.003 c 1.322 -2.291 3.815 -3.641 6.456 -3.512 c 1.149 0.056 2.301 0.057 3.455 0 c 2.638 -0.128 5.124 1.226 6.444 3.513 l 0.002 0.002 c 1.951 3.378 6.268 4.535 9.645 2.584 l 9.352 -5.4 c 3.378 -1.949 4.535 -6.268 2.584 -9.645 l -0.002 -0.002 c -1.321 -2.286 -1.25 -5.117 0.18 -7.336 c 0.625 -0.972 1.201 -1.97 1.726 -2.993 c 1.209 -2.353 3.625 -3.836 6.269 -3.836 h 0.003 c 3.9 0 7.061 -3.162 7.061 -7.061 V 39.599 C 90.001 35.7 86.84 32.538 82.94 32.538 z M 54.998 62.318 c -9.565 5.523 -21.796 2.245 -27.317 -7.32 c -5.523 -9.565 -2.245 -21.796 7.32 -27.317 s 21.796 -2.245 27.317 7.32 C 67.841 44.566 64.563 56.797 54.998 62.318 z"/></svg>';
function buildTile(tile) { function buildTile(tile) {
const man = registry.get(tile.module); const man = registry.get(tile.module);
@@ -258,6 +261,7 @@ function buildTile(tile) {
el.className = 'tile'; el.className = 'tile';
el.dataset.tileId = tile.id; el.dataset.tileId = tile.id;
if (man) el.dataset.module = man.id; if (man) el.dataset.module = man.id;
applyTint(tile, el);
const head = document.createElement('div'); const head = document.createElement('div');
head.className = 'tile-head'; head.className = 'tile-head';
@@ -276,23 +280,24 @@ function buildTile(tile) {
head.append(dot, title, controls); head.append(dot, title, controls);
const hasSettings = man && man.instanceSchema && Object.keys(man.instanceSchema).length; // Every tile has something to set: its own settings when the module
if (hasSettings) { // offers any, and the tint either way.
if (man) {
const gear = document.createElement('button'); const gear = document.createElement('button');
gear.className = 'tile-btn'; gear.className = 'tile-btn';
gear.title = 'Tile settings (this tile only)'; gear.title = 'Settings';
gear.innerHTML = GEAR_SVG; gear.innerHTML = GEAR_SVG;
gear.addEventListener('click', (e) => { gear.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
toggleSettingsPopover(tile, el); toggleSettingsPopover(tile, el, gear);
}); });
head.appendChild(gear); head.appendChild(gear);
} }
const close = document.createElement('button'); const close = document.createElement('button');
close.className = 'tile-btn close'; close.className = 'tile-btn close';
close.title = 'Remove this tile'; close.title = 'Remove';
close.textContent = '✕'; close.innerHTML = CLOSE_SVG; // a drawn cross, the same size in every browser (the ✕ glyph varies)
close.addEventListener('click', (e) => { close.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
removeTile(tile.id); removeTile(tile.id);
@@ -706,6 +711,10 @@ function buildDivider(L, R, top, bottom) {
*/ */
function addTile(moduleId, entry = null) { function addTile(moduleId, entry = null) {
const man = registry.get(moduleId); const man = registry.get(moduleId);
if (entry?.single && tiles.some((t) => t.module === moduleId && t.variant === String(entry.id))) {
toast(`${entry.name} is already on this dashboard`);
return;
}
const defaultSize = entry?.defaultSize || man?.defaultSize; const defaultSize = entry?.defaultSize || man?.defaultSize;
const size = { const size = {
w: Math.max(1, Math.min(COLS, defaultSize?.w || 4)), w: Math.max(1, Math.min(COLS, defaultSize?.w || 4)),
@@ -713,7 +722,8 @@ function addTile(moduleId, entry = null) {
}; };
const spot = findSpot(size.w, size.h); const spot = findSpot(size.w, size.h);
const settings = {}; const settings = {};
for (const [key, spec] of Object.entries(man?.instanceSchema || {})) { const { schema: addSchema } = tileSchema({ variant: entry ? String(entry.id) : '' }, man);
for (const [key, spec] of Object.entries(addSchema)) {
if (spec && 'default' in spec) settings[key] = spec.default; if (spec && 'default' in spec) settings[key] = spec.default;
} }
Object.assign(settings, entry?.settings || {}); Object.assign(settings, entry?.settings || {});
@@ -786,80 +796,130 @@ function tileMessage(entry, text) {
entry.body.appendChild(msg); entry.body.appendChild(msg);
} }
/* ── shared event streams ───────────────────────────────────────────── */ /* ── one stream per page ─────────────────────────────────────────────── */
/* Browsers allow about six HTTP/1.1 connections to one host, and an open /* Browsers allow about six HTTP/1.1 connections to one host, and an open
EventSource holds one of them for as long as it lives. One stream per EventSource holds one of them for as long as it lives. Sharing one
tile plus the shell's own /api/events reaches that ceiling at five or six stream per URL was not enough: a dashboard with six modules still ran
tiles, and from then on every further request — the + picker's module out (Safari first), and from then on every further request — a remounted
list, a new tile's /state, its stream — sits in the browser's queue tile's /state, an avatar, the + picker's list — sat in the browser's
waiting for a connection that is never handed back. So tiles that ask queue waiting for a connection that was never handed back. So the page
for the same stream share one connection: the pool keeps one EventSource holds ONE EventSource, GET /api/stream?s=<keys>, and the server relays
per URL, fans its events out to every subscriber, and reconnects it (on every stream named in it (a module's, or `shell:` for the shell's own)
the same 3-second timer the per-tile version used) while anyone is still with the key prefixed to each event name. Subscribers still see the
listening. A tile joining an already-open stream is told `open` straight events they asked for by their own names. Adding or dropping a stream
away, so its backfill runs exactly as if the connection were its own. */ remakes the connection with the new list, a moment later so several
tiles mounting together cause one reconnect; every stream announces
`open` again after it, so backfills run as before. */
const ssePool = new Map(); // url -> shared source const muxSubs = new Map(); // key -> { subs: Set<{ handlers }> }
let muxEs = null; // the page's one EventSource
let muxKeys = ''; // the key list it was opened with
let muxTimer = null; // the pending (re)connect
let muxRetry = null; // retry after the connection gave up
const muxListened = new Set(); // "key|name" pairs with a listener on muxEs
function sseSubscribe(url, handlers = {}) { /** The stream key for a URL: `/api/modules/<id><path>` → `<id>:<path>`, `/api/events` → `shell:`. */
let shared = ssePool.get(url); function muxKeyFor(url) {
if (!shared) { if (url === '/api/events') return 'shell:';
shared = { subs: new Set(), es: null, retryTimer: null, names: new Set() }; const m = /^\/api\/modules\/([^/?#]+)(\/[^?#]*)?$/.exec(url);
const fanOut = (kind, e) => { return m ? `${decodeURIComponent(m[1])}:${m[2] || '/'}` : null;
for (const sub of [...shared.subs]) { }
function muxFanOut(key, kind, e) {
for (const sub of [...(muxSubs.get(key)?.subs || [])]) {
const fn = kind === 'event' ? sub.handlers.events?.[e.type] : sub.handlers[kind]; const fn = kind === 'event' ? sub.handlers.events?.[e.type] : sub.handlers[kind];
try { fn?.(e); } catch (err) { console.error('[shell] stream handler failed:', err); } try { fn?.(e); } catch (err) { console.error('[shell] stream handler failed:', err); }
} }
}; }
shared.listen = (name) => shared.es.addEventListener(name, (e) => fanOut('event', e));
shared.connect = () => {
clearTimeout(shared.retryTimer);
shared.retryTimer = null;
try { shared.es?.close(); } catch { /* already closed */ }
const es = new EventSource(url);
shared.es = es;
es.onopen = (e) => fanOut('open', e);
es.onmessage = (e) => fanOut('message', e);
es.onerror = (e) => {
fanOut('error', e);
// EventSource retries transient drops itself but gives up for good on
// a completed non-SSE response (a 502 while the upstream is down).
if (es.readyState === EventSource.CLOSED && shared.subs.size) {
clearTimeout(shared.retryTimer);
shared.retryTimer = setTimeout(shared.connect, 3000);
}
};
for (const name of shared.names) shared.listen(name);
};
ssePool.set(url, shared);
}
const sub = { handlers }; /** Listen for `<key>|<name>` on the open connection and hand it to that key's subscribers as `name`. */
shared.subs.add(sub); function muxListen(key, name) {
for (const name of Object.keys(handlers.events || {})) { const full = `${key}|${name}`;
if (shared.names.has(name)) continue; if (!muxEs || muxListened.has(full)) return;
shared.names.add(name); muxListened.add(full);
if (shared.es) shared.listen(name); muxEs.addEventListener(full, (e) => {
if (name === '__open') return muxFanOut(key, 'open', new Event('open'));
if (name === '__down') return muxFanOut(key, 'error', new Event('error'));
const ev = { type: name, data: e.data, lastEventId: e.lastEventId };
muxFanOut(key, name === 'message' ? 'message' : 'event', ev);
});
}
function muxConnect() {
clearTimeout(muxTimer);
muxTimer = null;
clearTimeout(muxRetry);
muxRetry = null;
const keys = [...muxSubs.keys()].sort();
const wanted = keys.join(',');
if (!keys.length) {
try { muxEs?.close(); } catch { /* not open */ }
muxEs = null;
muxKeys = '';
return;
} }
if (!shared.es || shared.es.readyState === EventSource.CLOSED) { if (muxEs && muxKeys === wanted && muxEs.readyState !== EventSource.CLOSED) return;
// first subscriber, or the stream had given up while nobody listened try { muxEs?.close(); } catch { /* not open */ }
shared.connect(); muxListened.clear();
} else if (shared.es.readyState === EventSource.OPEN) { muxKeys = wanted;
// Joining mid-flight: report the open the way a fresh connection would const es = new EventSource(`/api/stream?s=${encodeURIComponent(wanted)}`);
// have, a moment later, so the caller's own setup finishes first. muxEs = es;
// Each stream says `open` for itself (the server's __open), so nothing to do on the connection's own open.
es.onerror = () => {
if (es !== muxEs) return;
for (const key of muxSubs.keys()) muxFanOut(key, 'error', new Event('error'));
// EventSource retries transient drops itself but gives up for good on a
// completed non-SSE response (a 502 while the server restarts).
if (es.readyState === EventSource.CLOSED && muxSubs.size) {
clearTimeout(muxRetry);
muxRetry = setTimeout(muxConnect, 3000);
}
};
for (const [key, entry] of muxSubs) {
muxListen(key, '__open');
muxListen(key, '__down');
muxListen(key, 'message');
for (const sub of entry.subs) for (const name of Object.keys(sub.handlers.events || {})) muxListen(key, name);
}
}
function sseSubscribe(url, handlers = {}) {
const key = muxKeyFor(url);
if (!key) {
console.warn('[shell] not a stream this page can carry:', url);
return { close() {} };
}
let entry = muxSubs.get(key);
if (!entry) {
entry = { subs: new Set() };
muxSubs.set(key, entry);
}
const sub = { handlers };
entry.subs.add(sub);
const carried = Boolean(muxEs) && muxKeys.split(',').includes(key) && muxEs.readyState !== EventSource.CLOSED;
if (carried) {
// Joining a stream the connection already carries: listeners for any
// new event names, and `open` a moment later so the caller's own setup
// finishes first — exactly as a fresh connection would have reported it.
for (const name of Object.keys(handlers.events || {})) muxListen(key, name);
if (muxEs.readyState === EventSource.OPEN) {
setTimeout(() => { setTimeout(() => {
if (!shared.subs.has(sub)) return; if (!entry.subs.has(sub)) return;
try { handlers.open?.(new Event('open')); } catch (err) { console.error('[shell] stream handler failed:', err); } try { handlers.open?.(new Event('open')); } catch (err) { console.error('[shell] stream handler failed:', err); }
}, 0); }, 0);
} }
} else {
clearTimeout(muxTimer);
muxTimer = setTimeout(muxConnect, 20);
}
return { return {
close() { close() {
if (!shared.subs.delete(sub) || shared.subs.size) return; if (!entry.subs.delete(sub) || entry.subs.size) return;
clearTimeout(shared.retryTimer); muxSubs.delete(key);
try { shared.es?.close(); } catch { /* already closed */ } // A remount closes and reopens within milliseconds: wait before remaking the connection.
ssePool.delete(url); clearTimeout(muxTimer);
muxTimer = setTimeout(muxConnect, 500);
}, },
}; };
} }
@@ -890,7 +950,7 @@ function buildModuleApi(tile, man, entry) {
/** Per-tile settings: schema defaults overlaid with what this tile saved. */ /** Per-tile settings: schema defaults overlaid with what this tile saved. */
get instanceSettings() { get instanceSettings() {
const out = {}; const out = {};
for (const [key, spec] of Object.entries(man.instanceSchema || {})) { for (const [key, spec] of Object.entries(tileSchema(tile, man).schema)) {
if (spec && 'default' in spec) out[key] = spec.default; if (spec && 'default' in spec) out[key] = spec.default;
} }
return Object.assign(out, tile.settings || {}); return Object.assign(out, tile.settings || {});
@@ -1075,6 +1135,23 @@ function unmountModule(tile) {
/* ── per-tile settings popover ──────────────────────────────────────── */ /* ── per-tile settings popover ──────────────────────────────────────── */
/** `<input type="color">` only accepts #rrggbb — anything else falls back. */ /** `<input type="color">` only accepts #rrggbb — anything else falls back. */
/* ── the tile tint ───────────────────────────────────────────────────
Every tile can be washed in a colour of its own — a way to tell one
screen's tiles apart at a glance (this campus green, that one amber).
It is the shell's, not a module's: the key is reserved, it is offered in
every gear window, and it saves with the layout like any other tile
setting. A tile with no tint looks exactly as before. */
const TINT_KEY = '__tint';
/** Paint (or clear) a tile's tint on its element. */
function applyTint(tile, el = tileEls.get(tile.id)?.el) {
if (!el) return;
const hex = colorHex(tile.settings?.[TINT_KEY], '');
if (hex) el.style.setProperty('--tile-tint', hex);
else el.style.removeProperty('--tile-tint');
el.classList.toggle('is-tinted', Boolean(hex));
}
function colorHex(value, fallback) { function colorHex(value, fallback) {
const s = String(value ?? '').trim(); const s = String(value ?? '').trim();
if (/^#[0-9a-fA-F]{6}$/.test(s)) return s.toLowerCase(); if (/^#[0-9a-fA-F]{6}$/.test(s)) return s.toLowerCase();
@@ -1082,28 +1159,165 @@ function colorHex(value, fallback) {
return fallback; return fallback;
} }
/** The settings a tile offers: its picker entry's own schema when it has one
— a solo timer wants none of a full tile's switches — else the module's.
Same for the groups that arrange them. */
function tileSchema(tile, man) {
const own = (v) => (v && typeof v === 'object' ? v : null);
const variant = String(tile?.variant || '');
const entry = variant && Array.isArray(man?.tiles)
? man.tiles.find((e) => String(e.id) === variant)
|| man.tiles.find((e) => Array.isArray(e.aliases) && e.aliases.includes(variant))
: null;
return {
schema: own(entry?.instanceSchema) || own(man?.instanceSchema) || {},
groups: own(entry?.instanceGroups) || own(man?.instanceGroups) || {},
};
}
/* ── tooltips: at once on hover, short, one for the whole page ──────────
Every header and top-bar button's `title` becomes a tip the shell shows
the moment the pointer arrives — the browser's own waits about a second
and looks different in each browser. The text moves from `title` to
data-tip so the native one never appears; a module that sets `title`
again later is picked up on the next hover. */
let tipEl = null;
const TIP_TARGETS = '.tile-btn, .tb-btn, [data-tip]';
function tipTextFor(el) {
if (el.title) {
el.dataset.tip = el.title;
if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', el.title);
el.removeAttribute('title');
}
return el.dataset.tip || '';
}
function showTip(el) {
const text = tipTextFor(el);
if (!text) return;
if (!tipEl) {
tipEl = document.createElement('div');
tipEl.id = 'tip';
tipEl.setAttribute('role', 'tooltip');
document.body.appendChild(tipEl);
}
tipEl.textContent = text;
tipEl.hidden = false;
const r = el.getBoundingClientRect();
const w = tipEl.offsetWidth;
const h = tipEl.offsetHeight;
const left = Math.max(6, Math.min(r.left + r.width / 2 - w / 2, window.innerWidth - w - 6));
let top = r.bottom + 6;
if (top + h > window.innerHeight - 6) top = r.top - h - 6;
tipEl.style.left = `${Math.round(left)}px`;
tipEl.style.top = `${Math.round(top)}px`;
tipEl._for = el;
}
function hideTip(el = null) {
if (!tipEl || tipEl.hidden) return;
if (el && tipEl._for !== el) return;
tipEl.hidden = true;
tipEl._for = null;
}
document.addEventListener('pointerover', (e) => {
const el = e.target?.closest?.(TIP_TARGETS);
if (!el || (tipEl && tipEl._for === el && !tipEl.hidden)) return;
showTip(el);
});
document.addEventListener('pointerout', (e) => {
const el = e.target?.closest?.(TIP_TARGETS);
if (!el || (e.relatedTarget && el.contains(e.relatedTarget))) return;
hideTip(el);
});
document.addEventListener('pointerdown', () => hideTip(), true);
document.addEventListener('focusin', (e) => {
const el = e.target?.closest?.(TIP_TARGETS);
if (el) showTip(el);
});
document.addEventListener('focusout', () => hideTip());
window.addEventListener('scroll', () => hideTip(), true);
window.addEventListener('resize', () => hideTip());
function closeSettingsPopovers() { function closeSettingsPopovers() {
document.querySelectorAll('.tile-settings').forEach((p) => { document.querySelectorAll('.tile-settings').forEach((p) => {
p._cleanup?.(); p._cleanup?.();
p._button?.classList.remove('is-open');
p.remove(); p.remove();
}); });
} }
function toggleSettingsPopover(tile, el) { /* ── the per-tile settings window ───────────────────────────────────── */
const existing = el.querySelector('.tile-settings');
/* It opens beside its tile — to the right when there is room, else the
left, else over it — and always inside the viewport. A window rather than
something inside the tile, because a tile can be smaller than its own
settings, and because the point of live settings is watching the tile
change while you change them: the window must not sit on top of it. */
function placePopover(pop, anchor) {
const vw = window.innerWidth;
const vh = window.innerHeight;
const pad = 8;
const w = pop.offsetWidth;
const h = pop.offsetHeight;
let left = anchor.right + pad;
if (left + w > vw - pad) left = anchor.left - w - pad;
if (left < pad) left = Math.min(Math.max(pad, anchor.left), Math.max(pad, vw - w - pad));
let top = anchor.top;
if (top + h > vh - pad) top = vh - h - pad;
if (top < pad) top = pad;
pop.style.left = Math.round(left) + 'px';
pop.style.top = Math.round(top) + 'px';
}
function clampPopover(pop) {
const pad = 8;
const maxLeft = Math.max(pad, window.innerWidth - pop.offsetWidth - pad);
const maxTop = Math.max(pad, window.innerHeight - pop.offsetHeight - pad);
pop.style.left = Math.min(Math.max(pad, pop.offsetLeft), maxLeft) + 'px';
pop.style.top = Math.min(Math.max(pad, pop.offsetTop), maxTop) + 'px';
}
/** Open (or close) the settings window for `tile`, beside its element `el`; `button` is the gear that asked. */
function toggleSettingsPopover(tile, el, button = null) {
const existing = document.querySelector(`.tile-settings[data-tile-id="${tile.id}"]`);
closeSettingsPopovers(); closeSettingsPopovers();
if (existing) return; if (existing) return;
const man = registry.get(tile.module); const man = registry.get(tile.module);
if (!man || !man.instanceSchema) return; if (!man) return;
const { schema, groups: groupMeta } = tileSchema(tile, man);
const pop = document.createElement('div'); const pop = document.createElement('div');
pop.className = 'tile-settings'; pop.className = 'tile-settings';
const inputs = new Map(); pop.dataset.tileId = tile.id;
pop.setAttribute('role', 'dialog');
pop.setAttribute('aria-label', `${tile.title || man.name} settings`);
// The gear that opened it wears the window's colour while it is open.
if (button) {
button.classList.add('is-open');
pop._button = button;
}
/* These are display choices — a text size, a filter, a colour — so they // A slim bar to move it by — nothing else. No title (the window sits beside
apply as they are changed and save with the layout. There is nothing to // the tile it belongs to) and no close button: a click anywhere else, or
confirm, so there is no Apply. Typing is debounced because applying // Escape, closes it.
means remounting the module, which shouldn't happen per keystroke. */ const bar = document.createElement('div');
bar.className = 'tile-settings-bar';
bar.title = 'Drag to move';
const body = document.createElement('div');
body.className = 'tile-settings-body';
pop.append(bar, body);
const inputs = new Map();
const shownWhen = []; // fields that appear only while another has a given value
/* Display choices apply as they change and save with the layout — nothing
to confirm, so no Apply. Typing is debounced because applying means
remounting the module, which shouldn't happen per keystroke. */
const same = (a, b) => a === b
|| (Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((v, i) => v === b[i]));
let applyTimer = null; let applyTimer = null;
const commit = (delay) => { const commit = (delay) => {
clearTimeout(applyTimer); clearTimeout(applyTimer);
@@ -1111,11 +1325,11 @@ function toggleSettingsPopover(tile, el) {
applyTimer = null; applyTimer = null;
// Only when something really moved: a text field fires `input` and then // Only when something really moved: a text field fires `input` and then
// `change` on the way out, and a module shouldn't be torn down twice // `change` on the way out, and a module shouldn't be torn down twice
// over for one edit. // over for one edit. A reader answering undefined has nothing to say.
let changed = false; let changed = false;
for (const [key, read] of inputs) { for (const [key, read] of inputs) {
const value = read(); const value = read();
if (tile.settings[key] === value) continue; if (value === undefined || same(tile.settings[key], value)) continue;
tile.settings[key] = value; tile.settings[key] = value;
changed = true; changed = true;
} }
@@ -1127,19 +1341,31 @@ function toggleSettingsPopover(tile, el) {
const live = (input, event = 'change', delay = 0) => { const live = (input, event = 'change', delay = 0) => {
input.addEventListener(event, () => commit(delay)); input.addEventListener(event, () => commit(delay));
}; };
/** Choices a module only knows at runtime, from one of its own routes. */
const fetchOptions = async (route) => {
try {
const res = await fetchWithTimeout(`/api/modules/${encodeURIComponent(man.id)}${route}`, 8000);
const data = res.ok ? await res.json() : null;
return Array.isArray(data?.options) ? data.options : null;
} catch {
return null;
}
};
const optValue = (opt) => String(typeof opt === 'object' ? opt.value : opt);
const optLabel = (opt) => String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt);
// Fields render in schema order; a field's `group` starts a labeled // Fields render in schema order; a field's `group` starts a labeled
// subsection that consecutive same-group fields share, and the manifest's // subsection that consecutive same-group fields share, and the groups map
// optional `instanceGroups[name]` adds { collapsed, columns, help } — // adds { collapsed, columns, help, toggle } — the same conventions as the
// the same conventions as the admin page's config form. // admin page's config form.
const groupMeta = man.instanceGroups && typeof man.instanceGroups === 'object' ? man.instanceGroups : {};
let groupName = null; let groupName = null;
let container = pop; let container = body;
let current = null; // the open group: { el, title, toggleKey }
for (const [key, spec] of Object.entries(man.instanceSchema)) { for (const [key, spec] of Object.entries(schema)) {
const type = spec?.type || 'string'; const type = spec?.type || 'string';
const label = spec?.label || key; const label = spec?.label || key;
const current = key in (tile.settings || {}) ? tile.settings[key] : spec?.default; const value = key in (tile.settings || {}) ? tile.settings[key] : spec?.default;
const g = spec?.group || ''; const g = spec?.group || '';
if (g !== groupName) { if (g !== groupName) {
@@ -1152,33 +1378,41 @@ function toggleSettingsPopover(tile, el) {
if (folds) group.open = !meta.collapsed; if (folds) group.open = !meta.collapsed;
const title = document.createElement(folds ? 'summary' : 'div'); const title = document.createElement(folds ? 'summary' : 'div');
title.className = 'field-group-title'; title.className = 'field-group-title';
title.textContent = g; const titleText = document.createElement('span');
titleText.className = 'field-group-name';
titleText.textContent = g;
title.appendChild(titleText);
group.appendChild(title); group.appendChild(title);
const body = document.createElement('div'); const groupBody = document.createElement('div');
body.className = 'field-group-body'; groupBody.className = 'field-group-body';
const cols = Math.min(4, Math.trunc(Number(meta.columns)) || 0); const cols = Math.min(4, Math.trunc(Number(meta.columns)) || 0);
if (cols > 1) { if (cols > 1) {
body.classList.add('is-columns'); groupBody.classList.add('is-columns');
body.style.setProperty('--cols', String(cols)); groupBody.style.setProperty('--cols', String(cols));
} }
if (meta.help) { if (meta.help) {
const help = document.createElement('div'); const help = document.createElement('div');
help.className = 'field-group-help'; help.className = 'field-group-help';
help.textContent = meta.help; help.textContent = meta.help;
body.appendChild(help); groupBody.appendChild(help);
} }
group.appendChild(body); group.appendChild(groupBody);
pop.appendChild(group); body.appendChild(group);
pop.classList.add('has-groups'); pop.classList.add('has-groups');
container = body; container = groupBody;
current = { el: group, title, toggleKey: typeof meta.toggle === 'string' ? meta.toggle : '' };
} else { } else {
container = pop; container = body;
current = null;
} }
} }
const isSwitch = type === 'boolean' || type === 'switch'; const isSwitch = type === 'boolean' || type === 'switch';
const field = document.createElement('label'); // A multiselect holds several controls, so it can't be a <label>: a click
// anywhere in one would flip the first control.
const field = document.createElement(type === 'multiselect' ? 'div' : 'label');
field.className = 'field' + (isSwitch ? ' check' : ''); field.className = 'field' + (isSwitch ? ' check' : '');
let switchInput = null;
if (isSwitch) { if (isSwitch) {
// A switch: on is shown/enabled, off is hidden/disabled — the label // A switch: on is shown/enabled, off is hidden/disabled — the label
@@ -1191,27 +1425,104 @@ function toggleSettingsPopover(tile, el) {
toggle.className = 'switch'; toggle.className = 'switch';
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'checkbox'; input.type = 'checkbox';
input.checked = Boolean(current); input.checked = Boolean(value);
input.setAttribute('aria-label', label);
const track = document.createElement('span'); const track = document.createElement('span');
track.className = 'track'; track.className = 'track';
toggle.append(input, track); toggle.append(input, track);
field.append(text, toggle); field.append(text, toggle);
inputs.set(key, () => input.checked); inputs.set(key, () => input.checked);
live(input); live(input);
} else if (type === 'select' && Array.isArray(spec.options)) { switchInput = input;
} else if (type === 'select' && (Array.isArray(spec.options) || spec.optionsRoute)) {
const span = document.createElement('span'); const span = document.createElement('span');
span.textContent = label; span.textContent = label;
const select = document.createElement('select'); const select = document.createElement('select');
for (const opt of spec.options) { const saved = value === undefined || value === null ? '' : String(value);
const fill = (options) => {
const wanted = select.options.length ? select.value : saved;
select.innerHTML = '';
// What this tile saved stays choosable even when the live list has
// moved on (a source that is off right now, say).
if (saved && !options.some((o) => optValue(o) === saved)) {
options = [{ value: saved, label: `${saved} (saved)` }, ...options];
}
for (const opt of options) {
const o = document.createElement('option'); const o = document.createElement('option');
o.value = String(typeof opt === 'object' ? opt.value : opt); o.value = optValue(opt);
o.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt); o.textContent = optLabel(opt);
select.appendChild(o); select.appendChild(o);
} }
select.value = String(current ?? ''); select.value = options.some((o) => optValue(o) === wanted) ? wanted : saved;
};
fill(Array.isArray(spec.options) ? spec.options : []);
if (spec.optionsRoute) fetchOptions(spec.optionsRoute).then((opts) => { if (opts) fill(opts); });
field.append(span, select); field.append(span, select);
inputs.set(key, () => select.value); inputs.set(key, () => select.value);
live(select); live(select);
} else if (type === 'multiselect' && (Array.isArray(spec.options) || spec.optionsRoute)) {
// Several picks from a list, one switch each, stored as an array. With
// `allByDefault`, a tile that has never chosen shows every option on
// and saves nothing until someone flips one — so options that turn up
// later are on as well, until there is an explicit list.
field.classList.add('multiselect-field');
const span = document.createElement('span');
span.className = 'field-label';
span.textContent = label;
const list = document.createElement('div');
list.className = 'multiselect';
const cols = Math.min(4, Math.trunc(Number(spec.columns)) || 0);
if (cols > 1) {
list.classList.add('is-columns');
list.style.setProperty('--cols', String(cols));
}
const saved = Array.isArray(value) ? value.map(String) : null; // null: never chosen
let touched = false;
const chosen = new Set(saved || []);
const fill = (options) => {
for (const input of list.querySelectorAll('input')) {
if (input.checked) chosen.add(input.value);
else chosen.delete(input.value);
}
list.innerHTML = '';
const missing = [...chosen].filter((v) => !options.some((o) => optValue(o) === v));
const all = [...options, ...missing.map((v) => ({ value: v, label: `${v} (saved — not listed now)` }))];
for (const opt of all) {
const row = document.createElement('label');
row.className = 'multiselect-row';
const text = document.createElement('span');
text.className = 'check-label';
text.textContent = optLabel(opt);
const toggle = document.createElement('span');
toggle.className = 'switch';
const input = document.createElement('input');
input.type = 'checkbox';
input.value = optValue(opt);
input.checked = saved === null && !touched && spec.allByDefault ? true : chosen.has(input.value);
if (input.checked) chosen.add(input.value);
const track = document.createElement('span');
track.className = 'track';
toggle.append(input, track);
row.append(text, toggle);
input.addEventListener('change', () => {
touched = true;
commit(0);
});
list.appendChild(row);
}
if (!all.length) {
const none = document.createElement('div');
none.className = 'multiselect-empty';
none.textContent = 'Nothing to choose from yet.';
list.appendChild(none);
}
};
fill(Array.isArray(spec.options) ? spec.options : []);
if (spec.optionsRoute) fetchOptions(spec.optionsRoute).then((opts) => { if (opts) fill(opts); });
field.append(span, list);
inputs.set(key, () => (saved === null && !touched
? undefined
: [...list.querySelectorAll('input:checked')].map((input) => input.value)));
} else if (type === 'color') { } else if (type === 'color') {
// A swatch picker; the value is always a #rrggbb string. // A swatch picker; the value is always a #rrggbb string.
field.classList.add('color-field'); field.classList.add('color-field');
@@ -1219,16 +1530,54 @@ function toggleSettingsPopover(tile, el) {
span.textContent = label; span.textContent = label;
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'color'; input.type = 'color';
input.value = colorHex(current, colorHex(spec?.default, '#2ee59a')); input.value = colorHex(value, colorHex(spec?.default, '#2ee59a'));
field.append(span, input); field.append(span, input);
inputs.set(key, () => input.value); inputs.set(key, () => input.value);
live(input, 'input', 150); // dragging the picker fires continuously live(input, 'input', 150); // dragging the picker fires continuously
} else if (type === 'template') {
// Text with placeholders, built by dragging (or clicking) the variables
// the module offers into it — nobody should have to remember the names.
field.classList.add('template-field');
const span = document.createElement('span');
span.textContent = label;
const input = document.createElement('input');
input.type = 'text';
input.spellcheck = false;
input.value = value === undefined || value === null ? '' : String(value);
const vars = document.createElement('div');
vars.className = 'template-vars';
for (const v of Array.isArray(spec.variables) ? spec.variables : []) {
const chip = document.createElement('button');
chip.type = 'button';
chip.className = 'template-var';
chip.draggable = true;
const token = optValue(v);
chip.textContent = optLabel(v);
chip.title = `Drag or click to insert ${token}`;
chip.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', token);
e.dataTransfer.effectAllowed = 'copy';
});
chip.addEventListener('click', (e) => {
e.preventDefault();
const start = input.selectionStart ?? input.value.length;
const end = input.selectionEnd ?? start;
input.setRangeText(token, start, end, 'end');
input.focus();
commit(0);
});
vars.appendChild(chip);
}
field.append(span, input, vars);
inputs.set(key, () => input.value);
live(input, 'input', 400);
live(input, 'change');
} else { } else {
const span = document.createElement('span'); const span = document.createElement('span');
span.textContent = label; span.textContent = label;
const input = document.createElement('input'); const input = document.createElement('input');
input.type = type === 'number' ? 'number' : (type === 'password' ? 'password' : 'text'); input.type = type === 'number' ? 'number' : (type === 'password' ? 'password' : 'text');
input.value = current === undefined || current === null ? '' : String(current); input.value = value === undefined || value === null ? '' : String(value);
field.append(span, input); field.append(span, input);
inputs.set(key, () => (type === 'number' ? Number(input.value) : input.value)); inputs.set(key, () => (type === 'number' ? Number(input.value) : input.value));
live(input, 'input', 400); // mid-word is not the moment to remount live(input, 'input', 400); // mid-word is not the moment to remount
@@ -1240,23 +1589,115 @@ function toggleSettingsPopover(tile, el) {
help.textContent = spec.help; help.textContent = spec.help;
field.appendChild(help); field.appendChild(help);
} }
if (typeof spec?.showWhen === 'string' && spec.showWhen) shownWhen.push({ field, key: spec.showWhen });
if (current && switchInput && key === current.toggleKey) {
// The group's on/off lives in its title row, never in the body, and a
// click on it must not fold a <details>.
const grp = current;
field.classList.add('group-toggle');
field.addEventListener('click', (e) => e.stopPropagation());
const sync = () => grp.el.classList.toggle('is-off', !switchInput.checked);
switchInput.addEventListener('change', sync);
sync();
grp.title.appendChild(field);
} else {
container.appendChild(field); container.appendChild(field);
} }
}
// Nothing to press: click away, press Escape, or click the gear again. /* The tint sits on its own at the foot of the window, under a hairline —
it is the shell's setting, not one of the module's. Unlike those, a
change needs no remount: nothing but the tile's own chrome reads it. */
{
const field = document.createElement('div');
field.className = 'field color-field tint-field'; // the swatch reads like a module's colour field
const text = document.createElement('span');
text.textContent = 'Tile tint';
const input = document.createElement('input');
input.type = 'color';
input.setAttribute('aria-label', 'Tile tint');
const clear = document.createElement('button');
clear.type = 'button';
clear.className = 'tint-clear';
clear.textContent = 'Clear';
clear.title = 'No tint';
const paint = () => {
const hex = colorHex(tile.settings[TINT_KEY], '');
// With no tint the swatch opens on the panel colour, so the picker
// starts somewhere sane rather than at black.
input.value = hex || colorHex(getComputedStyle(document.documentElement).getPropertyValue('--panel'), '#1a1d21');
clear.hidden = !hex;
};
const set = (hex) => {
if (hex) tile.settings[TINT_KEY] = hex;
else delete tile.settings[TINT_KEY];
applyTint(tile);
paint();
saveLayout();
};
input.addEventListener('input', () => set(colorHex(input.value, '')));
clear.addEventListener('click', () => set(''));
paint();
field.append(text, clear, input);
body.appendChild(field);
}
// "Short day" appears once "Date" is on: a field with showWhen:"K" follows
// K's switch, live — the same rule the admin page applies.
if (shownWhen.length) {
const refresh = () => {
for (const rule of shownWhen) {
const read = inputs.get(rule.key);
rule.field.hidden = read ? !read() : false;
}
};
refresh();
body.addEventListener('change', refresh);
body.addEventListener('input', refresh);
}
// Nothing to press: click away, press Escape, or the gear again.
// (The gear's own click stops propagating, so it toggles rather than // (The gear's own click stops propagating, so it toggles rather than
// closing and reopening.) // closing and reopening.)
const closeOnClick = (e) => { if (!pop.contains(e.target)) closeSettingsPopovers(); }; const closeOnClick = (e) => { if (!pop.contains(e.target)) closeSettingsPopovers(); };
const closeOnEscape = (e) => { if (e.key === 'Escape') closeSettingsPopovers(); }; const closeOnEscape = (e) => { if (e.key === 'Escape') closeSettingsPopovers(); };
const keepInView = () => clampPopover(pop);
pop._cleanup = () => { pop._cleanup = () => {
document.removeEventListener('click', closeOnClick); document.removeEventListener('click', closeOnClick);
document.removeEventListener('keydown', closeOnEscape); document.removeEventListener('keydown', closeOnEscape);
window.removeEventListener('resize', keepInView);
}; };
document.addEventListener('click', closeOnClick); document.addEventListener('click', closeOnClick);
document.addEventListener('keydown', closeOnEscape); document.addEventListener('keydown', closeOnEscape);
window.addEventListener('resize', keepInView);
// Moved by its bar.
bar.addEventListener('pointerdown', (e) => {
if (e.button !== 0 && e.pointerType === 'mouse') return;
e.preventDefault();
bar.setPointerCapture(e.pointerId);
const dx = e.clientX - pop.offsetLeft;
const dy = e.clientY - pop.offsetTop;
const onMove = (ev) => {
pop.style.left = (ev.clientX - dx) + 'px';
pop.style.top = (ev.clientY - dy) + 'px';
clampPopover(pop);
};
const onUp = () => {
bar.removeEventListener('pointermove', onMove);
bar.removeEventListener('pointerup', onUp);
bar.removeEventListener('pointercancel', onUp);
};
bar.addEventListener('pointermove', onMove);
bar.addEventListener('pointerup', onUp);
bar.addEventListener('pointercancel', onUp);
});
pop.addEventListener('pointerdown', (e) => e.stopPropagation()); pop.addEventListener('pointerdown', (e) => e.stopPropagation());
el.appendChild(pop); document.body.appendChild(pop);
placePopover(pop, el.getBoundingClientRect());
} }
/** Tear a tile's module down and start it again (settings changed). */ /** Tear a tile's module down and start it again (settings changed). */
@@ -1316,16 +1757,20 @@ function renderAddMenu(menu) {
title.textContent = man.name; title.textContent = man.name;
menu.appendChild(title); menu.appendChild(title);
for (const entry of entries) { for (const entry of entries) {
// An entry that allows one tile at most is offered greyed out once
// that tile exists — a second Send box would only confuse.
const placed = entry.single && tiles.some((t) => t.module === man.id && t.variant === String(entry.id));
// Descriptions ride along as tooltips: the list stays a list.
menu.appendChild(menuItem(entry.name, () => { menu.appendChild(menuItem(entry.name, () => {
menu.hidden = true; menu.hidden = true;
addTile(man.id, entry); addTile(man.id, entry);
}, { sub: entry.description || '' })); }, { disabled: placed, title: placed ? 'Already on this dashboard' : (entry.description || '') }));
} }
} else { } else {
menu.appendChild(menuItem(man.name, () => { menu.appendChild(menuItem(man.name, () => {
menu.hidden = true; menu.hidden = true;
addTile(man.id); addTile(man.id);
}, { sub: man.description || '' })); }, { title: man.description || '' }));
} }
} }
} }
@@ -1620,20 +2065,14 @@ new ResizeObserver(() => {
config or enabled flags change. Tiles of a changed module get config or enabled flags change. Tiles of a changed module get
onConfigChange(cfg) if they implement it, otherwise a clean remount. */ onConfigChange(cfg) if they implement it, otherwise a clean remount. */
let eventsSource = null;
let eventsRetry = null;
function subscribeEvents() { function subscribeEvents() {
try { eventsSource?.close(); } catch { /* not open */ } // Rides on the page's one stream connection (see sseSubscribe) as `shell:`.
eventsSource = new EventSource('/api/events'); sseSubscribe('/api/events', {
eventsSource.addEventListener('modules-changed', () => handleModulesChanged()); events: {
eventsSource.addEventListener('theme', handleThemeEvent); 'modules-changed': () => handleModulesChanged(),
eventsSource.onerror = () => { theme: handleThemeEvent,
if (eventsSource.readyState === EventSource.CLOSED) { },
clearTimeout(eventsRetry); });
eventsRetry = setTimeout(subscribeEvents, 4000);
}
};
} }
async function handleModulesChanged() { async function handleModulesChanged() {
+147 -22
View File
@@ -170,6 +170,7 @@ body.menu-hidden #topbar {
margin-top: 2px; margin-top: 2px;
white-space: normal; white-space: normal;
} }
.menu-item[disabled] { opacity: 0.45; cursor: default; }
.menu-item.danger { color: var(--danger); } .menu-item.danger { color: var(--danger); }
.menu-note { .menu-note {
padding: 8px 10px; padding: 8px 10px;
@@ -233,6 +234,18 @@ body.menu-hidden #topbar {
overflow: hidden; overflow: hidden;
} }
/* A tile the user gave a colour: a wash over the panel, a stronger edge and
a tinted title bar. Module content keeps its own colours — this is the
tile's chrome, so a tinted tile still reads like every other one. */
.tile.is-tinted {
background: color-mix(in srgb, var(--tile-tint) 14%, var(--panel));
border-color: color-mix(in srgb, var(--tile-tint) 45%, var(--border));
}
.tile.is-tinted > .tile-head {
background: color-mix(in srgb, var(--tile-tint) 20%, transparent);
border-bottom-color: color-mix(in srgb, var(--tile-tint) 35%, var(--border));
}
.tile.dragging, .tile.dragging,
.tile.resizing { .tile.resizing {
z-index: 40; z-index: 40;
@@ -305,6 +318,26 @@ body.menu-hidden #topbar {
.tile-btn:hover { background: var(--border); color: var(--text); } .tile-btn:hover { background: var(--border); color: var(--text); }
.tile-btn.close:hover { color: var(--danger); } .tile-btn.close:hover { color: var(--danger); }
.tile-btn.active { color: var(--accent); } .tile-btn.active { color: var(--accent); }
.tile-btn.is-open,
.tile-btn.is-open:hover { background: var(--accent); color: var(--accent-text); }
/* the shell's tooltip: shown at once under the button the pointer is on */
#tip {
position: fixed;
z-index: 200;
pointer-events: none;
padding: 3px 8px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--panel);
color: var(--text);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
white-space: nowrap;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
}
#tip[hidden] { display: none; }
.tile-btn svg { width: 13px; height: 13px; display: block; } .tile-btn svg { width: 13px; height: 13px; display: block; }
/* controls a module adds to its own title bar (moduleApi.header) */ /* controls a module adds to its own title bar (moduleApi.header) */
@@ -533,28 +566,122 @@ body.fullscreen #grid { padding-bottom: 0; }
/* ── per-tile settings popover ─────────────────────────────────────── */ /* ── per-tile settings popover ─────────────────────────────────────── */
/* A small window beside its tile (shell.js places and clamps it), not a
box inside the tile: a tile can be smaller than its own settings, and the
whole point of live settings is watching the tile change. */
.tile-settings { .tile-settings {
position: absolute; position: fixed;
top: 36px; z-index: 80;
right: 6px; display: flex;
z-index: 45; flex-direction: column;
min-width: 220px; /* as wide as its content wants and no wider: a couple of switches make a
max-width: calc(100% - 12px); small window, a three-column checklist a wide one */
max-height: calc(100% - 40px); width: max-content;
overflow-y: auto; min-width: 150px;
max-width: min(560px, calc(100vw - 16px));
max-height: calc(100vh - 16px);
overflow: hidden;
background: var(--panel); background: var(--panel);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 10px; border-radius: 10px;
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.55);
}
/* the grab bar: a slim accent strip with a grip mark, nothing to read or
press — the gear that opened the window wears the same colour meanwhile */
.tile-settings-bar {
flex: none;
height: 14px;
display: flex;
align-items: center;
justify-content: center;
background: var(--accent);
cursor: grab;
user-select: none;
-webkit-user-select: none;
touch-action: none;
}
.tile-settings-bar::before {
content: "";
width: 34px;
height: 4px;
border-radius: 2px;
background: var(--accent-text);
opacity: 0.7;
transition: opacity 0.12s;
}
.tile-settings-bar:hover::before { opacity: 1; }
.tile-settings .field[hidden] { display: none; }
/* the tint, the shell's own setting, under a hairline at the foot */
.tile-settings .tint-field {
margin: 8px 0 0;
padding-top: 9px;
border-top: 1px solid var(--border);
gap: 8px;
}
.tile-settings .tint-clear {
flex: none;
padding: 1px 7px;
border: 1px solid var(--border);
border-radius: 999px;
background: none;
color: var(--muted);
font: inherit;
font-size: 11px;
cursor: pointer;
}
.tile-settings .tint-clear:hover { color: var(--text); background: var(--border); }
.tile-settings .tint-clear[hidden] { display: none; }
.tile-settings-bar:active { cursor: grabbing; }
.tile-settings-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 10px; padding: 10px;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55);
} }
/* Help lines never widen the window: they take no room of their own when the
content is measured, then wrap to whatever width the fields settled on. */
.tile-settings .field-help,
.tile-settings .field-group-help,
.tile-settings .multiselect-empty { width: 0; min-width: 100%; }
/* a group's on/off sits in its title row; the body dims while it is off */
.tile-settings .field-group-title { display: flex; align-items: center; gap: 8px; }
.tile-settings .field-group-name { flex: 1; min-width: 0; }
.tile-settings .group-toggle { margin: 0 0 0 auto; }
.tile-settings .group-toggle .check-label { display: none; }
.tile-settings .field-group.is-off .field-group-body { opacity: 0.45; }
/* several picks from a list (type `multiselect`) */
.tile-settings .multiselect-field { display: flex; flex-direction: column; gap: 4px; }
.tile-settings .multiselect { display: flex; flex-direction: column; gap: 3px; }
.tile-settings .multiselect.is-columns {
display: grid;
grid-template-columns: repeat(var(--cols, 2), minmax(0, 1fr));
gap: 3px 14px;
}
.tile-settings .multiselect-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text);
cursor: pointer;
}
.tile-settings .multiselect-row .check-label { flex: 1 1 0; min-width: 0; }
.tile-settings .multiselect-empty { color: var(--muted); font-size: 12px; }
/* a text built from offered variables (type `template`) */
.tile-settings .template-vars { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 2px; }
.tile-settings .template-var {
font: inherit;
font-size: 11px;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
cursor: grab;
}
.tile-settings .template-var:hover { border-color: var(--accent); }
.tile-settings .template-var:active { cursor: grabbing; }
/* A popover with grouped settings needs room for its columns; it still
never grows past the tile (max-width above). */
.tile-settings.has-groups {
min-width: min(540px, calc(100% - 12px));
container-type: inline-size;
}
.tile-settings .field-group { .tile-settings .field-group {
margin: 0 0 10px; margin: 0 0 10px;
padding: 8px 10px; padding: 8px 10px;
@@ -592,7 +719,7 @@ body.fullscreen #grid { padding-bottom: 0; }
.tile-settings .field-group .field:last-child { margin-bottom: 0; } .tile-settings .field-group .field:last-child { margin-bottom: 0; }
/* Settings apply as they change, so nothing sits under the last field — its /* Settings apply as they change, so nothing sits under the last field — its
bottom margin would only pad the popover's own padding a second time. */ bottom margin would only pad the popover's own padding a second time. */
.tile-settings > :last-child { margin-bottom: 0; } .tile-settings-body > :last-child { margin-bottom: 0; }
.tile-settings .field-group-body.is-columns { .tile-settings .field-group-body.is-columns {
display: grid; display: grid;
grid-template-columns: repeat(var(--cols, 2), minmax(0, 1fr)); grid-template-columns: repeat(var(--cols, 2), minmax(0, 1fr));
@@ -600,12 +727,10 @@ body.fullscreen #grid { padding-bottom: 0; }
} }
.tile-settings .field-group-body.is-columns .field { margin-bottom: 0; } .tile-settings .field-group-body.is-columns .field { margin-bottom: 0; }
.tile-settings .field-group-body.is-columns .field-group-help { grid-column: 1 / -1; } .tile-settings .field-group-body.is-columns .field-group-help { grid-column: 1 / -1; }
@container (max-width: 420px) { /* a list of switches or a template with its chips is a row of its own, however
.tile-settings .field-group-body.is-columns { grid-template-columns: 1fr 1fr; } many columns the group lays its other fields out in */
} .tile-settings .field-group-body.is-columns .multiselect-field,
@container (max-width: 260px) { .tile-settings .field-group-body.is-columns .template-field { grid-column: 1 / -1; }
.tile-settings .field-group-body.is-columns { grid-template-columns: 1fr; }
}
.tile-settings .field-group-help, .tile-settings .field-group-help,
.tile-settings .field-help { .tile-settings .field-help {
display: block; display: block;
+203 -6
View File
@@ -13,6 +13,8 @@
* proddash.json in the data directory (this machine's overrides), or env vars: * proddash.json in the data directory (this machine's overrides), or env vars:
* PORT (default: value in proddash.json, else 24500) * PORT (default: value in proddash.json, else 24500)
* PRODDASH_PASSCODE (overrides adminPasscode in proddash.json) * PRODDASH_PASSCODE (overrides adminPasscode in proddash.json)
* PRODDASH_FORGE (overrides `forge`: "github", or a Gitea/Forgejo base
* URL such as http://10.3.8.90:3000)
* PRODDASH_DATA_DIR (where module config + layouts are kept; see below) * PRODDASH_DATA_DIR (where module config + layouts are kept; see below)
*/ */
'use strict'; 'use strict';
@@ -463,7 +465,20 @@ function sanitizeTileEntry(raw) {
name: String(raw.name), name: String(raw.name),
description: raw.description ? String(raw.description) : '', description: raw.description ? String(raw.description) : '',
settings: raw.settings && typeof raw.settings === 'object' ? raw.settings : {}, settings: raw.settings && typeof raw.settings === 'object' ? raw.settings : {},
// at most one tile of this entry per dashboard (the picker greys it out)
single: raw.single === true,
}; };
// An entry may offer its own gear settings — a solo timer wants none of a
// full tile's switches. Same shape as the manifest's instanceSchema/Groups.
for (const key of ['instanceSchema', 'instanceGroups']) {
if (raw[key] && typeof raw[key] === 'object') out[key] = raw[key];
}
// Earlier variant ids this entry stands for, so a tile added under one of
// them (a layout from an older module version) gets this entry's settings.
if (Array.isArray(raw.aliases)) {
const aliases = raw.aliases.map((a) => String(a)).filter(Boolean);
if (aliases.length) out.aliases = aliases;
}
for (const key of ['minSize', 'defaultSize']) { for (const key of ['minSize', 'defaultSize']) {
const size = raw[key]; const size = raw[key];
if (size && Number(size.w) > 0 && Number(size.h) > 0) { if (size && Number(size.w) > 0 && Number(size.h) > 0) {
@@ -546,6 +561,142 @@ setInterval(() => {
} }
}, 25000).unref(); }, 25000).unref();
/* ── one stream per page ─────────────────────────────────────────────
Browsers allow about six HTTP/1.1 connections to a host and an open
EventSource holds one for as long as it lives. A dashboard with six
modules ran out — Safari first — and from then on every further request
(a remounted tile's /state, an avatar) waited in the browser's queue for
ever. So a page opens ONE EventSource, GET /api/stream?s=<keys>, naming
the streams it wants: `slack:/stream`, `pco-plan:/timers/stream`, `shell:`
for the shell's own events. The server opens each module stream for it
over loopback (the module's route is untouched and still serves curl and
other tools), and forwards every frame with the event name prefixed by
its key — `event: slack:/stream|state`. Two frames of its own bracket a
stream's life: `<key>|__open` when the upstream answers, `<key>|__down`
when it ends (it is re-opened after a pause while the page stays). */
const MUX_MAX_STREAMS = 32;
const MUX_RETRY_MS = 3000;
const MUX_RETRY_REFUSED_MS = 10000;
const MUX_PING_MS = 15000;
const MUX_KEY_RE = /^([A-Za-z0-9_-]+):(\/[A-Za-z0-9_\-./]*)?$/;
function parseMuxKeys(search) {
const params = new URLSearchParams(search || '');
const out = [];
const seen = new Set();
for (const raw of String(params.get('s') || '').split(',')) {
const key = raw.trim();
const m = MUX_KEY_RE.exec(key);
if (!m || seen.has(key)) continue;
seen.add(key);
out.push({ key, id: m[1], subPath: m[2] || '' });
if (out.length >= MUX_MAX_STREAMS) break;
}
return out;
}
function handleMuxStream(req, res, search) {
const wanted = parseMuxKeys(search);
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
});
res.write('retry: 3000\n\n');
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
let closed = false;
const send = (frame) => {
if (closed) return;
try {
res.write(frame);
} catch {
/* the client is gone; 'close' follows */
}
};
// The shell's own events ride along under `shell:` — a stand-in sits in
// shellStreams and rewrites the event line of every frame it is handed.
let shellProxy = null;
const upstreams = [];
for (const entry of wanted) {
if (entry.id === 'shell') {
if (shellProxy) continue;
shellProxy = { write(frame) { send(String(frame).replace(/^event: /m, `event: ${entry.key}|`)); } };
shellStreams.add(shellProxy);
send(`event: ${entry.key}|__open\ndata: {}\n\n`);
continue;
}
upstreams.push({ ...entry, req: null, timer: null, gone: false });
}
function forwardBlock(entry, block) {
let name = 'message';
const data = [];
for (const line of block.split('\n')) {
if (line.startsWith(':')) continue; // the upstream's keepalive; this connection pings on its own
if (line.startsWith('event:')) name = line.slice(6).trim() || 'message';
else if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, ''));
// id: and retry: belong to the upstream connection, not to the page's
}
if (!data.length) return;
send(`event: ${entry.key}|${name}\n${data.map((d) => `data: ${d}`).join('\n')}\n\n`);
}
function down(entry, why, delay) {
if (closed || entry.gone) return;
entry.gone = true;
send(`event: ${entry.key}|__down\ndata: ${JSON.stringify({ why })}\n\n`);
try { entry.req?.destroy(); } catch { /* already gone */ }
entry.req = null;
entry.timer = setTimeout(() => {
entry.gone = false;
open(entry);
}, delay);
}
function open(entry) {
if (closed) return;
const path = `/api/modules/${encodeURIComponent(entry.id)}${entry.subPath || '/'}`;
const up = http.request({ host: '127.0.0.1', port: PORT, path, method: 'GET', headers: { accept: 'text/event-stream' } }, (upRes) => {
if (upRes.statusCode !== 200 || !/text\/event-stream/.test(String(upRes.headers['content-type'] || ''))) {
upRes.resume();
return down(entry, `HTTP ${upRes.statusCode}`, MUX_RETRY_REFUSED_MS);
}
send(`event: ${entry.key}|__open\ndata: {}\n\n`);
let buf = '';
upRes.setEncoding('utf8');
upRes.on('data', (chunk) => {
buf += chunk;
let i;
while ((i = buf.indexOf('\n\n')) >= 0) {
forwardBlock(entry, buf.slice(0, i));
buf = buf.slice(i + 2);
}
if (buf.length > 1e6) buf = ''; // a frame that never ends is dropped, not kept
});
upRes.on('end', () => down(entry, 'ended', MUX_RETRY_MS));
upRes.on('error', (err) => down(entry, err.message, MUX_RETRY_MS));
});
up.on('error', (err) => down(entry, err.message, MUX_RETRY_MS));
up.end();
entry.req = up;
}
for (const entry of upstreams) open(entry);
const ping = setInterval(() => send(': ping\n\n'), MUX_PING_MS);
req.on('close', () => {
closed = true;
clearInterval(ping);
if (shellProxy) shellStreams.delete(shellProxy);
for (const entry of upstreams) {
clearTimeout(entry.timer);
try { entry.req?.destroy(); } catch { /* already gone */ }
}
});
}
/* ── themes ─────────────────────────────────────────────────────────── */ /* ── themes ─────────────────────────────────────────────────────────── */
/* Preset palettes for the whole dashboard, chosen in Admin → Theme. The /* Preset palettes for the whole dashboard, chosen in Admin → Theme. The
@@ -616,14 +767,52 @@ function parseRepoSetting(value, fallbackBranch) {
const REPO = parseRepoSetting(process.env.PRODDASH_REPO || shellConfig.repo, shellConfig.branch) const REPO = parseRepoSetting(process.env.PRODDASH_REPO || shellConfig.repo, shellConfig.branch)
|| parseRepoSetting('michaelmcgary2008/ProdDash', 'main'); || parseRepoSetting('michaelmcgary2008/ProdDash', 'main');
/**
* Which server the code comes from. GitHub unless `forge` names another —
* give it a Gitea (or Forgejo) base URL, "http://10.3.8.90:3000", and updates
* and module installs come from there instead. The two hosts differ only in
* how an archive URL is spelled: the tarball itself is the same, pax header
* and commit sha included, so everything downstream is untouched.
*/
function parseForge(value) {
const s = String(value || '').trim().replace(/\/+$/, '');
if (!s || s.toLowerCase() === 'github') {
return { kind: 'github', base: 'https://github.com', archiveBase: 'https://codeload.github.com' };
}
if (!/^https?:\/\//i.test(s)) return null;
// A GitHub URL still means codeload for archives.
if (/^https?:\/\/(www\.)?github\.com$/i.test(s)) {
return { kind: 'github', base: s, archiveBase: 'https://codeload.github.com' };
}
return { kind: 'gitea', base: s, archiveBase: s };
}
const FORGE = parseForge(process.env.PRODDASH_FORGE || shellConfig.forge) || parseForge('github');
/** Where branch archives come from; tests point this at a local mock. */ /** Where branch archives come from; tests point this at a local mock. */
const ARCHIVE_BASE = String(process.env.PRODDASH_ARCHIVE_BASE || 'https://codeload.github.com').replace(/\/+$/, ''); const ARCHIVE_BASE = String(process.env.PRODDASH_ARCHIVE_BASE || FORGE.archiveBase).replace(/\/+$/, '');
const REMOTE_TTL_MS = 10 * 60 * 1000; const REMOTE_TTL_MS = 10 * 60 * 1000;
const REMOTE_CHECK_MS = 6 * 60 * 60 * 1000; const REMOTE_CHECK_MS = 6 * 60 * 60 * 1000;
const MAX_ARCHIVE_BYTES = 80 * 1024 * 1024; const MAX_ARCHIVE_BYTES = 80 * 1024 * 1024;
function tarballUrl() { function tarballUrl() {
return `${ARCHIVE_BASE}/${REPO.owner}/${REPO.repo}/tar.gz/${encodeURIComponent(REPO.branch)}`; const branch = encodeURIComponent(REPO.branch);
return FORGE.kind === 'gitea'
? `${ARCHIVE_BASE}/${REPO.owner}/${REPO.repo}/archive/${branch}.tar.gz`
: `${ARCHIVE_BASE}/${REPO.owner}/${REPO.repo}/tar.gz/${branch}`;
}
/** The page a human should read to see what changed between two commits. */
function forgeCompareUrl(from, to) {
return `${FORGE.base}/${REPO.owner}/${REPO.repo}/compare/${from}...${to}`;
}
/** The commit list for the branch we follow. */
function forgeCommitsUrl() {
const branch = encodeURIComponent(REPO.branch);
return FORGE.kind === 'gitea'
? `${FORGE.base}/${REPO.owner}/${REPO.repo}/commits/branch/${branch}`
: `${FORGE.base}/${REPO.owner}/${REPO.repo}/commits/${branch}`;
} }
function httpError(status, message) { function httpError(status, message) {
@@ -780,11 +969,11 @@ function shellStatus() {
else if (local.branch !== REPO.branch) blocker = `this checkout is on branch "${local.branch}", not "${REPO.branch}"`; else if (local.branch !== REPO.branch) blocker = `this checkout is on branch "${local.branch}", not "${REPO.branch}"`;
else if (local.dirty) blocker = 'this checkout has local changes — commit or discard them first'; else if (local.dirty) blocker = 'this checkout has local changes — commit or discard them first';
} }
const gh = `https://github.com/${REPO.owner}/${REPO.repo}`;
return { return {
version: SHELL_VERSION, version: SHELL_VERSION,
repo: `${REPO.owner}/${REPO.repo}`, repo: `${REPO.owner}/${REPO.repo}`,
branch: REPO.branch, branch: REPO.branch,
forge: FORGE.base,
local: { isGit: local.isGit, sha: local.sha, branch: local.branch, dirty: local.dirty }, local: { isGit: local.isGit, sha: local.sha, branch: local.branch, dirty: local.dirty },
remote: { version: remote.version, sha: remote.sha, checkedAt: remote.checkedAt, error: remote.error }, remote: { version: remote.version, sha: remote.sha, checkedAt: remote.checkedAt, error: remote.error },
updateAvailable: versionBehind || commitsBehind, updateAvailable: versionBehind || commitsBehind,
@@ -792,8 +981,8 @@ function shellStatus() {
method: local.isGit ? 'git' : 'archive', method: local.isGit ? 'git' : 'archive',
updateBlocker: blocker, updateBlocker: blocker,
changesUrl: local.sha && remote.sha changesUrl: local.sha && remote.sha
? `${gh}/compare/${local.sha.slice(0, 12)}...${remote.sha.slice(0, 12)}` ? forgeCompareUrl(local.sha.slice(0, 12), remote.sha.slice(0, 12))
: `${gh}/commits/${REPO.branch}`, : forgeCommitsUrl(),
}; };
} }
@@ -1055,6 +1244,9 @@ function applyConfigPatch(id, patch) {
} }
if (type === 'number') value = Number(value) || 0; if (type === 'number') value = Number(value) || 0;
else if (type === 'boolean' || type === 'switch') value = Boolean(value); else if (type === 'boolean' || type === 'switch') value = Boolean(value);
else if (type === 'multiselect') {
value = (Array.isArray(value) ? value : []).map((v) => String(v)).filter(Boolean);
}
else if (type === 'endpoint') { else if (type === 'endpoint') {
const src = value && typeof value === 'object' ? value : {}; const src = value && typeof value === 'object' ? value : {};
value = { value = {
@@ -1432,6 +1624,11 @@ function handleRequest(req, res) {
return sendJson(res, 200, themeView()); return sendJson(res, 200, themeView());
} }
/* — every stream a page wants, over one connection — */
if (urlPath === '/api/stream' && req.method === 'GET') {
return handleMuxStream(req, res, search);
}
/* — shell events: open dashboards learn about admin changes live — */ /* — shell events: open dashboards learn about admin changes live — */
if (urlPath === '/api/events' && req.method === 'GET') { if (urlPath === '/api/events' && req.method === 'GET') {
res.writeHead(200, { res.writeHead(200, {
@@ -1536,7 +1733,7 @@ server.on('listening', () => {
? ' Modules : ' + ids.map((id) => id + (isEnabled(id) ? '' : ' (disabled)') + (manifests.get(id).incompatible ? ' (needs newer ProdDash)' : '')).join(', ') ? ' Modules : ' + ids.map((id) => id + (isEnabled(id) ? '' : ' (disabled)') + (manifests.get(id).incompatible ? ' (needs newer ProdDash)' : '')).join(', ')
: ' Modules : none installed'); : ' Modules : none installed');
console.log(' Settings : ' + DATA_DIR + (DATA_DIR === REPO_CONFIG_DIR ? ' (inside the app folder)' : '')); console.log(' Settings : ' + DATA_DIR + (DATA_DIR === REPO_CONFIG_DIR ? ' (inside the app folder)' : ''));
console.log(' Repo : ' + REPO.owner + '/' + REPO.repo + '@' + REPO.branch); console.log(' Repo : ' + REPO.owner + '/' + REPO.repo + '@' + REPO.branch + ' (' + FORGE.base + ')');
}); });
// A restart hands the port over from the exiting ProdDash: wait for it. // A restart hands the port over from the exiting ProdDash: wait for it.
+23
View File
@@ -11,6 +11,10 @@
* GET /services/v2/service_types every service type, with its * GET /services/v2/service_types every service type, with its
* parent folder relationship * parent folder relationship
* GET /services/v2/service_types/{id} * GET /services/v2/service_types/{id}
* GET /services/v2/service_types/{id}/item_note_categories
* the note categories the
* items below use, plus one
* ("Stage") no note uses yet
* GET /services/v2/service_types/{id}/plans ?filter=future|past, order, * GET /services/v2/service_types/{id}/plans ?filter=future|past, order,
* per_page — today's plan, * per_page — today's plan,
* next week's and last week's * next week's and last week's
@@ -129,6 +133,22 @@ const ITEMS = [
{ id: 'i13', type: 'media', title: 'Walk-out Loop', pos: 'post', length: 600, notes: [['Video', 'Announcements loop until the room clears']] }, { id: 'i13', type: 'media', title: 'Walk-out Loop', pos: 'post', length: 600, notes: [['Video', 'Announcements loop until the room clears']] },
]; ];
/**
* Item note categories, per service type in Planning Center: every category
* the items above use, in the order the mock account lists them, plus one no
* note uses yet — a tile that has never chosen shows it on as well.
*/
const NOTE_CATEGORIES = ['Audio', 'Video', 'Lighting', 'ProPresenter', 'Stage'];
function noteCategoryRecord(name, index, stId) {
return {
type: 'ItemNoteCategory',
id: `${stId}-nc${index + 1}`,
attributes: { name, sequence: index + 1, frequently_used: index < 2, deleted_at: null },
relationships: { service_type: { data: { type: 'ServiceType', id: stId } } },
};
}
function itemsDocument(planId, include) { function itemsDocument(planId, include) {
const wants = new Set(String(include || '').split(',').map((s) => s.trim()).filter(Boolean)); const wants = new Set(String(include || '').split(',').map((s) => s.trim()).filter(Boolean));
const data = []; const data = [];
@@ -246,6 +266,9 @@ const server = http.createServer((req, res) => {
const st = SERVICE_TYPES.find((x) => x.id === parts[1]); const st = SERVICE_TYPES.find((x) => x.id === parts[1]);
if (!st) return notFound(res); if (!st) return notFound(res);
if (parts.length === 2) return send(res, 200, { data: serviceTypeRecord(st) }); if (parts.length === 2) return send(res, 200, { data: serviceTypeRecord(st) });
if (parts[2] === 'item_note_categories' && parts.length === 3) {
return collection(res, NOTE_CATEGORIES.map((name, i) => noteCategoryRecord(name, i, st.id)), url);
}
if (parts[2] !== 'plans') return notFound(res); if (parts[2] !== 'plans') return notFound(res);
const plans = plansFor(st.id); const plans = plansFor(st.id);
if (parts.length === 3) { if (parts.length === 3) {
+417
View File
@@ -0,0 +1,417 @@
/**
* Mock Slack Web API for developing the slack module without a workspace.
*
* node tools/slack-mock.js [port] (default 24716)
* SLACK_API_BASE=http://127.0.0.1:24716/api node server.js
*
* Then, in /admin → Slack, paste any token that starts with xoxp- (a user
* token: auth.test answers as the location account "Apollo Beach") or xoxb-
* (a bot token: bot_id set, user "proddash"); anything else is invalid_auth.
* Two more tokens exercise the error paths: xoxp-revoked → token_revoked,
* xoxp-noscope → missing_scope (needed channels:history) on history reads.
*
* Methods, all under /api/<method>, POST form or JSON (GET works too):
* auth.test identity for the token (see above)
* conversations.list #production, #ops, #campus-leads (private) as
* member; #announcements not a member; paginated
* conversations.info one channel, with is_member
* conversations.history newest-first window (limit); not_in_channel for
* #announcements
* conversations.replies a thread's parent + replies
* users.info display names and profile images (SVG squares
* served by this mock under /img/u/<id>.svg)
* chat.postMessage appends to the channel (echoes `username` for
* bots as a bot_message); text containing "!429"
* answers HTTP 429 with Retry-After: 3
* emoji.list four custom emoji: two images (/img/e/<name>.svg),
* an alias of a standard emoji, an alias of a custom
* reactions.add/.remove on a message (or reply) by channel + timestamp;
* already_reacted / no_reaction / message_not_found
* as Slack answers them; name "nope" → invalid_name
*
* #production is seeded with mrkdwn, mentions, a link, a thread, a bot
* attachment and a file; it gains a new fake message every ~8 s, edits one
* every ~25 s, deletes one every ~40 s and adds a thread reply every ~30 s.
* #ops gets a quieter trickle (every ~20 s), so a dashboard with both
* transcripts shows two channels moving at their own pace — and a
* chat.postMessage to either lands in that channel's history, which is how
* a send to the *selected* transcript can be watched arriving in the right
* tile. Every request is logged.
*
* Multi-channel dev flow: run this, then ProdDash with SLACK_API_BASE set,
* paste xoxp-anything in /admin → Slack, tick #production and #ops under
* Channels, Apply, and add both transcripts plus Send message from +.
*/
'use strict';
const http = require('http');
const PORT = Number.parseInt(process.argv[2] || '24716', 10);
/* ── data ───────────────────────────────────────────────────────────── */
const USERS = {
U001: { name: 'apollo.beach', real_name: 'Apollo Beach', display_name: 'Apollo Beach' },
U002: { name: 'michael', real_name: 'Michael McGary', display_name: 'Michael' },
U003: { name: 'sarah.lee', real_name: 'Sarah Lee', display_name: 'Sarah' },
U004: { name: 'north.attleboro', real_name: 'North Attleboro', display_name: 'North Attleboro' },
U005: { name: 'dan.k', real_name: 'Dan Kowalski', display_name: 'Dan K' },
UBOT: { name: 'proddash', real_name: 'ProdDash', display_name: '', is_bot: true },
};
const CHANNELS = [
{ id: 'C001', name: 'production', is_private: false, is_member: true },
{ id: 'C002', name: 'ops', is_private: false, is_member: true },
{ id: 'G001', name: 'campus-leads', is_private: true, is_member: true },
{ id: 'C003', name: 'announcements', is_private: false, is_member: false },
];
let tsCounter = 0;
function ts(offsetMs = 0) {
tsCounter += 1;
return `${Math.floor((Date.now() + offsetMs) / 1000)}.${String(tsCounter).padStart(6, '0')}`;
}
/** channel id -> messages (ascending); replies live inside their parent. */
const history = new Map();
for (const c of CHANNELS) history.set(c.id, []);
function push(channelId, msg) {
const list = history.get(channelId);
const m = { type: 'message', ts: msg.ts || ts(), ...msg };
list.push(m);
if (list.length > 400) list.splice(0, list.length - 400);
return m;
}
/** A message or a reply by timestamp, anywhere in a channel. */
function findMessage(channelId, stamp) {
for (const m of history.get(channelId) || []) {
if (m.ts === stamp) return m;
for (const r of m.replies || []) if (r.ts === stamp) return r;
}
return null;
}
/** Slack's reactions array: [{ name, users, count }], edited in place. */
function addReaction(m, name, userId) {
m.reactions = m.reactions || [];
let r = m.reactions.find((x) => x.name === name);
if (r && r.users.includes(userId)) return 'already_reacted';
if (!r) {
r = { name, users: [], count: 0 };
m.reactions.push(r);
}
r.users.push(userId);
r.count = r.users.length;
return '';
}
function removeReaction(m, name, userId) {
const r = (m.reactions || []).find((x) => x.name === name);
if (!r || !r.users.includes(userId)) return 'no_reaction';
r.users = r.users.filter((u) => u !== userId);
r.count = r.users.length;
if (!r.count) m.reactions = m.reactions.filter((x) => x !== r);
if (!m.reactions.length) delete m.reactions;
return '';
}
/* A square SVG with an initial, one hue per id — the mock's stand-in for a
profile image or a custom emoji picture. */
const IMAGE_HUES = { U001: 150, U002: 210, U003: 330, U004: 30, U005: 270, UBOT: 0, party_parrot: 120, waters: 200 };
function squareSvg(id, letter) {
const hue = IMAGE_HUES[id] ?? 180;
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72"><rect width="72" height="72" rx="18" fill="hsl(${hue} 50% 45%)"/><text x="36" y="47" text-anchor="middle" font-family="system-ui, sans-serif" font-size="34" font-weight="700" fill="#fff">${letter}</text></svg>`;
}
const CUSTOM_EMOJI = {
party_parrot: (port) => `http://127.0.0.1:${port}/img/e/party_parrot.svg`,
waters: (port) => `http://127.0.0.1:${port}/img/e/waters.svg`,
thumbsup_all: () => 'alias:+1',
parrot: () => 'alias:party_parrot',
};
function seed() {
const min = 60000;
const at = (minsAgo) => ts(-minsAgo * min);
push('C001', { ts: at(58), user: 'U003', subtype: 'channel_join', text: '<@U003> has joined the channel' });
push('C001', { ts: at(55), user: 'U002', text: 'Morning all — doors at 9:15, first service 10:00. Run of show is in <#C002|ops>.' });
push('C001', { ts: at(50), user: 'U001', text: 'Apollo Beach is up. Stream key checked, ProPresenter online :white_check_mark:' });
push('C001', { ts: at(47), user: 'U004', text: 'North Attleboro here — we have *no audio* on the sermon feed yet, working on it' });
push('C001', { ts: at(44), user: 'U002', text: '<@U004> try the Dante patch — `Sermon Feed L/R` should be on 31/32. _Ping me if not._' });
const parent = push('C001', { ts: at(40), user: 'U003', text: 'Countdown video is the new one this week, right? ~old one~ &lt;-- not this', reply_count: 2, latest_reply: '' });
parent.replies = [
{ type: 'message', ts: at(39), user: 'U002', text: 'Yes — "Countdown 5 min (Sept)" in the media bin', thread_ts: parent.ts },
{ type: 'message', ts: at(38), user: 'U003', text: 'Got it :+1:', thread_ts: parent.ts },
];
parent.latest_reply = parent.replies[1].ts;
push('C001', {
ts: at(35), subtype: 'bot_message', bot_id: 'B002', username: 'Planning Center', text: '',
attachments: [{ fallback: 'Plan updated: Sunday Service — Sept 14 (2 items changed)', title: 'Plan updated', text: 'Sunday Service — Sept 14 (2 items changed)' }],
});
push('C001', { ts: at(30), user: 'U005', text: 'Lower thirds for the announcements: <https://waterschurch.org/plan|this week’s plan>', files: [{ id: 'F001', name: 'lower-thirds-0914.png', title: 'lower-thirds-0914.png' }] });
const fixed = push('C001', { ts: at(25), user: 'U004', text: 'Audio fixed — Dante patch was it. Thanks <@U002> :pray:', edited: { user: 'U004', ts: at(24) } });
fixed.reactions = [{ name: '+1', users: ['U002', 'U003', 'U001'], count: 3 }, { name: 'party_parrot', users: ['U005'], count: 1 }, { name: 'pray::skin-tone-3', users: ['U002'], count: 1 }];
push('C001', { ts: at(23), user: 'U002', text: 'Custom emoji check :party_parrot: :waters: and an unknown :not_an_emoji: stay put' });
push('C001', { ts: at(20), user: 'U002', text: '<!here> 10 minutes to doors. Campuses, post "ready" when your pre-service loop is running.' });
push('C001', { ts: at(15), user: 'U001', text: 'Apollo Beach ready' });
push('C001', { ts: at(14), user: 'U004', text: 'North Attleboro ready' });
push('C002', { ts: at(120), user: 'U002', text: 'Run of show for Sept 14 is pinned. Changes go here, not in DMs.' });
push('C002', { ts: at(90), user: 'U005', text: 'Bumper video is 0:42 this week, not 0:30 — adjust your timers.' });
push('G001', { ts: at(200), user: 'U002', text: 'Leads: budget call moved to Tuesday 2pm.' });
push('C003', { ts: at(300), user: 'U002', text: 'Staff meeting notes are in the drive.' });
}
seed();
const CHATTER = [
{ user: 'U003', text: 'Walk-in music is a touch loud at the back, can we come down 2 dB?' },
{ user: 'U002', text: 'Cue 4 is the *baptism video* — heads up, it has its own audio' },
{ user: 'U005', text: 'Livestream is live, 38 viewers already :tada:' },
{ user: 'U004', text: 'North Attleboro: pastor is running about 3 min long today' },
{ user: 'U002', text: 'Copy. Apollo Beach, hold your bumper until you hear from us' },
{ user: 'U003', text: 'Lyrics slide 3 of "Living Hope" has a typo — fixing for second service' },
{ user: 'U005', text: 'Stream bitrate dipped for a sec, back to normal now' },
{ user: 'U002', text: 'Offering moment is *after* the sermon this week, not before' },
{ user: 'U004', text: 'Anyone else seeing the ProPresenter stage display lag?' },
{ user: 'U003', text: 'Kids check-in is slammed, could use one more volunteer at the desk' },
{ user: 'U002', text: 'Great first service everyone :clap: reset in 15' },
];
let chatterIx = 0;
const OPS_CHATTER = [
{ user: 'U005', text: 'Ops: parking team is short two people, sending greeters over' },
{ user: 'U002', text: 'Cafe is out of oat milk again :sweat_smile:' },
{ user: 'U005', text: 'Second-service headcount: 412 in the room, 38 online' },
{ user: 'U002', text: 'Facilities: the lobby HVAC is back, thanks Dan' },
];
let opsIx = 0;
function tickNew() {
const line = CHATTER[chatterIx % CHATTER.length];
chatterIx += 1;
const m = push('C001', { user: line.user, text: line.text });
console.log(`[slack-mock] + new message ${m.ts} from ${line.user}`);
}
function tickOps() {
const line = OPS_CHATTER[opsIx % OPS_CHATTER.length];
opsIx += 1;
const m = push('C002', { user: line.user, text: line.text });
console.log(`[slack-mock] + new message ${m.ts} in #ops from ${line.user}`);
}
function tickEdit() {
const list = history.get('C001');
const recent = list.slice(-8).filter((m) => m.user && !m.subtype && !m.edited);
if (!recent.length) return;
const m = recent[Math.floor(Math.random() * recent.length)];
m.text += ' — fixed';
m.edited = { user: m.user, ts: ts() };
console.log(`[slack-mock] ~ edited ${m.ts}`);
}
function tickDelete() {
const list = history.get('C001');
const candidates = list.slice(-10, -2).filter((m) => m.user && !m.subtype && !m.reply_count);
if (!candidates.length) return;
const victim = candidates[Math.floor(Math.random() * candidates.length)];
list.splice(list.indexOf(victim), 1);
console.log(`[slack-mock] - deleted ${victim.ts}`);
}
function tickReply() {
const list = history.get('C001');
const parent = list.find((m) => m.reply_count);
if (!parent) return;
const reply = { type: 'message', ts: ts(), user: ['U002', 'U003', 'U005'][parent.replies.length % 3], text: `Thread reply ${parent.replies.length + 1} :eyes:`, thread_ts: parent.ts };
parent.replies.push(reply);
parent.reply_count = parent.replies.length;
parent.latest_reply = reply.ts;
console.log(`[slack-mock] ↳ reply on ${parent.ts} (${parent.reply_count} total)`);
}
setInterval(tickNew, 8000).unref();
setInterval(tickOps, 20000).unref();
setInterval(tickEdit, 25000).unref();
setInterval(tickDelete, 40000).unref();
setInterval(tickReply, 30000).unref();
/* ── plumbing ───────────────────────────────────────────────────────── */
function send(res, status, body, headers = {}) {
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers });
res.end(JSON.stringify(body));
}
function readBody(req) {
return new Promise((resolve) => {
let data = '';
req.on('data', (c) => { data += c; });
req.on('end', () => resolve(data));
req.on('error', () => resolve(''));
});
}
function parseParams(req, url, raw) {
const params = Object.fromEntries(url.searchParams.entries());
const ctype = String(req.headers['content-type'] || '');
if (raw) {
if (ctype.includes('application/json')) {
try { Object.assign(params, JSON.parse(raw)); } catch { /* ignore */ }
} else {
for (const [k, v] of new URLSearchParams(raw)) params[k] = v;
}
}
return params;
}
function tokenOf(req, params) {
const m = /^Bearer\s+(\S+)/.exec(String(req.headers.authorization || ''));
return m ? m[1] : String(params.token || '');
}
function identity(token) {
if (token === 'xoxp-revoked') return { error: 'token_revoked' };
if (/^xoxp-/.test(token)) return { ok: true, url: 'https://waters.slack.com/', team: 'Waters Church', user: 'apollo.beach', team_id: 'T001', user_id: 'U001', noscope: token === 'xoxp-noscope' };
if (/^xoxb-/.test(token)) return { ok: true, url: 'https://waters.slack.com/', team: 'Waters Church', user: 'proddash', team_id: 'T001', user_id: 'UBOT', bot_id: 'B001' };
return { error: 'invalid_auth' };
}
function channelRecord(c) {
return { id: c.id, name: c.name, name_normalized: c.name, is_channel: !c.is_private, is_group: c.is_private, is_private: c.is_private, is_member: c.is_member, is_archived: false, num_members: 12 };
}
function publicMessage(m) {
const { replies, ...rest } = m;
return rest;
}
function findChannel(ref) {
const s = String(ref || '');
return CHANNELS.find((c) => c.id === s || c.name === s.replace(/^#/, ''));
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
const raw = req.method === 'POST' ? await readBody(req) : '';
const params = parseParams(req, url, raw);
const method = url.pathname.replace(/^\/api\//, '');
const summary = Object.entries(params).filter(([k]) => k !== 'token').map(([k, v]) => `${k}=${String(v).slice(0, 40)}`).join(' ');
console.log(`[slack-mock] ${req.method} ${url.pathname} ${summary}`);
const img = url.pathname.match(/^\/img\/(u|e)\/([A-Za-z0-9_+-]+)\.svg$/);
if (img) {
const id = img[2];
const letter = img[1] === 'u' ? (USERS[id]?.display_name || USERS[id]?.real_name || id)[0].toUpperCase() : id[0].toUpperCase();
res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'no-store' });
return res.end(squareSvg(id, letter));
}
if (!url.pathname.startsWith('/api/')) return send(res, 404, { ok: false, error: 'unknown_method' });
const token = tokenOf(req, params);
const who = identity(token);
if (!who.ok) return send(res, 200, { ok: false, error: who.error });
switch (method) {
case 'auth.test': {
const { noscope, ...rest } = who;
return send(res, 200, rest);
}
case 'users.info': {
const u = USERS[String(params.user)];
if (!u) return send(res, 200, { ok: false, error: 'user_not_found' });
const img = `http://127.0.0.1:${PORT}/img/u/${params.user}.svg`;
return send(res, 200, { ok: true, user: { id: params.user, name: u.name, real_name: u.real_name, is_bot: Boolean(u.is_bot), profile: { display_name: u.display_name, real_name: u.real_name, image_48: img, image_72: img } } });
}
case 'conversations.list': {
const types = String(params.types || 'public_channel').split(',');
let list = CHANNELS.filter((c) => (c.is_private ? types.includes('private_channel') : types.includes('public_channel')));
if (who.bot_id) list = list.filter((c) => c.is_member); // a bot sees only what it's in
const limit = Math.max(1, Math.min(1000, Number(params.limit) || 100));
const start = params.cursor ? Number(Buffer.from(String(params.cursor), 'base64').toString()) || 0 : 0;
const page = list.slice(start, start + limit);
const next = start + limit < list.length ? Buffer.from(String(start + limit)).toString('base64') : '';
return send(res, 200, { ok: true, channels: page.map(channelRecord), response_metadata: { next_cursor: next } });
}
case 'conversations.info': {
const c = findChannel(params.channel);
if (!c) return send(res, 200, { ok: false, error: 'channel_not_found' });
return send(res, 200, { ok: true, channel: channelRecord(c) });
}
case 'conversations.history': {
const c = findChannel(params.channel);
if (!c) return send(res, 200, { ok: false, error: 'channel_not_found' });
if (who.noscope) return send(res, 200, { ok: false, error: 'missing_scope', needed: 'channels:history', provided: 'channels:read,chat:write' });
if (!c.is_member) return send(res, 200, { ok: false, error: 'not_in_channel' });
const limit = Math.max(1, Math.min(1000, Number(params.limit) || 100));
const list = history.get(c.id);
const window = list.slice(-limit).reverse().map(publicMessage);
return send(res, 200, { ok: true, messages: window, has_more: list.length > limit, pin_count: 0 });
}
case 'conversations.replies': {
const c = findChannel(params.channel);
if (!c) return send(res, 200, { ok: false, error: 'channel_not_found' });
if (!c.is_member) return send(res, 200, { ok: false, error: 'not_in_channel' });
const parent = history.get(c.id).find((m) => m.ts === String(params.ts));
if (!parent) return send(res, 200, { ok: false, error: 'thread_not_found' });
const limit = Math.max(1, Math.min(1000, Number(params.limit) || 100));
return send(res, 200, { ok: true, messages: [publicMessage(parent), ...(parent.replies || []).slice(0, limit - 1)], has_more: false });
}
case 'emoji.list': {
const emoji = {};
for (const [name, v] of Object.entries(CUSTOM_EMOJI)) emoji[name] = v(PORT);
return send(res, 200, { ok: true, emoji, cache_ts: '1700000000.000000' });
}
case 'reactions.add':
case 'reactions.remove': {
const c = findChannel(params.channel);
if (!c) return send(res, 200, { ok: false, error: 'channel_not_found' });
if (!c.is_member) return send(res, 200, { ok: false, error: 'not_in_channel' });
const name = String(params.name || '');
if (!name || name === 'nope') return send(res, 200, { ok: false, error: 'invalid_name' });
const m = findMessage(c.id, String(params.timestamp || ''));
if (!m) return send(res, 200, { ok: false, error: 'message_not_found' });
const err = method === 'reactions.add' ? addReaction(m, name, who.user_id) : removeReaction(m, name, who.user_id);
if (err) return send(res, 200, { ok: false, error: err });
console.log(`[slack-mock] ${method === 'reactions.add' ? '➕' : '➖'} :${name}: on ${m.ts} in #${c.name} by ${who.user}`);
return send(res, 200, { ok: true });
}
case 'chat.postMessage': {
const c = findChannel(params.channel);
if (!c) return send(res, 200, { ok: false, error: 'channel_not_found' });
if (!c.is_member) return send(res, 200, { ok: false, error: 'not_in_channel' });
const text = String(params.text || '');
if (!text.trim()) return send(res, 200, { ok: false, error: 'no_text' });
if (text.includes('!429')) return send(res, 429, { ok: false, error: 'ratelimited' }, { 'Retry-After': '3' });
let m;
if (who.bot_id && params.username) {
m = push(c.id, { subtype: 'bot_message', bot_id: who.bot_id, username: String(params.username), icons: params.icon_emoji ? { emoji: String(params.icon_emoji) } : undefined, text });
} else if (who.bot_id) {
m = push(c.id, { user: who.user_id, bot_id: who.bot_id, bot_profile: { id: who.bot_id, name: 'ProdDash' }, text });
} else {
m = push(c.id, { user: who.user_id, text });
}
if (params.thread_ts) m.thread_ts = String(params.thread_ts);
console.log(`[slack-mock] ✉ posted to #${c.name} as ${params.username || who.user}: ${text.slice(0, 60)}`);
return send(res, 200, { ok: true, channel: c.id, ts: m.ts, message: publicMessage(m) });
}
default:
return send(res, 200, { ok: false, error: 'unknown_method' });
}
});
server.listen(PORT, '127.0.0.1', () => {
console.log(`[slack-mock] Slack Web API mock on http://127.0.0.1:${PORT}/api`);
console.log('[slack-mock] tokens: xoxp-anything (user "Apollo Beach"), xoxb-anything (bot), xoxp-revoked, xoxp-noscope');
console.log('[slack-mock] run ProdDash with: SLACK_API_BASE=http://127.0.0.1:%d/api node server.js', PORT);
});