← back to Marketing Command Center
Composer: one create flow — photo → details → song → decide reel-or-what
3c85e999d5c40b08652e93ca35be32865384796c · 2026-08-25 11:08:51 -0700 · Steve
Turn the Composer into the single 'pick a photo, add details, add a song, decide
the format' flow Steve wants, instead of hopping between Assets / Sounds / Reels /
Social panels.
- Format selector (📱 Feed post / 🎬 Reel / 📖 Story / 🎠 Carousel) in the assembly column
- Song picker sourced from the Sounds library (/api/sounds/catalog, 30 tracks) —
search + in-place preview + selected-track chip with remove
- Smart coupling: adding a song auto-switches format→Reel (a photo can't carry audio
as a feed post); picking a reel VIDEO also sets format→Reel
- Contextual hint: a still photo + song = a video → assemble here, then 'build in
Reel Studio' (#reels) which renders the photo over the track
- format + song carried through /api/channels/publish and persisted on the outbox
entry (additive, like handles) so the recipe survives for review/render
Verified: 4 format pills, 30-track picker, song→auto-Reel, chip renders; backend
round-trip persists format=reel + song on the outbox; no page errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M modules/channels/index.jsM public/panels/composer.htmlM public/panels/composer.js
Diff
commit 3c85e999d5c40b08652e93ca35be32865384796c
Author: Steve <steve@designerwallcoverings.com>
Date: Tue Aug 25 11:08:51 2026 -0700
Composer: one create flow — photo → details → song → decide reel-or-what
Turn the Composer into the single 'pick a photo, add details, add a song, decide
the format' flow Steve wants, instead of hopping between Assets / Sounds / Reels /
Social panels.
- Format selector (📱 Feed post / 🎬 Reel / 📖 Story / 🎠 Carousel) in the assembly column
- Song picker sourced from the Sounds library (/api/sounds/catalog, 30 tracks) —
search + in-place preview + selected-track chip with remove
- Smart coupling: adding a song auto-switches format→Reel (a photo can't carry audio
as a feed post); picking a reel VIDEO also sets format→Reel
- Contextual hint: a still photo + song = a video → assemble here, then 'build in
Reel Studio' (#reels) which renders the photo over the track
- format + song carried through /api/channels/publish and persisted on the outbox
entry (additive, like handles) so the recipe survives for review/render
Verified: 4 format pills, 30-track picker, song→auto-Reel, chip renders; backend
round-trip persists format=reel + song on the outbox; no page errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
modules/channels/index.js | 10 ++++--
public/panels/composer.html | 48 +++++++++++++++++++++++++
public/panels/composer.js | 86 ++++++++++++++++++++++++++++++++++++++++++++-
3 files changed, 141 insertions(+), 3 deletions(-)
diff --git a/modules/channels/index.js b/modules/channels/index.js
index 9e4a356..8b45fe0 100644
--- a/modules/channels/index.js
+++ b/modules/channels/index.js
@@ -1016,6 +1016,12 @@ module.exports = {
// DW-owned IG accounts (@handles) this post targets — recorded on the outbox
// entry so the selection is durable + the Board can filter a stream by account.
const handles = Array.isArray(d.handles) ? d.handles.map(s => String(s).replace(/^@/, '')).filter(Boolean).slice(0, 50) : [];
+ // Assembled format decision (post/reel/story/carousel) + attached song, recorded
+ // on the outbox entry so the recipe survives for review / Reel-Studio render.
+ const FORMATS = ['post', 'reel', 'story', 'carousel'];
+ const format = FORMATS.includes(String(d.format)) ? String(d.format) : 'post';
+ const song = (d.song && d.song.url && /^https?:\/\//i.test(d.song.url))
+ ? { name: String(d.song.name || 'Track').slice(0, 200), url: String(d.song.url).slice(0, 800) } : undefined;
const content = { caption: (d.caption || '').slice(0, 2200), mediaUrl: d.mediaUrl || '', videoUrl: d.videoUrl || '' };
if (!channels.length) return res.status(400).json({ error: 'no channels selected' });
if (!content.caption && !content.mediaUrl) return res.status(400).json({ error: 'empty post' });
@@ -1049,12 +1055,12 @@ module.exports = {
if (live && !block) {
const r = await ADAPTERS[ch](content, { pages: selectedPages }).catch(e => [{ ok: false, error: e.message }]);
const ok = r.length > 0 && r.every(x => x.ok);
- outbox.push({ id: 'pub-' + ch + '-' + outbox.length, channel: ch, caption: content.caption, mediaUrl: content.mediaUrl, pages: selectedPages.length || undefined, handles: handles.length ? handles : undefined, status: ok ? 'posted' : 'failed', detail: r, at: new Date().toISOString() });
+ outbox.push({ id: 'pub-' + ch + '-' + outbox.length, channel: ch, caption: content.caption, mediaUrl: content.mediaUrl, pages: selectedPages.length || undefined, handles: handles.length ? handles : undefined, format, song, status: ok ? 'posted' : 'failed', detail: r, at: new Date().toISOString() });
results.push({ channel: ch, live: true, ok, detail: r });
} else {
const reason = block ? `skipped — ${block}`
: (d.dryRun !== false ? 'staged (dry-run)' : 'staged (needs confirm)');
- outbox.push({ id: 'stg-' + ch + '-' + outbox.length, channel: ch, caption: content.caption, mediaUrl: content.mediaUrl, handles: handles.length ? handles : undefined, status: reason, at: new Date().toISOString() });
+ outbox.push({ id: 'stg-' + ch + '-' + outbox.length, channel: ch, caption: content.caption, mediaUrl: content.mediaUrl, handles: handles.length ? handles : undefined, format, song, status: reason, at: new Date().toISOString() });
results.push({ channel: ch, live: false, skipped: !!block, staged: true, reason });
}
}
diff --git a/public/panels/composer.html b/public/panels/composer.html
index be994f4..22f1734 100644
--- a/public/panels/composer.html
+++ b/public/panels/composer.html
@@ -38,6 +38,32 @@
</div>
<textarea id="cmp-caption" class="cmp-caption" placeholder="Write your caption — or hit ✨ Generate copy. #designerwallcoverings"></textarea>
</div>
+
+ <!-- Decide the format + add a song: photo → details → song → reel-or-what, all here -->
+ <div class="cmp-make">
+ <div class="cmp-make-row">
+ <span class="cmp-make-lbl">Format</span>
+ <div class="cmp-fmts" id="cmp-fmts">
+ <button type="button" class="cmp-fmt on" data-fmt="post">📱 Feed post</button>
+ <button type="button" class="cmp-fmt" data-fmt="reel">🎬 Reel</button>
+ <button type="button" class="cmp-fmt" data-fmt="story">📖 Story</button>
+ <button type="button" class="cmp-fmt" data-fmt="carousel">🎠 Carousel</button>
+ </div>
+ </div>
+ <div class="cmp-make-row">
+ <span class="cmp-make-lbl">Song</span>
+ <div style="flex:1;min-width:0">
+ <div id="cmp-song-sel" class="cmp-song-sel" style="display:none"></div>
+ <button type="button" class="btn ghost" id="cmp-song-open" style="font-size:12px;padding:5px 10px">🎵 Add a song</button>
+ <div id="cmp-song-pick" class="cmp-song-pick" style="display:none">
+ <input id="cmp-song-search" class="cmp-search" placeholder="Search music…" style="margin-bottom:6px">
+ <div id="cmp-song-list" class="cmp-song-list"><div class="loading">Loading…</div></div>
+ <audio id="cmp-song-audio" style="width:100%;margin-top:6px;display:none" controls preload="none"></audio>
+ </div>
+ </div>
+ </div>
+ <div id="cmp-make-hint" class="cmp-make-hint muted"></div>
+ </div>
</section>
<!-- RIGHT: publish targets + fire -->
@@ -137,6 +163,28 @@
.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}
+ /* Format + song block — the "decide if reel or what" step */
+ .cmp-make{border:1px solid var(--line,#ece6da);border-radius:12px;padding:11px;background:#fbfaf6;display:flex;flex-direction:column;gap:9px}
+ .cmp-make-row{display:flex;align-items:flex-start;gap:10px}
+ .cmp-make-lbl{font-size:11.5px;font-weight:700;color:var(--mut,#8a8170);min-width:52px;padding-top:6px}
+ .cmp-fmts{display:flex;gap:6px;flex-wrap:wrap;flex:1}
+ .cmp-fmt{font-size:12px;padding:6px 11px;border:1px solid var(--line,#e7e1d6);background:#fff;border-radius:999px;
+ cursor:pointer;color:var(--mut,#8a8170);font-weight:600;white-space:nowrap}
+ .cmp-fmt:hover{background:#f3eee3}
+ .cmp-fmt.on{background:var(--ink,#2a2620);color:#fff;border-color:var(--ink,#2a2620)}
+ .cmp-song-sel{display:flex;align-items:center;gap:8px;font-size:12.5px;padding:6px 9px;border:1px solid #d8cfb8;
+ border-radius:9px;background:#fff8ec;margin-bottom:4px}
+ .cmp-song-sel .nm{flex:1;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+ .cmp-song-sel .x{cursor:pointer;color:#9a5a5a;font-weight:700;border:0;background:transparent;font-size:14px;padding:0 2px}
+ .cmp-song-pick{margin-top:6px;border:1px solid var(--line,#e7e1d6);border-radius:9px;padding:7px;background:#fff}
+ .cmp-song-list{max-height:150px;overflow-y:auto;display:flex;flex-direction:column;gap:2px}
+ .cmp-song-row{display:flex;align-items:center;gap:8px;font-size:12px;padding:5px 7px;border-radius:7px;cursor:pointer}
+ .cmp-song-row:hover{background:#f4efe3}
+ .cmp-song-row .nm{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+ .cmp-song-row .pv{font-size:13px;color:var(--mut,#8a8170);border:0;background:transparent;cursor:pointer}
+ .cmp-song-row .sec{font-size:9.5px;color:#a49a86}
+ .cmp-make-hint{font-size:11px;line-height:1.4;min-height:2px}
+ .cmp-make-hint a{color:var(--gold,#8a6d2f);font-weight:600}
.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);
diff --git a/public/panels/composer.js b/public/panels/composer.js
index 5763a7d..9f51b82 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: '', videoUrl: '', src: 'assets', selected: new Set(), pages: new Set(), handles: new Set() };
+ const state = { mediaUrl: '', videoUrl: '', src: 'assets', selected: new Set(), pages: new Set(), handles: new Set(), format: 'post', song: null };
const IG_LS_KEY = 'mccComposerIgHandles'; // persist the account selection across reloads
const NEW_ARRIVALS = 'designerwallcoverings.com/collections/new-arrivals'; // bare → clickable via facets
@@ -181,6 +181,7 @@ window.MCC_PANELS['composer'] = {
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';
+ if (typeof updateMakeHint === 'function') updateMakeHint(); // photo chosen → refresh reel-or-what hint
}
// Pick a reel → the post carries its public video URL (videoUrl), not a still.
function selectReel(el, r) {
@@ -193,6 +194,8 @@ window.MCC_PANELS['composer'] = {
$('#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(); }
+ if (state.format === 'post') setFormat('reel'); // a reel video ⇒ format is Reel
+ else updateMakeHint();
}
// Render a Layouts template LIVE into the preview via /api/layouts/render (returns HTML).
async function renderBanner(templateId, el, aspect) {
@@ -240,6 +243,81 @@ window.MCC_PANELS['composer'] = {
$('#cmp-gen').disabled = false; $('#cmp-gen').textContent = '✨ Generate copy';
});
+ // ── FORMAT + SONG: "decide if reel or what", and attach a track ────────────
+ // Photo → details (above) → song → format, all assembled here before publish.
+ function setFormat(fmt) {
+ state.format = fmt;
+ root.querySelectorAll('.cmp-fmt').forEach(b => b.classList.toggle('on', b.dataset.fmt === fmt));
+ updateMakeHint();
+ }
+ root.querySelectorAll('.cmp-fmt').forEach(b => b.addEventListener('click', () => setFormat(b.dataset.fmt)));
+
+ function updateMakeHint() {
+ const h = $('#cmp-make-hint'); if (!h) return;
+ // A still photo + a song = a reel that has to be RENDERED (photo over music →
+ // mp4). The composer assembles the recipe; Reel Studio produces the video.
+ if (state.song && (state.format === 'reel' || state.format === 'story') && state.mediaUrl && !state.videoUrl) {
+ h.innerHTML = `🎬 Photo + song → this is a video. Assemble it here, then <a href="#reels">build it in Reel Studio</a> (renders the photo over the track).`;
+ } else if (state.song && state.format === 'post') {
+ h.innerHTML = `🎵 A feed photo can’t carry audio — a song makes it a <b>Reel</b> or <b>Story</b>. Switched the format for you.`;
+ } else if (state.format === 'reel' && !state.song && !state.videoUrl) {
+ h.textContent = 'Tip: add a song for the reel, or pick a ready-made reel video on the left.';
+ } else { h.textContent = ''; }
+ }
+
+ function renderSong() {
+ const sel = $('#cmp-song-sel'), open = $('#cmp-song-open');
+ if (state.song) {
+ sel.style.display = 'flex';
+ sel.innerHTML = `<span>🎵</span><span class="nm" title="${esc(state.song.name)}">${esc(state.song.name)}</span>` +
+ `<button type="button" class="pv" title="Preview">▶</button><button type="button" class="x" title="Remove song">×</button>`;
+ sel.querySelector('.x').onclick = () => { state.song = null; renderSong(); updateMakeHint(); };
+ sel.querySelector('.pv').onclick = () => { const a = $('#cmp-song-audio'); a.src = state.song.url; a.style.display = 'block'; a.play().catch(() => {}); };
+ open.textContent = '🎵 Change song';
+ } else {
+ sel.style.display = 'none'; sel.innerHTML = '';
+ open.textContent = '🎵 Add a song';
+ }
+ }
+
+ let SONGS = null;
+ async function loadSongs() {
+ const list = $('#cmp-song-list'); if (!list) return;
+ if (!SONGS) {
+ const d = await jget('/api/sounds/catalog');
+ // flatten grouped sections → {name, url, sec}; keep only playable rows (a url)
+ const secs = (d && (d.sections || d.catalog || [])) || [];
+ SONGS = [];
+ secs.forEach(s => (s.items || []).forEach(it => { if (it && it.url) SONGS.push({ name: it.name || it.title || 'Track', url: it.url, sec: s.title || '' }); }));
+ }
+ if (!SONGS.length) { list.innerHTML = '<div class="bd-empty" style="font-size:11.5px">No tracks in the Sounds library yet.</div>'; return; }
+ list.innerHTML = '';
+ SONGS.forEach(t => {
+ const row = document.createElement('div'); row.className = 'cmp-song-row';
+ row.innerHTML = `<button type="button" class="pv" title="Preview">▶</button><span class="nm" title="${esc(t.name)}">${esc(t.name)}</span><span class="sec">${esc(t.sec)}</span>`;
+ row.querySelector('.pv').onclick = (e) => { e.stopPropagation(); const a = $('#cmp-song-audio'); a.src = t.url; a.style.display = 'block'; a.play().catch(() => {}); };
+ row.onclick = () => { // pick this song
+ state.song = { name: t.name, url: t.url };
+ if (state.format === 'post') setFormat('reel'); // a song ⇒ this is a reel, not a feed post
+ $('#cmp-song-pick').style.display = 'none';
+ renderSong(); updateMakeHint();
+ };
+ list.appendChild(row);
+ });
+ }
+ $('#cmp-song-open').addEventListener('click', () => {
+ const pick = $('#cmp-song-pick');
+ const showing = pick.style.display !== 'none';
+ pick.style.display = showing ? 'none' : 'block';
+ if (!showing) loadSongs();
+ });
+ $('#cmp-song-search').addEventListener('input', e => {
+ const q = e.target.value.toLowerCase();
+ root.querySelectorAll('#cmp-song-list .cmp-song-row').forEach(r => {
+ r.style.display = ((r.querySelector('.nm') || {}).textContent || '').toLowerCase().includes(q) ? '' : 'none';
+ });
+ });
+
// ── PUBLISH ────────────────────────────────────────────────────────────────
$('#cmp-publish').addEventListener('click', async () => {
const out = $('#cmp-result'); out.innerHTML = '';
@@ -259,6 +337,11 @@ window.MCC_PANELS['composer'] = {
// the existing gated publish/stage flow is unchanged; this just carries the
// user's per-account selection through to the server.
handles: [...state.handles],
+ // The assembled format decision + attached song (name+url). Additive metadata,
+ // recorded on the staged/outbox entry so the recipe (photo + song + format) is
+ // captured for review / Reel-Studio render.
+ format: state.format,
+ song: state.song || undefined,
confirm: !dry, dryRun: dry,
});
if (r && r.results) {
@@ -284,6 +367,7 @@ window.MCC_PANELS['composer'] = {
await Promise.all([loadTargets(), loadSource('assets'), loadIgAccounts()]);
countIt();
+ renderSong(); updateMakeHint();
// Quick Post — easy per-platform "stage a draft" buttons (gated). Reads the
// live caption + whichever media (reel video or image) is selected.
← 4a911a5 assets: background catalog warmer (boot + 6h) + manual refre
·
back to Marketing Command Center
·
Add /posts/refresh-one endpoint: fresh IG image on 'Make it 796eba1 →