← back to Marketing Command Center
public/app.js
142 lines
// Shell loader — builds a GROUPED nav from /api/panels, lazy-loads each panel's
// HTML+JS into #panel. A panel = public/panels/<id>.html (markup) + <id>.js
// (optional, exports window.MCC_PANELS[id] = { init(root) }). Hash routing.
// Boot is hardened: any failed fetch renders a visible error + Retry instead of
// hanging on "Loading…" forever.
const $ = s => document.querySelector(s);
window.MCC_PANELS = window.MCC_PANELS || {};
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', 'sounds', 'templates'] },
{ name: 'Email', ids: ['constant-contact', 'send-times'] },
{ name: 'Audience', ids: ['clients', 'segments', 'profiles', 'segment-perf'] },
{ name: 'Automation', ids: ['journeys', 'browse-abandon', 'playbook'] },
{ name: 'Analytics', ids: ['performance', 'follow-counts', 'ab-tests'] },
{ name: 'Planning', ids: ['calendars', 'calendar', 'vendors'] },
];
// Always fetch against the bare origin. If the page itself was opened with
// embedded credentials (http://user:pass@host), relative URLs resolve against
// that credentialed base and fetch() throws "URL that includes credentials".
// location.origin never carries credentials, so this is immune to that.
const ORIGIN = location.origin;
async function jget(url) {
const r = await fetch(ORIGIN + url, { credentials: 'same-origin' });
if (!r.ok) throw new Error(`${url} → HTTP ${r.status}`);
return r.json();
}
function linkHTML(p) {
return `<a data-id="${p.id}" class="${p.pending ? 'pending' : ''}">` +
`<span class="i">${p.icon || '▪'}</span><span class="t">${p.title}</span></a>`;
}
function renderNav() {
const byId = Object.fromEntries(PANELS.map(p => [p.id, p]));
const used = new Set();
let html = '';
for (const g of GROUPS) {
const items = g.ids.map(id => byId[id]).filter(Boolean);
if (!items.length) continue;
items.forEach(p => used.add(p.id));
html += `<div class="navgroup"><div class="navhead">${g.name}</div>` +
items.map(linkHTML).join('') + `</div>`;
}
const leftover = PANELS.filter(p => !used.has(p.id));
if (leftover.length) {
html += `<div class="navgroup"><div class="navhead">More</div>` +
leftover.map(linkHTML).join('') + `</div>`;
}
$('#tabs').innerHTML = html;
$('#tabs').querySelectorAll('a').forEach(a => a.onclick = () => {
location.hash = a.dataset.id;
if (window.matchMedia('(max-width:860px)').matches) document.body.classList.remove('nav-open');
});
}
function renderBootError(e) {
$('#health').className = 'dot bad';
$('#health').textContent = 'offline';
$('#tabs').innerHTML = '';
$('#panel').innerHTML =
`<div class="card" style="max-width:520px;margin:40px auto">
<h2>Couldn't load the dashboard</h2>
<p class="muted">${e.message || e}</p>
<p class="muted" style="font-size:12.5px">If this is an auth error, reload the page once and enter
your MCC credentials in the browser dialog, then it will stick.</p>
<button class="btn gold" onclick="boot()">Retry</button>
</div>`;
}
async function boot() {
$('#panel').innerHTML = '<div class="loading">Loading…</div>';
try {
const h = await jget('/api/health').catch(() => ({ ok: false, panels: 0 }));
const d = await jget('/api/panels');
PANELS = d.panels || [];
$('#health').className = 'dot ' + (h.ok ? 'ok' : 'bad');
$('#health').textContent = h.ok ? `${h.panels} panels online` : 'offline';
renderNav();
route();
} catch (e) {
renderBootError(e);
}
}
window.boot = boot;
window.addEventListener('hashchange', route);
const loaded = new Set();
// Monotonic token: every route() call claims the next value. After each await we
// re-check that we're still the newest navigation; if a later click superseded us
// we abort BEFORE touching #panel. Without this, rapid panel-switching interleaves
// two route() calls that both write the single shared #panel — a stale one lands
// after the fresh render and wipes it, leaving the panel blank even though its API
// calls fired (the "all posts blank" bug). Latest-wins is the fix.
let routeToken = 0;
async function route() {
const myToken = ++routeToken;
const id = location.hash.replace('#', '') || (PANELS[0] && PANELS[0].id);
if (!id) return;
const meta = PANELS.find(p => p.id === id) || { id, title: id };
$('#tabs').querySelectorAll('a').forEach(a => a.classList.toggle('active', a.dataset.id === id));
$('#paneltitle').textContent = meta.title;
$('#crumb').textContent = meta.pending ? '— not built yet' : '';
const panel = $('#panel');
panel.innerHTML = '<div class="loading">Loading…</div>';
let html = '';
try { const r = await fetch(`${ORIGIN}/panels/${id}.html`, { credentials: 'same-origin' }); html = r.ok ? await r.text() : ''; } catch {}
if (myToken !== routeToken) return; // superseded during HTML fetch — don't clobber the newer panel
panel.innerHTML = html || `<div class="muted-banner">The “${meta.title}” panel isn’t built yet.</div>`;
if (!loaded.has(id)) {
await new Promise(res => {
const s = document.createElement('script'); s.src = `${ORIGIN}/panels/${id}.js`;
s.onload = res; s.onerror = res; document.body.appendChild(s);
});
loaded.add(id);
}
if (myToken !== routeToken) return; // superseded during panel-JS load — don't init a stale panel
try { window.MCC_PANELS[id] && window.MCC_PANELS[id].init && window.MCC_PANELS[id].init(panel); } catch (e) { console.error(e); }
}
// ── Hamburger: collapse on desktop, off-canvas overlay on mobile ─────────────
function toggleNav() {
if (window.matchMedia('(max-width:860px)').matches) {
document.body.classList.toggle('nav-open');
} else {
const hidden = document.body.classList.toggle('nav-hidden');
try { localStorage.setItem('mcc_nav_hidden', hidden ? '1' : ''); } catch {}
}
}
document.addEventListener('DOMContentLoaded', () => {
if (localStorage.getItem('mcc_nav_hidden') === '1' && !window.matchMedia('(max-width:860px)').matches) {
document.body.classList.add('nav-hidden');
}
$('#burger').onclick = toggleNav;
$('#scrim').onclick = () => document.body.classList.remove('nav-open');
});
boot();