← 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 →