[object Object]

← back to Whatsmystyle

feat(roles): 3 user layers (admin/public/set_decorator) + admin view-as preview + /productions résumé + role banner

68a720072ff658a16e0a7648207223c8b55379bf · 2026-05-12 07:14:05 -0700 · SteveStudio2

Files touched

Diff

commit 68a720072ff658a16e0a7648207223c8b55379bf
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue May 12 07:14:05 2026 -0700

    feat(roles): 3 user layers (admin/public/set_decorator) + admin view-as preview + /productions résumé + role banner
---
 public/admin-config.html |  51 +++++++++++++++++++++++
 public/css/app.css       |  51 +++++++++++++++++++++++
 public/index.html        |  29 +++++++++++++
 public/js/app.js         |  95 +++++++++++++++++++++++++++++++++++++++++++
 server.js                | 103 ++++++++++++++++++++++++++++++++++++++++++++++-
 5 files changed, 328 insertions(+), 1 deletion(-)

diff --git a/public/admin-config.html b/public/admin-config.html
index 97e7e21..18b9bf9 100644
--- a/public/admin-config.html
+++ b/public/admin-config.html
@@ -68,6 +68,30 @@
     <div id="status"></div>
   </form>
 
+  <hr style="margin: 40px 0; border: 0; border-top: 1px solid #e6e1d8;" />
+
+  <h2 style="font-size: 24px; font-weight: 600; letter-spacing: -0.02em;">User layer · preview</h2>
+  <p class="sub">WhatsMyStyle has three user tiers. As admin you see everything. Use this toggle to preview what a public consumer (general user) or a film/TV set-decoration buyer sees — without losing your admin session.</p>
+
+  <div class="pill-row">
+    <label>
+      <span class="name">View as</span>
+      <span class="hint">Flip to a non-admin role. Header shows an "exit preview" pill while active.</span>
+    </label>
+    <select id="view_as_select" style="font: inherit; padding: 10px 16px; border: 1px solid #d6d0c4; border-radius: 14px; background: #faf7f2;">
+      <option value="">Admin (full view)</option>
+      <option value="public">General user — public (retail)</option>
+      <option value="set_decorator">General user — set decorator</option>
+    </select>
+  </div>
+
+  <div style="display: flex; gap: 12px; flex-wrap: wrap; margin-top: 12px;">
+    <button id="open_retail" class="save" type="button">Open retail view →</button>
+    <button id="open_setdec" class="save" type="button" style="background:#5a3a1f;">Open set-decorator view →</button>
+    <button id="exit_preview" class="save" type="button" style="background:#fff;color:#1d1d1f;border:1px solid #d6d0c4;">Exit preview</button>
+  </div>
+  <div id="role_status" class="status" style="margin-top: 16px; display: none;"></div>
+
   <script>
     const $ = (id) => document.getElementById(id);
     const isAdminQ = location.search.includes('admin=1') ? '?admin=1' : '';
@@ -100,6 +124,33 @@
     });
 
     load();
+
+    // ---- View-as toggle ----------------------------------------------------
+    async function loadRole() {
+      const r = await fetch('/api/me');
+      const me = await r.json();
+      $('view_as_select').value = me.view_as || '';
+    }
+
+    async function setViewAs(v) {
+      const r = await fetch('/api/admin/view-as' + isAdminQ, {
+        method: 'PUT', headers: { 'content-type': 'application/json' },
+        body: JSON.stringify({ view_as: v === '' ? null : v }),
+      });
+      const j = await r.json();
+      const s = $('role_status');
+      s.style.display = 'block';
+      if (r.ok) { s.className = 'status ok'; s.textContent = v ? `Now previewing as: ${v}` : 'Exited preview. You are back to full admin view.'; }
+      else      { s.className = 'status err'; s.textContent = j.error || 'failed'; }
+      $('view_as_select').value = v || '';
+    }
+
+    $('view_as_select').addEventListener('change', e => setViewAs(e.target.value));
+    $('open_retail').addEventListener('click', async () => { await setViewAs('public'); location.href = '/'; });
+    $('open_setdec').addEventListener('click', async () => { await setViewAs('set_decorator'); location.href = '/'; });
+    $('exit_preview').addEventListener('click', () => setViewAs(''));
+
+    loadRole();
   </script>
 </body>
 </html>
