[object Object]

← back to Wallco Live Viewer

live viewer: full per-SKU action suite (publish/unpublish/remove/room/regen/TIF) + multi-select bulk bar

7a2ef4e29df86c0abead0aa3baef1b3f03ea24ca · 2026-06-09 11:58:47 -0700 · Steve Abrams

Was a read-only watch grid. Now every card carries the whole wallco-ai admin
action suite, proxied server-side to 127.0.0.1:9905 (loopback = admin per
admin-gate). Strict path whitelist on /act so the proxy can't be used as an
open relay. File→DB-id resolver (PNG suffix is the SDXL seed, NOT the design
id — spoon_all_designs.local_path maps file→id, one batched psql per tick)
with live published/staged/removed badges. Checkbox multi-select + bulk
publish/unpublish/remove. Lightbox zoom. Bind tightened *:9788 → 127.0.0.1
since the page now carries admin actions. Settlement gate stays enforced
server-side (PG publish_check trigger).

Verified: publish→unpublish roundtrip on #57686 flips is_published t/f;
166/240 cards resolve to DB rows, orphans show 'awaiting DB row' w/o actions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 7a2ef4e29df86c0abead0aa3baef1b3f03ea24ca
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jun 9 11:58:47 2026 -0700

    live viewer: full per-SKU action suite (publish/unpublish/remove/room/regen/TIF) + multi-select bulk bar
    
    Was a read-only watch grid. Now every card carries the whole wallco-ai admin
    action suite, proxied server-side to 127.0.0.1:9905 (loopback = admin per
    admin-gate). Strict path whitelist on /act so the proxy can't be used as an
    open relay. File→DB-id resolver (PNG suffix is the SDXL seed, NOT the design
    id — spoon_all_designs.local_path maps file→id, one batched psql per tick)
    with live published/staged/removed badges. Checkbox multi-select + bulk
    publish/unpublish/remove. Lightbox zoom. Bind tightened *:9788 → 127.0.0.1
    since the page now carries admin actions. Settlement gate stays enforced
    server-side (PG publish_check trigger).
    
    Verified: publish→unpublish roundtrip on #57686 flips is_published t/f;
    166/240 cards resolve to DB rows, orphans show 'awaiting DB row' w/o actions.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .gitignore |   5 ++
 server.js  | 249 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
 2 files changed, 234 insertions(+), 20 deletions(-)

diff --git a/.gitignore b/.gitignore
index 3c45938..1924158 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,8 @@
 node_modules/
+.env*
+tmp/
 *.log
 .DS_Store
+dist/
+build/
+.next/
diff --git a/server.js b/server.js
index c8ce30b..4a69008 100644
--- a/server.js
+++ b/server.js
@@ -1,10 +1,13 @@
 #!/usr/bin/env node
-// wallco-live-viewer — watch wallco.ai patterns get generated in real time.
-// Zero deps. Tails ~/Projects/wallco-ai/data/generated for <ms>_<id>.png files
-// (the generators write one per tick), newest first, auto-refreshing.
+// wallco-live-viewer — watch wallco.ai patterns get generated in real time,
+// and ACT on them: per-card + bulk publish / unpublish / remove / room render /
+// seam-regen / TIF build, proxied to the local wallco-ai server (127.0.0.1:9905)
+// whose admin-gate grants full admin to on-box loopback requests.
+// Zero deps. Tails ~/Projects/wallco-ai/data/generated for <ms>_<id>.png files.
 const http = require('http');
 const fs = require('fs');
 const path = require('path');
