← back to Dw Fleet Registry
viewer/index.html
422 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DW Fleet — Live Registry</title>
<style>
:root{--bg:#0e0f12;--panel:#16181d;--ink:#e8e6e1;--muted:#8b8f98;--rule:#272a31;--accent:#9c7a4d;--cols:5}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--ink);font-family:'Inter',system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased}
header{position:sticky;top:0;z-index:5;background:rgba(14,15,18,.92);backdrop-filter:blur(8px);border-bottom:1px solid var(--rule);padding:16px 24px}
.htop{display:flex;align-items:baseline;gap:14px;flex-wrap:wrap}
h1{font-family:'Cormorant Garamond',Georgia,serif;font-weight:600;font-size:26px;letter-spacing:.04em}
.sub{color:var(--muted);font-size:13px}
.controls{display:flex;gap:18px;align-items:center;margin-top:12px;flex-wrap:wrap}
.ctl{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--muted)}
select,input[type=range],input[type=search]{background:var(--panel);color:var(--ink);border:1px solid var(--rule);border-radius:6px;padding:6px 9px;font-size:12px;font-family:inherit}
input[type=search]{min-width:200px}
button{background:var(--accent);color:#1a1a1a;border:0;border-radius:6px;padding:7px 13px;font-size:12px;font-weight:600;cursor:pointer}
.stat{display:inline-flex;gap:5px;align-items:center}
.dot{width:8px;height:8px;border-radius:50%;display:inline-block}
.grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:14px;padding:20px 24px}
.card{position:relative;background:var(--panel);border:1px solid var(--rule);border-radius:10px;overflow:hidden;cursor:pointer;transition:transform .12s,border-color .12s;text-decoration:none;color:inherit;display:flex;flex-direction:column}
.card:hover{transform:translateY(-2px);border-color:var(--accent)}
.accentbar{height:5px}
.thumb{aspect-ratio:16/9;background:#0a0b0d center/cover no-repeat;border-bottom:1px solid var(--rule);position:relative;display:flex;align-items:center;justify-content:center;overflow:hidden}
.thumb img.heroimg{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;z-index:1}
.thumb .ph{font-family:'Cormorant Garamond',serif;font-size:22px;color:var(--muted);letter-spacing:.1em;text-align:center;padding:0 10px;z-index:0}
.thumb .heroref{position:absolute;top:6px;right:6px;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:13px;font-weight:400;line-height:1;display:flex;align-items:center;justify-content:center;opacity:0;transition:opacity .12s,background .12s}
.thumb:hover .heroref{opacity:1}
.thumb .heroref:hover{background:var(--accent);color:#1a1a1a;border-color:var(--accent)}
/* whole card is the drop zone — overlay covers the full card so the target is large */
.card.dragover{border-color:var(--accent)}
.card.dragover::after{content:'drop image to set hero';position:absolute;inset:0;z-index:6;display:flex;align-items:center;justify-content:center;text-align:center;padding:0 12px;background:rgba(14,15,18,.74);color:var(--ink);font-size:12px;letter-spacing:.06em;text-transform:uppercase;border:2px dashed var(--accent);border-radius:10px;pointer-events:none}
.thumb.busy{opacity:.55}
#toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:50;background:var(--panel);color:var(--ink);border:1px solid var(--rule);border-radius:8px;padding:9px 16px;font-size:12px;box-shadow:0 6px 24px rgba(0,0,0,.4);opacity:0;pointer-events:none;transition:opacity .2s}
#toast.show{opacity:1}
.body{padding:11px 12px 13px;display:flex;flex-direction:column;gap:6px;flex:1}
.dom{font-size:13px;font-weight:600;word-break:break-all;line-height:1.25}
.nm{font-size:11px;color:var(--muted)}
.badges{display:flex;flex-wrap:wrap;gap:5px;margin-top:auto;padding-top:6px}
.b{font-size:9px;letter-spacing:.04em;text-transform:uppercase;padding:2px 6px;border-radius:4px;border:1px solid var(--rule);color:var(--muted)}
.b.live{color:#4ade80;border-color:#4ade8044}.b.parked{color:#fbbf24;border-color:#fbbf2444}.b.down{color:#f87171;border-color:#f8717144}
.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>
<header>
<div class="htop">
<h1>Designer Wallcoverings — Live Fleet</h1>
<span class="sub" id="summary">loading…</span>
<span class="count" id="count"></span>
</div>
<div class="controls">
<div class="ctl">Sort
<select id="sort">
<option value="domain">Domain A→Z</option>
<option value="status">Live status</option>
<option value="serving">Serving mode</option>
<option value="fleet">Fleet</option>
<option value="hue">Accent hue</option>
</select>
</div>
<div class="ctl">Density <input type="range" id="density" min="3" max="8" value="5"></div>
<div class="ctl"><input type="search" id="q" placeholder="filter domains…"></div>
<label class="ctl" style="cursor:pointer"><input type="checkbox" id="dedupe" checked> Hide redirect dupes</label>
<button id="rescrape" title="Re-scrape every site's live hero page and re-bake heroes (no live-status probe)">↻ Rescrape heroes (all)</button>
<button id="reprobe">↻ Re-probe live</button>
</div>
</header>
<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>
<select id="libKind" style="margin-left:auto">
<option value="room">🛋 Rooms only</option>
<option value="">All images</option>
<option value="pattern">Patterns</option>
<option value="swatch">Swatches</option>
</select>
<input type="search" id="libQ" placeholder="filter by name…" style="min-width:160px">
</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 class="btn-ghost" id="poolSave">Save (registry)</button>
<button id="poolDeploy">Save & deploy live</button>
</footer>
</div>
</div>
<script>
const $ = s => document.querySelector(s);
// Route cross-origin hero images through the same-origin proxy (fleet sites send
// CORP same-origin, which blocks direct <img> hotlinking). Local /static & /img pass through.
const heroSrc = u => !u ? '' : (u.charAt(0)==='/' ? u : '/img?u='+encodeURIComponent(u));
// Downsized variant for GRID THUMBNAILS only — ask Shopify for a small render so a tile
// pulls ~30KB instead of the multi-MB master. The actual saved hero always keeps the
// full-res URL; this only rewrites the displayed src. Non-Shopify URLs fall back to full.
const thumbSrc = (u, w) => {
if(!u) return '';
if(u.charAt(0)==='/') return u; // local /static, /img — already small
if(/cdn\.shopify\.com/i.test(u)){ const [base,qs]=u.split('?'); const ps=new URLSearchParams(qs||''); ps.set('width', String(w||400)); return '/img?u='+encodeURIComponent(base+'?'+ps.toString()); }
return '/img?u='+encodeURIComponent(u)+'&w='+(w||400); // non-Shopify → server-side resize
};
let SITES = [];
const hueOf = hex => { if(!/^#?[0-9a-f]{6}$/i.test(hex||''))return 999; const n=parseInt(hex.replace('#',''),16); const r=(n>>16&255)/255,g=(n>>8&255)/255,b=(n&255)/255; const mx=Math.max(r,g,b),mn=Math.min(r,g,b),d=mx-mn; let h=0; if(d){ if(mx===r)h=((g-b)/d)%6; else if(mx===g)h=(b-r)/d+2; else h=(r-g)/d+4; } return Math.round(h*60+360)%360; };
const accentOf = s => (s.local&&s.local.port?null:null) || (s.manifest&&s.manifest.theme&&s.manifest.theme.accent) || (s.local&&s.local.theme&&s.local.theme.accent) || '#9c7a4d';
function render(){
const q = $('#q').value.trim().toLowerCase();
const sort = $('#sort').value;
const dedupe = $('#dedupe').checked;
// Map each canonical domain -> list of redirect aliases that fold into it.
const aliasOf = {}; for(const s of SITES){ if(s.shouldRedirectTo){ (aliasOf[s.shouldRedirectTo]=aliasOf[s.shouldRedirectTo]||[]).push(s.domain); } }
let rows = SITES.filter(s => !q || s.domain.includes(q) || (s.siteName||'').toLowerCase().includes(q));
// When deduping, drop the non-canonical redirect siblings; they fold into their canonical card.
if(dedupe) rows = rows.filter(s => !s.shouldRedirectTo);
const cmp = {
domain:(a,b)=>a.domain.localeCompare(b.domain),
status:(a,b)=>(a.live?.state||'z').localeCompare(b.live?.state||'z')||a.domain.localeCompare(b.domain),
serving:(a,b)=>(a.servingModes?.[0]||'').localeCompare(b.servingModes?.[0]||'')||a.domain.localeCompare(b.domain),
fleet:(a,b)=>(a.fleets?.[0]||'').localeCompare(b.fleets?.[0]||'')||a.domain.localeCompare(b.domain),
hue:(a,b)=>hueOf(a._accent)-hueOf(b._accent),
}[sort];
rows.sort(cmp);
$('#count').textContent = rows.length + ' shown';
const g = $('#grid'); g.innerHTML = '';
for(const s of rows){
const a = document.createElement('a');
a.className='card'; a.href='https://'+s.domain; a.target='_blank'; a.rel='noopener noreferrer';
const st = s.live?.state||'?';
const stClass = st==='live'?'live':st==='parked'?'parked':(st==='unreachable'||st==='timeout'||st==='error')?'down':'';
const serving = (s.servingModes||[]).map(m=>m==='standalone-node'?'local':'manifest');
const redir = s.shouldRedirectTo ? '<span class="b redir">→ '+s.shouldRedirectTo.replace(/\\.com$/,'')+'</span>' : '';
const aliases = aliasOf[s.domain] || [];
const canon = (s.isCanonical) ? '<span class="b canon">canonical</span>'+(dedupe&&aliases.length?'<span class="b redir" title="'+aliases.join(', ')+'">+'+aliases.length+' alias</span>':'') : '';
a.innerHTML =
'<div class="accentbar" style="background:'+s._accent+'"></div>'+
'<div class="thumb" data-domain="'+s.domain+'" style="background-color:'+s._accent+'22">'+
(s.heroImage?'<img class="heroimg" loading="lazy" src="'+thumbSrc(s.heroImage,800)+'" 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>'+
'<div class="nm">'+(s.siteName||'')+'</div>'+
'<div class="badges">'+
'<span class="b '+stClass+'"><span class="dot" style="background:currentColor"></span>'+st+'</span>'+
serving.map(m=>'<span class="b '+m+'">'+m+'</span>').join('')+
canon+redir+
'</div>'+
'</div>';
g.appendChild(a);
}
lazyHeroes();
}
let io;
function lazyHeroes(){
if(io) io.disconnect();
io = new IntersectionObserver((ents)=>{
for(const e of ents){ if(!e.isIntersecting) continue; const el=e.target; io.unobserve(el);
if(el.querySelector('img.heroimg')) continue; // baked hero already painted
const d=el.dataset.domain;
fetch('/api/hero?domain='+encodeURIComponent(d)).then(r=>r.json()).then(j=>{
if(j.image){ const u=thumbSrc(j.image,800); const img=new Image(); img.onload=()=>{ el.style.backgroundImage='url('+u+')'; const ph=el.querySelector('.ph'); if(ph)ph.style.display='none'; }; img.src=u; }
}).catch(()=>{});
}
},{rootMargin:'300px'});
document.querySelectorAll('.thumb').forEach(t=>io.observe(t));
}
async function load(){
const r = await fetch('/api/registry'); const reg = await r.json();
SITES = (reg.sites||[]).map(s=>({...s, _accent: ((s.manifest&&s.manifest.theme&&s.manifest.theme.accent)||(s.local&&s.local.theme&&s.local.theme.accent)||'#9c7a4d')}));
const sm = reg.summary||{}; const tally = reg.probeSummary?.tally||{};
$('#summary').textContent = (sm.totalDomains||SITES.length)+' domains · '+(sm.localMicrosites||0)+' local + '+(sm.manifestDomains||0)+' manifest · '+(tally.live||0)+' live'+(reg.probeSummary?' · probed '+new Date(reg.probeSummary.probedAt).toLocaleString():'');
render();
}
$('#sort').onchange=render; $('#q').oninput=render; $('#dedupe').onchange=()=>{localStorage['dwfleet-dedupe']=$('#dedupe').checked?'1':'0';render();};
if(localStorage['dwfleet-dedupe']==='0') $('#dedupe').checked=false;
$('#density').oninput=e=>document.documentElement.style.setProperty('--cols', e.target.value);
// persist controls
for(const id of ['sort','density']){ const el=$('#'+id); const k='dwfleet-'+id; if(localStorage[k]){ el.value=localStorage[k]; if(id==='density')document.documentElement.style.setProperty('--cols',el.value);} el.addEventListener('change',()=>localStorage[k]=el.value); el.addEventListener('input',()=>localStorage[k]=el.value); }
$('#reprobe').onclick=async()=>{ const b=$('#reprobe'); b.textContent='probing…'; b.disabled=true; try{ await fetch('/api/reprobe',{method:'POST'}); await load(); }finally{ b.textContent='↻ Re-probe live'; b.disabled=false; } };
$('#rescrape').onclick=async()=>{ const b=$('#rescrape'); b.textContent='rescraping…'; b.disabled=true; try{ const r=await fetch('/api/rescrape-heroes',{method:'POST'}); const j=await r.json().catch(()=>({})); if(j&&j.ok){ toast('Heroes rescraped — registry rebaked'); } else { toast('Rescrape failed'+(j&&j.error?': '+j.error:'')); } await load(); }catch{ toast('Rescrape failed'); }finally{ b.textContent='↻ Rescrape heroes (all)'; b.disabled=false; } };
// ---- per-card hero refresh + drag-to-set ----
let toastT;
function toast(msg){ const t=$('#toast'); t.textContent=msg; t.classList.add('show'); clearTimeout(toastT); toastT=setTimeout(()=>t.classList.remove('show'),2600); }
// Paint a freshly-assigned hero into a thumb and keep SITES in sync so re-render persists it.
function paintHero(thumb, url){
const src=heroSrc(url);
let img=thumb.querySelector('img.heroimg');
if(!img){ img=document.createElement('img'); img.className='heroimg'; img.loading='lazy'; img.alt=''; img.onerror=function(){this.remove();}; thumb.appendChild(img); }
img.src=src; thumb.style.backgroundImage='';
const ph=thumb.querySelector('.ph'); if(ph) ph.style.display='none';
const s=SITES.find(x=>x.domain===thumb.dataset.domain); if(s) s.heroImage=url;
}
async function postJSON(path, body){ const r=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}); return r.json().catch(()=>({error:'bad response'})); }
// Refresh button (delegated; cards are <a>, so swallow the click).
$('#grid').addEventListener('click', async (e)=>{
const btn=e.target.closest('.heroref'); if(!btn) return;
e.preventDefault(); e.stopPropagation();
const thumb=btn.closest('.thumb'); const d=thumb.dataset.domain;
thumb.classList.add('busy'); btn.disabled=true;
try{
const j=await postJSON('/api/hero/refresh',{domain:d});
if(j.ok){ paintHero(thumb, j.image); toast('New hero for '+d); }
else toast('Refresh failed: '+(j.error||'unknown'));
}catch{ toast('Refresh failed'); }
finally{ thumb.classList.remove('busy'); btn.disabled=false; }
});
// Drag-and-drop onto a chip: a local file uploads; a dragged web image sets by URL.
function extractUrl(dt){
const uri=(dt.getData('text/uri-list')||'').split('\n').map(s=>s.trim()).find(s=>/^https?:\/\//i.test(s));
if(uri) return uri;
const html=dt.getData('text/html'); if(html){ const m=html.match(/<img[^>]+src=["']([^"']+)["']/i); if(m&&/^https?:\/\//i.test(m[1])) return m[1]; }
const txt=(dt.getData('text/plain')||'').trim(); if(/^https?:\/\/.+\.(jpg|jpeg|png|webp|avif|gif)/i.test(txt)) return txt;
return null;
}
$('#grid').addEventListener('dragover', (e)=>{ const c=e.target.closest('.card'); if(!c) return; e.preventDefault(); e.dataTransfer.dropEffect='copy'; c.classList.add('dragover'); });
$('#grid').addEventListener('dragleave', (e)=>{ const c=e.target.closest('.card'); if(c && !c.contains(e.relatedTarget)) c.classList.remove('dragover'); });
$('#grid').addEventListener('drop', async (e)=>{
const card=e.target.closest('.card'); if(!card) return;
e.preventDefault(); card.classList.remove('dragover');
const thumb=card.querySelector('.thumb'); if(!thumb) return;
const d=thumb.dataset.domain; const dt=e.dataTransfer;
thumb.classList.add('busy');
try{
const file=dt.files && dt.files[0];
if(file && /^image\//i.test(file.type)){
const ext=(file.name.split('.').pop()||(file.type.split('/')[1])||'jpg').toLowerCase();
const buf=await file.arrayBuffer();
const r=await fetch('/api/hero/upload?domain='+encodeURIComponent(d)+'&ext='+encodeURIComponent(ext),{method:'POST',headers:{'Content-Type':file.type||'application/octet-stream'},body:buf});
const j=await r.json().catch(()=>({error:'bad response'}));
if(j.ok){ paintHero(thumb, j.image); toast('Hero updated from '+file.name); } else toast('Upload failed: '+(j.error||'unknown'));
} else {
const url=extractUrl(dt);
if(!url){ toast('No image found in drop'); }
else { const j=await postJSON('/api/hero/set',{domain:d,url}); if(j.ok){ paintHero(thumb, j.image); toast('Hero set for '+d); } else toast('Set failed: '+(j.error||'unknown')); }
}
}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 curKind(){ return ($('#libKind')&&$('#libKind').value)||''; }
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="'+thumbSrc(u,200)+'" 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 k=curKind();
const items=(LIB||[])
.filter(im=>!k||(im.kind||'pattern')===k)
.filter(im=>!ql||((im.title||'')+' '+im.src).toLowerCase().includes(ql))
.slice(0,300);
grid.innerHTML = items.length ? 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="'+thumbSrc(im.src,400)+'" alt="">'+
'<div class="added-tick">✓</div>'+
'<div class="res"><span>'+(im.kind==='room'?'🛋 ':'')+im.w+'×'+im.h+'</span></div>'+
'</div>').join('') : '<div class="poolempty">No '+(k||'')+' images match. Try "All images" or a different search.</div>';
}
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);
$('#libKind').onchange=()=>renderLibrary($('#libQ').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();
});
async function savePool(){
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; } }
return j;
}
$('#poolSave').onclick=async()=>{
const b=$('#poolSave'); b.disabled=true; b.textContent='saving…';
try{ const j=await savePool();
if(j.ok){ toast('Saved '+j.images.length+' hero'+(j.images.length===1?'':'es')+' (registry) for '+poolDomain); closePool(); render(); }
else toast('Save failed: '+(j.error||'unknown'));
}catch{ toast('Save failed'); }
finally{ b.disabled=false; b.textContent='Save (registry)'; }
};
$('#poolDeploy').onclick=async()=>{
const b=$('#poolDeploy'); b.disabled=true; b.textContent='deploying…';
try{
const j=await savePool();
if(!j.ok){ toast('Save failed: '+(j.error||'unknown')); return; }
const dj=await postJSON('/api/hero-pool/deploy',{domain:poolDomain});
if(dj.ok){ toast('Deployed '+dj.count+' hero'+(dj.count===1?'':'es')+' live → '+poolDomain); closePool(); render(); }
else toast('Deploy failed: '+(dj.error||'unknown'));
}catch{ toast('Deploy failed'); }
finally{ b.disabled=false; b.textContent='Save & deploy live'; }
};
document.addEventListener('keydown',(e)=>{ if(e.key==='Escape'&&$('#poolModal').classList.contains('show')) closePool(); });
load();
</script>
<a href="https://auth.agentabrams.com/sso/logout" title="Sign out of all agentabrams admin tools" style="position:fixed;bottom:12px;right:14px;z-index:9999;font:12px/1 system-ui,sans-serif;background:#16181d;color:#e8e6e1;border:1px solid #333;border-radius:6px;padding:7px 11px;text-decoration:none;opacity:.85">Sign out</a>
</body>
</html>