← back to Marketing Command Center
Add Sounds & Assets panel: curated free/royalty-free music + TikTok assets, brand-safety badged (Grateful Dead archive listen-only)
8bb0bc5d21b4ead8b07548cd7062db57e4d74b1b · 2026-07-14 12:49:58 -0700 · Steve
Files touched
M modules/channels/index.jsM modules/registry.jsA modules/sounds/index.jsM public/app.jsM public/panels/channels.jsA public/panels/sounds.htmlA public/panels/sounds.js
Diff
commit 8bb0bc5d21b4ead8b07548cd7062db57e4d74b1b
Author: Steve <steve@designerwallcoverings.com>
Date: Tue Jul 14 12:49:58 2026 -0700
Add Sounds & Assets panel: curated free/royalty-free music + TikTok assets, brand-safety badged (Grateful Dead archive listen-only)
---
modules/channels/index.js | 64 ++++++++++++++++++++++++++++++++---
modules/registry.js | 1 +
modules/sounds/index.js | 68 +++++++++++++++++++++++++++++++++++++
public/app.js | 2 +-
public/panels/channels.js | 13 ++++++--
public/panels/sounds.html | 39 ++++++++++++++++++++++
public/panels/sounds.js | 85 +++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 264 insertions(+), 8 deletions(-)
diff --git a/modules/channels/index.js b/modules/channels/index.js
index 20ca3b8..c69a918 100644
--- a/modules/channels/index.js
+++ b/modules/channels/index.js
@@ -271,6 +271,59 @@ function platformStatus() {
return base;
}
+// ── Meta token health (expiry-aware status) ───────────────────────────────────
+// platformStatus() is presence-only: a PRESENT-but-EXPIRED Meta token reads as
+// "connected" while every real post silently fails AND the reconnect button stays
+// hidden (connectUrl only shows when !connected). This adds a cached debug_token
+// check so an expired/invalid Meta token flips to needsReconnect → surfaces the
+// Connect button. FAIL-SAFE: a network blip or missing app creds returns ok:null
+// (unknown) and NEVER flips a possibly-good token to broken.
+let _metaHealth = null; // { token, at, v }
+const META_HEALTH_TTL = 10 * 60 * 1000; // 10 min — debug_token is free but rate-limited
+async function metaTokenHealth() {
+ const token = env('META_ACCESS_TOKEN');
+ if (!token) return { present: false, ok: false, reason: 'no token' };
+ const now = Date.now();
+ if (_metaHealth && _metaHealth.token === token && (now - _metaHealth.at) < META_HEALTH_TTL) return _metaHealth.v;
+ const appId = env('META_APP_ID'), secret = env('META_APP_SECRET');
+ if (!appId || !secret) { // can't verify without app creds → unknown, don't flip
+ const v = { present: true, ok: null, reason: 'no app id/secret to verify token' };
+ _metaHealth = { token, at: now, v }; return v;
+ }
+ try {
+ const r = await fetch(`${GRAPH}/debug_token?input_token=${encodeURIComponent(token)}&access_token=${encodeURIComponent(appId + '|' + secret)}`);
+ const j = await r.json().catch(() => ({}));
+ const d = j.data || {};
+ const v = {
+ present: true,
+ ok: d.is_valid === true,
+ expiresAt: d.expires_at ? d.expires_at * 1000 : null,
+ reason: d.error?.message || (d.is_valid === false ? 'token invalid/expired' : null),
+ };
+ _metaHealth = { token, at: now, v }; return v;
+ } catch (e) { // network failure → unknown, keep prior belief
+ const v = { present: true, ok: null, reason: 'validity check failed: ' + e.message };
+ _metaHealth = { token, at: now, v }; return v;
+ }
+}
+
+// Mutate the presence-based status with Meta token validity. Only acts when the
+// token is DEFINITIVELY invalid (ok === false); ok:null (unknown) is left alone.
+function augmentMetaHealth(st, health) {
+ for (const p of ['facebook', 'instagram']) {
+ if (!st[p] || !st[p].connected || !health || !health.present) continue;
+ if (health.ok === false) {
+ st[p].connected = false;
+ st[p].needsReconnect = true;
+ st[p].tokenError = health.reason || 'token expired — reconnect required';
+ st[p].connectUrl = st[p].configured ? `/api/channels/connect/${p}` : st[p].connectUrl;
+ } else if (health.ok === true && health.expiresAt) {
+ st[p].tokenExpiresAt = health.expiresAt; // lets the UI show "expires in N days"
+ }
+ }
+ return st;
+}
+
// ── Real adapters (fire only when connected) ──────────────────────────────────
async function postFacebook(content, opts = {}) {
// Two modes: (a) selected-pages — post to a chosen subset using each Page's OWN
@@ -400,8 +453,8 @@ module.exports = {
title: 'Channels & Publish',
icon: '📡',
mount(router) {
- router.get('/status', (_req, res) => {
- const st = platformStatus();
+ router.get('/status', async (_req, res) => {
+ const st = augmentMetaHealth(platformStatus(), await metaTokenHealth());
const connected = Object.values(st).filter(p => p.connected).length;
res.json({ platforms: st, connectedCount: connected, total: Object.keys(st).length, outbox: readOutbox().length });
});
@@ -421,8 +474,8 @@ module.exports = {
// Activate page data: provider cards + which keys are already set (booleans only,
// never the values) + the redirect URIs to register in each portal.
- router.get('/setup', (_req, res) => {
- const st = platformStatus();
+ router.get('/setup', async (_req, res) => {
+ const st = augmentMetaHealth(platformStatus(), await metaTokenHealth());
const providers = SETUP_PROVIDERS.map(p => ({
id: p.id, title: p.title, icon: p.icon, blurb: p.blurb,
portal: p.portal, portalLabel: p.portalLabel, permissions: p.permissions,
@@ -432,6 +485,9 @@ module.exports = {
id: pl, label: st[pl].label, connected: st[pl].connected,
configured: st[pl].configured, connectUrl: st[pl].connectUrl,
caveat: st[pl].caveat || null,
+ needsReconnect: st[pl].needsReconnect || false,
+ tokenError: st[pl].tokenError || null,
+ tokenExpiresAt: st[pl].tokenExpiresAt || null,
})),
}));
res.json({ redirectBase: redirectBase(), providers });
diff --git a/modules/registry.js b/modules/registry.js
index 40bf333..2581245 100644
--- a/modules/registry.js
+++ b/modules/registry.js
@@ -14,6 +14,7 @@ module.exports = [
'copy', // suggested copy: subject lines, email/social body (LLM-backed)
'layouts', // on-demand HTML/CSS email + social layout mockups
'assets', // image asset library: upload / URL / live DW-catalog pull
+ 'sounds', // Sounds & Assets: curated free/royalty-free music + TikTok creative sources, badged by brand-safety (incl. Grateful Dead live archive as listen-only)
'performance', // performance dashboard: email + GA4 KPIs, charts, top campaigns
'social', // social scheduler: IG/TikTok queue board, gated publish
'follow-counts', // Followers/Following: per-DW-IG-account followers_count + follows_count (COUNTS ONLY, official Graph API via Norma :9810) + daily-snapshot growth chart
diff --git a/modules/sounds/index.js b/modules/sounds/index.js
new file mode 100644
index 0000000..425afc4
--- /dev/null
+++ b/modules/sounds/index.js
@@ -0,0 +1,68 @@
+// Sounds & Assets — a curated, always-live directory of FREE music + TikTok
+// creative assets for making DW social content. Static link catalog (no scraping,
+// no keys) served to the panel; edit the CATALOG below to add/curate sources.
+//
+// Brand-safety is the whole point of the two buckets:
+// brandsafe → cleared/royalty-free to put UNDER a published DW post
+// listen → free to enjoy / inspiration only — NOT licensed for brand use
+// DW's socials are BUSINESS accounts, so a Grateful Dead live tape (free to hear)
+// still can't legally back a DW TikTok — only the platform's Commercial Music
+// Library or true royalty-free libraries can.
+
+const CATALOG = [
+ {
+ id: 'tiktok',
+ title: 'TikTok assets',
+ blurb: 'Trends, sounds, and the business-safe music library — start here when making a DW TikTok.',
+ items: [
+ { name: 'TikTok Creative Center', url: 'https://ads.tiktok.com/business/creativecenter/inspiration/popular/hashtag/pc/en', badge: 'tiktok', note: 'Top sounds, trending hashtags, trending videos by region/industry. Free, no ad account needed.' },
+ { name: 'TikTok Commercial Music Library (CML)', url: 'https://commercial-music.tiktok.com/', badge: 'brandsafe', note: 'The ONLY music cleared for business accounts on TikTok. Browse + preview; pick from here for DW posts.' },
+ { name: 'TikTok for Business — Creative Hub', url: 'https://www.tiktok.com/business/en/creative-center', badge: 'tiktok', note: 'Ad specs, creative best-practices, templates.' },
+ { name: 'CapCut (free editor + templates)', url: 'https://www.capcut.com/templates', badge: 'tiktok', note: 'TikTok-native editor; trend templates you drop DW photos/clips into.' },
+ ],
+ },
+ {
+ id: 'brandsafe',
+ title: 'Brand-safe music libraries',
+ blurb: 'Royalty-free / cleared for commercial social — safe to put behind a published DW post.',
+ items: [
+ { name: 'YouTube Audio Library', url: 'https://studio.youtube.com', badge: 'brandsafe', note: 'Free tracks + SFX, most usable commercially (check the per-track license). In Studio → Audio Library. Login required.' },
+ { name: 'Meta Sound Collection', url: 'https://business.facebook.com/creatorstudio/', badge: 'brandsafe', note: 'Free music/SFX cleared for Facebook + Instagram (incl. Reels). In Creator Studio → Sound Collection.' },
+ { name: 'Pixabay Music', url: 'https://pixabay.com/music/', badge: 'brandsafe', note: 'Free for commercial use, no attribution required. Big, clean catalog.' },
+ { name: 'Uppbeat', url: 'https://uppbeat.io/', badge: 'brandsafe', note: 'Free tier with a per-track credit code; built for creators/social.' },
+ { name: 'Chosic — Free Music', url: 'https://www.chosic.com/free-music/all/', badge: 'cc', note: 'Aggregates CC + royalty-free tracks with clear per-track license labels.' },
+ { name: 'Bensound', url: 'https://www.bensound.com/', badge: 'cc', note: 'Free with attribution (or license to drop it). Reliable background beds.' },
+ { name: 'Incompetech (Kevin MacLeod)', url: 'https://incompetech.com/music/royalty-free/music.html', badge: 'cc', note: 'Huge royalty-free library, CC-BY (credit Kevin MacLeod).' },
+ { name: 'Free Music Archive', url: 'https://freemusicarchive.org/', badge: 'cc', note: 'CC-licensed — filter to commercial-OK licenses before using on a DW post.' },
+ ],
+ },
+ {
+ id: 'listen',
+ title: 'Grateful Dead & live archives',
+ blurb: '🎧 Free to LISTEN — studio session fuel + inspiration. Not licensed to publish under a brand post.',
+ items: [
+ { name: 'Grateful Dead — Internet Archive', url: 'https://archive.org/details/GratefulDead', badge: 'listen', note: 'Thousands of full concerts the band let fans tape + share. Free to stream.' },
+ { name: 'Relisten (web + free app)', url: 'https://relisten.net/', badge: 'listen', note: 'Gapless player over the Archive.org Dead + jam-band tapes. Best listening experience.' },
+ { name: 'Live Music Archive (etree)', url: 'https://archive.org/details/etree', badge: 'listen', note: 'The broader taper-friendly collection — bands that allow free live sharing.' },
+ { name: 'SomaFM', url: 'https://somafm.com/', badge: 'listen', note: 'Free, ad-free curated internet radio (Groove Salad, Deep Space One) — great studio background.' },
+ { name: 'Musopen (public domain classical)', url: 'https://musopen.org/', badge: 'listen', note: 'Public-domain classical recordings — fully free to use, no license worries.' },
+ ],
+ },
+];
+
+module.exports = {
+ id: 'sounds',
+ title: 'Sounds & Assets',
+ icon: '🎵',
+ mount(router) {
+ router.get('/catalog', (_req, res) => res.json({
+ catalog: CATALOG,
+ badges: {
+ brandsafe: { label: '✅ Brand-safe', desc: 'Cleared / royalty-free for commercial social — OK to publish under a DW post.' },
+ cc: { label: '© CC / attribution', desc: 'Creative Commons — usually OK commercially WITH credit; check the per-track license.' },
+ listen: { label: '🎧 Listen / inspo', desc: 'Free to hear, NOT licensed for brand posts. Studio fuel + inspiration only.' },
+ tiktok: { label: '▶ TikTok', desc: 'TikTok-native creative resource.' },
+ },
+ }));
+ },
+};
diff --git a/public/app.js b/public/app.js
index 5a30805..9fd1826 100644
--- a/public/app.js
+++ b/public/app.js
@@ -10,7 +10,7 @@ let PANELS = [];
// Curated nav groups. Social Media leads. Any panel not listed falls into "More".
const GROUPS = [
{ name: 'Social Media', ids: ['accounts', 'board', 'social', 'channels', 'linkedin'] },
- { name: 'Content Studio', ids: ['copy', 'layouts', 'assets', 'templates'] },
+ { name: 'Content Studio', ids: ['copy', 'layouts', 'assets', 'sounds', 'templates'] },
{ name: 'Email', ids: ['constant-contact', 'send-times'] },
{ name: 'Audience', ids: ['segments', 'profiles', 'segment-perf'] },
{ name: 'Automation', ids: ['journeys', 'browse-abandon', 'playbook'] },
diff --git a/public/panels/channels.js b/public/panels/channels.js
index af3201e..0a31d4e 100644
--- a/public/panels/channels.js
+++ b/public/panels/channels.js
@@ -92,8 +92,15 @@ window.MCC_PANELS['channels'] = {
} catch { $('#ch-hosthint').innerHTML = ''; }
$('#ch-setup').innerHTML = d.providers.map(p => {
- const statusPills = p.platforms.map(pl =>
- `<span class="pill" style="background:${pl.connected ? '#e3efe0' : pl.configured ? '#eef3f8' : '#f3e9e9'};color:${pl.connected ? '#3a6b3a' : pl.configured ? '#3a5a8a' : '#9a5a5a'}">${esc(pl.label)}: ${pl.connected ? 'connected' : pl.configured ? 'ready' : 'needs keys'}</span>`).join(' ');
+ const statusPills = p.platforms.map(pl => {
+ // needsReconnect (token present but expired/invalid) takes precedence over
+ // the presence-based states so a dead token reads amber, not a false "ready".
+ const s = pl.needsReconnect ? { bg: '#fff8ec', fg: '#7a5a2a', txt: 'reconnect needed' }
+ : pl.connected ? { bg: '#e3efe0', fg: '#3a6b3a', txt: 'connected' }
+ : pl.configured ? { bg: '#eef3f8', fg: '#3a5a8a', txt: 'ready' }
+ : { bg: '#f3e9e9', fg: '#9a5a5a', txt: 'needs keys' };
+ return `<span class="pill" style="background:${s.bg};color:${s.fg}"${pl.tokenError ? ` title="${esc(pl.tokenError)}"` : ''}>${esc(pl.label)}: ${s.txt}</span>`;
+ }).join(' ');
const redirects = p.redirectUris.map(r =>
`<div class="copyrow"><code>${esc(r.uri)}</code><button class="btn ghost xs" data-copy="${esc(r.uri)}">copy</button></div>`).join('');
const fields = p.fields.map(f =>
@@ -102,7 +109,7 @@ window.MCC_PANELS['channels'] = {
placeholder="${f.set ? '•••••• saved — leave blank to keep' : esc(f.ph || '')}"></div>`).join('');
const connects = p.platforms.map(pl =>
pl.connected ? `<span class="pill" style="background:#e3efe0;color:#3a6b3a">✓ ${esc(pl.label)}</span>`
- : (pl.connectUrl ? `<a class="btn gated" style="text-decoration:none" href="${ORIGIN}${esc(pl.connectUrl)}">Connect ${esc(pl.label)} →</a>` : '')
+ : (pl.connectUrl ? `<a class="btn gated" style="text-decoration:none" href="${ORIGIN}${esc(pl.connectUrl)}">${pl.needsReconnect ? 'Reconnect' : 'Connect'} ${esc(pl.label)} →</a>` : '')
).join(' ');
// Meta has a known gotcha: Ads-scoped apps can't grant posting permissions.
// Bake the exact 4-step fix into the card so it's waiting whenever Steve returns.
diff --git a/public/panels/sounds.html b/public/panels/sounds.html
new file mode 100644
index 0000000..9f046f7
--- /dev/null
+++ b/public/panels/sounds.html
@@ -0,0 +1,39 @@
+<div class="muted-banner" id="snd-banner">Loading sounds & assets…</div>
+
+<div class="card" style="margin-bottom:14px">
+ <div class="row" style="gap:10px;align-items:center;flex-wrap:wrap">
+ <input type="search" id="snd-search" placeholder="Filter sources…" style="flex:1;min-width:200px" autocomplete="off">
+ <div id="snd-badgefilter" class="snd-badgefilter"></div>
+ </div>
+ <div class="muted" id="snd-legend" style="font-size:12px;margin-top:10px;line-height:1.6"></div>
+</div>
+
+<div id="snd-sections"><div class="muted">Loading…</div></div>
+
+<style>
+.snd-badgefilter{display:flex;gap:6px;flex-wrap:wrap}
+.snd-bf{background:transparent;border:1px solid var(--line);border-radius:999px;padding:5px 11px;cursor:pointer;
+ font:600 12px/1 Inter;color:var(--ink)}
+.snd-bf:hover{background:var(--cream)}
+.snd-bf.on{background:var(--accent);color:#fff;border-color:var(--accent)}
+.snd-legend b{color:var(--ink)}
+.snd-sec{margin-bottom:20px}
+.snd-sechead{display:flex;align-items:baseline;gap:10px;margin:0 0 4px}
+.snd-sechead h2{margin:0;font:600 20px/1.1 "Cormorant Garamond",Georgia,serif}
+.snd-sechead .ct{font:700 11px/1 Inter;background:var(--cream);color:var(--accent);border-radius:999px;padding:3px 9px}
+.snd-secblurb{font-size:12.5px;color:var(--mut);margin:0 0 12px}
+.snd-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px}
+.snd-card{border:1px solid var(--line);border-radius:12px;padding:14px;background:#fff;display:flex;flex-direction:column;gap:6px;
+ text-decoration:none;color:inherit;transition:border-color .12s,transform .12s}
+.snd-card:hover{border-color:var(--accent);transform:translateY(-1px)}
+.snd-card .st{display:flex;align-items:center;gap:8px}
+.snd-card .nm{font:600 14.5px/1.2 Inter;flex:1;min-width:0}
+.snd-card .arr{color:var(--mut);font-size:13px}
+.snd-card .bdg{font:700 9.5px/1 Inter;border-radius:999px;padding:3px 8px;white-space:nowrap}
+.bdg-brandsafe{background:#e3efe0;color:#2f6b34}
+.bdg-cc{background:#eaf0f7;color:#33578a}
+.bdg-listen{background:#fdf3dc;color:#8a6a1e}
+.bdg-tiktok{background:#f1e8f3;color:#7a3a8a}
+.snd-card .nt{font-size:12px;line-height:1.45;color:#5a544b}
+.snd-empty{color:var(--mut);font-size:12.5px;padding:8px 0}
+</style>
diff --git a/public/panels/sounds.js b/public/panels/sounds.js
new file mode 100644
index 0000000..c04b9d2
--- /dev/null
+++ b/public/panels/sounds.js
@@ -0,0 +1,85 @@
+// Sounds & Assets panel — free music + TikTok creative sources, badged by
+// brand-safety. Search + badge facets. Pure link directory (data from
+// /api/sounds/catalog); every card opens the source in a new tab.
+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);
+
+ const state = { catalog: [], badges: {}, q: '', badge: 'all' };
+
+ 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 by type, then click through — sources open in a new tab.`;
+ renderLegend();
+ renderBadgeFilter();
+ renderSections();
+ }
+
+ 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>
+ <div class="snd-secblurb">${esc(sec.blurb || '')}</div>
+ <div class="snd-grid">${items.map(cardHTML).join('')}</div>
+ </div>`;
+ }
+ box.innerHTML = shown ? html : '<div class="snd-empty">No sources match that filter.</div>';
+ }
+
+ function cardHTML(it) {
+ const b = state.badges[it.badge] || { label: it.badge };
+ return `<a class="snd-card" href="${esc(it.url)}" target="_blank" rel="noopener noreferrer">
+ <div class="st">
+ <span class="nm">${esc(it.name)}</span>
+ <span class="bdg bdg-${esc(it.badge)}">${esc(b.label)}</span>
+ </div>
+ <div class="nt">${esc(it.note || '')}</div>
+ <div class="arr">Open ↗</div>
+ </a>`;
+ }
+
+ $('#snd-search').addEventListener('input', e => { state.q = e.target.value; renderSections(); });
+
+ load();
+ },
+};
← fa0d1c1 deploy: ship Accounts panel to Kamatera; fix .deploy.conf HE
·
back to Marketing Command Center
·
Expand Sounds catalog: +6 royalty-free libraries & SFX, live c60d8ff →