← 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
M public/admin-config.htmlM public/css/app.cssM public/index.htmlM public/js/app.jsM server.js
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 →