Compare commits

...
Author SHA1 Message Date
Waters ChurchandClaude Opus 5 12f0b20e5f Rules for a clean interface, and the modules brought to them
Seven rules join the four in the module guide, as rules 5 to 11: no
explanatory text in a tile's gear and no label over three words; server-side
settings in configSchema only; a tile setting changes that tile alone;
hide-toggles ungrouped when there are four or fewer and in a folded "Show"
group when there are five or more; title-bar controls are icons with
tooltips, never names; anything needing words belongs in the gear; and one
control per action, either place but not both.

Two passages said the opposite and now say the same thing — "keep labels
short and put the explanation there" is scoped to configSchema, and "icon
buttons, short labels" becomes icons with no labels at all. CLAUDE.md
carries the summary.

Then the modules. Nothing here changes a setting key or a default, so saved
layouts, named layouts and every tile already on a wall keep working; group
names, labels, help and fold state are presentation and are not stored.

- pco-plan: sixteen labels cut to three words or fewer ("Tint items by type
  (song / media / other)" is now "Tint by type"), "Show / hide" renamed
  "Show" and folded. Its one remaining help line stays by request: the top
  bar's template needs its separator syntax spelled out, which the variable
  chips beside it cannot convey, so rule 5 carves out template fields.
- propresenter-timers: the "Timers ▾" menu was the only named control left
  in any title bar. It is a stopwatch icon now, with the count riding
  alongside it while some timers are hidden — setLabel() writes over the
  button's contents, so the count needed ProdCom's badge treatment rather
  than a label. "Show" folds; the Colours help goes.
- propresenter-now-next: "Show" folds, in the manifest and in the Now
  entry's own copy.
- prodcom-transcript: "Show channel icons" loses the redundant verb.

slack and clock were already compliant and are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:22:06 -04:00
michael 82e6386c73 Merge pull request 'Shell 1.6.0: bars that tuck away, and every edge resizes' (#6) from claude/tile-chrome into main 2026-09-17 12:44:07 -04:00
michael f5eb611872 Merge pull request 'README: the Slack module joins the list of included modules' (#5) from claude/readme-slack-module into main 2026-09-17 12:43:31 -04:00
michael d52cfcc48e Merge pull request 'Slack 1.2.1: a clearer Send tile, and a send target you can see' (#4) from claude/slack-send-target into main 2026-09-17 12:42:48 -04:00
michael 7d46cfbd8f Merge pull request 'ProPresenter 1.1.0: a Now tile, and switches for everything on it' (#3) from claude/propresenter-now into main 2026-09-17 12:41:53 -04:00
michael 7a5b57dc56 Merge pull request 'Admin sits behind a 6-digit PIN' (#1) from claude/admin-pin into main 2026-09-17 12:40:31 -04:00
Waters ChurchandClaude Opus 5 7f50fa0ba9 A pin on the title bar, and no more notch
Pointing at a tile's top edge already brings its bar back, which left the
notch doing half a job: it could still hide a bar, but hiding one tile at a
time stopped being the interesting question once the whole dashboard could
fold its bars at once. It is gone.

In its place, a pin beside each tile's label. Pinned, that tile keeps its
bar while auto-hide is on — for the one or two tiles whose controls are
wanted at a glance. It sits next to the label rather than out with the gear
and ✕ because it is about the bar, not about the tile's contents, and it
stays faint until the tile is pointed at or the pin is on, so a wall of
unpinned tiles is not a wall of pins.

Fullscreen keeps its own rule: every bar folds, pinned or not, because that
is a mode for watching. What it loses with the notch it gets back better —
the same hover at a tile's top edge peeks a bar there too, and now as an
overlay, so peeking no longer pushes the tile's content down and disturbs
the auto-fit. The peek timer the notch needed goes with it.

Per-tile `headHidden` gives way to `headPinned` in the layout, on disk and
on the server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:04:59 -04:00
Waters ChurchandClaude Opus 5 68f0ba806d Tiles resize from anywhere, so nothing marks the spots
Every edge and both bottom corners resize now, so the little marks that used
to appear on hover were pointing at the whole rim. They are gone — corners,
edges and the seam pill alike — and the cursor over an edge is what says it
can be dragged, the way a window's own edges do it. The seam still shows
itself while it is actually being dragged, which says what is moving rather
than where you may push.

With nothing drawn, the grips could be made to cover the rim properly: the
top strip now runs corner to corner instead of stopping 24px short, the
sides run from it down to the bottom corners, and the bottom strip fills
between those. No dead spots left along an edge.

The side grips also stopped standing down at a seam. They only did so to
avoid stacking three marks in one place, and it cost real ground: a tile
taller than its neighbour lost its whole side, not just the part the seam
covers. The seam sits above them anyway, so it still wins where it exists
and the rest of the edge now works.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:52:45 -04:00
Waters ChurchandClaude Opus 5 8e579ecb34 Shell 1.6.0: bars that tuck away, and every edge resizes
Three things about arranging a dashboard.

**Auto-hide bars.** A new button left of ⛶ folds every tile's title bar away
at once, for a windowed screen that should read like a wall display.
Fullscreen already did this for watching; this is the windowed equivalent,
asked for by hand. It starts off and saves with the layout, so a booth's
named layout loads with the bars already tucked away.

**A hidden bar is not a gone bar.** Folded away — by that switch or by a
tile's own notch — the bar leaves the flow and parks just above its tile, so
the module keeps the whole body and nothing reflows as the bar comes and
goes. Point at the tile's top edge and it slides back in over the content,
where its buttons click and its width drags the tile exactly as before. A
dashboard can now be arranged with every bar out of the way, which is the
point of hiding them.

**Every edge resizes.** The top, left and right edges join the bottom one:
each moves only itself, so the tile grows into the space beside it while the
opposite edge stays put, stopping at a neighbour or at the module's own
minimum. Where two tiles meet, that boundary already belongs to the seam
pill between them — it moves both edges and leaves no gap — so the two
tiles' side marks step aside there rather than offer a second, different
control on the same pixels.

One fix underneath: saving a named layout was dropping each tile's variant,
title, minimum size and hidden-bar state, so a saved "Now" tile came back as
the full Now/Next card and a solo timer came back with a full tile's
settings. The layout file now carries them — and the new switch with them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:39:34 -04:00
Waters ChurchandClaude Opus 5 c379025d30 README: the Slack module joins the list
Every shipped module had an entry under "Included modules" except Slack,
which wasn't mentioned anywhere in the README — so the one module that can
send as the campus was also the one nobody could read about.

Its entry now sits with the others, in their shape: what it does, the tiles
it offers in the + picker (one transcript per configured channel plus the
two single send surfaces), its four admin groups and its tile settings —
all taken from modules/slack/module.json and client.js.

The testing paragraph listed the ProdCom, ProPresenter and PCO mocks but
not the Slack one, though CLAUDE.md has documented it all along; it is now
there with the SLACK_API_BASE line it needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 09:10:03 -04:00
Waters ChurchandClaude Opus 5 f0be37796b Slack 1.2.1: a clearer Send tile, and a send target you can see
The line above the message box said "Sending as Apollo Beach → #ops" with a
Lock button beside it. The box's own placeholder already reads "Message
#ops…" and the title bar already has a Lock, so both went. Quick replies
keeps its line: it has no placeholder, so that is the only place its target
is named.

The bigger thing behind it: until a transcript was clicked, a send went to
the first channel in the admin list whether or not that channel was on the
dashboard — so a board showing only #ops would quietly aim at #general. The
target now falls to the first configured channel whose transcript is
actually up, which with a single transcript is that one. Only when the page
shows no transcript at all does it fall back to the first configured
channel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:58:57 -04:00
Waters ChurchandClaude Opus 5 52d700df3d ProPresenter 1.1.0: a Now tile, and switches for everything on it
The + picker now offers the module two ways. "Now / Next" is the card as it
was. "Now" is the live item on its own: the gradient comes off the card and
fills the tile edge to edge, the border, radius and box go, and the playlist
name and live dot float over the colour instead of sitting above it. A wall
of them reads as panels of colour rather than cards on a background.

Every part of the tile is now a switch in a "Show" group in the gear —
playlist, live status, the NOW and NEXT labels, slide count, progress bar,
the next list and its item tags. One or two words each, no descriptions. The
Now entry carries its own six, since the three Next ones have nothing to act
on there.

Two things the tile still insists on. Trouble overrides the switches: the
top row and the live chip come back the moment the feed drops, whatever is
switched off, because a tile that has stopped updating must never look like
one with nothing live. And at the Now tile's shortest sizes the floating top
row would sit on the title, so below the height it needs it goes — the
header dot and the title itself still say what is wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:39:55 -04:00
michael 6cca6d4d1a Merge pull request 'The church's own Gitea is where ProdDash lives' (#2) from claude/gitea-is-home into main 2026-09-16 19:00:51 -04:00
Waters ChurchandClaude Opus 5 1be5cbef31 The church's own Gitea is where ProdDash lives
The shipped defaults pointed at GitHub, so a fresh install with no
configuration would have looked there — and that repository is going away.
They now name michael/ProdDash on chronicles-ab.waterschurch.org:3000, which
a fresh install was verified to reach with nothing configured at all.

The hostname rather than the IP, because the UniFi DNS record is
authoritative and this survives the NAS changing address. Both production
machines already carry an explicit setting of their own, so neither is
affected either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:00:22 -04:00
Waters ChurchandClaude Opus 5 7690ae1fad Merge main into the admin PIN branch
Both sides added to the same two places — the environment-variable list in
server.js and the settings table in the README — because the forge work and
the PIN work each documented a new setting. Everything from both is kept.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 18:57:27 -04:00
Michael McGary 1c8cd63cd0 Merge pull request #20 from michaelmcgary2008/claude/shell-1-5-0
Shell 1.5.0
2026-09-16 18:25:02 -04:00
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
Waters ChurchandClaude Opus 5 6dabd9d8a1 Admin sits behind a 6-digit PIN
The passcode machinery existed but could only be set by hand in a config
file, so in practice every install left admin open to anyone on the
network. It is now a PIN you set from the admin page itself.

- Six digits, stored as a scrypt hash in this machine's proddash.json —
  never in the clear, since that file gets copied between machines.
- The unlock card takes the PIN and submits itself on the sixth digit.
  Five wrong tries from one address locks it out for 30 seconds, and the
  right PIN is refused during the lockout.
- Changing or turning off the PIN asks for the current one, so a browser
  left open on the admin page cannot be used to take the machine over.
  Whoever sets it keeps their session rather than being bounced to the
  card they just built.
- This is the shell's own PIN and guards /admin and the admin API only.
  Dashboards stay open, and it is unrelated to a module's PIN — Slack's
  4-8 digit send PIN still guards sending, separately and independently.
- Ways back in if it is forgotten: PRODDASH_PASSCODE in the environment
  (which overrides the PIN), or delete adminPin from proddash.json and
  restart. Both are in the README and on the page itself.
- The older free-text adminPasscode still works, so an existing install
  keeps running.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 10:46:41 -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
38 changed files with 7782 additions and 829 deletions
+8 -1
View File
@@ -32,18 +32,25 @@ title-bar controls). The rules that matter most:
and bump the module's `version` on every change. and bump the module's `version` on every change.
- Browsers never call upstream services directly; the module's server routes - Browsers never call upstream services directly; the module's server routes
proxy everything (no CORS, secrets stay server-side). proxy everything (no CORS, secrets stay server-side).
- **Keep the interface clean** (guide rules 5-11): no `help` and no label
over three words in a tile's gear; server-side settings in `configSchema`
only; a tile setting changes that tile alone; hide-toggles ungrouped when
there are four or fewer, in a folded **Show** group when there are five or
more; title-bar controls are icons with tooltips, never labels, and never
repeat something the gear already offers.
- Style with the shell's CSS variables only, scoped under - Style with the shell's CSS variables only, scoped under
`[data-module="<id>"]`; survive upstream loss (reconnect + `setStatus`). `[data-module="<id>"]`; survive upstream loss (reconnect + `setStatus`).
- 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:
+94 -22
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.
@@ -98,11 +99,34 @@ with this machine's shell settings — the same keys as the checked-in
| Key | Meaning | | Key | Meaning |
| --- | --- | | --- | --- |
| `port` | Port ProdDash serves on (default `24500`). The macOS launcher writes this one, so the port it is set to holds however ProdDash is started. | | `port` | Port ProdDash serves on (default `24500`). The macOS launcher writes this one, so the port it is set to holds however ProdDash is started. |
| `adminPasscode` | If set, the admin page asks for this shared passcode. Leave `""` for none — it's a plain-HTTP LAN tool. | | `adminPin` | The admin PIN, as a scrypt hash. **Set it in the admin page's Admin PIN section**, not by hand — 6 digits, and only the hash is stored. Absent means admin is open to anyone on the network. |
| `adminPasscode` | An older free-text passcode. Still honoured, so an existing install keeps working; new installs should use `adminPin`. |
| `forge` | Where updates and module installs are fetched from. Absent or `"github"` uses GitHub; a Gitea or Forgejo base URL such as `"http://10.3.8.90:3000"` uses a server of your own. Set it per machine in the data directory, alongside `repo`. |
| `theme` | The dashboard palette: `booth` (default), `harbor`, `graphite`, `ember` or `daylight`. The admin page's **Theme** section writes this one — see [Themes](#themes). | | `theme` | The dashboard palette: `booth` (default), `harbor`, `graphite`, `ember` or `daylight`. The admin page's **Theme** section writes this one — see [Themes](#themes). |
**Locked out?** Delete `adminPin` from the data directory's `proddash.json`
and restart, or start the server with `PRODDASH_PASSCODE` set — that passcode
overrides the PIN and always gets you in.
Only `/admin` and the admin API are behind the PIN. Dashboards stay open, so a
wall display needs no PIN, and it is unrelated to a module's own PIN (Slack's
4–8 digit send PIN, say), which guards that module's actions only.
**Updating from your own git server.** ProdDash fetches one `tar.gz` of the
branch — nothing else — so any Gitea or Forgejo works. Point `forge` at its
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.
@@ -130,10 +154,11 @@ set on a tile itself (a PCO Plan header colour, say) is deliberately kept.
## Installing modules and updating ## Installing modules and updating
Everything below happens in the **admin page**; ProdDash talks to its GitHub Everything below happens in the **admin page**; ProdDash talks to its git
repository (`michaelmcgary2008/ProdDash`, branch `main` — change with repository — `michael/ProdDash` on the church's own Gitea at
`repo` / `branch` in `proddash.json` or the `PRODDASH_REPO` variable, `chronicles-ab.waterschurch.org:3000`, branch `main`. Change it with `forge`,
`owner/repo#branch`). It downloads one small archive of the branch, at most `repo` and `branch` in `proddash.json`, or the `PRODDASH_FORGE` and
`PRODDASH_REPO` variables (`owner/repo#branch`). It downloads one small archive of the branch, at most
every few minutes, only while the admin page is open or for the update check every few minutes, only while the admin page is open or for the update check
below. Nothing is installed without someone clicking. below. Nothing is installed without someone clicking.
@@ -173,13 +198,17 @@ below. Nothing is installed without someone clicking.
- **+** — add any enabled module; multiple tiles of the same module are - **+** — add any enabled module; multiple tiles of the same module are
fine (e.g. two transcripts filtered to different channels). fine (e.g. two transcripts filtered to different channels).
- Drag a tile by its **title bar** to move it; drag either **bottom - Drag a tile by its **title bar** to move it; **✕** removes it.
corner** to resize; **✕** removes it. Hover a tile and a small pill - **Resize from any edge or bottom corner**, the way a window resizes:
appears on its **bottom edge** — drag it to change just the height. If nothing is drawn on a tile to mark the spots — the cursor over an edge is
another tile sits directly below, the pill moves the shared edge instead, what says it can be dragged. An edge moves only itself, so the tile grows
trading rows between the two so the column keeps its total height. Tiles or shrinks into the space beside it while the opposite edge stays put,
sit flush — when two share a vertical edge, hover the seam and the same stopping at a neighbour or at the module's own minimum size. Two
kind of pill appears; drag it to trade width (macOS split-view style). exceptions, both of which move a *shared* edge instead: with another tile
directly below, the bottom edge trades rows between the two so the column
keeps its total height; and where two tiles meet side by side, the seam
between them trades width (macOS split-view style), showing itself only
while you drag it.
- **Fullscreen** (the ⛶ button) is a viewing mode: the menu bar hides (its - **Fullscreen** (the ⛶ button) is a viewing mode: the menu bar hides (its
green lip at the top-left brings it back, minus the Admin button), the green lip at the top-left brings it back, minus the Admin button), the
occupied columns stretch to the full width, and every column runs to the occupied columns stretch to the full width, and every column runs to the
@@ -189,9 +218,21 @@ below. Nothing is installed without someone clicking.
gap; alone in its columns it stays as it is. Nothing is saved — the layout gap; alone in its columns it stays as it is. Nothing is saved — the layout
reverts when you leave fullscreen. A layout deeper than the screen keeps reverts when you leave fullscreen. A layout deeper than the screen keeps
its normal row height and scrolls. its normal row height and scrolls.
- A tile's actions (menus, text size, clear…) live in its **title bar**; - A tile's actions (menus, text size, clear…) live in its **title bar**.
the small **notch** on the bar's bottom edge hides/shows the whole bar - **Auto-hide bars** (the button left of ⛶) folds every title bar away, for
for a clean wall-display look. The menu bar's handle is the **ProdDash a windowed screen that should read like a wall display. A folded bar is
not a gone bar: point at the tile's top edge and it slides back in over
the content, where its buttons click and its width drags the tile exactly
as before — so a dashboard can be arranged with every bar out of the way.
The switch is off on a fresh dashboard and saves with the layout —
including a named layout on the server, so a booth loads its screen with
the bars already tucked away. Fullscreen folds every bar regardless, and
the same hover brings one back there too.
- The **pin** beside a tile's title keeps *that* tile's bar showing while
auto-hide is on — for the one or two tiles whose controls are wanted at
a glance. It turns the accent colour when it is on and saves with the
layout. Fullscreen still folds a pinned bar with the rest.
- The menu bar's own handle is the **ProdDash
name itself**, wrapped in a translucent green pill at the left — click it name itself**, wrapped in a translucent green pill at the left — click it
to hide the bar; the slim green lip it leaves at the top-left edge brings to hide the bar; the slim green lip it leaves at the top-left edge brings
the bar back. It stays at the left so it never sits on top of a tile's the bar back. It stays at the left so it never sits on top of a tile's
@@ -215,7 +256,13 @@ below. Nothing is installed without someone clicking.
Tile settings: channel icons, direction and timestamps. Tile settings: channel icons, direction and timestamps.
- **ProPresenter** — what's live and what's next, with slide progress, - **ProPresenter** — what's live and what's next, with slide progress,
driven by the shared `propresenter-core` client (ProPresenter 7/20). driven by the shared `propresenter-core` client (ProPresenter 7/20).
The + picker offers two tiles: **Now / Next**, the full card, and **Now**,
the live item alone with the gradient filling the tile edge to edge — a
wall panel that says one thing.
Admin config: ProPresenter endpoint (host : port), password. Admin config: ProPresenter endpoint (host : port), password.
Tile settings: a **Show** group of switches for the playlist name, live
status, NOW and NEXT labels, slide count, progress bar, the next list and
its item tags, plus tidy titles.
- **Timers** — live ProPresenter timers (countdown, countdown-to-time, - **Timers** — live ProPresenter timers (countdown, countdown-to-time,
elapsed) and LTC timecode as auto-sized cards. The + picker lists every elapsed) and LTC timecode as auto-sized cards. The + picker lists every
discovered timer (and the LTC card) as its own tile, or add "All timers" discovered timer (and the LTC card) as its own tile, or add "All timers"
@@ -249,6 +296,29 @@ below. Nothing is installed without someone clicking.
(gear menu): the top-bar text (placeholders such as (gear menu): the top-bar text (placeholders such as
`{series} • {part} | {serviceType} • {date}`), colors, text size, and a `{series} • {part} | {serviceType} • {date}`), colors, text size, and a
show/hide checklist for every detail. show/hide checklist for every detail.
- **Slack** — live transcripts of this location's Slack channels, plus
PIN-protected sending as the location. The + picker lists one transcript
tile per configured channel, titled `#channel`, and two send surfaces (one
of each per dashboard): **Send message**, a free-text box with the admin's
quick emojis one press away, and **Quick replies**, a grid of preset
buttons. Both post to the channel of whichever transcript was clicked last
— its bottom edge lights up in the accent color — and to the first
configured channel until one is clicked. Messages arrive with avatars,
names and times, Slack's inline formatting and the workspace's custom
emoji; a right-click on one opens the reaction menu, and text size and
jump-to-latest sit in the title bar. Nothing leaves the machine until the
PIN is entered: one PIN unlocks every Slack tile on that dashboard page
until the auto-lock idles out, a reload asks again, and the lock button on
a send tile's title bar ends it early.
Admin config is in four groups: **Slack account** (the location account's
User OAuth Token — `xoxp-…`, so messages are literally from the campus —
or a bot token plus "Send as"), **Channels** (the joined channels to
follow, one transcript tile each), **Transcript** (refresh interval,
messages kept, thread replies) and **Sending** (the switch on the group's
title row, a 4–8 digit PIN, auto-lock minutes, quick replies one per line
as `Label | Message` — add `| #channel` to bind one to a channel — and up
to eight quick emojis).
Tile settings: avatars, timestamps, names and channel notices.
- **Clock** — a big booth clock; also the smallest possible module and the - **Clock** — a big booth clock; also the smallest possible module and the
reference for module authors. reference for module authors.
@@ -256,10 +326,12 @@ To develop modules against mocks instead of real gear:
`node tools/prodcom-mock.js` (ProdCom on `127.0.0.1:24480`), `node tools/prodcom-mock.js` (ProdCom on `127.0.0.1:24480`),
`node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter slides on `node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter slides on
`127.0.0.1:1599`), `node tools/pp-timers-mock.js 1600` (ProPresenter `127.0.0.1:1599`), `node tools/pp-timers-mock.js 1600` (ProPresenter
timers + timecode on `127.0.0.1:1600`) and `node tools/pco-mock.js` timers + timecode on `127.0.0.1:1600`), `node tools/pco-mock.js`
(Planning Center Services on `127.0.0.1:24700` — run ProdDash with (Planning Center Services on `127.0.0.1:24700` — run ProdDash with
`PCO_PLAN_API_BASE=http://127.0.0.1:24700`). See the testing section of the `PCO_PLAN_API_BASE=http://127.0.0.1:24700`) and
[module guide](docs/MODULE-GUIDE.md). `node tools/slack-mock.js 24716` (the Slack Web API on `127.0.0.1:24716` —
run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`). See the
testing section of the [module guide](docs/MODULE-GUIDE.md).
## Install as a desktop web app ## Install as a desktop web app
+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": {
+2 -1
View File
@@ -1,6 +1,7 @@
{ {
"port": 24500, "port": 24500,
"adminPasscode": "", "adminPasscode": "",
"repo": "michaelmcgary2008/ProdDash", "forge": "http://chronicles-ab.waterschurch.org:3000",
"repo": "michael/ProdDash",
"branch": "main" "branch": "main"
} }
+132 -25
View File
@@ -34,6 +34,46 @@ people running the booth depend on them.
The admin page refuses to install a module whose requirement the running The admin page refuses to install a module whose requirement the running
shell doesn't meet, and it offers updates by comparing versions. shell doesn't meet, and it offers updates by comparing versions.
### Keeping the interface clean
A dashboard is read across a booth, at a glance, by someone doing three
other things. These rules are what keep every tile looking like one product
instead of six. They are as binding as the four above.
5. **No explanatory text in a tile's gear.** No `help` on an
`instanceSchema` field or an `instanceGroups` entry. Labels are **three
words at most, one or two preferred** — "Slide count", "Item tags",
"Running clock". A control that cannot be understood in three words is
not a display choice; it belongs in `configSchema`, where `help` is
welcome and expected. *One exception:* a `template` field may keep a
single `help` line naming its placeholders, which nobody can guess.
6. **Server-side settings are admin-only.** Anything the module's server
acts on — a host, a credential, what to fetch, how often to poll — is
`configSchema`. `instanceSchema` is display choices and nothing else:
what this tile shows, and how big.
7. **A tile setting changes that tile and nothing else.** Never another
tile, never the module as a whole, never the server. If two tiles must
agree on something, it is either admin config or page state the shell
owns — not a setting each tile keeps its own copy of.
8. **Offer toggles for the elements you draw.** Every tile should let the
operator switch off the parts it does not need. **One to four toggles:**
give them no `group` at all, so they sit unlabelled at the top of the
gear. **Five or more:** one group named **Show**, `collapsed: true`,
`columns: 2`. Nothing else goes in that group — colours, formats and
text belong in groups of their own.
9. **Title-bar controls are icons.** No `label` on `addButton` or
`addMenu`; the tooltip carries the words, and `docs` above covers drawing
the icon. A small state indicator may ride alongside an icon — ProdCom's
channel button shows a count while a filter is on — but a control is
never named in the bar.
10. **If a control needs words to explain it, it is not a title-bar
control.** Put it in the gear. The bar is for actions whose icon and
tooltip say everything: refresh, clear, jump to latest, bigger text.
11. **One control per action.** Whatever has a dedicated title-bar button is
not offered again in the gear, and the reverse. Text size is A− / A+ up
there, never a "Text size" field; a timestamps button in the bar means
no timestamps switch below it.
## The shape of a module ## The shape of a module
A module is one folder dropped into `modules/`: A module is one folder dropped into `modules/`:
@@ -104,7 +144,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 +165,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
@@ -159,17 +217,35 @@ the group name:
`help` on a field puts one line of guidance under its control — allowed `help` on a field puts one line of guidance under its control — allowed
placeholders, where a value comes from. Keep labels short and put the placeholders, where a value comes from. Keep labels short and put the
explanation there. explanation there. **This is for `configSchema` only**: a tile's gear
carries no explanations at all (rule 5), so a setting that needs one is
telling you it belongs in admin.
`group` and `help` work on `instanceSchema` fields too, and a manifest `group` works on `instanceSchema` fields too, and a manifest
`instanceGroups` map plays the `configGroups` role for the tile's gear `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`, `toggle` — but not `help`,
with a long show/hide checklist keeps the popover usable — see `pco-plan`. per rule 5). That is how a module with a long show/hide checklist keeps the
Display choices belong there, per tile; admin config is for connections. popover usable: five or more hide-toggles go in one **Show** group, folded
by default, and fewer than five go in no group at all — see `pco-plan` for
the long form and `slack` for the short one. Display choices belong there,
per tile; admin config is for connections. The popover itself is a small window beside the tile — sized to
its content, moved by the slim bar on top, closed by clicking anywhere else
or pressing Escape — so a tile never has to be big enough to hold its own
settings, and a change can be watched as it lands. Under everything you
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 +362,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 +377,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
@@ -321,12 +398,25 @@ menu.setLabel('Channels (2/4) ▾'); // update the button text any time
``` ```
Everything you add is removed automatically when your instance stops, so a 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. **Icons only, no labels** (rule 9): the
labels): the title bar is 32 px tall and shared with the tile's name. title bar is 32 px tall and shared with the tile's name, and the tooltip
says what the icon means. Draw icons as inline SVG rather than characters —
a glyph such as ✕ or + takes its size from whatever font each browser
picks; text-size buttons are a drawn − and +. A small state indicator may
ride alongside an icon (ProdCom's channel button carries a count while a
filter is on); a name may not.
One more thing to know: the user can hide the whole title bar with the **One control per action** (rule 11). Whatever has a dedicated header button
small notch on its bottom edge (a clean-view mode for wall displays). Your is not offered again in the gear popover: text size is − / + up here
controls disappear with it, so nothing essential — status, reconnects, (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 whole title bar folds away when the dashboard
auto-hides it, and always in fullscreen (a clean-view mode for wall
displays) — it comes back only while the pointer is at the tile's top edge.
Your controls fold away with it, so nothing essential — status, reconnects,
live data — may exist *only* as a header control. live data — may exist *only* as a header control.
`sse(path, handlers)` handlers: `sse(path, handlers)` handlers:
@@ -341,14 +431,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 +565,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 +578,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) {
+259 -48
View File
@@ -1,15 +1,23 @@
{ {
"id": "pco-plan", "id": "pco-plan",
"name": "PCO Plan", "name": "PCO Plan",
"version": "1.4.0", "version": "1.5.0",
"proddash": ">=1.4.0", "proddash": ">=1.4.0",
"description": "Order of service from Planning Center Services", "description": "Order of service from Planning Center Services",
"provides": ["timers"], "provides": [
"timers"
],
"client": "client.js", "client": "client.js",
"server": "server.js", "server": "server.js",
"style": "style.css", "style": "style.css",
"minSize": { "w": 3, "h": 3 }, "minSize": {
"defaultSize": { "w": 5, "h": 6 }, "w": 3,
"h": 3
},
"defaultSize": {
"w": 5,
"h": 6
},
"configGroups": { "configGroups": {
"Planning Center credentials": { "Planning Center credentials": {
"collapsed": "whenSet", "collapsed": "whenSet",
@@ -48,7 +56,12 @@
"type": "select", "type": "select",
"label": "Plan", "label": "Plan",
"default": "", "default": "",
"options": [{ "value": "", "label": "Automatic — today's plan, else the next upcoming" }], "options": [
{
"value": "",
"label": "Automatic — today's plan, else the next upcoming"
}
],
"optionsRoute": "/plans", "optionsRoute": "/plans",
"optionsDependsOn": "serviceTypeId" "optionsDependsOn": "serviceTypeId"
}, },
@@ -82,8 +95,14 @@
"group": "Service timing", "group": "Service timing",
"showWhen": "useDocumentTriggers", "showWhen": "useDocumentTriggers",
"options": [ "options": [
{ "value": "schedule", "label": "Scheduled service time" }, {
{ "value": "document", "label": "ProPresenter document" } "value": "schedule",
"label": "Scheduled service time"
},
{
"value": "document",
"label": "ProPresenter document"
}
] ]
}, },
"serviceStartDocument": { "serviceStartDocument": {
@@ -121,56 +140,248 @@
"collapsed": false, "collapsed": false,
"columns": 2 "columns": 2
}, },
"Show / hide": { "Item notes": {
"toggle": "showNotes"
},
"Show": {
"collapsed": true, "collapsed": true,
"columns": 3, "columns": 2
"help": "Switch off anything this tile should not show."
} }
}, },
"instanceSchema": { "instanceSchema": {
"textSize": { "type": "number", "label": "Text size (px)", "default": 14 }, "autoScroll": {
"autoScroll": { "type": "boolean", "label": "Keep the current item in view", "default": true }, "type": "boolean",
"noteCategories": { "label": "Auto-scroll",
"type": "string", "default": true
"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"
}, },
"keyColor": { "type": "color", "label": "Song key", "default": "#4ea1ff", "group": "Colors" }, {
"headerColor": { "type": "color", "label": "Section headers", "default": "#2ee59a", "group": "Colors" }, "value": "{part}",
"currentColor": { "type": "color", "label": "Current item", "default": "#2ee59a", "group": "Colors" }, "label": "Part / plan title"
"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" }, {
"showTopBar": { "type": "boolean", "label": "Top bar", "default": true, "group": "Show / hide" }, "value": "{serviceType}",
"showSeries": { "type": "boolean", "label": "Series name", "default": true, "group": "Show / hide" }, "label": "Service type"
"showPart": { "type": "boolean", "label": "Part # / plan title", "default": true, "group": "Show / hide" }, },
"showServiceType": { "type": "boolean", "label": "Service type", "default": true, "group": "Show / hide" }, {
"showDate": { "type": "boolean", "label": "Plan date", "default": true, "group": "Show / hide" }, "value": "{date}",
"showServiceTime": { "type": "boolean", "label": "Service time", "default": true, "group": "Show / hide" }, "label": "Date"
"showTotalLength": { "type": "boolean", "label": "Planned total length", "default": true, "group": "Show / hide" }, },
"showRunningClock": { "type": "boolean", "label": "Running clock (ahead / behind)", "default": true, "group": "Show / hide" }, {
"showCountdown": { "type": "boolean", "label": "Countdown to service start", "default": true, "group": "Show / hide" }, "value": "{shortDate}",
"showHeaders": { "type": "boolean", "label": "Section headers", "default": true, "group": "Show / hide" }, "label": "Short date"
"showItemNumbers": { "type": "boolean", "label": "Item numbers", "default": false, "group": "Show / hide" }, },
"showStartTimes": { "type": "boolean", "label": "Scheduled start times", "default": true, "group": "Show / hide" }, {
"showLength": { "type": "boolean", "label": "Item length", "default": true, "group": "Show / hide" }, "value": "{time}",
"showItemType": { "type": "boolean", "label": "Item type badge", "default": true, "group": "Show / hide" }, "label": "Service time"
"showKey": { "type": "boolean", "label": "Song key", "default": true, "group": "Show / hide" }, },
"showArrangement": { "type": "boolean", "label": "Song arrangement", "default": false, "group": "Show / hide" }, {
"showSongAuthor": { "type": "boolean", "label": "Song author / CCLI", "default": false, "group": "Show / hide" }, "value": "{length}",
"showDescription": { "type": "boolean", "label": "Item description", "default": true, "group": "Show / hide" }, "label": "Planned length"
"showNotes": { "type": "boolean", "label": "Item notes", "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" }, "value": "{items}",
"showNowIndicator": { "type": "boolean", "label": "Now indicator", "default": true, "group": "Show / hide" }, "label": "Item count"
"showItemRuntime": { "type": "boolean", "label": "Item runtime (elapsed vs planned)", "default": true, "group": "Show / hide" }, }
"dimCompleted": { "type": "boolean", "label": "Dim completed items", "default": true, "group": "Show / hide" }, ],
"showLiveFooter": { "type": "boolean", "label": "ProPresenter match line", "default": true, "group": "Show / hide" } "help": "Use • between items and | to start a second line."
},
"keyColor": {
"type": "color",
"label": "Song key",
"default": "#4ea1ff",
"group": "Colors"
},
"headerColor": {
"type": "color",
"label": "Section headers",
"default": "#2ee59a",
"group": "Colors"
},
"currentColor": {
"type": "color",
"label": "Current item",
"default": "#2ee59a",
"group": "Colors"
},
"headerGradient": {
"type": "boolean",
"label": "Header fade",
"default": true,
"group": "Colors"
},
"tintByType": {
"type": "boolean",
"label": "Tint by type",
"default": false,
"group": "Colors"
},
"showNotes": {
"type": "switch",
"label": "Item notes",
"default": true,
"group": "Item notes"
},
"noteCategories": {
"type": "multiselect",
"label": "Categories",
"group": "Item notes",
"optionsRoute": "/note-categories",
"columns": 3,
"allByDefault": true
},
"showTopBar": {
"type": "boolean",
"label": "Top bar",
"default": true,
"group": "Show"
},
"showSeries": {
"type": "boolean",
"label": "Series name",
"default": true,
"group": "Show"
},
"showPart": {
"type": "boolean",
"label": "Part / title",
"default": true,
"group": "Show"
},
"showServiceType": {
"type": "boolean",
"label": "Service type",
"default": true,
"group": "Show"
},
"showDate": {
"type": "boolean",
"label": "Plan date",
"default": true,
"group": "Show"
},
"showServiceTime": {
"type": "boolean",
"label": "Service time",
"default": true,
"group": "Show"
},
"showTotalLength": {
"type": "boolean",
"label": "Total length",
"default": true,
"group": "Show"
},
"showRunningClock": {
"type": "boolean",
"label": "Running clock",
"default": true,
"group": "Show"
},
"showCountdown": {
"type": "boolean",
"label": "Countdown",
"default": true,
"group": "Show"
},
"showHeaders": {
"type": "boolean",
"label": "Section headers",
"default": true,
"group": "Show"
},
"showItemNumbers": {
"type": "boolean",
"label": "Item numbers",
"default": false,
"group": "Show"
},
"showStartTimes": {
"type": "boolean",
"label": "Start times",
"default": true,
"group": "Show"
},
"showLength": {
"type": "boolean",
"label": "Item length",
"default": true,
"group": "Show"
},
"showItemType": {
"type": "boolean",
"label": "Type badge",
"default": true,
"group": "Show"
},
"showKey": {
"type": "boolean",
"label": "Song key",
"default": true,
"group": "Show"
},
"showArrangement": {
"type": "boolean",
"label": "Arrangement",
"default": false,
"group": "Show"
},
"showSongAuthor": {
"type": "boolean",
"label": "Author / CCLI",
"default": false,
"group": "Show"
},
"showDescription": {
"type": "boolean",
"label": "Item description",
"default": true,
"group": "Show"
},
"showPreService": {
"type": "boolean",
"label": "Pre-service",
"default": true,
"group": "Show"
},
"showPostService": {
"type": "boolean",
"label": "Post-service",
"default": true,
"group": "Show"
},
"showNowIndicator": {
"type": "boolean",
"label": "Now indicator",
"default": true,
"group": "Show"
},
"showItemRuntime": {
"type": "boolean",
"label": "Item runtime",
"default": true,
"group": "Show"
},
"dimCompleted": {
"type": "boolean",
"label": "Dim completed",
"default": true,
"group": "Show"
},
"showLiveFooter": {
"type": "boolean",
"label": "Match line",
"default": true,
"group": "Show"
}
} }
} }
+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
}, },
+20 -17
View File
@@ -1,22 +1,35 @@
{ {
"id": "prodcom-transcript", "id": "prodcom-transcript",
"name": "ProdCom", "name": "ProdCom",
"version": "1.2.1", "version": "1.3.2",
"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",
@@ -27,18 +40,8 @@
"instanceSchema": { "instanceSchema": {
"showAvatars": { "showAvatars": {
"type": "boolean", "type": "boolean",
"label": "Show channel icons", "label": "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));
},
}; };
}, },
}; };
+39 -9
View File
@@ -6,6 +6,12 @@
in /admin; the tile only renders. */ in /admin; the tile only renders. */
export default function create({ root, moduleApi }) { export default function create({ root, moduleApi }) {
/* "now" is the live item on its own, filling the tile; "full" is that plus
the rest of the playlist. The picker presets `view`; a tile placed before
this module had a picker has neither, and gets the full card. */
const view = String(moduleApi.instanceSettings.view || moduleApi.variant || 'full');
const nowOnly = view === 'now';
let state = null; let state = null;
let feedOffline = false; let feedOffline = false;
let stream = null; let stream = null;
@@ -33,11 +39,13 @@ export default function create({ root, moduleApi }) {
</div>`; </div>`;
const screenEl = root.querySelector('.nn-screen'); const screenEl = root.querySelector('.nn-screen');
if (nowOnly) screenEl.classList.add('nn-only-now');
const topRow = root.querySelector('.nn-top'); const topRow = root.querySelector('.nn-top');
const playlistNameEl = root.querySelector('.nn-playlist'); const playlistNameEl = root.querySelector('.nn-playlist');
const liveEl = root.querySelector('.nn-live'); const liveEl = root.querySelector('.nn-live');
const liveTextEl = root.querySelector('.nn-live-text'); const liveTextEl = root.querySelector('.nn-live-text');
const nowEl = root.querySelector('.nn-now'); const nowEl = root.querySelector('.nn-now');
const nowLabelEl = root.querySelector('.nn-label');
const nowTitleBox = root.querySelector('.nn-title-box'); const nowTitleBox = root.querySelector('.nn-title-box');
let nowTitleEl = root.querySelector('.nn-title'); let nowTitleEl = root.querySelector('.nn-title');
const nowMetaEl = root.querySelector('.nn-meta'); const nowMetaEl = root.querySelector('.nn-meta');
@@ -47,9 +55,11 @@ export default function create({ root, moduleApi }) {
const nextListEl = root.querySelector('.nn-next-list'); const nextListEl = root.querySelector('.nn-next-list');
const nextMoreEl = root.querySelector('.nn-next-more'); const nextMoreEl = root.querySelector('.nn-next-more');
/** A setting, defaulting to on — a Now tile's schema omits the Next ones. */
function prefs() { function prefs() {
return moduleApi.instanceSettings; // showPlaylist / showProgress / cleanTitles return moduleApi.instanceSettings;
} }
const on = (key) => prefs()[key] !== false;
/* ── title tidying (same rule as propresenter-core.cleanItemName) ─ */ /* ── title tidying (same rule as propresenter-core.cleanItemName) ─ */
@@ -65,7 +75,7 @@ export default function create({ root, moduleApi }) {
} }
function displayName(raw) { function displayName(raw) {
return prefs().cleanTitles ? cleanItemName(raw) : String(raw || '').trim(); return on('cleanTitles') ? cleanItemName(raw) : String(raw || '').trim();
} }
function itemTypeTag(type) { function itemTypeTag(type) {
@@ -307,7 +317,9 @@ export default function create({ root, moduleApi }) {
const total = state.currentSlideTotal || activeItem?.slideCount || 0; const total = state.currentSlideTotal || activeItem?.slideCount || 0;
const current = state.currentSlide || 0; const current = state.currentSlide || 0;
if (total > 0 && current > 0) { if (!on('showSlideCount')) {
nowMetaEl.textContent = '';
} else if (total > 0 && current > 0) {
const remaining = Math.max(0, total - current); const remaining = Math.max(0, total - current);
nowMetaEl.textContent = `Slide ${current} / ${total} · ${remaining} left`; nowMetaEl.textContent = `Slide ${current} / ${total} · ${remaining} left`;
} else if (total > 0) { } else if (total > 0) {
@@ -318,7 +330,7 @@ export default function create({ root, moduleApi }) {
nowMetaEl.textContent = ''; nowMetaEl.textContent = '';
} }
if (prefs().showProgress && total > 0) { if (on('showProgress') && total > 0) {
progressEl.hidden = false; progressEl.hidden = false;
const pct = current > 0 ? Math.max(0, Math.min(100, (current / total) * 100)) : 0; const pct = current > 0 ? Math.max(0, Math.min(100, (current / total) * 100)) : 0;
progressFillEl.style.width = `${pct}%`; progressFillEl.style.width = `${pct}%`;
@@ -349,6 +361,10 @@ export default function create({ root, moduleApi }) {
nameEl.textContent = displayName(item.name) || '(untitled)'; nameEl.textContent = displayName(item.name) || '(untitled)';
row.appendChild(nameEl); row.appendChild(nameEl);
if (!on('showTags')) {
nextListEl.appendChild(row);
continue;
}
if (item.type === 'presentation' && Number.isInteger(item.slideCount)) { if (item.type === 'presentation' && Number.isInteger(item.slideCount)) {
const chip = document.createElement('div'); const chip = document.createElement('div');
chip.className = 'nn-count-chip'; chip.className = 'nn-count-chip';
@@ -366,7 +382,7 @@ export default function create({ root, moduleApi }) {
nextListEl.appendChild(row); nextListEl.appendChild(row);
} }
nextLabelEl.hidden = upcoming.length === 0; nextLabelEl.hidden = upcoming.length === 0 || !on('showNextLabel');
nextMoreEl.hidden = true; nextMoreEl.hidden = true;
markNextOverflow(); markNextOverflow();
requestAnimationFrame(markNextOverflow); requestAnimationFrame(markNextOverflow);
@@ -374,12 +390,22 @@ export default function create({ root, moduleApi }) {
function render() { function render() {
screenEl.classList.toggle('is-offline', feedOffline); screenEl.classList.toggle('is-offline', feedOffline);
const showTop = prefs().showPlaylist || feedOffline || !state?.reachable || !state?.enabled; // Trouble always shows, whatever is switched off: a tile that has stopped
topRow.classList.toggle('hidden-row', !showTop); // updating must never look like one with nothing live.
playlistNameEl.textContent = prefs().showPlaylist ? state?.playlistName || '' : ''; const trouble = feedOffline || !state?.reachable || !state?.enabled;
const wantPlaylist = on('showPlaylist');
const wantLive = on('showLive') || trouble;
topRow.classList.toggle('hidden-row', !wantPlaylist && !wantLive);
liveEl.hidden = !wantLive;
// Hidden, not just emptied: it is flex:1, so an emptied one still holds
// the row open and pushes the live dot off the right edge.
playlistNameEl.hidden = !wantPlaylist;
playlistNameEl.textContent = state?.playlistName || '';
nowLabelEl.hidden = !on('showNowLabel');
screenEl.classList.toggle('nn-no-next', nowOnly || !on('showNext'));
renderLive(); renderLive();
renderNow(); renderNow();
renderNext(); if (!nowOnly && on('showNext')) renderNext();
} }
/* ── sizing: --unit scales the whole card to the tile ───────────── */ /* ── sizing: --unit scales the whole card to the tile ───────────── */
@@ -387,6 +413,10 @@ export default function create({ root, moduleApi }) {
function applyUnit() { function applyUnit() {
const unit = Math.max(6.5, Math.min(20, root.clientHeight * 0.032)); const unit = Math.max(6.5, Math.min(20, root.clientHeight * 0.032));
screenEl.style.setProperty('--unit', unit.toFixed(2) + 'px'); screenEl.style.setProperty('--unit', unit.toFixed(2) + 'px');
// On a Now tile the top row floats over the gradient, so at the shortest
// sizes it would sit on the title. Below the height it needs, it goes —
// the header dot and the title itself still report trouble.
screenEl.classList.toggle('nn-tight', nowOnly && root.clientHeight < unit * 9);
} }
/* ── live feed ──────────────────────────────────────────────────── */ /* ── live feed ──────────────────────────────────────────────────── */
+78 -10
View File
@@ -1,32 +1,100 @@
{ {
"id": "propresenter-now-next", "id": "propresenter-now-next",
"name": "ProPresenter", "name": "ProPresenter",
"version": "1.0.1", "version": "1.1.1",
"proddash": ">=1.1.0", "proddash": ">=1.5.0",
"description": "What is live in ProPresenter and what comes next", "description": "What is live in ProPresenter and what comes next",
"permissions": [ "permissions": [
{ "kind": "local-network", "reason": "Reaching ProPresenter on this network" } {
"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": 3, "h": 3 }, "minSize": {
"defaultSize": { "w": 4, "h": 5 }, "w": 3,
"h": 3
},
"defaultSize": {
"w": 4,
"h": 5
},
"configSchema": { "configSchema": {
"propresenter": { "propresenter": {
"type": "endpoint", "type": "endpoint",
"label": "ProPresenter", "label": "ProPresenter",
"default": { "host": "", "port": 1025 } "default": {
"host": "",
"port": 1025
}
}, },
"password": { "password": {
"type": "password", "type": "password",
"label": "API password (only if set in ProPresenter)", "label": "Password",
"default": "" "default": ""
} }
}, },
"instanceSchema": { "instanceSchema": {
"showPlaylist": { "type": "boolean", "label": "Show the playlist name", "default": true }, "cleanTitles": {
"showProgress": { "type": "boolean", "label": "Show the slide progress bar", "default": true }, "type": "switch",
"cleanTitles": { "type": "boolean", "label": "Tidy titles (hide [ Full ] tags and date codes)", "default": true } "label": "Tidy titles",
"default": true
},
"showPlaylist": {
"type": "switch",
"label": "Playlist",
"default": true,
"group": "Show"
},
"showLive": {
"type": "switch",
"label": "Live status",
"default": true,
"group": "Show"
},
"showNowLabel": {
"type": "switch",
"label": "Now label",
"default": true,
"group": "Show"
},
"showSlideCount": {
"type": "switch",
"label": "Slide count",
"default": true,
"group": "Show"
},
"showProgress": {
"type": "switch",
"label": "Progress bar",
"default": true,
"group": "Show"
},
"showNext": {
"type": "switch",
"label": "Next list",
"default": true,
"group": "Show"
},
"showNextLabel": {
"type": "switch",
"label": "Next label",
"default": true,
"group": "Show"
},
"showTags": {
"type": "switch",
"label": "Item tags",
"default": true,
"group": "Show"
}
},
"instanceGroups": {
"Show": {
"collapsed": true,
"columns": 2
}
} }
} }
+37
View File
@@ -108,6 +108,43 @@ module.exports = {
}; };
}, },
/**
* Two ways to place this module. "Now / Next" is the full card — what is
* live, then the rest of the playlist. "Now" is the same live item alone,
* edge to edge with the gradient filling the tile: a wall display that says
* one thing. The Now entry offers only the settings it can act on, so its
* gear carries no Next switches.
*/
tiles() {
return [
{
id: 'full',
name: 'Now / Next',
description: 'What is live, and the rest of the playlist',
defaultSize: { w: 4, h: 5 },
minSize: { w: 3, h: 3 },
settings: { view: 'full' },
},
{
id: 'now',
name: 'Now',
description: 'Only what is live, filling the tile',
defaultSize: { w: 4, h: 2 },
minSize: { w: 2, h: 1 },
settings: { view: 'now' },
instanceSchema: {
cleanTitles: { type: 'switch', label: 'Tidy titles', default: true },
showPlaylist: { type: 'switch', label: 'Playlist', default: true, group: 'Show' },
showLive: { type: 'switch', label: 'Live status', default: true, group: 'Show' },
showNowLabel: { type: 'switch', label: 'Now label', default: true, group: 'Show' },
showSlideCount: { type: 'switch', label: 'Slide count', default: true, group: 'Show' },
showProgress: { type: 'switch', label: 'Progress bar', default: true, group: 'Show' },
},
instanceGroups: { Show: { collapsed: true, columns: 2 } },
},
];
},
routes() { routes() {
return { return {
'GET /state': (req, res) => { 'GET /state': (req, res) => {
+48
View File
@@ -21,6 +21,51 @@
justify-content: center; justify-content: center;
} }
/* NEXT switched off, or a Now tile: the list goes and the card takes the room. */
[data-module="propresenter-now-next"] .nn-screen.nn-no-next .nn-next-wrap { display: none; }
[data-module="propresenter-now-next"] .nn-screen.nn-no-next { justify-content: center; }
/* ── the "Now" tile ──
Only what is live, edge to edge: the gradient moves off the card and onto
the whole tile, the card loses its border, radius and box, and the title
sits in the middle of everything. Nothing is inset, so a wall of these
reads as panels of colour rather than cards on a background. */
[data-module="propresenter-now-next"] .nn-screen.nn-only-now {
gap: 0;
background: linear-gradient(
160deg,
color-mix(in srgb, var(--accent) 14%, transparent),
color-mix(in srgb, var(--bg) 70%, transparent)
);
}
[data-module="propresenter-now-next"] .nn-only-now .nn-now {
flex: 1;
height: auto;
min-height: 0;
justify-content: center;
border: none;
border-radius: 0;
background: none;
padding: 0;
}
/* The live dot and playlist ride over the gradient rather than above it. */
[data-module="propresenter-now-next"] .nn-only-now .nn-top {
position: absolute;
inset: var(--nn-pad) var(--nn-pad) auto;
pointer-events: none;
}
[data-module="propresenter-now-next"] .nn-only-now .nn-title-box { margin-top: 0; }
/* The overlay floats, so it takes no room — this keeps the title clear of it
and still centred, by holding back as much at the bottom as at the top. */
[data-module="propresenter-now-next"] .nn-only-now:not(.nn-tight) .nn-now {
padding: calc(var(--unit) * 1.4) 0;
}
/* Too short for the overlay: the title gets the whole panel to itself. */
[data-module="propresenter-now-next"] .nn-only-now.nn-tight .nn-top,
[data-module="propresenter-now-next"] .nn-only-now.nn-tight.is-offline .nn-top {
display: none !important;
}
/* ── top row ── */ /* ── top row ── */
[data-module="propresenter-now-next"] .nn-top { [data-module="propresenter-now-next"] .nn-top {
@@ -59,6 +104,7 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
[data-module="propresenter-now-next"] .nn-playlist[hidden] { display: none; }
[data-module="propresenter-now-next"] .nn-live { [data-module="propresenter-now-next"] .nn-live {
display: flex; display: flex;
@@ -71,6 +117,7 @@
text-transform: uppercase; text-transform: uppercase;
color: var(--muted); color: var(--muted);
} }
[data-module="propresenter-now-next"] .nn-live[hidden] { display: none; }
[data-module="propresenter-now-next"] .nn-live-dot { [data-module="propresenter-now-next"] .nn-live-dot {
width: calc(var(--unit) * 0.6); width: calc(var(--unit) * 0.6);
@@ -122,6 +169,7 @@
text-transform: uppercase; text-transform: uppercase;
color: var(--accent); color: var(--accent);
} }
[data-module="propresenter-now-next"] .nn-label[hidden] { display: none; }
[data-module="propresenter-now-next"] .nn-title-box { [data-module="propresenter-now-next"] .nn-title-box {
flex: 1; flex: 1;
+241 -57
View File
@@ -15,9 +15,13 @@
Per-tile choices: the gear popover's static switches (whole sources, Per-tile choices: the gear popover's static switches (whole sources,
headings, status lines, the clock's format, the state colours) and the headings, status lines, the clock's format, the state colours) and the
"Timers ▾" header menu, which hides any single timer — keyed `source:id` timers header menu, which hides any single timer — keyed `source:id`
and saved with the layout via saveInstanceSettings. The header can be 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) {
@@ -163,6 +196,10 @@ function defaultStatus(timer, value, tone) {
/* ── the tile ─────────────────────────────────────────────────────────── */ /* ── the tile ─────────────────────────────────────────────────────────── */
/* The header menu's icon: a stopwatch, for the list of timers behind it.
Title-bar controls carry no words (module guide, rule 9). */
const TIMERS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 2h4"/><path d="M12 14V9"/><circle cx="12" cy="14" r="8"/></svg>';
export default function create({ root, moduleApi }) { export default function create({ root, moduleApi }) {
let state = null; let state = null;
let feedOffline = false; let feedOffline = false;
@@ -174,6 +211,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
@@ -185,7 +231,7 @@ export default function create({ root, moduleApi }) {
} }
/* Which picker entry this tile was added as (the module's tiles() list): /* Which picker entry this tile was added as (the module's tiles() list):
'' or 'all' = every timer, selectable via the Timers ▾ menu; '' or 'all' = every timer, selectable via the timers menu;
'solo:<source>:<id>' = exactly one card, no menu, no headings. The 'solo:<source>:<id>' = exactly one card, no menu, no headings. The
earlier entries 'timer:<uuid>' and 'ltc' map onto the new keys. */ earlier entries 'timer:<uuid>' and 'ltc' map onto the new keys. */
const variant = String(moduleApi.variant || ''); const variant = String(moduleApi.variant || '');
@@ -241,8 +287,8 @@ 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 ▾', icon: TIMERS_SVG,
title: 'Choose which timers this tile shows', title: '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');
@@ -250,6 +296,12 @@ export default function create({ root, moduleApi }) {
rebuildMenu(true); rebuildMenu(true);
}, },
}); });
// The icon carries the name; a count rides alongside it while some timers
// are hidden, so the bar says "filtered" without saying it in words.
const itemsCountEl = document.createElement('span');
itemsCountEl.className = 'tm-count';
itemsCountEl.hidden = true;
if (itemsMenu) itemsMenu.button.appendChild(itemsCountEl);
function menuRows() { function menuRows() {
const rows = []; const rows = [];
@@ -277,7 +329,9 @@ export default function create({ root, moduleApi }) {
if (!itemsMenu) return; if (!itemsMenu) return;
const rows = menuRows(); const rows = menuRows();
const visible = rows.filter((r) => !hidden.has(r.key)).length; const visible = rows.filter((r) => !hidden.has(r.key)).length;
itemsMenu.setLabel(visible === rows.length ? 'Timers ▾' : `Timers (${visible}/${rows.length}) ▾`); const filtering = visible !== rows.length;
itemsCountEl.textContent = filtering ? `${visible}/${rows.length}` : '';
itemsCountEl.hidden = !filtering;
} }
let menuSignature = ''; let menuSignature = '';
@@ -322,11 +376,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 +395,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 +404,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 +451,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 +462,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 +488,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 +501,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 +627,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 +644,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 +660,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 +671,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 +688,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,16 +773,25 @@ 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();
if (state && !state.sources.length) hint = 'Every source is switched off — turn one on in Admin.'; if (state && !state.sources.length) hint = 'Every source is switched off — turn one on in Admin.';
else if (state && !groups.length) hint = 'Nothing selected — switch a source on in this tile’s settings, or pick timers in the Timers ▾ menu.'; else if (state && !groups.length) hint = 'Nothing selected — switch a source on in this tile’s settings, or pick timers in the title bar’s timers menu.';
hintEl.hidden = !hint; hintEl.hidden = !hint;
hintEl.textContent = hint; hintEl.textContent = hint;
groupsEl.hidden = Boolean(hint); groupsEl.hidden = Boolean(hint);
@@ -690,18 +808,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 +872,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 +907,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`;
} }
} }
+139 -34
View File
@@ -1,19 +1,33 @@
{ {
"id": "propresenter-timers", "id": "propresenter-timers",
"name": "Timers", "name": "Timers",
"version": "1.3.0", "version": "1.4.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,42 +99,130 @@
"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": true,
"columns": 2
},
"Clock": {
"collapsed": false,
"columns": 2
},
"Colours": { "Colours": {
"collapsed": true, "collapsed": true,
"columns": 2, "columns": 2
"help": "The time's colour per state. Left at the default, a colour follows the dashboard theme; one you change here wins over it."
} }
}, },
"instanceSchema": { "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;
+42 -3
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;
@@ -290,7 +321,7 @@
@keyframes tm-nosignal-blink { 50% { opacity: 0.35; } } @keyframes tm-nosignal-blink { 50% { opacity: 0.35; } }
/* ── the "Timers ▾" menu (container styled/positioned by the shell) ── */ /* ── the timers menu (container styled/positioned by the shell) ── */
[data-module="propresenter-timers"] .tm-menu-item { [data-module="propresenter-timers"] .tm-menu-item {
display: flex; display: flex;
@@ -352,3 +383,11 @@
font-size: 12px; font-size: 12px;
white-space: nowrap; white-space: nowrap;
} }
/* the count beside the title-bar timers icon, while some are hidden */
[data-module="propresenter-timers"] .tm-count {
font-size: 10px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
[data-module="propresenter-timers"] .tm-count[hidden] { display: none; }
+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.1",
"proddash": ">=1.4.0",
"description": "Live transcripts of this location's Slack channels, plus PIN-protected sending as the location",
"client": "client.js",
"server": "server.js",
"style": "style.css",
"minSize": {
"w": 2,
"h": 2
},
"defaultSize": {
"w": 4,
"h": 4
},
"configGroups": {
"Slack account": {
"collapsed": "whenSet",
"help": "Create a Slack app in the workspace (api.slack.com/apps → Create New App → From scratch) and, under OAuth & Permissions → User Token Scopes, add channels:history, channels:read, groups:history, groups:read, chat:write, users:read, reactions:write (emoji reactions) and emoji:read (the workspace's custom emoji). Sign in to Slack as this location's account — the one named for the campus — open the app's OAuth & Permissions page and press Install to Workspace (or Reinstall to Workspace), then copy the User OAuth Token (xoxp-…) it shows and paste it here: messages from this machine are then literally from the location. A bot token (xoxb-…) works too; it posts as the app, with \"Send as\" naming the location."
},
"Channels": {
"help": "Every channel picked here becomes its own transcript tile in the + picker, titled with the channel's name. The Send message and Quick replies tiles post to whichever transcript was clicked last — its bottom edge lights up in the accent colour — and until one is clicked, to the first channel here whose transcript is on that dashboard: with a single transcript up, that one. A send never goes to a channel the dashboard is not showing. Only channels the account has joined are listed; join one in Slack first. Each channel is read on its own schedule, all of them together kept inside Slack's rate limit."
},
"Sending": {
"toggle": "sendEnabled",
"help": "Messages and emoji reactions leave this machine only after the PIN is entered — on a Send or Quick replies tile, or in the reaction menu a right-click on a transcript message opens. One PIN unlocks every Slack tile on that dashboard page until Auto-lock minutes pass without a send; a reload asks again. Sending stays refused until a 4–8 digit PIN is set here. A message goes to the channel of the transcript clicked last. Quick emojis are sent with one press from the Send message tile and offered first when reacting. Quick replies are one per line, Label | Message, with an optional | #channel-name to bind that one to a channel: a bound reply always goes there, whichever transcript is selected."
}
},
"configSchema": {
"token": {
"type": "password",
"label": "Token",
"default": "",
"group": "Slack account",
"help": "The location account's User OAuth Token (xoxp-…, recommended) or a Bot User OAuth Token (xoxb-…)."
},
"sendAs": {
"type": "string",
"label": "Send as",
"default": "",
"group": "Slack account",
"help": "Only used with a bot token — a user token always sends as its own account; needs the chat:write.customize scope."
},
"channels": {
"type": "multiselect",
"label": "Channels",
"default": [],
"group": "Channels",
"optionsRoute": "/channels",
"help": "One transcript tile per channel; until a transcript is clicked, sends go to the first channel here that the dashboard is actually showing. Only channels the account has joined are listed — join one in Slack first."
},
"pollSeconds": {
"type": "number",
"label": "Refresh interval (s)",
"default": 2,
"group": "Transcript",
"help": "How often each channel is re-read (1 s at the fastest). With several channels the interval stretches so the module stays inside Slack's rate limit — at least 1.5 s × the number of channels; the health line shows the interval in use."
},
"historyCount": {
"type": "number",
"label": "Messages kept",
"default": 40,
"group": "Transcript"
},
"includeReplies": {
"type": "switch",
"label": "Thread replies",
"default": false,
"group": "Transcript"
},
"sendEnabled": {
"type": "switch",
"label": "Sending",
"default": false,
"group": "Sending"
},
"pin": {
"type": "password",
"label": "PIN",
"default": "",
"group": "Sending",
"help": "4–8 digits; the pad unlocks by itself once they are all in."
},
"pinIdleMinutes": {
"type": "number",
"label": "Auto-lock (min)",
"default": 10,
"group": "Sending"
},
"quickReplies": {
"type": "text",
"label": "Quick replies",
"default": "",
"group": "Sending",
"help": "One per line: Label | Message. Add | #channel-name to bind one to a channel — it then ignores which transcript is selected."
},
"quickEmojis": {
"type": "string",
"label": "Quick emojis",
"default": "👍 🙏 ✅ 👀 🎉",
"group": "Sending",
"help": "Emoji or :codes: separated by spaces, eight at most. One press sends one from the Send message tile; they also head the reaction menu."
}
},
"instanceSchema": {
"showAvatars": {
"type": "switch",
"label": "Avatars",
"default": true
},
"showTimes": {
"type": "switch",
"label": "Timestamps",
"default": true
},
"showNames": {
"type": "switch",
"label": "Names",
"default": true
},
"showSystem": {
"type": "switch",
"label": "Channel notices",
"default": true
}
}
}
File diff suppressed because it is too large Load Diff
+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.6.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');
+68
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`) */
@@ -490,3 +512,49 @@ a.btn { text-decoration: none; }
color: var(--accent); color: var(--accent);
} }
#theme-status { margin-top: 4px; } #theme-status { margin-top: 4px; }
/* ── the admin PIN ── */
/* Digits, evenly spaced, so six of them read as a PIN rather than a word. */
.pin-input {
font-variant-numeric: tabular-nums;
letter-spacing: 0.35em;
text-align: center;
max-width: 9em;
}
.pin-form {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 12px;
}
.pin-field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: var(--muted);
}
.pin-field[hidden] { display: none; }
.pin-field input {
padding: 8px 10px;
font-size: 14px;
color: var(--text);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 7px;
}
.pin-field input:focus { outline: none; border-color: var(--accent); }
.pin-actions {
display: flex;
gap: 8px;
margin-left: auto;
}
.pin-note { margin-top: 12px; }
.pin-note code {
font-size: 0.92em;
padding: 1px 5px;
border-radius: 4px;
background: var(--bg);
border: 1px solid var(--border);
}
+170 -2
View File
@@ -2,8 +2,10 @@
Everything here is server-wide: module enable/disable and admin config Everything here is server-wide: module enable/disable and admin config
(written to config/modules.json, module re-inited on save), plus named (written to config/modules.json, module re-inited on save), plus named
layout management. Guarded by the shared passcode when one is set. */ layout management. Behind the admin PIN when one is set — a shell setting of
its own, nothing to do with a module's PIN (Slack's send PIN, say). */
const securitySection = document.getElementById('security-section');
const loginCard = document.getElementById('login-card'); const loginCard = document.getElementById('login-card');
const loginStatus = document.getElementById('login-status'); const loginStatus = document.getElementById('login-status');
const passcodeInput = document.getElementById('passcode'); const passcodeInput = document.getElementById('passcode');
@@ -62,20 +64,30 @@ function showLogin() {
layoutsSection.hidden = true; layoutsSection.hidden = true;
updateCard.hidden = true; updateCard.hidden = true;
themeSection.hidden = true; themeSection.hidden = true;
securitySection.hidden = true;
passcodeInput.value = '';
passcodeInput.focus(); passcodeInput.focus();
} }
let loggingIn = false;
async function login() { async function login() {
if (loggingIn) return;
loggingIn = true;
loginStatus.textContent = ''; loginStatus.textContent = '';
loginStatus.className = 'admin-status'; loginStatus.className = 'admin-status';
try { try {
await api('/api/admin/login', { method: 'POST', body: JSON.stringify({ passcode: passcodeInput.value }) }); await api('/api/admin/login', { method: 'POST', body: JSON.stringify({ pin: passcodeInput.value }) });
passcodeInput.value = ''; passcodeInput.value = '';
loginCard.hidden = true; loginCard.hidden = true;
await loadState(); await loadState();
} catch (e) { } catch (e) {
passcodeInput.value = '';
loginStatus.textContent = e.message; loginStatus.textContent = e.message;
loginStatus.className = 'admin-status error'; loginStatus.className = 'admin-status error';
passcodeInput.focus();
} finally {
loggingIn = false;
} }
} }
@@ -83,6 +95,83 @@ document.getElementById('login-btn').addEventListener('click', login);
passcodeInput.addEventListener('keydown', (e) => { passcodeInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') login(); if (e.key === 'Enter') login();
}); });
// Six digits is the whole PIN, so there is nothing left to press.
passcodeInput.addEventListener('input', () => {
passcodeInput.value = passcodeInput.value.replace(/\D/g, '').slice(0, 6);
if (passcodeInput.value.length === 6) login();
});
/* ── the admin PIN ───────────────────────────────────────────────────── */
const pinCurrentField = document.getElementById('pin-current-field');
const pinCurrent = document.getElementById('pin-current');
const pinNew = document.getElementById('pin-new');
const pinRepeat = document.getElementById('pin-repeat');
const pinSaveBtn = document.getElementById('pin-save');
const pinClearBtn = document.getElementById('pin-clear');
const pinStatus = document.getElementById('pin-status');
const pinStateEl = document.getElementById('pin-state');
for (const input of [pinCurrent, pinNew, pinRepeat]) {
input.addEventListener('input', () => {
input.value = input.value.replace(/\D/g, '').slice(0, 6);
});
}
/** Paint the section for "no PIN yet" or "one is set". */
function renderPin(state) {
const set = Boolean(state.pinSet);
const fromEnv = Boolean(state.pinFromEnv);
securitySection.hidden = false;
pinCurrentField.hidden = !set;
pinClearBtn.hidden = !set;
document.getElementById('pin-new-label').textContent = set ? 'New PIN' : 'PIN';
pinSaveBtn.textContent = set ? 'Change PIN' : 'Set PIN';
pinStateEl.textContent = fromEnv
? 'A passcode is set in this server’s environment (PRODDASH_PASSCODE) and takes precedence. Anything set here applies once that is removed.'
: set
? 'Admin is locked. This page asks for the PIN, and so does every admin request.'
: 'Admin is open to anyone on the network. Set a 6-digit PIN to lock it.';
if (state.dataDir) document.getElementById('pin-file').textContent = state.dataDir + '/proddash.json';
}
function pinMessage(text, bad = false) {
pinStatus.textContent = text;
pinStatus.className = 'admin-status' + (bad ? ' error' : ' ok');
}
async function savePin(clearing = false) {
pinStatus.textContent = '';
pinStatus.className = 'admin-status';
const current = pinCurrent.value;
const next = clearing ? '' : pinNew.value;
if (!clearing) {
if (!/^\d{6}$/.test(next)) return pinMessage('The PIN must be exactly 6 digits.', true);
if (next !== pinRepeat.value) return pinMessage('The two PINs are not the same.', true);
}
if (!pinCurrentField.hidden && !/^\d{6}$/.test(current)) {
return pinMessage('Enter the current PIN to change it.', true);
}
pinSaveBtn.disabled = true;
pinClearBtn.disabled = true;
try {
const body = await api('/api/admin/pin', { method: 'POST', body: JSON.stringify({ pin: next, current }) });
for (const i of [pinCurrent, pinNew, pinRepeat]) i.value = '';
pinMessage(body.pinSet ? 'PIN saved. It is asked for from the next visit.' : 'PIN removed — admin is open again.');
await loadState();
} catch (e) {
pinMessage(e.message, true);
} finally {
pinSaveBtn.disabled = false;
pinClearBtn.disabled = false;
}
}
pinSaveBtn.addEventListener('click', () => savePin(false));
pinClearBtn.addEventListener('click', () => {
if (confirm('Turn the admin PIN off? Anyone on the network could then change this server’s settings.')) savePin(true);
});
pinRepeat.addEventListener('keydown', (e) => { if (e.key === 'Enter') savePin(false); });
/* ── module cards ───────────────────────────────────────────────────── */ /* ── module cards ───────────────────────────────────────────────────── */
@@ -228,6 +317,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 +395,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;
@@ -778,6 +945,7 @@ async function loadState() {
for (const mod of state.modules || []) { for (const mod of state.modules || []) {
modulesList.appendChild(buildModuleCard(mod)); modulesList.appendChild(buildModuleCard(mod));
} }
renderPin(state);
loadTheme(); loadTheme();
loadLayouts(); loadLayouts();
loadCatalog(false); loadCatalog(false);
+36 -2
View File
@@ -18,10 +18,11 @@
<main id="admin-main"> <main id="admin-main">
<section class="admin-card" id="login-card" hidden> <section class="admin-card" id="login-card" hidden>
<h2>Admin passcode</h2> <h2>Admin PIN</h2>
<p class="admin-copy">This page changes the server-wide configuration for every dashboard in the building.</p> <p class="admin-copy">This page changes the server-wide configuration for every dashboard in the building.</p>
<div class="login-row"> <div class="login-row">
<input type="password" id="passcode" placeholder="Passcode" autocomplete="current-password"> <input type="password" id="passcode" class="pin-input" inputmode="numeric" autocomplete="off"
maxlength="6" placeholder="••••••" aria-label="Admin PIN">
<button class="btn primary" id="login-btn">Unlock</button> <button class="btn primary" id="login-btn">Unlock</button>
</div> </div>
<div class="admin-status" id="login-status"></div> <div class="admin-status" id="login-status"></div>
@@ -65,6 +66,39 @@
<h1 class="admin-h1">Saved layouts</h1> <h1 class="admin-h1">Saved layouts</h1>
<div id="layouts-list"></div> <div id="layouts-list"></div>
</section> </section>
<section id="security-section" hidden>
<h1 class="admin-h1">Admin PIN</h1>
<p class="admin-copy" id="pin-state"></p>
<div class="admin-card">
<div class="pin-form">
<label class="pin-field" id="pin-current-field" hidden>
<span>Current PIN</span>
<input type="password" id="pin-current" class="pin-input" inputmode="numeric"
autocomplete="off" maxlength="6" placeholder="••••••">
</label>
<label class="pin-field">
<span id="pin-new-label">New PIN</span>
<input type="password" id="pin-new" class="pin-input" inputmode="numeric"
autocomplete="off" maxlength="6" placeholder="6 digits">
</label>
<label class="pin-field">
<span>Repeat</span>
<input type="password" id="pin-repeat" class="pin-input" inputmode="numeric"
autocomplete="off" maxlength="6" placeholder="6 digits">
</label>
<div class="pin-actions">
<button class="btn primary" id="pin-save">Set PIN</button>
<button class="btn" id="pin-clear" hidden>Turn off</button>
</div>
</div>
<p class="admin-copy pin-note">Only this page and the admin API ask for it — dashboards stay
open, and it is nothing to do with a module's own PIN, such as Slack's send PIN. Forgotten?
Remove <code>adminPin</code> from <code id="pin-file">proddash.json</code> in the settings
folder and restart.</p>
<div class="admin-status" id="pin-status"></div>
</div>
</section>
</main> </main>
<div id="toast" hidden></div> <div id="toast" hidden></div>
+15 -8
View File
@@ -19,7 +19,15 @@
<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="autohide-btn" title="Auto-hide bars" aria-label="Auto-hide tile title bars" aria-pressed="false">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9 6l3-3 3 3"/>
<rect x="4" y="10" width="16" height="10" rx="1.5"/>
<path d="M4 14h16"/>
</svg>
</button>
<button class="tb-btn icon" id="fs-btn" title="Fullscreen" aria-label="Fullscreen">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <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 +35,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 +52,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>
+772 -214
View File
File diff suppressed because it is too large Load Diff
+262 -140
View File
@@ -126,6 +126,14 @@ body.menu-hidden #topbar {
line-height: 1; line-height: 1;
} }
.tb-btn.icon svg { display: block; } .tb-btn.icon svg { display: block; }
/* A top-bar button that is a switch rather than an action lights up the same
way an open tile button does, so "on" reads the same everywhere. */
.tb-btn.is-on,
.tb-btn.is-on:hover {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-text);
}
/* ── dropdown menus ────────────────────────────────────────────────── */ /* ── dropdown menus ────────────────────────────────────────────────── */
@@ -170,6 +178,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 +242,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;
@@ -257,11 +278,33 @@ body.menu-hidden #topbar {
transition: height 0.22s ease, border-bottom-width 0.22s ease; transition: height 0.22s ease, border-bottom-width 0.22s ease;
} }
.tile.dragging .tile-head { cursor: grabbing; } .tile.dragging .tile-head { cursor: grabbing; }
/* hidden = collapsed, not removed, so the notch toggle animates */
.tile.head-hidden .tile-head { /* Folded away: the bar leaves the flow and parks just above the tile, so the
height: 0; module keeps the whole body and nothing reflows as the bar comes and goes.
border-bottom-width: 0; Pointing at the tile's top edge slides it back in over the content
overflow: hidden; (shell.js adds .head-peek) — and because it is the same element, its
buttons click and its width drags the tile just as they always did. */
.tile.head-off > .tile-head {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 7;
background: color-mix(in srgb, var(--panel) 90%, transparent);
-webkit-backdrop-filter: blur(7px);
backdrop-filter: blur(7px);
transform: translateY(-100%);
opacity: 0;
pointer-events: none;
transition: transform 0.18s ease, opacity 0.18s ease;
}
.tile.head-off.head-peek > .tile-head {
transform: none;
opacity: 1;
pointer-events: auto;
}
.tile.is-tinted.head-off > .tile-head {
background: color-mix(in srgb, var(--tile-tint) 20%, var(--panel));
} }
.tile-head .dot { .tile-head .dot {
@@ -277,7 +320,7 @@ body.menu-hidden #topbar {
.tile-head .dot.error { background: var(--danger); } .tile-head .dot.error { background: var(--danger); }
.tile-title { .tile-title {
flex: 1; flex: 0 1 auto;
min-width: 0; min-width: 0;
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
@@ -305,6 +348,53 @@ 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 pin: a state, not an action, so it stays quiet until it is on and
sits closer to the label than to the buttons at the other end. It fades
in with the rest of the tile's chrome so a wall of unpinned tiles is not
a wall of pins. */
.tile-btn.pin {
width: 18px;
height: 18px;
margin-left: 2px;
opacity: 0;
transition: opacity 0.15s ease;
}
.tile-btn.pin svg { width: 12px; height: 12px; }
.tile:hover .tile-btn.pin,
.tile-btn.pin:focus-visible { opacity: 0.75; }
.tile-btn.pin:hover { opacity: 1; }
/* Pinned reads at a glance, whether or not anything is being pointed at. */
.tile-btn.pin.is-pinned,
.tile:hover .tile-btn.pin.is-pinned {
opacity: 1;
color: var(--accent);
}
.tile-btn.pin.is-pinned .pin-head { fill: currentColor; }
.tile-btn.pin.is-pinned:hover { background: var(--border); }
/* Keeps the label hugging its pin however wide the tile is. */
.tile-head-gap { flex: 1 1 auto; min-width: 0; }
/* the shell's tooltip: shown at once under the button the pointer is on */
#tip {
position: fixed;
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) */
@@ -341,55 +431,6 @@ body.menu-hidden #topbar {
} }
.tile-menu[hidden] { display: none; } .tile-menu[hidden] { display: none; }
/* notch on the title bar's bottom edge: click to hide/show the bar
(same idea as the ProdCom Listener's pull-down menubar handle) */
.tile-notch {
position: absolute;
top: 32px;
left: 50%;
transform: translateX(-50%);
width: 46px;
height: 13px;
padding: 0;
background: var(--panel);
border: 1px solid var(--border);
border-top: none;
border-radius: 0 0 10px 10px;
cursor: pointer;
z-index: 6;
opacity: 0;
transition: opacity 0.15s ease, top 0.22s ease;
}
.tile:hover .tile-notch { opacity: 0.6; }
.tile .tile-notch:hover { opacity: 1; }
.tile.head-hidden .tile-notch { top: 0; }
.tile-notch::after {
content: "";
display: block;
width: 18px;
height: 3px;
margin: 4px auto 0;
border-radius: 999px;
background: var(--muted);
}
.tile-notch:hover::after { background: var(--text); }
/* Fullscreen is for watching, not arranging: every title bar folds away, and
a tile's own hidden/shown preference is left untouched for when the screen
comes back. The notch still peeks one open, and it folds back once the
pointer has been off that tile for a while (PEEK_HIDE_MS in shell.js). */
body.fullscreen .tile:not(.head-peek) .tile-head {
height: 0;
border-bottom-width: 0;
overflow: hidden;
}
body.fullscreen .tile:not(.head-peek) .tile-notch { top: 0; }
body.fullscreen .tile.head-peek .tile-head {
height: 32px;
border-bottom-width: 1px;
}
body.fullscreen .tile.head-peek .tile-notch { top: 32px; }
.tile-body { .tile-body {
flex: 1; flex: 1;
position: relative; position: relative;
@@ -398,80 +439,67 @@ body.fullscreen .tile.head-peek .tile-notch { top: 32px; }
overflow: hidden; overflow: hidden;
} }
/* ── resizing: the whole rim of a tile, drawn nowhere ────────────────
Every edge and both bottom corners resize, so there is nothing to point
at — the grips are invisible and the cursor says what each one does, the
way a window's own edges do. Together they tile the tile's outer 8px
without a gap: the top strip runs corner to corner, the sides run between
it and the bottom corners, and the bottom strip fills between those. */
.tile-resize,
.tile-resize-left,
.tile-resize-bottom,
.tile-resize-top,
.tile-resize-right,
.tile-resize-side {
position: absolute;
z-index: 5;
touch-action: none;
}
/* the two bottom corners: both directions at once */
.tile-resize, .tile-resize,
.tile-resize-left { .tile-resize-left {
position: absolute;
bottom: 0; bottom: 0;
width: 20px; width: 20px;
height: 20px; height: 20px;
touch-action: none;
z-index: 5;
color: var(--muted);
opacity: 0; /* corner indicators only show while hovering the tile */
transition: opacity 0.15s ease;
} }
.tile-resize { right: 0; cursor: nwse-resize; } .tile-resize { right: 0; cursor: nwse-resize; }
.tile-resize-left { left: 0; cursor: nesw-resize; } .tile-resize-left { left: 0; cursor: nesw-resize; }
.tile:hover .tile-resize,
.tile:hover .tile-resize-left { opacity: 0.6; }
.tile .tile-resize:hover,
.tile .tile-resize-left:hover { opacity: 1; color: var(--text); }
.tile-resize::after,
.tile-resize-left::after {
content: "";
position: absolute;
bottom: 4px;
width: 9px;
height: 9px;
border-bottom: 2px solid currentColor;
}
.tile-resize::after {
right: 4px;
border-right: 2px solid currentColor;
border-radius: 0 0 3px 0;
}
.tile-resize-left::after {
left: 4px;
border-left: 2px solid currentColor;
border-radius: 0 0 0 3px;
}
/* bottom edge: drag to move the tile's bottom edge (height only). Drawn as /* top and bottom: height only */
the same 2px line as the corner handles it sits between, on the same .tile-resize-top,
4px inset, so the three read as one set of marks along the bottom. */
.tile-resize-bottom { .tile-resize-bottom {
position: absolute; height: 8px;
left: 24px;
right: 24px;
bottom: 0;
height: 10px;
z-index: 5;
display: flex;
align-items: flex-end;
justify-content: center;
cursor: ns-resize; cursor: ns-resize;
touch-action: none;
color: var(--muted);
opacity: 0;
transition: opacity 0.15s ease;
} }
.tile-resize-bottom::after { .tile-resize-top {
content: ""; top: 0;
width: 44px; left: 0;
max-width: 60%; right: 0;
height: 0; /* Above a revealed title bar: the bar slides in under the pointer here,
margin-bottom: 4px; and this strip has to stay the top edge rather than become part of it. */
border-bottom: 2px solid currentColor; z-index: 8;
} }
.tile:hover .tile-resize-bottom { opacity: 0.6; } .tile-resize-bottom { bottom: 0; left: 20px; right: 20px; }
.tile .tile-resize-bottom:hover,
.tile.resizing .tile-resize-bottom { opacity: 1; color: var(--text); } /* left and right: width only, between the top strip and the bottom corners */
.tile-resize-right,
.tile-resize-side {
top: 8px;
bottom: 20px;
width: 8px;
cursor: ew-resize;
}
.tile-resize-right { right: 0; }
.tile-resize-side { left: 0; }
/* ── seam divider between adjacent tiles ───────────────────────────── */ /* ── seam divider between adjacent tiles ───────────────────────────── */
/* When two tiles share a vertical edge, an invisible strip sits on the seam; /* When two tiles share a vertical edge, an invisible strip sits on the seam.
hovering reveals a small macOS-split-view-style grab pill. Dragging it Dragging it trades width between the two tiles rather than resizing one,
trades width between the two tiles. */ so it sits above their own edge grips. Like them it draws nothing at rest
— only while it is being dragged, to show which seam is moving. */
.tile-divider { .tile-divider {
position: absolute; position: absolute;
width: 14px; width: 14px;
@@ -495,22 +523,24 @@ body.fullscreen .tile.head-peek .tile-notch { top: 32px; }
transition: opacity 0.15s ease, background 0.15s ease; transition: opacity 0.15s ease, background 0.15s ease;
} }
.tile-divider:hover::after,
.tile-divider.linked::after { opacity: 0.7; }
.tile-divider.active::after { opacity: 1; background: var(--text); } .tile-divider.active::after { opacity: 1; background: var(--text); }
/* Hide the pills while a tile itself is being moved or corner-resized — /* Drop the seams while a tile itself is being moved or resized — they're
they're rebuilt for the new geometry on release. */ rebuilt for the new geometry on release. */
#grid.no-dividers .tile-divider { display: none; } #grid.no-dividers .tile-divider { display: none; }
/* ── fullscreen mode ───────────────────────────────────────────────── */ /* ── fullscreen mode ───────────────────────────────────────────────── */
/* Fullscreen is a viewing mode: the layout auto-fits the screen, so the /* Fullscreen is a viewing mode: the layout auto-fits the screen, so the
editing affordances (corner handles, seam pills) are parked. */ editing affordances (the rim grips and the seams) are parked. */
body.fullscreen .tile-resize, body.fullscreen .tile-resize,
body.fullscreen .tile-resize-left, body.fullscreen .tile-resize-left,
body.fullscreen .tile-resize-bottom, body.fullscreen .tile-resize-bottom,
body.fullscreen .tile-resize-top,
body.fullscreen .tile-resize-right,
body.fullscreen .tile-resize-side,
body.fullscreen .tile-divider, body.fullscreen .tile-divider,
body.fullscreen #autohide-btn,
body.fullscreen #admin-link { display: none; } body.fullscreen #admin-link { display: none; }
body.fullscreen #grid { padding-bottom: 0; } body.fullscreen #grid { padding-bottom: 0; }
#gridwrap.no-scroll { overflow-y: hidden; } #gridwrap.no-scroll { overflow-y: hidden; }
@@ -533,28 +563,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 +716,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 +724,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;
+375 -18
View File
@@ -12,7 +12,10 @@
* Configure via config/proddash.json (defaults, in the repo), an optional * Configure via config/proddash.json (defaults, in the repo), an optional
* 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 (a passcode that overrides the stored admin PIN —
* also the way back in if the PIN is ever forgotten)
* PRODDASH_FORGE (overrides `forge`: "github", or a Gitea/Forgejo base
* URL such as http://10.3.8.90:3000)
* PRODDASH_DATA_DIR (where module config + layouts are kept; see below) * PRODDASH_DATA_DIR (where module config + layouts are kept; see below)
*/ */
'use strict'; 'use strict';
@@ -463,7 +466,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 +562,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
@@ -615,15 +767,53 @@ 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('michael/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 +970,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 +982,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(),
}; };
} }
@@ -981,12 +1171,109 @@ function restartServer(reason) {
/* ── admin API ──────────────────────────────────────────────────────── */ /* ── admin API ──────────────────────────────────────────────────────── */
/* Admin is behind a 6-digit PIN, set in the admin page and kept in this
machine's proddash.json as a scrypt hash — never in the clear, because that
file gets copied between machines. A dashboard needs no PIN; only /admin
and the admin API do. Two ways back in if it is ever forgotten: set
PRODDASH_PASSCODE in the environment, or delete "adminPin" from
proddash.json in the data directory and restart. `adminPasscode` (the older
free-text setting) still works so an existing install keeps running. */
const PIN_RE = /^\d{6}$/;
const PIN_MAX_WRONG = 5;
const PIN_LOCKOUT_MS = 30000;
const ADMIN_PASSCODE = String(process.env.PRODDASH_PASSCODE || shellConfig.adminPasscode || ''); const ADMIN_PASSCODE = String(process.env.PRODDASH_PASSCODE || shellConfig.adminPasscode || '');
/** Session token: regenerating it every boot is fine — admins just re-enter the passcode. */ /** Session token: regenerating it every boot is fine — admins just re-enter the PIN. */
const adminToken = crypto.randomBytes(24).toString('hex'); const adminToken = crypto.randomBytes(24).toString('hex');
/** The stored PIN, as "scrypt$<salt>$<hash>"; '' when admin is open. */
let adminPinHash = String(shellConfig.adminPin || '');
const pinAttempts = new Map(); // ip → { count, lockedUntil }
function hashPin(pin, salt = crypto.randomBytes(16).toString('hex')) {
return `scrypt$${salt}$${crypto.scryptSync(String(pin), salt, 32).toString('hex')}`;
}
/** Constant-time check of a PIN against the stored hash. */
function pinMatches(given, stored) {
const parts = String(stored || '').split('$');
if (parts.length !== 3 || parts[0] !== 'scrypt') return false;
const expected = Buffer.from(parts[2], 'hex');
const actual = crypto.scryptSync(String(given), parts[1], expected.length);
return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
}
/** Is a credential required at all? */
function adminLocked() {
return Boolean(ADMIN_PASSCODE || adminPinHash);
}
/** True when `given` is the credential in force (the env passcode, or the PIN). */
function credentialOk(given) {
const s = String(given ?? '');
if (!s) return false;
if (ADMIN_PASSCODE) {
const a = Buffer.from(s);
const b = Buffer.from(ADMIN_PASSCODE);
if (a.length === b.length && crypto.timingSafeEqual(a, b)) return true;
}
return adminPinHash ? pinMatches(s, adminPinHash) : false;
}
function clientIp(req) {
return String(req.socket?.remoteAddress || 'unknown');
}
/** Seconds of lockout left for this address, or 0. */
function lockoutLeft(ip) {
const a = pinAttempts.get(ip);
if (!a || !a.lockedUntil) return 0;
const left = a.lockedUntil - Date.now();
if (left <= 0) {
pinAttempts.delete(ip);
return 0;
}
return Math.ceil(left / 1000);
}
function noteWrongPin(ip) {
const a = pinAttempts.get(ip) || { count: 0, lockedUntil: 0 };
a.count += 1;
if (a.count >= PIN_MAX_WRONG) {
a.count = 0;
a.lockedUntil = Date.now() + PIN_LOCKOUT_MS;
console.warn(`[proddash] ${PIN_MAX_WRONG} wrong admin PINs from ${ip} — locked out for ${PIN_LOCKOUT_MS / 1000} s`);
}
pinAttempts.set(ip, a);
}
/**
* Store a new PIN (6 digits), or '' to open admin up again. Keeps every other
* key in this machine's proddash.json. Throws an httpError the route reports.
*/
function setAdminPin(pin) {
const next = String(pin ?? '');
if (next && !PIN_RE.test(next)) throw httpError(400, 'The PIN must be exactly 6 digits.');
const file = path.join(DATA_DIR, 'proddash.json');
let existing = {};
if (fs.existsSync(file)) {
existing = readJson(file, null);
if (!existing || typeof existing !== 'object' || Array.isArray(existing)) {
throw httpError(500, `${file} is not a JSON object — fix it by hand before changing the PIN.`);
}
}
const hash = next ? hashPin(next) : '';
const out = { ...existing };
if (hash) out.adminPin = hash;
else delete out.adminPin;
writeJson(file, out);
adminPinHash = hash;
shellConfig.adminPin = hash;
pinAttempts.clear();
console.log(`[proddash] admin PIN ${hash ? 'set' : 'removed — admin is open again'}`);
return { pinSet: Boolean(hash) };
}
function isAuthed(req) { function isAuthed(req) {
if (!ADMIN_PASSCODE) return true; if (!adminLocked()) return true;
const cookies = String(req.headers.cookie || ''); const cookies = String(req.headers.cookie || '');
return cookies.split(';').some((c) => c.trim() === 'proddash_admin=' + adminToken); return cookies.split(';').some((c) => c.trim() === 'proddash_admin=' + adminToken);
} }
@@ -994,7 +1281,7 @@ function isAuthed(req) {
/** Guard a state-changing admin route. Returns true when the request was refused. */ /** Guard a state-changing admin route. Returns true when the request was refused. */
function refuseAdminWrite(req, res) { function refuseAdminWrite(req, res) {
if (!isAuthed(req)) { if (!isAuthed(req)) {
sendJson(res, 401, { error: 'Enter the admin passcode first.', authRequired: true }); sendJson(res, 401, { error: 'Enter the admin PIN first.', authRequired: true });
return true; return true;
} }
// Requiring application/json forces a CORS preflight that a hostile page // Requiring application/json forces a CORS preflight that a hostile page
@@ -1055,6 +1342,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 = {
@@ -1076,10 +1366,19 @@ async function handleAdminApi(req, res, urlPath) {
} catch { } catch {
return sendJson(res, 400, { error: 'Malformed request.' }); return sendJson(res, 400, { error: 'Malformed request.' });
} }
if (!ADMIN_PASSCODE) return sendJson(res, 200, { ok: true, authRequired: false }); if (!adminLocked()) return sendJson(res, 200, { ok: true, authRequired: false });
if (String(body.passcode || '') !== ADMIN_PASSCODE) { const ip = clientIp(req);
return sendJson(res, 403, { error: 'Wrong passcode.' }); const left = lockoutLeft(ip);
if (left) return sendJson(res, 429, { error: `Too many wrong PINs — try again in ${left} s.`, retryAfter: left });
// `passcode` is what the older admin page sent; `pin` is what this one does.
if (!credentialOk(body.pin ?? body.passcode)) {
noteWrongPin(ip);
const now = lockoutLeft(ip);
return sendJson(res, 403, now
? { error: `Too many wrong PINs — try again in ${now} s.`, retryAfter: now }
: { error: 'Wrong PIN.' });
} }
pinAttempts.delete(ip);
res.writeHead(200, { res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8', 'Content-Type': 'application/json; charset=utf-8',
'Set-Cookie': `proddash_admin=${adminToken}; Path=/; SameSite=Lax; HttpOnly`, 'Set-Cookie': `proddash_admin=${adminToken}; Path=/; SameSite=Lax; HttpOnly`,
@@ -1088,12 +1387,50 @@ async function handleAdminApi(req, res, urlPath) {
return res.end(JSON.stringify({ ok: true })); return res.end(JSON.stringify({ ok: true }));
} }
if (urlPath === '/api/admin/pin' && req.method === 'POST') {
if (refuseAdminWrite(req, res)) return;
let body = {};
try {
body = JSON.parse((await readBody(req)) || '{}');
} catch {
return sendJson(res, 400, { error: 'Malformed request.' });
}
// Changing a credential re-asserts it, so a browser left open on the
// admin page can't be used to take the machine over.
if (adminPinHash) {
const ip = clientIp(req);
const left = lockoutLeft(ip);
if (left) return sendJson(res, 429, { error: `Too many wrong PINs — try again in ${left} s.`, retryAfter: left });
if (!pinMatches(String(body.current ?? ''), adminPinHash)) {
noteWrongPin(ip);
return sendJson(res, 403, { error: 'That is not the current PIN.' });
}
pinAttempts.delete(ip);
}
try {
const result = setAdminPin(body.pin);
// Whoever just set it has proved they are allowed, so hand them the
// session rather than bouncing them to the unlock card they just built.
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Set-Cookie': `proddash_admin=${adminToken}; Path=/; SameSite=Lax; HttpOnly`,
'Cache-Control': 'no-store',
});
return res.end(JSON.stringify(result));
} catch (err) {
return sendJson(res, err.status || 500, { error: err.message || 'Could not save the PIN.' });
}
}
if (urlPath === '/api/admin/state' && req.method === 'GET') { if (urlPath === '/api/admin/state' && req.method === 'GET') {
if (!isAuthed(req)) { if (!isAuthed(req)) {
return sendJson(res, 401, { authRequired: true, authed: false }); return sendJson(res, 401, { authRequired: true, authed: false });
} }
return sendJson(res, 200, { return sendJson(res, 200, {
authRequired: Boolean(ADMIN_PASSCODE), authRequired: adminLocked(),
// The env passcode can't be changed from here; the page says so.
pinSet: Boolean(adminPinHash),
pinFromEnv: Boolean(ADMIN_PASSCODE),
authed: true, authed: true,
version: SHELL_VERSION, version: SHELL_VERSION,
dataDir: DATA_DIR, dataDir: DATA_DIR,
@@ -1202,7 +1539,11 @@ function layoutPath(name) {
function sanitizeLayout(raw) { function sanitizeLayout(raw) {
const tiles = Array.isArray(raw?.tiles) ? raw.tiles.slice(0, 100) : []; const tiles = Array.isArray(raw?.tiles) ? raw.tiles.slice(0, 100) : [];
return { return {
tiles: tiles.map((t) => ({ // Whether tile title bars auto-hide is part of how a dashboard is
// arranged, so it travels with the layout like the tiles themselves.
autoHideHeads: Boolean(raw?.autoHideHeads),
tiles: tiles.map((t) => {
const tile = {
id: String(t?.id || ''), id: String(t?.id || ''),
module: String(t?.module || ''), module: String(t?.module || ''),
x: Number(t?.x) || 0, x: Number(t?.x) || 0,
@@ -1210,7 +1551,18 @@ function sanitizeLayout(raw) {
w: Number(t?.w) || 1, w: Number(t?.w) || 1,
h: Number(t?.h) || 1, h: Number(t?.h) || 1,
settings: t?.settings && typeof t.settings === 'object' ? t.settings : {}, settings: t?.settings && typeof t.settings === 'object' ? t.settings : {},
})), // Which picker entry the tile came from, and what it was called.
// Without these a saved "Now" tile comes back as the full Now/Next
// card, and a solo timer comes back with a full tile's settings.
variant: String(t?.variant || ''),
title: String(t?.title || ''),
headPinned: Boolean(t?.headPinned),
};
if (Number(t?.minSize?.w) > 0 && Number(t?.minSize?.h) > 0) {
tile.minSize = { w: Number(t.minSize.w), h: Number(t.minSize.h) };
}
return tile;
}),
}; };
} }
@@ -1274,7 +1626,7 @@ async function handleLayoutsApi(req, res, urlPath) {
} }
if (!m[2] && req.method === 'DELETE') { if (!m[2] && req.method === 'DELETE') {
if (!isAuthed(req)) return sendJson(res, 401, { error: 'Enter the admin passcode first.', authRequired: true }); if (!isAuthed(req)) return sendJson(res, 401, { error: 'Enter the admin PIN first.', authRequired: true });
try { try {
fs.unlinkSync(layoutPath(name)); fs.unlinkSync(layoutPath(name));
} catch { } catch {
@@ -1432,6 +1784,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 +1893,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);
});