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;
+}