← 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
A modules/composer/index.jsM modules/registry.jsA public/panels/composer.htmlA public/panels/composer.js
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 →