diff --git a/README.md b/README.md index 8eb3267..c469264 100644 --- a/README.md +++ b/README.md @@ -60,29 +60,33 @@ are written by the app itself. ## Using the dashboard -- **+ Add tile** — add any enabled module; multiple tiles of the same - module are fine (e.g. two transcripts filtered to different channels). -- Drag a tile by its **header** to move it; drag the **corner handle** to - resize; **✕** removes it. Tiles sit flush — when two share an edge, hover - the seam and a small grab pill appears; drag it to trade width between - the two tiles (macOS split-view style). +- **+** — add any enabled module; multiple tiles of the same module are + fine (e.g. two transcripts filtered to different channels). +- Drag a tile by its **title bar** to move it; drag the **corner handle** + to resize; **✕** removes it. Tiles sit flush — when two share an edge, + hover the seam and a small grab pill appears; drag it to trade width + between the two tiles (macOS split-view style). +- A tile's actions (menus, text size, clear…) live in its **title bar**; + the small **notch** on the bar's bottom edge hides/shows the whole bar + for a clean wall-display look. - The **gear** on a tile holds per-tile settings (filters, text size, display options) — these belong to this browser's layout, not the server. -- **Layout ▾** — save the current arrangement to the server under a name, - load a named layout (it becomes a local copy — browsers are never linked), - or reset this browser's dashboard. +- The **grid icon** — save the current arrangement to the server under a + name, load a named layout (it becomes a local copy — browsers are never + linked), or reset this browser's dashboard. - Every tile has a **status dot**: green = live, amber = connecting, red = upstream unreachable (hover it for details). Tiles reconnect automatically. ## Included modules -- **ProdCom Transcript** — live transcript stream with in-place updates of - in-progress speech, channel colors, per-tile channel visibility toggles - and group presets. Admin config: ProdCom URL, API key. -- **ProPresenter Now / Next** — what's live and what's next, with slide - progress, driven by the shared `propresenter-core` client (ProPresenter - 7/20). Admin config: host, port, password. +- **ProdCom** — live transcript stream with in-place updates of in-progress + speech, channel colors, per-tile channel visibility toggles and group + presets, timestamps, text size and jump-to-latest in the title bar. + Admin config: ProdCom URL, API key. +- **ProPresenter** — what's live and what's next, with slide progress, + driven by the shared `propresenter-core` client (ProPresenter 7/20). + Admin config: host, port, password. - **Clock** — a big booth clock; also the smallest possible module and the reference for module authors. diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md index 1dc59a4..55a4491 100644 --- a/docs/MODULE-GUIDE.md +++ b/docs/MODULE-GUIDE.md @@ -113,6 +113,43 @@ export default function create({ root, moduleApi }) { | `fetch(path, opts)` | `fetch` scoped to your server routes: `fetch('/state')` hits `/api/modules//state`. | | `sse(path, handlers)` | An `EventSource` scoped the same way, **with auto-reconnect** (see below). Returns a handle with `close()`. | | `setStatus(state, msg)` | Drives the tile's status dot: `'ok'`, `'connecting'` or `'error'`, plus a hover message. Call it honestly — during a live service the dot is how an operator spots a dead feed. | +| `header.addButton(…)` | Put a small button in the tile's own title bar (see below). | +| `header.addMenu(…)` | Put a dropdown menu button there (see below). | + +### Title-bar controls + +Modules don't build their own toolbars — actions live in the tile's title +bar, next to the shell's gear/close buttons: + +```js +const btn = moduleApi.header.addButton({ + icon: '…', // inline SVG string, and/or + label: 'A+', // short text + title: 'Larger text', // hover tooltip + onClick() { … }, +}); +btn.classList.add('active'); // toggle styling for on/off buttons + +const menu = moduleApi.header.addMenu({ + label: 'Channels ▾', + title: 'Choose visible channels', + build(menuEl) { + // called on every open with an emptied menu element — fill it with + // 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) + }, +}); +menu.setLabel('Channels (2/4) ▾'); // update the button text any time +``` + +Everything you add is removed automatically when your instance stops, so a +remount never duplicates controls. Keep them compact (icon buttons, short +labels): the title bar is 32 px tall and shared with the tile's name. + +One more thing to know: the user can hide the whole title bar with the +small notch on its bottom edge (a clean-view mode for wall displays). Your +controls disappear with it, so nothing essential — status, reconnects, +live data — may exist *only* as a header control. `sse(path, handlers)` handlers: diff --git a/modules/prodcom-transcript/client.js b/modules/prodcom-transcript/client.js index 8d85ca2..f81590d 100644 --- a/modules/prodcom-transcript/client.js +++ b/modules/prodcom-transcript/client.js @@ -22,14 +22,6 @@ export default function create({ root, moduleApi }) { root.innerHTML = `
-
-
- - -
- - -
Waiting for transcript…
@@ -42,14 +34,66 @@ export default function create({ root, moduleApi }) { const entriesEl = root.querySelector('.pt-entries'); const emptyEl = root.querySelector('.pt-empty'); const jumpBtn = root.querySelector('.pt-jump'); - const channelBtn = root.querySelector('.pt-channels-btn'); - const channelMenu = root.querySelector('.pt-menu'); + + /* ── title-bar controls ─────────────────────────────────────────── */ + + const CLOCK_SVG = ''; + const JUMP_SVG = ''; + const CLEAR_SVG = ''; + + let channelMenuEl = null; // the open menu's element (filled on each open) + const channelsMenu = moduleApi.header.addMenu({ + label: 'Channels ▾', + title: 'Choose visible channels', + build(menu) { + menu.classList.add('pt-menu'); + channelMenuEl = menu; + rebuildChannelMenu(); + }, + }); + channelsMenu.button.classList.add('pt-channels-btn'); + + let timeBtn = null; + timeBtn = moduleApi.header.addButton({ + icon: CLOCK_SVG, + title: 'Show / hide timestamps', + onClick() { + moduleApi.saveInstanceSettings({ showTimes: !moduleApi.instanceSettings.showTimes }); + applyInstanceSettings(); + }, + }); + + function bumpTextSize(delta) { + const current = Number(moduleApi.instanceSettings.textSize) || 15; + moduleApi.saveInstanceSettings({ textSize: Math.min(48, Math.max(10, current + delta)) }); + applyInstanceSettings(); + } + moduleApi.header.addButton({ label: 'A−', title: 'Smaller text', onClick: () => bumpTextSize(-2) }); + moduleApi.header.addButton({ label: 'A+', title: 'Larger text', onClick: () => bumpTextSize(2) }); + + moduleApi.header.addButton({ + icon: JUMP_SVG, + title: 'Jump to latest', + onClick() { + pinnedToBottom = true; + jumpBtn.hidden = true; + scrollToBottom(); + }, + }); + + const clearBtn = moduleApi.header.addButton({ + icon: CLEAR_SVG, + title: 'Clear this tile (does not affect ProdCom)', + onClick: clearView, + }); + clearBtn.classList.add('pt-clear'); function applyInstanceSettings() { const s = moduleApi.instanceSettings; const size = Math.min(48, Math.max(10, Number(s.textSize) || 15)); wrap.style.setProperty('--pt-size', size + 'px'); wrap.classList.toggle('show-times', Boolean(s.showTimes)); + timeBtn?.classList.toggle('active', Boolean(s.showTimes)); } applyInstanceSettings(); @@ -106,7 +150,20 @@ export default function create({ root, moduleApi }) { ); } + function updateChannelsLabel() { + let visibleCount = 0; + for (const id of channels.keys()) { + if (!hiddenChannels.has(id)) visibleCount++; + } + channelsMenu.setLabel(visibleCount === channels.size + ? 'Channels ▾' + : `Channels (${visibleCount}/${channels.size}) ▾`); + } + function rebuildChannelMenu() { + updateChannelsLabel(); + const channelMenu = channelMenuEl; + if (!channelMenu) return; // menu not opened yet — the label is enough channelMenu.innerHTML = ''; if (groups.size) { @@ -134,10 +191,8 @@ export default function create({ root, moduleApi }) { channelMenu.appendChild(divider); } - let visibleCount = 0; for (const [id, info] of channels) { const on = !hiddenChannels.has(id); - if (on) visibleCount++; const item = document.createElement('button'); item.className = 'pt-channel-item' + (on ? ' on' : ''); item.setAttribute('role', 'menuitemcheckbox'); @@ -158,27 +213,15 @@ export default function create({ root, moduleApi }) { }); channelMenu.appendChild(item); } - channelBtn.textContent = visibleCount === channels.size - ? 'Channels ▾' - : `Channels (${visibleCount}/${channels.size}) ▾`; } - channelBtn.addEventListener('click', (e) => { - e.stopPropagation(); - channelMenu.hidden = !channelMenu.hidden; - }); - const onDocClick = (e) => { - if (!channelMenu.hidden && !e.target.closest('.pt-dd')) channelMenu.hidden = true; - }; - document.addEventListener('click', onDocClick); - /* ── clear (local view only) ────────────────────────────────────── */ - root.querySelector('.pt-clear').addEventListener('click', () => { + function clearView() { entryEls.forEach((el) => el.remove()); entryEls.clear(); emptyEl.hidden = false; - }); + } /* ── scrolling ──────────────────────────────────────────────────── */ @@ -393,8 +436,7 @@ export default function create({ root, moduleApi }) { stopped = true; clearTimeout(bootTimer); stream?.close(); - document.removeEventListener('click', onDocClick); - root.innerHTML = ''; + root.innerHTML = ''; // header controls are removed by the shell }, onResize() { if (pinnedToBottom) scrollToBottom(); diff --git a/modules/prodcom-transcript/module.json b/modules/prodcom-transcript/module.json index 8e05663..42d808f 100644 --- a/modules/prodcom-transcript/module.json +++ b/modules/prodcom-transcript/module.json @@ -1,6 +1,6 @@ { "id": "prodcom-transcript", - "name": "ProdCom Transcript", + "name": "ProdCom", "version": "1.0.0", "description": "Live ProdCom transcript stream", "client": "client.js", diff --git a/modules/prodcom-transcript/style.css b/modules/prodcom-transcript/style.css index 93dfd9e..4b84fad 100644 --- a/modules/prodcom-transcript/style.css +++ b/modules/prodcom-transcript/style.css @@ -9,49 +9,9 @@ flex-direction: column; } -/* ── toolbar ── */ - -[data-module="prodcom-transcript"] .pt-bar { - flex: none; - display: flex; - align-items: center; - gap: 6px; - padding: 5px 8px; - border-bottom: 1px solid var(--border); -} - -[data-module="prodcom-transcript"] .pt-spacer { flex: 1; } - -[data-module="prodcom-transcript"] .pt-btn { - background: var(--bg); - color: var(--text); - border: 1px solid var(--border); - border-radius: 6px; - padding: 3px 8px; - font-size: 11px; - font-family: inherit; - cursor: pointer; - white-space: nowrap; -} -[data-module="prodcom-transcript"] .pt-btn:hover { border-color: var(--muted); } - -[data-module="prodcom-transcript"] .pt-dd { position: relative; } - -[data-module="prodcom-transcript"] .pt-menu { - position: absolute; - top: calc(100% + 5px); - left: 0; - z-index: 30; - min-width: 170px; - max-height: 45vh; - overflow-y: auto; - background: var(--panel); - border: 1px solid var(--border); - border-radius: 10px; - padding: 5px; - box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55); -} -[data-module="prodcom-transcript"] .pt-menu[hidden] { display: none; } +/* ── channels menu ── + The menu container is created and positioned by the shell (.tile-menu); + it lives inside the tile, so [data-module] scoping still applies. */ [data-module="prodcom-transcript"] .pt-channel-item { display: flex; diff --git a/modules/propresenter-now-next/module.json b/modules/propresenter-now-next/module.json index eb3feae..24d0cc5 100644 --- a/modules/propresenter-now-next/module.json +++ b/modules/propresenter-now-next/module.json @@ -1,6 +1,6 @@ { "id": "propresenter-now-next", - "name": "ProPresenter Now / Next", + "name": "ProPresenter", "version": "1.0.0", "description": "What is live in ProPresenter and what comes next", "client": "client.js", diff --git a/public/index.html b/public/index.html index 83b26d4..71abe71 100644 --- a/public/index.html +++ b/public/index.html @@ -20,12 +20,18 @@ diff --git a/public/shell.js b/public/shell.js index 4f759b5..7e44ef4 100644 --- a/public/shell.js +++ b/public/shell.js @@ -150,6 +150,7 @@ function sanitizeTile(t) { w, h: Math.max(1, Number(t.h) || 2), settings: (t.settings && typeof t.settings === 'object') ? t.settings : {}, + headHidden: Boolean(t.headHidden), }; } @@ -229,7 +230,11 @@ function buildTile(tile) { title.className = 'tile-title'; title.textContent = man ? man.name : tile.module + ' (not installed)'; - head.append(dot, title); + // where moduleApi.header puts a module's own buttons and menus + const controls = document.createElement('span'); + controls.className = 'tile-controls'; + + head.append(dot, title, controls); const hasSettings = man && man.instanceSchema && Object.keys(man.instanceSchema).length; if (hasSettings) { @@ -261,10 +266,22 @@ function buildTile(tile) { resize.className = 'tile-resize'; resize.title = 'Drag to resize'; - el.append(head, body, resize); + // notch on the title bar's bottom edge: hides/shows the bar + const notch = document.createElement('button'); + notch.className = 'tile-notch'; + notch.title = 'Hide / show the title bar'; + notch.addEventListener('click', (e) => { + e.stopPropagation(); + tile.headHidden = !tile.headHidden; + el.classList.toggle('head-hidden', tile.headHidden); + saveLayout(); + }); + + el.append(head, body, resize, notch); + el.classList.toggle('head-hidden', Boolean(tile.headHidden)); grid.appendChild(el); - const entry = { el, body, dot, titleEl: title, instance: null }; + const entry = { el, body, dot, titleEl: title, controls, instance: null }; tileEls.set(tile.id, entry); applyRect(tile); @@ -283,7 +300,7 @@ function refreshEmptyState() { function wireDrag(tile, el, handle) { handle.addEventListener('pointerdown', (ev) => { - if (ev.target.closest('.tile-btn')) return; + if (ev.target.closest('.tile-btn') || ev.target.closest('.tile-menu') || ev.target.closest('.tile-notch')) return; if (ev.button !== 0 && ev.pointerType === 'mouse') return; ev.preventDefault(); closeSettingsPopovers(); @@ -555,6 +572,8 @@ function tileMessage(entry, text) { */ function buildModuleApi(tile, man, entry) { const sseHandles = new Set(); + const headerEls = new Set(); // controls this instance added to the title bar + const docListeners = new Set(); // outside-click closers for header menus const api = { id: man.id, @@ -636,9 +655,96 @@ function buildModuleApi(tile, man, entry) { entry.dot.title = msg; }, - /* shell-internal: safety net so a forgotten stream can't outlive the tile */ + /** + * The tile's title bar. Controls added here live next to the gear/close + * buttons and are removed automatically when the instance stops. The + * whole bar can be hidden by the user via the notch, so nothing + * essential should exist ONLY as a header control. + */ + header: { + /** A small button. icon = inline SVG string, label = short text (either or both). */ + addButton({ icon = '', label = '', title = '', onClick } = {}) { + const btn = document.createElement('button'); + btn.className = 'tile-btn module-btn'; + btn.title = title || label; + if (icon) btn.innerHTML = icon; + if (label) btn.appendChild(document.createTextNode(label)); + if (typeof onClick === 'function') { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + onClick(e); + }); + } + entry.controls.appendChild(btn); + headerEls.add(btn); + return btn; + }, + + /** + * A dropdown menu button. build(menuEl) runs on every open and fills + * the (emptied) menu element. Returns { button, menu, setLabel, close }. + */ + addMenu({ icon = '', label = '', title = '', build } = {}) { + const wrap = document.createElement('span'); + wrap.className = 'tile-dd'; + const btn = document.createElement('button'); + btn.className = 'tile-btn module-btn'; + btn.title = title || label; + if (icon) btn.innerHTML = icon; + if (label) btn.appendChild(document.createTextNode(label)); + const menu = document.createElement('div'); + menu.className = 'tile-menu'; + menu.hidden = true; + + const open = () => { + if (typeof build === 'function') { + menu.innerHTML = ''; + build(menu); + } + menu.hidden = false; + // fixed positioning (escapes the tile's overflow): under the + // button, right-aligned, clamped to the viewport + const r = btn.getBoundingClientRect(); + menu.style.top = Math.min(r.bottom + 6, window.innerHeight - 80) + 'px'; + menu.style.left = '0px'; + const w = menu.offsetWidth; + menu.style.left = Math.max(8, Math.min(r.right - w, window.innerWidth - w - 8)) + 'px'; + }; + btn.addEventListener('click', (e) => { + e.stopPropagation(); + if (menu.hidden) open(); + else menu.hidden = true; + }); + const onDocClick = (e) => { + if (!menu.hidden && !menu.contains(e.target) && !btn.contains(e.target)) menu.hidden = true; + }; + document.addEventListener('click', onDocClick); + docListeners.add(onDocClick); + + wrap.append(btn, menu); + entry.controls.appendChild(wrap); + headerEls.add(wrap); + return { + button: btn, + menu, + setLabel(text) { + btn.textContent = text; + }, + close() { + menu.hidden = true; + }, + }; + }, + }, + + /* shell-internal: safety net so a forgotten stream, header control or + listener can't outlive the tile */ _closeAll() { for (const handle of [...sseHandles]) handle.close(); + for (const el of headerEls) el.remove(); + headerEls.clear(); + for (const fn of docListeners) document.removeEventListener('click', fn); + docListeners.clear(); }, }; return api; diff --git a/public/style.css b/public/style.css index 9500743..0cca1cf 100644 --- a/public/style.css +++ b/public/style.css @@ -71,6 +71,14 @@ button { font-family: inherit; } } .tb-btn:hover { border-color: var(--muted); } .tb-btn:active { background: var(--border); } +.tb-btn.icon { + padding: 6px 8px; + min-width: 30px; + justify-content: center; + font-size: 14px; + line-height: 1; +} +.tb-btn.icon svg { display: block; } /* ── dropdown menus ────────────────────────────────────────────────── */ @@ -191,7 +199,8 @@ button { font-family: inherit; } display: flex; align-items: center; gap: 8px; - padding: 5px 8px; + height: 32px; /* fixed so the notch below can sit at a known offset */ + padding: 0 8px; background: rgba(255, 255, 255, 0.03); border-bottom: 1px solid var(--border); cursor: grab; @@ -200,6 +209,7 @@ button { font-family: inherit; } touch-action: none; } .tile.dragging .tile-head { cursor: grabbing; } +.tile.head-hidden .tile-head { display: none; } .tile-head .dot { flex: none; @@ -241,8 +251,76 @@ button { font-family: inherit; } } .tile-btn:hover { background: var(--border); color: var(--text); } .tile-btn.close:hover { color: var(--danger); } +.tile-btn.active { color: var(--accent); } .tile-btn svg { width: 13px; height: 13px; display: block; } +/* controls a module adds to its own title bar (moduleApi.header) */ +.tile-controls { + display: flex; + align-items: center; + gap: 2px; + min-width: 0; + overflow: hidden; +} +.tile-btn.module-btn { + width: auto; + min-width: 22px; + padding: 0 5px; + font-size: 11px; + font-weight: 600; + white-space: nowrap; +} +.tile-dd { position: relative; display: inline-flex; } + +/* a module header menu — fixed-positioned by the shell so the tile's + overflow:hidden can't clip it */ +.tile-menu { + position: fixed; + z-index: 70; + min-width: 180px; + max-height: 60vh; + overflow-y: auto; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + padding: 5px; + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55); +} +.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.15s 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); } + .tile-body { flex: 1; position: relative; @@ -331,7 +409,7 @@ button { font-family: inherit; } .tile-settings { position: absolute; - top: 30px; + top: 36px; right: 6px; z-index: 45; min-width: 220px;