[object Object]

← back to Wallco Ai

wallco_rooms persistence + gallery + admin remove/restore/marketing

e8f73bfb05b9ff2916f939c0b73a14a0df404ab5 · 2026-05-11 19:38:46 -0700 · SteveStudio2

Files touched

Diff

commit e8f73bfb05b9ff2916f939c0b73a14a0df404ab5
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Mon May 11 19:38:46 2026 -0700

    wallco_rooms persistence + gallery + admin remove/restore/marketing
---
 server.js | 102 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
 1 file changed, 96 insertions(+), 6 deletions(-)

diff --git a/server.js b/server.js
index 8e7053d..52e9212 100644
--- a/server.js
+++ b/server.js
@@ -66,7 +66,7 @@ function pgEsc(v) {
 
 // Admin-token check for destructive room ops. Empty/unset token => admin features locked off.
 const ADMIN_TOKEN = process.env.ADMIN_TOKEN || '';
-function isAdmin(req) {
+function isRoomsAdmin(req) {
   if (!ADMIN_TOKEN) return false;
   return (req.headers['x-admin-token'] === ADMIN_TOKEN)
       || (req.query && req.query.admin === ADMIN_TOKEN);
@@ -1958,9 +1958,99 @@ ${htmlHeader('/designs')}
       </div>` : ''}
     </div>
   </div>
+
+  <!-- Generated Rooms gallery — hydrates from /api/rooms?design_id={{id}} -->
+  <section id="rooms-gallery" class="rooms-gallery" data-design-id="${design.id}" style="margin:48px auto 0;max-width:1200px;padding:0 24px;">
+    <div class="rg-head" style="display:flex;align-items:baseline;gap:16px;border-top:1px solid var(--rule,#e5e5e0);padding-top:24px;margin-bottom:18px;">
+      <h2 style="margin:0;font:500 22px/1.2 ui-serif,Georgia,serif;">Generated rooms</h2>
+      <span id="rg-count" style="font:11px/1.4 ui-sans-serif,system-ui,sans-serif;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-faint,#6e6e68);"></span>
+      <span id="rg-admin-badge" style="display:none;margin-left:auto;font:10px/1.4 ui-sans-serif,system-ui,sans-serif;letter-spacing:.14em;text-transform:uppercase;color:#b00020;">admin mode</span>
+    </div>
+    <div id="rg-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:14px;"></div>
+    <div id="rg-empty" style="display:none;padding:32px;text-align:center;color:var(--ink-faint,#6e6e68);font-style:italic;">No rooms generated for this pattern yet. Use the "Generate Room" button above to create one.</div>
+  </section>
 </main>
 ${FOOTER}
 
+<script>
+(function(){
+  const sec = document.getElementById('rooms-gallery');
+  if (!sec) return;
+  const designId = sec.dataset.designId;
+  const params = new URLSearchParams(window.location.search);
+  let adminToken = params.get('admin') || localStorage.getItem('wallco_admin_token') || '';
+  if (params.get('admin')) localStorage.setItem('wallco_admin_token', adminToken);
+  if (adminToken) document.getElementById('rg-admin-badge').style.display = 'block';
+
+  async function refresh() {
+    const q = new URLSearchParams({ design_id: designId });
+    if (adminToken) { q.set('admin', adminToken); q.set('include_removed', '1'); }
+    let data;
+    try { data = await (await fetch('/api/rooms?' + q.toString())).json(); }
+    catch (e) { document.getElementById('rg-grid').innerHTML = '<div style="color:#b00020">' + e.message + '</div>'; return; }
+    const rooms = data.rooms || [];
+    const isAdmin = !!data.admin;
+    document.getElementById('rg-count').textContent = rooms.filter(r => !r.removed_at).length + ' saved';
+    document.getElementById('rg-empty').style.display = rooms.length ? 'none' : 'block';
+    document.getElementById('rg-grid').innerHTML = rooms.map(function(r){
+      const removed = !!r.removed_at;
+      const marketing = !!r.selected_for_marketing;
+      const tag = r.room_type ? r.room_type.replace(/_/g,' ') : '';
+      const dim = removed ? 'opacity:0.35;filter:grayscale(0.7)' : '';
+      const star = marketing ? '★' : '☆';
+      const adminCtrls = isAdmin
+        ? '<div style="position:absolute;top:6px;right:6px;display:flex;gap:4px;">' +
+            '<button data-id="' + r.id + '" data-act="marketing" title="Toggle marketing" style="background:rgba(0,0,0,0.75);color:#fff;border:0;border-radius:3px;padding:3px 7px;font-size:13px;cursor:pointer;">' + star + '</button>' +
+            (removed
+              ? '<button data-id="' + r.id + '" data-act="restore" title="Restore" style="background:rgba(0,0,0,0.75);color:#fff;border:0;border-radius:3px;padding:3px 7px;font-size:11px;cursor:pointer;">↺</button>'
+              : '<button data-id="' + r.id + '" data-act="remove" title="Remove" style="background:rgba(176,0,32,0.85);color:#fff;border:0;border-radius:3px;padding:3px 7px;font-size:11px;cursor:pointer;">×</button>'
+            ) +
+          '</div>'
+        : '';
+      return '<figure style="margin:0;background:var(--paper,#faf8f2);border:1px solid var(--rule,#e5e5e0);position:relative;' + dim + '">' +
+        '<a href="' + r.image_path + '" target="_blank" style="display:block;aspect-ratio:1/1;overflow:hidden;background:#000;">' +
+          '<img src="' + r.image_path + '" alt="Room with this pattern — ' + tag + '" loading="lazy" style="width:100%;height:100%;object-fit:cover;display:block;" />' +
+        '</a>' +
+        adminCtrls +
+        '<figcaption style="padding:8px 10px 10px;font:11px/1.4 ui-sans-serif,system-ui,sans-serif;color:var(--ink-faint,#6e6e68);display:flex;justify-content:space-between;gap:8px;">' +
+          '<span>' + tag + (marketing ? ' · <strong style="color:#9c7100;">marketing</strong>' : '') + '</span>' +
+          '<time>' + new Date(r.created_at).toLocaleDateString('en-US',{month:'short',day:'numeric'}) + '</time>' +
+        '</figcaption>' +
+      '</figure>';
+    }).join('');
+
+    if (isAdmin) {
+      document.querySelectorAll('#rg-grid button[data-act]').forEach(function(btn){
+        btn.addEventListener('click', async function(){
+          const id = btn.dataset.id, act = btn.dataset.act;
+          const headers = { 'Content-Type': 'application/json', 'X-Admin-Token': adminToken };
+          let url, body = '{}';
+          if (act === 'remove') {
+            if (!confirm('Remove this room? (soft-delete — recoverable)')) return;
+            url = '/api/rooms/' + id + '/remove';
+          } else if (act === 'restore') {
+            url = '/api/rooms/' + id + '/restore';
+          } else if (act === 'marketing') {
+            const fig = btn.closest('figure');
+            const wasStar = btn.textContent.trim() === '★';
+            url = '/api/rooms/' + id + '/marketing';
+            body = JSON.stringify({ selected: !wasStar });
+          }
+          try {
+            const r = await fetch(url, { method: 'POST', headers: headers, body: body });
+            const j = await r.json();
+            if (!j.ok) alert('failed: ' + (j.error||'unknown'));
+            refresh();
+          } catch (e) { alert(e.message); }
+        });
+      });
+    }
+  }
+  refresh();
+  window.addEventListener('wallco:room-generated', refresh);
+})();
+</script>
+
 <script type="application/ld+json">${schema}</script>
 ${HAMBURGER_JS}
 </body>
@@ -2617,7 +2707,7 @@ app.post('/api/room', async (req, res) => {
 // Returns generated rooms for a design (or all, if no design_id) with metadata.
 app.get('/api/rooms', (req, res) => {
   const designId = req.query.design_id ? parseInt(req.query.design_id, 10) : null;
-  const includeRemoved = req.query.include_removed === '1' && isAdmin(req);
+  const includeRemoved = req.query.include_removed === '1' && isRoomsAdmin(req);
   const marketingOnly = req.query.marketing_only === '1';
   try {
     const where = [];
@@ -2629,7 +2719,7 @@ app.get('/api/rooms', (req, res) => {
       FROM (SELECT id, design_id, room_type, angle, image_path, filename, file_size_bytes,
                    selected_for_marketing, removed_at, removed_reason, notes, created_at
               FROM wallco_rooms ${whereSQL}) t;`);
-    res.json({ ok: true, rooms: JSON.parse(raw || '[]'), admin: isAdmin(req) });
+    res.json({ ok: true, rooms: JSON.parse(raw || '[]'), admin: isRoomsAdmin(req) });
   } catch (e) {
     res.status(500).json({ ok: false, error: e.message });
   }
@@ -2639,7 +2729,7 @@ app.get('/api/rooms', (req, res) => {
 // Soft-deletes; the image file stays on disk so prior page links continue to work for caching.
 // Pass { hard: true, reason: "..." } to also unlink the file.
 app.post('/api/rooms/:id/remove', (req, res) => {
-  if (!isAdmin(req)) return res.status(403).json({ ok: false, error: 'admin only' });
+  if (!isRoomsAdmin(req)) return res.status(403).json({ ok: false, error: 'admin only' });
   const id = parseInt(req.params.id, 10);
   if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ ok: false, error: 'bad id' });
   const { reason = '', hard = false } = req.body || {};
@@ -2659,7 +2749,7 @@ app.post('/api/rooms/:id/remove', (req, res) => {
 
 // ── POST /api/rooms/:id/restore  (admin only) — reverses a soft-delete.
 app.post('/api/rooms/:id/restore', (req, res) => {
-  if (!isAdmin(req)) return res.status(403).json({ ok: false, error: 'admin only' });
+  if (!isRoomsAdmin(req)) return res.status(403).json({ ok: false, error: 'admin only' });
   const id = parseInt(req.params.id, 10);
   if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ ok: false, error: 'bad id' });
   try {
@@ -2674,7 +2764,7 @@ app.post('/api/rooms/:id/restore', (req, res) => {
 
 // ── POST /api/rooms/:id/marketing  (admin only) — toggle marketing flag + optional notes.
 app.post('/api/rooms/:id/marketing', (req, res) => {
-  if (!isAdmin(req)) return res.status(403).json({ ok: false, error: 'admin only' });
+  if (!isRoomsAdmin(req)) return res.status(403).json({ ok: false, error: 'admin only' });
   const id = parseInt(req.params.id, 10);
   if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ ok: false, error: 'bad id' });
   const { selected = true, notes = null } = req.body || {};

← c9365f5 design/:id: Photoshop-style edit panel — sliders, palette do  ·  back to Wallco Ai  ·  Fix /age-themes after debate-team review — strip fabricated f3c9acd →