← back to Marketing Command Center
Add Streams Board — Hootsuite-style column view (social channels + Constant Contact)
a32a88983d04fed07e90123e656488d0009212fa · 2026-06-15 12:10:34 -0700 · SteveStudio2
Files touched
A modules/board/index.jsM modules/registry.jsM public/app.jsA public/panels/board.htmlA public/panels/board.js
Diff
commit a32a88983d04fed07e90123e656488d0009212fa
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Mon Jun 15 12:10:34 2026 -0700
Add Streams Board — Hootsuite-style column view (social channels + Constant Contact)
---
modules/board/index.js | 29 ++++++++++
modules/registry.js | 1 +
public/app.js | 2 +-
public/panels/board.html | 56 +++++++++++++++++++
public/panels/board.js | 142 +++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 229 insertions(+), 1 deletion(-)
diff --git a/modules/board/index.js b/modules/board/index.js
new file mode 100644
index 0000000..3ac6b3e
--- /dev/null
+++ b/modules/board/index.js
@@ -0,0 +1,29 @@
+// Streams Board — the Hootsuite-style multi-column view of the whole marketing
+// machine. One vertical column per channel (the connected social platforms +
+// Constant Contact email), each showing connection state + that channel's queue
+// (scheduled / staged / posted) as dated cards. Read-only aggregation: the board
+// pulls from the EXISTING module APIs client-side (/api/channels, /constant-contact,
+// /linkedin, /social) so there's no data duplicated here — this module only
+// declares the column layout (incl. channels not yet wired, shown as "soon").
+module.exports = {
+ id: 'board',
+ title: 'Streams Board',
+ icon: '🗂️',
+ mount(router) {
+ // The ordered columns the board renders. `kind` tells the front-end which
+ // existing API feeds the column; `soon` marks channels with no publish path
+ // yet (Threads/Bluesky) so they render as a styled placeholder, not broken.
+ router.get('/columns', (_req, res) => res.json({
+ columns: [
+ { id: 'facebook', label: 'Facebook', icon: '📘', kind: 'social' },
+ { id: 'instagram', label: 'Instagram', icon: '📸', kind: 'social' },
+ { id: 'tiktok', label: 'TikTok', icon: '🎵', kind: 'social' },
+ { id: 'youtube', label: 'YouTube', icon: '▶️', kind: 'social' },
+ { id: 'linkedin', label: 'LinkedIn', icon: '💼', kind: 'linkedin' },
+ { id: 'threads', label: 'Threads', icon: '🧵', kind: 'social', soon: true },
+ { id: 'bluesky', label: 'Bluesky', icon: '🦋', kind: 'social', soon: true },
+ { id: 'email', label: 'Email · Constant Contact', icon: '✉️', kind: 'email' },
+ ],
+ }));
+ },
+};
diff --git a/modules/registry.js b/modules/registry.js
index d5c3d5c..8ad3999 100644
--- a/modules/registry.js
+++ b/modules/registry.js
@@ -7,6 +7,7 @@
// — never this file or server.js (avoids cross-agent conflicts). To add a module,
// the integrator appends one line here.
module.exports = [
+ 'board', // Hootsuite-style streams board: one column per channel (social + email), dated cards
'constant-contact', // CC: campaigns, contacts, calendar, draft + schedule (gated sends)
'calendar', // marketing calendar: retail/holiday dates + campaign schedule
'copy', // suggested copy: subject lines, email/social body (LLM-backed)
diff --git a/public/app.js b/public/app.js
index 906db9c..7799c2f 100644
--- a/public/app.js
+++ b/public/app.js
@@ -9,7 +9,7 @@ let PANELS = [];
// Curated nav groups. Social Media leads. Any panel not listed falls into "More".
const GROUPS = [
- { name: 'Social Media', ids: ['social', 'channels', 'linkedin'] },
+ { name: 'Social Media', ids: ['board', 'social', 'channels', 'linkedin'] },
{ name: 'Content Studio', ids: ['copy', 'layouts', 'assets', 'templates'] },
{ name: 'Email', ids: ['constant-contact', 'send-times'] },
{ name: 'Audience', ids: ['segments', 'profiles', 'segment-perf'] },
diff --git a/public/panels/board.html b/public/panels/board.html
new file mode 100644
index 0000000..72c2358
--- /dev/null
+++ b/public/panels/board.html
@@ -0,0 +1,56 @@
+<div class="board-toolbar">
+ <div class="muted" style="font-size:12.5px">Live streams across every channel — scheduled, staged & published. Scroll sideways for more channels.</div>
+ <div class="board-controls">
+ <label class="muted" style="font-size:12px">Sort
+ <select id="bd-sort" style="width:auto;font-size:12px">
+ <option value="newest">Newest</option>
+ <option value="oldest">Oldest</option>
+ </select>
+ </label>
+ <label class="muted" style="font-size:12px">Width
+ <input id="bd-width" type="range" min="240" max="420" step="10" value="300" style="vertical-align:middle">
+ </label>
+ <a class="btn gold" href="#channels" style="text-decoration:none;font-size:12.5px;padding:6px 12px">✍️ Compose</a>
+ <button class="btn ghost" id="bd-refresh" style="font-size:12.5px;padding:6px 12px">↻ Refresh</button>
+ </div>
+</div>
+
+<div id="bd-board" class="board-scroll"><div class="loading">Loading streams…</div></div>
+
+<style>
+ .board-toolbar{display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap;margin-bottom:14px}
+ .board-controls{display:flex;align-items:center;gap:16px;flex-wrap:wrap}
+ /* Horizontal column rail — the Hootsuite signature */
+ .board-scroll{display:flex;gap:16px;overflow-x:auto;padding-bottom:14px;align-items:flex-start;
+ scroll-snap-type:x proximity}
+ .bd-col{flex:0 0 var(--bd-w,300px);width:var(--bd-w,300px);background:var(--card,#fff);
+ border:1px solid var(--line,#e7e1d6);border-radius:14px;display:flex;flex-direction:column;
+ max-height:calc(100vh - 210px);scroll-snap-align:start;box-shadow:0 1px 3px rgba(0,0,0,.04)}
+ .bd-col.soon{opacity:.72}
+ .bd-colhead{padding:12px 14px;border-bottom:1px solid var(--line,#e7e1d6);position:sticky;top:0;
+ background:var(--card,#fff);border-radius:14px 14px 0 0;z-index:1}
+ .bd-colhead .row1{display:flex;align-items:center;gap:8px}
+ .bd-colhead .ico{font-size:18px}
+ .bd-colhead .nm{font-weight:700;font-size:13.5px;color:var(--ink,#2a2620);flex:1;min-width:0;
+ overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+ .bd-colhead .cnt{font-size:11px;color:var(--mut,#8a8170);font-weight:600}
+ .bd-pill{display:inline-block;font-size:10px;font-weight:700;padding:2px 8px;border-radius:999px;margin-top:7px}
+ .bd-pill.on{background:#e3efe0;color:#3a6b3a}
+ .bd-pill.off{background:#f3e9e9;color:#9a5a5a}
+ .bd-pill.stage{background:#eef3f8;color:#3a5a8a}
+ .bd-pill.soon{background:#f5efe2;color:#8a6a2a}
+ .bd-body{padding:10px;overflow-y:auto;display:flex;flex-direction:column;gap:9px;flex:1}
+ .bd-card{border:1px solid var(--line,#ece6da);border-radius:10px;padding:9px 10px;background:#fdfcf9}
+ .bd-card .when{font-size:10.5px;color:var(--mut,#8a8170);margin-bottom:4px}
+ .bd-card .txt{font-size:12.5px;color:var(--ink,#2a2620);line-height:1.4;word-break:break-word}
+ .bd-card .media{margin-top:7px;width:100%;height:120px;object-fit:cover;border-radius:7px;display:block;background:#f0ece3}
+ .bd-card .foot{display:flex;justify-content:space-between;align-items:center;margin-top:7px;gap:6px}
+ .bd-status{font-size:9.5px;font-weight:700;padding:1px 7px;border-radius:999px}
+ .bd-status.posted{background:#e3efe0;color:#3a6b3a}
+ .bd-status.failed{background:#f6dede;color:#9a3a3a}
+ .bd-status.pending,.bd-status.staged{background:#eef3f8;color:#3a5a8a}
+ .bd-status.draft{background:#f5efe2;color:#8a6a2a}
+ .bd-empty{font-size:12px;color:var(--mut,#8a8170);text-align:center;padding:22px 8px;font-style:italic}
+ .bd-metric{font-size:11px;color:var(--mut,#8a8170)}
+ .bd-soonmsg{font-size:12px;color:var(--mut,#8a8170);text-align:center;padding:26px 12px;line-height:1.5}
+</style>
diff --git a/public/panels/board.js b/public/panels/board.js
new file mode 100644
index 0000000..381a6ca
--- /dev/null
+++ b/public/panels/board.js
@@ -0,0 +1,142 @@
+window.MCC_PANELS = window.MCC_PANELS || {};
+window.MCC_PANELS['board'] = {
+ async init(root) {
+ const ORIGIN = location.origin;
+ const $ = s => root.querySelector(s);
+ const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
+ const jget = async u => { try { const r = await fetch(ORIGIN + u, { credentials: 'same-origin' }); return r.ok ? await r.json() : null; } catch { return null; } };
+ // created date + time in the admin's local tz (admin-card convention)
+ const fmtWhen = iso => { if (!iso) return ''; const d = new Date(iso); return isNaN(d) ? '' : d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); };
+ const pickDate = o => o && (o.at || o.created_at || o.createdAt || o.date || o.scheduled_at || o.scheduledAt || o.scheduled || o.send_time || o.when || '');
+ const pickText = o => o && (o.caption || o.body || o.text || o.message || o.subject || o.name || o.title || '');
+
+ // persisted controls
+ const board = $('#bd-board');
+ const setWidth = w => { board.style.setProperty('--bd-w', w + 'px'); };
+ let WIDTH = +(localStorage.getItem('mcc_board_w') || 300);
+ let SORT = localStorage.getItem('mcc_board_sort') || 'newest';
+ $('#bd-width').value = WIDTH; setWidth(WIDTH);
+ $('#bd-sort').value = SORT;
+
+ let DATA = {};
+ async function load() {
+ board.innerHTML = '<div class="loading">Loading streams…</div>';
+ const [cols, status, outbox, cc, ccCal, li] = await Promise.all([
+ jget('/api/board/columns'),
+ jget('/api/channels/status'),
+ jget('/api/channels/outbox'),
+ jget('/api/constant-contact/campaigns'),
+ jget('/api/constant-contact/calendar'),
+ jget('/api/linkedin/drafts'),
+ ]);
+ DATA = {
+ columns: (cols && cols.columns) || [],
+ platforms: (status && status.platforms) || {},
+ outbox: (outbox && outbox.items) || [],
+ campaigns: (cc && (cc.campaigns || cc.items)) || [],
+ ccCal: (ccCal && (ccCal.items || ccCal.campaigns || ccCal.events)) || [],
+ drafts: (li && li.drafts) || [],
+ };
+ render();
+ }
+
+ function sortItems(arr) {
+ const a = arr.slice();
+ a.sort((x, y) => {
+ const dx = pickDate(x) || '', dy = pickDate(y) || '';
+ return SORT === 'oldest' ? String(dx).localeCompare(dy) : String(dy).localeCompare(dx);
+ });
+ return a;
+ }
+
+ function statusClass(s) {
+ s = (s || '').toLowerCase();
+ if (s.includes('post')) return 'posted';
+ if (s.includes('fail')) return 'failed';
+ if (s.includes('draft')) return 'draft';
+ if (s.includes('schedul')) return 'staged';
+ return 'pending';
+ }
+
+ function cardHTML({ when, text, media, status, metric }) {
+ const w = fmtWhen(when);
+ return `<div class="bd-card">
+ ${w ? `<div class="when" title="${esc(when)}">🕓 ${esc(w)}</div>` : ''}
+ <div class="txt">${esc((text || '').slice(0, 220)) || '<span class="bd-metric">(no text)</span>'}</div>
+ ${media ? `<img class="media" src="${esc(media)}" loading="lazy" onerror="this.style.display='none'">` : ''}
+ <div class="foot">
+ ${metric ? `<span class="bd-metric">${esc(metric)}</span>` : '<span></span>'}
+ ${status ? `<span class="bd-status ${statusClass(status)}">${esc(status)}</span>` : ''}
+ </div>
+ </div>`;
+ }
+
+ // build the cards + header pill for one column
+ function columnContent(col) {
+ // ── Threads / Bluesky: no publish path yet ──
+ if (col.soon) {
+ return { pill: { cls: 'soon', txt: 'Coming soon' }, count: 0,
+ body: `<div class="bd-soonmsg">🔌 <b>${esc(col.label)}</b> isn't wired for publishing yet.<br>The column is here so it slots in the moment the connector lands.</div>` };
+ }
+ // ── LinkedIn: drafts from the linkedin module ──
+ if (col.kind === 'linkedin') {
+ const items = sortItems(DATA.drafts);
+ const body = items.length
+ ? items.map(d => cardHTML({ when: pickDate(d), text: pickText(d), status: d.status || 'draft' })).join('')
+ : `<div class="bd-empty">No LinkedIn drafts yet.</div>`;
+ return { pill: { cls: 'stage', txt: 'Drafts · manual' }, count: items.length, body };
+ }
+ // ── Email: Constant Contact campaigns (reports) + scheduled calendar ──
+ if (col.kind === 'email') {
+ const cal = sortItems(DATA.ccCal).map(e => cardHTML({
+ when: pickDate(e), text: pickText(e), status: 'scheduled',
+ }));
+ const camps = DATA.campaigns.slice(0, 40).map(c => cardHTML({
+ when: pickDate(c), text: c.name || pickText(c),
+ metric: [c.sends != null ? c.sends + ' sent' : null,
+ c.opens != null ? c.opens + ' opens' : null,
+ c.open_rate != null ? Math.round(c.open_rate * 100) + '% open' : null].filter(Boolean).join(' · '),
+ status: 'report',
+ }));
+ const all = cal.concat(camps);
+ const has = DATA.campaigns.length || DATA.ccCal.length;
+ return { pill: { cls: has ? 'on' : 'stage', txt: has ? 'Reports loaded' : 'Import CSV' },
+ count: all.length, body: all.length ? all.join('') : `<div class="bd-empty">No campaigns yet — import a CSV in the Email panel.</div>` };
+ }
+ // ── Social: facebook / instagram / tiktok / youtube from the channels outbox ──
+ const pf = DATA.platforms[col.id];
+ const items = sortItems(DATA.outbox.filter(it => (it.channel || '').toLowerCase() === col.id));
+ let pill;
+ if (pf && pf.connected) pill = { cls: 'on', txt: 'Connected · live' };
+ else if (pf) pill = { cls: 'stage', txt: 'Stages (not connected)' };
+ else pill = { cls: 'off', txt: 'Not connected' };
+ const body = items.length
+ ? items.map(it => cardHTML({ when: it.at, text: it.caption, media: it.mediaUrl, status: it.status })).join('')
+ : `<div class="bd-empty">Nothing queued for ${esc(col.label)}.</div>`;
+ return { pill, count: items.length, body };
+ }
+
+ function render() {
+ if (!DATA.columns.length) { board.innerHTML = `<div class="muted-banner">No columns configured.</div>`; return; }
+ board.innerHTML = DATA.columns.map(col => {
+ const c = columnContent(col);
+ return `<div class="bd-col ${col.soon ? 'soon' : ''}">
+ <div class="bd-colhead">
+ <div class="row1"><span class="ico">${col.icon || '▪'}</span>
+ <span class="nm">${esc(col.label)}</span>
+ <span class="cnt">${c.count}</span></div>
+ <span class="bd-pill ${c.pill.cls}">${esc(c.pill.txt)}</span>
+ </div>
+ <div class="bd-body">${c.body}</div>
+ </div>`;
+ }).join('');
+ }
+
+ // controls
+ $('#bd-width').addEventListener('input', e => { WIDTH = +e.target.value; setWidth(WIDTH); localStorage.setItem('mcc_board_w', WIDTH); });
+ $('#bd-sort').addEventListener('change', e => { SORT = e.target.value; localStorage.setItem('mcc_board_sort', SORT); render(); });
+ $('#bd-refresh').addEventListener('click', load);
+
+ await load();
+ },
+};
← 535807b MCC polish: publish-time caveats for half-wired channels (Ti
·
back to Marketing Command Center
·
Channels: add Facebook/Instagram Page picker — select WHICH 5435736 →