← 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
A public/admin/drunk-curator.htmlM server.js
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,'<').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 →