[object Object]

← 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

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 →