← back to Dw Fleet Registry
viewer: hero-pool picker — pick high-res images to rotate per site
706726cbd359363be83ee42a7b83fbd4d50eb5a5 · 2026-06-01 15:19:31 -0700 · SteveStudio2
Adds /api/image-library (265-img catalog-hires pool, highest-res first, searchable),
/api/hero-pool GET + /set. New ⊞ per-card button opens a modal: left = ordered
rotation pool (drag-reorder, remove, sort-by-resolution, top=primary), right =
high-res library grid (click to add/remove, shows WxH). Saves heroImages[] to the
registry; heroImage = pool[0]. Pairs with the live rotateHero v2 (hero-pool.json).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M viewer/index.htmlM viewer/server.mjs
Diff
commit 706726cbd359363be83ee42a7b83fbd4d50eb5a5
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Mon Jun 1 15:19:31 2026 -0700
viewer: hero-pool picker — pick high-res images to rotate per site
Adds /api/image-library (265-img catalog-hires pool, highest-res first, searchable),
/api/hero-pool GET + /set. New ⊞ per-card button opens a modal: left = ordered
rotation pool (drag-reorder, remove, sort-by-resolution, top=primary), right =
high-res library grid (click to add/remove, shows WxH). Saves heroImages[] to the
registry; heroImage = pool[0]. Pairs with the live rotateHero v2 (hero-pool.json).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
viewer/index.html | 161 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
viewer/server.mjs | 47 ++++++++++++++++
2 files changed, 208 insertions(+)
diff --git a/viewer/index.html b/viewer/index.html
index 2d4064f..e7ea8d8 100644
--- a/viewer/index.html
+++ b/viewer/index.html
@@ -44,6 +44,40 @@
.b.local{color:#7dd3fc;border-color:#7dd3fc44}.b.manifest{color:#c4b5fd;border-color:#c4b5fd44}
.b.canon{color:#fcd34d;border-color:#fcd34d55}.b.redir{color:#fb923c;border-color:#fb923c55}
.count{font-size:12px;color:var(--muted);margin-left:auto}
+ .thumb .heropool{position:absolute;top:6px;right:38px;z-index:4;width:26px;height:26px;padding:0;border-radius:50%;background:rgba(14,15,18,.72);color:var(--ink);border:1px solid var(--rule);font-size:12px;line-height:1;display:flex;align-items:center;justify-content:center;opacity:0;transition:opacity .12s,background .12s}
+ .thumb:hover .heropool{opacity:1}
+ .thumb .heropool:hover{background:var(--accent);color:#1a1a1a;border-color:var(--accent)}
+ .thumb .poolcount{position:absolute;bottom:6px;left:6px;z-index:3;font-size:9px;letter-spacing:.04em;background:rgba(14,15,18,.7);border:1px solid var(--rule);color:var(--ink);border-radius:4px;padding:1px 5px;pointer-events:none}
+ /* ---- hero-pool picker modal ---- */
+ .modal{display:none;position:fixed;inset:0;z-index:100;background:rgba(0,0,0,.66);align-items:center;justify-content:center}
+ .modal.show{display:flex}
+ .sheet{background:var(--panel);border:1px solid var(--rule);border-radius:12px;width:min(1100px,94vw);height:min(82vh,820px);display:flex;flex-direction:column;overflow:hidden;box-shadow:0 30px 80px rgba(0,0,0,.6)}
+ .sheet header{position:static;background:transparent;border-bottom:1px solid var(--rule);padding:14px 18px;display:flex;align-items:center;gap:12px}
+ .sheet header h2{font-family:'Cormorant Garamond',Georgia,serif;font-weight:600;font-size:20px;margin:0}
+ .sheet header .x{margin-left:auto;background:transparent;border:0;color:var(--muted);font-size:22px;cursor:pointer;padding:0 4px}
+ .sheet .cols{display:grid;grid-template-columns:300px 1fr;flex:1;min-height:0}
+ .pane{padding:14px 16px;overflow-y:auto;min-height:0}
+ .pane.left{border-right:1px solid var(--rule);display:flex;flex-direction:column;gap:8px}
+ .pane h3{font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);margin:0 0 6px}
+ .poolitem{display:flex;gap:9px;align-items:center;background:var(--bg);border:1px solid var(--rule);border-radius:8px;padding:6px;cursor:grab}
+ .poolitem.dragging{opacity:.4}
+ .poolitem img{width:64px;height:40px;object-fit:cover;border-radius:4px;flex-shrink:0;background:#000}
+ .poolitem .meta{font-size:10px;color:var(--muted);flex:1;min-width:0;word-break:break-all;line-height:1.3}
+ .poolitem .primary{color:var(--accent);font-weight:700}
+ .poolitem .rm{background:transparent;border:0;color:#f87171;font-size:16px;cursor:pointer;padding:0 4px;flex-shrink:0}
+ .poolempty{font-size:12px;color:var(--muted);padding:10px;border:1px dashed var(--rule);border-radius:8px;text-align:center}
+ .libbar{display:flex;gap:10px;align-items:center;margin-bottom:10px}
+ .libgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:9px}
+ .libcell{position:relative;border:1px solid var(--rule);border-radius:8px;overflow:hidden;cursor:pointer;aspect-ratio:1/1;background:#000}
+ .libcell img{width:100%;height:100%;object-fit:cover;display:block;transition:transform .15s}
+ .libcell:hover img{transform:scale(1.05)}
+ .libcell.added{outline:2px solid var(--accent);outline-offset:-2px}
+ .libcell .res{position:absolute;bottom:0;left:0;right:0;font-size:9px;background:rgba(0,0,0,.6);color:#fff;padding:2px 4px;display:flex;justify-content:space-between}
+ .libcell .added-tick{position:absolute;top:4px;right:4px;background:var(--accent);color:#1a1a1a;border-radius:50%;width:18px;height:18px;font-size:11px;display:none;align-items:center;justify-content:center}
+ .libcell.added .added-tick{display:flex}
+ .sheet footer{border-top:1px solid var(--rule);padding:12px 18px;display:flex;gap:10px;align-items:center}
+ .sheet footer .hint{font-size:11px;color:var(--muted);margin-right:auto}
+ .btn-ghost{background:transparent;border:1px solid var(--rule);color:var(--ink)}
</style>
</head>
<body>
@@ -73,6 +107,36 @@
<div class="grid" id="grid"></div>
<div id="toast"></div>
+<!-- hero-pool picker modal -->
+<div class="modal" id="poolModal">
+ <div class="sheet">
+ <header>
+ <h2 id="poolTitle">Hero pool</h2>
+ <span class="sub" id="poolSub"></span>
+ <button class="x" id="poolClose" title="close">×</button>
+ </header>
+ <div class="cols">
+ <div class="pane left">
+ <h3>Rotation pool — top is primary, drag to reorder</h3>
+ <div id="poolList"></div>
+ <button class="btn-ghost" id="poolSortRes" style="margin-top:6px">↧ Sort by resolution (highest first)</button>
+ </div>
+ <div class="pane">
+ <div class="libbar">
+ <h3 style="margin:0">High-res library — click to add</h3>
+ <input type="search" id="libQ" placeholder="filter by name…" style="margin-left:auto;min-width:180px">
+ </div>
+ <div class="libgrid" id="libGrid"></div>
+ </div>
+ </div>
+ <footer>
+ <span class="hint" id="poolHint"></span>
+ <button class="btn-ghost" id="poolCancel">Cancel</button>
+ <button id="poolSave">Save pool</button>
+ </footer>
+ </div>
+</div>
+
<script>
const $ = s => document.querySelector(s);
// Route cross-origin hero images through the same-origin proxy (fleet sites send
@@ -115,6 +179,8 @@ function render(){
'<div class="thumb" data-domain="'+s.domain+'" style="background-color:'+s._accent+'22">'+
(s.heroImage?'<img class="heroimg" loading="lazy" src="'+heroSrc(s.heroImage)+'" alt="" onerror="this.remove()">':'')+
'<button class="heroref" type="button" title="Refresh to a new image — or drag an image here to set it" aria-label="refresh hero">↻</button>'+
+ '<button class="heropool" type="button" title="Manage hero pool — pick high-res images to rotate" aria-label="manage hero pool">⊞</button>'+
+ ((s.heroImages&&s.heroImages.length>1)?'<span class="poolcount">'+s.heroImages.length+' heroes</span>':'')+
'<div class="ph">'+(s.siteName||s.domain).slice(0,18)+'</div></div>'+
'<div class="body">'+
'<div class="dom">'+s.domain+'</div>'+
@@ -220,6 +286,101 @@ $('#grid').addEventListener('drop', async (e)=>{
}catch(err){ toast('Drop failed'); }
finally{ thumb.classList.remove('busy'); }
});
+// ---- hero-pool picker modal ----
+let LIB = null; // [{src,w,h,title}] loaded once
+const LIBW = new Map(); // src -> {w,h}
+let poolDomain = null, poolArr = []; // current modal state
+
+async function ensureLibrary(){
+ if(LIB) return LIB;
+ const r = await fetch('/api/image-library?min=1400'); const j = await r.json();
+ LIB = j.images || []; LIB.forEach(im=>LIBW.set(im.src,{w:im.w,h:im.h}));
+ return LIB;
+}
+function resOf(u){ const r=LIBW.get(u); return r?(r.w+'×'+r.h):''; }
+function widthOf(u){ const r=LIBW.get(u); return r?r.w:0; }
+
+function renderPool(){
+ const host=$('#poolList');
+ if(!poolArr.length){ host.innerHTML='<div class="poolempty">No heroes yet — click images on the right to add. With an empty pool the live site keeps its sharp static hero.</div>'; }
+ else host.innerHTML = poolArr.map((u,i)=>
+ '<div class="poolitem" draggable="true" data-i="'+i+'">'+
+ '<img src="'+heroSrc(u)+'" alt="" onerror="this.style.visibility=\'hidden\'">'+
+ '<div class="meta">'+(i===0?'<span class="primary">PRIMARY · </span>':'')+(resOf(u)||'')+'<br>'+u.split('/').pop().split('?')[0].slice(0,42)+'</div>'+
+ '<button class="rm" data-rm="'+i+'" title="remove">×</button>'+
+ '</div>').join('');
+ $('#poolHint').textContent = poolArr.length+' hero'+(poolArr.length===1?'':'es')+' · rotates in this order, top = primary';
+ // reflect added state in library
+ document.querySelectorAll('.libcell').forEach(c=>c.classList.toggle('added', poolArr.includes(c.dataset.src)));
+}
+function renderLibrary(q){
+ const grid=$('#libGrid'); const ql=(q||'').toLowerCase();
+ const items=(LIB||[]).filter(im=>!ql||((im.title||'')+' '+im.src).toLowerCase().includes(ql)).slice(0,300);
+ grid.innerHTML = items.map(im=>
+ '<div class="libcell'+(poolArr.includes(im.src)?' added':'')+'" data-src="'+im.src.replace(/"/g,'"')+'" title="'+(im.title||'').replace(/"/g,'"')+'">'+
+ '<img loading="lazy" src="'+heroSrc(im.src)+'" alt="">'+
+ '<div class="added-tick">✓</div>'+
+ '<div class="res"><span>'+im.w+'×'+im.h+'</span></div>'+
+ '</div>').join('');
+}
+async function openPool(domain){
+ poolDomain=domain; $('#poolTitle').textContent='Hero pool'; $('#poolSub').textContent=domain;
+ $('#poolModal').classList.add('show');
+ $('#libGrid').innerHTML='<div class="poolempty">loading library…</div>';
+ $('#poolList').innerHTML='';
+ const [pj] = await Promise.all([ fetch('/api/hero-pool?domain='+encodeURIComponent(domain)).then(r=>r.json()), ensureLibrary() ]);
+ poolArr = (pj.images||[]).slice();
+ // make sure widths are known for any pool urls that are in the library
+ renderLibrary($('#libQ').value); renderPool();
+}
+function closePool(){ $('#poolModal').classList.remove('show'); poolDomain=null; poolArr=[]; }
+
+$('#grid').addEventListener('click',(e)=>{
+ const btn=e.target.closest('.heropool'); if(!btn) return;
+ e.preventDefault(); e.stopPropagation();
+ openPool(btn.closest('.thumb').dataset.domain);
+});
+$('#poolClose').onclick=closePool; $('#poolCancel').onclick=closePool;
+$('#poolModal').addEventListener('click',(e)=>{ if(e.target.id==='poolModal') closePool(); });
+$('#libQ').oninput=e=>renderLibrary(e.target.value);
+$('#poolSortRes').onclick=()=>{ poolArr.sort((a,b)=>widthOf(b)-widthOf(a)); renderPool(); };
+// add / remove from library grid
+$('#libGrid').addEventListener('click',(e)=>{
+ const cell=e.target.closest('.libcell'); if(!cell) return;
+ const src=cell.dataset.src;
+ const i=poolArr.indexOf(src);
+ if(i>=0) poolArr.splice(i,1); else poolArr.push(src);
+ renderPool();
+});
+// remove from pool list
+$('#poolList').addEventListener('click',(e)=>{
+ const rm=e.target.closest('[data-rm]'); if(!rm) return;
+ poolArr.splice(parseInt(rm.dataset.rm,10),1); renderPool();
+});
+// drag-reorder within the pool list
+let dragI=null;
+$('#poolList').addEventListener('dragstart',(e)=>{ const it=e.target.closest('.poolitem'); if(!it)return; dragI=+it.dataset.i; it.classList.add('dragging'); });
+$('#poolList').addEventListener('dragend',(e)=>{ const it=e.target.closest('.poolitem'); if(it)it.classList.remove('dragging'); dragI=null; });
+$('#poolList').addEventListener('dragover',(e)=>{ e.preventDefault(); });
+$('#poolList').addEventListener('drop',(e)=>{
+ e.preventDefault(); const it=e.target.closest('.poolitem'); if(it==null||dragI==null)return;
+ const toI=+it.dataset.i; if(toI===dragI)return;
+ const [m]=poolArr.splice(dragI,1); poolArr.splice(toI,0,m); renderPool();
+});
+$('#poolSave').onclick=async()=>{
+ const b=$('#poolSave'); b.disabled=true; b.textContent='saving…';
+ try{
+ const j=await postJSON('/api/hero-pool/set',{domain:poolDomain,images:poolArr});
+ if(j.ok){
+ const s=SITES.find(x=>x.domain===poolDomain); if(s){ s.heroImages=j.images; s.heroImage=j.primary||s.heroImage; }
+ toast('Saved '+j.images.length+' hero'+(j.images.length===1?'':'es')+' for '+poolDomain);
+ closePool(); render();
+ } else toast('Save failed: '+(j.error||'unknown'));
+ }catch{ toast('Save failed'); }
+ finally{ b.disabled=false; b.textContent='Save pool'; }
+};
+document.addEventListener('keydown',(e)=>{ if(e.key==='Escape'&&$('#poolModal').classList.contains('show')) closePool(); });
+
load();
</script>
</body>
diff --git a/viewer/server.mjs b/viewer/server.mjs
index cb0f3d0..10919c7 100644
--- a/viewer/server.mjs
+++ b/viewer/server.mjs
@@ -237,6 +237,53 @@ const server = http.createServer(async (req, res) => {
setTimeout(() => { if (!done) { done = true; rescrapeRunning = false; } }, 5 * 60 * 1000).unref();
return;
}
+ // High-res image library to choose hero images from (catalog-hires.json pool),
+ // sorted highest-res first, filtered to >= min width, optional ?q= title/meta search.
+ if (u.pathname === '/api/image-library') {
+ const q = (u.searchParams.get('q') || '').trim().toLowerCase();
+ const min = parseInt(u.searchParams.get('min') || '1400', 10) || 1400;
+ let pool = [];
+ try { pool = (JSON.parse(fs.readFileSync(CATALOG, 'utf8')).pool || []); } catch { /* none */ }
+ let out = pool
+ .filter(p => p && p.src && (p.w || 0) >= min)
+ .filter(p => !q || ((p.title || '') + ' ' + (p.meta || '')).toLowerCase().includes(q))
+ .sort((a, b) => (b.w || 0) - (a.w || 0))
+ .map(p => ({ src: p.src, w: p.w || 0, h: p.h || 0, title: p.title || '' }));
+ return json(res, 200, { count: out.length, images: out.slice(0, 600) });
+ }
+ // Read one site's hero rotation pool (heroImages[], highest-res first; primary = [0]).
+ if (u.pathname === '/api/hero-pool') {
+ const d = u.searchParams.get('domain') || '';
+ if (!/^[a-z0-9.-]+$/i.test(d)) return json(res, 400, { error: 'bad domain' });
+ try {
+ const reg = JSON.parse(fs.readFileSync(REG, 'utf8'));
+ const site = (reg.sites || []).find(s => s.domain === d);
+ if (!site) return json(res, 404, { error: 'domain not in registry' });
+ const pool = Array.isArray(site.heroImages) && site.heroImages.length ? site.heroImages
+ : (site.heroImage ? [site.heroImage] : []);
+ return json(res, 200, { domain: d, images: pool });
+ } catch { return json(res, 500, { error: 'registry read failed' }); }
+ }
+ // Set one site's full hero pool (ordered array). images[0] becomes the primary heroImage.
+ if (u.pathname === '/api/hero-pool/set' && req.method === 'POST') {
+ let body = {};
+ try { body = JSON.parse((await readBody(req)).toString('utf8') || '{}'); } catch { return json(res, 400, { error: 'bad json' }); }
+ const d = body.domain || '';
+ if (!/^[a-z0-9.-]+$/i.test(d)) return json(res, 400, { error: 'bad domain' });
+ let images = Array.isArray(body.images) ? body.images : [];
+ images = [...new Set(images.filter(u => typeof u === 'string' && /^(https?:\/\/|\/)/.test(u.trim())).map(u => u.trim()))];
+ try {
+ const reg = JSON.parse(fs.readFileSync(REG, 'utf8'));
+ const site = (reg.sites || []).find(s => s.domain === d);
+ if (!site) return json(res, 404, { error: 'domain not in registry' });
+ site.heroImages = images;
+ if (images.length) { site.heroImage = images[0]; site.heroSource = 'pool'; }
+ site.heroAt = new Date().toISOString();
+ fs.writeFileSync(REG, JSON.stringify(reg, null, 1));
+ heroCache.delete(d);
+ return json(res, 200, { ok: true, domain: d, images, primary: images[0] || null });
+ } catch (e) { return json(res, 500, { error: String(e && e.message || e) }); }
+ }
if (u.pathname === '/api/reprobe' && req.method === 'POST') {
heroCache.clear(); // force fresh hero resolution after a re-probe
const root = path.join(DIR, '..');
← 8a825a8 normalize header names → domain name across 53 sites (remove
·
back to Dw Fleet Registry
·
viewer: deploy curated hero pools live (hero-pool.json rsync e53dc97 →