← back to Wallco Ai
public/admin/drunk-curator.html
745 lines
<!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:11px 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;height:calc(100vh - 56px);padding:14px;gap:10px;overflow:auto;}
.panes{display:grid;grid-template-columns:1fr 1fr;gap:16px;width:min(96vw,1100px);height:min(60vh,540px);}
.pane{position:relative;border-radius:14px;overflow:hidden;border:1px solid var(--line);background:#000;}
.rsz{position:absolute;right:1px;bottom:1px;width:20px;height:20px;z-index:10;cursor:nwse-resize;background:linear-gradient(135deg,transparent 44%,rgba(255,255,255,.55) 44% 54%,transparent 54% 66%,rgba(255,255,255,.55) 66% 76%,transparent 76%);}
.pane .tag{position:absolute;top:8px;left:10px;z-index:3;font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:#fff;background:rgba(0,0,0,.45);padding:2px 8px;border-radius:20px;backdrop-filter:blur(4px);}
/* left: flat tile — fill exactly so auto-ID error boxes map 1:1 to the image */
.tile{width:100%;height:100%;object-fit:fill;display:block;}
/* auto-detected error boxes (dotted) overlaid on the flat tile */
.eboxes{position:absolute;inset:0;z-index:4;pointer-events:none;}
.ebox{position:absolute;border:2px dashed #ffb300;background:rgba(255,179,0,.12);border-radius:2px;}
.ebox b{position:absolute;bottom:-1px;left:-1px;background:#ffb300;color:#1a1a1a;font:800 9px sans-serif;padding:1px 5px;border-radius:0 3px 0 0;}
.scanning{position:absolute;top:8px;right:10px;z-index:5;background:rgba(0,0,0,.6);color:#ffd68a;font:700 10px sans-serif;padding:2px 8px;border-radius:20px;}
.extracted{position:fixed;top:60px;left:50%;transform:translateX(-50%);z-index:99;background:#1f6a2c;color:#fff;padding:8px 16px;border-radius:8px;font-size:13px;display:flex;align-items:center;gap:10px;}
.extracted img{height:42px;width:42px;object-fit:contain;background:#fff;border-radius:6px;}
/* right: CSS room setting */
.room{position:relative;background:#cfc7ba;flex:0 0 auto;height:100%;aspect-ratio:1;}
.roomrow{display:flex;gap:8px;align-items:flex-start;height:100%;justify-content:center;}
.roombtns{display:flex;flex-direction:column;gap:8px;padding-top:14px;}
.rb{background:#1d2023;color:var(--ink);border:1px solid #3a3f44;border-radius:9px;padding:11px 12px;font:600 12px sans-serif;cursor:pointer;white-space:nowrap;}
.rb:hover{filter:brightness(1.3);} .rb:active{transform:scale(.96);}
.wall{position:absolute;inset:0 0 24% 0;background-repeat:repeat;background-position:center top;background-size:24% auto;filter:brightness(.98);}
.wall::after{content:"";position:absolute;inset:0;background:radial-gradient(120% 90% at 50% 35%,transparent 55%,rgba(0,0,0,.28));} /* gentle room light */
.floor{position:absolute;left:0;right:0;bottom:0;height:24%;background:linear-gradient(#b9a684,#8d7a5b);}
/* vertical pinstripe lines marking each pattern-repeat boundary on the wall */
.repeats{position:absolute;inset:0 0 24% 0;z-index:5;pointer-events:none;}
.repeats .vline{position:absolute;top:0;bottom:0;width:1px;background:#000;opacity:.6;}
.repeats .vline.edge{opacity:.9;width:1.5px;}
.repbadge{position:absolute;top:8px;right:10px;z-index:7;background:rgba(0,0,0,.62);color:#fff;font:800 10px sans-serif;padding:3px 9px;border-radius:20px;}
.baseboard{position:absolute;left:0;right:0;bottom:24%;height:3.2%;background:#efe9df;border-top:1px solid rgba(0,0,0,.12);box-shadow:0 2px 5px rgba(0,0,0,.18);}
.furn{position:absolute;left:50%;bottom:24%;transform:translateX(-50%);width:64%;z-index:2;filter:drop-shadow(0 8px 14px rgba(0,0,0,.32));}
.meta{display:flex;justify-content:space-between;align-items:center;width:min(96vw,1100px);font-size:11.5px;color:var(--mut);font-variant-numeric:tabular-nums;}
/* ID-cue chips — let Steve sort the queue by eye: density, subject, generator, colorway */
.cues{display:flex;flex-wrap:wrap;gap:6px;align-items:center;}
.cue{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:11px;font:600 11px ui-sans-serif,sans-serif;color:#fff;background:#3a3a3a;letter-spacing:.02em;}
.cue.id{background:#222;color:#cfcfcf;font-weight:700;}
.cue.n-hi{background:#d23a1a;} /* n>=20 — fully wallpaper-density */
.cue.n-md{background:#c98a00;} /* 10..19 — solid all-over */
.cue.n-lo{background:#6b7280;} /* 3..9 — borderline */
.cue.n-none{background:#444;color:#999;}
.cue.gen-comfy{background:#2e6f5c;}
.cue.gen-replicate{background:#6d4caa;}
.cue.gen-bgswap{background:#2c6fbf;}
.cue.gen-smartfix{background:#a83232;}
.cue.gen-gemini{background:#1976d2;}
.cue.gen-other{background:#555;}
.cue.subj{background:#1a1a1a;color:#fff;border:1px solid #444;}
.cue.colorway{background:#3a2440;color:#f0d8f0;}
.cue.fresh{background:#0b6d3a;} /* created within the last 24h — fresh-gen flag */
.cue.rank-top{background:#b5891a;color:#1a1a1a;} /* top-20 by density in the current queue */
.prompt{font-size:11px;color:var(--mut);width:min(96vw,1100px);max-height:30px;overflow:hidden;text-align:center;cursor:pointer;}
.prompt.open{max-height:none;}
.btns{display:flex;gap:18px;}
button{border:0;border-radius:12px;padding:13px 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;}
.fix{background:#c98a00;color:#1a1a1a;} .extract{background:#2e7d32;color:#fff;}
.btns button{padding:13px 24px;}
.hint{font-size:11px;color:var(--mut);}
.nav{display:flex;align-items:center;gap:14px;}
.nav .navb{background:#2a2a2a;color:var(--ink);border:1px solid #444;padding:8px 16px;font-size:13px;font-weight:600;border-radius:10px;}
.nav .navb:disabled{opacity:.3;cursor:default;}
.nav .pos{font-size:12px;color:var(--mut);font-variant-numeric:tabular-nums;min-width:96px;text-align:center;}
.done{text-align:center;color:var(--mut);margin-top:18vh;} .done h2{color:var(--ink);}
.flash{position:fixed;inset:0;pointer-events:none;opacity:0;transition:opacity .25s;font-size:140px;display:flex;align-items:center;justify-content:center;z-index:9;}
.flash.show{opacity:1;}
/* repeat-scale control: stretch the tile to a real print width + inch ruler */
.scalebar{display:flex;align-items:center;gap:10px;font-size:12px;color:var(--mut);}
.scalebar .sb{background:#2a2a2a;color:var(--ink);border:1px solid #444;padding:6px 13px;font-size:12px;font-weight:700;border-radius:8px;}
.scalebar .sb.on{background:var(--ink);color:#111;border-color:var(--ink);}
.scalebar input[type=range]{width:150px;accent-color:var(--hot);}
.scalewrap{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;}
.scaleimg{position:relative;background:#000;flex:0 0 auto;box-shadow:0 0 0 1px #333;}
.scaleimg .tile{width:100%;height:100%;}
.ruler{position:relative;height:15px;flex:0 0 auto;}
.ruler .tick{position:absolute;top:0;width:1px;height:5px;background:#7a7a7a;}
.ruler .tick.maj{height:10px;background:#cfcfcf;}
.ruler .lab{position:absolute;top:8px;transform:translateX(-50%);font:700 8.5px sans-serif;color:#cfcfcf;white-space:nowrap;}
/* vertical inch ruler for the pattern HEIGHT (sits left of the tile) */
.tilebox{position:relative;display:flex;align-items:flex-start;gap:3px;}
.ruler.vert{height:auto;width:18px;}
.ruler.vert .tick{top:auto;right:0;left:auto;width:5px;height:1px;}
.ruler.vert .tick.maj{width:10px;}
.ruler.vert .lab{top:auto;right:9px;left:auto;transform:translateY(-50%);}
/* room wall rulers — FEET+INCHES, overlaid on the wall's top + left edges */
.rruler{position:absolute;z-index:6;pointer-events:none;}
.rruler.h{top:0;left:0;height:13px;} .rruler.v{top:0;left:0;width:13px;}
.rruler .rt{position:absolute;background:#fff;opacity:.85;}
.rruler.h .rt{top:0;width:1px;height:5px;} .rruler.h .rt.maj{height:9px;}
.rruler.v .rt{left:0;height:1px;width:5px;} .rruler.v .rt.maj{width:9px;}
.rruler .rl{position:absolute;font:800 8px sans-serif;color:#fff;white-space:nowrap;text-shadow:0 1px 2px rgba(0,0,0,.9);}
.rruler.h .rl{top:5px;transform:translateX(-50%);} .rruler.v .rl{left:5px;transform:translateY(-50%);}
/* manual "mark misaligned" highlighter — admin drags red boxes on the flat tile */
.mboxes{position:absolute;inset:0;z-index:5;}
.mbox{position:absolute;border:2px dashed #ff3b3b;background:rgba(255,59,59,.10);cursor:pointer;border-radius:2px;}
.mbox.drawing{background:rgba(255,59,59,.22);pointer-events:none;}
.mbox b{position:absolute;top:-2px;left:-2px;min-width:18px;text-align:center;background:#ff3b3b;color:#fff;font:800 11px sans-serif;padding:1px 6px;border-radius:3px 0 3px 0;}
.mhint{position:absolute;bottom:4px;left:50%;transform:translateX(-50%);z-index:6;pointer-events:none;background:rgba(0,0,0,.55);color:#ffd0d0;font:700 9px sans-serif;padding:2px 7px;border-radius:10px;white-space:nowrap;}
</style>
</head>
<body>
<header>
<h1>🔥 <span id="lineName">Hot or Not</span> <small>· tile + room</small></h1>
<select id="coll" title="pick a line" style="background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:4px 9px;font-size:13px;max-width:300px;">
<option value="">drunk samples (default)</option>
</select>
<select id="sort" title="sort mode (persists)" style="background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:4px 9px;font-size:13px;">
<option value="density">↓ density (n=64 first)</option>
<option value="character">🎨 character first (raffia/textile)</option>
<option value="newest">✨ newest first</option>
<option value="oldest">🕓 oldest first</option>
</select>
<select id="filterGen" title="filter by generator (persists)" style="background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:4px 9px;font-size:13px;">
<option value="">all generators</option>
<option value="bgswap">bgswap (raffia/textile)</option>
<option value="comfy">comfy (fresh dense)</option>
<option value="replicate">replicate</option>
<option value="gemini">gemini</option>
</select>
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--mut);cursor:pointer;user-select:none;">
<input type="checkbox" id="autotoggle"> 🔍 Auto-ID <span style="color:#6a737d">(Gemini ~$.003/item)</span>
</label>
<button id="fsbtn" title="toggle fullscreen" onclick="toggleFS()" style="background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:5px 10px;font-size:13px;cursor:pointer">⛶ Fullscreen</button>
<a id="gridlink" title="grid view — drag to bulk-select + apply" style="background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:5px 10px;font-size:13px;cursor:pointer;text-decoration:none">▦ Grid (bulk)</a>
<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>
<!-- furniture silhouette (console + lamp + vase), muted so it never fights the wallpaper -->
<svg style="display:none"><symbol id="console" viewBox="0 0 320 150">
<g fill="#2c2620"><rect x="40" y="60" width="240" height="14" rx="3"/>
<rect x="52" y="74" width="9" height="62"/><rect x="259" y="74" width="9" height="62"/>
<!-- lamp -->
<rect x="92" y="22" width="6" height="38"/><path d="M73 22 h44 l-9 -20 h-26 z"/>
<!-- vase + stems -->
<path d="M212 36 q-10 24 0 24 h22 q10 0 0 -24 z"/>
<path d="M220 36 q-6 -22 -16 -30 M226 36 q2 -24 14 -30 M223 36 q0 -26 0 -34" stroke="#2c2620" stroke-width="2.4" fill="none" stroke-linecap="round"/>
</g></symbol>
<symbol id="sofa" viewBox="0 0 320 150"><g fill="#2c2620"><rect x="30" y="70" width="260" height="55" rx="12"/><rect x="26" y="52" width="42" height="64" rx="12"/><rect x="252" y="52" width="42" height="64" rx="12"/><rect x="72" y="58" width="176" height="22" rx="9"/><rect x="46" y="123" width="14" height="20"/><rect x="260" y="123" width="14" height="20"/></g></symbol>
<symbol id="bed" viewBox="0 0 320 150"><g fill="#2c2620"><rect x="36" y="36" width="30" height="100" rx="4"/><rect x="36" y="88" width="248" height="40" rx="6"/><rect x="78" y="70" width="120" height="22" rx="9"/><rect x="50" y="128" width="12" height="14"/><rect x="270" y="122" width="12" height="20"/></g></symbol>
</svg>
<script>
const $=s=>document.querySelector(s);
const ADMIN=new URLSearchParams(location.search).get('admin');
const CATEGORY=new URLSearchParams(location.search).get('category')||'';
// q(path) — build a fetchable URL. Anchors '/'-prefixed paths to location.origin
// so URLs are credentials-free; the Fetch spec rejects Request construction
// when the resolved URL contains userinfo (happens when the page was loaded
// via http://admin:pw@host/...). Appends ?admin=<token> when present.
const q = s => {
const path = s.startsWith('/') ? location.origin + s : s;
return ADMIN ? path + (path.includes('?') ? '&' : '?') + 'admin=' + encodeURIComponent(ADMIN) : path;
};
let QUEUE=[], RAW_ITEMS=[], i=0, hot=0, not=0, streak=0, decided={};
// Sort + filter — client-side over RAW_ITEMS so changes are instant. Persist in localStorage.
let SORT_MODE = localStorage.getItem('drunk_sort') || 'density';
let FILTER_GEN = localStorage.getItem('drunk_filter_gen') || '';
// "Character first" demotes strict-grid drunk-zoo-36 toiles and surfaces bgswap raffia/textile-bg
// designs (Steve's original-drunk-monkey aesthetic). Within each tier, falls back to density.
function characterScore(d){
const g = String(d.generator||'').toLowerCase();
const c = String(d.category||'').toLowerCase();
let s = 0;
if ((g.includes('bgswap') || g.includes('bg-swap'))) s += 100; // raffia/textile-bg family wins
if (g.includes('gemini')) s += 70; // gemini edits (high-aesthetic)
if (g.includes('comfy')) s += 40; // fresh comfy dense
if (g.includes('replicate-fast')) s += 30;
if (g.includes('replicate')) s += 20;
if (g.includes('smartfix')||g.includes('midheal')||g.includes('edgeheal')) s -= 30;
if (c.includes('drunk-zoo-36')) s -= 25; // strict-grid toiles demoted
if (c.includes('drunk-animals-sample')) s += 10; // fresh samples slight boost
return s;
}
function rebuildQueue(){
let items = RAW_ITEMS.slice();
if (FILTER_GEN){
items = items.filter(d => {
const g = String(d.generator||'').toLowerCase();
if (FILTER_GEN === 'bgswap') return g.includes('bgswap') || g.includes('bg-swap');
if (FILTER_GEN === 'comfy') return g.includes('comfy');
if (FILTER_GEN === 'gemini') return g.includes('gemini') && !g.includes('bg-swap'); // bgswap has its own bucket
return g.includes(FILTER_GEN);
});
}
if (SORT_MODE === 'density'){
items.sort((a,b)=>(b.auto_hot_n??-1)-(a.auto_hot_n??-1) || b.id-a.id);
} else if (SORT_MODE === 'character'){
items.sort((a,b)=>characterScore(b)-characterScore(a) || (b.auto_hot_n??-1)-(a.auto_hot_n??-1) || b.id-a.id);
} else if (SORT_MODE === 'newest'){
items.sort((a,b)=>{
const ta = a.created_at?new Date(a.created_at).getTime():0;
const tb = b.created_at?new Date(b.created_at).getTime():0;
return tb-ta || b.id-a.id;
});
} else if (SORT_MODE === 'oldest'){
items.sort((a,b)=>{
const ta = a.created_at?new Date(a.created_at).getTime():0;
const tb = b.created_at?new Date(b.created_at).getTime():0;
return ta-tb || a.id-b.id;
});
}
QUEUE = items;
// Reset cursor to top whenever the filter/sort shifts the queue out from under it
i = 0;
const leftEl = document.getElementById('left'); if (leftEl) leftEl.textContent = QUEUE.length;
render();
}
// Repeat-scale preview: show the flat tile stretched to a real print width with an
// inch ruler. 24/36/54″ are the standard wallpaper/panel widths. Persists.
const SCALE_WIDTHS = [24, 36, 54]; // only these print widths — no slider
const snapScale = n => SCALE_WIDTHS.reduce((a,b)=>Math.abs(b-n)<Math.abs(a-n)?b:a, 36);
let scaleInches = snapScale(parseInt(localStorage.getItem('drunk_scale_in'))||36);
// room wall size in inches — user-adjustable via slider; default 13.5 ft (Steve 2026-05-27)
let WALL_INCHES = Math.min(216, Math.max(96, parseInt(localStorage.getItem('drunk_wall_in')) || 162));
function setWallInches(n){
WALL_INCHES = Math.min(216, Math.max(96, parseInt(n) || 162));
localStorage.setItem('drunk_wall_in', WALL_INCHES);
const lab=document.getElementById('wallval'); if(lab) lab.textContent=(WALL_INCHES/12).toFixed(WALL_INCHES%12?1:0)+'′';
applyScale();
}
function toggleFS(){
const el=document.documentElement;
if(!document.fullscreenElement){ (el.requestFullscreen||el.webkitRequestFullscreen||function(){}).call(el); }
else { (document.exitFullscreen||document.webkitExitFullscreen||function(){}).call(document); }
}
// Shared display square for BOTH panes (flat tile == room), user-resizable by
// dragging the corner grips. Persisted.
let imgSize = Math.max(220, Math.min(900, parseInt(localStorage.getItem('drunk_imgsize')) || Math.round(Math.min(window.innerHeight*0.55, 480))));
function setImgSize(n){ imgSize = Math.max(220, Math.min(900, Math.round(n))); localStorage.setItem('drunk_imgsize', imgSize); applyScale(); }
let _rsz=null;
function startResize(e){ e.preventDefault(); e.stopPropagation(); _rsz={x:e.clientX, y:e.clientY, s:imgSize}; document.body.style.userSelect='none'; }
addEventListener('mousemove', e=>{ if(!_rsz) return; setImgSize(_rsz.s + ((e.clientX-_rsz.x)+(e.clientY-_rsz.y))/2); });
addEventListener('mouseup', ()=>{ if(_rsz){ _rsz=null; document.body.style.userSelect=''; } });
const FURN = ['console', 'sofa', 'bed']; // furniture silhouettes the "Furniture" button cycles for staging/scale
let furnIdx = (parseInt(localStorage.getItem('drunk_furn')) || 0) % FURN.length;
// "Furniture" → render a REAL furnished room scene for THIS design via /api/room
// (ROOM_API photoreal renderer). Cycles room types on repeat clicks; swaps the
// flat CSS room for the render. Advancing the queue rebuilds the flat room fresh.
const ROOMTYPES = ['bedroom', 'living_room', 'dining_room', 'office'];
let roomTypeIdx = 0;
let furnRoomType = null; // null = flat room showing; else the photoreal roomType currently rendered
// Render ONE furnished room at the current repeat width. Called by the Furniture
// button (cycling room types) AND re-called automatically when the width changes
// after a render, so the room setting always reflects the chosen repeat scale.
async function renderRoom(rt){
const d = QUEUE[i], room = document.getElementById('room'); if(!d || !room) return;
let ov = document.getElementById('roomov');
if(!ov){ ov = document.createElement('div'); ov.id = 'roomov';
ov.style.cssText = 'position:absolute;inset:0;z-index:8;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.6);color:#fff;font:600 13px sans-serif;text-align:center;padding:12px';
room.appendChild(ov); }
ov.textContent = `🪑 rendering ${rt.replace(/_/g,' ')} @ ${scaleInches}″ repeat… (ROOM_API)`;
try{
const r = await fetch(q('/api/room'), { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ design_id: d.id, roomType: rt, patternWidth: scaleInches, patternHeight: scaleInches }) });
const j = await r.json();
if(!j.ok || !j.image_url) throw new Error(j.error || ('HTTP ' + r.status));
furnRoomType = rt;
room.innerHTML = `<img src="${j.image_url}" style="width:100%;height:100%;object-fit:cover;display:block">`
+ `<span class="tag" style="bottom:8px;top:auto">🪑 ${rt.replace(/_/g,' ')} · ${scaleInches}″ repeat · Furniture → another</span>`;
}catch(e){
ov.innerHTML = '⚠ render failed: ' + String(e.message).slice(0,50) + '<br><span style="font-size:11px;opacity:.8">ROOM_API may be down — flat room kept</span>';
setTimeout(()=>document.getElementById('roomov')?.remove(), 4000);
}
}
function genFurniture(){ const rt = ROOMTYPES[roomTypeIdx % ROOMTYPES.length]; roomTypeIdx++; renderRoom(rt); }
// Re-render the furnished room at the new repeat width (only if one is showing).
function refreshFurnitureScale(){ if(furnRoomType) renderRoom(furnRoomType); }
function setScale(n){
scaleInches = snapScale(parseInt(n)||36);
localStorage.setItem('drunk_scale_in', scaleInches);
applyScale();
refreshFurnitureScale(); // if a furnished room is showing, re-render it at the new repeat width
}
// Size the tile so its width = scaleInches at a constant px/inch (52″ fills the pane,
// smaller widths render proportionally narrower — it literally "stretches"), then
// lay an inch ruler beneath it and scale the room wall's repeat to match.
function applyScale(){
document.querySelectorAll('.scalebar .sb').forEach(b=>b.classList.toggle('on', +b.dataset.in===scaleInches));
const num=document.getElementById('scalenum'); if(num) num.textContent=scaleInches;
const sv=document.getElementById('scaleval'); if(sv) sv.textContent=scaleInches;
const wall=document.querySelector('.wall'); if(wall) wall.style.backgroundSize=(scaleInches/WALL_INCHES*100).toFixed(1)+'% auto';
const wrap=document.querySelector('.scalewrap'); if(!wrap) return;
// EQUAL-SIZE both panes to a shared square `imgSize` (user-resizable via the
// corner grips). The inch ruler maps 0→scaleInches across that fixed square.
const panes=document.querySelector('.panes');
if(panes){ panes.style.width=(imgSize*2+150)+'px'; panes.style.height=(imgSize+26)+'px'; }
const W=imgSize;
const ppi=W/scaleInches;
const img=document.getElementById('scaleimg'); const ruler=document.getElementById('ruler');
if(img){ img.style.width=W+'px'; img.style.height=W+'px'; }
if(ruler){
ruler.style.width=W+'px';
let h='';
for(let inch=0; inch<=scaleInches; inch++){
const x=(inch*ppi).toFixed(1), maj=(inch%12===0)||inch===scaleInches;
h+=`<div class="tick${maj?' maj':''}" style="left:${x}px"></div>`;
if(maj) h+=`<div class="lab" style="left:${x}px">${inch}″</div>`;
}
ruler.innerHTML=h;
}
// vertical inch ruler — pattern HEIGHT (tile is square, so = scaleInches)
const rv=document.getElementById('rulerV'), svh=document.getElementById('scalevalh');
if(svh) svh.textContent=scaleInches;
if(rv){
rv.style.height=W+'px'; let h='';
for(let inch=0; inch<=scaleInches; inch++){
const y=(inch*ppi).toFixed(1), maj=(inch%12===0)||inch===scaleInches;
h+=`<div class="tick${maj?' maj':''}" style="top:${y}px"></div>`;
if(maj) h+=`<div class="lab" style="top:${y}px">${inch}″</div>`;
}
rv.innerHTML=h;
}
drawRoomRulers();
}
// Room wall rulers in FEET+INCHES. Wall = WALL_INCHES (96″/8ft) wide; height is
// derived from the rendered wall's aspect so the ruler matches what's shown.
function drawRoomRulers(){
const room=document.getElementById('room'); if(!room) return;
// room is the SAME square size as the flat tile (shared imgSize, resizable)
const side=imgSize;
room.style.width=side+'px'; room.style.height=side+'px';
const ppi=side/WALL_INCHES;
// vertical pinstripe lines at each pattern-repeat boundary + a repeat count
const repPx=scaleInches*ppi, nRep=WALL_INCHES/scaleInches;
const rep=document.getElementById('repeats');
if(rep){ let s=''; for(let x=0; x<=side+0.5; x+=repPx){ const edge=(x<1||x>side-1); s+=`<div class="vline${edge?' edge':''}" style="left:${x.toFixed(1)}px"></div>`; } rep.innerHTML=s; }
const badge=document.getElementById('repbadge'); if(badge) badge.textContent=`↔ ${nRep.toFixed(nRep%1?1:0)} repeats × ${scaleInches}″`;
const fmt=v=>{const f=Math.floor(v/12), i=Math.round(v%12); return i?`${f}′${i}″`:`${f}′`;};
const tag=document.getElementById('roomtag'); if(tag) tag.textContent=`room setting · ${fmt(WALL_INCHES)} × ${fmt(WALL_INCHES)} room (ft·in)`;
const rh=document.getElementById('roomRulerH');
if(rh){ rh.style.width=side+'px'; let s='';
for(let inch=0; inch<=WALL_INCHES; inch+=6){ const maj=inch%12===0, x=(inch*ppi).toFixed(1);
s+=`<div class="rt${maj?' maj':''}" style="left:${x}px"></div>`;
if(maj) s+=`<div class="rl" style="left:${x}px">${fmt(inch)}</div>`; }
rh.innerHTML=s; }
const rv=document.getElementById('roomRulerV');
if(rv){ rv.style.height=side+'px'; let s='';
for(let inch=0; inch<=WALL_INCHES; inch+=6){ const maj=inch%12===0, y=(inch*ppi).toFixed(1);
s+=`<div class="rt${maj?' maj':''}" style="top:${y}px"></div>`;
if(maj) s+=`<div class="rl" style="top:${y}px">${fmt(inch)}</div>`; }
rv.innerHTML=s; }
}
addEventListener('resize',()=>{ if(document.querySelector('.scalewrap')) applyScale(); });
// === Manual "mark misaligned" highlighter ===========================
// Admin drags a red box on the flat tile over any misaligned/seam-broken area.
// Each box is saved to bad_examples.boxes (label 'misaligned', auto:false) via the
// shared annotations endpoint, so it joins the learning corpus. Click a box to
// remove it. Self-wires through a MutationObserver so it never touches render().
let curBoxes = [];
const curDesign = () => (i < QUEUE.length && QUEUE[i]) ? QUEUE[i] : null;
async function saveBoxes(){
const d = curDesign(); if(!d) return;
try{ await fetch(q('/api/admin/cactus/'+d.id+'/annotations'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({boxes:curBoxes})}); flash('🖍'); }
catch{ flash('⚠'); }
}
function drawMboxes(){
const si=document.getElementById('scaleimg'); if(!si) return;
let layer=document.getElementById('mboxes');
if(!layer){ layer=document.createElement('div'); layer.id='mboxes'; layer.className='mboxes'; si.appendChild(layer); }
const manual = curBoxes.filter(b=>!b.auto);
// sequential numbering (1, 2, 3...) by draw order — auto-renumbers on add/remove.
// The persisted label is preserved (notes the box's reason); the displayed number
// is purely positional so it stays right when boxes change.
layer.innerHTML = manual.map((b,k)=>`<div class="mbox" style="left:${b.x*100}%;top:${b.y*100}%;width:${b.w*100}%;height:${b.h*100}%" title="${(b.label||'wrong').replace(/"/g,'')} — click to remove"><b>${k+1}</b></div>`).join('')
+ `<div class="mhint">click = enlarge · drag = number a wrong area · click a numbered box to remove</div>`;
[...layer.querySelectorAll('.mbox')].forEach((el,k)=>el.addEventListener('click',ev=>{
ev.stopPropagation(); const target=manual[k]; curBoxes=curBoxes.filter(b=>b!==target); saveBoxes(); drawMboxes();
}));
}
function startDraw(e){
if(e.button!==0) return;
const si=document.getElementById('scaleimg'); if(!si) return;
if(e.target.closest('.mbox')) return; // clicking an existing box = remove, not draw
const rect=si.getBoundingClientRect();
const x0=(e.clientX-rect.left)/rect.width, y0=(e.clientY-rect.top)/rect.height;
if(x0<0||x0>1||y0<0||y0>1) return;
e.preventDefault();
const layer=document.getElementById('mboxes')||si;
const ghost=document.createElement('div'); ghost.className='mbox drawing'; layer.appendChild(ghost);
const mv=ev=>{ const x=(ev.clientX-rect.left)/rect.width, y=(ev.clientY-rect.top)/rect.height;
const L=Math.min(x0,x),T=Math.min(y0,y),W=Math.abs(x-x0),H=Math.abs(y-y0);
ghost.style.cssText=`left:${L*100}%;top:${T*100}%;width:${W*100}%;height:${H*100}%`; };
const up=ev=>{ document.removeEventListener('mousemove',mv); document.removeEventListener('mouseup',up);
const x=(ev.clientX-rect.left)/rect.width, y=(ev.clientY-rect.top)/rect.height;
const L=Math.max(0,Math.min(x0,x)),T=Math.max(0,Math.min(y0,y));
const W=Math.min(1-L,Math.abs(x-x0)),H=Math.min(1-T,Math.abs(y-y0));
ghost.remove();
if(W>0.02&&H>0.02){
// label is the box's NUMBER at creation time (display will renumber on render,
// but storing the original gives a stable provenance string for the corpus).
const n = curBoxes.filter(b=>!b.auto).length + 1;
curBoxes.push({x:+L.toFixed(4),y:+T.toFixed(4),w:+W.toFixed(4),h:+H.toFixed(4),label:'wrong-'+n,auto:false});
saveBoxes(); drawMboxes();
}
else { openZoom(); } // negligible drag = a plain click → open the larger view
};
document.addEventListener('mousemove',mv); document.addEventListener('mouseup',up);
}
// === Click-to-enlarge lightbox ======================================
// Plain click on the tile opens a big view of the pattern; toggle 1×/2×/3×
// repeat to inspect seams/misalignment at scale. Click backdrop or Esc to close.
let zoomRepeat = 2;
function ensureZoom(){
let z=document.getElementById('zoombox'); if(z) return z;
z=document.createElement('div'); z.id='zoombox';
z.style.cssText='position:fixed;inset:0;z-index:200;background:rgba(8,9,11,.92);display:none;flex-direction:column;align-items:center;justify-content:center;gap:12px;';
z.innerHTML=`<div id="zoomctl" style="display:flex;gap:8px;align-items:center;color:#cfcfcf;font:600 13px sans-serif">
repeat: <button class="rb" data-r="1">1×1</button><button class="rb" data-r="2">2×2</button><button class="rb" data-r="3">3×3</button>
<span style="margin-left:14px;color:#8a929a">click outside / Esc to close</span></div>
<div id="zoomfield" style="background:#fff;box-shadow:0 8px 40px rgba(0,0,0,.6);background-repeat:repeat;"></div>`;
document.body.appendChild(z);
z.addEventListener('mousedown',e=>{ if(e.target===z) closeZoom(); });
z.querySelectorAll('#zoomctl .rb').forEach(b=>b.addEventListener('click',e=>{ e.stopPropagation(); zoomRepeat=+b.dataset.r; paintZoom(); }));
return z;
}
function paintZoom(){
const d=curDesign(); if(!d) return;
const z=document.getElementById('zoombox'); if(!z) return;
const f=z.querySelector('#zoomfield');
const side=Math.min(window.innerHeight*0.82, window.innerWidth*0.82);
const src=`/designs/img/by-id/${d.id}`;
f.style.width=side+'px'; f.style.height=side+'px';
f.style.backgroundImage=`url('${src}')`;
f.style.backgroundSize=(100/zoomRepeat).toFixed(3)+'% '+(100/zoomRepeat).toFixed(3)+'%';
z.querySelectorAll('#zoomctl .rb').forEach(b=>b.style.outline=(+b.dataset.r===zoomRepeat)?'2px solid #ff4d6d':'none');
}
function openZoom(){ const z=ensureZoom(); z.style.display='flex'; paintZoom(); }
function closeZoom(){ const z=document.getElementById('zoombox'); if(z) z.style.display='none'; }
addEventListener('keydown',e=>{ if(e.key==='Escape') closeZoom(); });
new MutationObserver(()=>{
const si=document.getElementById('scaleimg'); if(!si || si.dataset.mwired) return;
si.dataset.mwired='1';
const d=curDesign();
curBoxes = (d && Array.isArray(d.annotations)) ? d.annotations.slice() : [];
si.addEventListener('mousedown',startDraw);
drawMboxes();
}).observe(document.getElementById('stage'),{childList:true,subtree:true});
// Move through the queue WITHOUT deciding — lets Steve go back/forward to review
// or fix items. Clamps to [0, length] (length = the cleared screen).
function go(delta){ if(busy) return; const ni=i+delta; if(ni<0||ni>QUEUE.length) return; i=ni; render(); }
let autoIdOn = localStorage.getItem('drunk_autoid')==='1'; // Auto-ID is OPT-IN (off by default — saves Gemini cost)
function fmtDate(iso){ if(!iso)return''; try{return new Date(iso).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});}catch{return iso;} }
// At-a-glance ID cues: density, subject, generator, colorway, freshness, rank.
const ANIMAL_EMOJI = {monkey:'🐒',monkeys:'🐒',zebra:'🦓',giraffe:'🦒',lion:'🦁',tiger:'🐅',elephant:'🐘',panda:'🐼',koala:'🐨',bear:'🐻',fox:'🦊',owl:'🦉',octopus:'🐙',sloth:'🦥',rabbit:'🐇',bunny:'🐇',penguin:'🐧',dog:'🐕',cat:'🐈',frog:'🐸',cow:'🐄',pig:'🐖',sheep:'🐑',horse:'🐎',deer:'🦌',flamingo:'🦩',parrot:'🦜',toucan:'🦜',cheetah:'🐆',leopard:'🐆',moose:'🫎',raccoon:'🦝',skunk:'🦨',hedgehog:'🦔',otter:'🦦',squirrel:'🐿',bat:'🦇',shark:'🦈',whale:'🐳',dolphin:'🐬',crab:'🦀',lobster:'🦞',butterfly:'🦋',bee:'🐝',snail:'🐌',hippo:'🦛',rhino:'🦏',camel:'🐪',llama:'🦙',kangaroo:'🦘',badger:'🦡',crocodile:'🐊',turtle:'🐢',snake:'🐍',lizard:'🦎',cactus:'🌵',unicorn:'🦄'};
function pickSubject(prompt){
if(!prompt) return null;
const p = prompt.toLowerCase();
for(const k of Object.keys(ANIMAL_EMOJI)){
if(new RegExp('\\b'+k+'\\b').test(p)) return {name:k, emoji:ANIMAL_EMOJI[k]};
}
return null;
}
function splitCategory(cat){
if(!cat) return {base:'', colorway:''};
const parts = String(cat).split('·').map(s=>s.trim()).filter(Boolean);
return {base: parts[0]||'', colorway: parts.slice(1).join(' · ')||''};
}
function genClass(gen){
const g = String(gen||'').toLowerCase();
// Check bgswap BEFORE gemini — the bgswap generator name (gemini-2.5-flash-image-bg-swap)
// also contains "gemini", and bgswap is the more specific signal Steve cares about.
if((g.includes('bgswap') || g.includes('bg-swap'))) return 'gen-bgswap';
if(g.includes('comfy')) return 'gen-comfy';
if(g.includes('replicate')) return 'gen-replicate';
if(g.includes('smartfix')||g.includes('midheal')||g.includes('edgeheal')) return 'gen-smartfix';
if(g.includes('luxe')) return 'gen-gemini';
if(g.includes('gemini')) return 'gen-gemini';
return 'gen-other';
}
function genLabel(gen){
const g = String(gen||'').toLowerCase();
// bgswap first — same precedence trap as genClass
if((g.includes('bgswap') || g.includes('bg-swap'))) return 'bgswap';
if(g.includes('comfy')) return 'comfy';
if(g.includes('replicate-fast')) return 'repl·fast';
if(g.includes('replicate')) return 'repl';
if(g.includes('smartfix')) return 'smartfix';
if(g.includes('midheal')) return 'mid-heal';
if(g.includes('edgeheal')) return 'edge-heal';
if(g.includes('luxe')) return 'luxe';
if(g.includes('gemini')) return 'gemini';
return (gen||'?').slice(0,12);
}
function nClass(n){
if(n==null) return 'n-none';
if(n>=20) return 'n-hi';
if(n>=10) return 'n-md';
return 'n-lo';
}
function isFresh(iso){
if(!iso) return false;
try { return (Date.now() - new Date(iso).getTime()) < 24*3600*1000; } catch { return false; }
}
function buildCues(d, idx){
const subj = pickSubject(d.prompt);
const {base, colorway} = splitCategory(d.category);
const n = d.auto_hot_n;
const cues = [];
cues.push(`<span class="cue id" title="design id">#${d.id}</span>`);
if(idx < 20) cues.push(`<span class="cue rank-top" title="top-20 by density in this queue">★ top ${idx+1}</span>`);
cues.push(`<span class="cue ${nClass(n)}" title="triage instance-count (Gemini composition gate)">🔢 n=${n!=null?n:'—'}</span>`);
if(subj) cues.push(`<span class="cue subj" title="subject (parsed from prompt)">${subj.emoji} ${subj.name}</span>`);
cues.push(`<span class="cue ${genClass(d.generator)}" title="generator: ${d.generator||'?'}">${genLabel(d.generator)}</span>`);
if(colorway) cues.push(`<span class="cue colorway" title="colorway">🎨 ${colorway.replace(/</g,'<')}</span>`);
if(base) cues.push(`<span class="cue" title="category base" style="background:#2a2a2a;color:#bbb">${base.replace(/</g,'<')}</span>`);
if(isFresh(d.created_at)) cues.push(`<span class="cue fresh" title="created in the last 24h">✨ fresh</span>`);
return cues.join('');
}
function flash(e){ const f=$('#flash'); f.textContent=e; 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} → defect registry</div><div class="hint" style="margin-top:10px">Generate more, then refresh.</div><div class="nav" style="justify-content:center;margin-top:16px"><button class="navb" onclick="go(-1)">‹ Back to last item</button></div></div>`; return; }
furnRoomType=null; // new item rebuilds the flat room — drop any prior furnished render
const d=QUEUE[i], src=`/designs/img/by-id/${d.id}`;
$('#stage').innerHTML=`
<div class="panes">
<div class="pane"><div class="rsz" onmousedown="startResize(event)" title="drag corner to resize both"></div><span class="tag">flat tile · pattern <span id="scaleval">${scaleInches}</span>″ × <span id="scalevalh">${scaleInches}</span>″ (in)</span>
<div class="scalewrap">
<div class="tilebox">
<div class="ruler vert" id="rulerV"></div>
<div class="scaleimg" id="scaleimg"><img class="tile" src="${src}" onerror="this.style.opacity=.25"><div class="eboxes" id="eboxes"></div></div>
</div>
<div class="ruler" id="ruler"></div>
</div>
</div>
<div class="pane"><div class="rsz" onmousedown="startResize(event)" title="drag corner to resize both"></div><span class="tag" id="roomtag">room setting · 12′ × 12′ room (ft·in)</span>
<div class="roomrow">
<div class="room" id="room">
<div class="wall" style="background-image:url('${src}')"></div>
<div class="repeats" id="repeats"></div>
<div class="repbadge" id="repbadge"></div>
<div class="rruler h" id="roomRulerH"></div>
<div class="rruler v" id="roomRulerV"></div>
<div class="baseboard"></div><div class="floor"></div>
<svg class="furn" id="furn"><use href="#${FURN[furnIdx]}"></use></svg>
</div>
<div class="roombtns">
<button class="rb" onclick="genFurniture()">🪑 Furniture</button>
<button class="rb" onclick="extract()">✂️ Extract elements</button>
</div>
</div>
</div>
</div>
<div class="scalebar">📏 Width
<button class="sb" data-in="24" onclick="setScale(24)">24″</button>
<button class="sb" data-in="36" onclick="setScale(36)">36″</button>
<button class="sb" data-in="54" onclick="setScale(54)">54″</button>
<span><b id="scalenum">${scaleInches}</b>″ repeat · ruler in inches</span>
<span style="margin-left:18px">🧱 Wall <input type="range" id="wallslider" min="96" max="216" step="6" value="${WALL_INCHES}" oninput="setWallInches(this.value)" style="width:130px;accent-color:var(--hot);vertical-align:middle"> <b id="wallval">${(WALL_INCHES/12).toFixed(WALL_INCHES%12?1:0)}′</b></span>
</div>
<div class="meta"><span class="cues">${buildCues(d, i)}</span><span title="${d.created_at||''}">🕓 ${fmtDate(d.created_at)}</span></div>
<div class="prompt" onclick="this.classList.toggle('open')">${(d.prompt||'').replace(/</g,'<').slice(0,240)}</div>
<div class="nav"><button class="navb" onclick="go(-1)" ${i<=0?'disabled':''}>‹ Prev</button><span class="pos">${i+1} / ${QUEUE.length}${decided[d.id]?' · '+decided[d.id]:''}</span><button class="navb" onclick="go(1)" ${i>=QUEUE.length-1?'disabled':''}>Next ›</button></div>
<div class="btns"><button class="hot" onclick="decide('hot')">🔥 HOT</button><button class="fix" onclick="decide('fix')">🔧 Fix</button><button class="not" onclick="decide('not')">🗑 NOT</button><button class="etsy" onclick="sendToEtsy()" style="background:#d4634e;color:#fff">🛒 Etsy</button><button class="extract" onclick="extract()">✂️ Extract</button></div>
<div class="hint">🔥/→ HOT · 🔧/F Fix · 🗑/← NOT · 🛒/S Etsy bucket · ✂️/E Extract · ‹ Prev , / [ · Next › . / ] (skip) · dotted = auto-detected errors</div>`;
applyScale(); // size tile to the chosen print width + draw the inch ruler
if(autoIdOn) autoIdCurrent(); // opt-in: only scan when the Auto-ID toggle is on
}
// Auto-detect error regions for the current item and overlay dotted boxes on the
// flat tile (reuses the category-aware /auto-id endpoint; boxes persist server-side).
async function autoIdCurrent(){
const d=QUEUE[i]; if(!d) return; const myId=d.id;
const box=$('#eboxes'); if(!box) return;
box.insertAdjacentHTML('beforebegin','<span class="scanning" id="scanning">🔍 scanning…</span>');
try{
const r=await fetch(q(`/api/admin/cactus/${myId}/auto-id`));
const j=await r.json();
if(QUEUE[i] && QUEUE[i].id!==myId) return; // user already advanced — drop stale result
document.getElementById('scanning')?.remove();
const boxes=(j.boxes||j.candidates||[]);
$('#eboxes').innerHTML = boxes.map(b=>`<div class="ebox" style="left:${b.x*100}%;top:${b.y*100}%;width:${b.w*100}%;height:${b.h*100}%"><b>${(b.label||'').replace(/</g,'')}</b></div>`).join('');
}catch(e){ document.getElementById('scanning')?.remove(); }
}
// Extract the OPAQUE motif only (alpha-trimmed solid silhouette) → elements library.
// Non-terminal: saves the element + confirms; you still HOT/Fix/Trash the source.
// 🛒 Send the current item to the Etsy bucket — gets queued for digital-download
// listing (state='queued', target_dpi=300 → 24" tile upscale). Non-destructive:
// the design stays in PG with an 'etsy-bucket' tag; the etsy push happens via
// the activation wizard. Advances the queue on success like HOT/Fix/NOT.
async function sendToEtsy(){
if(busy || i>=QUEUE.length) return; const d=QUEUE[i];
busy=true;
const btn=document.querySelector('.etsy'); const orig=btn?btn.textContent:'';
if(btn){btn.disabled=true; btn.textContent='🛒 sending…';}
try{
const r=await fetch(q('/api/etsy-bucket/add'),{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({ids:[d.id], target_dpi:300})});
const j=await r.json();
if(!r.ok || !j.ok) throw new Error(j.error || ('HTTP '+r.status));
const result = (j.results||[])[0] || {};
if(result.ok === false){
// eligibility rejected (e.g., seam FAIL) — surface the reason, don't advance
busy=false;
$('#stage').insertAdjacentHTML('afterbegin',`<div id="saveerr" style="position:fixed;top:62px;left:50%;transform:translateX(-50%);background:#8a5b1f;color:#fff;padding:8px 16px;border-radius:8px;z-index:99;font-size:13px;max-width:90vw">🛒 #${d.id} not eligible: ${(result.reason||'unknown')}</div>`);
setTimeout(()=>document.getElementById('saveerr')?.remove(),5000);
if(btn){btn.disabled=false; btn.textContent=orig;}
return;
}
flash('🛒'); decided[d.id]='etsy';
i++; busy=false; render();
}catch(e){
busy=false;
flash('⚠'); console.error('etsy send failed',d.id,e);
$('#stage').insertAdjacentHTML('afterbegin',`<div id="saveerr" style="position:fixed;top:62px;left:50%;transform:translateX(-50%);background:#a13030;color:#fff;padding:8px 16px;border-radius:8px;z-index:99;font-size:13px">🛒 send failed for #${d.id}: ${e.message}</div>`);
setTimeout(()=>document.getElementById('saveerr')?.remove(),4000);
if(btn){btn.disabled=false; btn.textContent=orig;}
}
}
async function extract(){
if(i>=QUEUE.length) return; const d=QUEUE[i];
const btn=document.querySelector('.extract'); if(btn){btn.disabled=true;btn.textContent='✂️ …';}
try{
const r=await fetch(q('/api/admin/drunk/extract'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:d.id})});
const j=await r.json();
if(!j.ok) throw new Error(j.error||('HTTP '+r.status));
flash('✂️');
const el=document.createElement('div'); el.className='extracted';
el.innerHTML=`<img src="${j.motif_url}"> opaque motif extracted → elements library (#${d.id})`;
document.body.appendChild(el); setTimeout(()=>el.remove(),3500);
}catch(e){ flash('⚠'); alert('Extract failed for #'+d.id+': '+e.message); }
finally{ if(btn){btn.disabled=false;btn.textContent='✂️ Extract';} }
}
let busy=false;
async function decide(v){
if(busy || i>=QUEUE.length) return; const d=QUEUE[i];
busy=true;
// SAVE FIRST, then advance — never advance on a failed/unconfirmed save, or the
// unsaved item loops back on reload (Codex-confirmed root cause 2026-05-27).
let j={};
try{
// Steve directive 2026-05-28: clicking HOT here is the human curator's
// override — the publish-gate must NOT block it. The 'Publish anyway' path
// becomes the default (force:true on every HOT). Fix/NOT/etc unaffected.
const body = (v === 'hot') ? {id:d.id, verdict:v, force:true} : {id:d.id, verdict:v};
const res = await fetch(q('/api/admin/drunk/decide'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
j = await res.json().catch(()=>({}));
if(!res.ok || !j.ok){ throw new Error(j.error||('HTTP '+res.status)); }
}catch(err){
busy=false; flash('⚠'); console.error('decide failed',d.id,v,err);
$('#stage').insertAdjacentHTML('afterbegin',`<div id="saveerr" style="position:fixed;top:62px;left:50%;transform:translateX(-50%);background:#a13030;color:#fff;padding:8px 16px;border-radius:8px;z-index:99;font-size:13px">Save failed for #${d.id}: ${err.message} — vote NOT recorded, try again</div>`);
setTimeout(()=>document.getElementById('saveerr')?.remove(),4000);
return; // do NOT advance — the vote didn't persist
}
// HOT that failed the 100% publish gate: the vote IS saved + parked (server holds
// it, not lost). Tell Steve why it didn't go live + offer a one-click force-publish,
// then advance — no more loop on the same item.
if(v==='hot' && j.gate_held){
hot++; flash('🔧'); decided[d.id]='held';
const reasons=(j.reasons||[]).join(' · ')||'failed publish gate';
// Attach to <body>, NOT #stage — render() (called below) rewrites #stage.innerHTML
// and would instantly wipe a toast placed inside it (the "HOT does nothing" bug).
document.getElementById('saveerr')?.remove();
const t=document.createElement('div'); t.id='saveerr';
t.style.cssText='position:fixed;top:62px;left:50%;transform:translateX(-50%);background:#8a6d1f;color:#fff;padding:8px 16px;border-radius:8px;z-index:99;font-size:13px;max-width:90vw';
t.innerHTML=`🔧 #${d.id} loved & held — not auto-published: ${reasons.replace(/</g,'<')}. <a href="#" id="forcepub" data-id="${d.id}" style="color:#ffe9a8;font-weight:600;text-decoration:underline">Publish anyway</a>`;
document.body.appendChild(t);
setTimeout(()=>document.getElementById('saveerr')?.remove(),8000);
document.getElementById('forcepub')?.addEventListener('click',ev=>{ev.preventDefault();forcePublish(parseInt(ev.currentTarget.dataset.id,10));});
} else if(v==='hot'){hot++;streak++;flash('🔥');decided[d.id]='hot';}
else if(v==='fix'){flash('🔧');decided[d.id]='fix';}
else {not++;streak=0;flash('👎');decided[d.id]='trash';}
i++; busy=false; render();
}
// One-click override: re-POST the HOT with force=true to bypass the gate (Steve's
// human call wins — e.g. an off-concept false-positive he disagrees with).
async function forcePublish(id){
try{
const res=await fetch(q('/api/admin/drunk/decide'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id,verdict:'hot',force:true})});
const j=await res.json().catch(()=>({}));
if(res.ok&&j.ok&&j.published!==false){ flash('🔥'); document.getElementById('saveerr')?.remove(); }
else { flash('⚠'); }
}catch{ flash('⚠'); }
}
document.addEventListener('keydown',e=>{ const k=e.key.toLowerCase();
if(e.target.tagName==='SELECT'||e.target.tagName==='INPUT')return;
if(k===','||e.key==='[')return go(-1); // prev — skip back without deciding
if(k==='.'||e.key===']')return go(1); // next — skip forward without deciding
if(e.key==='ArrowRight'||k==='h')decide('hot'); else if(e.key==='ArrowLeft'||k==='n')decide('not');
else if(k==='s')sendToEtsy();
else if(k==='f')decide('fix'); else if(k==='e')extract(); });
// collection picker — switch the hot-or-not queue to any wallco.ai line
async function loadCollections(){
try{
const r=await fetch(q('/api/admin/curator/collections')); const cols=(await r.json()).collections||[];
const sel=$('#coll');
for(const c of cols){
const o=document.createElement('option');
o.value=c.line; o.textContent=`${c.line} (${c.total})`;
if(c.line===CATEGORY) o.selected=true;
sel.appendChild(o);
}
sel.addEventListener('change',()=>{ location.href=q('/admin/drunk-curator?category='+encodeURIComponent(sel.value)); });
}catch{}
}
if(CATEGORY) $('#lineName').textContent=CATEGORY;
// Auto-ID toggle (opt-in): scan the current item when turned on, clear boxes when off.
const _autoTog=$('#autotoggle'); _autoTog.checked=autoIdOn;
_autoTog.addEventListener('change',()=>{
autoIdOn=_autoTog.checked; localStorage.setItem('drunk_autoid', autoIdOn?'1':'0');
if(autoIdOn){ autoIdCurrent(); }
else { const b=$('#eboxes'); if(b) b.innerHTML=''; document.getElementById('scanning')?.remove(); }
});
{ const gl=document.getElementById('gridlink'); if(gl) gl.href=q('/admin/design-curator'+(CATEGORY?'?category='+encodeURIComponent(CATEGORY):'')); }
loadCollections();
(async function(){
const r=await fetch(q('/api/admin/drunk/list'+(CATEGORY?'?category='+encodeURIComponent(CATEGORY):'')));
RAW_ITEMS = (await r.json()).items || [];
// Wire sort + filter dropdowns to current state and rebuild on change
const sortEl = document.getElementById('sort');
const filtEl = document.getElementById('filterGen');
if (sortEl){ sortEl.value = SORT_MODE; sortEl.onchange = () => { SORT_MODE = sortEl.value; localStorage.setItem('drunk_sort', SORT_MODE); rebuildQueue(); }; }
if (filtEl){ filtEl.value = FILTER_GEN; filtEl.onchange = () => { FILTER_GEN = filtEl.value; localStorage.setItem('drunk_filter_gen', FILTER_GEN); rebuildQueue(); }; }
rebuildQueue();
})();
</script>
<script src="/admin/admin-modal-kit.js" defer></script>
</body>
</html>