RECORDGOD/site/search.html
2026-06-22 21:45:31 +10:00

379 lines
26 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RecordGod — search & locate</title>
<script defer src="/nav.js?v=5"></script>
<style>
:root{--pink:#d10f7a;--hot:#ff2e93;--bg:#f4f5f7;--card:#ffffff;--ink:#1b1b22;--line:#e2e2ea;--mut:#5f5f6c;--ok:#1a8f54}
*{box-sizing:border-box}
html,body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 system-ui,sans-serif}
button{cursor:pointer;border:0;border-radius:8px;font:500 13px system-ui}
.ghost{background:#fff;color:#2a2a30;border:1px solid var(--line);padding:7px 10px}
.ghost:hover{background:#f3f3f6}.ghost:disabled{opacity:.4;cursor:default}
.ghost.on{background:#fdeef6;color:var(--pink);border-color:var(--pink)}
input,select{padding:10px 12px;border:1px solid var(--line);border-radius:9px;background:#fff;color:var(--ink);font:15px system-ui}
input:focus,select:focus{outline:none;border-color:var(--pink)}
#gate{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:var(--bg);z-index:50}
#gate .b{width:320px;text-align:center}#gate h1{color:var(--pink)}
#app{display:none;grid-template-columns:1.1fr 1.2fr;gap:16px;padding:16px;max-width:1340px;margin:0 auto}
.panel{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px;box-shadow:0 1px 3px rgba(0,0,0,.05);margin-bottom:14px}
.bar{display:flex;gap:8px;align-items:center}
.res{display:flex;flex-direction:column;gap:6px;max-height:36vh;overflow:auto;margin-top:8px}
.ri{display:flex;gap:11px;align-items:center;padding:9px;border-radius:9px;cursor:pointer;border:1px solid transparent}
.ri:hover{background:#f5f5f8}.ri.sel{border-color:var(--pink);background:#fdeef6}
.ri img,.ri .ph{width:42px;height:42px;border-radius:6px;object-fit:cover;background:#ececf0;flex-shrink:0}
.ri .t{flex:1;min-width:0}.ri .nm{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:600}
.muted{color:var(--mut);font-size:12px}.pink{color:var(--pink)}.ok{color:var(--ok)}.oos{color:#c0392b}
.loc{display:inline-block;margin-top:2px;padding:1px 7px;border-radius:20px;background:#fdeef6;color:var(--pink);font-size:11px;font-weight:600}
.navhdr{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px}
.crumb{font-weight:600;flex:1}.crumb b{color:var(--pink)}
.lvl{display:flex;align-items:center;gap:6px}
#cv{width:100%;background:#fbfbfd;border:1px solid var(--line);border-radius:10px;display:block;cursor:pointer}
table{width:100%;border-collapse:collapse;font-size:13px}
td,th{padding:6px 5px;border-bottom:1px solid #eee;text-align:left}th{color:var(--mut);font-weight:500}
.legend{display:flex;gap:14px;margin-top:8px;font-size:11px;color:var(--mut);flex-wrap:wrap}
.sw{display:inline-block;width:11px;height:11px;border-radius:3px;vertical-align:-1px;margin-right:4px}
</style>
</head>
<body>
<div id="gate"><div class="b"><h1>Record<b style="color:var(--pink)">God</b> · search</h1>
<div class="bar"><input id="tok" type="password" placeholder="admin token" style="flex:1"><button class="ghost" onclick="signin()">Enter</button></div>
<div id="gerr" class="muted"></div></div></div>
<div id="app">
<!-- LEFT: search + crate contents -->
<div>
<div class="panel">
<div class="bar">
<input id="q" placeholder="🔍 search title / artist / SKU — or scan" autocomplete="off" autofocus style="flex:1">
<button class="ghost" onclick="run()">Search</button>
</div>
<div class="muted" id="count" style="margin-top:6px">type to search — click a hit to locate it</div>
<div id="res" class="res"></div>
</div>
<div class="panel">
<div class="bar" style="justify-content:space-between">
<div><span id="ccName" class="muted">Crate contents</span> <span id="ccMeta" class="muted"></span></div>
</div>
<div id="ccList" style="max-height:28vh;overflow:auto;margin-top:8px"><div class="muted">pick a crate on the map →</div></div>
</div>
<div class="panel" id="colPanel">
<div class="bar" style="justify-content:space-between"><b style="font-size:13px">📚 Collections</b>
<span><button class="ghost" style="padding:3px 9px" onclick="edNew()"> New</button> <span id="colCount" class="muted"></span></span></div>
<div id="colList" style="max-height:22vh;overflow:auto;margin-top:8px"><div class="muted">loading…</div></div>
</div>
<div class="panel" id="editPanel" style="display:none">
<div class="bar" style="justify-content:space-between"><b id="edTitle">New collection</b><button class="ghost" style="padding:3px 9px" onclick="edCancel()">✕ close</button></div>
<div style="display:grid;gap:8px;margin-top:8px">
<div class="bar"><input id="edName" placeholder="Name — e.g. HOUSE $15+" style="flex:1"><input id="edColor" type="color" value="#3498db" style="width:42px;height:38px;padding:2px"></div>
<div class="bar"><span class="muted">Priority</span><input id="edPrio" type="number" value="0" style="width:64px">
<span class="muted">Sort</span><select id="edSort" style="flex:1">
<option value="alpha_artist">Artist AZ</option><option value="alpha_title">Title AZ</option>
<option value="year_asc">Year ↑</option><option value="year_desc">Year ↓</option>
<option value="price_asc">Price ↑</option><option value="price_desc">Price ↓</option><option value="date_added">Date added</option></select></div>
<div class="bar"><span class="muted">Price</span><input id="edPmin" type="number" placeholder="min" style="width:62px" oninput="edStats()"><input id="edPmax" type="number" placeholder="max" style="width:62px" oninput="edStats()">
<span class="muted">Year</span><input id="edYmin" type="number" placeholder="min" style="width:60px" oninput="edStats()"><input id="edYmax" type="number" placeholder="max" style="width:60px" oninput="edStats()"></div>
<div><div class="muted">Genres</div><div id="edGenres" style="display:flex;flex-wrap:wrap;gap:4px;margin-top:4px"></div></div>
<div><div class="muted">Styles</div><div class="res"><input id="edStyleQ" placeholder="search styles to add…" style="width:100%" autocomplete="off"><div id="edStyleRes" class="drop" style="display:none"></div></div>
<div id="edStyles" style="display:flex;flex-wrap:wrap;gap:4px;margin-top:4px"></div></div>
<div><div class="muted">Crates <span class="pink">— click crates on the map to add →</span></div><div id="edCrates" style="display:flex;flex-wrap:wrap;gap:4px;margin-top:4px"></div></div>
<div id="edStats" class="muted"></div>
<div class="bar"><button onclick="edSave()" style="flex:1;background:var(--pink);color:#fff;border:0;border-radius:8px;padding:9px">Save collection</button>
<button class="ghost" onclick="edDelete()" id="edDel" style="display:none;color:#c0392b">Delete</button></div>
</div>
</div>
</div>
<!-- RIGHT: the Store → Rack navigator -->
<div class="panel">
<div class="navhdr">
<span class="crumb" id="crumb">Store map</span>
<select id="spaceSel" style="padding:6px 8px"></select>
<button class="ghost" id="backBtn" style="display:none" onclick="toStore()">◀ Store</button>
<span class="lvl" id="lvlCtl" style="display:none">
<button class="ghost" id="lvlPrev" onclick="stepLevel(-1)"></button>
<span id="lvlLabel" class="muted">Level</span>
<button class="ghost" id="lvlNext" onclick="stepLevel(1)"></button>
</span>
</div>
<canvas id="cv" width="560" height="560"></canvas>
<div class="legend" id="legend"></div>
</div>
</div>
<script>
const $=s=>document.querySelector(s);
let TOKEN=localStorage.getItem('rg_token')||'';
const hdr=()=>({'Authorization':'Bearer '+TOKEN});
const get=u=>fetch(u,{headers:hdr()}).then(r=>r.json());
const money=n=>n==null?'—':'$'+Number(n).toFixed(2);
const esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
// WebP cover via RecordGod's own image cache; fall back to the raw discogs thumb if the cover can't convert
const imgTag=it=> it.release_id ? `<img src="/img/r/${it.release_id}" onerror="this.onerror=null;this.src='${(it.thumb||'').replace(/'/g,'')}'">` : (it.thumb?`<img src="${esc(it.thumb)}">`:'<span class=ph></span>');
// ── facing convention (copied from WowPlatter production, see RECORDGOD_NAVIGATOR_PLAN §3) ──
const CELL=0.34;
function directionYaw(d){ d=(d||'').toLowerCase();
if(d==='back'||d==='south') return Math.PI;
if(d==='left'||d==='west') return Math.PI/2;
if(d==='right'||d==='east') return -Math.PI/2;
return 0; } // forward/front/north
const ARROW={forward:'↓',front:'↓',north:'↓',back:'↑',south:'↑',left:'←',west:'←',right:'→',east:'→'};
let ST={view:'store', space:null, racks:[], rack:null, level:1, crates:[], levels:[], selCrate:null, hit:[]};
async function signin(){
TOKEN=$('#tok').value.trim();
if(!(await fetch('/admin/stats',{headers:hdr()})).ok){ $('#gerr').textContent='invalid token'; return; }
localStorage.setItem('rg_token',TOKEN);
$('#gate').style.display='none'; $('#app').style.display='grid';
$('#q').focus(); await loadSpaces(); toStore(); loadCollections();
}
async function loadSpaces(){
const d=await get('/nav/spaces');
$('#spaceSel').innerHTML=(d.spaces||[]).map(s=>`<option value="${s.id}">${esc(s.name||('Space '+s.id))}${s.is_default?' (default)':''}</option>`).join('');
$('#spaceSel').onchange=()=>toStore();
}
// ── STORE VIEW ──
async function toStore(){
ST.view='store'; ST.rack=null; ST.selCrate=null;
$('#backBtn').style.display='none'; $('#lvlCtl').style.display='none';
$('#crumb').textContent='Store map';
const sid=$('#spaceSel').value;
const d=await get('/nav/store-layout'+(sid?('?space_id='+sid):''));
ST.space=d.space; ST.racks=d.racks||[];
$('#legend').innerHTML='<span><i class="sw" style="background:#8bc34a"></i>rack — click to open</span>';
drawStore();
}
function drawStore(){
const cv=$('#cv'), g=cv.getContext('2d'), W=cv.width, H=cv.height;
g.clearRect(0,0,W,H); g.fillStyle='#fbfbfd'; g.fillRect(0,0,W,H);
ST.hit=[];
if(!ST.racks.length){ g.fillStyle='#888'; g.font='14px system-ui'; g.textAlign='center'; g.fillText('No racks in this space',W/2,H/2); return; }
// bounds over racks (pos ± half-dims) → fit
let minX=1e9,maxX=-1e9,minZ=1e9,maxZ=-1e9;
ST.racks.forEach(r=>{ const hw=(r.w||0.5)/2, hd=(r.d||0.5)/2;
minX=Math.min(minX,r.x-hw); maxX=Math.max(maxX,r.x+hw); minZ=Math.min(minZ,r.z-hd); maxZ=Math.max(maxZ,r.z+hd); });
const pad=34, sc=Math.min((W-2*pad)/((maxX-minX)||1),(H-2*pad)/((maxZ-minZ)||1));
const X=x=>pad+(x-minX)*sc, Y=z=>pad+(z-minZ)*sc;
ST.racks.forEach(r=>{
const yaw=directionYaw(r.direction)+(r.rot||0)*Math.PI/180;
const hw=(r.w||0.5)/2*sc, hd=(r.d||0.5)/2*sc, cx=X(r.x), cy=Y(r.z);
const cos=Math.cos(yaw), sin=Math.sin(yaw);
const corners=[[-hw,-hd],[hw,-hd],[hw,hd],[-hw,hd]].map(([px,pz])=>[cx+px*cos-pz*sin, cy+px*sin+pz*cos]);
g.beginPath(); g.moveTo(corners[0][0],corners[0][1]); corners.slice(1).forEach(c=>g.lineTo(c[0],c[1])); g.closePath();
g.fillStyle=r.items? '#8bc34a':'#cfe3b0'; g.fill(); g.strokeStyle='#558b2f'; g.lineWidth=1.5; g.stroke();
g.fillStyle='#1b1b22'; g.font='bold 10px system-ui'; g.textAlign='center'; g.textBaseline='middle';
const lbl=(r.name||('Rack '+r.id)); g.fillText(lbl.length>15?lbl.slice(0,14)+'…':lbl, cx, cy-(r.items?5:0));
if(r.items){ g.fillStyle='#3a3a3a'; g.font='9px system-ui'; g.fillText(r.items+' items', cx, cy+7); }
ST.hit.push({type:'rack', id:r.id, cx, cy, r:Math.max(hw,hd)+4});
});
}
// ── RACK VIEW ──
async function openRack(id, focusCrate){
const d=await get('/nav/rack/'+id);
ST.view='rack'; ST.rack=d.rack; ST.crates=d.crates||[]; ST.levels=d.levels||[];
// default to the level holding the most crates (or the focus crate's level)
const byLvl={}; ST.crates.forEach(c=>{ const l=c.level==null?1:c.level; byLvl[l]=(byLvl[l]||0)+1; });
ST.level = focusCrate!=null ? (ST.crates.find(c=>c.id===focusCrate)?.level ?? 1)
: (+Object.keys(byLvl).sort((a,b)=>byLvl[b]-byLvl[a])[0] || 1);
ST.selCrate=focusCrate||null;
$('#backBtn').style.display='';
$('#crumb').innerHTML='Store / <b>'+esc(ST.rack.name||('Rack '+ST.rack.id))+'</b>';
drawRack();
}
function levelMeta(){
const lv=ST.rack.levels||1;
const cur=ST.level, label=lv>1?`Level ${cur} of ${lv}${cur===1?' (BOTTOM)':(cur===lv?' (TOP)':'')}`:'Single level';
return {lv, label};
}
function drawRack(){
const {lv,label}=levelMeta();
$('#lvlCtl').style.display = lv>1?'inline-flex':'none';
$('#lvlLabel').textContent=label; $('#lvlPrev').disabled=ST.level<=1; $('#lvlNext').disabled=ST.level>=lv;
$('#legend').innerHTML='<span><i class="sw" style="background:#9ecbff"></i>crate</span><span>↑↓←→ = crate facing · BACK/FRONT = rack frame · green=items</span>';
const cv=$('#cv'), g=cv.getContext('2d'), W=cv.width, H=cv.height;
g.clearRect(0,0,W,H); g.fillStyle='#fbfbfd'; g.fillRect(0,0,W,H);
ST.hit=[];
const rw=ST.rack.w||1.7, rd=ST.rack.d||0.7;
const cols=Math.max(1,Math.floor(rw/CELL)), rows=Math.max(1,Math.round(rd/CELL));
const pad=42, sc=0.92*Math.min((W-2*pad)/rw,(H-2*pad)/rd);
const cW=rw*sc, cH=rd*sc, oX=(W-cW)/2, oY=(H-cH)/2, cellW=cW/cols, cellH=cH/rows;
// boundary
g.fillStyle='#fff'; g.strokeStyle='#222'; g.lineWidth=3; g.beginPath(); g.rect(oX,oY,cW,cH); g.fill(); g.stroke();
// edge labels (viewRot 0: BACK top, FRONT bottom, L left, R right)
g.fillStyle='#888'; g.font='11px system-ui'; g.textAlign='center';
g.fillText('BACK',oX+cW/2,oY-8); g.fillText('FRONT',oX+cW/2,oY+cH+16);
g.save(); g.translate(oX-10,oY+cH/2); g.rotate(-Math.PI/2); g.fillText('L',0,0); g.restore();
g.save(); g.translate(oX+cW+12,oY+cH/2); g.rotate(Math.PI/2); g.fillText('R',0,0); g.restore();
// grid
g.strokeStyle='#eee'; g.lineWidth=1;
for(let i=1;i<cols;i++){ g.beginPath(); g.moveTo(oX+i*cellW,oY); g.lineTo(oX+i*cellW,oY+cH); g.stroke(); }
for(let i=1;i<rows;i++){ g.beginPath(); g.moveTo(oX,oY+i*cellH); g.lineTo(oX+cW,oY+i*cellH); g.stroke(); }
// crates on the current level (fall back: crates with null level show on level 1)
const here=ST.crates.filter(c=>(c.level==null?1:c.level)===ST.level);
let autoSlot=0;
here.forEach(c=>{
let slot=c.slot; if(!slot||slot<1){ autoSlot++; slot=autoSlot; } // ponytail: place slotless crates sequentially
const idx=slot-1, col=idx%cols, row=Math.floor(idx/cols);
if(row>=rows) return;
const x=oX+col*cellW+3, y=oY+row*cellH+3, w=cellW-6, h=cellH-6;
const sel=ST.selCrate===c.id;
g.fillStyle=sel?'rgba(255,46,147,.30)':'rgba(30,144,255,.16)';
g.strokeStyle=sel?'#d10f7a':'#1e90ff'; g.lineWidth=sel?3:1.5;
g.beginPath(); g.rect(x,y,w,h); g.fill(); g.stroke();
g.fillStyle='#1b1b22'; g.font='bold 10px system-ui'; g.textAlign='center'; g.textBaseline='top';
g.fillText('#'+c.id, x+w/2, y+4);
const gl=(c.label_text||c.crate_purpose||'').toString().split(',')[0].slice(0,12);
g.fillStyle='#555'; g.font='9px system-ui'; g.fillText(gl||'—', x+w/2, y+16);
// facing arrow (big, centre-bottom)
g.fillStyle=sel?'#d10f7a':'#1e90ff'; g.font='bold 20px system-ui'; g.textBaseline='middle';
g.fillText(ARROW[(c.direction||'').toLowerCase()]||'•', x+w/2, y+h-13);
// slot number top-right, item count bottom-left
g.fillStyle='#1a8f54'; g.font='9px system-ui'; g.textAlign='right'; g.textBaseline='top';
g.fillText(slot, x+w-3, y+3);
g.fillStyle='#888'; g.textAlign='left'; g.fillText((c.items||0), x+3, y+h-12);
ST.hit.push({type:'crate', id:c.id, x, y, w, h});
});
}
function stepLevel(d){ ST.level=Math.max(1,Math.min(ST.rack.levels||1, ST.level+d)); drawRack(); }
// ── crate contents ──
async function loadCrate(id){
ST.selCrate=id; if(ST.view==='rack') drawRack();
const d=await get('/nav/crate/'+id); const c=d.crate;
$('#ccName').innerHTML='📦 '+esc(c.label_text||c.name||('Crate '+c.id));
$('#ccName').className='pink';
$('#ccMeta').textContent=`· ${c.rack_name||''} L${c.level??'?'} slot ${c.slot??'?'} · ${d.items.length} items`;
$('#ccList').innerHTML=d.items.length? '<table><thead><tr><th>Slot</th><th>Title</th><th>Artist</th><th>Price</th></tr></thead><tbody>'+
d.items.map(i=>`<tr><td>${i.slot??'—'}</td><td>${esc(i.title||i.sku)}${i.in_stock?'':' <span class="oos">sold</span>'}</td><td class="muted">${esc(i.artist||'')}</td><td class="pink">${money(i.price)}</td></tr>`).join('')+'</tbody></table>'
: '<div class="muted">empty crate</div>';
}
// ── collection editor ──
function showEditor(){ $('#colPanel').style.display='none'; $('#editPanel').style.display=''; }
function edCancel(){ ST.edit=null; $('#editPanel').style.display='none'; $('#colPanel').style.display=''; }
let GENRES=[];
async function loadGenres(){ if(!GENRES.length) GENRES=(await get('/nav/genres')).genres||[]; renderGenres(); }
function renderGenres(){ $('#edGenres').innerHTML=GENRES.map(g=>{ const on=ST.edit.genre_ids.includes(g.id);
return `<span onclick="edTogGenre(${g.id})" style="cursor:pointer;padding:2px 8px;border-radius:20px;font-size:11px;border:1px solid var(--line);${on?'background:#fdeef6;color:var(--pink);border-color:var(--pink)':'background:#fff'}">${esc(g.name)}</span>`;}).join(''); }
function edTogGenre(id){ const a=ST.edit.genre_ids, i=a.indexOf(id); if(i<0)a.push(id);else a.splice(i,1); renderGenres(); edStats(); }
function edNew(){ ST.edit={id:null,crate_ids:[],crateNames:{},genre_ids:[],style_ids:[],styleNames:{}};
$('#edTitle').textContent='New collection'; $('#edDel').style.display='none';
$('#edName').value=''; $('#edColor').value='#3498db'; $('#edPrio').value=0; $('#edSort').value='alpha_artist';
['edPmin','edPmax','edYmin','edYmax'].forEach(k=>$('#'+k).value=''); loadGenres(); edRender(); showEditor(); }
async function edLoad(id){ const d=await get('/nav/collections/'+id); const c=d.collection;
ST.edit={id:c.id,crate_ids:(c.crate_ids||[]).slice(),crateNames:{},genre_ids:(c.genre_ids||[]).slice(),style_ids:(c.style_ids||[]).slice(),styleNames:{}};
(d.crates||[]).forEach(x=>ST.edit.crateNames[x.id]=x.label_text||x.name||('Crate '+x.id));
(c.styles||[]).forEach(s=>ST.edit.styleNames[s.id]=s.name);
$('#edTitle').textContent='Edit: '+esc(c.name); $('#edDel').style.display='';
$('#edName').value=c.name||''; $('#edColor').value=c.color||'#3498db'; $('#edPrio').value=c.priority||0; $('#edSort').value=c.sort_method||'alpha_artist';
$('#edPmin').value=c.price_min??''; $('#edPmax').value=c.price_max??''; $('#edYmin').value=c.year_min??''; $('#edYmax').value=c.year_max??'';
await loadGenres(); edRender(); showEditor(); }
function chip(label,ondel){ return `<span style="display:inline-flex;gap:5px;align-items:center;background:#eef0f5;border:1px solid #d4d8e0;border-radius:20px;padding:2px 8px;font-size:11px">${esc(label)} <span onclick="${ondel}" style="cursor:pointer;color:#a44">✕</span></span>`; }
function edRender(){
$('#edStyles').innerHTML=ST.edit.style_ids.map(s=>chip(ST.edit.styleNames[s]||('style '+s),`edDelStyle(${s})`)).join('');
$('#edCrates').innerHTML=ST.edit.crate_ids.map(c=>chip(ST.edit.crateNames[c]||('crate '+c),`edDelCrate(${c})`)).join('')||'<span class="muted">none — click crates on the map</span>';
edStats();
}
function edAddCrate(id,label){ if(!ST.edit.crate_ids.includes(id)){ ST.edit.crate_ids.push(id); ST.edit.crateNames[id]=label||('crate '+id); edRender(); } }
function edDelCrate(id){ ST.edit.crate_ids=ST.edit.crate_ids.filter(x=>x!==id); edRender(); }
function edDelStyle(id){ ST.edit.style_ids=ST.edit.style_ids.filter(x=>x!==id); edRender(); }
let stq;
$('#edStyleQ').addEventListener('input',e=>{ clearTimeout(stq); stq=setTimeout(()=>edStyleSearch(e.target.value),200); });
async function edStyleSearch(q){ if(!q.trim()){ $('#edStyleRes').style.display='none'; return; }
const d=await get('/nav/styles?q='+encodeURIComponent(q)); $('#edStyleRes').style.display='block';
$('#edStyleRes').innerHTML=(d.styles||[]).map(s=>`<div class="ri" onclick='edAddStyle(${s.id},${JSON.stringify(s.name)})'>${esc(s.name)}</div>`).join('')||'<div class="muted" style="padding:6px">no match</div>'; }
function edAddStyle(id,name){ if(!ST.edit.style_ids.includes(id)){ ST.edit.style_ids.push(id); ST.edit.styleNames[id]=name; } $('#edStyleQ').value=''; $('#edStyleRes').style.display='none'; edRender(); }
function edDraft(){ const v=k=>{const x=$('#'+k).value; return x===''?null:+x;};
return {id:ST.edit.id, name:$('#edName').value.trim(), color:$('#edColor').value, priority:+$('#edPrio').value||0,
sort_method:$('#edSort').value, crate_ids:ST.edit.crate_ids, genre_ids:ST.edit.genre_ids, style_ids:ST.edit.style_ids,
price_min:v('edPmin'), price_max:v('edPmax'), year_min:v('edYmin'), year_max:v('edYmax')}; }
let stt;
async function edStats(){ if(!ST.edit) return; clearTimeout(stt); stt=setTimeout(async()=>{
const r=await fetch('/nav/collections/stats',{method:'POST',headers:hdr(),body:JSON.stringify(edDraft())}).then(x=>x.json());
$('#edStats').innerHTML=`<b class="pink">${r.matching}</b> matching · <b class="ok">${r.located}</b> located · <b class="oos">${r.not_located}</b> not located`; },250); }
async function edSave(){ const d=edDraft(); if(!d.name){ alert('name required'); return; }
const r=await fetch('/nav/collections',{method:'POST',headers:hdr(),body:JSON.stringify(d)}).then(x=>x.json());
if(r.ok){ edCancel(); loadCollections(); } }
async function edDelete(){ if(!ST.edit.id||!confirm('Delete this collection?')) return;
await fetch('/nav/collections/'+ST.edit.id,{method:'DELETE',headers:hdr()}); edCancel(); loadCollections(); }
// ── canvas clicks ──
$('#cv').addEventListener('click',e=>{
const r=$('#cv').getBoundingClientRect(), sx=$('#cv').width/r.width, sy=$('#cv').height/r.height;
const mx=(e.clientX-r.left)*sx, my=(e.clientY-r.top)*sy;
if(ST.view==='store'){
let best=null,bd=1e9; for(const h of ST.hit){ const dd=Math.hypot(mx-h.cx,my-h.cy); if(dd<h.r&&dd<bd){bd=dd;best=h;} }
if(best) openRack(best.id);
} else {
for(const h of ST.hit){ if(mx>=h.x&&mx<=h.x+h.w&&my>=h.y&&my<=h.y+h.h){
if(ST.edit){ const cr=ST.crates.find(c=>c.id===h.id); edAddCrate(h.id, cr&&(cr.label_text||cr.crate_purpose)); }
else loadCrate(h.id); return; } }
}
});
// ── search → locate ──
let st;
$('#q').addEventListener('input',e=>{ clearTimeout(st); st=setTimeout(run,200); });
$('#q').addEventListener('keydown',e=>{ if(e.key==='Enter'){clearTimeout(st);run();} });
async function run(){
const q=$('#q').value.trim();
if(!q){ $('#res').innerHTML=''; $('#count').textContent='type to search — click a hit to locate it'; return; }
const d=await get('/admin/inventory?q='+encodeURIComponent(q));
$('#count').textContent=`${d.total} match${d.total===1?'':'es'} — click to locate`;
$('#res').innerHTML=(d.items||[]).map(it=>`<div class="ri" onclick='locate(${it.crate_id||'null'}, ${JSON.stringify(it.sku||'')})'>
${imgTag(it)}
<div class="t"><div class="nm">${esc(it.title||it.sku)}</div>
<div class="muted">${esc(it.artist||'')} ${it.condition?'· '+esc(it.condition):''} · <b class="${it.in_stock?'pink':'oos'}">${money(it.price)}</b></div>
${it.crate?`<span class="loc">📍 ${esc(it.crate)}</span>`:'<span class="muted">· no bin</span>'}</div></div>`).join('')
|| '<div class="muted" style="padding:8px">no match anywhere in the store</div>';
}
async function locate(crateId, sku){
if(crateId){ goCrate(crateId); return; }
// no exact bin → fallback: where SHOULD it be (collection zone)
const d=await get('/nav/locate?sku='+encodeURIComponent(sku||''));
if(d.suggested) showSuggestion(d.suggested);
else { $('#ccName').className='muted'; $('#ccName').textContent='No bin & no matching collection zone'; $('#ccMeta').textContent=''; $('#ccList').innerHTML='<div class="muted">this record isn\'t filed and no collection rule matches it yet</div>'; }
}
async function goCrate(id){
const d=await get('/nav/crate/'+id);
if(d.crate && d.crate.rack_id) await openRack(d.crate.rack_id, id);
loadCrate(id);
}
function showSuggestion(sug){
$('#ccName').className='pink'; $('#ccName').innerHTML='🎯 Should be in: '+esc(sug.name);
$('#ccMeta').textContent='· fallback zone ('+sug.crates.length+' crates)';
$('#ccList').innerHTML=sug.crates.length? sug.crates.map(c=>`<div class="ri" onclick="goCrate(${c.id})"><div class="t"><div class="nm">${esc(c.label||('Crate '+c.id))}</div></div><span class="loc">go →</span></div>`).join('')
: '<div class="muted">collection has no crates assigned</div>';
}
// ── collections ──
async function loadCollections(){
const d=await get('/nav/collections');
$('#colCount').textContent=(d.collections||[]).length+' saved';
$('#colList').innerHTML=(d.collections||[]).map(c=>{
const rule=[c.price_min!=null?('$'+c.price_min+'+'):'', c.year_min?(c.year_min+(c.year_max?'-'+c.year_max:'+')):'', c.n_styles?(c.n_styles+'st'):'', c.n_genres?(c.n_genres+'g'):''].filter(Boolean).join(' · ');
return `<div class="ri" onclick="openCollection(${c.id})"><span class="sw" style="background:${esc(c.color||'#3498db')}"></span>
<div class="t"><div class="nm">${esc(c.name)}</div><div class="muted">${c.crate_count} crates${rule?' · '+esc(rule):''}</div></div>
<span onclick="event.stopPropagation();edLoad(${c.id})" style="cursor:pointer;padding:0 4px" title="edit">✏️</span></div>`;
}).join('')||'<div class="muted">no collections</div>';
}
async function openCollection(id){
const d=await get('/nav/collections/'+id); const c=d.collection;
$('#ccName').className='pink'; $('#ccName').innerHTML='📚 '+esc(c.name);
$('#ccMeta').textContent='· '+(d.crates||[]).length+' crates';
$('#ccList').innerHTML=(d.crates||[]).length? (d.crates||[]).map(x=>`<div class="ri" onclick="goCrate(${x.id})"><div class="t"><div class="nm">${esc(x.label_text||x.name||('Crate '+x.id))}</div><div class="muted">${x.items||0} items</div></div><span class="loc">go →</span></div>`).join('')
: '<div class="muted">no crates in this collection</div>';
}
if(TOKEN){ $('#tok').value=TOKEN; signin(); }
</script>
</body>
</html>