[object Object]

← back to Marketing Command Center

Sounds: drag songs (or tap +) into a social-selection bar — chips, dedupe, remove, Copy list, localStorage-persisted (v1.8.3)

13b1209ff5b5bc9b1f80222bd121f1f52f1f9f14 · 2026-08-12 12:31:41 -0700 · Steve

Files touched

Diff

commit 13b1209ff5b5bc9b1f80222bd121f1f52f1f9f14
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Aug 12 12:31:41 2026 -0700

    Sounds: drag songs (or tap +) into a social-selection bar — chips, dedupe, remove, Copy list, localStorage-persisted (v1.8.3)
---
 package.json              |   2 +-
 public/panels/sounds.html |  47 ++++++++++++++++++++-
 public/panels/sounds.js   | 101 +++++++++++++++++++++++++++++++++++++++++++---
 3 files changed, 143 insertions(+), 7 deletions(-)

diff --git a/package.json b/package.json
index 5235d7f..42fe2ec 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "marketing-command-center",
-  "version": "1.8.2",
+  "version": "1.8.3",
   "description": "DW Marketing Command Center — Constant Contact, marketing calendar, suggested copy, on-demand layouts",
   "main": "server.js",
   "scripts": {
diff --git a/public/panels/sounds.html b/public/panels/sounds.html
index 01c5345..0043cc1 100644
--- a/public/panels/sounds.html
+++ b/public/panels/sounds.html
@@ -1,6 +1,22 @@
 <div class="muted-banner" id="snd-banner">Loading sounds &amp; assets…</div>
 <div class="muted snd-legend" id="snd-legend" style="font-size:12px;margin:0 0 12px;line-height:1.6"></div>
 
+<!-- social-selection bar: drag songs here (or tap +) to queue them for a post -->
+<div class="snd-tray" id="snd-tray" aria-label="Social selection bar">
+  <div class="snd-tray-lead">
+    <span class="snd-tray-ico">🎵</span>
+    <span class="snd-tray-ttl">Social selection</span>
+    <span class="snd-tray-ct" id="snd-tray-ct">0</span>
+  </div>
+  <div class="snd-tray-drop" id="snd-tray-drop">
+    <span class="snd-tray-hint">Drag songs here — or tap + — to queue them for a social post.</span>
+  </div>
+  <div class="snd-tray-act">
+    <button type="button" class="snd-tray-btn" id="snd-tray-copy">Copy list</button>
+    <button type="button" class="snd-tray-btn ghost" id="snd-tray-clear">Clear</button>
+  </div>
+</div>
+
 <div class="snd-split">
   <!-- LEFT: every asset lives in its own panel here -->
   <aside class="snd-left">
@@ -21,6 +37,28 @@
 .snd-split{display:grid;grid-template-columns:300px 1fr;gap:16px;align-items:start}
 @media(max-width:900px){.snd-split{grid-template-columns:1fr}}
 
+/* social-selection bar */
+.snd-tray{display:flex;align-items:center;gap:12px;flex-wrap:wrap;border:1px solid var(--line);border-radius:12px;
+ background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.04);padding:10px 12px;margin:0 0 14px}
+.snd-tray-lead{display:flex;align-items:center;gap:8px;flex:none}
+.snd-tray-ico{font-size:16px}
+.snd-tray-ttl{font:600 13px/1 Inter;color:var(--ink)}
+.snd-tray-ct{font:700 10px/1 Inter;background:var(--accent);color:#fff;border-radius:999px;padding:3px 8px;min-width:16px;text-align:center}
+.snd-tray-drop{flex:1;min-width:200px;min-height:40px;display:flex;align-items:center;gap:6px;flex-wrap:wrap;
+ border:1.5px dashed var(--line);border-radius:10px;padding:6px 10px;transition:border-color .12s,background .12s}
+.snd-tray-drop.over{border-color:var(--accent);background:var(--cream)}
+.snd-tray-hint{font-size:12px;color:var(--mut)}
+.snd-chip{display:inline-flex;align-items:center;gap:6px;background:var(--paper);border:1px solid var(--line);
+ border-radius:999px;padding:4px 6px 4px 10px;font:600 12px/1 Inter;color:var(--ink);max-width:220px}
+.snd-chip .cn{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.snd-chip .dot{width:8px;height:8px;border-radius:50%;flex:none}
+.snd-chip .x{border:0;background:transparent;cursor:pointer;color:var(--mut);font:700 15px/1 Inter;padding:0 2px}
+.snd-chip .x:hover{color:var(--accent)}
+.snd-tray-act{display:flex;gap:6px;flex:none}
+.snd-tray-btn{border:1px solid var(--line);border-radius:8px;background:#fff;cursor:pointer;font:600 11.5px/1 Inter;color:var(--ink);padding:8px 11px}
+.snd-tray-btn:hover{background:var(--cream)}
+.snd-tray-btn.ghost{color:var(--mut)}
+
 /* left panel */
 .snd-left{border:1px solid var(--line);border-radius:12px;background:#fff;
  box-shadow:0 1px 3px rgba(0,0,0,.04);display:flex;flex-direction:column;
@@ -42,10 +80,17 @@
 
 /* an asset = a row, NOT a link — selecting previews it in-panel */
 .snd-item{width:100%;text-align:left;border:1px solid var(--line);border-radius:10px;padding:9px 11px;background:#fff;
- display:flex;flex-direction:column;gap:4px;cursor:pointer;margin-bottom:6px;transition:border-color .12s,background .12s}
+ display:flex;flex-direction:column;gap:4px;cursor:grab;margin-bottom:6px;transition:border-color .12s,background .12s,opacity .12s}
 .snd-item:hover{border-color:var(--accent);background:var(--paper)}
+.snd-item:active{cursor:grabbing}
+.snd-item.dragging{opacity:.5}
+.snd-item.queued{opacity:.6}
 .snd-item.on{border-color:var(--accent);background:var(--cream);box-shadow:inset 3px 0 0 var(--accent)}
 .snd-item .st{display:flex;align-items:center;gap:8px}
+.snd-add{flex:none;width:24px;height:24px;padding:0;border:1px solid var(--line);border-radius:7px;background:#fff;
+ cursor:pointer;font:700 14px/1 Inter;color:var(--accent)}
+.snd-add:hover{background:var(--cream)}
+.snd-add.on{background:var(--accent);color:#fff;border-color:var(--accent)}
 .snd-item .nm{font:600 13.5px/1.2 Inter;flex:1;min-width:0}
 .snd-item .nt{font-size:11.5px;line-height:1.4;color:#5a544b;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
 .snd-item .bdg,.snd-vhead .bdg{font:700 9px/1 Inter;border-radius:999px;padding:3px 8px;white-space:nowrap}
diff --git a/public/panels/sounds.js b/public/panels/sounds.js
index a1b35e3..8b8bc51 100644
--- a/public/panels/sounds.js
+++ b/public/panels/sounds.js
@@ -12,7 +12,13 @@ window.MCC_PANELS['sounds'] = {
       ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
     const $ = sel => root.querySelector(sel);
 
-    const state = { catalog: [], badges: {}, q: '', badge: 'all', selKey: null };
+    // social-selection bar — persisted list of songs queued for a post
+    const LS_KEY = 'mccSndSocial';
+    const DOT = { brandsafe: '#2f6b34', cc: '#33578a', listen: '#8a6a1e', tiktok: '#7a3a8a' };
+    const loadSel = () => { try { return JSON.parse(localStorage.getItem(LS_KEY)) || []; } catch (_) { return []; } };
+    const saveSel = () => { try { localStorage.setItem(LS_KEY, JSON.stringify(state.sel)); } catch (_) {} };
+
+    const state = { catalog: [], badges: {}, q: '', badge: 'all', selKey: null, sel: loadSel() };
 
     // stable key for an item so selection survives re-renders (filtering)
     const keyOf = it => (it.name || '') + '' + (it.url || '');
@@ -29,6 +35,11 @@ window.MCC_PANELS['sounds'] = {
       renderLegend();
       renderBadgeFilter();
       renderSections();
+      // drop any queued songs that are no longer in the catalog, then paint the bar
+      const keys = new Set();
+      for (const sec of state.catalog) for (const it of sec.items) keys.add(keyOf(it));
+      state.sel = state.sel.filter(s => keys.has(s.k));
+      saveSel(); renderTray(); markQueued();
     }
 
     function renderLegend() {
@@ -69,10 +80,25 @@ window.MCC_PANELS['sounds'] = {
         </div>`;
       }
       box.innerHTML = shown ? html : '<div class="snd-empty">No sources match that filter.</div>';
-      box.querySelectorAll('.snd-item').forEach(el => el.onclick = () => {
+      box.querySelectorAll('.snd-item').forEach(el => {
         const k = el.getAttribute('data-k');
-        const found = findByKey(k);
-        if (found) select(found.item, found.section);
+        // click row (but not the + button) → preview
+        el.onclick = e => {
+          if (e.target.closest('.snd-add')) return;
+          const found = findByKey(k);
+          if (found) select(found.item, found.section);
+        };
+        // drag row → social bar
+        el.addEventListener('dragstart', e => {
+          e.dataTransfer.setData('text/plain', k);
+          e.dataTransfer.effectAllowed = 'copy';
+          el.classList.add('dragging');
+        });
+        el.addEventListener('dragend', () => el.classList.remove('dragging'));
+      });
+      box.querySelectorAll('.snd-add').forEach(btn => btn.onclick = e => {
+        e.stopPropagation();
+        addToSel(btn.getAttribute('data-k'));
       });
     }
 
@@ -87,10 +113,13 @@ window.MCC_PANELS['sounds'] = {
       const b = state.badges[it.badge] || { label: it.badge };
       const k = keyOf(it);
       const on = state.selKey === k ? ' on' : '';
-      return `<div class="snd-item${on}" data-k="${esc(k)}" role="button" tabindex="0">
+      const queued = state.sel.some(s => s.k === k);
+      return `<div class="snd-item${on}${queued ? ' queued' : ''}" data-k="${esc(k)}" draggable="true" role="button" tabindex="0">
         <div class="st">
           <span class="nm">${esc(it.name)}</span>
           <span class="bdg bdg-${esc(it.badge)}">${esc(b.label)}</span>
+          <button type="button" class="snd-add${queued ? ' on' : ''}" data-k="${esc(k)}"
+                  title="${queued ? 'Queued for social' : 'Add to social selection'}">${queued ? '✓' : '+'}</button>
         </div>
         ${it.note ? `<div class="nt">${esc(it.note)}</div>` : ''}
       </div>`;
@@ -134,6 +163,66 @@ window.MCC_PANELS['sounds'] = {
       };
     }
 
+    // ── social-selection bar ────────────────────────────────────────────────
+    function addToSel(k) {
+      if (state.sel.some(s => s.k === k)) return;      // dedupe
+      const found = findByKey(k);
+      if (!found) return;
+      const it = found.item;
+      state.sel.push({ k, name: it.name, url: it.url, badge: it.badge });
+      saveSel(); renderTray(); markQueued();
+    }
+    function removeFromSel(k) {
+      state.sel = state.sel.filter(s => s.k !== k);
+      saveSel(); renderTray(); markQueued();
+    }
+    // reflect queued state on the left rows without a full re-render
+    function markQueued() {
+      root.querySelectorAll('.snd-item').forEach(el => {
+        const q = state.sel.some(s => s.k === el.getAttribute('data-k'));
+        el.classList.toggle('queued', q);
+        const add = el.querySelector('.snd-add');
+        if (add) { add.classList.toggle('on', q); add.textContent = q ? '✓' : '+';
+          add.title = q ? 'Queued for social' : 'Add to social selection'; }
+      });
+    }
+    function renderTray() {
+      $('#snd-tray-ct').textContent = state.sel.length;
+      const drop = $('#snd-tray-drop');
+      if (!state.sel.length) {
+        drop.innerHTML = '<span class="snd-tray-hint">Drag songs here — or tap + — to queue them for a social post.</span>';
+        return;
+      }
+      drop.innerHTML = state.sel.map(s =>
+        `<span class="snd-chip" title="${esc(s.url)}">
+           <span class="dot" style="background:${DOT[s.badge] || '#8a8275'}"></span>
+           <span class="cn">${esc(s.name)}</span>
+           <button type="button" class="x" data-k="${esc(s.k)}" title="Remove">×</button>
+         </span>`).join('');
+      drop.querySelectorAll('.snd-chip .x').forEach(x =>
+        x.onclick = () => removeFromSel(x.getAttribute('data-k')));
+    }
+    function setupTray() {
+      const drop = $('#snd-tray-drop');
+      drop.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; drop.classList.add('over'); });
+      drop.addEventListener('dragleave', () => drop.classList.remove('over'));
+      drop.addEventListener('drop', e => {
+        e.preventDefault(); drop.classList.remove('over');
+        const k = e.dataTransfer.getData('text/plain');
+        if (k) addToSel(k);
+      });
+      $('#snd-tray-clear').onclick = () => { state.sel = []; saveSel(); renderTray(); markQueued(); };
+      $('#snd-tray-copy').onclick = () => {
+        const btn = $('#snd-tray-copy');
+        if (!state.sel.length) { btn.textContent = 'Nothing queued'; setTimeout(() => (btn.textContent = 'Copy list'), 1400); return; }
+        const text = state.sel.map(s => `${s.name} — ${s.url}`).join('\n');
+        navigator.clipboard.writeText(text)
+          .then(() => btn.textContent = `Copied ${state.sel.length} ✓`)
+          .catch(() => btn.textContent = 'Copy failed');
+        setTimeout(() => (btn.textContent = 'Copy list'), 1400);
+      };
+    }
+
     $('#snd-search').addEventListener('input', e => { state.q = e.target.value; renderSections(); });
 
     // keyboard: Enter/Space selects the focused asset row
@@ -143,6 +232,8 @@ window.MCC_PANELS['sounds'] = {
       }
     });
 
+    setupTray();
+    renderTray();
     load();
   },
 };

← 488e005 chore: refactor (drop unused param), v1.8.2 (session close)  ·  back to Marketing Command Center  ·  deploy: social-selection bar live on marketing.dw #sounds (v 985e409 →