← back to Wallco Ai

public/admin/botanical-wonders-curator.html

219 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Botanical Wonders — Substitute Curator</title>
<style>
  :root { --cols: 4; --bg:#14110e; --panel:#1d1915; --ink:#ece4d6; --muted:#9a9082;
          --line:#332c24; --pass:#5fbf7a; --warn:#d8a23a; --fail:#d4574f; --accent:#c9b07a; }
  * { box-sizing: border-box; }
  body { margin:0; background:var(--bg); color:var(--ink);
         font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif; }
  header { position:sticky; top:0; z-index:20; background:rgba(20,17,14,.96);
           backdrop-filter:blur(8px); border-bottom:1px solid var(--line); padding:14px 22px; }
  h1 { margin:0 0 2px; font-size:20px; font-weight:600; letter-spacing:.02em; }
  h1 small { color:var(--muted); font-weight:400; font-size:13px; margin-left:8px; }
  .bar { display:flex; flex-wrap:wrap; gap:14px; align-items:center; margin-top:10px; }
  .bar label { color:var(--muted); font-size:12px; text-transform:uppercase; letter-spacing:.06em; }
  select, button { font:inherit; color:var(--ink); background:var(--panel);
           border:1px solid var(--line); border-radius:8px; padding:7px 11px; cursor:pointer; }
  button.primary { background:var(--accent); color:#1a1611; border-color:var(--accent); font-weight:600; }
  button.ghost { background:transparent; }
  button:disabled { opacity:.4; cursor:not-allowed; }
  input[type=range] { accent-color:var(--accent); width:150px; vertical-align:middle; }
  .stat { color:var(--muted); font-size:13px; }
  .stat b { color:var(--ink); }
  main { padding:18px 22px 90px; }
  .family { margin:0 0 36px; }
  .family h2 { font-size:17px; font-weight:600; margin:0 0 4px;
              display:flex; align-items:baseline; gap:10px; flex-wrap:wrap; }
  .family h2 .cnt { color:var(--muted); font-size:12px; font-weight:400; }
  .family h2 .parent { color:var(--accent); font-size:11px; font-weight:600; letter-spacing:.06em;
                       background:rgba(201,176,122,.1); border:1px solid var(--line); padding:2px 7px; border-radius:5px; }
  .family .acts { font-size:12px; }
  .family .acts a { color:var(--accent); cursor:pointer; margin-right:12px; }
  .family .sub { color:var(--muted); font-size:12px; font-style:italic; margin:0 0 8px; }
  .grid { display:grid; grid-template-columns:repeat(var(--cols),1fr); gap:14px; margin-top:10px; }
  .chip { position:relative; border-radius:10px; overflow:hidden; border:2px solid var(--line);
          background:#0d0b09; aspect-ratio:1/1; cursor:pointer; transition:border-color .12s, opacity .12s; }
  .chip img { width:100%; height:100%; object-fit:cover; display:block; }
  .chip.keep { border-color:var(--pass); }
  .chip.reject { opacity:.34; border-color:var(--line); }
  .chip .motif { position:absolute; top:6px; left:6px; font-size:10.5px; font-weight:600;
          padding:3px 7px; border-radius:5px; background:rgba(0,0,0,.6); color:var(--ink); letter-spacing:.02em; }
  .chip .live { position:absolute; top:6px; right:6px; font-size:10px; font-weight:700;
          padding:2px 6px; border-radius:5px; background:var(--pass); color:#0c1a10; display:none; }
  .chip.published .live { display:block; }
  .chip .pick { position:absolute; bottom:24px; right:6px; width:24px; height:24px; border-radius:50%;
          background:#000a; display:flex; align-items:center; justify-content:center; font-size:14px; font-weight:700; }
  .chip.keep .pick { background:var(--pass); color:#0c1a10; }
  .chip .when { position:absolute; left:0; right:0; bottom:0; padding:3px 7px; background:rgba(13,11,9,.82);
          color:var(--muted); font-size:10.5px; font-variant-numeric:tabular-nums; letter-spacing:.02em;
          white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
  footer { position:fixed; bottom:0; left:0; right:0; background:rgba(20,17,14,.97);
           border-top:1px solid var(--line); padding:12px 22px; display:flex; gap:12px; align-items:center; z-index:30; }
  footer .grow { flex:1; }
  .toast { position:fixed; bottom:70px; left:50%; transform:translateX(-50%); background:var(--panel);
           border:1px solid var(--accent); border-radius:8px; padding:10px 18px; opacity:0; transition:opacity .2s; }
  .toast.show { opacity:1; }
  .note { color:var(--muted); font-size:11.5px; max-width:520px; line-height:1.45; }
  .note b { color:var(--accent); font-weight:600; }
</style>
</head>
<body>
<header>
  <h1>Botanical Wonders — Substitute Curator <small id="sub">loading…</small></h1>
  <div class="bar">
    <span><label>Sort</label>
      <select id="sort">
        <option value="family">Family + Motif</option>
        <option value="newest">Newest</option>
        <option value="color">Color (dominant hue)</option>
      </select></span>
    <span><label>Density</label>
      <input type="range" id="density" min="2" max="8" step="1" value="4">
      <span id="densval" class="stat">4</span></span>
    <span class="stat">Kept&nbsp;<b id="cKeep">0</b>&nbsp;·&nbsp;Live&nbsp;<b id="cLive">0</b>&nbsp;of&nbsp;<b id="cTotal">0</b></span>
    <span class="grow"></span>
    <span class="note">In-house wallco.ai substitutes for 2 retired Clarke &amp; Clarke "Wonderlust" sample SKUs.
      Settlement gate: <b>OK</b> (logged 2026-05-27).
      Pick which to publish; <b>publish flip still requires TIF archive</b> per CLAUDE.md.</span>
  </div>
</header>

<main id="main"></main>

<footer>
  <span class="stat">Kept: <b id="fKeep">0</b> · Live now: <b id="fLive">0</b></span>
  <span class="grow"></span>
  <button class="ghost" id="selAll">Keep all</button>
  <button class="ghost" id="selClear">Clear picks</button>
  <button class="primary" id="publish">Publish kept →</button>
  <button class="ghost" id="unpublish">Unpublish kept</button>
</footer>
<div class="toast" id="toast"></div>

<script>
const state = { items: [], keep: new Set() };
const $ = s => document.querySelector(s);
const FAMILY_LABEL = { 'dove-multi':'Dove Multi', 'teal-multi':'Teal Multi' };
const PARENT_SKU = { 'dove-multi':'DWKK-127831-Sample', 'teal-multi':'DWKK-127832-Sample' };
const MOTIF_NAME = {
  'dragonfly-fern':'Dragonfly & Fern', 'bee-honeysuckle':'Bee & Honeysuckle',
  'beetle-leaf':'Beetle & Leaf', 'moth-grass':'Moth & Grass',
  'ladybug-clover':'Ladybugs & Clover', 'mantis-stem':'Mantis on Stem',
};
const familyOf = c => (c || '').replace(/^botanical-wonders · /, '');
function motifOf(prompt) {
  const m = (prompt || '').match(/\[var:([a-z0-9-]+)\]/);
  return m ? m[1] : '';
}
function fmtDate(iso){
  if(!iso) return '—';
  const d = new Date(iso); if(isNaN(d)) return '—';
  return d.toLocaleString(undefined, { year:'numeric', month:'short', day:'numeric', hour:'numeric', minute:'2-digit' });
}
function hueOf(hex){ if(!/^#?[0-9a-f]{6}$/i.test(hex||'')) return 999;
  const n=hex.replace('#',''); const r=parseInt(n.slice(0,2),16)/255,g=parseInt(n.slice(2,4),16)/255,b=parseInt(n.slice(4,6),16)/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; h*=60; if(h<0)h+=360; } return h; }

async function load(){
  const r = await fetch('/api/admin/botanical-wonders/list', {credentials:'same-origin'});
  if(!r.ok){ $('#sub').textContent='admin auth required'; return; }
  const j = await r.json();
  state.items = (j.items || []).map(d => ({...d, _motif: motifOf(d.prompt), _family: familyOf(d.category)}));
  state.items.forEach(d => { if(d.is_published) state.keep.add(d.id); });
  render();
}

function render(){
  const sort = $('#sort').value;
  let items = state.items.slice();
  const cmp = {
    family:(a,b)=> a._family.localeCompare(b._family) || a._motif.localeCompare(b._motif) || a.id-b.id,
    newest:(a,b)=> b.id-a.id,
    color: (a,b)=> hueOf(a.dominant_hex)-hueOf(b.dominant_hex) || a.id-b.id,
  }[sort];
  items.sort(cmp);

  const groups = {};
  items.forEach(d => { (groups[d._family] ||= []).push(d); });
  const main = $('#main'); main.innerHTML='';
  Object.keys(groups).sort().forEach(fam => {
    const sec = document.createElement('section'); sec.className='family';
    const rows = groups[fam];
    const label = FAMILY_LABEL[fam] || fam;
    const parent = PARENT_SKU[fam] || '';
    sec.innerHTML = `<h2>${label}
        <span class="parent">substitutes for ${parent}</span>
        <span class="cnt">${rows.length} designs</span>
        <span class="acts"><a data-keep="${fam}">keep all</a><a data-rej="${fam}">reject all</a></span></h2>
      <p class="sub">Six botanical motifs — dragonfly, bee, beetle, moth, ladybug, mantis — each rendered as a single-ink solid screen-print on the ${label.toLowerCase()} ground.</p>
      <div class="grid"></div>`;
    const grid = sec.querySelector('.grid');
    rows.forEach(d => grid.appendChild(chip(d)));
    main.appendChild(sec);
  });
  $('#cKeep').textContent=state.keep.size;
  $('#fKeep').textContent=state.keep.size;
  $('#cTotal').textContent=state.items.length;
  $('#cLive').textContent=state.items.filter(d=>d.is_published).length;
  $('#fLive').textContent=state.items.filter(d=>d.is_published).length;
  $('#sub').textContent=`${state.items.length} designs · 2 colorway families · is_published=FALSE awaiting curation`;
}

function chip(d){
  const el=document.createElement('div');
  el.className='chip '+(state.keep.has(d.id)?'keep':'reject')+(d.is_published?' published':'');
  const motifLabel = MOTIF_NAME[d._motif] || d._motif || '?';
  el.innerHTML=`<img loading="lazy" src="/designs/img/by-id/${d.id}" alt="#${d.id}">
    <span class="motif">${motifLabel}</span>
    <span class="live">LIVE</span>
    <span class="pick">${state.keep.has(d.id)?'✓':''}</span>
    <div class="when" title="created ${d.created_at||''}">🕓 ${fmtDate(d.created_at)} · #${d.id}</div>`;
  el.title=`#${d.id} · ${motifLabel} · ${FAMILY_LABEL[d._family]||d._family}${d.is_published?' · LIVE':''}`;
  el.onclick=()=>{ if(state.keep.has(d.id))state.keep.delete(d.id); else state.keep.add(d.id);
    el.classList.toggle('keep'); el.classList.toggle('reject');
    el.querySelector('.pick').textContent=state.keep.has(d.id)?'✓':'';
    $('#cKeep').textContent=state.keep.size; $('#fKeep').textContent=state.keep.size; };
  return el;
}

function toast(m){ const t=$('#toast'); t.textContent=m; t.classList.add('show'); setTimeout(()=>t.classList.remove('show'),2400); }

async function bulk(action){
  const ids=[...state.keep]; if(!ids.length){ toast('No designs kept'); return; }
  const r=await fetch('/api/designs/bulk-action',{method:'POST',credentials:'same-origin',
    headers:{'Content-Type':'application/json'},body:JSON.stringify({ids,action})});
  const j=await r.json();
  if(j.ok){ state.items.forEach(d=>{ if(state.keep.has(d.id)) d.is_published=(action==='publish'); });
    toast(`${action==='publish'?'Published':'Unpublished'} ${j.affected} designs`); render(); }
  else toast('Error: '+(j.error||'failed'));
}

$('#sort').onchange=()=>{ localStorage.bwSort=$('#sort').value; render(); };
$('#density').oninput=e=>{ document.documentElement.style.setProperty('--cols',e.target.value);
  $('#densval').textContent=e.target.value; localStorage.bwCols=e.target.value; };
$('#selAll').onclick=()=>{ state.items.forEach(d=>state.keep.add(d.id)); render(); };
$('#selClear').onclick=()=>{ state.keep.clear(); render(); };
$('#publish').onclick=()=>bulk('publish');
$('#unpublish').onclick=()=>bulk('unpublish');
document.addEventListener('click',e=>{
  const k=e.target.getAttribute&&e.target.getAttribute('data-keep');
  const r=e.target.getAttribute&&e.target.getAttribute('data-rej');
  if(k){ state.items.filter(d=>d._family===k).forEach(d=>state.keep.add(d.id)); render(); }
  if(r){ state.items.filter(d=>d._family===r).forEach(d=>state.keep.delete(d.id)); render(); }
});

if(localStorage.bwSort) $('#sort').value=localStorage.bwSort;
const cols=localStorage.bwCols||'4';
$('#density').value=cols; $('#densval').textContent=cols;
document.documentElement.style.setProperty('--cols',cols);
load();
</script>
  <script src="/admin/admin-modal-kit.js" defer></script>
</body>
</html>