Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3967af110 |
@@ -325,9 +325,8 @@ below. Nothing is installed without someone clicking.
|
|||||||
session (Start ProdDash.command) and approve the one-time prompt; a server
|
session (Start ProdDash.command) and approve the one-time prompt; a server
|
||||||
started over SSH or by bare launchd silently captures zeros. (Engine
|
started over SSH or by bare launchd silently captures zeros. (Engine
|
||||||
self-test: `node modules/propresenter-timers/ltc.selftest.js`.)
|
self-test: `node modules/propresenter-timers/ltc.selftest.js`.)
|
||||||
- **PCO Plan** — Planning Center Services, as three tiles in the + picker:
|
- **PCO Plan** — Planning Center Services, as two tiles in the + picker:
|
||||||
**Plan** (the order of service), **Teams** (who is scheduled) and **Song
|
**Plan** (the order of service) and **Teams** (who is scheduled).
|
||||||
Key** (the key of the item on screen).
|
|
||||||
|
|
||||||
**Plan** shows every
|
**Plan** shows every
|
||||||
plan item with its scheduled start time, length, type, song key,
|
plan item with its scheduled start time, length, type, song key,
|
||||||
@@ -358,16 +357,6 @@ below. Nothing is installed without someone clicking.
|
|||||||
is switched off, or it has no host — nothing to turn off and nothing to
|
is switched off, or it has no host — nothing to turn off and nothing to
|
||||||
explain.
|
explain.
|
||||||
|
|
||||||
**Song Key** is one value, as large as the tile allows: the key of the item
|
|
||||||
ProPresenter has live, with the song's name and the next key under it — both
|
|
||||||
switches, as is the colour. When the feed that picks the item is gone the key
|
|
||||||
stays up, faded, and the tile's dot says so rather than pretending to be
|
|
||||||
current.
|
|
||||||
|
|
||||||
Keys are written as notes wherever the module shows one: Planning Center's
|
|
||||||
`Bb` and `F#m` read as B♭ and F♯m, on the Plan tile's item badge and in the
|
|
||||||
Song Key tile alike.
|
|
||||||
|
|
||||||
**Teams** shows the same plan's team and position assignments — each team
|
**Teams** shows the same plan's team and position assignments — each team
|
||||||
as a band of its people, with their photo (fetched through ProdDash, never
|
as a band of its people, with their photo (fetched through ProdDash, never
|
||||||
by the browser; initials when Planning Center has none) and a dot for
|
by the browser; initials when Planning Center has none) and a dot for
|
||||||
|
|||||||
@@ -391,7 +391,8 @@ const menu = moduleApi.header.addMenu({
|
|||||||
build(menuEl) {
|
build(menuEl) {
|
||||||
// called on every open with an emptied menu element — fill it with
|
// called on every open with an emptied menu element — fill it with
|
||||||
// buttons (give them your scoped classes; the container is styled and
|
// buttons (give them your scoped classes; the container is styled and
|
||||||
// fixed-positioned by the shell, so the tile's overflow can't clip it)
|
// positioned by the shell, hosted on the body under an element carrying
|
||||||
|
// your data-module, so your scoped CSS applies and nothing clips it)
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
menu.setLabel('Channels (2/4) ▾'); // update the button text any time
|
menu.setLabel('Channels (2/4) ▾'); // update the button text any time
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/* PCO Plan — client part.
|
/* PCO Plan — client part.
|
||||||
|
|
||||||
The module presents three tiles and this is the entry for all of them: the
|
The module presents two tiles and this is the entry for both: the Teams
|
||||||
Teams tile (picker entry "teams") is built by teams-client.js and the Song
|
tile (picker entry "teams") is built by teams-client.js, everything below
|
||||||
Key tile ("key") by key-client.js; everything below draws the Plan tile.
|
draws the Plan tile.
|
||||||
|
|
||||||
The server part reads Planning Center and (optionally) follows the
|
The server part reads Planning Center and (optionally) follows the
|
||||||
ProPresenter module's live item; this tile only renders the plan it is
|
ProPresenter module's live item; this tile only renders the plan it is
|
||||||
@@ -13,8 +13,6 @@
|
|||||||
the connection settings. */
|
the connection settings. */
|
||||||
|
|
||||||
import createTeams from "./teams-client.js";
|
import createTeams from "./teams-client.js";
|
||||||
import createKey from "./key-client.js";
|
|
||||||
import { prettyKey } from "./key-notation.js";
|
|
||||||
|
|
||||||
const REFRESH_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>';
|
const REFRESH_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>';
|
||||||
|
|
||||||
@@ -23,9 +21,8 @@ const MINUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" st
|
|||||||
const PLUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>';
|
const PLUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>';
|
||||||
|
|
||||||
export default function create({ root, moduleApi }) {
|
export default function create({ root, moduleApi }) {
|
||||||
// Each picker entry carries its variant and its own settings.
|
// Tiles added as "Teams" carry that variant and have their own settings.
|
||||||
if (moduleApi.variant === "teams") return createTeams({ root, moduleApi });
|
if (moduleApi.variant === "teams") return createTeams({ root, moduleApi });
|
||||||
if (moduleApi.variant === "key") return createKey({ root, moduleApi });
|
|
||||||
return createPlan({ root, moduleApi });
|
return createPlan({ root, moduleApi });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,8 +367,7 @@ function createPlan({ root, moduleApi }) {
|
|||||||
if (on('showKey') && item.keyName) {
|
if (on('showKey') && item.keyName) {
|
||||||
const key = document.createElement('span');
|
const key = document.createElement('span');
|
||||||
key.className = 'pp-key';
|
key.className = 'pp-key';
|
||||||
// B♭, F♯m — the same notation the Song Key tile uses.
|
key.textContent = item.keyName;
|
||||||
key.textContent = prettyKey(item.keyName);
|
|
||||||
titleLine.appendChild(key);
|
titleLine.appendChild(key);
|
||||||
}
|
}
|
||||||
if (on('showNowIndicator')) {
|
if (on('showNowIndicator')) {
|
||||||
|
|||||||
@@ -1,181 +0,0 @@
|
|||||||
/* PCO Plan — the Song Key tile.
|
|
||||||
|
|
||||||
One thing, big: the key of the plan item on screen. It reads the same
|
|
||||||
streamed state the Plan tile does, so it needs no connection of its own;
|
|
||||||
which item is current comes from the ProPresenter module, as ever. */
|
|
||||||
|
|
||||||
import { parseKey, prettyKey } from "./key-notation.js";
|
|
||||||
|
|
||||||
export default function createKey({ root, moduleApi }) {
|
|
||||||
let state = null;
|
|
||||||
let stream = null;
|
|
||||||
let feedOffline = false;
|
|
||||||
|
|
||||||
root.innerHTML = `
|
|
||||||
<div class="pk-wrap">
|
|
||||||
<div class="pk-key"></div>
|
|
||||||
<div class="pk-name"></div>
|
|
||||||
<div class="pk-next"></div>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
const wrap = root.querySelector('.pk-wrap');
|
|
||||||
const keyEl = root.querySelector('.pk-key');
|
|
||||||
const nameEl = root.querySelector('.pk-name');
|
|
||||||
const nextEl = root.querySelector('.pk-next');
|
|
||||||
|
|
||||||
const cfg = () => moduleApi.instanceSettings || {};
|
|
||||||
const on = (key) => cfg()[key] !== false; // unset = shown
|
|
||||||
|
|
||||||
/* ── the item on screen, and the one after it ───────────────────── */
|
|
||||||
|
|
||||||
function items() {
|
|
||||||
return Array.isArray(state?.items) ? state.items : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentItem() {
|
|
||||||
const id = state?.live?.following ? state.live.currentItemId : '';
|
|
||||||
return id ? items().find((it) => it.id === id) || null : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The next item that actually has a key — a talk between two songs is skipped. */
|
|
||||||
function nextKeyed(after) {
|
|
||||||
const list = items();
|
|
||||||
const from = after ? list.findIndex((it) => it.id === after.id) + 1 : 0;
|
|
||||||
if (from <= 0) return null;
|
|
||||||
for (let i = from; i < list.length; i += 1) {
|
|
||||||
if (list[i].keyName) return list[i];
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function titleOf(item) {
|
|
||||||
return item ? (item.song?.title || item.title || '') : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── render ─────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
function renderStatus() {
|
|
||||||
if (feedOffline) return moduleApi.setStatus('error', 'ProdDash server unreachable — reconnecting…');
|
|
||||||
if (!state) return moduleApi.setStatus('connecting', 'Connecting…');
|
|
||||||
if (!state.configured) return moduleApi.setStatus('error', 'No Planning Center credentials — add them in Admin');
|
|
||||||
if (!state.serviceTypeSet) return moduleApi.setStatus('error', 'No service type selected — pick one in Admin');
|
|
||||||
if (!state.reachable) {
|
|
||||||
return moduleApi.setStatus(state.lastFetched ? 'error' : 'connecting', state.lastError || 'Reading the plan…');
|
|
||||||
}
|
|
||||||
const item = currentItem();
|
|
||||||
if (!state.live?.following) return moduleApi.setStatus('ok', 'Not following ProPresenter — no item is current');
|
|
||||||
// The key on screen stays — the band still needs it — but a tile must
|
|
||||||
// never look confidently live while the feed that chose it is gone.
|
|
||||||
if (!state.live.available) {
|
|
||||||
return moduleApi.setStatus('error', `${state.live.reason || 'ProPresenter feed unavailable'} — showing the last item`);
|
|
||||||
}
|
|
||||||
if (!state.live.ppReachable) return moduleApi.setStatus('error', 'ProPresenter unreachable — showing the last item');
|
|
||||||
if (!item) return moduleApi.setStatus('ok', 'Waiting for an item to go live');
|
|
||||||
moduleApi.setStatus('ok', `${titleOf(item)}${item.keyName ? ` — ${prettyKey(item.keyName)}` : ' — no key in the plan'}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A key is a note, so it is worth setting properly: "Bb" and "F#m" read
|
|
||||||
* better with the accidental raised and the minor kept small.
|
|
||||||
*/
|
|
||||||
function drawKey(text) {
|
|
||||||
keyEl.innerHTML = '';
|
|
||||||
const parts = parseKey(text);
|
|
||||||
if (!parts) {
|
|
||||||
keyEl.textContent = text;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
keyEl.appendChild(document.createTextNode(parts.note));
|
|
||||||
if (parts.accidental) {
|
|
||||||
const acc = document.createElement('span');
|
|
||||||
acc.className = 'pk-accidental';
|
|
||||||
acc.textContent = parts.accidental;
|
|
||||||
keyEl.appendChild(acc);
|
|
||||||
}
|
|
||||||
if (parts.quality) {
|
|
||||||
const tail = document.createElement('span');
|
|
||||||
tail.className = 'pk-quality';
|
|
||||||
tail.textContent = parts.quality;
|
|
||||||
keyEl.appendChild(tail);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function render() {
|
|
||||||
wrap.classList.toggle('is-offline', feedOffline);
|
|
||||||
const live = state?.live;
|
|
||||||
wrap.classList.toggle('is-stale', Boolean(live?.following && (!live.available || !live.ppReachable)));
|
|
||||||
const color = String(cfg().keyColor || '').trim();
|
|
||||||
wrap.style.setProperty('--pk-color', /^[#a-zA-Z0-9(),.%\s-]+$/.test(color) && color ? color : '#4ea1ff');
|
|
||||||
renderStatus();
|
|
||||||
|
|
||||||
const item = currentItem();
|
|
||||||
const key = item?.keyName || '';
|
|
||||||
wrap.classList.toggle('has-key', Boolean(key));
|
|
||||||
|
|
||||||
if (!state || !state.configured || !state.serviceTypeSet) {
|
|
||||||
drawKey('—');
|
|
||||||
nameEl.textContent = state ? 'Set up PCO Plan in Admin' : 'Connecting…';
|
|
||||||
nextEl.textContent = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!item) {
|
|
||||||
drawKey('—');
|
|
||||||
nameEl.textContent = state.live?.following ? 'Nothing live' : 'Not following ProPresenter';
|
|
||||||
nextEl.textContent = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
drawKey(key || '—');
|
|
||||||
nameEl.textContent = on('showSongName') ? titleOf(item) : '';
|
|
||||||
nameEl.hidden = !nameEl.textContent;
|
|
||||||
|
|
||||||
const next = on('showNextKey') ? nextKeyed(item) : null;
|
|
||||||
nextEl.textContent = next ? `Next: ${prettyKey(next.keyName)} · ${titleOf(next)}` : '';
|
|
||||||
nextEl.hidden = !nextEl.textContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── live feed ──────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
function accept(payload) {
|
|
||||||
if (!payload || typeof payload !== 'object' || !payload.state) return;
|
|
||||||
state = payload.state;
|
|
||||||
feedOffline = false;
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
|
|
||||||
function connect() {
|
|
||||||
stream = moduleApi.sse('/stream', {
|
|
||||||
open() { feedOffline = false; render(); },
|
|
||||||
error() { feedOffline = true; render(); },
|
|
||||||
events: {
|
|
||||||
state(ev) {
|
|
||||||
try {
|
|
||||||
accept(JSON.parse(ev.data));
|
|
||||||
} catch { /* a bad frame is not fatal */ }
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── lifecycle ──────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
return {
|
|
||||||
start() {
|
|
||||||
moduleApi.setStatus('connecting', 'Connecting…');
|
|
||||||
moduleApi.fetch('/state')
|
|
||||||
.then((res) => (res.ok ? res.json() : null))
|
|
||||||
.then((body) => { if (body && !state) accept(body); })
|
|
||||||
.catch(() => { /* the stream will cover it */ });
|
|
||||||
connect();
|
|
||||||
render();
|
|
||||||
},
|
|
||||||
stop() {
|
|
||||||
stream?.close();
|
|
||||||
stream = null;
|
|
||||||
root.innerHTML = '';
|
|
||||||
},
|
|
||||||
onConfigChange() {
|
|
||||||
// Admin holds the connection only; the server re-inits and re-sends.
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
/* PCO Plan — how a song key is written.
|
|
||||||
|
|
||||||
Planning Center stores keys as typed: "Bb", "F#m", "Ab". Every tile in this
|
|
||||||
module shows them as notes instead — B♭, F♯m, A♭ — so the plan, the badge
|
|
||||||
on an item and the Song Key tile all agree. Shared rather than repeated
|
|
||||||
because the day one of them changes, they all should. */
|
|
||||||
|
|
||||||
/** "F#m" → { note: 'F', accidental: '♯', quality: 'm' }; anything odd → null. */
|
|
||||||
export function parseKey(raw) {
|
|
||||||
const match = /^([A-G])\s*([#b♯♭]?)\s*(.*)$/.exec(String(raw || '').trim());
|
|
||||||
if (!match) return null;
|
|
||||||
const [, note, accidental, quality] = match;
|
|
||||||
return {
|
|
||||||
note,
|
|
||||||
accidental: accidental === '#' ? '♯' : accidental === 'b' ? '♭' : accidental,
|
|
||||||
quality: quality.trim(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The same thing as one string, for a badge or a caption. */
|
|
||||||
export function prettyKey(raw) {
|
|
||||||
const parts = parseKey(raw);
|
|
||||||
if (!parts) return String(raw || '').trim();
|
|
||||||
return `${parts.note}${parts.accidental}${parts.quality}`;
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"id": "pco-plan",
|
"id": "pco-plan",
|
||||||
"name": "PCO Plan",
|
"name": "PCO Plan",
|
||||||
"version": "1.9.0",
|
"version": "1.8.0",
|
||||||
"proddash": ">=1.4.0",
|
"proddash": ">=1.4.0",
|
||||||
"description": "Order of service, team assignments and the song key from Planning Center Services",
|
"description": "Order of service and team assignments from Planning Center Services",
|
||||||
"provides": [
|
"provides": [
|
||||||
"timers"
|
"timers"
|
||||||
],
|
],
|
||||||
@@ -621,36 +621,6 @@
|
|||||||
"columns": 2
|
"columns": 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "key",
|
|
||||||
"name": "Song Key",
|
|
||||||
"description": "The key of the item on screen, big",
|
|
||||||
"defaultSize": {
|
|
||||||
"w": 2,
|
|
||||||
"h": 2
|
|
||||||
},
|
|
||||||
"minSize": {
|
|
||||||
"w": 1,
|
|
||||||
"h": 1
|
|
||||||
},
|
|
||||||
"instanceSchema": {
|
|
||||||
"keyColor": {
|
|
||||||
"type": "color",
|
|
||||||
"label": "Key",
|
|
||||||
"default": "#4ea1ff"
|
|
||||||
},
|
|
||||||
"showSongName": {
|
|
||||||
"type": "boolean",
|
|
||||||
"label": "Song name",
|
|
||||||
"default": true
|
|
||||||
},
|
|
||||||
"showNextKey": {
|
|
||||||
"type": "boolean",
|
|
||||||
"label": "Next key",
|
|
||||||
"default": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,9 +206,7 @@
|
|||||||
|
|
||||||
[data-module="pco-plan"] .pp-now-tag {
|
[data-module="pco-plan"] .pp-now-tag {
|
||||||
display: none;
|
display: none;
|
||||||
/* Optically centred like the section chips: uppercase has no descenders,
|
padding: 0.05em 0.45em;
|
||||||
so the capitals sat 0.59px low in the pill. */
|
|
||||||
padding: 0.018em 0.45em 0.082em;
|
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--pp-current);
|
background: var(--pp-current);
|
||||||
color: var(--bg);
|
color: var(--bg);
|
||||||
@@ -637,110 +635,25 @@
|
|||||||
|
|
||||||
[data-module="pco-plan"] .pp-abs-sec {
|
[data-module="pco-plan"] .pp-abs-sec {
|
||||||
flex: none;
|
flex: none;
|
||||||
/* Uppercase text has no descenders, so the font's descent allowance below
|
padding: 0.1em 0.5em;
|
||||||
the baseline leaves the caps sitting high in the box — measured at 0.9px
|
border-radius: 999px;
|
||||||
too much headroom. The padding is a touch shallower on top and deeper
|
border: 1px solid var(--border);
|
||||||
below to centre the letters themselves; in em, so it holds at any text
|
|
||||||
size. */
|
|
||||||
padding: 0.095em 0.55em 0.185em;
|
|
||||||
/* The same rounded square, tinted ground and transparent border the AbleSet
|
|
||||||
module gives its own section pills, so the two read as one family. */
|
|
||||||
border-radius: 0.35em;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
background: color-mix(in srgb, var(--text) 8%, var(--panel));
|
|
||||||
font-size: 0.72em;
|
font-size: 0.72em;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease;
|
transition: color 0.2s ease, border-color 0.2s ease;
|
||||||
}
|
}
|
||||||
[data-module="pco-plan"] .pp-abs-sec.is-done { opacity: 0.45; }
|
[data-module="pco-plan"] .pp-abs-sec.is-done { opacity: 0.45; }
|
||||||
|
|
||||||
/* The section now playing fills left to right as it goes, like the row. */
|
/* The section now playing fills left to right as it goes, like the row. */
|
||||||
/* The section playing keeps its own ground and fills across it, so the
|
|
||||||
progress reads against the chip rather than against the row behind it. */
|
|
||||||
[data-module="pco-plan"] .pp-abs-sec.is-now {
|
[data-module="pco-plan"] .pp-abs-sec.is-now {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border-color: color-mix(in srgb, var(--pp-current) 60%, transparent);
|
border-color: color-mix(in srgb, var(--pp-current) 60%, transparent);
|
||||||
background:
|
background:
|
||||||
linear-gradient(90deg,
|
linear-gradient(90deg,
|
||||||
color-mix(in srgb, var(--pp-current) 45%, var(--panel)) var(--pp-sec-fill, 0%),
|
color-mix(in srgb, var(--pp-current) 40%, transparent) var(--pp-sec-fill, 0%),
|
||||||
color-mix(in srgb, var(--text) 8%, var(--panel)) var(--pp-sec-fill, 0%));
|
transparent var(--pp-sec-fill, 0%));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── the Song Key tile ────────────────────────────────────────────────
|
|
||||||
One value, as large as the tile allows. Sizes come from the tile itself
|
|
||||||
(container units), like the AbleSet module's single readouts, so a small
|
|
||||||
tile and a wall-sized one both read properly. */
|
|
||||||
|
|
||||||
[data-module="pco-plan"] .pk-wrap {
|
|
||||||
--pk-color: #4ea1ff;
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
container-type: size;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.2em;
|
|
||||||
padding: 4%;
|
|
||||||
text-align: center;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-module="pco-plan"] .pk-key {
|
|
||||||
font-size: clamp(18px, 46cqmin, 260px);
|
|
||||||
font-weight: 800;
|
|
||||||
line-height: 1;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
color: var(--muted);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
[data-module="pco-plan"] .pk-wrap.has-key .pk-key { color: var(--pk-color); }
|
|
||||||
|
|
||||||
/* A key is a note, but it has to read as one word at a glance from the back
|
|
||||||
of a room: the accidental and the quality sit on the same line as the
|
|
||||||
letter, only smaller. Raising the accidental looked like two stacked
|
|
||||||
characters at tile sizes. */
|
|
||||||
[data-module="pco-plan"] .pk-accidental {
|
|
||||||
font-size: 0.68em;
|
|
||||||
margin-left: 0.01em;
|
|
||||||
}
|
|
||||||
[data-module="pco-plan"] .pk-quality {
|
|
||||||
font-size: 0.5em;
|
|
||||||
font-weight: 700;
|
|
||||||
margin-left: 0.05em;
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-module="pco-plan"] .pk-name {
|
|
||||||
max-width: 100%;
|
|
||||||
font-size: clamp(9px, 9cqmin, 30px);
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1.2;
|
|
||||||
color: var(--text);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
[data-module="pco-plan"] .pk-name[hidden] { display: none; }
|
|
||||||
|
|
||||||
[data-module="pco-plan"] .pk-next {
|
|
||||||
max-width: 100%;
|
|
||||||
font-size: clamp(8px, 6.5cqmin, 20px);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--muted);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
[data-module="pco-plan"] .pk-next[hidden] { display: none; }
|
|
||||||
|
|
||||||
[data-module="pco-plan"] .pk-wrap.is-offline { opacity: 0.6; }
|
|
||||||
/* The feed that picks the item is gone: the key stays readable, but faded,
|
|
||||||
so nobody trusts it as current without looking at the status dot. */
|
|
||||||
[data-module="pco-plan"] .pk-wrap.is-stale .pk-key { opacity: 0.55; }
|
|
||||||
[data-module="pco-plan"] .pk-wrap.is-stale .pk-name,
|
|
||||||
[data-module="pco-plan"] .pk-wrap.is-stale .pk-next { opacity: 0.6; }
|
|
||||||
|
|||||||
@@ -81,8 +81,8 @@ export default function create({ root, moduleApi }) {
|
|||||||
moduleApi.saveInstanceSettings({ textSize: Math.min(48, Math.max(10, current + delta)) });
|
moduleApi.saveInstanceSettings({ textSize: Math.min(48, Math.max(10, current + delta)) });
|
||||||
applyInstanceSettings();
|
applyInstanceSettings();
|
||||||
}
|
}
|
||||||
moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-2) });
|
const minusBtn = moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-2) });
|
||||||
moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(2) });
|
const plusBtn = moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(2) });
|
||||||
|
|
||||||
// icon and tooltip track the current direction — applyInstanceSettings sets both
|
// icon and tooltip track the current direction — applyInstanceSettings sets both
|
||||||
const flowBtn = moduleApi.header.addButton({
|
const flowBtn = moduleApi.header.addButton({
|
||||||
@@ -95,7 +95,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
moduleApi.header.addButton({
|
const latestBtn = moduleApi.header.addButton({
|
||||||
icon: JUMP_SVG,
|
icon: JUMP_SVG,
|
||||||
title: 'Latest',
|
title: 'Latest',
|
||||||
onClick() {
|
onClick() {
|
||||||
@@ -112,6 +112,82 @@ export default function create({ root, moduleApi }) {
|
|||||||
});
|
});
|
||||||
clearBtn.classList.add('pt-clear');
|
clearBtn.classList.add('pt-clear');
|
||||||
|
|
||||||
|
/* ── a narrow tile: the bar keeps Channels, the rest fold into ⋯ ──
|
||||||
|
Seven controls need ~175 px, and the shell clips whatever the bar
|
||||||
|
cannot fit — on a 3-column tile among many, or one squeezed by the
|
||||||
|
fullscreen fit, that used to take the channel filter with it. Below
|
||||||
|
COMPACT_PX the six secondary controls hide and a single ⋯ menu offers
|
||||||
|
the same actions, so the filter button is the last thing to go. */
|
||||||
|
const MORE_SVG = '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>';
|
||||||
|
const COMPACT_PX = 380; // tile width below which the full bar no longer fits
|
||||||
|
|
||||||
|
let moreMenuEl = null;
|
||||||
|
const moreMenu = moduleApi.header.addMenu({
|
||||||
|
icon: MORE_SVG,
|
||||||
|
title: 'More',
|
||||||
|
build(menu) {
|
||||||
|
menu.classList.add('pt-menu');
|
||||||
|
moreMenuEl = menu;
|
||||||
|
rebuildMoreMenu();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const moreWrap = moreMenu.button.parentElement; // the shell's .tile-dd
|
||||||
|
const secondaryBtns = [timeBtn, minusBtn, plusBtn, flowBtn, latestBtn, clearBtn];
|
||||||
|
|
||||||
|
function addMoreItem(label, { on = null, onClick } = {}) {
|
||||||
|
const item = document.createElement('button');
|
||||||
|
item.className = 'pt-channel-item' + (on ? ' on' : '');
|
||||||
|
if (on !== null) {
|
||||||
|
item.setAttribute('role', 'menuitemcheckbox');
|
||||||
|
item.setAttribute('aria-checked', String(on));
|
||||||
|
} else {
|
||||||
|
item.setAttribute('role', 'menuitem');
|
||||||
|
}
|
||||||
|
const check = document.createElement('span');
|
||||||
|
check.className = 'check';
|
||||||
|
check.textContent = '✓';
|
||||||
|
const name = document.createElement('span');
|
||||||
|
name.className = 'name';
|
||||||
|
name.textContent = label;
|
||||||
|
item.append(check, name);
|
||||||
|
item.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation(); // the rebuild detaches this item — keep the menu open
|
||||||
|
onClick();
|
||||||
|
});
|
||||||
|
moreMenuEl.appendChild(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildMoreMenu() {
|
||||||
|
if (!moreMenuEl) return;
|
||||||
|
moreMenuEl.innerHTML = '';
|
||||||
|
const s = moduleApi.instanceSettings;
|
||||||
|
addMoreItem('Timestamps', { on: Boolean(s.showTimes), onClick: () => timeBtn.click() });
|
||||||
|
addMoreItem('Newest first', { on: newestFirst(), onClick: () => flowBtn.click() });
|
||||||
|
const divider = document.createElement('div');
|
||||||
|
divider.className = 'pt-menu-divider';
|
||||||
|
moreMenuEl.appendChild(divider);
|
||||||
|
addMoreItem('Smaller text', { onClick: () => bumpTextSize(-2) });
|
||||||
|
addMoreItem('Larger text', { onClick: () => bumpTextSize(2) });
|
||||||
|
addMoreItem('Latest', { onClick: () => { moreMenu.close(); latestBtn.click(); } });
|
||||||
|
addMoreItem('Clear', { onClick: () => { moreMenu.close(); clearView(); } });
|
||||||
|
}
|
||||||
|
|
||||||
|
let compact = null; // unknown until the first measurement
|
||||||
|
function applyCompact(width) {
|
||||||
|
const next = width > 0 && width < COMPACT_PX;
|
||||||
|
if (next === compact) return;
|
||||||
|
compact = next;
|
||||||
|
for (const btn of secondaryBtns) btn.hidden = compact;
|
||||||
|
moreWrap.hidden = !compact;
|
||||||
|
if (!compact) moreMenu.close();
|
||||||
|
}
|
||||||
|
// the tile's own body tells the width; onResize only knows grid cells
|
||||||
|
const sizeWatch = new ResizeObserver((entries) => {
|
||||||
|
applyCompact(entries[0]?.contentRect.width || root.clientWidth);
|
||||||
|
});
|
||||||
|
sizeWatch.observe(root);
|
||||||
|
applyCompact(root.clientWidth);
|
||||||
|
|
||||||
function applyInstanceSettings() {
|
function applyInstanceSettings() {
|
||||||
const s = moduleApi.instanceSettings;
|
const s = moduleApi.instanceSettings;
|
||||||
const size = Math.min(48, Math.max(10, Number(s.textSize) || 15));
|
const size = Math.min(48, Math.max(10, Number(s.textSize) || 15));
|
||||||
@@ -126,6 +202,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
flowBtn.classList.toggle('active', up);
|
flowBtn.classList.toggle('active', up);
|
||||||
flowBtn.title = up ? 'Newest first' : 'Oldest first';
|
flowBtn.title = up ? 'Newest first' : 'Oldest first';
|
||||||
jumpBtn.textContent = (up ? '↑' : '↓') + ' New messages';
|
jumpBtn.textContent = (up ? '↑' : '↓') + ' New messages';
|
||||||
|
rebuildMoreMenu(); // its checkmarks mirror the buttons
|
||||||
}
|
}
|
||||||
applyInstanceSettings();
|
applyInstanceSettings();
|
||||||
|
|
||||||
@@ -415,6 +492,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
},
|
},
|
||||||
stop() {
|
stop() {
|
||||||
stopped = true;
|
stopped = true;
|
||||||
|
sizeWatch.disconnect();
|
||||||
stream?.close();
|
stream?.close();
|
||||||
root.innerHTML = ''; // header controls are removed by the shell
|
root.innerHTML = ''; // header controls are removed by the shell
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "prodcom-transcript",
|
"id": "prodcom-transcript",
|
||||||
"name": "ProdCom",
|
"name": "ProdCom",
|
||||||
"version": "1.3.2",
|
"version": "1.3.3",
|
||||||
"proddash": ">=1.1.0",
|
"proddash": ">=1.1.0",
|
||||||
"description": "Live ProdCom transcript",
|
"description": "Live ProdCom transcript",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
|
|||||||
@@ -20,9 +20,13 @@
|
|||||||
}
|
}
|
||||||
[data-module="prodcom-transcript"] .pt-count[hidden] { display: none; }
|
[data-module="prodcom-transcript"] .pt-count[hidden] { display: none; }
|
||||||
|
|
||||||
|
/* narrow tile: the secondary controls fold into the ⋯ menu (client.js) */
|
||||||
|
[data-module="prodcom-transcript"] .tile-btn[hidden],
|
||||||
|
[data-module="prodcom-transcript"] .tile-dd[hidden] { display: none; }
|
||||||
|
|
||||||
/* ── channels menu ──
|
/* ── channels menu ──
|
||||||
The menu container is created and positioned by the shell (.tile-menu);
|
The menu container is created and positioned by the shell (.tile-menu);
|
||||||
it lives inside the tile, so [data-module] scoping still applies. */
|
it hangs off the body under a [data-module] host, so scoping still applies. */
|
||||||
|
|
||||||
[data-module="prodcom-transcript"] .pt-channel-item {
|
[data-module="prodcom-transcript"] .pt-channel-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proddash",
|
"name": "proddash",
|
||||||
"version": "1.9.2",
|
"version": "1.9.4",
|
||||||
"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",
|
||||||
|
|||||||
+41
-10
@@ -639,8 +639,10 @@ function buildTile(tile) {
|
|||||||
el.addEventListener('pointermove', (ev) => {
|
el.addEventListener('pointermove', (ev) => {
|
||||||
if (!headIsOff(tile)) return;
|
if (!headIsOff(tile)) return;
|
||||||
// Mid-drag the tile moves under the pointer, and mid-resize the edges do
|
// Mid-drag the tile moves under the pointer, and mid-resize the edges do
|
||||||
// — neither is a moment to take the bar away.
|
// — neither is a moment to take the bar away. Nor is an open header
|
||||||
|
// menu, which hangs below the bar and is about to be pointed at.
|
||||||
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
|
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
|
||||||
|
if (el.classList.contains('menu-open')) return;
|
||||||
const y = ev.clientY - el.getBoundingClientRect().top;
|
const y = ev.clientY - el.getBoundingClientRect().top;
|
||||||
const shown = el.classList.contains('head-peek');
|
const shown = el.classList.contains('head-peek');
|
||||||
// It takes less to hold the bar open than to open it, so a bar that
|
// It takes less to hold the bar open than to open it, so a bar that
|
||||||
@@ -651,6 +653,7 @@ function buildTile(tile) {
|
|||||||
|
|
||||||
el.addEventListener('pointerleave', () => {
|
el.addEventListener('pointerleave', () => {
|
||||||
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
|
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
|
||||||
|
if (el.classList.contains('menu-open')) return; // the pointer is on its way to the menu
|
||||||
el.classList.remove('head-peek');
|
el.classList.remove('head-peek');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -658,7 +661,7 @@ function buildTile(tile) {
|
|||||||
applyHeadState(tile, el);
|
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, openMenus: new Set() };
|
||||||
tileEls.set(tile.id, entry);
|
tileEls.set(tile.id, entry);
|
||||||
applyRect(tile);
|
applyRect(tile);
|
||||||
|
|
||||||
@@ -1416,34 +1419,60 @@ function buildModuleApi(tile, man, entry) {
|
|||||||
menu.className = 'tile-menu';
|
menu.className = 'tile-menu';
|
||||||
menu.hidden = true;
|
menu.hidden = true;
|
||||||
|
|
||||||
|
/* The menu lives on the body, not in the bar. A folded-away bar
|
||||||
|
(auto-hide, fullscreen) carries a backdrop-filter and a transform,
|
||||||
|
and either one makes a fixed-positioned descendant position
|
||||||
|
itself against the bar instead of the viewport — the menu landed
|
||||||
|
a screen-height below its button, inside the tile's overflow, and
|
||||||
|
never showed. Its host carries the tile's data-module so the
|
||||||
|
module's scoped CSS still reaches the items. */
|
||||||
|
const host = document.createElement('div');
|
||||||
|
host.className = 'tile-menu-host';
|
||||||
|
if (man) host.dataset.module = man.id;
|
||||||
|
host.appendChild(menu);
|
||||||
|
|
||||||
|
const setOpen = (on) => {
|
||||||
|
if (on === !menu.hidden) return;
|
||||||
|
menu.hidden = !on;
|
||||||
|
btn.classList.toggle('is-open', on);
|
||||||
|
if (on) entry.openMenus.add(menu);
|
||||||
|
else entry.openMenus.delete(menu);
|
||||||
|
// an open menu holds a folded-away bar out; when the last one
|
||||||
|
// closes with the pointer elsewhere, the bar goes back
|
||||||
|
entry.el.classList.toggle('menu-open', entry.openMenus.size > 0);
|
||||||
|
if (!on && !entry.openMenus.size && !entry.el.matches(':hover')) entry.el.classList.remove('head-peek');
|
||||||
|
};
|
||||||
const open = () => {
|
const open = () => {
|
||||||
if (typeof build === 'function') {
|
if (typeof build === 'function') {
|
||||||
menu.innerHTML = '';
|
menu.innerHTML = '';
|
||||||
build(menu);
|
build(menu);
|
||||||
}
|
}
|
||||||
menu.hidden = false;
|
setOpen(true);
|
||||||
// fixed positioning (escapes the tile's overflow): under the
|
// under the button, right-aligned, kept whole inside the viewport
|
||||||
// button, right-aligned, clamped to the viewport
|
|
||||||
const r = btn.getBoundingClientRect();
|
const r = btn.getBoundingClientRect();
|
||||||
menu.style.top = Math.min(r.bottom + 6, window.innerHeight - 80) + 'px';
|
|
||||||
menu.style.left = '0px';
|
menu.style.left = '0px';
|
||||||
|
menu.style.top = '0px';
|
||||||
const w = menu.offsetWidth;
|
const w = menu.offsetWidth;
|
||||||
|
const h = menu.offsetHeight;
|
||||||
|
menu.style.top = Math.max(8, Math.min(r.bottom + 6, window.innerHeight - h - 8)) + 'px';
|
||||||
menu.style.left = Math.max(8, Math.min(r.right - w, window.innerWidth - w - 8)) + 'px';
|
menu.style.left = Math.max(8, Math.min(r.right - w, window.innerWidth - w - 8)) + 'px';
|
||||||
};
|
};
|
||||||
btn.addEventListener('click', (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (menu.hidden) open();
|
if (menu.hidden) open();
|
||||||
else menu.hidden = true;
|
else setOpen(false);
|
||||||
});
|
});
|
||||||
const onDocClick = (e) => {
|
const onDocClick = (e) => {
|
||||||
if (!menu.hidden && !menu.contains(e.target) && !btn.contains(e.target)) menu.hidden = true;
|
if (!menu.hidden && !menu.contains(e.target) && !btn.contains(e.target)) setOpen(false);
|
||||||
};
|
};
|
||||||
document.addEventListener('click', onDocClick);
|
document.addEventListener('click', onDocClick);
|
||||||
docListeners.add(onDocClick);
|
docListeners.add(onDocClick);
|
||||||
|
|
||||||
wrap.append(btn, menu);
|
wrap.append(btn);
|
||||||
entry.controls.appendChild(wrap);
|
entry.controls.appendChild(wrap);
|
||||||
|
document.body.appendChild(host);
|
||||||
headerEls.add(wrap);
|
headerEls.add(wrap);
|
||||||
|
headerEls.add(host);
|
||||||
return {
|
return {
|
||||||
button: btn,
|
button: btn,
|
||||||
menu,
|
menu,
|
||||||
@@ -1451,7 +1480,7 @@ function buildModuleApi(tile, man, entry) {
|
|||||||
btn.textContent = text;
|
btn.textContent = text;
|
||||||
},
|
},
|
||||||
close() {
|
close() {
|
||||||
menu.hidden = true;
|
setOpen(false);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -1463,6 +1492,8 @@ function buildModuleApi(tile, man, entry) {
|
|||||||
for (const handle of [...sseHandles]) handle.close();
|
for (const handle of [...sseHandles]) handle.close();
|
||||||
for (const el of headerEls) el.remove();
|
for (const el of headerEls) el.remove();
|
||||||
headerEls.clear();
|
headerEls.clear();
|
||||||
|
entry.openMenus.clear();
|
||||||
|
entry.el.classList.remove('menu-open');
|
||||||
for (const fn of docListeners) document.removeEventListener('click', fn);
|
for (const fn of docListeners) document.removeEventListener('click', fn);
|
||||||
docListeners.clear();
|
docListeners.clear();
|
||||||
},
|
},
|
||||||
|
|||||||
+3
-2
@@ -445,8 +445,9 @@ body.menu-hidden #topbar {
|
|||||||
}
|
}
|
||||||
.tile-dd { position: relative; display: inline-flex; }
|
.tile-dd { position: relative; display: inline-flex; }
|
||||||
|
|
||||||
/* a module header menu — fixed-positioned by the shell so the tile's
|
/* a module header menu — hosted on the body (shell.js addMenu) so neither
|
||||||
overflow:hidden can't clip it */
|
the tile's overflow nor a folded-away bar's backdrop-filter can touch it */
|
||||||
|
.tile-menu-host { display: contents; }
|
||||||
.tile-menu {
|
.tile-menu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 70;
|
z-index: 70;
|
||||||
|
|||||||
Reference in New Issue
Block a user