← back to Wallco Live Viewer
wallco live pattern-generation viewer (zero-dep, tails data/generated)
09578af0509b01c9d7d8d8526f8b416fdee0429f · 2026-06-09 11:49:28 -0700 · Steve
Files touched
Diff
commit 09578af0509b01c9d7d8d8526f8b416fdee0429f
Author: Steve <steve@designerwallcoverings.com>
Date: Tue Jun 9 11:49:28 2026 -0700
wallco live pattern-generation viewer (zero-dep, tails data/generated)
---
.gitignore | 3 ++
server.js | 161 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 164 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3c45938
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+node_modules/
+*.log
+.DS_Store
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..c8ce30b
--- /dev/null
+++ b/server.js
@@ -0,0 +1,161 @@
+#!/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.
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+
+const GEN_DIR = path.join(process.env.HOME, 'Projects/wallco-ai/data/generated');
+const LOGS = {
+ 'drunk-animals': path.join(process.env.HOME, 'Projects/wallco-ai/data/logs/drunk_animals_tick.log'),
+ 'pattern': '/tmp/wallco-generator.log',
+};
+const PORT = process.env.PORT || 9788;
+const NAME_RE = /^(\d{10,})_(\d+)\.png$/; // <ms>_<rand>.png, excludes *.original.png & derivatives
+
+function listDesigns(limit = 240) {
+ let files;
+ try { files = fs.readdirSync(GEN_DIR); } catch { return []; }
+ const out = [];
+ for (const f of files) {
+ const m = f.match(NAME_RE);
+ if (!m) continue;
+ let created = parseInt(m[1], 10);
+ 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.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)
+function liveSignal() {
+ const now = Date.now();
+ const sig = { generating: false, lastLog: null, recentTickMs: null };
+ for (const [k, p] of Object.entries(LOGS)) {
+ try {
+ const st = fs.statSync(p);
+ if (!sig.recentTickMs || st.mtimeMs > sig.recentTickMs) {
+ sig.recentTickMs = st.mtimeMs; sig.lastLog = k;
+ }
+ if (now - st.mtimeMs < 90 * 1000) sig.generating = true;
+ } catch {}
+ }
+ return sig;
+}
+
+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}
+*{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}
+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}
+.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:hover{transform:translateY(-2px);border-color:#3a3a44}
+.card img{width:100%;aspect-ratio:1/1;object-fit:cover;display:block;background:#0e0e12}
+.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}
+.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}
+@keyframes fadeNew{0%,70%{opacity:1}100%{opacity:0}}
+.empty{color:var(--mut);text-align:center;padding:60px;font-size:15px}
+</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="spacer"></span>
+ <div class="ctrl">sort
+ <select id="sort">
+ <option value="newest">Newest</option>
+ <option value="oldest">Oldest</option>
+ <option value="id">ID ↑</option>
+ </select>
+ </div>
+ <div class="ctrl">density
+ <input type="range" id="cols" min="2" max="9" step="1">
+ </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>
+<script>
+const grid=document.getElementById('grid'), gEmpty=document.getElementById('empty');
+let seen=new Set(), first=true;
+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 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 a.sort((x,y)=>y.created-x.created); return a;}
+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+'">'+
+ '<div class="meta"><div class="when" title="'+new Date(d.created).toISOString()+'">🕓 '+fmt(d.created)+'</div>'+
+ '<div class="id">#'+d.id+'</div></div></div>';
+ }).join('');
+ items.forEach(d=>seen.add(d.file)); first=false;}
+async function tick(){
+ try{
+ const r=await fetch('/api/recent');const d=await r.json();
+ const st=document.getElementById('status'),tx=document.getElementById('statustxt'),dot=st.querySelector('.dot');
+ if(d.live&&d.live.generating){dot.className='dot live';tx.textContent='generating now…';}
+ else{dot.className='dot idle';tx.textContent='idle (cron armed)';}
+ document.getElementById('count').textContent=d.items.length+' patterns';
+ document.getElementById('latest').textContent=d.items.length?('latest '+fmt(d.items[0].created)):'—';
+ render(d.items);
+ }catch(e){document.getElementById('statustxt').textContent='offline';}
+}
+tick(); setInterval(tick,4000);
+</script></body></html>`;
+
+const server = http.createServer((req, res) => {
+ const u = new URL(req.url, 'http://x');
+ if (u.pathname === '/' || u.pathname === '/index.html') {
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(HTML);
+ }
+ if (u.pathname === '/api/recent') {
+ res.writeHead(200, { 'content-type': 'application/json' });
+ return res.end(JSON.stringify({ items: listDesigns(), live: liveSignal() }));
+ }
+ if (u.pathname.startsWith('/img/')) {
+ const f = decodeURIComponent(u.pathname.slice(5));
+ if (!NAME_RE.test(f)) { res.writeHead(404); return res.end('nope'); } // path-traversal guard
+ const fp = path.join(GEN_DIR, f);
+ fs.readFile(fp, (e, buf) => {
+ if (e) { res.writeHead(404); return res.end('not found'); }
+ res.writeHead(200, { 'content-type': 'image/png', 'cache-control': 'public, max-age=86400' });
+ res.end(buf);
+ });
+ return;
+ }
+ res.writeHead(404); res.end('not found');
+});
+server.listen(PORT, () => console.log('wallco-live-viewer → http://127.0.0.1:' + PORT + ' (watching ' + GEN_DIR + ')'));
(oldest)
·
back to Wallco Live Viewer
·
live viewer: full per-SKU action suite (publish/unpublish/re 7a2ef4e →