← back to Wallco Ai
front-end: bring wall+room viewer to customer-facing /design/:id (iframe public/wall-room-viewer.html w/ 24/36/54 repeat pills + wall-width slider 96-240in + repeat-count + pinstripes + ft+in rulers). Add hidden admin-nav block to public hamburger w/ links to all curator tools; /api/admin/whoami probe unhides it for admin requests. Same wall/room tech now visible on both front (customers, no API cost) and back (admins, full curator)
557b826c35ad1e5e90857a13ed86a4edeadf62af · 2026-05-28 10:22:32 -0700 · Steve Abrams
Files touched
A public/wall-room-viewer.htmlM server.js
Diff
commit 557b826c35ad1e5e90857a13ed86a4edeadf62af
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu May 28 10:22:32 2026 -0700
front-end: bring wall+room viewer to customer-facing /design/:id (iframe public/wall-room-viewer.html w/ 24/36/54 repeat pills + wall-width slider 96-240in + repeat-count + pinstripes + ft+in rulers). Add hidden admin-nav block to public hamburger w/ links to all curator tools; /api/admin/whoami probe unhides it for admin requests. Same wall/room tech now visible on both front (customers, no API cost) and back (admins, full curator)
---
public/wall-room-viewer.html | 189 +++++++++++++++++++++++++++++++++++++++++++
server.js | 35 ++++++++
2 files changed, 224 insertions(+)
diff --git a/public/wall-room-viewer.html b/public/wall-room-viewer.html
new file mode 100644
index 0000000..d3850a5
--- /dev/null
+++ b/public/wall-room-viewer.html
@@ -0,0 +1,189 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Wall preview — wallco.ai</title>
+<style>
+ /* Customer-facing wall+repeat preview. Ported from the admin drunk-curator's
+ room-scale scene minus the curator-only bits (Furniture render, Extract,
+ mark-misaligned boxes). Standalone — designed to drop into the /design/:id
+ page either inline or via iframe; ?id=<design_id> picks the pattern. */
+ :root{--bg:#fafaf7;--panel:#fff;--line:#e5e0d4;--ink:#1c1b18;--mut:#6a6357;
+ --accent:#d2b15c;--accent-soft:#f4eccb;}
+ *{box-sizing:border-box;}
+ html,body{margin:0;background:var(--bg);color:var(--ink);
+ font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;}
+ .frame{max-width:1080px;margin:0 auto;padding:18px;}
+ .controls{display:flex;gap:18px;align-items:center;flex-wrap:wrap;
+ padding:12px 14px;background:var(--panel);border:1px solid var(--line);
+ border-radius:10px;margin-bottom:14px;}
+ .ctl{display:flex;flex-direction:column;gap:4px;}
+ .ctl .lab{font:600 11.5px/1 inherit;color:var(--mut);text-transform:uppercase;letter-spacing:.06em;}
+ .ctl .val{font:600 13px/1 inherit;color:var(--ink);font-variant-numeric:tabular-nums;}
+ input[type=range]{accent-color:var(--accent);width:200px;}
+ .pillset{display:flex;gap:4px;}
+ .pillset button{font:700 12px inherit;padding:6px 12px;border-radius:7px;
+ border:1px solid var(--line);background:#fff;color:var(--ink);cursor:pointer;}
+ .pillset button.on{background:var(--accent);color:#1a1a1a;border-color:var(--accent);}
+ .pillset button:hover:not(.on){border-color:var(--accent);}
+ .info{margin-left:auto;font-size:12.5px;color:var(--mut);}
+ .info b{color:var(--ink);font-variant-numeric:tabular-nums;}
+
+ .stage{position:relative;background:var(--panel);border:1px solid var(--line);
+ border-radius:12px;padding:32px 40px 40px;}
+ .room{position:relative;margin:0 auto;background-color:#f0ece2;
+ background-repeat:repeat;background-position:left top;
+ border:1px solid #c8c0ad;box-shadow:0 12px 36px rgba(0,0,0,.06) inset;}
+ .repeats{position:absolute;inset:0;pointer-events:none;}
+ .repeats .vline{position:absolute;top:0;bottom:0;width:1px;
+ background:rgba(0,0,0,.35);box-shadow:0 0 0 .5px rgba(255,255,255,.4);}
+ .repeats .vline.edge{background:rgba(0,0,0,.55);width:2px;}
+ .repbadge{position:absolute;top:8px;right:8px;background:rgba(0,0,0,.78);
+ color:#fff;font:700 11px ui-monospace,monospace;padding:4px 8px;border-radius:999px;}
+ /* room rulers in feet+inches */
+ .rh,.rv{position:absolute;color:var(--mut);font:10.5px ui-monospace,monospace;}
+ .rh{left:0;height:14px;bottom:-18px;}
+ .rv{top:0;width:24px;left:-30px;}
+ .rt{position:absolute;background:var(--mut);}
+ .rh .rt{top:0;width:1px;height:5px;} .rh .rt.maj{height:9px;background:#000;}
+ .rv .rt{left:0;height:1px;width:5px;} .rv .rt.maj{width:9px;background:#000;}
+ .rh .rl{position:absolute;top:6px;transform:translateX(-50%);white-space:nowrap;}
+ .rv .rl{position:absolute;left:12px;transform:translateY(-50%);white-space:nowrap;}
+ .roomtag{position:absolute;bottom:8px;left:8px;color:var(--mut);font:11px ui-monospace,monospace;background:rgba(255,255,255,.78);padding:3px 7px;border-radius:4px;}
+
+ .empty{padding:80px 20px;text-align:center;color:var(--mut);}
+</style>
+</head>
+<body>
+<div class="frame">
+ <div id="err" class="empty" style="display:none">No design — append <code>?id=<design></code> to the URL.</div>
+ <div id="root" style="display:none">
+ <div class="controls">
+ <div class="ctl">
+ <span class="lab">Repeat width</span>
+ <div class="pillset" id="scalepills">
+ <button data-w="24">24″</button>
+ <button data-w="36" class="on">36″</button>
+ <button data-w="54">54″</button>
+ </div>
+ </div>
+ <div class="ctl" style="min-width:230px">
+ <span class="lab">Wall width <span class="val" id="wallval">13.5′</span></span>
+ <input type="range" id="wallSlider" min="96" max="240" step="6" value="162">
+ </div>
+ <div class="info">
+ <b id="repCount">—</b> repeats across · pattern tile <b id="repInches">36</b>″ wide
+ </div>
+ </div>
+
+ <div class="stage">
+ <div class="room" id="room">
+ <div class="repeats" id="repeats"></div>
+ <div class="repbadge" id="repbadge">—</div>
+ <div class="roomtag" id="roomtag"></div>
+ </div>
+ <div class="rh" id="roomRulerH"></div>
+ <div class="rv" id="roomRulerV"></div>
+ </div>
+ </div>
+</div>
+
+<script>
+const params = new URLSearchParams(location.search);
+const DESIGN_ID = parseInt(params.get('id'), 10);
+if (!Number.isFinite(DESIGN_ID) || DESIGN_ID < 1) {
+ document.getElementById('err').style.display = 'block';
+} else {
+ document.getElementById('root').style.display = 'block';
+}
+
+const SCALE_WIDTHS = [24, 36, 54]; // standard wallpaper / panel widths
+let scaleInches = parseInt(localStorage.getItem('wrv_scale') || '36', 10);
+if (!SCALE_WIDTHS.includes(scaleInches)) scaleInches = 36;
+let wallInches = Math.min(240, Math.max(96, parseInt(localStorage.getItem('wrv_wall') || '162', 10)));
+
+const $ = id => document.getElementById(id);
+const fmtFt = inches => {
+ const f = Math.floor(inches / 12), i = Math.round(inches % 12);
+ return i ? `${f}′${i}″` : `${f}′`;
+};
+
+function applyScale(){
+ // Set the room canvas dims (square): width follows the viewport; cap at 900.
+ const stage = document.querySelector('.stage');
+ const roomW = Math.min(900, stage.clientWidth - 80);
+ const room = $('room');
+ room.style.width = roomW + 'px';
+ room.style.height = roomW + 'px';
+ room.style.backgroundImage = `url(/designs/img/by-id/${DESIGN_ID})`;
+ // backgroundSize = repeat width / wall width × 100 (one pattern repeat as %
+ // of the wall). Smaller repeat = smaller % = more repeats fitting on the wall.
+ const sizePct = (scaleInches / wallInches * 100).toFixed(2);
+ room.style.backgroundSize = sizePct + '% auto';
+
+ // Pinstripe lines at each repeat boundary
+ const ppi = roomW / wallInches;
+ const repPx = scaleInches * ppi;
+ const nRep = wallInches / scaleInches;
+ const rep = $('repeats');
+ let s = '';
+ for (let x = 0; x <= roomW + 0.5; x += repPx) {
+ const edge = (x < 1 || x > roomW - 1);
+ s += `<div class="vline${edge ? ' edge' : ''}" style="left:${x.toFixed(1)}px"></div>`;
+ }
+ rep.innerHTML = s;
+ $('repbadge').textContent = `↔ ${nRep.toFixed(nRep % 1 ? 1 : 0)} × ${scaleInches}″`;
+ $('repCount').textContent = nRep.toFixed(nRep % 1 ? 1 : 0);
+ $('repInches').textContent = scaleInches;
+ $('roomtag').textContent = `${fmtFt(wallInches)} × ${fmtFt(wallInches)} wall · ${scaleInches}″ repeat`;
+ $('wallval').textContent = fmtFt(wallInches);
+
+ // Pill highlight
+ document.querySelectorAll('#scalepills button')
+ .forEach(b => b.classList.toggle('on', +b.dataset.w === scaleInches));
+
+ // Rulers in feet+inches (tick every 6", labels at every 12")
+ const rh = $('roomRulerH');
+ rh.style.width = roomW + 'px';
+ rh.style.left = (room.offsetLeft) + 'px';
+ let rhs = '';
+ for (let inch = 0; inch <= wallInches; inch += 6) {
+ const maj = inch % 12 === 0, x = (inch * ppi).toFixed(1);
+ rhs += `<div class="rt${maj ? ' maj' : ''}" style="left:${x}px"></div>`;
+ if (maj) rhs += `<div class="rl" style="left:${x}px">${fmtFt(inch)}</div>`;
+ }
+ rh.innerHTML = rhs;
+ const rv = $('roomRulerV');
+ rv.style.height = roomW + 'px';
+ rv.style.top = (room.offsetTop) + 'px';
+ let rvs = '';
+ for (let inch = 0; inch <= wallInches; inch += 6) {
+ const maj = inch % 12 === 0, y = (inch * ppi).toFixed(1);
+ rvs += `<div class="rt${maj ? ' maj' : ''}" style="top:${y}px"></div>`;
+ if (maj) rvs += `<div class="rl" style="top:${y}px">${fmtFt(inch)}</div>`;
+ }
+ rv.innerHTML = rvs;
+}
+
+// Wire controls
+document.querySelectorAll('#scalepills button').forEach(b => {
+ b.addEventListener('click', () => {
+ scaleInches = +b.dataset.w;
+ localStorage.setItem('wrv_scale', scaleInches);
+ applyScale();
+ });
+});
+$('wallSlider').value = wallInches;
+$('wallSlider').addEventListener('input', e => {
+ wallInches = Math.min(240, Math.max(96, parseInt(e.target.value, 10) || 162));
+ localStorage.setItem('wrv_wall', wallInches);
+ applyScale();
+});
+addEventListener('resize', applyScale);
+
+// Boot
+if (Number.isFinite(DESIGN_ID) && DESIGN_ID >= 1) applyScale();
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
index 8290f9e..1ee5caf 100644
--- a/server.js
+++ b/server.js
@@ -1325,6 +1325,12 @@ function writeSecretsLine(key, value) {
else text += (text.endsWith('\n') ? '' : '\n') + `${key}=${value}\n`;
fs.writeFileSync(p, text);
}
+// GET /api/admin/whoami — boolean admin-state probe for the public hamburger
+// nav. NOT a 404 when non-admin (avoids noisy console errors on every page);
+// just returns {isAdmin:false}.
+app.get('/api/admin/whoami', (req, res) => {
+ res.json({ isAdmin: !!isAdmin(req) });
+});
app.get('/api/etsy-setup/status', async (req, res) => {
if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
const env = readSecretsEnv();
@@ -4446,6 +4452,22 @@ function htmlHeader(active) {
<a href="/me/saved" class="${active === '/me/saved' ? 'active' : ''}">My Saved</a>
<a href="/me/activity" class="${active === '/me/activity' ? 'active' : ''}">My Activity</a>
<a href="/logout" class="nav-logout">⎋ Logout</a>
+
+ <!-- Admin tools menu — hidden by default; the /api/admin/whoami probe in the
+ script block below unhides it when the requester is admin (loopback /
+ dw_auth JWT / ?admin=TOKEN). One menu to reach every curator tool from
+ any page on the public site. -->
+ <div id="admin-nav" hidden style="margin-top:14px;padding-top:14px;border-top:1px solid var(--line,#e5e0d4)">
+ <div style="font:600 10.5px/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-faint,#7a6e5a);margin:0 14px 8px">Admin</div>
+ <a href="/admin/cactus-curator" class="${active === '/admin/cactus-curator' ? 'active' : ''}">🌵 Cactus Curator (grid)</a>
+ <a href="/admin/drunk-curator" class="${active === '/admin/drunk-curator' ? 'active' : ''}">🥃 Hot-or-Not + Wall Viewer</a>
+ <a href="/admin/rooms" class="${active === '/admin/rooms' ? 'active' : ''}">🛋 Room Renders Gallery</a>
+ <a href="/admin/elements" class="${active === '/admin/elements' ? 'active' : ''}">✂️ Elements Library</a>
+ <a href="/admin/seam-debug" class="${active === '/admin/seam-debug' ? 'active' : ''}">🔬 Seam Debugger</a>
+ <a href="/admin/etsy-bucket" class="${active === '/admin/etsy-bucket' ? 'active' : ''}">🛒 Etsy Bucket</a>
+ <a href="/admin/etsy-setup" class="${active === '/admin/etsy-setup' ? 'active' : ''}">⚙️ Etsy Activation</a>
+ <a href="/admin/golive" class="">📊 Go-Live Dashboard</a>
+ </div>
<button class="theme-toggle" id="theme-toggle" aria-label="Toggle dark/light mode">
<svg class="sun-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
<svg class="moon-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
@@ -4481,6 +4503,19 @@ function htmlHeader(active) {
try { window.dispatchEvent(new CustomEvent('trade-me-loaded', { detail: j })); } catch(e){}
})
.catch(function(){});
+
+ // Admin discovery — public pages don't render server-side admin flags, so
+ // ask the server. /api/admin/whoami is a cheap loopback-or-JWT check; reveals
+ // the #admin-nav block in the hamburger when isAdmin(req) is true.
+ fetch('/api/admin/whoami', { credentials: 'same-origin' })
+ .then(function(r){ return r.ok ? r.json() : {}; })
+ .then(function(j){
+ if (j && j.isAdmin) {
+ var box = document.getElementById('admin-nav');
+ if (box) box.hidden = false;
+ }
+ })
+ .catch(function(){});
// Listen for save-state changes from any surface (PDP toggle, /me/saved Remove,
// future autocomplete heart, etc.) and re-sync the nav pill count without a fetch.
window.addEventListener('wallco-saved-changed', function(ev){
← 153d690 etsyEligible: pass --path to seam scanner (resolve from DESI
·
back to Wallco Ai
·
wall-viewer: ROOM_API render as sign-in upsell. /api/room no 86dd47e →