[object Object]

← back to Wallco Ai

add /admin/drunk-curator hot-or-not picker: HOT publishes, NOT unpublishes + logs to wallco_defect_registry; keyboard nav + streak meter; curates drunk-animals-sample* pool

c8123d275813cbda911cb081688824961b483efc · 2026-05-27 10:58:51 -0700 · Steve Abrams

Files touched

Diff

commit c8123d275813cbda911cb081688824961b483efc
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed May 27 10:58:51 2026 -0700

    add /admin/drunk-curator hot-or-not picker: HOT publishes, NOT unpublishes + logs to wallco_defect_registry; keyboard nav + streak meter; curates drunk-animals-sample* pool
---
 public/admin/drunk-curator.html | 90 +++++++++++++++++++++++++++++++++++++++++
 server.js                       | 44 ++++++++++++++++++++
 2 files changed, 134 insertions(+)

diff --git a/public/admin/drunk-curator.html b/public/admin/drunk-curator.html
new file mode 100644
index 0000000..ebeceda
--- /dev/null
+++ b/public/admin/drunk-curator.html
@@ -0,0 +1,90 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Drunk-Animals · Hot or Not</title>
+<style>
+  :root{--bg:#0d0e10;--panel:#17191b;--line:#2a2e31;--ink:#eef1f3;--mut:#9aa3ab;--hot:#ff4d6d;--not:#5a6470;}
+  *{box-sizing:border-box;}
+  body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;overflow:hidden;}
+  header{display:flex;align-items:center;gap:16px;padding:12px 20px;border-bottom:1px solid var(--line);}
+  h1{font-size:16px;margin:0;font-weight:650;} h1 small{color:var(--mut);font-weight:400;}
+  .stats{margin-left:auto;display:flex;gap:18px;font-size:13px;color:var(--mut);font-variant-numeric:tabular-nums;}
+  .stats b{color:var(--ink);}
+  .streak{color:var(--hot);}
+  .stage{display:flex;flex-direction:column;align-items:center;justify-content:center;height:calc(100vh - 58px);gap:14px;padding:14px;}
+  .card{position:relative;width:min(62vh,560px);max-width:92vw;}
+  .card img{width:100%;aspect-ratio:1/1;object-fit:cover;object-position:center 12%;border-radius:14px;background:#000;border:1px solid var(--line);box-shadow:0 12px 40px rgba(0,0,0,.5);}
+  .meta{display:flex;justify-content:space-between;margin-top:8px;font-size:11.5px;color:var(--mut);font-variant-numeric:tabular-nums;}
+  .prompt{font-size:11px;color:var(--mut);max-width:min(62vh,560px);max-height:34px;overflow:hidden;text-align:center;cursor:pointer;}
+  .prompt.open{max-height:none;}
+  .btns{display:flex;gap:18px;}
+  button{border:0;border-radius:12px;padding:14px 34px;font-size:17px;font-weight:700;cursor:pointer;transition:transform .08s;}
+  button:active{transform:scale(.94);}
+  .not{background:var(--not);color:#fff;} .hot{background:var(--hot);color:#fff;}
+  .hint{font-size:11px;color:var(--mut);}
+  .done{text-align:center;color:var(--mut);}
+  .done h2{color:var(--ink);}
+  .flash{position:fixed;inset:0;pointer-events:none;opacity:0;transition:opacity .25s;font-size:120px;display:flex;align-items:center;justify-content:center;}
+  .flash.show{opacity:1;}
+</style>
+</head>
+<body>
+<header>
+  <h1>🔥 Drunk-Animals <small>· Hot or Not</small></h1>
+  <div class="stats">
+    <span>seen <b id="seen">0</b></span>
+    <span>🔥 hot <b id="hot">0</b></span>
+    <span>👎 not <b id="not">0</b></span>
+    <span class="streak" id="streak"></span>
+    <span>left <b id="left">…</b></span>
+  </div>
+</header>
+<div class="stage" id="stage"><div class="done">loading…</div></div>
+<div class="flash" id="flash"></div>
+
+<script>
+const $=s=>document.querySelector(s);
+let QUEUE=[], i=0, hot=0, not=0, streak=0;
+
+function fmtDate(iso){ if(!iso)return''; try{return new Date(iso).toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});}catch{return iso;} }
+function flash(emoji){ const f=$('#flash'); f.textContent=emoji; f.classList.add('show'); setTimeout(()=>f.classList.remove('show'),250); }
+
+function render(){
+  $('#seen').textContent=hot+not; $('#hot').textContent=hot; $('#not').textContent=not;
+  $('#left').textContent=Math.max(0,QUEUE.length-i);
+  $('#streak').textContent = streak>=3 ? `🔥×${streak} streak` : '';
+  if(i>=QUEUE.length){
+    $('#stage').innerHTML=`<div class="done"><h2>Pile cleared 🎉</h2><div>${hot} published · ${not} sent to defect registry</div><div class="hint" style="margin-top:10px">Generate more, then refresh.</div></div>`;
+    return;
+  }
+  const d=QUEUE[i];
+  $('#stage').innerHTML=`
+    <div class="card"><img src="/designs/img/by-id/${d.id}" alt="design ${d.id}" onerror="this.style.opacity=.25"></div>
+    <div class="meta" style="width:min(62vh,560px)"><span>#${d.id} · ${d.category||''}</span><span>🕓 ${fmtDate(d.created_at)}</span></div>
+    <div class="prompt" onclick="this.classList.toggle('open')">${(d.prompt||'').replace(/</g,'&lt;').slice(0,240)}</div>
+    <div class="btns">
+      <button class="not" onclick="decide('not')">👎 NOT</button>
+      <button class="hot" onclick="decide('hot')">🔥 HOT</button>
+    </div>
+    <div class="hint">← / N = not · → / H = hot · click image prompt to expand</div>`;
+}
+async function decide(verdict){
+  if(i>=QUEUE.length) return;
+  const d=QUEUE[i];
+  if(verdict==='hot'){ hot++; streak++; flash('🔥'); } else { not++; streak=0; flash('👎'); }
+  i++; render();
+  try{ await fetch('/api/admin/drunk/decide',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:d.id,verdict})}); }catch{}
+}
+document.addEventListener('keydown',e=>{
+  if(e.key==='ArrowRight'||e.key.toLowerCase()==='h') decide('hot');
+  else if(e.key==='ArrowLeft'||e.key.toLowerCase()==='n') decide('not');
+});
+(async function(){
+  const r=await fetch('/api/admin/drunk/list'); const j=await r.json();
+  QUEUE=j.items||[]; render();
+})();
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
index 90afe7b..716380f 100644
--- a/server.js
+++ b/server.js
@@ -1133,6 +1133,50 @@ app.get('/admin/defect-registry', (req, res) => {
   res.sendFile(path.join(__dirname, 'public', 'admin', 'defect-registry.html'));
 });
 
