[object Object]

← back to Wallco Ai

dogs curator: /admin/dogs-curator hot-or-not surface + /api/admin/dogs/list (edges badges, sort+density, bulk publish via existing endpoint)

8d5f8a8e74bc675144310b603af57e16741535f1 · 2026-05-26 15:33:42 -0700 · Steve Abrams

Files touched

Diff

commit 8d5f8a8e74bc675144310b603af57e16741535f1
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue May 26 15:33:42 2026 -0700

    dogs curator: /admin/dogs-curator hot-or-not surface + /api/admin/dogs/list (edges badges, sort+density, bulk publish via existing endpoint)
---
 public/admin/dogs-curator.html | 200 +++++++++++++++++++++++++++++++++++++++++
 server.js                      |  27 ++++++
 2 files changed, 227 insertions(+)

diff --git a/public/admin/dogs-curator.html b/public/admin/dogs-curator.html
new file mode 100644
index 0000000..e461c51
--- /dev/null
+++ b/public/admin/dogs-curator.html
@@ -0,0 +1,200 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Dogs Collection — Curator</title>
+<style>
+  :root { --cols: 6; --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 80px; }
+  .breed { margin:0 0 30px; }
+  .breed h2 { font-size:16px; font-weight:600; margin:0 0 4px; text-transform:capitalize;
+              display:flex; align-items:baseline; gap:10px; }
+  .breed h2 .cnt { color:var(--muted); font-size:12px; font-weight:400; }
+  .breed .acts { font-size:12px; }
+  .breed .acts a { color:var(--accent); cursor:pointer; margin-right:12px; }
+  .grid { display:grid; grid-template-columns:repeat(var(--cols),1fr); gap:12px; 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 .badge { position:absolute; top:6px; left:6px; font-size:10px; font-weight:700;
+          letter-spacing:.05em; padding:2px 6px; border-radius:5px; background:#000a; }
+  .chip .badge.PASS{ color:var(--pass);} .chip .badge.WARN{ color:var(--warn);} .chip .badge.FAIL{ color:var(--fail);}
+  .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:6px; right:6px; width:22px; height:22px; border-radius:50%;
+          background:#000a; display:flex; align-items:center; justify-content:center; font-size:13px; }
+  .chip.keep .pick { background:var(--pass); color:#0c1a10; }
+  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; }
+</style>
+</head>
+<body>
+<header>
+  <h1>Dogs Collection — Curator <small id="sub">loading…</small></h1>
+  <div class="bar">
+    <span><label>Sort</label>
+      <select id="sort">
+        <option value="breed">Breed A→Z</option>
+        <option value="edges">Edges verdict (PASS first)</option>
+        <option value="color">Color (dominant hue)</option>
+        <option value="newest">Newest</option>
+      </select></span>
+    <span><label>Density</label>
+      <input type="range" id="density" min="3" max="10" step="1" value="6">
+      <span id="densval" class="stat">6</span></span>
+    <span class="stat">Showing&nbsp;<b id="cShow">0</b>&nbsp;·&nbsp;PASS <b id="cPass">0</b> · WARN <b id="cWarn">0</b> · FAIL <b id="cFail">0</b></span>
+    <span class="grow"></span>
+    <button class="ghost" id="filterFail">Hide FAILs</button>
+  </div>
+</header>
+
+<main id="main"></main>
+
+<footer>
+  <span class="stat">Kept: <b id="cKeep">0</b> · Live now: <b id="cLive">0</b></span>
+  <span class="grow"></span>
+  <button class="ghost" id="selPass">Keep all PASS</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(), hideFail: false };
+const $ = s => document.querySelector(s);
+const breedOf = c => (c || '').replace(/^dogs · /, '').replace(/-/g, ' ');
+
+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/dogs/list', {credentials:'same-origin'});
+  if(!r.ok){ $('#sub').textContent='admin auth required'; return; }
+  const j = await r.json();
+  state.items = j.items || [];
+  // default picks: keep PASS + WARN, reject FAIL ("only clean designs welcome")
+  state.items.forEach(d => { if(d.edges==='PASS'||d.edges==='WARN') state.keep.add(d.id);
+                             if(d.is_published) state.keep.add(d.id); });
+  render();
+}
+
+function render(){
+  const sort = $('#sort').value;
+  let items = state.items.slice();
+  if(state.hideFail) items = items.filter(d => d.edges!=='FAIL');
+  const order = {PASS:0,WARN:1,FAIL:2,'':3};
+  const cmp = {
+    breed:(a,b)=> a.category.localeCompare(b.category)||a.id-b.id,
+    edges:(a,b)=> (order[a.edges]-order[b.edges])||a.id-b.id,
+    color:(a,b)=> hueOf(a.dominant_hex)-hueOf(b.dominant_hex)||a.id-b.id,
+    newest:(a,b)=> b.id-a.id,
+  }[sort];
+  items.sort(cmp);
+
+  // group by breed (keep within-group order from the chosen sort)
+  const groups = {};
+  items.forEach(d => { (groups[d.category] ||= []).push(d); });
+  const main = $('#main'); main.innerHTML='';
+  Object.keys(groups).sort().forEach(cat => {
+    const sec = document.createElement('section'); sec.className='breed';
+    const rows = groups[cat];
+    sec.innerHTML = `<h2>${breedOf(cat)} <span class="cnt">${rows.length} designs</span>
+      <span class="acts"><a data-keep="${cat}">keep all</a><a data-rej="${cat}">reject all</a></span></h2>
+      <div class="grid"></div>`;
+    const grid = sec.querySelector('.grid');
+    rows.forEach(d => grid.appendChild(chip(d)));
+    main.appendChild(sec);
+  });
+  // counts
+  const all = state.items;
+  $('#cShow').textContent=items.length;
+  $('#cPass').textContent=all.filter(d=>d.edges==='PASS').length;
+  $('#cWarn').textContent=all.filter(d=>d.edges==='WARN').length;
+  $('#cFail').textContent=all.filter(d=>d.edges==='FAIL').length;
+  $('#cKeep').textContent=state.keep.size;
+  $('#cLive').textContent=all.filter(d=>d.is_published).length;
+  $('#sub').textContent=`${all.length} designs · 11 breeds`;
+}
+
+function chip(d){
+  const el=document.createElement('div');
+  el.className='chip '+(state.keep.has(d.id)?'keep':'reject')+(d.is_published?' published':'');
+  el.innerHTML=`<img loading="lazy" src="/designs/img/by-id/${d.id}" alt="#${d.id}">
+    <span class="badge ${d.edges}">${d.edges||'?'}</span>
+    <span class="live">LIVE</span>
+    <span class="pick">${state.keep.has(d.id)?'✓':''}</span>`;
+  el.title=`#${d.id} · ${breedOf(d.category)} · edges ${d.edges||'?'}${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; };
+  return el;
+}
+
+function toast(m){ const t=$('#toast'); t.textContent=m; t.classList.add('show'); setTimeout(()=>t.classList.remove('show'),2200); }
+
+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'));
+}
+
+// controls
+$('#sort').onchange=()=>{ localStorage.dogSort=$('#sort').value; render(); };
+$('#density').oninput=e=>{ document.documentElement.style.setProperty('--cols',e.target.value);
+  $('#densval').textContent=e.target.value; localStorage.dogCols=e.target.value; };
+$('#filterFail').onclick=()=>{ state.hideFail=!state.hideFail;
+  $('#filterFail').textContent=state.hideFail?'Show FAILs':'Hide FAILs'; render(); };
+$('#selPass').onclick=()=>{ state.items.forEach(d=>{ if(d.edges==='PASS')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.category===k).forEach(d=>state.keep.add(d.id)); render(); }
+  if(r){ state.items.filter(d=>d.category===r).forEach(d=>state.keep.delete(d.id)); render(); }
+});
+
+// restore persisted controls
+if(localStorage.dogSort) $('#sort').value=localStorage.dogSort;
+const cols=localStorage.dogCols||'6';
+$('#density').value=cols; $('#densval').textContent=cols;
+document.documentElement.style.setProperty('--cols',cols);
+load();
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
index 048a190..5b7f01e 100644
--- a/server.js
+++ b/server.js
@@ -1074,6 +1074,33 @@ app.get('/admin/edges-review', (req, res) => {
   res.sendFile(path.join(__dirname, 'public', 'admin', 'edges-review.html'));
 });
 
+// ── /admin/dogs-curator — hot-or-not curator for the "dogs" collection.
+// All 220 dog designs land is_published=FALSE; Steve picks which go live here
+// (curate-then-publish, per the 2026-05-26 directive). Grouped by breed, each
+// chip badged with its edges-agent verdict (PASS/WARN/FAIL). Publish/unpublish
+// flow through the existing /api/designs/bulk-action endpoint.
+app.get('/admin/dogs-curator', (req, res) => {
+  if (!isAdmin(req)) return res.status(404).type('html').send('<h1>404</h1>');
+  res.sendFile(path.join(__dirname, 'public', 'admin', 'dogs-curator.html'));
+});
+
+// JSON list of every dogs-collection design (incl. unpublished) for the curator.
+app.get('/api/admin/dogs/list', (req, res) => {
+  if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+  try {
+    const rawJson = psqlQuery(
+      "SELECT COALESCE(json_agg(t ORDER BY t.category, t.id), '[]'::json) FROM (" +
+        "SELECT id, category, dominant_hex, is_published, " +
+        "COALESCE(split_part(notes,'DOGS_EDGES:',2),'') AS edges " +
+        "FROM spoon_all_designs WHERE category LIKE 'dogs · %'" +
+      ") t;"
+    );
+    res.json({ ok: true, items: JSON.parse(rawJson || '[]') });
+  } catch (e) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
 // ── /admin/elements — curated library of "solid opaque middle" designs.
 // Browse-first surface for composing new designs from quality-vetted
 // existing tiles. Per the elements skill (~/.claude/skills/elements).

← 9c864f6 security: gate 4 internal /api routes admin-only + redact br  ·  back to Wallco Ai  ·  Add style-aware-sample-verify.js — settlement + edges-scan + fdb9464 →