← back to Marketing Command Center

public/panels/sounds.js

240 lines

// Sounds & Assets panel — free music + TikTok creative sources, badged by
// brand-safety. Two-column master-detail: every asset lives in its own LEFT
// panel (search + badge facets + grouped list); selecting one PREVIEWS it in
// an in-panel iframe on the right. Deliberately does NOT link out — no
// target="_blank" anchors; the source URL is shown as copyable text only.
// Data from /api/sounds/catalog.
window.MCC_PANELS = window.MCC_PANELS || {};
window.MCC_PANELS['sounds'] = {
  init(root) {
    const ORIGIN = location.origin;
    const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
      ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
    const $ = sel => root.querySelector(sel);

    // 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 || '');

    async function load() {
      let d;
      try { d = await fetch(ORIGIN + '/api/sounds/catalog', { credentials: 'same-origin' }).then(r => r.json()); }
      catch (_) { $('#snd-banner').textContent = 'Could not load the catalog.'; return; }
      state.catalog = d.catalog || [];
      state.badges = d.badges || {};
      const total = state.catalog.reduce((n, s) => n + s.items.length, 0);
      $('#snd-banner').innerHTML = `<b>${total}</b> free / brand-safe sources across ${state.catalog.length} groups. ` +
        `Filter on the left, then click an asset to preview it here — nothing opens a new tab.`;
      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() {
      $('#snd-legend').innerHTML = Object.values(state.badges)
        .map(b => `<span style="margin-right:16px"><b>${esc(b.label)}</b> — ${esc(b.desc)}</span>`).join('');
    }

    function renderBadgeFilter() {
      const box = $('#snd-badgefilter');
      const keys = ['all', ...Object.keys(state.badges)];
      box.innerHTML = keys.map(k => {
        const label = k === 'all' ? 'All' : (state.badges[k] ? state.badges[k].label : k);
        return `<button type="button" class="snd-bf${state.badge === k ? ' on' : ''}" data-b="${esc(k)}">${esc(label)}</button>`;
      }).join('');
      box.querySelectorAll('.snd-bf').forEach(b => b.onclick = () => {
        state.badge = b.getAttribute('data-b'); renderBadgeFilter(); renderSections();
      });
    }

    function match(item) {
      if (state.badge !== 'all' && item.badge !== state.badge) return false;
      const q = state.q.trim().toLowerCase();
      if (q && !((item.name || '').toLowerCase().includes(q) || (item.note || '').toLowerCase().includes(q))) return false;
      return true;
    }

    function renderSections() {
      const box = $('#snd-sections');
      let html = '';
      let shown = 0;
      for (const sec of state.catalog) {
        const items = sec.items.filter(match);
        if (!items.length) continue;
        shown += items.length;
        html += `<div class="snd-sec">
          <div class="snd-sechead"><h2>${esc(sec.title)}</h2><span class="ct">${items.length}</span></div>
          ${items.map(itemHTML).join('')}
        </div>`;
      }
      box.innerHTML = shown ? html : '<div class="snd-empty">No sources match that filter.</div>';
      box.querySelectorAll('.snd-item').forEach(el => {
        const k = el.getAttribute('data-k');
        // 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'));
      });
    }

    function findByKey(k) {
      for (const sec of state.catalog) {
        for (const it of sec.items) if (keyOf(it) === k) return { item: it, section: sec.title };
      }
      return null;
    }

    function itemHTML(it) {
      const b = state.badges[it.badge] || { label: it.badge };
      const k = keyOf(it);
      const on = state.selKey === k ? ' on' : '';
      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>`;
    }

    // Preview the asset IN-PANEL. No navigation, no new tab — the source loads
    // in an iframe; if it refuses to embed (X-Frame-Options), the header card
    // + copyable URL keep the panel useful.
    function select(it, secTitle) {
      state.selKey = keyOf(it);
      // repaint left rows so the active one highlights
      root.querySelectorAll('.snd-item').forEach(el =>
        el.classList.toggle('on', el.getAttribute('data-k') === state.selKey));

      const b = state.badges[it.badge] || { label: it.badge };
      const view = $('#snd-view');
      view.innerHTML = `
        <div class="snd-vhead">
          <div class="top">
            <h2>${esc(it.name)}</h2>
            <span class="bdg bdg-${esc(it.badge)}">${esc(b.label)}</span>
          </div>
          <div class="sec">${esc(secTitle || '')}</div>
          ${it.note ? `<div class="nt">${esc(it.note)}</div>` : ''}
          <div class="snd-urlrow">
            <div class="snd-url" title="${esc(it.url)}">${esc(it.url)}</div>
            <button type="button" class="snd-copy" data-url="${esc(it.url)}">Copy URL</button>
          </div>
        </div>
        <div class="snd-frame-wrap">
          <iframe src="${esc(it.url)}" title="${esc(it.name)} preview"
                  loading="lazy" referrerpolicy="no-referrer"></iframe>
          <div class="snd-blockmsg">Preview loads in-panel. If it stays blank, this source blocks embedding — use Copy URL.</div>
        </div>`;

      const copy = view.querySelector('.snd-copy');
      copy.onclick = async () => {
        try { await navigator.clipboard.writeText(copy.getAttribute('data-url')); copy.textContent = 'Copied ✓'; }
        catch (_) { copy.textContent = 'Copy failed'; }
        setTimeout(() => (copy.textContent = 'Copy URL'), 1400);
      };
    }

    // ── 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
    $('#snd-sections').addEventListener('keydown', e => {
      if ((e.key === 'Enter' || e.key === ' ') && e.target.classList.contains('snd-item')) {
        e.preventDefault(); e.target.click();
      }
    });

    setupTray();
    renderTray();
    load();
  },
};