/* 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, 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/*). */ /* the text-size buttons: a drawn − and + at the icon size, like every other header icon */ const MINUS_SVG = ''; const PLUS_SVG = ''; 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 pinnedToLatest = 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 = ''; 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({ icon: CHANNELS_SVG, title: 'Channels', build(menu) { menu.classList.add('pt-menu'); channelMenuEl = menu; rebuildChannelMenu(); }, }); 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({ icon: CLOCK_SVG, title: '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({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-2) }); moduleApi.header.addButton({ icon: PLUS_SVG, 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: 'Latest', onClick() { pinnedToLatest = true; jumpBtn.hidden = true; scrollToLatest(); }, }); const clearBtn = moduleApi.header.addButton({ icon: CLEAR_SVG, title: 'Clear', 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)); 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 first' : 'Oldest first'; jumpBtn.textContent = (up ? '↑' : '↓') + ' New messages'; } applyInstanceSettings(); /* ── channel visibility toggles ─────────────────────────────────── */ function persistHidden() { moduleApi.saveInstanceSettings({ hiddenChannels: [...hiddenChannels] }); } function applyFilter(el) { el.classList.toggle('hidden-by-filter', hiddenChannels.has(el.dataset.channelId)); } function toggleChannel(id) { if (hiddenChannels.has(id)) hiddenChannels.delete(id); else hiddenChannels.add(id); persistHidden(); rebuildChannelMenu(); entryEls.forEach((el) => applyFilter(el)); if (pinnedToLatest) scrollToLatest(); } /** 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 (pinnedToLatest) scrollToLatest(); } 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) ); } /* 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++; } 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 ${visibleCount}/${channels.size}` : 'Channels'; } function rebuildChannelMenu() { updateChannelsButton(); 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 ──────────────────────────────────────────────────── */ /** Newest entry at the top (bottom-up) instead of at the bottom? */ function newestFirst() { return Boolean(moduleApi.instanceSettings.newestFirst); } streamEl.addEventListener('scroll', () => { const near = newestFirst() ? streamEl.scrollTop < 60 : streamEl.scrollHeight - streamEl.scrollTop - streamEl.clientHeight < 60; pinnedToLatest = near; if (near) jumpBtn.hidden = true; }); jumpBtn.addEventListener('click', () => { pinnedToLatest = true; jumpBtn.hidden = true; scrollToLatest(); }); /** 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 (pinnedToLatest) scrollToLatest(); 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 || ''; 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 (pinnedToLatest && !el.classList.contains('hidden-by-filter')) scrollToLatest(); } } 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; } scrollToLatest(); connect(); } return { start() { boot(); }, stop() { stopped = true; clearTimeout(bootTimer); stream?.close(); root.innerHTML = ''; // header controls are removed by the shell }, onResize() { if (pinnedToLatest) scrollToLatest(); }, }; }