[object Object]

← 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

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 →