← back to Quadrille Internal

public/curate.html

161 lines

<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Select · Astek → Phillipe Romano</title>
<style>
:root{--cols:8;--bg:#0c0d12;--panel:#15171f;--ink:#eef0f5;--mut:#9aa0b0;--acc:#5eead4;--line:#262a36;--new:#f59e0b}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:13px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
header{position:sticky;top:0;z-index:9;background:linear-gradient(180deg,#0c0d12,#0c0d12e8);backdrop-filter:blur(8px);
 border-bottom:1px solid var(--line);padding:10px 14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap}
h1{font-size:14px;margin:0;font-weight:700}
h1 .pl{color:var(--acc)}
.pill{background:var(--new);color:#1a1205;font-weight:700;border-radius:999px;padding:2px 10px;font-size:12px;font-variant-numeric:tabular-nums}
.muted{color:var(--mut)}
select,input[type=text]{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12.5px}
input[type=range]{accent-color:var(--acc);width:150px}
.btn{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:5px 11px;cursor:pointer;font-size:12.5px;text-decoration:none}
.btn:hover{border-color:var(--acc)}
.btn.on{outline:1px solid var(--acc);color:var(--acc)}
.ctl{display:flex;align-items:center;gap:6px}
/* texture-only grid — the image IS the tile */
.grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:6px;padding:8px 10px 70px}
.tile{position:relative;aspect-ratio:1/1;background:#0a0b0f;border:1px solid var(--line);border-radius:2px;overflow:hidden;cursor:pointer;user-select:none}
.tile img{width:100%;height:100%;object-fit:cover;display:block;pointer-events:none}
.tile:hover{border-color:#414a5e}
.tile.sel{border-color:var(--acc);box-shadow:inset 0 0 0 2px var(--acc)}
.tile.sel::after{content:'✓';position:absolute;top:5px;right:5px;width:20px;height:20px;border-radius:50%;
 background:var(--acc);color:#04251e;font-weight:800;font-size:13px;display:flex;align-items:center;justify-content:center}
.tile .tip{position:absolute;left:0;right:0;bottom:0;background:#000b;color:#cbd5e1;font-size:10px;padding:2px 6px;
 white-space:nowrap;overflow:hidden;text-overflow:ellipsis;opacity:0;transition:opacity .12s}
.tile:hover .tip{opacity:1}
#more{display:block;margin:8px auto 70px;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:9px 18px;cursor:pointer}
#empty{padding:60px;text-align:center;color:var(--mut)}
/* selection tray */
#tray{position:fixed;left:0;right:0;bottom:0;z-index:20;display:flex;gap:10px;align-items:center;flex-wrap:wrap;
 background:#0e0f15f2;border-top:1px solid var(--line);padding:9px 14px;backdrop-filter:blur(8px)}
#tray .cnt{font-weight:700;color:var(--acc);font-variant-numeric:tabular-nums}
#save{font-size:11px;color:var(--mut)}
</style></head>
<body>
<header>
  <h1>Astek → <span class="pl">Phillipe Romano</span> · Select</h1>
  <span class="pill" id="total">…</span>
  <span class="muted" id="meta"></span>
  <div class="ctl"><input type="text" id="q" placeholder="search pattern / sku / color…"></div>
  <div class="ctl"><select id="book"><option value="">All books</option></select></div>
  <div class="ctl"><select id="color"><option value="">All colors</option></select></div>
  <div class="ctl"><label class="muted">Grid</label>
    <input type="range" id="density" min="1" max="20" step="1"><span class="muted" id="denv"></span></div>
  <a class="btn" href="/">← Catalog</a>
</header>
<div class="grid" id="grid"></div>
<button id="more" style="display:none">Load more</button>
<div id="empty" style="display:none">No products match.</div>
<div id="tray">
  <span><span class="cnt" id="selcount">0</span> selected</span>
  <button class="btn" id="selvis">☑ Select visible</button>
  <button class="btn" id="unselvis">☐ Unselect visible</button>
  <button class="btn" id="showsel">Show selected only</button>
  <button class="btn" id="clear">✕ Clear all</button>
  <button class="btn" id="csv">⬇ Export CSV</button>
  <span id="save" class="muted"></span>
  <span class="muted" style="margin-left:auto">click = select · shift-click = range · ⌘-click = open product</span>
</div>
<script>
const $=s=>document.querySelector(s);
const grid=$('#grid');
let ALL=[], VIEW=[], shown=0, lastIdx=null, showSelOnly=false;
const PAGE=400;
const SEL=new Set();          // dw_sku set — the Phillipe Romano candidate list
const BY_SKU={};              // dw_sku -> product
function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
const dens=localStorage.getItem('quadrille.cur.density')||'8';
$('#density').value=dens; $('#denv').textContent=dens;
document.documentElement.style.setProperty('--cols',dens);
$('#density').oninput=()=>{const v=$('#density').value;document.documentElement.style.setProperty('--cols',v);
 $('#denv').textContent=v;localStorage.setItem('quadrille.cur.density',v);};

function tile(p,idx){
  const on=SEL.has(p.dw_sku);
  return `<div class="tile${on?' sel':''}" data-sku="${esc(p.dw_sku)}" data-idx="${idx}" data-href="/product/${encodeURIComponent(p.handle)}">
    <img loading="lazy" src="${esc(p.swatch)}" alt="${esc(p.display_name||p.sku||'')}">
    <span class="tip">${esc(p.series||p.display_name||'')}${p.color?' · '+esc(p.color):''} · ${esc(p.sku||'')}</span>
  </div>`;
}
grid.addEventListener('click',e=>{
  const t=e.target.closest('.tile'); if(!t)return;
  if(e.metaKey||e.ctrlKey){window.open(t.dataset.href,'_blank');return;}
  const idx=Number(t.dataset.idx);
  if(e.shiftKey&&lastIdx!=null){
    const [a,b]=[Math.min(lastIdx,idx),Math.max(lastIdx,idx)];
    const turnOn=!SEL.has(t.dataset.sku);
    for(let i=a;i<=b;i++){const p=VIEW[i];if(!p)continue;turnOn?SEL.add(p.dw_sku):SEL.delete(p.dw_sku);}
    repaint();
  }else{
    SEL.has(t.dataset.sku)?SEL.delete(t.dataset.sku):SEL.add(t.dataset.sku);
    t.classList.toggle('sel',SEL.has(t.dataset.sku));
  }
  lastIdx=idx; syncCount(); saveSoon();
});
function repaint(){document.querySelectorAll('.tile').forEach(t=>t.classList.toggle('sel',SEL.has(t.dataset.sku)));}
function syncCount(){$('#selcount').textContent=SEL.size.toLocaleString();}
let st;
function saveSoon(){clearTimeout(st);st=setTimeout(saveNow,700);}
async function saveNow(){
  try{
    const r=await fetch(location.origin+'/api/selection',{method:'POST',headers:{'Content-Type':'application/json'},
      body:JSON.stringify({skus:[...SEL]})}).then(r=>r.json());
    $('#save').textContent=r.ok?('saved '+new Date().toLocaleTimeString()):'save failed';
  }catch{$('#save').textContent='save failed';}
}
function applyFilters(){
  const bk=$('#book').value, cl=$('#color').value, q=$('#q').value.trim().toLowerCase();
  VIEW=ALL.filter(p=>
    (!showSelOnly||SEL.has(p.dw_sku))&&
    (!bk||p.book===bk)&&(!cl||p.color_bucket===cl)&&
    (!q||[p.display_name,p.series,p.sku,p.dw_sku,p.color,p.book].some(v=>v&&String(v).toLowerCase().includes(q))));
  shown=0; lastIdx=null; grid.innerHTML=''; renderMore();
  $('#total').textContent=VIEW.length.toLocaleString()+' designs';
  $('#empty').style.display=VIEW.length?'none':'block';
}
function renderMore(){
  const slice=VIEW.slice(shown,shown+PAGE);
  grid.insertAdjacentHTML('beforeend',slice.map((p,i)=>tile(p,shown+i)).join(''));
  shown+=slice.length;
  $('#meta').textContent=`${shown.toLocaleString()} of ${VIEW.length.toLocaleString()} shown`;
  $('#more').style.display=shown<VIEW.length?'block':'none';
}
$('#more').onclick=renderMore;
addEventListener('scroll',()=>{if(shown<VIEW.length&&innerHeight+scrollY>=document.body.offsetHeight-600)renderMore();});
$('#book').onchange=applyFilters; $('#color').onchange=applyFilters;
let t; $('#q').oninput=()=>{clearTimeout(t);t=setTimeout(applyFilters,300);};
$('#selvis').onclick=()=>{VIEW.slice(0,shown).forEach(p=>SEL.add(p.dw_sku));repaint();syncCount();saveSoon();};
$('#unselvis').onclick=()=>{VIEW.slice(0,shown).forEach(p=>SEL.delete(p.dw_sku));repaint();syncCount();saveSoon();};
$('#showsel').onclick=()=>{showSelOnly=!showSelOnly;$('#showsel').classList.toggle('on',showSelOnly);applyFilters();};
$('#clear').onclick=()=>{if(!SEL.size||confirm('Clear all '+SEL.size+' selected?')){SEL.clear();repaint();syncCount();saveSoon();applyFilters();}};
$('#csv').onclick=()=>{
  const rows=[['dw_sku','vendor_sku','pattern','color','book','handle','swatch_url']];
  for(const s of SEL){const p=BY_SKU[s];if(!p)continue;
    rows.push([p.dw_sku,p.sku,p.series||p.display_name,p.color||'',p.book||'',p.handle,p.swatch||'']);}
  const csv=rows.map(r=>r.map(v=>'"'+String(v??'').replace(/"/g,'""')+'"').join(',')).join('\n');
  const a=document.createElement('a');
  a.href=URL.createObjectURL(new Blob([csv],{type:'text/csv'}));
  a.download='phillipe-romano-candidates-'+new Date().toISOString().slice(0,10)+'.csv';
  a.click();
};
async function load(){
  const [pd,fc,sel]=await Promise.all([
    fetch(location.origin+'/api/products').then(r=>r.json()),
    fetch(location.origin+'/api/facets').then(r=>r.json()),
    fetch(location.origin+'/api/selection').then(r=>r.json())]);
  ALL=pd.products; ALL.forEach(p=>BY_SKU[p.dw_sku]=p);
  (sel.skus||[]).forEach(s=>SEL.add(s));
  $('#book').innerHTML='<option value="">All books</option>'+(fc.books||[]).map(([b,n])=>`<option value="${esc(b)}">${esc(b)} (${n.toLocaleString()})</option>`).join('');
  const colors={}; ALL.forEach(p=>{if(p.color_bucket)colors[p.color_bucket]=(colors[p.color_bucket]||0)+1;});
  $('#color').innerHTML='<option value="">All colors</option>'+Object.entries(colors).sort((a,b)=>b[1]-a[1]).map(([c,n])=>`<option value="${esc(c)}">${esc(c)} (${n.toLocaleString()})</option>`).join('');
  syncCount(); applyFilters();
}
load();
</script>
</body></html>