← 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
M public/index.htmlM server.js
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 →