[object Object]

← back to Marketing Command Center

composer: new central board — combine asset/banner + copy → publish to all connected socials at once

8b2fd01bbb65ded1450e71ac840925c5e174b5fa · 2026-07-16 17:50:49 -0700 · Steve Abrams

Files touched

Diff

commit 8b2fd01bbb65ded1450e71ac840925c5e174b5fa
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jul 16 17:50:49 2026 -0700

    composer: new central board — combine asset/banner + copy → publish to all connected socials at once
---
 modules/composer/index.js   |  27 ++++++++++
 modules/registry.js         |   1 +
 public/panels/composer.html |  92 ++++++++++++++++++++++++++++++++
 public/panels/composer.js   | 125 ++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 245 insertions(+)

diff --git a/modules/composer/index.js b/modules/composer/index.js
new file mode 100644
index 0000000..5a08019
--- /dev/null
+++ b/modules/composer/index.js
@@ -0,0 +1,27 @@
+// Composer — the central "combine everything" board. Pull an ASSET (or render a
+// BANNER), write/GENERATE the COPY, pick the target SOCIALS, and publish (or stage)
+// to all of them in one shot. Pure client-side aggregation over the EXISTING module
+// APIs (/api/assets, /api/layouts, /api/copy, /api/channels) — this module owns no
+// data of its own; it's the assembly surface that ties the pieces together and fires
+// /api/channels/publish. Sits at the top of the nav so it's the first thing you see.
+module.exports = {
+  id: 'composer',
+  title: 'Composer',
+  icon: '🎨',
+  mount(router) {
+    // Convenience: the ordered channel list the composer offers as publish targets,
+    // so the front-end doesn't hard-code it. Connection state is layered in client-
+    // side from /api/channels/status (a channel with no creds still shows, disabled).
+    router.get('/targets', (_req, res) => res.json({
+      targets: [
+        { id: 'facebook',  label: 'Facebook',  icon: '📘' },
+        { id: 'instagram', label: 'Instagram', icon: '📸' },
+        { id: 'bluesky',   label: 'Bluesky',   icon: '🦋' },
+        { id: 'linkedin',  label: 'LinkedIn',  icon: '💼' },
+        { id: 'tiktok',    label: 'TikTok',    icon: '🎵' },
+        { id: 'youtube',   label: 'YouTube',   icon: '▶️' },
+        { id: 'threads',   label: 'Threads',   icon: '🧵' },
+      ],
+    }));
+  },
+};
diff --git a/modules/registry.js b/modules/registry.js
index 9ebdd39..c95655d 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 = [
+  'composer',           // Composer board: combine an asset/banner + copy → publish/stage to all connected socials at once (assembles assets+copy+channels)
   'accounts',           // Amazon-style rail: every social account + connection status + inline "how to fix" credentials (read-only view over channels + linkedin)
   'board',              // Hootsuite-style streams board: one column per channel (social + email), dated cards
   'constant-contact',   // CC: campaigns, contacts, calendar, draft + schedule (gated sends)
diff --git a/public/panels/composer.html b/public/panels/composer.html
new file mode 100644
index 0000000..6025db9
--- /dev/null
+++ b/public/panels/composer.html
@@ -0,0 +1,92 @@
+<div class="cmp-head">
+  <div class="muted" style="font-size:12.5px">Combine an image or banner + copy, pick your socials, and publish (or stage) to all of them at once.</div>
+  <button class="btn ghost" id="cmp-refresh" style="font-size:12.5px;padding:6px 12px">↻ Refresh</button>
+</div>
+
+<div class="cmp-grid">
+  <!-- LEFT: source rail — assets + banners -->
+  <section class="cmp-col cmp-left">
+    <div class="cmp-tabs">
+      <button class="cmp-tab on" data-src="assets">🖼️ Assets</button>
+      <button class="cmp-tab" data-src="banners">🎏 Banners</button>
+    </div>
+    <input id="cmp-search" class="cmp-search" placeholder="Filter…">
+    <div id="cmp-srcgrid" class="cmp-srcgrid"><div class="loading">Loading…</div></div>
+  </section>
+
+  <!-- MIDDLE: the post being assembled — THIS is where it all combines -->
+  <section class="cmp-col cmp-mid">
+    <div class="cmp-preview" id="cmp-preview">
+      <div class="cmp-ph">Pick an asset or banner on the left ←</div>
+      <img id="cmp-img" style="display:none" alt="selected">
+    </div>
+    <div class="cmp-copywrap">
+      <div class="cmp-copybar">
+        <label class="muted" style="font-size:11.5px">Tone
+          <select id="cmp-tone" style="width:auto;font-size:12px">
+            <option value="luxury">Luxury</option>
+            <option value="playful">Playful</option>
+            <option value="editorial">Editorial</option>
+            <option value="trade">Trade / B2B</option>
+          </select>
+        </label>
+        <button class="btn ghost" id="cmp-gen" style="font-size:12px;padding:5px 10px">✨ Generate copy</button>
+        <span id="cmp-count" class="muted" style="font-size:11px;margin-left:auto">0</span>
+      </div>
+      <textarea id="cmp-caption" class="cmp-caption" placeholder="Write your caption — or hit ✨ Generate copy. #designerwallcoverings"></textarea>
+    </div>
+  </section>
+
+  <!-- RIGHT: publish targets + fire -->
+  <section class="cmp-col cmp-right">
+    <div class="cmp-rhead">
+      <span style="font-weight:700;font-size:13px">Post to</span>
+      <button class="btn ghost" id="cmp-all" style="font-size:11px;padding:3px 9px">Select connected</button>
+    </div>
+    <div id="cmp-targets" class="cmp-targets"><div class="loading">Loading…</div></div>
+    <label class="cmp-dry"><input type="checkbox" id="cmp-dry" checked> Dry-run (stage, don't post live)</label>
+    <button class="btn gold" id="cmp-publish" style="width:100%;font-size:14px;padding:11px">Publish</button>
+    <div id="cmp-result" class="cmp-result"></div>
+  </section>
+</div>
+
+<style>
+  .cmp-head{display:flex;justify-content:space-between;align-items:center;gap:14px;margin-bottom:14px;flex-wrap:wrap}
+  .cmp-grid{display:grid;grid-template-columns:280px 1fr 260px;gap:16px;align-items:start}
+  @media(max-width:1100px){.cmp-grid{grid-template-columns:1fr}}
+  .cmp-col{background:var(--card,#fff);border:1px solid var(--line,#e7e1d6);border-radius:14px;padding:12px;
+    box-shadow:0 1px 3px rgba(0,0,0,.04);max-height:calc(100vh - 190px);display:flex;flex-direction:column}
+  .cmp-tabs{display:flex;gap:6px;margin-bottom:9px}
+  .cmp-tab{flex:1;font-size:12px;padding:6px 8px;border:1px solid var(--line,#e7e1d6);background:#fbf9f5;
+    border-radius:9px;cursor:pointer;color:var(--mut,#8a8170);font-weight:600}
+  .cmp-tab.on{background:var(--ink,#2a2620);color:#fff;border-color:var(--ink,#2a2620)}
+  .cmp-search{width:100%;font-size:12px;padding:7px 9px;border:1px solid var(--line,#e7e1d6);border-radius:9px;margin-bottom:9px}
+  .cmp-srcgrid{overflow-y:auto;display:grid;grid-template-columns:1fr 1fr;gap:8px;flex:1}
+  .cmp-thumb{border:1px solid var(--line,#ece6da);border-radius:9px;overflow:hidden;cursor:pointer;background:#f0ece3;
+    aspect-ratio:1/1;position:relative}
+  .cmp-thumb img{width:100%;height:100%;object-fit:cover;display:block}
+  .cmp-thumb.sel{outline:3px solid var(--gold,#b8945a);outline-offset:-1px}
+  .cmp-thumb .lbl{position:absolute;left:0;right:0;bottom:0;font-size:9.5px;background:rgba(0,0,0,.55);color:#fff;
+    padding:2px 5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+  .cmp-mid{gap:12px}
+  .cmp-preview{border:1px solid var(--line,#ece6da);border-radius:12px;background:#f7f4ee;min-height:260px;
+    display:flex;align-items:center;justify-content:center;overflow:hidden}
+  .cmp-preview img{width:100%;max-height:420px;object-fit:contain;display:block}
+  .cmp-ph{color:var(--mut,#8a8170);font-size:13px;font-style:italic;padding:30px}
+  .cmp-copybar{display:flex;align-items:center;gap:10px;margin-bottom:7px}
+  .cmp-caption{width:100%;min-height:120px;font-size:13.5px;line-height:1.5;padding:10px;resize:vertical;
+    border:1px solid var(--line,#e7e1d6);border-radius:10px;font-family:inherit}
+  .cmp-rhead{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px}
+  .cmp-targets{display:flex;flex-direction:column;gap:7px;overflow-y:auto;flex:1;margin-bottom:10px}
+  .cmp-tgt{display:flex;align-items:center;gap:9px;font-size:13px;padding:8px 10px;border:1px solid var(--line,#ece6da);
+    border-radius:10px;cursor:pointer;background:#fdfcf9}
+  .cmp-tgt.off{opacity:.5;cursor:not-allowed}
+  .cmp-tgt .ico{font-size:16px}
+  .cmp-tgt .nm{flex:1;font-weight:600}
+  .cmp-tgt .st{font-size:9.5px;font-weight:700;padding:1px 7px;border-radius:999px}
+  .cmp-tgt .st.on{background:#e3efe0;color:#3a6b3a}
+  .cmp-tgt .st.off{background:#f3e9e9;color:#9a5a5a}
+  .cmp-dry{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--mut,#8a8170);margin-bottom:10px}
+  .cmp-result{margin-top:11px;font-size:12px;line-height:1.5}
+  .cmp-result .ok{color:#3a6b3a}.cmp-result .warn{color:#8a6a2a}.cmp-result .err{color:#9a3a3a}
+</style>
diff --git a/public/panels/composer.js b/public/panels/composer.js
new file mode 100644
index 0000000..8453311
--- /dev/null
+++ b/public/panels/composer.js
@@ -0,0 +1,125 @@
+window.MCC_PANELS = window.MCC_PANELS || {};
+window.MCC_PANELS['composer'] = {
+  async init(root) {
+    const O = 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(O + u, { credentials: 'same-origin' }); return r.ok ? await r.json() : null; } catch { return null; } };
+    const jpost = async (u, b) => { try { const r = await fetch(O + u, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(b) }); return await r.json().catch(() => ({})); } catch (e) { return { error: e.message }; } };
+
+    const state = { mediaUrl: '', src: 'assets', selected: new Set() };
+
+    // ── RIGHT rail: publish targets, connection-aware ──────────────────────────
+    async function loadTargets() {
+      const [t, s] = await Promise.all([jget('/api/composer/targets'), jget('/api/channels/status')]);
+      const targets = (t && t.targets) || [];
+      const st = (s && (s.platforms || s)) || {};
+      const wrap = $('#cmp-targets'); wrap.innerHTML = '';
+      state.connected = [];
+      targets.forEach(tg => {
+        const on = !!(st[tg.id] && st[tg.id].connected);
+        if (on) state.connected.push(tg.id);
+        const el = document.createElement('label');
+        el.className = 'cmp-tgt' + (on ? '' : ' off');
+        el.innerHTML = `<input type="checkbox" ${on ? '' : 'disabled'} data-ch="${tg.id}">
+          <span class="ico">${tg.icon}</span><span class="nm">${esc(tg.label)}</span>
+          <span class="st ${on ? 'on' : 'off'}">${on ? 'connected' : 'not connected'}</span>`;
+        const cb = el.querySelector('input');
+        cb.addEventListener('change', () => { cb.checked ? state.selected.add(tg.id) : state.selected.delete(tg.id); });
+        wrap.appendChild(el);
+      });
+    }
+    $('#cmp-all').addEventListener('click', () => {
+      root.querySelectorAll('#cmp-targets input[data-ch]:not([disabled])').forEach(cb => { cb.checked = true; state.selected.add(cb.dataset.ch); });
+    });
+
+    // ── LEFT rail: assets + banners ────────────────────────────────────────────
+    async function loadSource(kind) {
+      const grid = $('#cmp-srcgrid'); grid.innerHTML = '<div class="loading">Loading…</div>';
+      if (kind === 'assets') {
+        const d = await jget('/api/assets/list');
+        const items = (d && d.assets) || [];
+        if (!items.length) { grid.innerHTML = '<div class="bd-empty" style="grid-column:1/-1">No assets yet — add some in the Assets panel.</div>'; return; }
+        grid.innerHTML = '';
+        items.forEach(a => {
+          const url = a.src || a.url || a.image || a.thumb || '';
+          if (!url) return;
+          const d1 = document.createElement('div');
+          d1.className = 'cmp-thumb'; d1.dataset.url = url;
+          d1.innerHTML = `<img loading="lazy" src="${esc(url)}"><span class="lbl">${esc(a.name || a.title || '')}</span>`;
+          d1.addEventListener('click', () => select(d1, url));
+          grid.appendChild(d1);
+        });
+      } else {
+        const d = await jget('/api/layouts/templates');
+        const items = (d && d.templates) || [];
+        grid.innerHTML = `<div class="bd-empty" style="grid-column:1/-1">Banners are built in the <a href="#layouts">Layouts</a> panel (they render to a designed HTML card). Pick one there, export the image, then add it to Assets to post it here.</div>`;
+        // still list the template names as quick links
+        items.forEach(t => {
+          const b = document.createElement('a');
+          b.href = '#layouts'; b.className = 'cmp-tgt'; b.style.textDecoration = 'none'; b.style.gridColumn = '1/-1';
+          b.innerHTML = `<span class="ico">🎏</span><span class="nm">${esc(t.name || t.title || t.id || t)}</span><span class="st on">open →</span>`;
+          grid.appendChild(b);
+        });
+      }
+    }
+    function select(el, url) {
+      root.querySelectorAll('.cmp-thumb.sel').forEach(x => x.classList.remove('sel'));
+      el.classList.add('sel');
+      state.mediaUrl = url;
+      const img = $('#cmp-img'); img.src = url; img.style.display = 'block';
+      $('#cmp-preview').querySelector('.cmp-ph').style.display = 'none';
+    }
+    root.querySelectorAll('.cmp-tab').forEach(tab => tab.addEventListener('click', () => {
+      root.querySelectorAll('.cmp-tab').forEach(t => t.classList.remove('on'));
+      tab.classList.add('on'); state.src = tab.dataset.src; loadSource(state.src);
+    }));
+
+    // ── MIDDLE: copy ───────────────────────────────────────────────────────────
+    const cap = $('#cmp-caption');
+    const countIt = () => { $('#cmp-count').textContent = cap.value.length; };
+    cap.addEventListener('input', countIt);
+    $('#cmp-gen').addEventListener('click', async () => {
+      $('#cmp-gen').disabled = true; $('#cmp-gen').textContent = '…generating';
+      const tone = $('#cmp-tone').value;
+      const r = await jpost('/api/copy/generate', { kind: 'social', tone, topic: `${tone} wallcovering social post for Designer Wallcoverings`, product: 'any' });
+      const v = (r && r.variants) || [];
+      const first = v.length ? (typeof v[0] === 'string' ? v[0] : (v[0].text || v[0].body || v[0].caption || '')) : '';
+      if (first) { cap.value = first; countIt(); }
+      $('#cmp-gen').disabled = false; $('#cmp-gen').textContent = '✨ Generate copy';
+    });
+
+    // ── PUBLISH ────────────────────────────────────────────────────────────────
+    $('#cmp-publish').addEventListener('click', async () => {
+      const out = $('#cmp-result'); out.innerHTML = '';
+      const channels = [...state.selected];
+      if (!channels.length) { out.innerHTML = '<span class="warn">Pick at least one connected channel.</span>'; return; }
+      if (!cap.value.trim() && !state.mediaUrl) { out.innerHTML = '<span class="warn">Add a caption or an image first.</span>'; return; }
+      const dry = $('#cmp-dry').checked;
+      $('#cmp-publish').disabled = true; $('#cmp-publish').textContent = dry ? 'Staging…' : 'Publishing…';
+      const r = await jpost('/api/channels/publish', { channels, caption: cap.value.trim(), mediaUrl: state.mediaUrl, confirm: !dry, dryRun: dry });
+      if (r && r.results) {
+        out.innerHTML = r.results.map(x => {
+          const cls = x.live && x.ok ? 'ok' : (x.staged ? 'warn' : (x.ok ? 'ok' : 'err'));
+          const msg = x.live && x.ok ? 'posted live ✓' : (x.staged ? ('staged — ' + (x.reason || '')) : (x.ok ? 'ok' : ('failed' + (x.detail && x.detail[0] && x.detail[0].error ? ': ' + x.detail[0].error : ''))));
+          return `<div class="${cls}">${esc(x.channel)}: ${esc(msg)}</div>`;
+        }).join('') + `<div class="muted" style="margin-top:6px;font-size:11px">${esc(r.note || '')}</div>`;
+      } else {
+        out.innerHTML = `<span class="err">${esc((r && r.error) || 'publish failed')}</span>`;
+      }
+      $('#cmp-publish').disabled = false; $('#cmp-publish').textContent = 'Publish';
+    });
+
+    $('#cmp-refresh').addEventListener('click', () => { loadTargets(); loadSource(state.src); });
+    $('#cmp-search').addEventListener('input', e => {
+      const q = e.target.value.toLowerCase();
+      root.querySelectorAll('#cmp-srcgrid .cmp-thumb').forEach(t => {
+        const l = (t.querySelector('.lbl') || {}).textContent || '';
+        t.style.display = l.toLowerCase().includes(q) ? '' : 'none';
+      });
+    });
+
+    await Promise.all([loadTargets(), loadSource('assets')]);
+    countIt();
+  },
+};

← 5e38c36 clients panel: one-click 'Copy not-actioned emails' (respect  ·  back to Marketing Command Center  ·  5x sweep 2: composer drops broken asset thumbnails (dead URL c5779ce →