← back to Marketing Command Center
MCC shell: fix auth-cookie so fetch() works (was hanging on Loading), grouped hamburger nav w/ Social Media leading, hardened boot + error states
c79449d286a60167357908c83fcc3892e9658cfd · 2026-06-13 08:14:57 -0700 · Steve Abrams
Files touched
M public/app.jsM public/index.htmlM public/style.cssM server.js
Diff
commit c79449d286a60167357908c83fcc3892e9658cfd
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sat Jun 13 08:14:57 2026 -0700
MCC shell: fix auth-cookie so fetch() works (was hanging on Loading), grouped hamburger nav w/ Social Media leading, hardened boot + error states
---
public/app.js | 111 ++++++++++++++++++++++++++++++++++++++++++++++--------
public/index.html | 13 ++++++-
public/style.css | 68 ++++++++++++++++++++++++---------
server.js | 16 +++++++-
4 files changed, 172 insertions(+), 36 deletions(-)
diff --git a/public/app.js b/public/app.js
index 5888124..d1842f2 100644
--- a/public/app.js
+++ b/public/app.js
@@ -1,21 +1,86 @@
-// Shell loader — builds 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 (#<id>).
+// 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: ['social', 'channels'] },
+ { name: 'Content Studio', ids: ['copy', 'layouts', 'assets', 'templates'] },
+ { name: 'Email', ids: ['constant-contact', 'send-times'] },
+ { name: 'Audience', ids: ['segments', 'profiles', 'segment-perf'] },
+ { name: 'Automation', ids: ['journeys', 'browse-abandon', 'playbook'] },
+ { name: 'Analytics', ids: ['performance', 'ab-tests'] },
+ { name: 'Planning', ids: ['calendar', 'vendors'] },
+];
+
+async function jget(url) {
+ const r = await fetch(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
+ <b>admin</b> / <b>DWMarketing2026!</b> in the browser dialog, then it will stick.</p>
+ <button class="btn gold" onclick="boot()">Retry</button>
+ </div>`;
+}
+
async function boot() {
- const h = await (await fetch('/api/health')).json().catch(() => ({ ok: false }));
- $('#health').className = 'dot ' + (h.ok ? 'ok' : 'bad');
- $('#health').textContent = h.ok ? `${h.panels} panels online` : 'offline';
- const d = await (await fetch('/api/panels')).json();
- PANELS = d.panels || [];
- $('#tabs').innerHTML = PANELS.map(p =>
- `<a data-id="${p.id}" class="${p.pending ? 'pending' : ''}"><span class="i">${p.icon || '▪'}</span>${p.title}</a>`).join('');
- $('#tabs').querySelectorAll('a').forEach(a => a.onclick = () => location.hash = a.dataset.id);
- route();
+ $('#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();
@@ -28,11 +93,9 @@ async function route() {
$('#crumb').textContent = meta.pending ? '— not built yet' : '';
const panel = $('#panel');
panel.innerHTML = '<div class="loading">Loading…</div>';
- // load HTML
let html = '';
- try { const r = await fetch(`/panels/${id}.html`); html = r.ok ? await r.text() : ''; } catch {}
+ try { const r = await fetch(`/panels/${id}.html`, { credentials: 'same-origin' }); html = r.ok ? await r.text() : ''; } catch {}
panel.innerHTML = html || `<div class="muted-banner">The “${meta.title}” panel isn’t built yet.</div>`;
- // load JS once, then init
if (!loaded.has(id)) {
await new Promise(res => {
const s = document.createElement('script'); s.src = `/panels/${id}.js`;
@@ -42,4 +105,22 @@ async function route() {
}
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();
diff --git a/public/index.html b/public/index.html
index 4eb9ad2..294c6f4 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1,16 +1,25 @@
<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Marketing Command Center · Designer Wallcoverings</title>
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@500;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/style.css">
</head>
<body>
+<div id="scrim"></div>
<aside id="nav">
<div class="brand"><span class="logo">DW</span><div><b>Marketing</b><small>Command Center</small></div></div>
- <nav id="tabs"></nav>
+ <nav id="tabs"><div class="loading">Loading…</div></nav>
<div class="navfoot"><span id="health" class="dot">…</span></div>
</aside>
<main id="stage">
- <header id="topbar"><h1 id="paneltitle">Marketing Command Center</h1><span id="crumb" class="muted"></span></header>
+ <header id="topbar">
+ <button id="burger" aria-label="Toggle menu" title="Toggle menu">
+ <span></span><span></span><span></span>
+ </button>
+ <h1 id="paneltitle">Marketing Command Center</h1>
+ <span id="crumb" class="muted"></span>
+ </header>
<section id="panel"><div class="loading">Loading…</div></section>
</main>
<script src="/app.js"></script>
diff --git a/public/style.css b/public/style.css
index 8be692e..e1a9734 100644
--- a/public/style.css
+++ b/public/style.css
@@ -1,34 +1,59 @@
-:root{--ink:#14110f;--cream:#f4efe7;--paper:#fbf9f5;--gold:#b8925a;--sage:#6f7a63;--accent:#8a5a44;
- --line:#e3ddd0;--mut:#8a8275;--bg:#f7f4ee;--card:#fff}
+:root{--ink:#14110f;--cream:#f4efe7;--paper:#fbf9f5;--gold:#b8925a;--gold-soft:#d8c19a;--sage:#6f7a63;--accent:#8a5a44;
+ --line:#e3ddd0;--mut:#8a8275;--bg:#f7f4ee;--card:#fff;--nav-w:248px}
*{box-sizing:border-box}
body{margin:0;display:flex;min-height:100vh;background:var(--bg);color:var(--ink);
font:14px/1.5 Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
-#nav{width:230px;min-width:230px;background:var(--ink);color:var(--cream);display:flex;flex-direction:column;
- position:sticky;top:0;height:100vh}
+
+/* ── Sidebar ─────────────────────────────────────────────────────────────── */
+#nav{width:var(--nav-w);min-width:var(--nav-w);background:var(--ink);color:var(--cream);
+ display:flex;flex-direction:column;position:sticky;top:0;height:100vh;z-index:40;
+ transition:margin-left .22s ease, transform .22s ease}
+body.nav-hidden #nav{margin-left:calc(-1 * var(--nav-w))}
.brand{display:flex;gap:11px;align-items:center;padding:18px 18px 16px;border-bottom:1px solid #2b2622}
-.brand .logo{width:38px;height:38px;border-radius:9px;background:var(--gold);color:var(--ink);
- display:grid;place-items:center;font-weight:800;font-family:"Cormorant Garamond",Georgia,serif;font-size:19px}
-.brand b{font-size:15px;letter-spacing:.3px}.brand small{display:block;color:#b7ad9c;font-size:11px;letter-spacing:1.5px;text-transform:uppercase}
-#tabs{flex:1;padding:12px 10px;display:flex;flex-direction:column;gap:3px;overflow:auto}
-#tabs a{display:flex;gap:10px;align-items:center;padding:9px 12px;border-radius:9px;color:#d8cfc0;
- text-decoration:none;font-weight:600;font-size:13.5px;cursor:pointer}
-#tabs a .i{width:20px;text-align:center}
+.brand .logo{width:38px;height:38px;border-radius:9px;background:linear-gradient(150deg,var(--gold),#9c773f);color:var(--ink);
+ display:grid;place-items:center;font-weight:700;font-family:"Cormorant Garamond",Georgia,serif;font-size:19px;box-shadow:0 2px 8px rgba(0,0,0,.3)}
+.brand b{font-size:15px;letter-spacing:.3px}
+.brand small{display:block;color:#b7ad9c;font-size:10.5px;letter-spacing:1.6px;text-transform:uppercase}
+
+#tabs{flex:1;padding:10px 10px 16px;display:flex;flex-direction:column;gap:2px;overflow:auto}
+#tabs::-webkit-scrollbar{width:8px}#tabs::-webkit-scrollbar-thumb{background:#332d28;border-radius:8px}
+.navgroup{margin-top:14px}
+.navgroup:first-child{margin-top:6px}
+.navhead{font-size:10px;font-weight:700;letter-spacing:1.4px;text-transform:uppercase;color:var(--gold-soft);
+ padding:6px 12px 5px;opacity:.85}
+#tabs a{display:flex;gap:11px;align-items:center;padding:9px 12px;border-radius:9px;color:#d8cfc0;
+ text-decoration:none;font-weight:500;font-size:13.5px;cursor:pointer;transition:background .12s,color .12s}
+#tabs a .i{width:20px;text-align:center;font-size:15px;flex:none}
+#tabs a .t{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#tabs a:hover{background:#241f1b;color:#fff}
-#tabs a.active{background:var(--gold);color:var(--ink)}
-#tabs a.pending{opacity:.5}
+#tabs a.active{background:linear-gradient(120deg,var(--gold),#a8814b);color:var(--ink);font-weight:600;box-shadow:0 1px 6px rgba(0,0,0,.25)}
+#tabs a.pending{opacity:.45}
#tabs a.pending::after{content:'soon';margin-left:auto;font-size:9px;letter-spacing:.5px;text-transform:uppercase;color:#b7ad9c}
.navfoot{padding:12px 18px;border-top:1px solid #2b2622;font-size:11px;color:#b7ad9c}
.dot::before{content:'';display:inline-block;width:8px;height:8px;border-radius:50%;background:#9aa;margin-right:7px;vertical-align:1px}
.dot.ok::before{background:#5fbf7e}.dot.bad::before{background:#e07a5f}
+
+/* ── Stage ───────────────────────────────────────────────────────────────── */
#stage{flex:1;display:flex;flex-direction:column;min-width:0}
#topbar{position:sticky;top:0;z-index:5;background:var(--paper);border-bottom:1px solid var(--line);
- padding:16px 26px;display:flex;align-items:baseline;gap:14px}
-#topbar h1{margin:0;font:600 20px/1.2 "Cormorant Garamond",Georgia,serif;letter-spacing:.2px}
+ padding:13px 22px;display:flex;align-items:center;gap:14px}
+#topbar h1{margin:0;font:600 21px/1.2 "Cormorant Garamond",Georgia,serif;letter-spacing:.2px}
.muted{color:var(--mut)}
+
+/* hamburger */
+#burger{flex:none;width:38px;height:34px;border:1px solid var(--line);border-radius:9px;background:#fff;
+ display:flex;flex-direction:column;justify-content:center;align-items:center;gap:4px;cursor:pointer;transition:background .12s}
+#burger:hover{background:var(--cream)}
+#burger span{display:block;width:17px;height:2px;background:var(--ink);border-radius:2px}
+
#panel{flex:1;padding:24px 26px 60px}
.loading{color:var(--mut);padding:50px;text-align:center}
-/* shared panel primitives modules can use */
-.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:18px 20px;margin-bottom:18px}
+
+/* scrim (mobile drawer backdrop) */
+#scrim{display:none;position:fixed;inset:0;background:rgba(15,12,10,.45);z-index:35}
+
+/* ── Shared panel primitives ─────────────────────────────────────────────── */
+.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:18px 20px;margin-bottom:18px;box-shadow:0 1px 2px rgba(20,17,15,.03)}
.card h2{margin:0 0 4px;font:600 17px/1.2 "Cormorant Garamond",Georgia,serif}
.grid{display:grid;gap:16px}
.btn{background:var(--ink);color:var(--cream);border:0;border-radius:9px;padding:9px 16px;font-weight:600;cursor:pointer;font-size:13.5px}
@@ -41,3 +66,12 @@ input,textarea,select{font:inherit;border:1px solid var(--line);border-radius:9p
label{font-size:12px;font-weight:600;color:var(--mut);display:block;margin:0 0 5px}
.row{display:flex;gap:12px;flex-wrap:wrap}
.muted-banner{background:#fff8ec;border:1px solid #f0e0c0;color:#7a5a2a;border-radius:10px;padding:10px 14px;font-size:13px;margin-bottom:16px}
+
+/* ── Mobile: sidebar becomes an off-canvas drawer ────────────────────────── */
+@media(max-width:860px){
+ #nav{position:fixed;left:0;top:0;transform:translateX(-100%);margin-left:0;box-shadow:6px 0 24px rgba(0,0,0,.25)}
+ body.nav-open #nav{transform:translateX(0)}
+ body.nav-open #scrim{display:block}
+ #topbar{padding:11px 16px}
+ #panel{padding:18px 16px 50px}
+}
diff --git a/server.js b/server.js
index 1b5ec77..222329b 100644
--- a/server.js
+++ b/server.js
@@ -27,11 +27,23 @@ app.use(express.json({ limit: '28mb' }));
// ── Basic auth (matches DW fleet pattern; cred overridable via env) ──────────
const USER = process.env.MCC_USER || 'admin';
const PASS = process.env.MCC_PASS || 'DWMarketing2026!';
+// Auth accepts the standard Basic header OR an `mcc` cookie we set on first
+// success. The cookie is what makes XHR/fetch work after the page is opened
+// with embedded URL credentials (http://user:pass@host) — Chrome won't re-attach
+// those creds to subresource/fetch requests, so without the cookie every
+// /api/* fetch 401s and the shell hangs forever on "Loading…".
app.use((req, res, next) => {
const hdr = req.headers.authorization || '';
- const [, b64] = hdr.split(' ');
+ let b64 = hdr.startsWith('Basic ') ? hdr.slice(6) : '';
+ if (!b64) {
+ const m = (req.headers.cookie || '').match(/(?:^|;\s*)mcc=([^;]+)/);
+ if (m) b64 = decodeURIComponent(m[1]);
+ }
const [u, p] = Buffer.from(b64 || '', 'base64').toString().split(':');
- if (u === USER && p === PASS) return next();
+ if (u === USER && p === PASS) {
+ res.setHeader('Set-Cookie', `mcc=${encodeURIComponent(b64)}; Path=/; Max-Age=604800; SameSite=Lax`);
+ return next();
+ }
res.set('WWW-Authenticate', 'Basic realm="Marketing Command Center"').status(401).end('Auth required');
});
← c6baf4f social: add validated meta-go-live.sh (Graph-verify token →
·
back to Marketing Command Center
·
MCC shell: fetch against location.origin so embedded-URL cre 2b26633 →