← back to Marketing Command Center
public/app.js
199 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 — four plain-language actions for first-time users.
// Power tools not listed here fall into "More" and remain available via search.
const GROUPS = [
{ name: 'Make a post', ids: ['quickpost', 'compose'] },
{ name: 'Plan', ids: ['calendarhub', 'playbook', 'board'] },
{ name: 'See results', ids: ['insights', 'social', 'reels'] },
{ name: 'Accounts', ids: ['accounts', 'channels'] },
];
// Relabel a panel in the SIDEBAR only, WITHOUT editing its module (a module may be
// owned by another session — e.g. modules/vendors/index.js). Keyed by panel id.
const TITLE_OVERRIDES = {
quickpost: 'Make a post',
compose: 'Post ideas',
social: 'Post history',
reels: 'Videos',
vendors: 'Account check',
board: 'What’s happening',
calendarhub: 'What’s planned',
playbook: 'Post plan',
insights: 'Results',
channels: 'Social accounts',
layouts: 'Layouts', // trim "On-Demand Layouts" for the rail
};
// Hidden/merged panels → their host, so the command palette can show a "· via Host"
// breadcrumb and still let power users jump straight to them.
const HIDDEN_HOST = {
calendars: 'calendarhub', calendar: 'calendarhub', engine: 'compose', composer: 'compose',
performance: 'insights', 'segment-perf': 'insights', 'send-times': 'insights', segments: 'insights',
journeys: 'insights', profiles: 'insights', 'follow-counts': 'insights', 'ab-tests': 'insights',
};
// Static maps the command palette reads (panel id → group label, and the nav order).
window.MCC_GROUP_MAP = Object.fromEntries(GROUPS.flatMap(g => g.ids.map(id => [id, g.name])));
window.MCC_HIDDEN_HOST = HIDDEN_HOST;
window.MCC_NAV_ORDER = GROUPS.flatMap(g => g.ids);
// Panels whose ROUTERS stay mounted (their APIs are still called) but whose NAV
// entries are hidden — because they were merged into a host panel above. Removing
// them from registry.js would unmount their APIs and break the host, so we hide
// them here instead. calendars/calendar → calendarhub; engine/composer → compose.
const HIDDEN = new Set([
'calendars', 'calendar', 'engine', 'composer', // → calendarhub / compose
'performance', 'segment-perf', 'send-times', 'segments', // → insights (preview)
'journeys', 'profiles', 'follow-counts', 'ab-tests',
]);
// 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) {
const label = TITLE_OVERRIDES[p.id] || p.title; // rail label only; the panel header keeps its module title
return `<a data-id="${p.id}" class="${p.pending ? 'pending' : ''}">` +
`<span class="i">${p.icon || '▪'}</span><span class="t">${label}</span></a>`;
}
function renderNav() {
const visible = PANELS.filter(p => !HIDDEN.has(p.id)); // hide merged-away panels
const byId = Object.fromEntries(visible.map(p => [p.id, p]));
const collapsed = new Set(JSON.parse(localStorage.getItem('mcc_nav_collapsed') || '[]'));
const group = (name, items, canCollapse = true) => {
const isC = canCollapse && collapsed.has(name);
return `<div class="navgroup${isC ? ' collapsed' : ''}">` +
`<div class="navhead" data-group="${name}"><span class="caret">${isC ? '▸' : '▾'}</span>${name}</div>` +
`<div class="navitems">${items.map(linkHTML).join('')}</div></div>`;
};
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 += group(g.name, items);
}
const leftover = visible.filter(p => !used.has(p.id));
if (leftover.length) html += group('More', leftover); // should be empty now that ig-activity has a home
$('#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');
});
// Collapse/expand a group (persisted). renderNav re-runs — cheap, and it re-inserts
// the palette trigger below.
$('#tabs').querySelectorAll('.navhead[data-group]').forEach(h => h.onclick = () => {
const c = new Set(JSON.parse(localStorage.getItem('mcc_nav_collapsed') || '[]'));
c.has(h.dataset.group) ? c.delete(h.dataset.group) : c.add(h.dataset.group);
localStorage.setItem('mcc_nav_collapsed', JSON.stringify([...c]));
renderNav();
});
// setting #tabs.innerHTML wiped the palette's "Search panels…" trigger — re-mount it
// (setData re-inserts the trigger + refreshes the palette's item list).
if (window.cmdPalette && window.cmdPalette.setData) window.cmdPalette.setData(PANELS);
}
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 requested = location.hash.replace('#', '');
const firstPanel = PANELS.find(p => p.id === 'quickpost') || PANELS[0];
const id = requested || (firstPanel && firstPanel.id);
if (!id) return;
const meta = PANELS.find(p => p.id === id) || { id, title: id };
// A hidden/merged panel (e.g. composer, follow-counts — reachable via the palette)
// has no nav <a> of its own, so highlight its HOST group entry instead of leaving
// the whole sidebar deselected.
const activeId = (HIDDEN_HOST && HIDDEN_HOST[id]) || id;
$('#tabs').querySelectorAll('a').forEach(a => a.classList.toggle('active', a.dataset.id === activeId));
$('#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();