← back to Marketing Command Center
Composer: add reel/story asset picker + Facebook Page selector
97dcfe7a2f5c3c2e4534acd620a232ecc78950b2 · 2026-07-20 12:56:36 -0700 · Steve
- /api/composer/reels serves generated reels as selectable video post media
- Reels tab (portrait video tiles) → sets videoUrl; video preview in composer
- Facebook Page picker (reuses /api/channels/pages) shown when FB is targeted
- postFacebook/postInstagram now publish video (FB /videos, IG REELS) + selected pages
- publish body carries videoUrl + pages; verified headless (25 reels, 80 pages, 0 errors)
Files touched
M modules/channels/index.jsM modules/composer/index.jsM public/panels/composer.htmlM public/panels/composer.js
Diff
commit 97dcfe7a2f5c3c2e4534acd620a232ecc78950b2
Author: Steve <steve@designerwallcoverings.com>
Date: Mon Jul 20 12:56:36 2026 -0700
Composer: add reel/story asset picker + Facebook Page selector
- /api/composer/reels serves generated reels as selectable video post media
- Reels tab (portrait video tiles) → sets videoUrl; video preview in composer
- Facebook Page picker (reuses /api/channels/pages) shown when FB is targeted
- postFacebook/postInstagram now publish video (FB /videos, IG REELS) + selected pages
- publish body carries videoUrl + pages; verified headless (25 reels, 80 pages, 0 errors)
---
modules/channels/index.js | 30 ++++++++++++----
modules/composer/index.js | 51 +++++++++++++++++++++++----
public/panels/composer.html | 37 +++++++++++++++++++-
public/panels/composer.js | 84 +++++++++++++++++++++++++++++++++++++++++----
4 files changed, 182 insertions(+), 20 deletions(-)
diff --git a/modules/channels/index.js b/modules/channels/index.js
index f535259..2776c9d 100644
--- a/modules/channels/index.js
+++ b/modules/channels/index.js
@@ -383,9 +383,19 @@ async function postFacebook(content, opts = {}) {
}
const results = [];
for (const t of targets) {
- const url = `${GRAPH}/${t.id}/${content.mediaUrl ? 'photos' : 'feed'}`;
- const body = content.mediaUrl ? { url: content.mediaUrl, caption: content.caption, access_token: t.token }
- : { message: content.caption, access_token: t.token };
+ // video (a selected reel) → /videos with a public file_url; photo → /photos;
+ // text → /feed. Video + photo posting both need pages_manage_posts on the token.
+ let url, body;
+ if (content.videoUrl) {
+ url = `${GRAPH}/${t.id}/videos`;
+ body = { file_url: content.videoUrl, description: content.caption, access_token: t.token };
+ } else if (content.mediaUrl) {
+ url = `${GRAPH}/${t.id}/photos`;
+ body = { url: content.mediaUrl, caption: content.caption, access_token: t.token };
+ } else {
+ url = `${GRAPH}/${t.id}/feed`;
+ body = { message: content.caption, access_token: t.token };
+ }
const r = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
const j = await r.json().catch(() => ({}));
results.push({ pageId: t.id, name: t.name, ok: r.ok, id: j.id || j.post_id, error: r.ok ? null : (j.error?.message || `HTTP ${r.status}`) });
@@ -393,7 +403,7 @@ async function postFacebook(content, opts = {}) {
return results;
}
async function postInstagram(content, opts = {}) {
- if (!content.mediaUrl) return [{ ok: false, error: 'Instagram requires an image/video URL' }];
+ if (!content.mediaUrl && !content.videoUrl) return [{ ok: false, error: 'Instagram requires an image or video URL' }];
// selected-pages → post to each selected Page's LINKED IG account (Page token);
// legacy → single IG_BUSINESS_ACCOUNT_ID + IG_ACCESS_TOKEN.
let igTargets = [];
@@ -406,16 +416,22 @@ async function postInstagram(content, opts = {}) {
}
const results = [];
for (const t of igTargets) {
+ // A selected reel → REELS container (video_url); else an IMAGE container.
+ const createBody = content.videoUrl
+ ? { media_type: 'REELS', video_url: content.videoUrl, caption: content.caption, access_token: t.token }
+ : { image_url: content.mediaUrl, caption: content.caption, access_token: t.token };
const create = await fetch(`${GRAPH}/${t.igId}/media`, {
method: 'POST', headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ image_url: content.mediaUrl, caption: content.caption, access_token: t.token }),
+ body: JSON.stringify(createBody),
});
const cj = await create.json().catch(() => ({}));
if (!create.ok || !cj.id) { results.push({ igId: t.igId, name: t.name, ok: false, error: cj.error?.message || 'media create failed' }); continue; }
- // Meta processes the image container ASYNC — publishing before it's FINISHED throws
+ // Meta processes the container ASYNC — publishing before it's FINISHED throws
// "Media ID is not available". Poll status_code until FINISHED (or ERROR/timeout).
+ // Video (REELS) transcodes slower than an image, so give it more polls.
let ready = false;
- for (let i = 0; i < 12; i++) {
+ const maxPolls = content.videoUrl ? 30 : 12;
+ for (let i = 0; i < maxPolls; i++) {
await new Promise(r => setTimeout(r, i === 0 ? 1500 : 2500));
const s = await fetch(`${GRAPH}/${cj.id}?fields=status_code&access_token=${encodeURIComponent(t.token)}`);
const sj = await s.json().catch(() => ({}));
diff --git a/modules/composer/index.js b/modules/composer/index.js
index 5a08019..61f8bf5 100644
--- a/modules/composer/index.js
+++ b/modules/composer/index.js
@@ -1,9 +1,31 @@
-// 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.
+// Composer — the central "combine everything" board. Pull an ASSET, a rendered
+// BANNER, or a generated REEL/STORY, write/GENERATE the COPY, pick the target SOCIALS
+// (and, for Facebook, which Page), 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) plus the two data endpoints below.
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+
+// The reels live in the SEPARATE dw-marketing-reels app; both run on the same host,
+// so we read its manifest by path. REELS_MANIFEST overrides; otherwise try the known
+// local (Mac) + prod (Kamatera) locations in order. The public video URL is served by
+// that app's /reels/*.mp4 passthrough under REELS_PUBLIC_BASE.
+const REELS_PUBLIC_BASE = (process.env.REELS_PUBLIC_BASE || 'https://marketing.designerwallcoverings.com').replace(/\/$/, '');
+const REELS_MANIFEST_CANDIDATES = [
+ process.env.REELS_MANIFEST,
+ path.join(__dirname, '..', '..', '..', 'dw-marketing-reels', 'data', 'reels.json'),
+ '/root/public-projects/dw-marketing-reels/data/reels.json',
+ path.join(os.homedir(), 'Projects', 'dw-marketing-reels', 'data', 'reels.json'),
+].filter(Boolean);
+
+function readReelsManifest() {
+ for (const p of REELS_MANIFEST_CANDIDATES) {
+ try { if (fs.existsSync(p)) return { path: p, reels: JSON.parse(fs.readFileSync(p, 'utf8')) }; } catch { /* try next */ }
+ }
+ return { path: null, reels: [] };
+}
+
module.exports = {
id: 'composer',
title: 'Composer',
@@ -23,5 +45,22 @@ module.exports = {
{ id: 'threads', label: 'Threads', icon: '🧵' },
],
}));
+
+ // Reel / Story assets — the generated portrait videos from dw-marketing-reels,
+ // exposed as selectable post media (videoUrl). Newest first, capped so the picker
+ // stays snappy. Each carries a public .mp4 URL Meta/Bluesky can fetch.
+ router.get('/reels', (_req, res) => {
+ const { path: manPath, reels } = readReelsManifest();
+ const list = (Array.isArray(reels) ? reels : (reels.reels || [])).slice(0, 40).map(r => ({
+ file: r.file,
+ url: `${REELS_PUBLIC_BASE}/reels/${encodeURIComponent(r.file)}`,
+ title: (r.titles && r.titles[0]) || r.kind || r.file,
+ kind: r.kind || 'reel',
+ caption: r.caption || '',
+ seconds: r.seconds || null,
+ created_at: r.created_at || null,
+ })).filter(r => r.file);
+ res.json({ source: manPath, count: list.length, reels: list });
+ });
},
};
diff --git a/public/panels/composer.html b/public/panels/composer.html
index f162571..48a4ed6 100644
--- a/public/panels/composer.html
+++ b/public/panels/composer.html
@@ -8,6 +8,7 @@
<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="reels">🎬 Reels</button>
<button class="cmp-tab" data-src="banners">🎏 Banners</button>
</div>
<input id="cmp-search" class="cmp-search" placeholder="Filter…">
@@ -17,8 +18,9 @@
<!-- 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>
+ <div class="cmp-ph">Pick an asset, reel, or banner on the left ←</div>
<img id="cmp-img" style="display:none" alt="selected">
+ <video id="cmp-video" style="display:none" controls muted playsinline preload="metadata"></video>
</div>
<div id="cmp-bannernote" class="muted" style="font-size:11.5px;color:#8a6a2a;min-height:14px"></div>
<div class="cmp-copywrap">
@@ -45,6 +47,18 @@
<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>
+ <!-- Facebook Page picker — revealed only when Facebook is a selected target -->
+ <div id="cmp-fbpages" class="cmp-fbpages" style="display:none">
+ <div class="cmp-fbpages-head">
+ <span>📘 Post to Page(s)</span>
+ <span class="cmp-fbpages-tools">
+ <button id="cmp-fball" class="cmp-mini">all</button>
+ <button id="cmp-fbrefresh" class="cmp-mini" title="Refresh Pages from Meta">↻</button>
+ </span>
+ </div>
+ <input id="cmp-fbsearch" class="cmp-search" style="margin-bottom:6px" placeholder="Filter pages…">
+ <div id="cmp-fbpages-list" class="cmp-fbpages-list"><div class="loading">Loading…</div></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>
@@ -73,7 +87,28 @@
.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-preview video{width:100%;max-height:460px;object-fit:contain;display:block;background:#000}
.cmp-ph{color:var(--mut,#8a8170);font-size:13px;font-style:italic;padding:30px}
+ /* reel thumbnails — portrait video tiles with a play badge */
+ .cmp-srcgrid.reels{grid-template-columns:1fr 1fr}
+ .cmp-reel{border:1px solid var(--line,#ece6da);border-radius:9px;overflow:hidden;cursor:pointer;background:#000;
+ position:relative;aspect-ratio:9/16}
+ .cmp-reel video{width:100%;height:100%;object-fit:cover;display:block;pointer-events:none}
+ .cmp-reel.sel{outline:3px solid var(--gold,#b8945a);outline-offset:-1px}
+ .cmp-reel .play{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
+ font-size:26px;color:#fff;text-shadow:0 2px 8px rgba(0,0,0,.6);pointer-events:none}
+ .cmp-reel .lbl{position:absolute;left:0;right:0;bottom:0;font-size:9.5px;background:rgba(0,0,0,.6);color:#fff;
+ padding:3px 5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+ /* facebook page picker */
+ .cmp-fbpages{border:1px solid var(--line,#ece6da);border-radius:10px;padding:9px;margin-bottom:10px;background:#fbfaf6}
+ .cmp-fbpages-head{display:flex;justify-content:space-between;align-items:center;font-size:12px;font-weight:700;margin-bottom:7px}
+ .cmp-fbpages-tools{display:flex;gap:5px}
+ .cmp-mini{font-size:10.5px;padding:2px 7px;border:1px solid var(--line,#e7e1d6);background:#fff;border-radius:7px;cursor:pointer;color:var(--mut,#8a8170)}
+ .cmp-fbpages-list{max-height:150px;overflow-y:auto;display:flex;flex-direction:column;gap:3px}
+ .cmp-fbrow{display:flex;align-items:center;gap:7px;font-size:12px;padding:4px 6px;border-radius:7px;cursor:pointer}
+ .cmp-fbrow:hover{background:#f2ede3}
+ .cmp-fbrow .nm{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+ .cmp-fbrow .ig{font-size:9.5px;color:#8a8170}
.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}
diff --git a/public/panels/composer.js b/public/panels/composer.js
index 90ab4c5..b05b3a9 100644
--- a/public/panels/composer.js
+++ b/public/panels/composer.js
@@ -7,7 +7,7 @@ window.MCC_PANELS['composer'] = {
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() };
+ const state = { mediaUrl: '', videoUrl: '', src: 'assets', selected: new Set(), pages: new Set() };
const NEW_ARRIVALS = 'designerwallcoverings.com/collections/new-arrivals'; // bare → clickable via facets
// ── RIGHT rail: publish targets, connection-aware ──────────────────────────
@@ -26,17 +26,55 @@ window.MCC_PANELS['composer'] = {
<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); });
+ cb.addEventListener('change', () => {
+ cb.checked ? state.selected.add(tg.id) : state.selected.delete(tg.id);
+ if (tg.id === 'facebook') toggleFbPages();
+ });
wrap.appendChild(el);
});
+ toggleFbPages();
}
$('#cmp-all').addEventListener('click', () => {
root.querySelectorAll('#cmp-targets input[data-ch]:not([disabled])').forEach(cb => { cb.checked = true; state.selected.add(cb.dataset.ch); });
+ toggleFbPages();
+ });
+
+ // ── Facebook Page picker: shown only when Facebook is a selected target ──
+ function toggleFbPages() {
+ const on = state.selected.has('facebook');
+ $('#cmp-fbpages').style.display = on ? '' : 'none';
+ if (on && !state._pagesLoaded) loadPages();
+ }
+ async function loadPages(refresh) {
+ const box = $('#cmp-fbpages-list'); box.innerHTML = '<div class="loading">Loading…</div>';
+ const d = await jget('/api/channels/pages' + (refresh ? '?refresh=1' : ''));
+ const pages = (d && d.pages) || [];
+ state._pagesLoaded = true;
+ if (!pages.length) { box.innerHTML = `<div class="bd-empty" style="font-size:11.5px">${d && d.hasToken ? 'No Pages found — hit ↻ to fetch from Meta.' : 'Meta not connected — add a token in the Setup panel.'}</div>`; return; }
+ box.innerHTML = '';
+ pages.forEach(p => {
+ const row = document.createElement('label'); row.className = 'cmp-fbrow';
+ row.innerHTML = `<input type="checkbox" data-pid="${esc(p.id)}"><span class="nm">${esc(p.name || p.id)}</span>${p.hasIG ? `<span class="ig">@${esc(p.igUsername || 'ig')}</span>` : ''}`;
+ const cb = row.querySelector('input');
+ cb.addEventListener('change', () => { cb.checked ? state.pages.add(p.id) : state.pages.delete(p.id); });
+ box.appendChild(row);
+ });
+ }
+ $('#cmp-fball').addEventListener('click', () => {
+ root.querySelectorAll('#cmp-fbpages-list input[data-pid]').forEach(cb => { cb.checked = true; state.pages.add(cb.dataset.pid); });
+ });
+ $('#cmp-fbrefresh').addEventListener('click', () => { state.pages.clear(); loadPages(true); });
+ $('#cmp-fbsearch').addEventListener('input', e => {
+ const q = e.target.value.toLowerCase();
+ root.querySelectorAll('#cmp-fbpages-list .cmp-fbrow').forEach(r => {
+ r.style.display = (r.querySelector('.nm').textContent || '').toLowerCase().includes(q) ? '' : 'none';
+ });
});
// ── LEFT rail: assets + banners ────────────────────────────────────────────
async function loadSource(kind) {
const grid = $('#cmp-srcgrid'); grid.innerHTML = '<div class="loading">Loading…</div>';
+ grid.classList.toggle('reels', kind === 'reels');
if (kind === 'assets') {
const d = await jget('/api/assets/list');
const items = (d && d.assets) || [];
@@ -54,6 +92,21 @@ window.MCC_PANELS['composer'] = {
d1.addEventListener('click', () => select(d1, url));
grid.appendChild(d1);
});
+ } else if (kind === 'reels') {
+ // Reel / Story assets — the generated portrait videos. Selecting one sets the
+ // post's videoUrl (a public .mp4 the platforms fetch), not a still image.
+ const d = await jget('/api/composer/reels');
+ const items = (d && d.reels) || [];
+ if (!items.length) { grid.innerHTML = '<div class="bd-empty" style="grid-column:1/-1">No reels found yet — generate some in the Reels app.</div>'; return; }
+ grid.innerHTML = '';
+ items.forEach(r => {
+ const el = document.createElement('div');
+ el.className = 'cmp-reel'; el.dataset.url = r.url;
+ // #t=0.1 nudges the <video> to paint a real first-frame poster (not a black tile)
+ el.innerHTML = `<video src="${esc(r.url)}#t=0.1" muted preload="metadata"></video><span class="play">▶</span><span class="lbl">${esc(r.title || r.file)}</span>`;
+ el.addEventListener('click', () => selectReel(el, r));
+ grid.appendChild(el);
+ });
} else {
// Banners: click a template → render it LIVE (real HTML from /api/layouts/render)
// into the middle preview, using the current caption as the headline and the
@@ -73,13 +126,26 @@ window.MCC_PANELS['composer'] = {
}
}
function select(el, url) {
- root.querySelectorAll('.cmp-thumb.sel').forEach(x => x.classList.remove('sel'));
+ root.querySelectorAll('.cmp-thumb.sel, .cmp-reel.sel').forEach(x => x.classList.remove('sel'));
el.classList.add('sel');
- state.mediaUrl = url;
+ state.mediaUrl = url; state.videoUrl = ''; // image chosen → clear any reel
const img = $('#cmp-img'); img.src = url; img.style.display = 'block';
+ const v = $('#cmp-video'); if (v) { v.style.display = 'none'; v.removeAttribute('src'); }
const bf = $('#cmp-banner'); if (bf) bf.style.display = 'none'; // asset chosen → hide any banner preview
$('#cmp-preview').querySelector('.cmp-ph').style.display = 'none';
}
+ // Pick a reel → the post carries its public video URL (videoUrl), not a still.
+ function selectReel(el, r) {
+ root.querySelectorAll('.cmp-reel.sel, .cmp-thumb.sel, .cmp-tgt.sel').forEach(x => x.classList.remove('sel'));
+ el.classList.add('sel');
+ state.videoUrl = r.url; state.mediaUrl = '';
+ const img = $('#cmp-img'); img.style.display = 'none';
+ const bf = $('#cmp-banner'); if (bf) bf.style.display = 'none';
+ const v = $('#cmp-video'); v.src = r.url; v.style.display = 'block';
+ $('#cmp-preview').querySelector('.cmp-ph').style.display = 'none';
+ // seed the reel's own caption when the box is empty (keeps media + copy aligned)
+ if (r.caption && !$('#cmp-caption').value.trim()) { $('#cmp-caption').value = r.caption; countIt(); }
+ }
// Render a Layouts template LIVE into the preview via /api/layouts/render (returns HTML).
async function renderBanner(templateId, el, aspect) {
root.querySelectorAll('.cmp-tgt.sel').forEach(x => x.classList.remove('sel')); if (el) el.classList.add('sel');
@@ -132,11 +198,17 @@ window.MCC_PANELS['composer'] = {
const channels = [...state.selected];
if (!channels.length) { out.innerHTML = '<span class="warn">Pick at least one connected channel.</span>'; return; }
// HARD RULE: DW never posts text-only — a photo/video/reel is required.
- if (!state.mediaUrl) { out.innerHTML = '<span class="warn">📷 A photo or video is required — DW never posts text-only. Pick an asset on the left first.</span>'; return; }
+ if (!state.mediaUrl && !state.videoUrl) { out.innerHTML = '<span class="warn">📷 A photo, reel, or video is required — DW never posts text-only. Pick an asset or reel on the left first.</span>'; return; }
if (!cap.value.trim()) { out.innerHTML = '<span class="warn">Add a caption.</span>'; return; }
+ if (channels.includes('facebook') && !state.pages.size) { out.innerHTML = '<span class="warn">📘 Pick at least one Facebook Page to post to.</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 });
+ const r = await jpost('/api/channels/publish', {
+ channels, caption: cap.value.trim(),
+ mediaUrl: state.mediaUrl, videoUrl: state.videoUrl,
+ pages: [...state.pages],
+ 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'));
← 02a76e0 auto-save: 2026-07-19T07:55:24 (1 files) — package-lock.json
·
back to Marketing Command Center
·
chore: lint + refactor + v1.6.0 (session close — reel/FB-pag 540ae08 →