[object Object]

← 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

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 &amp; 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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 →