diff --git a/public/css/app.css b/public/css/app.css
index d4590ca..a1fa685 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -452,5 +452,56 @@ h2 { font-family: var(--font-display); font-weight: 600; font-size: 36px; margin
 }
 .credits-pill:hover { background: #fff; color: #1d1d1f; }
 
+/* ---- Three-tier role layer (tick 15.5) ---- */
+.role-banner {
+  position: sticky; top: 0; z-index: 50;
+  background: #1d1d1f; color: #faf7f2;
+  padding: 10px 20px;
+  display: flex; align-items: center; justify-content: space-between;
+  font-size: 13px;
+  letter-spacing: 0.02em;
+}
+.role-banner-label { color: #faf7f2; }
+.role-banner-label strong { color: #f3d76f; }
+.role-banner-exit {
+  background: rgba(255,255,255,0.1);
+  border: 1px solid rgba(255,255,255,0.25);
+  color: #faf7f2;
+  padding: 5px 14px;
+  border-radius: 999px;
+  font: inherit;
+  cursor: pointer;
+}
+.role-banner-exit:hover { background: rgba(255,255,255,0.2); }
+
+/* Productions list (shotonwhat-style résumé index for set decorators) */
+.production-add {
+  display: flex; flex-wrap: wrap; gap: 8px;
+  margin: 12px 0 24px;
+}
+.production-add input, .production-add select {
+  font: inherit; padding: 10px 14px;
+  border: 1px solid #d6d0c4; border-radius: 14px;
+  background: #faf7f2;
+  flex: 1; min-width: 140px;
+}
+.production-add input[type="text"] { flex: 2; }
+.productions-list { display: flex; flex-direction: column; gap: 12px; }
+.production-row {
+  background: #fff;
+  border: 1px solid #e6e1d8;
+  border-radius: 20px;
+  padding: 16px 22px;
+}
+.production-title { font-size: 22px; font-weight: 600; letter-spacing: -0.01em; margin: 0 0 6px; }
+.production-meta {
+  display: flex; flex-wrap: wrap; gap: 8px;
+  font-size: 12px;
+}
+.production-meta > span {
+  background: #faf7f2; border: 1px solid #e6e1d8;
+  padding: 3px 10px; border-radius: 999px; color: #555;
+}
+
 /* utils */
 [hidden] { display: none !important; }
diff --git a/public/index.html b/public/index.html
index bcb2356..b534c54 100644
--- a/public/index.html
+++ b/public/index.html
@@ -9,6 +9,7 @@
 </head>
 <body>
 <a href="#main" class="skip-link">Skip to main content</a>
+<div id="role-banner" class="role-banner" hidden></div>
 <header class="topbar" role="banner">
   <button class="logo" aria-label="Home — WhatsMyStyle" onclick="route('home')">WhatsMyStyle<span class="bullet" aria-hidden="true">·</span><em>placeholder</em></button>
   <button class="hamburger" aria-label="Open menu" aria-expanded="false" aria-controls="menu" onclick="toggleMenu()">
@@ -28,6 +29,8 @@
   <a href="#" data-screen="taste">My Taste</a>
   <a href="#" data-screen="studio">Studio</a>
   <a href="#" data-screen="tryons">My Try-Ons</a>
+  <a href="#" data-screen="productions" data-role-only="set_decorator">My Productions</a>
+  <a href="/admin-config" data-role-only="admin">Admin Config</a>
   <a href="#" data-screen="stores">Stores Near Me</a>
   <a href="#" data-screen="connect">Connections</a>
   <a href="#" data-screen="about">About</a>
@@ -329,6 +332,32 @@
     </p>
   </section>
 
+  <!-- Productions (set-decorator role only) — shotonwhat-style project list -->
+  <section id="productions" class="screen" hidden>
+    <h2>My Productions</h2>
+    <p class="muted">Each production groups the closet pieces, try-ons, and outfit picks you built for that show or shoot. Same résumé view as shotonwhat.com — but for the room, not the cast.</p>
+    <form id="production-add" class="production-add">
+      <input type="text" id="prod-title" placeholder="Show or film title (e.g., 'Westworld')" required />
+      <input type="number" id="prod-year" placeholder="Year" min="1900" max="2099" style="max-width:120px;" />
+      <select id="prod-kind">
+        <option value="tv-episode">TV episode</option>
+        <option value="tv-season">TV season</option>
+        <option value="film">Film</option>
+        <option value="commercial">Commercial</option>
+        <option value="theater">Theater</option>
+      </select>
+      <select id="prod-role">
+        <option>Set Decoration Buyer</option>
+        <option>Set Decorator</option>
+        <option>Production Designer</option>
+        <option>Costume Designer</option>
+        <option>Wardrobe Supervisor</option>
+      </select>
+      <button class="ghost wide" type="submit">+ Add production</button>
+    </form>
+    <div id="productions-list" class="productions-list"></div>
+  </section>
+
   <!-- Try-on history -->
   <section id="tryons" class="screen" hidden>
     <h2>My Try-Ons</h2>
diff --git a/public/js/app.js b/public/js/app.js
index fd809ae..72386fc 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -41,12 +41,105 @@ async function forgetMe() {
 }
 window.forgetMe = forgetMe;
 $$('.menu a').forEach(a => a.addEventListener('click', e => {
+  // Admin-config is a top-level page, not a screen — let the browser handle it.
+  if (a.getAttribute('href') && a.getAttribute('href').startsWith('/')) return;
   e.preventDefault();
   const id = a.dataset.screen;
   $('#menu').hidden = true;
   route(id);
 }));
 
+// ---------- Three-tier role layer (admin / public / set_decorator) ---------
+// Loaded once at boot, refreshed when admin flips view_as. Drives:
+//   - role banner above the topbar (preview indicator + exit pill)
+//   - menu item visibility via data-role-only=<role>
+//   - per-screen feature gates (set_decorator unlocks /productions)
+const Role = {
+  role: 'public',
+  view_as: null,
+  effective: 'public',
+  is_admin: false,
+  async refresh() {
+    try {
+      const r = await fetch('/api/me');
+      const me = await r.json();
+      this.role = me.role || 'public';
+      this.view_as = me.view_as || null;
+      this.effective = me.effective_role || this.role;
+      this.is_admin = !!me.is_admin;
+      this.applyToDom();
+    } catch {}
+  },
+  applyToDom() {
+    // Menu items can be role-gated. data-role-only="admin" shows only when
+    // user_role is admin. data-role-only="set_decorator" shows when effective
+    // role is set_decorator OR user_role is set_decorator.
+    $$('.menu a[data-role-only]').forEach(a => {
+      const need = a.dataset.roleOnly;
+      const showIt = (need === 'admin' && this.is_admin) ||
+                     (need === 'set_decorator' && (this.effective === 'set_decorator' || this.role === 'set_decorator'));
+      a.style.display = showIt ? '' : 'none';
+    });
+    // Banner: only show when an admin is previewing as something else.
+    const banner = $('#role-banner');
+    if (this.is_admin && this.view_as) {
+      banner.hidden = false;
+      banner.innerHTML = `
+        <span class="role-banner-label">Previewing as <strong>${this.view_as === 'public' ? 'public consumer' : 'set decorator'}</strong></span>
+        <button class="role-banner-exit" type="button">Exit preview →</button>
+      `;
+      banner.querySelector('.role-banner-exit').onclick = async () => {
+        await fetch('/api/admin/view-as', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ view_as: null }) });
+        await Role.refresh();
+      };
+    } else {
+      banner.hidden = true;
+    }
+  },
+};
+window.Role = Role;
+
+// ---------- Productions (set-decorator only) -------------------------------
+async function loadProductions() {
+  const r = await fetch('/api/productions');
+  const { productions } = await r.json();
+  const list = $('#productions-list');
+  if (!productions.length) {
+    list.innerHTML = `<p class="muted">No productions yet. Add one above. Once you have closet pieces or try-ons tagged with a production, they'll show up here with credit counts.</p>`;
+    return;
+  }
+  list.innerHTML = productions.map(p => `
+    <article class="production-row">
+      <h3 class="production-title">${escapeHtml(p.title)}${p.year ? ` <span class="muted">(${p.year})</span>` : ''}</h3>
+      <div class="production-meta">
+        <span>${escapeHtml(p.kind || 'tv-episode')}</span>
+        <span>${escapeHtml(p.role || 'Set Decoration Buyer')}</span>
+        <span><strong>${p.total_credits}</strong> credits</span>
+        <span class="muted">${p.closet_count} closet · ${p.tryon_count} try-on</span>
+      </div>
+    </article>
+  `).join('');
+}
+
+document.addEventListener('submit', async e => {
+  if (e.target.id !== 'production-add') return;
+  e.preventDefault();
+  const body = {
+    title: $('#prod-title').value.trim(),
+    year: $('#prod-year').value ? Number($('#prod-year').value) : null,
+    kind: $('#prod-kind').value,
+    role: $('#prod-role').value,
+  };
+  if (!body.title) return;
+  const r = await fetch('/api/productions', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
+  if (r.ok) {
+    $('#prod-title').value = ''; $('#prod-year').value = '';
+    loadProductions();
+  } else {
+    alert('Could not add — try again.');
+  }
+});
+
 // ---------- Onboarding -----------------------------------------------------
 // Per the 2026-05-12 onboarding-length debate (verdict: modify) — mandatory
 // = body-shape, budget, geolocation. Other 8 are optional (skippable).
@@ -1153,6 +1246,7 @@ function route(name) {
   if (name === 'taste') loadTaste();
   if (name === 'support') loadSupport();
   if (name === 'debates') loadDebates();
+  if (name === 'productions') loadProductions();
   if (name === 'connect') fetch('/api/connect').then(r => r.json()).then(c => $('#connect-state').textContent = JSON.stringify(c.connections, null, 2));
 }
 
@@ -1219,6 +1313,7 @@ document.addEventListener('click', e => {
 
 // ---------- Boot -----------------------------------------------------------
 (async function boot() {
+  await Role.refresh();  // tick 15.5 — populate role + banner before first paint
   const me = await fetch('/api/me').then(r => r.json());
   if (!me.onboarded) { show('onboard'); onbRender(); }
   else route('home');
diff --git a/server.js b/server.js
index 9289586..cdbe1b6 100644
--- a/server.js
+++ b/server.js
@@ -240,6 +240,31 @@ CREATE INDEX IF NOT EXISTS embedding_drifts_item_idx ON embedding_drifts(item_id
 // Tick 13: closet.privacy column (default 'shared'). Idempotent.
 safeAddColumn('closet', "privacy TEXT DEFAULT 'shared'");
 
+// Tick 15.5: three user layers — admin / public / set_decorator.
+// `user_role` is the user's actual tier; `view_as` is admin-only role
+// simulation so the admin can preview the retail/general-user view.
+// `effective_role` (computed) = view_as if admin & view_as set, else user_role.
+safeAddColumn('users', "user_role TEXT DEFAULT 'public'");
+safeAddColumn('users', "view_as TEXT");
+
+// Tick 15.5: productions — film/TV/commercial projects a set_decorator user
+// is buying for. Lets set-decorator-mode anchor closet + try-on rows to a
+// specific show + episode, mirroring shotonwhat.com's credit structure.
+db.exec(`
+CREATE TABLE IF NOT EXISTS productions (
+  id INTEGER PRIMARY KEY,
+  user_id INTEGER NOT NULL,
+  title TEXT NOT NULL,
+  year INTEGER,
+  kind TEXT,                       -- 'film' | 'tv-episode' | 'tv-season' | 'commercial' | 'theater'
+  role TEXT,                       -- 'Set Decorator' | 'Set Decoration Buyer' | 'Production Designer' | freeform
+  created_at TEXT DEFAULT (datetime('now'))
+);
+CREATE INDEX IF NOT EXISTS productions_user_idx ON productions(user_id, id);
+`);
+safeAddColumn('closet', 'production_id INTEGER');
+safeAddColumn('tryon_jobs', 'production_id INTEGER');
+
 // ---------- app ------------------------------------------------------------
 const app = express();
 app.use(express.json({ limit: '5mb' }));
@@ -280,9 +305,85 @@ app.get('/api/public-config', (req, res) => {
   });
 });
 
+// Tick 15.5: role helpers. Admin tier is granted by hostname OR ?admin=1,
+// the same gate that already protects /api/admin/*. We also persist
+// `users.user_role = 'admin'` on first hit when the gate matches so the
+// role survives an admin loading the app from 127.0.0.1.
+function resolveAdmin(req) {
+  return req.hostname === '127.0.0.1' || req.hostname === 'localhost' || req.query.admin === '1';
+}
+function effectiveRoleFor(u, req) {
+  const isAdmin = u.user_role === 'admin' || resolveAdmin(req);
+  if (isAdmin && u.view_as && ['public', 'set_decorator'].includes(u.view_as)) return u.view_as;
+  return u.user_role || 'public';
+}
+function maybePromoteAdmin(u, req) {
+  if (resolveAdmin(req) && u.user_role !== 'admin') {
+    db.prepare("UPDATE users SET user_role='admin' WHERE id=?").run(u.id);
+    u.user_role = 'admin';
+  }
+  return u;
+}
+
 app.get('/api/me', (req, res) => {
+  let u = currentUser(req);
+  u = maybePromoteAdmin(u, req);
+  const effective = effectiveRoleFor(u, req);
+  res.json({
+    user: u,
+    onboarded: !!u.onboarded_at,
+    role: u.user_role || 'public',
+    view_as: u.view_as || null,
+    effective_role: effective,
+    is_admin: u.user_role === 'admin' || resolveAdmin(req),
+  });
+});
+
+// Admin-only — write user_role on a specific user (default self).
+// Use case: bootstrapping a set-decorator user without rolling auth.
+app.put('/api/admin/set-role', (req, res) => {
+  if (!adminGate(req)) return res.status(403).json({ error: 'admin only' });
+  const role = req.body?.role;
+  const userId = Number(req.body?.user_id || currentUser(req).id);
+  if (!['admin', 'public', 'set_decorator'].includes(role)) return res.status(400).json({ error: 'role must be admin|public|set_decorator' });
+  db.prepare('UPDATE users SET user_role=? WHERE id=?').run(role, userId);
+  res.json({ ok: true, user_id: userId, role });
+});
+
+// Admin-only — flip the admin's own `view_as` so they can preview the
+// public/set_decorator UI without leaving their session. Pass null/''
+// to exit preview mode.
+app.put('/api/admin/view-as', (req, res) => {
+  if (!adminGate(req)) return res.status(403).json({ error: 'admin only' });
   const u = currentUser(req);
-  res.json({ user: u, onboarded: !!u.onboarded_at });
+  let viewAs = req.body?.view_as;
+  if (viewAs === '' || viewAs === null) viewAs = null;
+  else if (!['public', 'set_decorator'].includes(viewAs)) return res.status(400).json({ error: 'view_as must be public|set_decorator|null' });
+  db.prepare('UPDATE users SET view_as=? WHERE id=?').run(viewAs, u.id);
+  res.json({ ok: true, view_as: viewAs });
+});
+
+// Productions — list for self (any logged-in user; gated UI for set_decorator role).
+app.get('/api/productions', (req, res) => {
+  const u = currentUser(req);
+  const rows = db.prepare('SELECT * FROM productions WHERE user_id=? ORDER BY year DESC, id DESC').all(u.id);
+  // Annotate each with the count of closet rows and tryon_jobs anchored on it.
+  const counts = rows.map(r => {
+    const closetCount = db.prepare('SELECT COUNT(*) c FROM closet WHERE production_id=?').get(r.id).c;
+    const tryonCount = db.prepare('SELECT COUNT(*) c FROM tryon_jobs WHERE production_id=?').get(r.id).c;
+    return { ...r, closet_count: closetCount, tryon_count: tryonCount, total_credits: closetCount + tryonCount };
+  });
+  res.json({ productions: counts });
+});
+
+app.post('/api/productions', (req, res) => {
+  const u = currentUser(req);
+  const { title, year, kind, role } = req.body || {};
+  if (!title || String(title).trim().length < 2) return res.status(400).json({ error: 'title required' });
+  const { lastInsertRowid } = db.prepare(
+    'INSERT INTO productions (user_id, title, year, kind, role) VALUES (?, ?, ?, ?, ?)'
+  ).run(u.id, String(title).trim(), year ? Number(year) : null, kind || 'tv-episode', role || 'Set Decoration Buyer');
+  res.json({ ok: true, id: lastInsertRowid });
 });
 
 app.post('/api/onboard', (req, res) => {

← 4633077 ux(credits): shotonwhat-style item résumé — every try-on/out  ·  back to Whatsmystyle  ·  feat(productions): per-production résumé + closet/tryon tagg 545c7d2 →