/* ProdCom Transcript — client part. 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/*). */ export default function create({ root, moduleApi }) { /* ── per-instance state ─────────────────────────────────────────── */ const channels = new Map(); // channelId -> {name, color} const groups = new Map(); // groupId -> {name, channelIds} const entryEls = new Map(); // entryId -> element let pinnedToBottom = true; let hiddenChannels = new Set(moduleApi.instanceSettings.hiddenChannels || []); let stream = null; // moduleApi.sse handle let bootTimer = null; let stopped = false; /* ── DOM skeleton ───────────────────────────────────────────────── */ root.innerHTML = `
Waiting for transcript…
`; const wrap = root.querySelector('.pt-wrap'); const streamEl = root.querySelector('.pt-stream'); const entriesEl = root.querySelector('.pt-entries'); const emptyEl = root.querySelector('.pt-empty'); const jumpBtn = root.querySelector('.pt-jump'); /* ── 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(); /* ── 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() { moduleApi.saveInstanceSettings({ hiddenChannels: [...hiddenChannels] }); } function applyFilter(el) { const hidden = hiddenChannels.has(el.dataset.channelId) || filteredOut(el.dataset.channelId, el.dataset.channelName); el.classList.toggle('hidden-by-filter', hidden); } function toggleChannel(id) { if (hiddenChannels.has(id)) hiddenChannels.delete(id); else hiddenChannels.add(id); persistHidden(); rebuildChannelMenu(); entryEls.forEach((el) => applyFilter(el)); if (pinnedToBottom) scrollToBottom(); } /** Show exactly this group's channels, hide the rest */ function applyGroup(g) { hiddenChannels = new Set([...channels.keys()].filter((cid) => !g.channelIds.includes(cid))); persistHidden(); rebuildChannelMenu(); entryEls.forEach((el) => applyFilter(el)); if (pinnedToBottom) scrollToBottom(); } function groupIsActive(g) { const members = g.channelIds.filter((cid) => channels.has(cid)); if (!members.length) return false; return [...channels.keys()].every((cid) => members.includes(cid) ? !hiddenChannels.has(cid) : hiddenChannels.has(cid) ); } 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) { for (const g of groups.values()) { const on = groupIsActive(g); const item = document.createElement('button'); item.className = 'pt-channel-item pt-group-item' + (on ? ' on' : ''); item.setAttribute('role', 'menuitemradio'); item.setAttribute('aria-checked', String(on)); const check = document.createElement('span'); check.className = 'check'; check.textContent = '✓'; const name = document.createElement('span'); name.className = 'name'; name.textContent = g.name; item.append(check, name); item.addEventListener('click', (e) => { e.stopPropagation(); applyGroup(g); }); channelMenu.appendChild(item); } const divider = document.createElement('div'); divider.className = 'pt-menu-divider'; channelMenu.appendChild(divider); } for (const [id, info] of channels) { const on = !hiddenChannels.has(id); const item = document.createElement('button'); item.className = 'pt-channel-item' + (on ? ' on' : ''); item.setAttribute('role', 'menuitemcheckbox'); item.setAttribute('aria-checked', String(on)); const check = document.createElement('span'); check.className = 'check'; check.textContent = '✓'; const name = document.createElement('span'); name.className = 'name'; name.textContent = info.name; name.style.color = info.color; item.append(check, name); item.addEventListener('click', (e) => { // keep the menu open: the rebuild detaches this item, which would // make the outside-click handler think we clicked outside e.stopPropagation(); toggleChannel(id); }); channelMenu.appendChild(item); } } /* ── clear (local view only) ────────────────────────────────────── */ function clearView() { entryEls.forEach((el) => el.remove()); entryEls.clear(); emptyEl.hidden = false; } /* ── scrolling ──────────────────────────────────────────────────── */ streamEl.addEventListener('scroll', () => { const nearBottom = streamEl.scrollHeight - streamEl.scrollTop - streamEl.clientHeight < 60; pinnedToBottom = nearBottom; if (nearBottom) jumpBtn.hidden = true; }); jumpBtn.addEventListener('click', () => { pinnedToBottom = true; jumpBtn.hidden = true; scrollToBottom(); }); function scrollToBottom() { streamEl.scrollTop = streamEl.scrollHeight; } function afterInsert(el) { if (el.classList.contains('hidden-by-filter')) return; if (pinnedToBottom) scrollToBottom(); else jumpBtn.hidden = false; } /* ── entry rendering ────────────────────────────────────────────── */ function channelInfo(entry) { const known = channels.get(entry.channelId); return { name: (known && known.name) || entry.channelName || 'Unknown', color: (known && known.color) || 'var(--muted)', }; } function fmtTime(iso) { const d = iso ? new Date(iso) : new Date(); return isNaN(d) ? '' : d.toLocaleTimeString([], { hour12: false }); } function upsertEntry(entry) { if (!entry || typeof entry.text !== 'string') return; const id = entry.id || entry.channelId + '|' + entry.date; let el = entryEls.get(id); if (!el) { emptyEl.hidden = true; 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); const time = document.createElement('span'); time.className = 'time'; time.textContent = fmtTime(entry.date); const avatar = document.createElement('span'); avatar.className = 'avatar'; avatar.style.color = info.color; avatar.innerHTML = ''; const ch = document.createElement('span'); ch.className = 'channel'; ch.textContent = info.name; ch.style.color = info.color; const text = document.createElement('span'); text.className = 'text'; // channel name with its timestamp to the right; the clock button // only toggles the timestamp's visibility const chLine = document.createElement('span'); chLine.className = 'channel-line'; chLine.append(ch, time); const body = document.createElement('div'); body.className = 'body'; body.append(chLine, text); el.append(avatar, body); entryEls.set(id, el); applyFilter(el); entriesEl.appendChild(el); updateEntryEl(el, entry); afterInsert(el); } else { updateEntryEl(el, entry); if (pinnedToBottom && !el.classList.contains('hidden-by-filter')) scrollToBottom(); } } function updateEntryEl(el, entry) { el.querySelector('.text').textContent = entry.translatedText || entry.text; el.classList.toggle('in-progress', entry.inProgress === true); } /* ── SSE event parsing ──────────────────────────────────────────── */ /* The stream sends JSON objects for new/updated/completed transcript entries. Be liberal about the envelope shape. */ function extractEntries(obj) { if (!obj || typeof obj !== 'object') return []; if (Array.isArray(obj)) return obj.flatMap(extractEntries); if (typeof obj.text === 'string' && (obj.id || obj.channelId)) return [obj]; for (const key of ['data', 'payload', 'entry', 'entries', 'transcript']) { if (obj[key] && typeof obj[key] === 'object') { const found = extractEntries(obj[key]); if (found.length) return found; } } return []; } function handleEventData(raw) { let obj; try { obj = JSON.parse(raw); } catch { return; } const kind = String(obj.type || obj.event || '').toLowerCase(); if (kind.includes('clear')) { entryEls.forEach((el) => el.remove()); entryEls.clear(); emptyEl.hidden = false; return; } extractEntries(obj).forEach(upsertEntry); } /* ── data loading (all via the module's proxy) ──────────────────── */ async function loadChannels() { const res = await moduleApi.fetch('/prodcom/api/v1/channels'); if (!res.ok) throw new Error('channels ' + res.status); const body = await res.json(); channels.clear(); for (const ch of body.data || []) { channels.set(ch.id, { name: ch.name, color: ch.color || '#8b98a5' }); } rebuildChannelMenu(); } async function loadGroups() { const res = await moduleApi.fetch('/prodcom/api/v1/groups'); if (!res.ok) throw new Error('groups ' + res.status); const body = await res.json(); groups.clear(); for (const g of body.data || []) { groups.set(g.id, { name: g.name, channelIds: g.channelIds || [] }); } rebuildChannelMenu(); } async function loadHistory() { const res = await moduleApi.fetch('/prodcom/api/v1/transcript?limit=100'); if (!res.ok) throw new Error('transcript ' + res.status); const body = await res.json(); const entries = (body.data || []).slice(); entries.sort((a, b) => new Date(a.date) - new Date(b.date)); entries.forEach(upsertEntry); } /* ── live stream ────────────────────────────────────────────────── */ const SSE_EVENT_NAMES = [ 'transcript', 'transcript.new', 'transcript.updated', 'transcript.update', 'transcript.completed', 'transcript.complete', 'entry', 'new', 'update', 'updated', 'complete', 'completed', ]; function connect() { const onEvent = (ev) => handleEventData(ev.data); const events = {}; for (const name of SSE_EVENT_NAMES) events[name] = onEvent; stream = moduleApi.sse('/prodcom/api/v1/transcript/stream', { open() { moduleApi.setStatus('ok', 'Live'); // refresh channels/groups and backfill entries missed while // disconnected (upsertEntry dedupes by id, so this is harmless) loadChannels().catch(() => {}); loadGroups().catch(() => {}); loadHistory().catch(() => {}); }, error() { moduleApi.setStatus('error', 'ProdCom unreachable — reconnecting…'); }, message: onEvent, events, }); } /* ── lifecycle ──────────────────────────────────────────────────── */ async function boot() { if (stopped) return; moduleApi.setStatus('connecting', 'Connecting to ProdCom…'); try { await loadChannels(); await loadGroups().catch(() => {}); // groups are optional await loadHistory(); } catch { moduleApi.setStatus('error', 'ProdCom unreachable — retrying…'); bootTimer = setTimeout(boot, 5000); return; } scrollToBottom(); connect(); } return { start() { boot(); }, stop() { stopped = true; clearTimeout(bootTimer); stream?.close(); root.innerHTML = ''; // header controls are removed by the shell }, onResize() { if (pinnedToBottom) scrollToBottom(); }, }; }