← 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 =>
({ '&': '&', '<': '<', '>': '>', '"': '"' }[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();
},
};