+// ── /admin/drunk-curator — hot-or-not picker for fresh drunk-animal samples ──
+// HOT → is_published=TRUE. NOT → unpublish + user_removed + log to the defect
+// registry so the reject feeds the "never recreate the same error" system.
+app.get('/admin/drunk-curator', (req, res) => {
+  if (!isAdmin(req)) return res.status(404).type('html').send('<h1>404</h1>');
+  res.sendFile(path.join(__dirname, 'public', 'admin', 'drunk-curator.html'));
+});
+
+app.get('/api/admin/drunk/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.id), '[]'::json) FROM (" +
+        "SELECT id, category, generator, left(prompt,260) AS prompt, created_at " +
+        "FROM all_designs " +
+        "WHERE (category LIKE 'drunk-animals-sample%' OR category='drunk-animals-replfast') " +
+          "AND is_published=FALSE AND (user_removed IS NULL OR user_removed=FALSE) " +
+          "AND local_path IS NOT NULL " +
+      ") t;"
+    );
+    res.json({ ok: true, items: JSON.parse(rawJson || '[]') });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+app.post('/api/admin/drunk/decide', express.json({ limit: '4kb' }), (req, res) => {
+  if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+  const id = parseInt(req.body && req.body.id, 10);
+  const verdict = (req.body && req.body.verdict) === 'hot' ? 'hot' : 'not';
+  if (!Number.isFinite(id)) return res.status(400).json({ error: 'bad id' });
+  try {
+    if (verdict === 'hot') {
+      psqlExecLocal(`UPDATE all_designs SET is_published=TRUE, user_removed=FALSE WHERE id=${id};`);
+    } else {
+      psqlExecLocal(`UPDATE all_designs SET is_published=FALSE, user_removed=TRUE WHERE id=${id};`);
+      psqlExecLocal(
+        "INSERT INTO wallco_defect_registry (design_id, category, generator, local_path, defect_type, defect_detail, prompt, seed, source) " +
+        `SELECT id, category, generator, local_path, 'curator_reject', 'Steve hot-or-not: NOT', prompt, seed, 'drunk_curator' FROM all_designs WHERE id=${id} ` +
+        "ON CONFLICT (design_id, defect_type) DO NOTHING;"
+      );
+    }
+    res.json({ ok: true, id, verdict });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
 app.get('/api/admin/defect-registry/list', (req, res) => {
   if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
   const limit  = Math.min(Math.max(parseInt(req.query.limit  || '600', 10), 1), 8000);

← 6371c5c tooling: repoint-stale-paths (350 removed designs → quaranti  ·  back to Wallco Ai  ·  drunk-curator: two-pane hot-or-not — flat tile left + instan dabde51 →