diff --git a/README.md b/README.md index 5458a01..279da41 100644 --- a/README.md +++ b/README.md @@ -168,8 +168,10 @@ below. Nothing is installed without someone clicking. - **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. + presets, timestamps, text size, a top-down / bottom-up direction toggle + and jump-to-latest in the title bar. Admin config: ProdCom server (host : port), API key. + Tile settings: channel icons, direction and timestamps. - **ProPresenter** — what's live and what's next, with slide progress, driven by the shared `propresenter-core` client (ProPresenter 7/20). Admin config: ProPresenter endpoint (host : port), password. diff --git a/modules/prodcom-transcript/client.js b/modules/prodcom-transcript/client.js index 255f97f..76bff3b 100644 --- a/modules/prodcom-transcript/client.js +++ b/modules/prodcom-transcript/client.js @@ -2,9 +2,9 @@ Ported from prodcom-listener/public/app.js into the ProdDash module contract: everything lives inside the tile's root, per-tile state - (hidden channels, timestamps, text size) persists in instance settings, - and all network traffic goes through moduleApi (the module's server - proxy at /prodcom/*). */ + (hidden channels, channel icons, timestamps, text size, flow direction) + persists in instance settings, and all network traffic goes through + moduleApi (the module's server proxy at /prodcom/*). */ export default function create({ root, moduleApi }) { /* ── per-instance state ─────────────────────────────────────────── */ @@ -12,7 +12,7 @@ export default function create({ root, moduleApi }) { const channels = new Map(); // channelId -> {name, color} const groups = new Map(); // groupId -> {name, channelIds} const entryEls = new Map(); // entryId -> element - let pinnedToBottom = true; + let pinnedToLatest = true; let hiddenChannels = new Set(moduleApi.instanceSettings.hiddenChannels || []); let stream = null; // moduleApi.sse handle let bootTimer = null; @@ -40,10 +40,14 @@ export default function create({ root, moduleApi }) { const CLOCK_SVG = ''; const JUMP_SVG = ''; const CLEAR_SVG = ''; + const CHANNELS_SVG = ''; + // arrow shows where new messages land: down = oldest first, up = newest first + const FLOW_DOWN_SVG = ''; + const FLOW_UP_SVG = ''; let channelMenuEl = null; // the open menu's element (filled on each open) const channelsMenu = moduleApi.header.addMenu({ - label: 'Channels ▾', + icon: CHANNELS_SVG, title: 'Choose visible channels', build(menu) { menu.classList.add('pt-menu'); @@ -52,6 +56,11 @@ export default function create({ root, moduleApi }) { }, }); channelsMenu.button.classList.add('pt-channels-btn'); + // the icon carries the label; a count rides alongside it while filtering + const channelCountEl = document.createElement('span'); + channelCountEl.className = 'pt-count'; + channelCountEl.hidden = true; + channelsMenu.button.appendChild(channelCountEl); let timeBtn = null; timeBtn = moduleApi.header.addButton({ @@ -71,13 +80,24 @@ export default function create({ root, moduleApi }) { moduleApi.header.addButton({ label: 'A−', title: 'Smaller text', onClick: () => bumpTextSize(-2) }); moduleApi.header.addButton({ label: 'A+', title: 'Larger text', onClick: () => bumpTextSize(2) }); + // icon and tooltip track the current direction — applyInstanceSettings sets both + const flowBtn = moduleApi.header.addButton({ + icon: FLOW_DOWN_SVG, + onClick() { + moduleApi.saveInstanceSettings({ newestFirst: !moduleApi.instanceSettings.newestFirst }); + applyInstanceSettings(); + // the two directions have opposite "latest" ends — follow the flip + if (pinnedToLatest) scrollToLatest(); + }, + }); + moduleApi.header.addButton({ icon: JUMP_SVG, title: 'Jump to latest', onClick() { - pinnedToBottom = true; + pinnedToLatest = true; jumpBtn.hidden = true; - scrollToBottom(); + scrollToLatest(); }, }); @@ -93,25 +113,20 @@ export default function create({ root, moduleApi }) { 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)); + wrap.classList.toggle('hide-avatars', s.showAvatars === false); timeBtn?.classList.toggle('active', Boolean(s.showTimes)); + + const up = newestFirst(); + wrap.classList.toggle('newest-first', up); + flowBtn.innerHTML = up ? FLOW_UP_SVG : FLOW_DOWN_SVG; + flowBtn.classList.toggle('active', up); + flowBtn.title = up + ? 'Newest at top — click for newest at bottom' + : 'Newest at bottom — click for newest at top'; + jumpBtn.textContent = (up ? '↑' : '↓') + ' New messages'; } applyInstanceSettings(); - /* ── channel filter (instance setting) ──────────────────────────── */ - - function channelFilter() { - return String(moduleApi.instanceSettings.channel || '').trim().toLowerCase(); - } - - /** Entry hidden by the hard per-tile filter (settings.channel)? */ - function filteredOut(channelId, channelName) { - const want = channelFilter(); - if (!want) return false; - const known = channels.get(channelId); - const name = ((known && known.name) || channelName || '').toLowerCase(); - return name !== want && String(channelId).toLowerCase() !== want; - } - /* ── channel visibility toggles ─────────────────────────────────── */ function persistHidden() { @@ -119,9 +134,7 @@ export default function create({ root, moduleApi }) { } function applyFilter(el) { - const hidden = hiddenChannels.has(el.dataset.channelId) - || filteredOut(el.dataset.channelId, el.dataset.channelName); - el.classList.toggle('hidden-by-filter', hidden); + el.classList.toggle('hidden-by-filter', hiddenChannels.has(el.dataset.channelId)); } function toggleChannel(id) { @@ -130,7 +143,7 @@ export default function create({ root, moduleApi }) { persistHidden(); rebuildChannelMenu(); entryEls.forEach((el) => applyFilter(el)); - if (pinnedToBottom) scrollToBottom(); + if (pinnedToLatest) scrollToLatest(); } /** Show exactly this group's channels, hide the rest */ @@ -139,7 +152,7 @@ export default function create({ root, moduleApi }) { persistHidden(); rebuildChannelMenu(); entryEls.forEach((el) => applyFilter(el)); - if (pinnedToBottom) scrollToBottom(); + if (pinnedToLatest) scrollToLatest(); } function groupIsActive(g) { @@ -150,18 +163,24 @@ export default function create({ root, moduleApi }) { ); } - function updateChannelsLabel() { + /* The button is an icon, so the "some channels are hidden" state shows as + an accent tint plus a compact count beside it. */ + function updateChannelsButton() { let visibleCount = 0; for (const id of channels.keys()) { if (!hiddenChannels.has(id)) visibleCount++; } - channelsMenu.setLabel(visibleCount === channels.size - ? 'Channels ▾' - : `Channels (${visibleCount}/${channels.size}) ▾`); + const filtering = channels.size > 0 && visibleCount !== channels.size; + channelCountEl.textContent = filtering ? `${visibleCount}/${channels.size}` : ''; + channelCountEl.hidden = !filtering; + channelsMenu.button.classList.toggle('active', filtering); + channelsMenu.button.title = filtering + ? `Channels — showing ${visibleCount} of ${channels.size}` + : 'Choose visible channels'; } function rebuildChannelMenu() { - updateChannelsLabel(); + updateChannelsButton(); const channelMenu = channelMenuEl; if (!channelMenu) return; // menu not opened yet — the label is enough channelMenu.innerHTML = ''; @@ -225,25 +244,33 @@ export default function create({ root, moduleApi }) { /* ── scrolling ──────────────────────────────────────────────────── */ + /** Newest entry at the top (bottom-up) instead of at the bottom? */ + function newestFirst() { + return Boolean(moduleApi.instanceSettings.newestFirst); + } + streamEl.addEventListener('scroll', () => { - const nearBottom = streamEl.scrollHeight - streamEl.scrollTop - streamEl.clientHeight < 60; - pinnedToBottom = nearBottom; - if (nearBottom) jumpBtn.hidden = true; + const near = newestFirst() + ? streamEl.scrollTop < 60 + : streamEl.scrollHeight - streamEl.scrollTop - streamEl.clientHeight < 60; + pinnedToLatest = near; + if (near) jumpBtn.hidden = true; }); jumpBtn.addEventListener('click', () => { - pinnedToBottom = true; + pinnedToLatest = true; jumpBtn.hidden = true; - scrollToBottom(); + scrollToLatest(); }); - function scrollToBottom() { - streamEl.scrollTop = streamEl.scrollHeight; + /** The latest entry is at the top when flipped, at the bottom otherwise. */ + function scrollToLatest() { + streamEl.scrollTop = newestFirst() ? 0 : streamEl.scrollHeight; } function afterInsert(el) { if (el.classList.contains('hidden-by-filter')) return; - if (pinnedToBottom) scrollToBottom(); + if (pinnedToLatest) scrollToLatest(); else jumpBtn.hidden = false; } @@ -272,7 +299,6 @@ export default function create({ root, moduleApi }) { el = document.createElement('div'); el.className = 'pt-entry'; el.dataset.channelId = entry.channelId || ''; - el.dataset.channelName = entry.channelName || ''; const info = channelInfo(entry); el.style.setProperty('--ch', info.color); @@ -314,7 +340,7 @@ export default function create({ root, moduleApi }) { afterInsert(el); } else { updateEntryEl(el, entry); - if (pinnedToBottom && !el.classList.contains('hidden-by-filter')) scrollToBottom(); + if (pinnedToLatest && !el.classList.contains('hidden-by-filter')) scrollToLatest(); } } @@ -430,7 +456,7 @@ export default function create({ root, moduleApi }) { bootTimer = setTimeout(boot, 5000); return; } - scrollToBottom(); + scrollToLatest(); connect(); } @@ -445,7 +471,7 @@ export default function create({ root, moduleApi }) { root.innerHTML = ''; // header controls are removed by the shell }, onResize() { - if (pinnedToBottom) scrollToBottom(); + if (pinnedToLatest) scrollToLatest(); }, }; } diff --git a/modules/prodcom-transcript/module.json b/modules/prodcom-transcript/module.json index 95772dd..390696c 100644 --- a/modules/prodcom-transcript/module.json +++ b/modules/prodcom-transcript/module.json @@ -1,7 +1,7 @@ { "id": "prodcom-transcript", "name": "ProdCom", - "version": "1.0.0", + "version": "1.2.0", "proddash": ">=1.1.0", "description": "Live ProdCom transcript stream", "client": "client.js", @@ -22,15 +22,15 @@ } }, "instanceSchema": { - "channel": { - "type": "string", - "label": "Channel filter (channel name; empty = all)", - "default": "" + "showAvatars": { + "type": "boolean", + "label": "Show channel icons", + "default": true }, - "textSize": { - "type": "number", - "label": "Text size (px)", - "default": 15 + "newestFirst": { + "type": "boolean", + "label": "Newest first (transcript reads bottom-up)", + "default": false }, "showTimes": { "type": "boolean", diff --git a/modules/prodcom-transcript/style.css b/modules/prodcom-transcript/style.css index 58e2358..0153e8d 100644 --- a/modules/prodcom-transcript/style.css +++ b/modules/prodcom-transcript/style.css @@ -9,6 +9,17 @@ flex-direction: column; } +/* ── channels button ── + Icon-only; the count appears next to it while some channels are hidden. */ + +[data-module="prodcom-transcript"] .pt-channels-btn { gap: 3px; } +[data-module="prodcom-transcript"] .pt-count { + font-size: 10px; + font-weight: 700; + font-variant-numeric: tabular-nums; +} +[data-module="prodcom-transcript"] .pt-count[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. */ @@ -113,6 +124,7 @@ background: color-mix(in srgb, var(--ch, var(--muted)) 17%, transparent); } [data-module="prodcom-transcript"] .pt-entry .avatar svg { width: 66%; height: 66%; display: block; } +[data-module="prodcom-transcript"] .hide-avatars .pt-entry .avatar { display: none; } [data-module="prodcom-transcript"] .pt-entry .body { flex: 1; min-width: 0; } @@ -143,6 +155,12 @@ [data-module="prodcom-transcript"] .pt-entry.hidden-by-filter { display: none; } +/* bottom-up: newest entry at the top, the stream reading upwards */ +[data-module="prodcom-transcript"] .newest-first .pt-entries { + display: flex; + flex-direction: column-reverse; +} + /* ── jump-to-latest ── */ [data-module="prodcom-transcript"] .pt-jump { @@ -163,3 +181,9 @@ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5); } [data-module="prodcom-transcript"] .pt-jump[hidden] { display: none; } + +/* flipped, "latest" is the top of the stream — so is the pill */ +[data-module="prodcom-transcript"] .newest-first .pt-jump { + top: 12px; + bottom: auto; +}