Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12f6e22187 | ||
|
|
5025e12b71 | ||
|
|
3009b39453 | ||
|
|
41f64f3a13 | ||
|
|
12f0b20e5f | ||
|
|
f72170b4e1 | ||
|
|
ea1eac1c28 | ||
|
|
ea8be2ac76 | ||
|
|
82e6386c73 | ||
|
|
f5eb611872 | ||
|
|
d52cfcc48e | ||
|
|
7d46cfbd8f | ||
|
|
7a5b57dc56 | ||
|
|
7f50fa0ba9 | ||
|
|
68f0ba806d | ||
|
|
8e579ecb34 | ||
|
|
f0be37796b | ||
|
|
52d700df3d | ||
|
|
7690ae1fad | ||
|
|
6dabd9d8a1 |
@@ -32,6 +32,12 @@ 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
|
||||||
|
|||||||
@@ -99,9 +99,18 @@ 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`. |
|
| `forge` | Where updates and module installs are fetched from. Absent or `"github"` uses GitHub; a Gitea or Forgejo base URL such as `"http://10.3.8.90:3000"` uses a server of your own. Set it per machine in the data directory, alongside `repo`. |
|
||||||
| `theme` | The dashboard palette: `booth` (default), `harbor`, `graphite`, `ember` or `daylight`. The admin page's **Theme** section writes this one — see [Themes](#themes). |
|
| `theme` | The dashboard palette: `booth` (default), `harbor`, `graphite`, `ember`, `daylight`, `midnight`, `moss`, `plum`, `sepia`, `blackout`, `paper` or `iron`. The admin page's **Theme** section writes this one — see [Themes](#themes). |
|
||||||
|
|
||||||
|
**Locked out?** Delete `adminPin` from the data directory's `proddash.json`
|
||||||
|
and restart, or start the server with `PRODDASH_PASSCODE` set — that passcode
|
||||||
|
overrides the PIN and always gets you in.
|
||||||
|
|
||||||
|
Only `/admin` and the admin API are behind the PIN. Dashboards stay open, so a
|
||||||
|
wall display needs no PIN, and it is unrelated to a module's own PIN (Slack's
|
||||||
|
4–8 digit send PIN, say), which guards that module's actions only.
|
||||||
|
|
||||||
**Updating from your own git server.** ProdDash fetches one `tar.gz` of the
|
**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
|
branch — nothing else — so any Gitea or Forgejo works. Point `forge` at its
|
||||||
@@ -131,12 +140,26 @@ ignored from then on.
|
|||||||
|
|
||||||
The whole dashboard — every tile, on every browser — draws with one palette,
|
The whole dashboard — every tile, on every browser — draws with one palette,
|
||||||
chosen in the admin page's **Theme** section and switched live: pick a swatch
|
chosen in the admin page's **Theme** section and switched live: pick a swatch
|
||||||
and open dashboards change without a reload. Five presets ship: **Booth**
|
and open dashboards change without a reload. The section starts folded —
|
||||||
(the default dark, mint accent), **Harbor** (deep navy, cyan), **Graphite**
|
a building picks its palette once — and holds twelve presets:
|
||||||
(neutral charcoal, electric blue), **Ember** (warm broadcast black, orange)
|
|
||||||
and **Daylight**, a light palette for a front-of-house desk in daytime. Each
|
| | |
|
||||||
keeps the status colours apart — the accent is never near the danger red or
|
|---|---|
|
||||||
the warning amber — and muted text readable in a dark booth. The choice is
|
| **Booth** | the default dark, mint accent |
|
||||||
|
| **Harbor** | deep navy, cyan |
|
||||||
|
| **Graphite** | neutral charcoal, electric blue |
|
||||||
|
| **Ember** | warm broadcast black, orange |
|
||||||
|
| **Daylight** | a cool light palette, for a front-of-house desk in daytime |
|
||||||
|
| **Midnight** | indigo night, violet |
|
||||||
|
| **Moss** | forest floor, lime |
|
||||||
|
| **Plum** | aubergine, purple |
|
||||||
|
| **Sepia** | warm brown room, cool teal — Ember's arrangement reversed |
|
||||||
|
| **Blackout** | true black, cyan, for a dark room where any glow carries |
|
||||||
|
| **Paper** | the warm light one, indigo accent |
|
||||||
|
| **Iron** | no colour of its own: a near-white accent, so the only hues on screen are a module's own and the red and amber that mean something |
|
||||||
|
|
||||||
|
Each keeps the status colours apart — the accent is never near the danger red
|
||||||
|
or the warning amber — and muted text readable in a dark booth. The choice is
|
||||||
stored as `theme` in the data directory's `proddash.json` (table above), so
|
stored as `theme` in the data directory's `proddash.json` (table above), so
|
||||||
it holds across restarts and updates; a browser remembers the last theme it
|
it holds across restarts and updates; a browser remembers the last theme it
|
||||||
saw and applies it before the server answers, so nothing flashes. Modules
|
saw and applies it before the server answers, so nothing flashes. Modules
|
||||||
@@ -177,7 +200,18 @@ below. Nothing is installed without someone clicking.
|
|||||||
no local changes — otherwise the banner says why and you update with git).
|
no local changes — otherwise the banner says why and you update with git).
|
||||||
A plain folder (no `.git`) has the repo archive unpacked over it; files
|
A plain folder (no `.git`) has the repo archive unpacked over it; files
|
||||||
removed upstream are left behind but harmless. Settings and layouts are
|
removed upstream are left behind but harmless. Settings and layouts are
|
||||||
never touched. **What changed** opens the commit comparison on GitHub.
|
never touched — nothing inside the data directory is written, even on an
|
||||||
|
install where that directory *is* the app's own `config/`. **What changed**
|
||||||
|
opens the commit comparison on the forge.
|
||||||
|
- **If an update goes wrong**: the download has to parse before anything is
|
||||||
|
swapped in, so a truncated or mangled `server.js` is refused with the
|
||||||
|
reason and the running copy is left exactly as it was. Past that, the copy
|
||||||
|
being replaced is set aside as `app.previous` next to the app folder, and
|
||||||
|
dropped once the new version has stayed up for ninety seconds — so that
|
||||||
|
folder existing means "the last update has not proven itself yet". The
|
||||||
|
macOS app watches for a version that will not stay up (four stops in two
|
||||||
|
minutes) and puts the old one back by itself, once, with a notification.
|
||||||
|
Running some other way, the way back is a rename: `mv app.previous app`.
|
||||||
- **Restarting**: after an update the server exits with code 75 and starts
|
- **Restarting**: after an update the server exits with code 75 and starts
|
||||||
its own replacement, which waits for the port to free up. If you supervise
|
its own replacement, which waits for the port to free up. If you supervise
|
||||||
ProdDash with your own launcher, set `PRODDASH_LAUNCHER=1` so the server
|
ProdDash with your own launcher, set `PRODDASH_LAUNCHER=1` so the server
|
||||||
@@ -189,13 +223,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
|
||||||
@@ -205,9 +243,21 @@ below. Nothing is installed without someone clicking.
|
|||||||
gap; alone in its columns it stays as it is. Nothing is saved — the layout
|
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
|
||||||
@@ -231,7 +281,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"
|
||||||
|
|||||||
+66
-19
@@ -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/`:
|
||||||
@@ -177,14 +217,18 @@ 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`, `toggle`). That is how
|
popover (`collapsed: true | false`, `columns`, `toggle` — but not `help`,
|
||||||
a module with a long show/hide checklist keeps the popover usable — see
|
per rule 5). That is how a module with a long show/hide checklist keeps the
|
||||||
`pco-plan`. Display choices belong there, per tile; admin config is for
|
popover usable: five or more hide-toggles go in one **Show** group, folded
|
||||||
connections. The popover itself is a small window beside the tile — sized to
|
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
|
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
|
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
|
settings, and a change can be watched as it lands. Under everything you
|
||||||
@@ -354,22 +398,25 @@ menu.setLabel('Channels (2/4) ▾'); // update the button text any time
|
|||||||
```
|
```
|
||||||
|
|
||||||
Everything you add is removed automatically when your instance stops, so a
|
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. Draw
|
title bar is 32 px tall and shared with the tile's name, and the tooltip
|
||||||
icons as inline SVG rather than characters — a glyph such as ✕ or + takes
|
says what the icon means. Draw icons as inline SVG rather than characters —
|
||||||
its size from whatever font each browser picks; text-size buttons are a
|
a glyph such as ✕ or + takes its size from whatever font each browser
|
||||||
drawn − and +.
|
picks; text-size buttons are a drawn − and +. A small state indicator may
|
||||||
|
ride alongside an icon (ProdCom's channel button carries a count while a
|
||||||
|
filter is on); a name may not.
|
||||||
|
|
||||||
**One control per action.** Whatever has a dedicated header button is not
|
**One control per action** (rule 11). Whatever has a dedicated header button
|
||||||
offered again in the gear popover: text size is A− / A+ up here (persisted
|
is not offered again in the gear popover: text size is − / + up here
|
||||||
through `saveInstanceSettings({ textSize })`, read back with a sane
|
(persisted through `saveInstanceSettings({ textSize })`, read back with a
|
||||||
fallback), never a "Text size" field in `instanceSchema`; a timestamps or
|
sane fallback), never a "Text size" field in `instanceSchema`; a timestamps
|
||||||
newest-first toggle in the header means no such switch in the gear. The
|
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.
|
gear is for display choices that have no button of their own.
|
||||||
|
|
||||||
One more thing to know: the user can hide the whole title bar with the
|
One more thing to know: the whole title bar folds away when the dashboard
|
||||||
small notch on its bottom edge (a clean-view mode for wall displays). Your
|
auto-hides it, and always in fullscreen (a clean-view mode for wall
|
||||||
controls disappear with it, so nothing essential — status, reconnects,
|
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:
|
||||||
|
|||||||
@@ -103,6 +103,45 @@ enum ProdDash {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Go back to the copy ProdDash set aside before its last update.
|
||||||
|
|
||||||
|
A version that will not stay up is the one state this app cannot talk its
|
||||||
|
way out of: the admin page went down with it, and installing a fresh
|
||||||
|
`.app` does nothing, because seeding only replaces an *older* copy and a
|
||||||
|
broken one carries a higher version. So ProdDash writes `app.previous`
|
||||||
|
before it overwrites itself and drops it again once the new version has
|
||||||
|
stayed up — meaning that folder exists only while an update is still
|
||||||
|
unproven. This puts it back.
|
||||||
|
|
||||||
|
Returns the version restored, or nil when there is nothing to go back to.
|
||||||
|
*/
|
||||||
|
@discardableResult
|
||||||
|
static func restorePrevious(root: URL, log: (String) -> Void) -> String? {
|
||||||
|
let fm = FileManager.default
|
||||||
|
let previous = URL(fileURLWithPath: root.path + ".previous")
|
||||||
|
guard isRoot(previous) else { return nil }
|
||||||
|
let version = shellVersion(root: previous)
|
||||||
|
let named = version.isEmpty ? "(unknown version)" : version
|
||||||
|
let broken = URL(fileURLWithPath: root.path + ".broken")
|
||||||
|
do {
|
||||||
|
try? fm.removeItem(at: broken)
|
||||||
|
if fm.fileExists(atPath: root.path) { try fm.moveItem(at: root, to: broken) }
|
||||||
|
try fm.moveItem(at: previous, to: root)
|
||||||
|
makeWritable(root)
|
||||||
|
try? fm.removeItem(at: broken)
|
||||||
|
log("rolled ProdDash back to \(named) — the copy it replaced")
|
||||||
|
return named
|
||||||
|
} catch {
|
||||||
|
log("could not roll back to the previous copy: \(error.localizedDescription)")
|
||||||
|
// Leave something runnable behind rather than nothing at all.
|
||||||
|
if !fm.fileExists(atPath: root.path), fm.fileExists(atPath: broken.path) {
|
||||||
|
try? fm.moveItem(at: broken, to: root)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Files copied out of a bundle can arrive read-only, and this copy is
|
/// Files copied out of a bundle can arrive read-only, and this copy is
|
||||||
/// exactly where ProdDash writes its own updates.
|
/// exactly where ProdDash writes its own updates.
|
||||||
private static func makeWritable(_ root: URL) {
|
private static func makeWritable(_ root: URL) {
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ final class ServerController: ObservableObject {
|
|||||||
private var restartAfterExit = false
|
private var restartAfterExit = false
|
||||||
private var startedAt: Date?
|
private var startedAt: Date?
|
||||||
private var crashTimes: [Date] = []
|
private var crashTimes: [Date] = []
|
||||||
|
/// One roll-back per bad update: a second would just swap copies forever.
|
||||||
|
private var rolledBack = false
|
||||||
private var pendingRestart: DispatchWorkItem?
|
private var pendingRestart: DispatchWorkItem?
|
||||||
private var healthTimer: Timer?
|
private var healthTimer: Timer?
|
||||||
private var healthMisses = 0
|
private var healthMisses = 0
|
||||||
@@ -299,7 +301,7 @@ final class ServerController: ObservableObject {
|
|||||||
child = nil
|
child = nil
|
||||||
healthy = false
|
healthy = false
|
||||||
let ranFor = startedAt.map { Date().timeIntervalSince($0) } ?? 0
|
let ranFor = startedAt.map { Date().timeIntervalSince($0) } ?? 0
|
||||||
if ranFor > 60 { crashTimes.removeAll() } // a long healthy run clears the slate
|
if ranFor > 60 { crashTimes.removeAll(); rolledBack = false } // a long healthy run clears the slate
|
||||||
|
|
||||||
if restartAfterExit {
|
if restartAfterExit {
|
||||||
restartAfterExit = false
|
restartAfterExit = false
|
||||||
@@ -328,6 +330,22 @@ final class ServerController: ObservableObject {
|
|||||||
crashTimes = crashTimes.filter { Date().timeIntervalSince($0) < 120 }
|
crashTimes = crashTimes.filter { Date().timeIntervalSince($0) < 120 }
|
||||||
|
|
||||||
guard settings.restartOnCrash, crashTimes.count < 4 else {
|
guard settings.restartOnCrash, crashTimes.count < 4 else {
|
||||||
|
// Before giving up: if an update is still unproven, the copy it
|
||||||
|
// replaced is sitting right there. Going back beats sitting dark,
|
||||||
|
// and only once — if the old one will not run either, the problem
|
||||||
|
// is not the update.
|
||||||
|
if settings.restartOnCrash, !rolledBack, let root = rootURL,
|
||||||
|
let restored = ProdDash.restorePrevious(root: root, log: { [log] in log.launcher($0) }) {
|
||||||
|
rolledBack = true
|
||||||
|
crashTimes.removeAll()
|
||||||
|
state = .stopped
|
||||||
|
message = "Rolled back to ProdDash \(restored) — starting…"
|
||||||
|
Notifier.shared.post(title: "ProdDash rolled back",
|
||||||
|
body: "The new version would not stay up. Back on \(restored).")
|
||||||
|
refreshEnvironment()
|
||||||
|
start()
|
||||||
|
return
|
||||||
|
}
|
||||||
let why = settings.restartOnCrash
|
let why = settings.restartOnCrash
|
||||||
? "ProdDash stopped \(crashTimes.count) times in two minutes — leaving it stopped."
|
? "ProdDash stopped \(crashTimes.count) times in two minutes — leaving it stopped."
|
||||||
: "ProdDash \(how)."
|
: "ProdDash \(how)."
|
||||||
|
|||||||
+245
-49
@@ -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": {
|
||||||
@@ -124,37 +143,94 @@
|
|||||||
"Item notes": {
|
"Item notes": {
|
||||||
"toggle": "showNotes"
|
"toggle": "showNotes"
|
||||||
},
|
},
|
||||||
"Show / hide": {
|
"Show": {
|
||||||
"collapsed": true,
|
"collapsed": true,
|
||||||
"columns": 3,
|
"columns": 2
|
||||||
"help": "Switch off anything this tile should not show."
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"instanceSchema": {
|
"instanceSchema": {
|
||||||
"autoScroll": { "type": "boolean", "label": "Keep the current item in view", "default": true },
|
"autoScroll": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Auto-scroll",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
"headerTemplate": {
|
"headerTemplate": {
|
||||||
"type": "template",
|
"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",
|
||||||
"variables": [
|
"variables": [
|
||||||
{ "value": "{series}", "label": "Series" },
|
{
|
||||||
{ "value": "{part}", "label": "Part / plan title" },
|
"value": "{series}",
|
||||||
{ "value": "{serviceType}", "label": "Service type" },
|
"label": "Series"
|
||||||
{ "value": "{date}", "label": "Date" },
|
},
|
||||||
{ "value": "{shortDate}", "label": "Short date" },
|
{
|
||||||
{ "value": "{time}", "label": "Service time" },
|
"value": "{part}",
|
||||||
{ "value": "{length}", "label": "Planned length" },
|
"label": "Part / plan title"
|
||||||
{ "value": "{items}", "label": "Item count" }
|
},
|
||||||
|
{
|
||||||
|
"value": "{serviceType}",
|
||||||
|
"label": "Service type"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "{date}",
|
||||||
|
"label": "Date"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "{shortDate}",
|
||||||
|
"label": "Short date"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "{time}",
|
||||||
|
"label": "Service time"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "{length}",
|
||||||
|
"label": "Planned length"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "{items}",
|
||||||
|
"label": "Item count"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"help": "Use • between items and | to start a second line."
|
"help": "Use • between items and | to start a second line."
|
||||||
},
|
},
|
||||||
"keyColor": { "type": "color", "label": "Song key", "default": "#4ea1ff", "group": "Colors" },
|
"keyColor": {
|
||||||
"headerColor": { "type": "color", "label": "Section headers", "default": "#2ee59a", "group": "Colors" },
|
"type": "color",
|
||||||
"currentColor": { "type": "color", "label": "Current item", "default": "#2ee59a", "group": "Colors" },
|
"label": "Song key",
|
||||||
"headerGradient": { "type": "boolean", "label": "Fade header color to the right", "default": true, "group": "Colors" },
|
"default": "#4ea1ff",
|
||||||
"tintByType": { "type": "boolean", "label": "Tint items by type (song / media / other)", "default": false, "group": "Colors" },
|
"group": "Colors"
|
||||||
"showNotes": { "type": "switch", "label": "Item notes", "default": true, "group": "Item notes" },
|
},
|
||||||
|
"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": {
|
"noteCategories": {
|
||||||
"type": "multiselect",
|
"type": "multiselect",
|
||||||
"label": "Categories",
|
"label": "Categories",
|
||||||
@@ -163,29 +239,149 @@
|
|||||||
"columns": 3,
|
"columns": 3,
|
||||||
"allByDefault": true
|
"allByDefault": true
|
||||||
},
|
},
|
||||||
"showTopBar": { "type": "boolean", "label": "Top bar", "default": true, "group": "Show / hide" },
|
"showTopBar": {
|
||||||
"showSeries": { "type": "boolean", "label": "Series name", "default": true, "group": "Show / hide" },
|
"type": "boolean",
|
||||||
"showPart": { "type": "boolean", "label": "Part # / plan title", "default": true, "group": "Show / hide" },
|
"label": "Top bar",
|
||||||
"showServiceType": { "type": "boolean", "label": "Service type", "default": true, "group": "Show / hide" },
|
"default": true,
|
||||||
"showDate": { "type": "boolean", "label": "Plan date", "default": true, "group": "Show / hide" },
|
"group": "Show"
|
||||||
"showServiceTime": { "type": "boolean", "label": "Service time", "default": true, "group": "Show / hide" },
|
},
|
||||||
"showTotalLength": { "type": "boolean", "label": "Planned total length", "default": true, "group": "Show / hide" },
|
"showSeries": {
|
||||||
"showRunningClock": { "type": "boolean", "label": "Running clock (ahead / behind)", "default": true, "group": "Show / hide" },
|
"type": "boolean",
|
||||||
"showCountdown": { "type": "boolean", "label": "Countdown to service start", "default": true, "group": "Show / hide" },
|
"label": "Series name",
|
||||||
"showHeaders": { "type": "boolean", "label": "Section headers", "default": true, "group": "Show / hide" },
|
"default": true,
|
||||||
"showItemNumbers": { "type": "boolean", "label": "Item numbers", "default": false, "group": "Show / hide" },
|
"group": "Show"
|
||||||
"showStartTimes": { "type": "boolean", "label": "Scheduled start times", "default": true, "group": "Show / hide" },
|
},
|
||||||
"showLength": { "type": "boolean", "label": "Item length", "default": true, "group": "Show / hide" },
|
"showPart": {
|
||||||
"showItemType": { "type": "boolean", "label": "Item type badge", "default": true, "group": "Show / hide" },
|
"type": "boolean",
|
||||||
"showKey": { "type": "boolean", "label": "Song key", "default": true, "group": "Show / hide" },
|
"label": "Part / title",
|
||||||
"showArrangement": { "type": "boolean", "label": "Song arrangement", "default": false, "group": "Show / hide" },
|
"default": true,
|
||||||
"showSongAuthor": { "type": "boolean", "label": "Song author / CCLI", "default": false, "group": "Show / hide" },
|
"group": "Show"
|
||||||
"showDescription": { "type": "boolean", "label": "Item description", "default": true, "group": "Show / hide" },
|
},
|
||||||
"showPreService": { "type": "boolean", "label": "Pre-service items", "default": true, "group": "Show / hide" },
|
"showServiceType": {
|
||||||
"showPostService": { "type": "boolean", "label": "Post-service items", "default": true, "group": "Show / hide" },
|
"type": "boolean",
|
||||||
"showNowIndicator": { "type": "boolean", "label": "Now indicator", "default": true, "group": "Show / hide" },
|
"label": "Service type",
|
||||||
"showItemRuntime": { "type": "boolean", "label": "Item runtime (elapsed vs planned)", "default": true, "group": "Show / hide" },
|
"default": true,
|
||||||
"dimCompleted": { "type": "boolean", "label": "Dim completed items", "default": true, "group": "Show / hide" },
|
"group": "Show"
|
||||||
"showLiveFooter": { "type": "boolean", "label": "ProPresenter match line", "default": true, "group": "Show / hide" }
|
},
|
||||||
|
"showDate": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Plan date",
|
||||||
|
"default": true,
|
||||||
|
"group": "Show"
|
||||||
|
},
|
||||||
|
"showServiceTime": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Service time",
|
||||||
|
"default": true,
|
||||||
|
"group": "Show"
|
||||||
|
},
|
||||||
|
"showTotalLength": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Total length",
|
||||||
|
"default": true,
|
||||||
|
"group": "Show"
|
||||||
|
},
|
||||||
|
"showRunningClock": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Running clock",
|
||||||
|
"default": true,
|
||||||
|
"group": "Show"
|
||||||
|
},
|
||||||
|
"showCountdown": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Countdown",
|
||||||
|
"default": true,
|
||||||
|
"group": "Show"
|
||||||
|
},
|
||||||
|
"showHeaders": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Section headers",
|
||||||
|
"default": true,
|
||||||
|
"group": "Show"
|
||||||
|
},
|
||||||
|
"showItemNumbers": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Item numbers",
|
||||||
|
"default": false,
|
||||||
|
"group": "Show"
|
||||||
|
},
|
||||||
|
"showStartTimes": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Start times",
|
||||||
|
"default": true,
|
||||||
|
"group": "Show"
|
||||||
|
},
|
||||||
|
"showLength": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Item length",
|
||||||
|
"default": true,
|
||||||
|
"group": "Show"
|
||||||
|
},
|
||||||
|
"showItemType": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Type badge",
|
||||||
|
"default": true,
|
||||||
|
"group": "Show"
|
||||||
|
},
|
||||||
|
"showKey": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Song key",
|
||||||
|
"default": true,
|
||||||
|
"group": "Show"
|
||||||
|
},
|
||||||
|
"showArrangement": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Arrangement",
|
||||||
|
"default": false,
|
||||||
|
"group": "Show"
|
||||||
|
},
|
||||||
|
"showSongAuthor": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Author / CCLI",
|
||||||
|
"default": false,
|
||||||
|
"group": "Show"
|
||||||
|
},
|
||||||
|
"showDescription": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Item description",
|
||||||
|
"default": true,
|
||||||
|
"group": "Show"
|
||||||
|
},
|
||||||
|
"showPreService": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Pre-service",
|
||||||
|
"default": true,
|
||||||
|
"group": "Show"
|
||||||
|
},
|
||||||
|
"showPostService": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Post-service",
|
||||||
|
"default": true,
|
||||||
|
"group": "Show"
|
||||||
|
},
|
||||||
|
"showNowIndicator": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Now indicator",
|
||||||
|
"default": true,
|
||||||
|
"group": "Show"
|
||||||
|
},
|
||||||
|
"showItemRuntime": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Item runtime",
|
||||||
|
"default": true,
|
||||||
|
"group": "Show"
|
||||||
|
},
|
||||||
|
"dimCompleted": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Dim completed",
|
||||||
|
"default": true,
|
||||||
|
"group": "Show"
|
||||||
|
},
|
||||||
|
"showLiveFooter": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Match line",
|
||||||
|
"default": true,
|
||||||
|
"group": "Show"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "prodcom-transcript",
|
"id": "prodcom-transcript",
|
||||||
"name": "ProdCom",
|
"name": "ProdCom",
|
||||||
"version": "1.3.1",
|
"version": "1.3.2",
|
||||||
"proddash": ">=1.1.0",
|
"proddash": ">=1.1.0",
|
||||||
"description": "Live ProdCom transcript",
|
"description": "Live ProdCom transcript",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
"instanceSchema": {
|
"instanceSchema": {
|
||||||
"showAvatars": {
|
"showAvatars": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"label": "Show channel icons",
|
"label": "Channel icons",
|
||||||
"default": true
|
"default": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ──────────────────────────────────────────────────── */
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
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
|
A solo tile's gear carries only the settings its one card can use (the
|
||||||
@@ -196,6 +196,10 @@ function defaultStatus(timer, value, tone) {
|
|||||||
|
|
||||||
/* ── the tile ─────────────────────────────────────────────────────────── */
|
/* ── the 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;
|
||||||
@@ -227,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 || '');
|
||||||
@@ -283,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 timers',
|
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');
|
||||||
@@ -292,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 = [];
|
||||||
@@ -319,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 = '';
|
||||||
@@ -779,7 +791,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
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);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"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": [
|
"provides": [
|
||||||
@@ -112,7 +112,7 @@
|
|||||||
},
|
},
|
||||||
"instanceGroups": {
|
"instanceGroups": {
|
||||||
"Show": {
|
"Show": {
|
||||||
"collapsed": false,
|
"collapsed": true,
|
||||||
"columns": 2
|
"columns": 2
|
||||||
},
|
},
|
||||||
"Clock": {
|
"Clock": {
|
||||||
@@ -121,8 +121,7 @@
|
|||||||
},
|
},
|
||||||
"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": {
|
||||||
|
|||||||
@@ -321,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;
|
||||||
@@ -383,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; }
|
||||||
|
|||||||
+16
-5
@@ -141,13 +141,17 @@ const sendTarget = (() => {
|
|||||||
/**
|
/**
|
||||||
* Where a send goes, given the configured channels in admin order: the
|
* Where a send goes, given the configured channels in admin order: the
|
||||||
* selection while it is still configured and its transcript is on the
|
* selection while it is still configured and its transcript is on the
|
||||||
* page; otherwise the first configured channel.
|
* page, else the first configured channel that IS on this page — with one
|
||||||
|
* transcript up, that one — and only if the page shows none, the first
|
||||||
|
* configured channel. A send must never quietly go to a channel the
|
||||||
|
* operator cannot see.
|
||||||
*/
|
*/
|
||||||
resolve(configured) {
|
resolve(configured) {
|
||||||
const list = Array.isArray(configured) ? configured : [];
|
const list = Array.isArray(configured) ? configured : [];
|
||||||
if (!list.length) return '';
|
if (!list.length) return '';
|
||||||
if (id && list.includes(id) && this.hasTranscript(id)) return id;
|
if (id && list.includes(id) && this.hasTranscript(id)) return id;
|
||||||
return list[0];
|
const onPage = list.find((c) => this.hasTranscript(c));
|
||||||
|
return onPage || list[0];
|
||||||
},
|
},
|
||||||
on(fn) {
|
on(fn) {
|
||||||
bus.addEventListener('change', fn);
|
bus.addEventListener('change', fn);
|
||||||
@@ -1100,7 +1104,7 @@ function createSendTile({ root, moduleApi, mode }) {
|
|||||||
if (stopped || !state) return;
|
if (stopped || !state) return;
|
||||||
if (token) {
|
if (token) {
|
||||||
if (composeEls) {
|
if (composeEls) {
|
||||||
composeEls.line.textContent = sendingLine();
|
if (composeEls.line) composeEls.line.textContent = sendingLine();
|
||||||
if (composeEls.input) composeEls.input.placeholder = placeholder();
|
if (composeEls.input) composeEls.input.placeholder = placeholder();
|
||||||
}
|
}
|
||||||
} else renderGate();
|
} else renderGate();
|
||||||
@@ -1177,14 +1181,21 @@ function createSendTile({ root, moduleApi, mode }) {
|
|||||||
let composeEls = null;
|
let composeEls = null;
|
||||||
function buildCompose() {
|
function buildCompose() {
|
||||||
compose.textContent = '';
|
compose.textContent = '';
|
||||||
|
// Send message names the channel in the box's own placeholder and locks
|
||||||
|
// from the title bar, so a line above the field would only say both
|
||||||
|
// again. Quick replies has no placeholder — there it is the only place
|
||||||
|
// the target is named, so it stays.
|
||||||
|
let line = null;
|
||||||
|
if (mode !== 'send') {
|
||||||
const head = el('div', 'sl-compose-head');
|
const head = el('div', 'sl-compose-head');
|
||||||
const line = el('span', 'sl-sending', sendingLine());
|
line = el('span', 'sl-sending', sendingLine());
|
||||||
const lockLink = el('button', 'sl-lock-link', 'Lock');
|
const lockLink = el('button', 'sl-lock-link', 'Lock');
|
||||||
lockLink.type = 'button';
|
lockLink.type = 'button';
|
||||||
lockLink.title = 'Lock now';
|
lockLink.title = 'Lock now';
|
||||||
lockLink.addEventListener('click', () => lock('Locked'));
|
lockLink.addEventListener('click', () => lock('Locked'));
|
||||||
head.append(line, lockLink);
|
head.append(line, lockLink);
|
||||||
compose.appendChild(head);
|
compose.appendChild(head);
|
||||||
|
}
|
||||||
|
|
||||||
const foot = el('div', 'sl-compose-foot');
|
const foot = el('div', 'sl-compose-foot');
|
||||||
const msg = el('div', 'sl-msg');
|
const msg = el('div', 'sl-msg');
|
||||||
@@ -1462,7 +1473,7 @@ function createSendTile({ root, moduleApi, mode }) {
|
|||||||
if (!s.sending?.enabled || !s.sending?.pinSet) return void lock('');
|
if (!s.sending?.enabled || !s.sending?.pinSet) return void lock('');
|
||||||
if (!composeEls) return void showCompose(); // the page was unlocked before this tile appeared
|
if (!composeEls) return void showCompose(); // the page was unlocked before this tile appeared
|
||||||
if (composeEls) {
|
if (composeEls) {
|
||||||
composeEls.line.textContent = sendingLine();
|
if (composeEls.line) composeEls.line.textContent = sendingLine();
|
||||||
if (composeEls.input) composeEls.input.placeholder = placeholder();
|
if (composeEls.input) composeEls.input.placeholder = placeholder();
|
||||||
}
|
}
|
||||||
} else renderGate();
|
} else renderGate();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "slack",
|
"id": "slack",
|
||||||
"name": "Slack",
|
"name": "Slack",
|
||||||
"version": "1.2.0",
|
"version": "1.2.1",
|
||||||
"proddash": ">=1.4.0",
|
"proddash": ">=1.4.0",
|
||||||
"description": "Live transcripts of this location's Slack channels, plus PIN-protected sending as the location",
|
"description": "Live transcripts of this location's Slack channels, plus PIN-protected sending as the location",
|
||||||
"client": "client.js",
|
"client": "client.js",
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
"help": "Create a Slack app in the workspace (api.slack.com/apps → Create New App → From scratch) and, under OAuth & Permissions → User Token Scopes, add channels:history, channels:read, groups:history, groups:read, chat:write, users:read, reactions:write (emoji reactions) and emoji:read (the workspace's custom emoji). Sign in to Slack as this location's account — the one named for the campus — open the app's OAuth & Permissions page and press Install to Workspace (or Reinstall to Workspace), then copy the User OAuth Token (xoxp-…) it shows and paste it here: messages from this machine are then literally from the location. A bot token (xoxb-…) works too; it posts as the app, with \"Send as\" naming the location."
|
"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": {
|
"Channels": {
|
||||||
"help": "Every channel picked here becomes its own transcript tile in the + picker, titled with the channel's name. The Send message and Quick replies tiles post to whichever transcript was clicked last — its bottom edge lights up in the accent colour — and to the first channel here until one is clicked. Only channels the account has joined are listed; join one in Slack first. Each channel is read on its own schedule, all of them together kept inside Slack's rate limit."
|
"help": "Every channel picked here becomes its own transcript tile in the + picker, titled with the channel's name. The Send message and Quick replies tiles post to whichever transcript was clicked last — its bottom edge lights up in the accent colour — and until one is clicked, to the first channel here whose transcript is on that dashboard: with a single transcript up, that one. A send never goes to a channel the dashboard is not showing. Only channels the account has joined are listed; join one in Slack first. Each channel is read on its own schedule, all of them together kept inside Slack's rate limit."
|
||||||
},
|
},
|
||||||
"Sending": {
|
"Sending": {
|
||||||
"toggle": "sendEnabled",
|
"toggle": "sendEnabled",
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
"default": [],
|
"default": [],
|
||||||
"group": "Channels",
|
"group": "Channels",
|
||||||
"optionsRoute": "/channels",
|
"optionsRoute": "/channels",
|
||||||
"help": "One transcript tile per channel; the first one is where sends go until a transcript is clicked. Only channels the account has joined are listed — join one in Slack first."
|
"help": "One transcript tile per channel; until a transcript is clicked, sends go to the first channel here that the dashboard is actually showing. Only channels the account has joined are listed — join one in Slack first."
|
||||||
},
|
},
|
||||||
"pollSeconds": {
|
"pollSeconds": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proddash",
|
"name": "proddash",
|
||||||
"version": "1.5.0",
|
"version": "1.7.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
|
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
|
|||||||
@@ -451,6 +451,36 @@ a.btn { text-decoration: none; }
|
|||||||
|
|
||||||
/* one swatch per preset: a miniature dashboard (page, a tile on it, the
|
/* one swatch per preset: a miniature dashboard (page, a tile on it, the
|
||||||
accent as a status dot) and the name; the current one wears the accent */
|
accent as a status dot) and the name; the current one wears the accent */
|
||||||
|
/* ── a collapsible admin section ─────────────────────────────────────
|
||||||
|
The pattern for any section of this page that folds. The markup is a
|
||||||
|
<details class="admin-fold"> holding the section's contents, headed by a
|
||||||
|
<summary class="admin-h1 admin-fold-title">: the title keeps the same
|
||||||
|
type as every other section heading — a <summary> would otherwise come
|
||||||
|
out at the normal weight — and the caret follows the words, so a folded
|
||||||
|
section still reads as a heading first. The whole row is the hit target. */
|
||||||
|
details.admin-fold > summary.admin-fold-title {
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 700; /* matches h1.admin-h1; summary defaults to normal */
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
details.admin-fold > summary.admin-fold-title::-webkit-details-marker { display: none; }
|
||||||
|
/* after the title, not before it — the heading reads first, the caret says
|
||||||
|
there is more behind it */
|
||||||
|
details.admin-fold > summary.admin-fold-title::after {
|
||||||
|
content: "";
|
||||||
|
width: 0; height: 0;
|
||||||
|
border-left: 6px solid var(--muted);
|
||||||
|
border-top: 5px solid transparent;
|
||||||
|
border-bottom: 5px solid transparent;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
details.admin-fold[open] > summary.admin-fold-title::after { transform: rotate(90deg); }
|
||||||
|
details.admin-fold > summary.admin-fold-title:hover { color: var(--accent); }
|
||||||
|
|
||||||
.theme-swatches {
|
.theme-swatches {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(116px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(116px, 1fr));
|
||||||
@@ -512,3 +542,41 @@ 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;
|
||||||
|
}
|
||||||
|
|||||||
+91
-2
@@ -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,82 @@ 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.';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -856,6 +944,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);
|
||||||
|
|||||||
+35
-3
@@ -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>
|
||||||
@@ -40,9 +41,11 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="theme-section" hidden>
|
<section id="theme-section" hidden>
|
||||||
<h1 class="admin-h1">Theme</h1>
|
<details class="admin-fold">
|
||||||
|
<summary class="admin-h1 admin-fold-title">Theme</summary>
|
||||||
<div class="theme-swatches" id="theme-swatches" role="radiogroup" aria-label="Theme"></div>
|
<div class="theme-swatches" id="theme-swatches" role="radiogroup" aria-label="Theme"></div>
|
||||||
<div class="admin-status" id="theme-status"></div>
|
<div class="admin-status" id="theme-status"></div>
|
||||||
|
</details>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="modules-section" hidden>
|
<section id="modules-section" hidden>
|
||||||
@@ -65,6 +68,35 @@
|
|||||||
<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>
|
||||||
|
<div class="admin-status" id="pin-status"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div id="toast" hidden></div>
|
<div id="toast" hidden></div>
|
||||||
|
|||||||
@@ -19,6 +19,14 @@
|
|||||||
|
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
|
|
||||||
|
<button class="tb-btn icon" id="autohide-btn" title="Auto-hide bars" aria-label="Auto-hide tile title bars" aria-pressed="false">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M9 6l3-3 3 3"/>
|
||||||
|
<rect x="4" y="10" width="16" height="10" rx="1.5"/>
|
||||||
|
<path d="M4 14h16"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button class="tb-btn icon" id="fs-btn" title="Fullscreen" aria-label="Fullscreen">
|
<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"/>
|
||||||
|
|||||||
+211
-92
@@ -24,6 +24,7 @@ function toast(msg, isError = false) {
|
|||||||
toastTimer = setTimeout(() => { toastEl.hidden = true; }, 3200);
|
toastTimer = setTimeout(() => { toastEl.hidden = true; }, 3200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const autoHideBtn = document.getElementById('autohide-btn');
|
||||||
const modalBackdrop = document.getElementById('modal-backdrop');
|
const modalBackdrop = document.getElementById('modal-backdrop');
|
||||||
const modalEl = document.getElementById('modal');
|
const modalEl = document.getElementById('modal');
|
||||||
|
|
||||||
@@ -161,18 +162,25 @@ async function loadRegistry() {
|
|||||||
let tiles = [];
|
let tiles = [];
|
||||||
const tileEls = new Map(); // tile id -> { el, body, dot, titleEl, instance }
|
const tileEls = new Map(); // tile id -> { el, body, dot, titleEl, instance }
|
||||||
|
|
||||||
|
/* Whether tile title bars fold away until you point at them. It is part of
|
||||||
|
how a dashboard is arranged rather than a preference of this browser, so
|
||||||
|
it saves with the layout — including a named layout on the server — and a
|
||||||
|
fresh dashboard starts with the bars showing. Fullscreen folds every bar
|
||||||
|
away regardless; this is the windowed equivalent, asked for by hand. */
|
||||||
|
let autoHideHeads = false;
|
||||||
|
|
||||||
function saveLayout() {
|
function saveLayout() {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(LS_LAYOUT, JSON.stringify({ v: 1, tiles }));
|
localStorage.setItem(LS_LAYOUT, JSON.stringify({ v: 1, autoHideHeads, tiles }));
|
||||||
} catch { /* private mode / full — layout just won't persist */ }
|
} catch { /* private mode / full — layout just won't persist */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadLayoutFromStorage() {
|
function loadLayoutFromStorage() {
|
||||||
try {
|
try {
|
||||||
const raw = JSON.parse(localStorage.getItem(LS_LAYOUT) || 'null');
|
const raw = JSON.parse(localStorage.getItem(LS_LAYOUT) || 'null');
|
||||||
if (raw && Array.isArray(raw.tiles)) return raw.tiles;
|
if (raw && Array.isArray(raw.tiles)) return raw;
|
||||||
} catch { /* corrupted — start fresh */ }
|
} catch { /* corrupted — start fresh */ }
|
||||||
return [];
|
return { tiles: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeTile(t) {
|
function sanitizeTile(t) {
|
||||||
@@ -185,7 +193,7 @@ function sanitizeTile(t) {
|
|||||||
w,
|
w,
|
||||||
h: Math.max(1, Number(t.h) || 2),
|
h: Math.max(1, Number(t.h) || 2),
|
||||||
settings: (t.settings && typeof t.settings === 'object') ? t.settings : {},
|
settings: (t.settings && typeof t.settings === 'object') ? t.settings : {},
|
||||||
headHidden: Boolean(t.headHidden),
|
headPinned: Boolean(t.headPinned),
|
||||||
// set when the tile was added from a module's tile list (multi-tile modules)
|
// set when the tile was added from a module's tile list (multi-tile modules)
|
||||||
variant: t.variant ? String(t.variant) : '',
|
variant: t.variant ? String(t.variant) : '',
|
||||||
title: t.title ? String(t.title) : '',
|
title: t.title ? String(t.title) : '',
|
||||||
@@ -249,9 +257,48 @@ function minSizeOf(tile) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── title bars that fold away ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* A bar is "off" when it is folded out of sight: the dashboard has auto-hide
|
||||||
|
on and this tile is not pinned, or the screen is in fullscreen, which folds
|
||||||
|
every bar for watching whatever the tiles say. Off is never gone — the top
|
||||||
|
edge of the tile brings it back for as long as the pointer is there. */
|
||||||
|
|
||||||
|
/** How close to the tile's top edge the pointer opens a folded-away bar. */
|
||||||
|
const HEAD_REVEAL_PX = 22;
|
||||||
|
/** How far down it may wander before that bar folds away again (bar + a little). */
|
||||||
|
const HEAD_HOLD_PX = 38;
|
||||||
|
|
||||||
|
function headIsOff(tile) {
|
||||||
|
if (document.body.classList.contains('fullscreen')) return true;
|
||||||
|
return autoHideHeads && !tile.headPinned;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyHeadState(tile, el = tileEls.get(tile.id)?.el) {
|
||||||
|
if (!el) return;
|
||||||
|
const off = headIsOff(tile);
|
||||||
|
el.classList.toggle('head-off', off);
|
||||||
|
if (!off) el.classList.remove('head-peek');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-read every tile's bar — after the toggle, or after fullscreen changes. */
|
||||||
|
function applyAllHeadStates() {
|
||||||
|
for (const tile of tiles) applyHeadState(tile);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAutoHideHeads(on, { save = true } = {}) {
|
||||||
|
autoHideHeads = Boolean(on);
|
||||||
|
document.body.classList.toggle('autohide-heads', autoHideHeads);
|
||||||
|
autoHideBtn?.classList.toggle('is-on', autoHideHeads);
|
||||||
|
autoHideBtn?.setAttribute('aria-pressed', autoHideHeads ? 'true' : 'false');
|
||||||
|
applyAllHeadStates();
|
||||||
|
if (save) saveLayout();
|
||||||
|
}
|
||||||
|
|
||||||
/* ── tile DOM ───────────────────────────────────────────────────────── */
|
/* ── tile DOM ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
const CLOSE_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>';
|
const CLOSE_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>';
|
||||||
|
const PIN_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 16.5V22"/><path class="pin-head" d="M8.5 3h7l-1.2 7 3.2 3.2H6.5L9.7 10 8.5 3z"/></svg>';
|
||||||
const GEAR_SVG = '<svg viewBox="0 0 90 90" fill="currentColor" aria-hidden="true"><path d="M 82.94 32.538 c -2.685 0 -5.093 -1.545 -6.327 -3.929 c -0.245 -0.476 -0.504 -0.948 -0.774 -1.416 c -0.27 -0.468 -0.55 -0.928 -0.84 -1.378 c -1.448 -2.26 -1.582 -5.117 -0.239 -7.443 c 1.951 -3.378 0.792 -7.696 -2.584 -9.645 l -9.352 -5.4 c -3.378 -1.951 -7.696 -0.792 -9.645 2.584 l -0.002 0.003 c -1.322 2.291 -3.815 3.641 -6.456 3.512 c -1.149 -0.056 -2.301 -0.057 -3.455 0 c -2.638 0.128 -5.124 -1.226 -6.444 -3.513 l -0.002 -0.002 c -1.951 -3.378 -6.269 -4.535 -9.645 -2.584 l -9.352 5.4 c -3.378 1.951 -4.535 6.269 -2.584 9.645 l 0.002 0.002 c 1.321 2.286 1.25 5.117 -0.18 7.336 c -0.625 0.972 -1.201 1.97 -1.726 2.993 c -1.209 2.353 -3.625 3.836 -6.269 3.836 H 7.063 c -3.9 0 -7.061 3.162 -7.061 7.061 v 10.801 c 0 3.9 3.162 7.061 7.061 7.061 l 0 0 c 2.685 0 5.093 1.545 6.327 3.929 c 0.245 0.475 0.504 0.948 0.774 1.416 c 0.27 0.468 0.55 0.928 0.84 1.378 c 1.448 2.26 1.582 5.117 0.239 7.443 c -1.951 3.378 -0.792 7.696 2.584 9.645 l 9.352 5.4 c 3.378 1.951 7.696 0.792 9.645 -2.584 l 0.002 -0.003 c 1.322 -2.291 3.815 -3.641 6.456 -3.512 c 1.149 0.056 2.301 0.057 3.455 0 c 2.638 -0.128 5.124 1.226 6.444 3.513 l 0.002 0.002 c 1.951 3.378 6.268 4.535 9.645 2.584 l 9.352 -5.4 c 3.378 -1.949 4.535 -6.268 2.584 -9.645 l -0.002 -0.002 c -1.321 -2.286 -1.25 -5.117 0.18 -7.336 c 0.625 -0.972 1.201 -1.97 1.726 -2.993 c 1.209 -2.353 3.625 -3.836 6.269 -3.836 h 0.003 c 3.9 0 7.061 -3.162 7.061 -7.061 V 39.599 C 90.001 35.7 86.84 32.538 82.94 32.538 z M 54.998 62.318 c -9.565 5.523 -21.796 2.245 -27.317 -7.32 c -5.523 -9.565 -2.245 -21.796 7.32 -27.317 s 21.796 -2.245 27.317 7.32 C 67.841 44.566 64.563 56.797 54.998 62.318 z"/></svg>';
|
const GEAR_SVG = '<svg viewBox="0 0 90 90" fill="currentColor" aria-hidden="true"><path d="M 82.94 32.538 c -2.685 0 -5.093 -1.545 -6.327 -3.929 c -0.245 -0.476 -0.504 -0.948 -0.774 -1.416 c -0.27 -0.468 -0.55 -0.928 -0.84 -1.378 c -1.448 -2.26 -1.582 -5.117 -0.239 -7.443 c 1.951 -3.378 0.792 -7.696 -2.584 -9.645 l -9.352 -5.4 c -3.378 -1.951 -7.696 -0.792 -9.645 2.584 l -0.002 0.003 c -1.322 2.291 -3.815 3.641 -6.456 3.512 c -1.149 -0.056 -2.301 -0.057 -3.455 0 c -2.638 0.128 -5.124 -1.226 -6.444 -3.513 l -0.002 -0.002 c -1.951 -3.378 -6.269 -4.535 -9.645 -2.584 l -9.352 5.4 c -3.378 1.951 -4.535 6.269 -2.584 9.645 l 0.002 0.002 c 1.321 2.286 1.25 5.117 -0.18 7.336 c -0.625 0.972 -1.201 1.97 -1.726 2.993 c -1.209 2.353 -3.625 3.836 -6.269 3.836 H 7.063 c -3.9 0 -7.061 3.162 -7.061 7.061 v 10.801 c 0 3.9 3.162 7.061 7.061 7.061 l 0 0 c 2.685 0 5.093 1.545 6.327 3.929 c 0.245 0.475 0.504 0.948 0.774 1.416 c 0.27 0.468 0.55 0.928 0.84 1.378 c 1.448 2.26 1.582 5.117 0.239 7.443 c -1.951 3.378 -0.792 7.696 2.584 9.645 l 9.352 5.4 c 3.378 1.951 7.696 0.792 9.645 -2.584 l 0.002 -0.003 c 1.322 -2.291 3.815 -3.641 6.456 -3.512 c 1.149 0.056 2.301 0.057 3.455 0 c 2.638 -0.128 5.124 1.226 6.444 3.513 l 0.002 0.002 c 1.951 3.378 6.268 4.535 9.645 2.584 l 9.352 -5.4 c 3.378 -1.949 4.535 -6.268 2.584 -9.645 l -0.002 -0.002 c -1.321 -2.286 -1.25 -5.117 0.18 -7.336 c 0.625 -0.972 1.201 -1.97 1.726 -2.993 c 1.209 -2.353 3.625 -3.836 6.269 -3.836 h 0.003 c 3.9 0 7.061 -3.162 7.061 -7.061 V 39.599 C 90.001 35.7 86.84 32.538 82.94 32.538 z M 54.998 62.318 c -9.565 5.523 -21.796 2.245 -27.317 -7.32 c -5.523 -9.565 -2.245 -21.796 7.32 -27.317 s 21.796 -2.245 27.317 7.32 C 67.841 44.566 64.563 56.797 54.998 62.318 z"/></svg>';
|
||||||
|
|
||||||
function buildTile(tile) {
|
function buildTile(tile) {
|
||||||
@@ -278,7 +325,34 @@ function buildTile(tile) {
|
|||||||
const controls = document.createElement('span');
|
const controls = document.createElement('span');
|
||||||
controls.className = 'tile-controls';
|
controls.className = 'tile-controls';
|
||||||
|
|
||||||
head.append(dot, title, controls);
|
/* Pinned tiles keep their bar through auto-hide. The switch sits right
|
||||||
|
beside the label rather than out with the actions, because it is about
|
||||||
|
this bar rather than about the tile's contents. */
|
||||||
|
const pin = document.createElement('button');
|
||||||
|
pin.className = 'tile-btn pin';
|
||||||
|
pin.innerHTML = PIN_SVG;
|
||||||
|
const paintPin = () => {
|
||||||
|
const on = Boolean(tile.headPinned);
|
||||||
|
pin.classList.toggle('is-pinned', on);
|
||||||
|
pin.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||||
|
// Instant tooltips read `title` the first time and `data-tip` after.
|
||||||
|
pin.title = on ? 'Unpin bar' : 'Pin bar';
|
||||||
|
pin.setAttribute('data-tip', pin.title);
|
||||||
|
};
|
||||||
|
paintPin();
|
||||||
|
pin.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
tile.headPinned = !tile.headPinned;
|
||||||
|
paintPin();
|
||||||
|
applyHeadState(tile, el);
|
||||||
|
saveLayout();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keeps the label hugging the pin however wide the tile gets.
|
||||||
|
const gap = document.createElement('span');
|
||||||
|
gap.className = 'tile-head-gap';
|
||||||
|
|
||||||
|
head.append(dot, title, pin, gap, controls);
|
||||||
|
|
||||||
// Every tile has something to set: its own settings when the module
|
// Every tile has something to set: its own settings when the module
|
||||||
// offers any, and the tint either way.
|
// offers any, and the tint either way.
|
||||||
@@ -307,45 +381,47 @@ function buildTile(tile) {
|
|||||||
const body = document.createElement('div');
|
const body = document.createElement('div');
|
||||||
body.className = 'tile-body';
|
body.className = 'tile-body';
|
||||||
|
|
||||||
const resize = document.createElement('div');
|
/* The rim: both bottom corners resize in two directions, the bottom edge
|
||||||
resize.className = 'tile-resize';
|
moves that edge (trading rows with tiles below), and the other three
|
||||||
resize.title = 'Drag to resize';
|
move only themselves — the top edge changes where the tile starts, the
|
||||||
|
sides how wide it is — each stopping where a neighbour or the tile's own
|
||||||
|
minimum says it must. None of them draw anything: the cursor over an
|
||||||
|
edge is what says it can be dragged, as on a window. */
|
||||||
|
const grip = (cls) => {
|
||||||
|
const gripEl = document.createElement('div');
|
||||||
|
gripEl.className = cls;
|
||||||
|
return gripEl;
|
||||||
|
};
|
||||||
|
const resize = grip('tile-resize');
|
||||||
|
const resizeLeft = grip('tile-resize-left');
|
||||||
|
const resizeBottom = grip('tile-resize-bottom');
|
||||||
|
const edgeTop = grip('tile-resize-top');
|
||||||
|
const edgeRight = grip('tile-resize-right');
|
||||||
|
const edgeLeft = grip('tile-resize-side');
|
||||||
|
|
||||||
const resizeLeft = document.createElement('div');
|
// A folded-away bar is still reachable: point at the tile's top edge and it
|
||||||
resizeLeft.className = 'tile-resize-left';
|
// slides in over the content, where its buttons click and its whole width
|
||||||
resizeLeft.title = 'Drag to resize';
|
// drags the tile exactly as when it is showing.
|
||||||
|
el.addEventListener('pointermove', (ev) => {
|
||||||
// pill on the bottom edge: moves that edge (trading rows with tiles below)
|
if (!headIsOff(tile)) return;
|
||||||
const resizeBottom = document.createElement('div');
|
// Mid-drag the tile moves under the pointer, and mid-resize the edges do
|
||||||
resizeBottom.className = 'tile-resize-bottom';
|
// — neither is a moment to take the bar away.
|
||||||
resizeBottom.title = 'Drag to adjust height';
|
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
|
||||||
|
const y = ev.clientY - el.getBoundingClientRect().top;
|
||||||
// notch on the title bar's bottom edge: hides/shows the bar
|
const shown = el.classList.contains('head-peek');
|
||||||
const notch = document.createElement('button');
|
// It takes less to hold the bar open than to open it, so a bar that
|
||||||
notch.className = 'tile-notch';
|
// appears under the pointer cannot flicker straight back out.
|
||||||
notch.title = 'Hide / show the title bar';
|
if (!shown && y <= HEAD_REVEAL_PX) el.classList.add('head-peek');
|
||||||
notch.addEventListener('click', (e) => {
|
else if (shown && y > HEAD_HOLD_PX) el.classList.remove('head-peek');
|
||||||
e.stopPropagation();
|
|
||||||
// In fullscreen the bars are all folded away for watching; the notch
|
|
||||||
// peeks one open for a moment without touching what this tile saved.
|
|
||||||
if (document.body.classList.contains('fullscreen')) {
|
|
||||||
peekHead(tile, el, !el.classList.contains('head-peek'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
tile.headHidden = !tile.headHidden;
|
|
||||||
el.classList.toggle('head-hidden', tile.headHidden);
|
|
||||||
saveLayout();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// A peeked title bar stays while the pointer is on its tile, and starts
|
|
||||||
// counting down as soon as it leaves.
|
|
||||||
el.addEventListener('pointerenter', () => clearPeekTimer(tile.id));
|
|
||||||
el.addEventListener('pointerleave', () => {
|
el.addEventListener('pointerleave', () => {
|
||||||
if (el.classList.contains('head-peek')) schedulePeekHide(tile.id, el);
|
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
|
||||||
|
el.classList.remove('head-peek');
|
||||||
});
|
});
|
||||||
|
|
||||||
el.append(head, body, resize, resizeLeft, resizeBottom, notch);
|
el.append(head, body, resize, resizeLeft, resizeBottom, edgeTop, edgeRight, edgeLeft);
|
||||||
el.classList.toggle('head-hidden', Boolean(tile.headHidden));
|
applyHeadState(tile, el);
|
||||||
grid.appendChild(el);
|
grid.appendChild(el);
|
||||||
|
|
||||||
const entry = { el, body, dot, titleEl: title, controls, instance: null };
|
const entry = { el, body, dot, titleEl: title, controls, instance: null };
|
||||||
@@ -356,6 +432,9 @@ function buildTile(tile) {
|
|||||||
wireResize(tile, el, resize, false);
|
wireResize(tile, el, resize, false);
|
||||||
wireResize(tile, el, resizeLeft, true);
|
wireResize(tile, el, resizeLeft, true);
|
||||||
wireBottomResize(tile, el, resizeBottom);
|
wireBottomResize(tile, el, resizeBottom);
|
||||||
|
wireEdgeResize(tile, el, edgeTop, 'n');
|
||||||
|
wireEdgeResize(tile, el, edgeRight, 'e');
|
||||||
|
wireEdgeResize(tile, el, edgeLeft, 'w');
|
||||||
|
|
||||||
mountModule(tile);
|
mountModule(tile);
|
||||||
return entry;
|
return entry;
|
||||||
@@ -369,7 +448,7 @@ function refreshEmptyState() {
|
|||||||
|
|
||||||
function wireDrag(tile, el, handle) {
|
function wireDrag(tile, el, handle) {
|
||||||
handle.addEventListener('pointerdown', (ev) => {
|
handle.addEventListener('pointerdown', (ev) => {
|
||||||
if (ev.target.closest('.tile-btn') || ev.target.closest('.tile-menu') || ev.target.closest('.tile-notch')) return;
|
if (ev.target.closest('.tile-btn') || ev.target.closest('.tile-menu')) return;
|
||||||
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
|
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
|
||||||
if (document.body.classList.contains('fullscreen')) return;
|
if (document.body.classList.contains('fullscreen')) return;
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
@@ -486,6 +565,80 @@ function wireResize(tile, el, handle, fromLeft) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── drag the top, left or right edge ───────────────────────────────── */
|
||||||
|
|
||||||
|
/* One edge moves and the opposite one stays where it is, so the tile grows
|
||||||
|
or shrinks into the space beside it. Unlike the bottom-edge pill these do
|
||||||
|
not trade rows or columns with a neighbour: the edge simply stops when it
|
||||||
|
meets one, which is what "resize this tile" should mean. */
|
||||||
|
|
||||||
|
function wireEdgeResize(tile, el, handle, edge) {
|
||||||
|
handle.addEventListener('pointerdown', (ev) => {
|
||||||
|
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
|
||||||
|
if (document.body.classList.contains('fullscreen')) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
closeSettingsPopovers();
|
||||||
|
handle.setPointerCapture(ev.pointerId);
|
||||||
|
const m = cellMetrics();
|
||||||
|
const sx = ev.clientX;
|
||||||
|
const sy = ev.clientY;
|
||||||
|
const startX = tile.x;
|
||||||
|
const startY = tile.y;
|
||||||
|
const startW = tile.w;
|
||||||
|
const startH = tile.h;
|
||||||
|
const min = minSizeOf(tile);
|
||||||
|
el.classList.add('resizing');
|
||||||
|
grid.classList.add('no-dividers');
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
const onMove = (e) => {
|
||||||
|
const dCols = Math.round((e.clientX - sx) / (m.cw + m.gap));
|
||||||
|
const dRows = Math.round((e.clientY - sy) / (m.ch + m.gap));
|
||||||
|
let cand;
|
||||||
|
if (edge === 'n') {
|
||||||
|
// the top edge moves; the bottom edge stays put
|
||||||
|
const y = Math.max(0, Math.min(startY + startH - min.h, startY + dRows));
|
||||||
|
cand = { x: tile.x, y, w: tile.w, h: startY + startH - y };
|
||||||
|
} else if (edge === 'w') {
|
||||||
|
// the left edge moves; the right edge stays put
|
||||||
|
const x = Math.max(0, Math.min(startX + startW - min.w, startX + dCols));
|
||||||
|
cand = { x, y: tile.y, w: startX + startW - x, h: tile.h };
|
||||||
|
} else {
|
||||||
|
cand = {
|
||||||
|
x: tile.x,
|
||||||
|
y: tile.y,
|
||||||
|
w: Math.max(min.w, Math.min(COLS - tile.x, startW + dCols)),
|
||||||
|
h: tile.h,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (cand.x === tile.x && cand.y === tile.y && cand.w === tile.w && cand.h === tile.h) return;
|
||||||
|
if (!isFree(cand, tile.id)) return;
|
||||||
|
tile.x = cand.x;
|
||||||
|
tile.y = cand.y;
|
||||||
|
tile.w = cand.w;
|
||||||
|
tile.h = cand.h;
|
||||||
|
applyRect(tile);
|
||||||
|
changed = true;
|
||||||
|
notifyResize(tile);
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
handle.removeEventListener('pointermove', onMove);
|
||||||
|
handle.removeEventListener('pointerup', onUp);
|
||||||
|
handle.removeEventListener('pointercancel', onUp);
|
||||||
|
el.classList.remove('resizing');
|
||||||
|
grid.classList.remove('no-dividers');
|
||||||
|
if (changed) {
|
||||||
|
saveLayout();
|
||||||
|
rebuildDividers();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
handle.addEventListener('pointermove', onMove);
|
||||||
|
handle.addEventListener('pointerup', onUp);
|
||||||
|
handle.addEventListener('pointercancel', onUp);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/* ── drag the bottom edge: height only ──────────────────────────────── */
|
/* ── drag the bottom edge: height only ──────────────────────────────── */
|
||||||
|
|
||||||
/* The pill on a tile's bottom edge moves that edge. When other tiles sit
|
/* The pill on a tile's bottom edge moves that edge. When other tiles sit
|
||||||
@@ -613,7 +766,6 @@ function seamPairsAt(boundary) {
|
|||||||
function buildDivider(L, R, top, bottom) {
|
function buildDivider(L, R, top, bottom) {
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = 'tile-divider';
|
el.className = 'tile-divider';
|
||||||
el.title = 'Drag to resize this column';
|
|
||||||
|
|
||||||
const place = () => {
|
const place = () => {
|
||||||
const m = cellMetrics();
|
const m = cellMetrics();
|
||||||
@@ -626,14 +778,6 @@ function buildDivider(L, R, top, bottom) {
|
|||||||
|
|
||||||
const boundary = () => L.x + L.w;
|
const boundary = () => L.x + L.w;
|
||||||
const groupEls = () => dividers.filter((d) => d.boundary() === boundary()).map((d) => d.el);
|
const groupEls = () => dividers.filter((d) => d.boundary() === boundary()).map((d) => d.el);
|
||||||
const unlink = () => { for (const d of dividers) d.el.classList.remove('linked'); };
|
|
||||||
|
|
||||||
// Before anything is dragged, show which seams travel with this one.
|
|
||||||
el.addEventListener('pointerenter', () => {
|
|
||||||
if (document.body.classList.contains('fullscreen')) return;
|
|
||||||
for (const other of groupEls()) other.classList.add('linked');
|
|
||||||
});
|
|
||||||
el.addEventListener('pointerleave', unlink);
|
|
||||||
|
|
||||||
el.addEventListener('pointerdown', (ev) => {
|
el.addEventListener('pointerdown', (ev) => {
|
||||||
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
|
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
|
||||||
@@ -689,7 +833,6 @@ function buildDivider(L, R, top, bottom) {
|
|||||||
el.removeEventListener('pointerup', onUp);
|
el.removeEventListener('pointerup', onUp);
|
||||||
el.removeEventListener('pointercancel', onUp);
|
el.removeEventListener('pointercancel', onUp);
|
||||||
for (const other of group) other.classList.remove('active');
|
for (const other of group) other.classList.remove('active');
|
||||||
unlink();
|
|
||||||
if (changed) saveLayout();
|
if (changed) saveLayout();
|
||||||
rebuildDividers(); // adjacencies may have changed
|
rebuildDividers(); // adjacencies may have changed
|
||||||
};
|
};
|
||||||
@@ -727,7 +870,7 @@ function addTile(moduleId, entry = null) {
|
|||||||
if (spec && 'default' in spec) settings[key] = spec.default;
|
if (spec && 'default' in spec) settings[key] = spec.default;
|
||||||
}
|
}
|
||||||
Object.assign(settings, entry?.settings || {});
|
Object.assign(settings, entry?.settings || {});
|
||||||
const tile = { id: newId(), module: moduleId, ...spot, ...size, settings };
|
const tile = { id: newId(), module: moduleId, ...spot, ...size, settings, headPinned: false };
|
||||||
if (entry) {
|
if (entry) {
|
||||||
tile.variant = String(entry.id);
|
tile.variant = String(entry.id);
|
||||||
tile.title = String(entry.name || '');
|
tile.title = String(entry.name || '');
|
||||||
@@ -743,7 +886,6 @@ function addTile(moduleId, entry = null) {
|
|||||||
function removeTile(id) {
|
function removeTile(id) {
|
||||||
const idx = tiles.findIndex((t) => t.id === id);
|
const idx = tiles.findIndex((t) => t.id === id);
|
||||||
if (idx < 0) return;
|
if (idx < 0) return;
|
||||||
clearPeekTimer(id);
|
|
||||||
unmountModule(tiles[idx]);
|
unmountModule(tiles[idx]);
|
||||||
tiles.splice(idx, 1);
|
tiles.splice(idx, 1);
|
||||||
const entry = tileEls.get(id);
|
const entry = tileEls.get(id);
|
||||||
@@ -997,7 +1139,7 @@ function buildModuleApi(tile, man, entry) {
|
|||||||
/**
|
/**
|
||||||
* The tile's title bar. Controls added here live next to the gear/close
|
* The tile's title bar. Controls added here live next to the gear/close
|
||||||
* buttons and are removed automatically when the instance stops. The
|
* buttons and are removed automatically when the instance stops. The
|
||||||
* whole bar can be hidden by the user via the notch, so nothing
|
* whole bar folds away when the dashboard auto-hides it, so nothing
|
||||||
* essential should exist ONLY as a header control.
|
* essential should exist ONLY as a header control.
|
||||||
*/
|
*/
|
||||||
header: {
|
header: {
|
||||||
@@ -1712,9 +1854,13 @@ function remountTile(tile) {
|
|||||||
|
|
||||||
/* ── layout apply / reset ───────────────────────────────────────────── */
|
/* ── layout apply / reset ───────────────────────────────────────────── */
|
||||||
|
|
||||||
function renderLayout(list) {
|
function renderLayout(layout) {
|
||||||
|
// Callers pass a whole layout; an array on its own is still understood so
|
||||||
|
// "reset" can simply hand over no tiles.
|
||||||
|
const list = Array.isArray(layout) ? layout : (layout?.tiles || []);
|
||||||
clearAllTiles();
|
clearAllTiles();
|
||||||
tiles = list.map(sanitizeTile);
|
tiles = list.map(sanitizeTile);
|
||||||
|
if (!Array.isArray(layout)) setAutoHideHeads(Boolean(layout?.autoHideHeads), { save: false });
|
||||||
for (const tile of tiles) buildTile(tile);
|
for (const tile of tiles) buildTile(tile);
|
||||||
refreshEmptyState();
|
refreshEmptyState();
|
||||||
rebuildDividers();
|
rebuildDividers();
|
||||||
@@ -1794,7 +1940,7 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
|
|||||||
const res = await fetch('/api/layouts/' + encodeURIComponent(name), {
|
const res = await fetch('/api/layouts/' + encodeURIComponent(name), {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ layout: { tiles } }),
|
body: JSON.stringify({ layout: { autoHideHeads, tiles } }),
|
||||||
});
|
});
|
||||||
const body = await res.json().catch(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) throw new Error(body.error || 'Save failed (' + res.status + ')');
|
if (!res.ok) throw new Error(body.error || 'Save failed (' + res.status + ')');
|
||||||
@@ -1838,7 +1984,7 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
|
|||||||
if (!res.ok) throw new Error(body.error || 'Load failed (' + res.status + ')');
|
if (!res.ok) throw new Error(body.error || 'Load failed (' + res.status + ')');
|
||||||
// Loading copies the layout into this browser's own state — it
|
// Loading copies the layout into this browser's own state — it
|
||||||
// does not live-link browsers together.
|
// does not live-link browsers together.
|
||||||
renderLayout(body.layout.tiles || []);
|
renderLayout(body.layout);
|
||||||
saveLayout();
|
saveLayout();
|
||||||
toast(`Loaded “${l.name}”`);
|
toast(`Loaded “${l.name}”`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1850,7 +1996,7 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
|
|||||||
menu.insertBefore(block, reset);
|
menu.insertBefore(block, reset);
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ── menu bar show/hide (notch, like the tile title bars) ───────────── */
|
/* ── menu bar show/hide (its own notch, at the top-left) ────────────── */
|
||||||
|
|
||||||
const topbar = document.getElementById('topbar');
|
const topbar = document.getElementById('topbar');
|
||||||
const topbarNotch = document.getElementById('topbar-notch');
|
const topbarNotch = document.getElementById('topbar-notch');
|
||||||
@@ -1877,41 +2023,6 @@ try {
|
|||||||
if (localStorage.getItem(LS_MENU) === '1') setMenuHidden(true, { persist: false });
|
if (localStorage.getItem(LS_MENU) === '1') setMenuHidden(true, { persist: false });
|
||||||
} catch { /* fine */ }
|
} catch { /* fine */ }
|
||||||
|
|
||||||
/* ── fullscreen: peeking at a tile's title bar ──────────────────────── */
|
|
||||||
|
|
||||||
/** How long a peeked title bar stays after the pointer leaves its tile. */
|
|
||||||
const PEEK_HIDE_MS = 10000;
|
|
||||||
const peekTimers = new Map(); // tile id → timeout
|
|
||||||
|
|
||||||
function clearPeekTimer(id) {
|
|
||||||
const timer = peekTimers.get(id);
|
|
||||||
if (timer === undefined) return;
|
|
||||||
clearTimeout(timer);
|
|
||||||
peekTimers.delete(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function schedulePeekHide(id, el) {
|
|
||||||
clearPeekTimer(id);
|
|
||||||
peekTimers.set(id, setTimeout(() => {
|
|
||||||
peekTimers.delete(id);
|
|
||||||
el.classList.remove('head-peek');
|
|
||||||
}, PEEK_HIDE_MS));
|
|
||||||
}
|
|
||||||
|
|
||||||
function peekHead(tile, el, on) {
|
|
||||||
el.classList.toggle('head-peek', on);
|
|
||||||
clearPeekTimer(tile.id);
|
|
||||||
// Opened from a click the pointer is on the tile, so the countdown waits
|
|
||||||
// for it to leave — unless it already has (a keyboard or touch open).
|
|
||||||
if (on && !el.matches(':hover')) schedulePeekHide(tile.id, el);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Back to a plain screen: no peeks left open, no timers left running. */
|
|
||||||
function clearPeeks() {
|
|
||||||
for (const id of [...peekTimers.keys()]) clearPeekTimer(id);
|
|
||||||
document.querySelectorAll('.tile.head-peek').forEach((el) => el.classList.remove('head-peek'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── fullscreen: auto-fit the layout to the screen ──────────────────── */
|
/* ── fullscreen: auto-fit the layout to the screen ──────────────────── */
|
||||||
|
|
||||||
/* Fullscreen is a viewing mode. The occupied columns stretch proportionally
|
/* Fullscreen is a viewing mode. The occupied columns stretch proportionally
|
||||||
@@ -2026,6 +2137,13 @@ function clearFullscreenFit() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
autoHideBtn?.addEventListener('click', () => {
|
||||||
|
setAutoHideHeads(!autoHideHeads);
|
||||||
|
toast(autoHideHeads
|
||||||
|
? 'Title bars hide until you point at a tile\u2019s top edge'
|
||||||
|
: 'Title bars stay put');
|
||||||
|
});
|
||||||
|
|
||||||
fsBtn.addEventListener('click', () => {
|
fsBtn.addEventListener('click', () => {
|
||||||
if (!document.fullscreenElement) {
|
if (!document.fullscreenElement) {
|
||||||
document.documentElement.requestFullscreen?.().catch(() => {});
|
document.documentElement.requestFullscreen?.().catch(() => {});
|
||||||
@@ -2039,14 +2157,15 @@ document.addEventListener('fullscreenchange', () => {
|
|||||||
document.body.classList.toggle('fullscreen', active);
|
document.body.classList.toggle('fullscreen', active);
|
||||||
if (active) {
|
if (active) {
|
||||||
closeSettingsPopovers();
|
closeSettingsPopovers();
|
||||||
|
applyAllHeadStates(); // fullscreen has its own rule for the bars
|
||||||
menuHiddenBeforeFs = document.body.classList.contains('menu-hidden');
|
menuHiddenBeforeFs = document.body.classList.contains('menu-hidden');
|
||||||
setMenuHidden(true, { persist: false }); // the menu auto-hides; its notch brings it back
|
setMenuHidden(true, { persist: false }); // the menu auto-hides; its notch brings it back
|
||||||
// let the collapsed menubar's space settle before measuring
|
// let the collapsed menubar's space settle before measuring
|
||||||
requestAnimationFrame(() => requestAnimationFrame(applyFullscreenFit));
|
requestAnimationFrame(() => requestAnimationFrame(applyFullscreenFit));
|
||||||
} else {
|
} else {
|
||||||
setMenuHidden(menuHiddenBeforeFs, { persist: false });
|
setMenuHidden(menuHiddenBeforeFs, { persist: false });
|
||||||
clearPeeks();
|
|
||||||
clearFullscreenFit();
|
clearFullscreenFit();
|
||||||
|
applyAllHeadStates();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+216
-120
@@ -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 ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -270,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 {
|
||||||
@@ -290,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;
|
||||||
@@ -321,6 +351,33 @@ body.menu-hidden #topbar {
|
|||||||
.tile-btn.is-open,
|
.tile-btn.is-open,
|
||||||
.tile-btn.is-open:hover { background: var(--accent); color: var(--accent-text); }
|
.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 */
|
/* the shell's tooltip: shown at once under the button the pointer is on */
|
||||||
#tip {
|
#tip {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -374,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;
|
||||||
@@ -431,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;
|
||||||
@@ -528,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; }
|
||||||
@@ -944,8 +941,10 @@ body.fullscreen #grid { padding-bottom: 0; }
|
|||||||
every shell element follows, and a colour a tile is configured with (set
|
every shell element follows, and a colour a tile is configured with (set
|
||||||
on the tile itself) still wins. The :root block at the top of this file is
|
on the tile itself) still wins. The :root block at the top of this file is
|
||||||
"booth", the default; the list of ids and names lives in server.js
|
"booth", the default; the list of ids and names lives in server.js
|
||||||
(GET /api/theme). All four are booth-grade: high contrast, the accent
|
(GET /api/theme). Every one is booth-grade: high contrast, the accent
|
||||||
nowhere near the danger red or the warning amber, muted still readable. */
|
nowhere near the danger red or the warning amber, muted still readable.
|
||||||
|
Each sets --accent-text too when the default (a near-black green, for
|
||||||
|
booth's mint) would look wrong against its own accent. */
|
||||||
|
|
||||||
/* Harbor — deep navy, cyan accent */
|
/* Harbor — deep navy, cyan accent */
|
||||||
html[data-theme="harbor"] {
|
html[data-theme="harbor"] {
|
||||||
@@ -996,3 +995,100 @@ html[data-theme="daylight"] {
|
|||||||
--danger: #d1242f;
|
--danger: #d1242f;
|
||||||
--warn: #b26a00;
|
--warn: #b26a00;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Midnight — indigo night, violet accent */
|
||||||
|
html[data-theme="midnight"] {
|
||||||
|
--bg: #0a0b18;
|
||||||
|
--panel: #14162b;
|
||||||
|
--border: #272a4a;
|
||||||
|
--text: #e8e9fb;
|
||||||
|
--muted: #9a9cc8;
|
||||||
|
--accent: #9d8cff;
|
||||||
|
--accent-text: #12103a;
|
||||||
|
--danger: #ff5a72;
|
||||||
|
--warn: #f5b93a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Moss — forest floor, lime accent (warn pushed orange so the two greens
|
||||||
|
and the amber never read as one another) */
|
||||||
|
html[data-theme="moss"] {
|
||||||
|
--bg: #08120c;
|
||||||
|
--panel: #101e15;
|
||||||
|
--border: #1f3728;
|
||||||
|
--text: #e9f4ea;
|
||||||
|
--muted: #8fb09a;
|
||||||
|
--accent: #8fdb3f;
|
||||||
|
--accent-text: #15250a;
|
||||||
|
--danger: #ff5a52;
|
||||||
|
--warn: #ff9f1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Plum — aubergine, purple accent */
|
||||||
|
html[data-theme="plum"] {
|
||||||
|
--bg: #110a17;
|
||||||
|
--panel: #1d1228;
|
||||||
|
--border: #332042;
|
||||||
|
--text: #f2e9f8;
|
||||||
|
--muted: #b195c2;
|
||||||
|
--accent: #c084fc;
|
||||||
|
--accent-text: #260a37;
|
||||||
|
--danger: #ff4d4f;
|
||||||
|
--warn: #f7b53b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sepia — warm brown room, cool teal accent: the opposite arrangement to
|
||||||
|
Ember, which is warm throughout */
|
||||||
|
html[data-theme="sepia"] {
|
||||||
|
--bg: #12100b;
|
||||||
|
--panel: #1d1913;
|
||||||
|
--border: #33291d;
|
||||||
|
--text: #f4ede0;
|
||||||
|
--muted: #b3a48b;
|
||||||
|
--accent: #5eead4;
|
||||||
|
--accent-text: #06231e;
|
||||||
|
--danger: #ff5a52;
|
||||||
|
--warn: #ffc247;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blackout — true black for a dark room, where any glow off the screen
|
||||||
|
carries; everything else is as bright as it can be against it */
|
||||||
|
html[data-theme="blackout"] {
|
||||||
|
--bg: #000000;
|
||||||
|
--panel: #0a0a0a;
|
||||||
|
--border: #1f1f1f;
|
||||||
|
--text: #ffffff;
|
||||||
|
--muted: #9e9e9e;
|
||||||
|
--accent: #00e5ff;
|
||||||
|
--accent-text: #00212a;
|
||||||
|
--danger: #ff3b30;
|
||||||
|
--warn: #ffcc00;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Paper — the warm light one, next to Daylight's cool white. Danger and warn
|
||||||
|
darken, as they must on a light background, and the indigo accent takes
|
||||||
|
white text. */
|
||||||
|
html[data-theme="paper"] {
|
||||||
|
--bg: #f5f1e8;
|
||||||
|
--panel: #fffdf8;
|
||||||
|
--border: #ddd5c4;
|
||||||
|
--text: #1c1a15;
|
||||||
|
--muted: #6b6455;
|
||||||
|
--accent: #4f46e5;
|
||||||
|
--accent-text: #ffffff;
|
||||||
|
--danger: #c8102e;
|
||||||
|
--warn: #9a6700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Iron — no colour of its own: the accent is near-white, so the only hues on
|
||||||
|
screen are a module's own and the red/amber that mean something */
|
||||||
|
html[data-theme="iron"] {
|
||||||
|
--bg: #0c0c0d;
|
||||||
|
--panel: #161617;
|
||||||
|
--border: #2b2b2d;
|
||||||
|
--text: #f2f2f3;
|
||||||
|
--muted: #9a9a9d;
|
||||||
|
--accent: #d8d8dc;
|
||||||
|
--accent-text: #131314;
|
||||||
|
--danger: #ff4d4f;
|
||||||
|
--warn: #ffb224;
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
* Configure via config/proddash.json (defaults, in the repo), an optional
|
* 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
|
* PRODDASH_FORGE (overrides `forge`: "github", or a Gitea/Forgejo base
|
||||||
* URL such as http://10.3.8.90:3000)
|
* 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)
|
||||||
@@ -711,6 +712,13 @@ const THEMES = [
|
|||||||
{ id: 'graphite', name: 'Graphite', colors: { bg: '#0f1012', panel: '#17191d', accent: '#6cb4ff' } },
|
{ id: 'graphite', name: 'Graphite', colors: { bg: '#0f1012', panel: '#17191d', accent: '#6cb4ff' } },
|
||||||
{ id: 'ember', name: 'Ember', colors: { bg: '#120c0a', panel: '#1d1411', accent: '#ff8f3a' } },
|
{ id: 'ember', name: 'Ember', colors: { bg: '#120c0a', panel: '#1d1411', accent: '#ff8f3a' } },
|
||||||
{ id: 'daylight', name: 'Daylight', colors: { bg: '#eef1f5', panel: '#ffffff', accent: '#0b8a5f' } },
|
{ id: 'daylight', name: 'Daylight', colors: { bg: '#eef1f5', panel: '#ffffff', accent: '#0b8a5f' } },
|
||||||
|
{ id: 'midnight', name: 'Midnight', colors: { bg: '#0a0b18', panel: '#14162b', accent: '#9d8cff' } },
|
||||||
|
{ id: 'moss', name: 'Moss', colors: { bg: '#08120c', panel: '#101e15', accent: '#8fdb3f' } },
|
||||||
|
{ id: 'plum', name: 'Plum', colors: { bg: '#110a17', panel: '#1d1228', accent: '#c084fc' } },
|
||||||
|
{ id: 'sepia', name: 'Sepia', colors: { bg: '#12100b', panel: '#1d1913', accent: '#5eead4' } },
|
||||||
|
{ id: 'blackout', name: 'Blackout', colors: { bg: '#000000', panel: '#0a0a0a', accent: '#00e5ff' } },
|
||||||
|
{ id: 'paper', name: 'Paper', colors: { bg: '#f5f1e8', panel: '#fffdf8', accent: '#4f46e5' } },
|
||||||
|
{ id: 'iron', name: 'Iron', colors: { bg: '#0c0c0d', panel: '#161617', accent: '#d8d8dc' } },
|
||||||
];
|
];
|
||||||
const DEFAULT_THEME = THEMES[0].id;
|
const DEFAULT_THEME = THEMES[0].id;
|
||||||
|
|
||||||
@@ -1096,12 +1104,13 @@ function uninstallModule(id) {
|
|||||||
|
|
||||||
/* — updating the shell itself — */
|
/* — updating the shell itself — */
|
||||||
|
|
||||||
function copyTree(src, dst) {
|
function copyTree(src, dst, include = null) {
|
||||||
fs.mkdirSync(dst, { recursive: true });
|
fs.mkdirSync(dst, { recursive: true });
|
||||||
for (const e of fs.readdirSync(src, { withFileTypes: true })) {
|
for (const e of fs.readdirSync(src, { withFileTypes: true })) {
|
||||||
|
if (include && !include(e.name)) continue;
|
||||||
const s = path.join(src, e.name);
|
const s = path.join(src, e.name);
|
||||||
const d = path.join(dst, e.name);
|
const d = path.join(dst, e.name);
|
||||||
if (e.isDirectory()) copyTree(s, d);
|
if (e.isDirectory()) copyTree(s, d, include);
|
||||||
else fs.copyFileSync(s, d);
|
else fs.copyFileSync(s, d);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1121,25 +1130,83 @@ async function updateShell() {
|
|||||||
const out = git(['pull', '--ff-only', 'origin', REPO.branch], 120000);
|
const out = git(['pull', '--ff-only', 'origin', REPO.branch], 120000);
|
||||||
console.log('[proddash] git pull:', out.split('\n').filter(Boolean).slice(-2).join(' | '));
|
console.log('[proddash] git pull:', out.split('\n').filter(Boolean).slice(-2).join(' | '));
|
||||||
} else {
|
} else {
|
||||||
const keep = (rel) => rel === 'config/modules.json' || rel.startsWith('config/layouts/') || rel.startsWith('.git/');
|
|
||||||
const staging = path.join(ROOT, '.proddash-update');
|
const staging = path.join(ROOT, '.proddash-update');
|
||||||
fs.rmSync(staging, { recursive: true, force: true });
|
fs.rmSync(staging, { recursive: true, force: true });
|
||||||
for (const [rel, data] of remote.files) {
|
for (const [rel, data] of remote.files) {
|
||||||
if (!safeRelPath(rel) || keep(rel)) continue;
|
if (!safeRelPath(rel) || isKeptOnUpdate(rel)) continue;
|
||||||
const target = path.join(staging, rel);
|
const target = path.join(staging, rel);
|
||||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||||
fs.writeFileSync(target, data);
|
fs.writeFileSync(target, data);
|
||||||
}
|
}
|
||||||
if (!fs.existsSync(path.join(staging, 'server.js'))) {
|
const newServer = path.join(staging, 'server.js');
|
||||||
|
if (!fs.existsSync(newServer)) {
|
||||||
fs.rmSync(staging, { recursive: true, force: true });
|
fs.rmSync(staging, { recursive: true, force: true });
|
||||||
throw httpError(502, 'The downloaded archive does not look like ProdDash (no server.js).');
|
throw httpError(502, 'The downloaded archive does not look like ProdDash (no server.js).');
|
||||||
}
|
}
|
||||||
|
// Nothing is swapped in until the new server at least parses. A file that
|
||||||
|
// arrived truncated or mangled would otherwise be found only by a process
|
||||||
|
// that cannot start — the one state this app cannot talk its way out of.
|
||||||
|
try {
|
||||||
|
execFileSync(process.execPath, ['--check', newServer], { timeout: 30000, stdio: ['ignore', 'ignore', 'pipe'] });
|
||||||
|
} catch (err) {
|
||||||
|
fs.rmSync(staging, { recursive: true, force: true });
|
||||||
|
// node --check leads with the staging path and a caret diagram; the
|
||||||
|
// line that names the fault is the one worth repeating.
|
||||||
|
const lines = String(err?.stderr || err?.message || err).split('\n').map((l) => l.trim());
|
||||||
|
const why = lines.find((l) => /^[A-Za-z]*Error\b/.test(l)) || 'it does not parse';
|
||||||
|
throw httpError(502, `The downloaded ProdDash will not run — nothing was changed (${why}).`);
|
||||||
|
}
|
||||||
|
keepPreviousCopy();
|
||||||
copyTree(staging, ROOT);
|
copyTree(staging, ROOT);
|
||||||
fs.rmSync(staging, { recursive: true, force: true });
|
fs.rmSync(staging, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
return { from: SHELL_VERSION, to: remote.version || '', method: status.method };
|
return { from: SHELL_VERSION, to: remote.version || '', method: status.method };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── what an update must not touch ───────────────────────────────────
|
||||||
|
Anything inside the data directory. Normally it sits well outside the app
|
||||||
|
folder and none of this applies, but when it cannot (an unwritable
|
||||||
|
Application Support, or PRODDASH_DATA_DIR aimed inside the app) the data
|
||||||
|
directory IS the app's config/ — and an update would then write the repo's
|
||||||
|
shipped defaults over this machine's own proddash.json, taking the admin
|
||||||
|
PIN, the theme and the forge with it. The two legacy names stay listed for
|
||||||
|
an install whose state never moved out. */
|
||||||
|
|
||||||
|
const DATA_DIR_INSIDE_ROOT = (() => {
|
||||||
|
const rel = path.relative(ROOT, DATA_DIR);
|
||||||
|
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return '';
|
||||||
|
return rel.split(path.sep).join('/') + '/';
|
||||||
|
})();
|
||||||
|
|
||||||
|
function isKeptOnUpdate(rel) {
|
||||||
|
if (rel.startsWith('.git/')) return true;
|
||||||
|
if (rel === 'config/modules.json' || rel.startsWith('config/layouts/')) return true;
|
||||||
|
return Boolean(DATA_DIR_INSIDE_ROOT) && `${rel}/`.startsWith(DATA_DIR_INSIDE_ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── the way back ────────────────────────────────────────────────────
|
||||||
|
The copy being replaced is set aside first, so there is always one known
|
||||||
|
version to return to. The launcher restores it if the new one will not
|
||||||
|
stay up (see ServerController); by hand it is a rename. It is replaced by
|
||||||
|
the next update, never accumulated. */
|
||||||
|
|
||||||
|
function previousCopyPath() {
|
||||||
|
return `${ROOT}.previous`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function keepPreviousCopy() {
|
||||||
|
const previous = previousCopyPath();
|
||||||
|
try {
|
||||||
|
fs.rmSync(previous, { recursive: true, force: true });
|
||||||
|
copyTree(ROOT, previous, (rel) => rel !== '.proddash-update' && rel !== '.git');
|
||||||
|
fs.writeFileSync(path.join(previous, '.proddash-version'), SHELL_VERSION);
|
||||||
|
console.log(`[proddash] kept ${SHELL_VERSION} at ${previous} in case the new one will not run`);
|
||||||
|
} catch (err) {
|
||||||
|
// Worth saying, not worth refusing the update over.
|
||||||
|
console.warn('[proddash] could not set the current copy aside:', err?.message || err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exit so the new code runs. Exit code 75 tells a launcher loop to start us
|
* Exit so the new code runs. Exit code 75 tells a launcher loop to start us
|
||||||
* again; when nothing is supervising (PRODDASH_LAUNCHER unset) we start our
|
* again; when nothing is supervising (PRODDASH_LAUNCHER unset) we start our
|
||||||
@@ -1170,12 +1237,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);
|
||||||
}
|
}
|
||||||
@@ -1183,7 +1347,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
|
||||||
@@ -1268,10 +1432,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`,
|
||||||
@@ -1280,12 +1453,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,
|
||||||
@@ -1394,7 +1605,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,
|
||||||
@@ -1402,7 +1617,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;
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1466,7 +1692,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 {
|
||||||
@@ -1751,6 +1977,22 @@ server.on('error', (err) => {
|
|||||||
|
|
||||||
server.listen(PORT, '0.0.0.0');
|
server.listen(PORT, '0.0.0.0');
|
||||||
|
|
||||||
|
/* An update proves itself by staying up. Once this version has, the copy it
|
||||||
|
replaced is dropped — so `app.previous` existing always means "the last
|
||||||
|
update has not proven itself yet", which is exactly what the launcher
|
||||||
|
should roll back to and nothing more. */
|
||||||
|
const UPDATE_PROVEN_AFTER_MS = 90000;
|
||||||
|
setTimeout(() => {
|
||||||
|
const previous = previousCopyPath();
|
||||||
|
if (!fs.existsSync(previous)) return;
|
||||||
|
try {
|
||||||
|
fs.rmSync(previous, { recursive: true, force: true });
|
||||||
|
console.log(`[proddash] ${SHELL_VERSION} has stayed up — dropped the copy it replaced`);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[proddash] could not drop the previous copy:', err?.message || err);
|
||||||
|
}
|
||||||
|
}, UPDATE_PROVEN_AFTER_MS).unref();
|
||||||
|
|
||||||
// Update check: shortly after boot, then every few hours (the admin page
|
// Update check: shortly after boot, then every few hours (the admin page
|
||||||
// shows the result; nothing is installed without someone clicking).
|
// shows the result; nothing is installed without someone clicking).
|
||||||
if (!process.env.PRODDASH_NO_REMOTE_CHECK) {
|
if (!process.env.PRODDASH_NO_REMOTE_CHECK) {
|
||||||
|
|||||||
Reference in New Issue
Block a user