[object Object]

← back to Feature Harvest

feature-harvest: in-UI 'Rebuild thumbnails' button with async job + live progress polling

a537317e01f657d45334ec1199f13b0413b6f202 · 2026-06-26 09:01:02 -0700 · Steve

Files touched

Diff

commit a537317e01f657d45334ec1199f13b0413b6f202
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Jun 26 09:01:02 2026 -0700

    feature-harvest: in-UI 'Rebuild thumbnails' button with async job + live progress polling
---
 public/index.html | 25 +++++++++++++++++++++++++
 server.js         | 26 ++++++++++++++++++++++++++
 2 files changed, 51 insertions(+)

diff --git a/public/index.html b/public/index.html
index ba82730..3b11962 100644
--- a/public/index.html
+++ b/public/index.html
@@ -151,6 +151,7 @@
       <input type="range" id="dens" min="2" max="6" value="5" />
     </div>
     <button class="btn" id="refreshBtn" title="Re-pull wins from CNCP + rebuild">↻ Refresh</button>
+    <button class="btn" id="thumbsBtn" title="Re-screenshot app UIs (headless Chrome — takes ~1 min)">⟳ Rebuild thumbnails</button>
     <button class="btn primary" id="exportBtn">⬇ Export shortlist</button>
   </div>
   <div class="progress"><i id="bar"></i></div>
@@ -389,6 +390,30 @@ $('#refreshBtn').onclick=async()=>{
   await load();
   alert(d.ok?`Refreshed — ${d.count} items`:`Kept cached corpus (${d.count}) — ${d.error||'CNCP unreachable'}`);
 };
+// rebuild thumbnails (async job + progress polling)
+const thumbsBtn = $('#thumbsBtn');
+let thumbPoll = null;
+function bustThumbCache(){ // force <img> reload after regen (same filenames)
+  $$('.preview .thumb').forEach(im=>{ const u=im.getAttribute('src').split('?')[0]; im.src=u+'?t='+Date.now(); });
+}
+async function pollThumbs(){
+  const r = await fetch('/api/thumbs/status'); const s = await r.json();
+  if(s.running){
+    thumbsBtn.textContent = `⟳ Rebuilding… ${s.done}/${s.total||'?'}`;
+  } else {
+    clearInterval(thumbPoll); thumbPoll=null;
+    thumbsBtn.disabled=false; thumbsBtn.textContent='⟳ Rebuild thumbnails';
+    await load();            // re-fetch items (picks up new thumb manifest)
+    bustThumbCache();
+    if(s.error) alert('Thumbnail rebuild failed: '+s.error);
+  }
+}
+thumbsBtn.onclick=async()=>{
+  if(thumbPoll) return;
+  thumbsBtn.disabled=true; thumbsBtn.textContent='⟳ Starting…';
+  await fetch('/api/thumbs',{method:'POST'});
+  thumbPoll=setInterval(pollThumbs,1500); pollThumbs();
+};
 // keyboard (queue mode)
 document.addEventListener('keydown',e=>{
   if(state.mode!=='queue') return;
diff --git a/server.js b/server.js
index 02930d0..a34b5b0 100644
--- a/server.js
+++ b/server.js
@@ -96,6 +96,25 @@ function exportMarkdown() {
   );
 }
 
+// ---- thumbnail rebuild job (async, progress-tracked) --------------
+let thumbJob = { running: false, done: 0, total: 0, ok: 0, startedAt: 0, error: '' };
+function startThumbJob() {
+  if (thumbJob.running) return;
+  thumbJob = { running: true, done: 0, total: 0, ok: 0, startedAt: Date.now(), error: '' };
+  const child = require('child_process').spawn('node', [path.join(ROOT, 'build-thumbs.js')], { cwd: ROOT });
+  const onChunk = (buf) => {
+    const s = buf.toString();
+    let m, re = /(\d+)\/(\d+)/g;           // last "done/total" wins per chunk
+    while ((m = re.exec(s))) { thumbJob.done = +m[1]; thumbJob.total = +m[2]; }
+    const ok = s.match(/thumbnails:\s*(\d+)\/(\d+)/);
+    if (ok) { thumbJob.ok = +ok[1]; thumbJob.total = +ok[2]; }
+  };
+  child.stdout.on('data', onChunk);
+  child.stderr.on('data', () => {});       // ignore deprecation noise
+  child.on('error', (e) => { thumbJob.running = false; thumbJob.error = e.message; });
+  child.on('close', () => { thumbJob.running = false; });
+}
+
 // ---- routes -------------------------------------------------------
 const server = http.createServer((req, res) => {
   if (!authed(req)) {
@@ -140,6 +159,13 @@ const server = http.createServer((req, res) => {
   if (u.pathname === '/api/export') {
     return send(res, 200, exportMarkdown(), 'text/markdown; charset=utf-8');
   }
+  if (u.pathname === '/api/thumbs' && req.method === 'POST') {
+    startThumbJob();
+    return send(res, 200, { ...thumbJob });
+  }
+  if (u.pathname === '/api/thumbs/status') {
+    return send(res, 200, { ...thumbJob });
+  }
   if (u.pathname.startsWith('/thumb/')) {
     const file = path.basename(u.pathname.slice('/thumb/'.length)); // basename → no traversal
     const fp = path.join(THUMBS, file);

← 71db8fd feature-harvest: add static-render preview thumbnails for ap  ·  back to Feature Harvest  ·  feature-harvest: handle /favicon.ico (204) — silences consol bd3f8ae →