+const { spawnSync } = require('child_process');
 
 const GEN_DIR = path.join(process.env.HOME, 'Projects/wallco-ai/data/generated');
 const LOGS = {
@@ -12,8 +15,19 @@ const LOGS = {
   'pattern': '/tmp/wallco-generator.log',
 };
 const PORT = process.env.PORT || 9788;
+const WALLCO = { host: '127.0.0.1', port: 9905 };
 const NAME_RE = /^(\d{10,})_(\d+)\.png$/;   // <ms>_<rand>.png, excludes *.original.png & derivatives
 
+// Only these wallco-ai paths may be proxied — keeps /act from being an open relay.
+const ACT_WHITELIST = [
+  /^\/api\/designs\/bulk-action$/,            // {action: publish|unpublish|delete, ids:[..]}
+  /^\/api\/design\/\d+\/remove(\?undo=1)?$/,  // single remove / undo
+  /^\/api\/admin\/design\/\d+\/build-tif$/,   // TIF print master (sync, up to 5 min)
+  /^\/api\/seam-debug\/\d+\/regenerate$/,     // regen from same prompt → NEW row
+  /^\/api\/room$/,                            // room mockup (~$0.10 metered)
+  /^\/api\/design\/\d+$/,                     // status lookup (GET)
+];
+
 function listDesigns(limit = 240) {
   let files;
   try { files = fs.readdirSync(GEN_DIR); } catch { return []; }
@@ -25,14 +39,43 @@ function listDesigns(limit = 240) {
     if (!Number.isFinite(created) || created < 1e12) {
       try { created = fs.statSync(path.join(GEN_DIR, f)).mtimeMs; } catch { continue; }
     }
-    out.push({ file: f, id: m[2], created });
+    out.push({ file: f, seed: m[2], created });
   }
   out.sort((a, b) => b.created - a.created);
   return out.slice(0, limit);
 }
 
-// Is a generator actively producing right now? (tick log touched in last 90s,
-// or a generator node process alive)
+// ── file → DB row resolver ────────────────────────────────────────────────
+// The PNG name is <ms>_<SDXL-seed>.png — the suffix is the seed, NOT the
+// design id. spoon_all_designs.local_path carries the exact filename, so one
+// batched psql query maps file → {id, is_published, user_removed}. The id
+// mapping is immutable (cached); publish/removed state is re-read each call
+// so card badges stay live.
+const PSQL = '/opt/homebrew/opt/postgresql@14/bin/psql';
+const idCache = new Map();   // file → id (permanent)
+
+function resolveDbRows(items) {
+  const files = items.map((d) => d.file);
+  if (!files.length) return {};
+  const inList = files.map((f) => `'${f.replace(/'/g, "''")}'`).join(',');
+  const sql = `SELECT regexp_replace(local_path,'^.*/','') AS fn, id, is_published, COALESCE(user_removed,false)
+               FROM spoon_all_designs
+               WHERE regexp_replace(local_path,'^.*/','') IN (${inList});`;
+  const r = spawnSync(PSQL, ['-U', 'stevestudio2', '-d', 'dw_unified', '-tA', '-F', '|', '-c', sql],
+    { encoding: 'utf8', timeout: 10000 });
+  const map = {};
+  if (r.status === 0) {
+    for (const line of (r.stdout || '').trim().split('\n')) {
+      if (!line) continue;
+      const [fn, id, pub, removed] = line.split('|');
+      map[fn] = { id: parseInt(id, 10), published: pub === 't', removed: removed === 't' };
+      idCache.set(fn, parseInt(id, 10));
+    }
+  }
+  return map;
+}
+
+// Is a generator actively producing right now? (tick log touched in last 90s)
 function liveSignal() {
   const now = Date.now();
   const sig = { generating: false, lastLog: null, recentTickMs: null };
@@ -48,43 +91,97 @@ function liveSignal() {
   return sig;
 }
 
+// Server-side proxy to wallco-ai. Sends NO client headers, so the upstream
+// socket peer is loopback with no x-forwarded-* → admin-gate grants admin.
+function proxyToWallco(method, actPath, bodyObj, cb) {
+  const payload = bodyObj ? JSON.stringify(bodyObj) : '';
+  const req = http.request({
+    host: WALLCO.host, port: WALLCO.port, path: actPath, method,
+    headers: payload
+      ? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) }
+      : {},
+    timeout: 6 * 60 * 1000,   // TIF build is sync and can take ~5 min
+  }, (up) => {
+    let buf = '';
+    up.on('data', (c) => buf += c);
+    up.on('end', () => cb(null, up.statusCode, buf));
+  });
+  req.on('timeout', () => { req.destroy(new Error('upstream timeout')); });
+  req.on('error', (e) => cb(e));
+  if (payload) req.write(payload);
+  req.end();
+}
+
 const HTML = `<!doctype html><html lang="en"><head><meta charset="utf-8">
 <meta name="viewport" content="width=device-width, initial-scale=1">
 <title>wallco.ai · live pattern generation</title>
 <style>
-:root{--cols:5;--bg:#0b0b0e;--card:#16161c;--ink:#f4f4f6;--mut:#9a9aa6;--ok:#22c55e;--hot:#f59e0b}
+:root{--cols:5;--bg:#0b0b0e;--card:#16161c;--ink:#f4f4f6;--mut:#9a9aa6;--ok:#22c55e;--hot:#f59e0b;--bad:#ef4444;--acc:#818cf8}
 *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
 header{position:sticky;top:0;z-index:5;background:linear-gradient(180deg,#0b0b0e,#0b0b0ee0);backdrop-filter:blur(6px);
- padding:14px 18px;border-bottom:1px solid #23232b;display:flex;align-items:center;gap:16px;flex-wrap:wrap}
+ padding:14px 18px;border-bottom:1px solid #23232b;display:flex;align-items:center;gap:14px;flex-wrap:wrap}
 h1{font-size:16px;margin:0;font-weight:700;letter-spacing:.3px}
 .dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:6px;vertical-align:middle}
 .dot.live{background:var(--hot);box-shadow:0 0 0 0 rgba(245,158,11,.6);animation:pulse 1.3s infinite}
 .dot.idle{background:var(--ok)}
 @keyframes pulse{0%{box-shadow:0 0 0 0 rgba(245,158,11,.55)}70%{box-shadow:0 0 0 8px rgba(245,158,11,0)}100%{box-shadow:0 0 0 0 rgba(245,158,11,0)}}
 .pill{background:#23232b;color:var(--mut);padding:4px 10px;border-radius:999px;font-size:12px}
+.pill a{color:var(--acc);text-decoration:none}
 .spacer{flex:1}
 .ctrl{display:flex;align-items:center;gap:8px;color:var(--mut);font-size:12px}
 select,input[type=range]{accent-color:var(--hot)}
 select{background:#23232b;color:var(--ink);border:1px solid #32323c;border-radius:8px;padding:5px 8px}
 main{padding:16px 18px}
 .grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:14px}
-.card{background:var(--card);border:1px solid #23232b;border-radius:12px;overflow:hidden;position:relative;transition:transform .15s}
+.card{background:var(--card);border:1px solid #23232b;border-radius:12px;overflow:hidden;position:relative;transition:transform .15s,border-color .15s}
 .card:hover{transform:translateY(-2px);border-color:#3a3a44}
-.card img{width:100%;aspect-ratio:1/1;object-fit:cover;display:block;background:#0e0e12}
+.card.sel{border-color:var(--acc);box-shadow:0 0 0 1px var(--acc)}
+.card img{width:100%;aspect-ratio:1/1;object-fit:cover;display:block;background:#0e0e12;cursor:zoom-in}
 .meta{padding:8px 10px}
 .when{color:var(--mut);font-size:11px;display:flex;align-items:center;gap:5px}
-.id{color:var(--ink);font-size:12px;font-weight:600;margin-top:2px}
+.id{color:var(--ink);font-size:12px;font-weight:600;margin-top:2px;display:flex;align-items:center;gap:8px}
+.state{font-size:10px;padding:1px 7px;border-radius:999px;background:#23232b;color:var(--mut)}
+.state.pub{background:#143a23;color:#4ade80}.state.unpub{background:#3a2914;color:#fbbf24}.state.gone{background:#3a1414;color:#f87171}
 .new{position:absolute;top:8px;left:8px;background:var(--hot);color:#1a1205;font-weight:800;font-size:10px;
- padding:2px 7px;border-radius:999px;letter-spacing:.5px;animation:fadeNew 6s forwards}
+ padding:2px 7px;border-radius:999px;letter-spacing:.5px;animation:fadeNew 6s forwards;z-index:2}
 @keyframes fadeNew{0%,70%{opacity:1}100%{opacity:0}}
+.pick{position:absolute;top:8px;right:8px;z-index:3;width:18px;height:18px;accent-color:var(--acc);cursor:pointer}
+.acts{display:flex;flex-wrap:wrap;gap:5px;padding:0 10px 10px}
+.acts button,.acts a{background:#23232b;border:1px solid #32323c;color:var(--ink);border-radius:7px;
+ font-size:11px;padding:4px 8px;cursor:pointer;text-decoration:none;line-height:1.3}
+.acts button:hover,.acts a:hover{border-color:var(--acc);color:#c7d2fe}
+.acts button:disabled{opacity:.4;cursor:wait}
+.acts .danger:hover{border-color:var(--bad);color:#fca5a5}
+.bulk{display:none;align-items:center;gap:10px;background:#1b1b30;border:1px solid #34345c;border-radius:10px;padding:6px 12px}
+.bulk.on{display:flex}
+.bulk button{background:#2b2b4a;border:1px solid #44447a;color:var(--ink);border-radius:7px;font-size:12px;padding:5px 10px;cursor:pointer}
+.bulk button:hover{border-color:var(--acc)}
+.bulk .danger:hover{border-color:var(--bad);color:#fca5a5}
 .empty{color:var(--mut);text-align:center;padding:60px;font-size:15px}
+#toasts{position:fixed;bottom:18px;right:18px;z-index:50;display:flex;flex-direction:column;gap:8px;max-width:380px}
+.toast{background:#16161c;border:1px solid #32323c;border-left:4px solid var(--acc);border-radius:10px;padding:10px 14px;font-size:13px;
+ box-shadow:0 8px 24px rgba(0,0,0,.5);animation:tIn .2s}
+.toast.ok{border-left-color:var(--ok)}.toast.err{border-left-color:var(--bad)}
+@keyframes tIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
+#lightbox{display:none;position:fixed;inset:0;background:#000c;z-index:40;align-items:center;justify-content:center;cursor:zoom-out}
+#lightbox img{max-width:92vw;max-height:92vh;border-radius:8px}
 </style></head><body>
 <header>
  <h1>wallco.ai <span style="color:var(--mut);font-weight:400">· live generation</span></h1>
  <span class="pill" id="status"><span class="dot idle"></span><span id="statustxt">connecting…</span></span>
  <span class="pill" id="count">—</span>
  <span class="pill" id="latest">—</span>
+ <span class="pill">🌵 <a href="http://127.0.0.1:9905/admin/cactus-curator" target="_blank">curator</a></span>
+ <span class="pill">🔍 <a href="http://127.0.0.1:9905/admin/edges-review" target="_blank">edges</a></span>
+ <div class="bulk" id="bulk">
+  <span id="bulkn">0 selected</span>
+  <button onclick="bulkAct('publish')">✅ Publish</button>
+  <button onclick="bulkAct('unpublish')">🚫 Unpublish</button>
+  <button class="danger" onclick="bulkAct('delete')">🗑 Remove</button>
+  <button onclick="clearSel()">✕</button>
+ </div>
  <span class="spacer"></span>
+ <div class="ctrl"><label><input type="checkbox" id="selall"> select all</label></div>
  <div class="ctrl">sort
   <select id="sort">
    <option value="newest">Newest</option>
@@ -97,31 +194,113 @@ main{padding:16px 18px}
  </div>
 </header>
 <main><div class="grid" id="grid"></div><div class="empty" id="empty" style="display:none">No patterns yet — waiting for the next generator tick…</div></main>
+<div id="toasts"></div>
+<div id="lightbox" onclick="this.style.display='none'"><img id="lbimg"></div>
 <script>
 const grid=document.getElementById('grid'), gEmpty=document.getElementById('empty');
-let seen=new Set(), first=true;
+let seen=new Set(), first=true, last=[], sel=new Set(), states={};
 const LS=k=>localStorage.getItem('wlv:'+k), setLS=(k,v)=>localStorage.setItem('wlv:'+k,v);
 const colsEl=document.getElementById('cols'), sortEl=document.getElementById('sort');
 colsEl.value=LS('cols')||5; sortEl.value=LS('sort')||'newest';
 function applyCols(){document.documentElement.style.setProperty('--cols',colsEl.value);setLS('cols',colsEl.value);}
 colsEl.oninput=applyCols; sortEl.onchange=()=>{setLS('sort',sortEl.value);render(last);}; applyCols();
 function fmt(ms){return new Date(ms).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit',second:'2-digit'});}
-let last=[];
+function toast(msg,cls){const t=document.createElement('div');t.className='toast '+(cls||'');t.textContent=msg;
+ document.getElementById('toasts').appendChild(t);setTimeout(()=>t.remove(),6000);}
 function sortItems(items){const s=sortEl.value;const a=[...items];
  if(s==='oldest')a.sort((x,y)=>x.created-y.created);
- else if(s==='id')a.sort((x,y)=>(+x.id)-(+y.id));
+ else if(s==='id')a.sort((x,y)=>(x.dbId||1e15)-(y.dbId||1e15));
  else a.sort((x,y)=>y.created-x.created); return a;}
+
+async function act(path,body,method){
+ const r=await fetch('/act',{method:'POST',headers:{'content-type':'application/json'},
+   body:JSON.stringify({path,body,method:method||'POST'})});
+ const d=await r.json().catch(()=>({error:'bad json'}));
+ if(!r.ok||d.error)throw new Error(d.error||('HTTP '+r.status));
+ return d;
+}
+
+function stateBadge(d){
+ const s=states[d.dbId]||d.state;   // optimistic overlay until next tick
+ if(s==='pub')return '<span class="state pub">published</span>';
+ if(s==='unpub')return '<span class="state unpub">staged</span>';
+ if(s==='gone')return '<span class="state gone">removed</span>';
+ return '<span class="state">no row yet</span>';
+}
+
+async function doAct(btn,id,kind){
+ const card=btn.closest('.card');[...card.querySelectorAll('button')].forEach(b=>b.disabled=true);
+ try{
+  if(kind==='publish'){await act('/api/designs/bulk-action',{action:'publish',ids:[+id]});states[id]='pub';toast('#'+id+' published ✅','ok');}
+  else if(kind==='unpublish'){await act('/api/designs/bulk-action',{action:'unpublish',ids:[+id]});states[id]='unpub';toast('#'+id+' unpublished','ok');}
+  else if(kind==='remove'){if(!confirm('Remove #'+id+' from the catalog? (undoable)'))return;
+   await act('/api/design/'+id+'/remove',{});states[id]='gone';toast('#'+id+' removed 🗑 (click ↩ to undo)','ok');}
+  else if(kind==='undo'){await act('/api/design/'+id+'/remove?undo=1',{});delete states[id];toast('#'+id+' restored ↩','ok');}
+  else if(kind==='room'){if(!confirm('Render a room mockup for #'+id+'? (~10¢ via Gemini, ~30-60s)'))return;
+   toast('#'+id+' room render started…');const d=await act('/api/room',{design_id:+id,roomType:'living_room'});
+   toast('#'+id+' room done 🛋','ok');if(d.url||d.image)window.open(d.url||d.image,'_blank');}
+  else if(kind==='regen'){if(!confirm('Regenerate #'+id+' from its prompt? Creates a NEW design row (Round-1 originals stay sacred). May take minutes + AI cost.'))return;
+   toast('#'+id+' regen started…');const d=await act('/api/seam-debug/'+id+'/regenerate',{});
+   toast('regen → new design #'+(d.newId||d.new_id||'?')+' 🔁','ok');}
+  else if(kind==='tif'){if(!confirm('Build the TIF print master for #'+id+'? Sync — can take up to 5 min.'))return;
+   toast('#'+id+' TIF building…');const d=await act('/api/admin/design/'+id+'/build-tif',{});
+   toast('#'+id+' TIF '+(d.ok?'built 🏗':'FAILED: '+(d.stderr||'').slice(-120)),d.ok?'ok':'err');}
+ }catch(e){toast('#'+id+' '+kind+' failed: '+e.message,'err');}
+ finally{[...card.querySelectorAll('button')].forEach(b=>b.disabled=false);render(last);}
+}
+
+async function bulkAct(action){
+ const ids=[...sel].map(Number);
+ if(!ids.length)return;
+ const label={publish:'Publish',unpublish:'Unpublish',delete:'REMOVE'}[action];
+ if(!confirm(label+' '+ids.length+' design(s)? '+(action==='delete'?'(undoable per-card)':'')))return;
+ try{
+  await act('/api/designs/bulk-action',{action,ids});
+  ids.forEach(id=>states[id]=action==='publish'?'pub':action==='unpublish'?'unpub':'gone');
+  toast(label+' done for '+ids.length+' designs','ok');clearSel();
+ }catch(e){toast('Bulk '+action+' failed: '+e.message,'err');}
+ render(last);
+}
+function clearSel(){sel.clear();document.getElementById('selall').checked=false;syncBulk();render(last);}
+function syncBulk(){const b=document.getElementById('bulk');b.classList.toggle('on',sel.size>0);
+ document.getElementById('bulkn').textContent=sel.size+' selected';}
+function togglePick(cb,id){cb.checked?sel.add(id):sel.delete(id);syncBulk();
+ cb.closest('.card').classList.toggle('sel',cb.checked);}
+document.getElementById('selall').onchange=function(){
+ if(this.checked){last.forEach(d=>{if(d.dbId)sel.add(d.dbId);});}else sel.clear();
+ syncBulk();render(last);};
+
 function render(items){last=items;
  if(!items.length){grid.innerHTML='';gEmpty.style.display='block';return;} gEmpty.style.display='none';
  const sorted=sortItems(items);
  grid.innerHTML=sorted.map(d=>{
    const isNew=!first&&!seen.has(d.file);
-   return '<div class="card">'+(isNew?'<span class="new">NEW</span>':'')+
-     '<img loading="lazy" src="/img/'+encodeURIComponent(d.file)+'" alt="design '+d.id+'">'+
+   const id=d.dbId;
+   const picked=id&&sel.has(id);
+   const gone=(states[id]||d.state)==='gone';
+   return '<div class="card'+(picked?' sel':'')+'" data-id="'+(id||'')+'">'+(isNew?'<span class="new">NEW</span>':'')+
+     (id?'<input type="checkbox" class="pick" '+(picked?'checked ':'')+'onchange="togglePick(this,'+id+')">':'')+
+     '<img loading="lazy" src="/img/'+encodeURIComponent(d.file)+'" alt="design '+(id||d.seed)+'" onclick="lb(this.src)">'+
      '<div class="meta"><div class="when" title="'+new Date(d.created).toISOString()+'">🕓 '+fmt(d.created)+'</div>'+
-     '<div class="id">#'+d.id+'</div></div></div>';
+     '<div class="id">'+(id?('#'+id):('seed '+d.seed))+' '+stateBadge(d)+'</div></div>'+
+     '<div class="acts">'+
+      (id
+       ? '<a href="http://127.0.0.1:9905/design/'+id+'" target="_blank" title="open PDP">👁 PDP</a>'+
+         (gone
+          ? '<button onclick="doAct(this,'+id+',\\'undo\\')">↩ Undo</button>'
+          : '<button onclick="doAct(this,'+id+',\\'publish\\')" title="settlement gate enforced server-side">✅ Publish</button>'+
+            '<button onclick="doAct(this,'+id+',\\'unpublish\\')">🚫 Unpub</button>'+
+            '<button onclick="doAct(this,'+id+',\\'room\\')" title="~10¢ metered">🛋 Room</button>'+
+            '<button onclick="doAct(this,'+id+',\\'regen\\')" title="new row, original kept">🔁 Regen</button>'+
+            '<button onclick="doAct(this,'+id+',\\'tif\\')" title="print master, up to 5 min">🏗 TIF</button>'+
+            '<button class="danger" onclick="doAct(this,'+id+',\\'remove\\')">🗑</button>')
+       : '<span style="color:var(--mut);font-size:11px">awaiting DB row…</span>')+
+     '</div></div>';
  }).join('');
  items.forEach(d=>seen.add(d.file)); first=false;}
+
+function lb(src){document.getElementById('lbimg').src=src;document.getElementById('lightbox').style.display='flex';}
+
 async function tick(){
  try{
   const r=await fetch('/api/recent');const d=await r.json();
@@ -142,8 +321,38 @@ const server = http.createServer((req, res) => {
     res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(HTML);
   }
   if (u.pathname === '/api/recent') {
+    const items = listDesigns();
+    const rows = resolveDbRows(items);
+    for (const d of items) {
+      const row = rows[d.file];
+      d.dbId = row ? row.id : (idCache.get(d.file) || null);
+      d.state = row ? (row.removed ? 'gone' : row.published ? 'pub' : 'unpub') : null;
+    }
     res.writeHead(200, { 'content-type': 'application/json' });
-    return res.end(JSON.stringify({ items: listDesigns(), live: liveSignal() }));
+    return res.end(JSON.stringify({ items, live: liveSignal() }));
+  }
+  if (u.pathname === '/act' && req.method === 'POST') {
+    let raw = '';
+    req.on('data', (c) => { raw += c; if (raw.length > 65536) req.destroy(); });
+    req.on('end', () => {
+      let p;
+      try { p = JSON.parse(raw || '{}'); } catch { res.writeHead(400); return res.end('{"error":"bad json"}'); }
+      const actPath = String(p.path || '');
+      const method = p.method === 'GET' ? 'GET' : 'POST';
+      if (!ACT_WHITELIST.some((rx) => rx.test(actPath))) {
+        res.writeHead(403, { 'content-type': 'application/json' });
+        return res.end(JSON.stringify({ error: 'path not allowed: ' + actPath }));
+      }
+      proxyToWallco(method, actPath, method === 'GET' ? null : (p.body || {}), (err, code, body) => {
+        if (err) {
+          res.writeHead(502, { 'content-type': 'application/json' });
+          return res.end(JSON.stringify({ error: 'wallco-ai unreachable: ' + err.message }));
+        }
+        res.writeHead(code, { 'content-type': 'application/json' });
+        res.end(body || '{}');
+      });
+    });
+    return;
   }
   if (u.pathname.startsWith('/img/')) {
     const f = decodeURIComponent(u.pathname.slice(5));
@@ -158,4 +367,4 @@ const server = http.createServer((req, res) => {
   }
   res.writeHead(404); res.end('not found');
 });
-server.listen(PORT, () => console.log('wallco-live-viewer → http://127.0.0.1:' + PORT + '  (watching ' + GEN_DIR + ')'));
+server.listen(PORT, '127.0.0.1', () => console.log('wallco-live-viewer → http://127.0.0.1:' + PORT + '  (watching ' + GEN_DIR + ', actions → ' + WALLCO.host + ':' + WALLCO.port + ')'));

← 09578af wallco live pattern-generation viewer (zero-dep, tails data/  ·  back to Wallco Live Viewer  ·  viewer: state filter (All/Published/Staged/Removed/Awaiting f1c90e2 →