RECORDGOD/site/search.html

547 lines
39 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 class="bar" id="ccBtns" style="display:none"><button class="ghost" style="padding:3px 8px" onclick="scanToggle()">🔫 Scan</button><button class="ghost" style="padding:3px 8px" onclick="crateEditToggle()">✏️ edit</button></div>
</div>
<div id="crateEdit" style="display:none;margin-top:8px"></div>
<div id="scanPanel" style="display:none;margin-top:8px"></div>
<div id="ccList" style="max-height:26vh;overflow:auto;margin-top:8px"><div class="muted">pick a crate on the map →</div></div>
</div>
<div class="panel">
<div class="bar" style="justify-content:space-between"><b style="font-size:13px">💿 Release Info</b>
<div class="bar"><input id="relQ" placeholder="release id / sku" style="width:130px"><button class="ghost" onclick="relLookup()"></button></div></div>
<div id="relBody" class="muted" style="margin-top:8px;max-height:30vh;overflow:auto">click a record in a crate, or look one up →</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>
<button class="ghost" id="rackEditBtn" style="display:none" onclick="rackEdit()">✏️ rack</button>
<button class="ghost" onclick="reorgOpen()">🔀 Reorganize</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,'Content-Type':'application/json'});
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'; $('#rackEditBtn').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=''; $('#rackEditBtn').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;
ST.crateInfo=c; $('#ccBtns').style.display='flex'; $('#scanPanel').style.display='none'; $('#crateEdit').style.display='none';
$('#ccName').innerHTML='📦 '+esc(c.label_text||c.name||('Crate '+c.id));
$('#ccName').className='pink';
const ls = c.last_scanned ? ` · scanned ${String(c.last_scanned).slice(0,10)}${c.updated_by?' by '+esc(c.updated_by):''}` : '';
$('#ccMeta').innerHTML=`· ${esc(c.rack_name||'')} L${c.level??'?'} · ${c.items_count??d.items.length} items${ls} · <a class="pink" style="cursor:pointer" onclick="compressCrate(${id})">compress</a>`;
$('#ccList').innerHTML=d.items.length? '<table><thead><tr><th>Slot</th><th>Title</th><th>Artist</th><th>Genre/Style</th><th>Price</th></tr></thead><tbody>'+
d.items.map(i=>`<tr style="cursor:pointer" onclick="showRelease(${i.release_id||'null'})" title="release ${i.release_id||'?'}">
<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="muted" style="font-size:11px">${esc(i.genre||'')}${i.style?' · '+esc(i.style):''}</td>
<td class="pink">${money(i.price)}</td></tr>`).join('')+'</tbody></table>'
: '<div class="muted">empty crate</div>';
}
async function compressCrate(id){
if(!confirm('Renumber this crate\'s slots 1..N (close gaps)?')) return;
await fetch('/nav/reorg/compress/'+id,{method:'POST',headers:hdr()}); loadCrate(id);
}
async function relLookup(){
const q=$('#relQ').value.trim(); if(!q) return;
if(/^\d+$/.test(q)) return showRelease(parseInt(q));
const d=await get('/nav/locate?sku='+encodeURIComponent(q)); // sku → release_id
if(d.release_id) showRelease(d.release_id);
else $('#relBody').innerHTML='<div class="muted">no release found for that SKU</div>';
}
function relItems(items){
return items.length ? items.map(i=>`<div class="ri" ${i.crate_id?`onclick="goCrate(${i.crate_id})"`:''} style="${i.crate_id?'cursor:pointer':''}">
<div class="t"><div class="nm">${esc(i.sku)} · <b class="${i.in_stock?'pink':'oos'}">${money(i.price)}</b>${i.in_stock?'':' <span class="oos">sold</span>'}</div>
<div class="muted">${esc(i.condition||'')}${i.sleeve_cond?' / '+esc(i.sleeve_cond):''} ${i.crate?'· 📍 '+esc(i.crate)+' L'+(i.level??'?')+' slot '+(i.slot??'?'):'· unfiled'}</div></div>
${i.crate_id?'<span class="loc">go →</span>':''}</div>`).join('') : '<div class="muted">no inventory copies</div>';
}
async function showRelease(rid){
if(!rid){ $('#relBody').innerHTML='<div class="muted">that item has no release id</div>'; return; }
$('#relQ').value=rid;
const d=await get('/nav/release/'+rid); const r=d.release;
if(!r){ $('#relBody').innerHTML=`<div class="muted">release ${rid} isn't in the local mirror</div>`+relItems(d.items); return; }
$('#relBody').innerHTML=`
<div style="display:flex;gap:10px;margin-bottom:8px">
<img src="/img/r/${r.id}" style="width:66px;height:66px;border-radius:6px;object-fit:cover;background:#ececf0" onerror="this.style.visibility='hidden'">
<div style="flex:1;min-width:0"><div style="font-weight:700">${esc(r.title||'')}</div><div>${esc(r.artist||'')}</div>
<div class="muted" style="font-size:11px">${esc(r.label||'')}${r.format?' · '+esc(r.format):''}</div>
<div class="muted" style="font-size:11px">${esc(r.country||'')} ${r.year||''}${r.genre?' · '+esc(r.genre):''}${r.style?' / '+esc(r.style):''}</div>
${r.barcode?`<div class="muted" style="font-size:11px">📷 ${esc(r.barcode)}</div>`:''}</div>
</div>
<div class="muted" style="margin:4px 0">Inventory copies (${d.items.length})</div>${relItems(d.items)}`;
}
// ── editable crate info ──
function crateEditToggle(){
const p=$('#crateEdit'); if(p.style.display!=='none'){ p.style.display='none'; return; }
$('#scanPanel').style.display='none'; const c=ST.crateInfo||{};
p.style.display='block';
p.innerHTML=`<div class="bar"><input id="ceName" placeholder="name" value="${esc(c.name||'')}" style="flex:1">
<input id="ceLabel" placeholder="label / genre" value="${esc(c.label_text||'')}" style="flex:1">
<button onclick="crateEditSave()">Save</button></div>`;
}
async function crateEditSave(){
await fetch('/nav/crate/'+ST.selCrate,{method:'POST',headers:hdr(),body:JSON.stringify({name:$('#ceName').value,label_text:$('#ceLabel').value})});
$('#crateEdit').style.display='none'; loadCrate(ST.selCrate);
}
// ── scanner (assign scanned items to the active crate's slots) ──
function scanToggle(){
const p=$('#scanPanel'); if(p.style.display!=='none'){ p.style.display='none'; return; }
$('#crateEdit').style.display='none';
const last=localStorage.getItem('rg_lastscan')||'', c=ST.crateInfo||{};
p.style.display='block';
p.innerHTML=`<div class="muted">🔫 Scan into <b class="pink">${esc(c.label_text||('Crate '+ST.selCrate))}</b> — Release IDs / SKUs / barcodes, one per line</div>
<textarea id="scanLines" rows="5" style="width:100%;margin-top:4px"></textarea>
<div class="bar" style="margin-top:4px;flex-wrap:wrap"><select id="scanMode" onchange="$('#scanAt').style.display=this.value==='insert'?'inline-block':'none'">
<option value="replace">Replace all</option><option value="append">Add at end</option><option value="prepend">Add at start</option><option value="insert">Insert at slot…</option></select>
<input id="scanAt" type="number" placeholder="slot" style="width:60px;display:none">
<button class="ghost" onclick="scanTest()">Test</button><button onclick="scanProcess()">Process</button>
<button class="ghost" onclick="$('#scanLines').value=''">Clear</button>${last?'<button class="ghost" onclick="scanRecover()">↺ recover last</button>':''}</div>
<div id="scanOut" class="muted" style="margin-top:6px;max-height:24vh;overflow:auto"></div>`;
}
function scanBody(dry){
return { crate_id:ST.selCrate, lines:$('#scanLines').value.split('\n').map(s=>s.trim()).filter(Boolean),
mode:$('#scanMode').value, insert_at:($('#scanAt').value?parseInt($('#scanAt').value):null), dry_run:!!dry };
}
async function scanTest(){
const b=scanBody(true); if(!b.lines.length) return;
const d=await fetch('/nav/scan',{method:'POST',headers:hdr(),body:JSON.stringify(b)}).then(x=>x.json());
$('#scanOut').innerHTML=`<b>Preview — ${d.count} slots:</b>`+(d.plan||[]).map(p=>`<div>${p.slot}. ${esc(p.title||p.sku)} <span class="muted">${esc(p.artist||'')}</span></div>`).join('')
+(d.not_found.length?`<div class="oos">not found: ${d.not_found.map(esc).join(', ')}</div>`:'');
}
async function scanProcess(){
const b=scanBody(false); if(!b.lines.length) return;
if(b.mode==='replace' && !confirm('Replace all — items not scanned will be unfiled from this crate. Continue?')) return;
localStorage.setItem('rg_lastscan', $('#scanLines').value);
const d=await fetch('/nav/scan',{method:'POST',headers:hdr(),body:JSON.stringify(b)}).then(x=>x.json());
$('#scanOut').innerHTML=`<span class="ok">✓ ${d.assigned} assigned to slots</span>`+(d.not_found.length?`<div class="oos">not found: ${d.not_found.map(esc).join(', ')}</div>`:'');
loadCrate(ST.selCrate);
}
function scanRecover(){ $('#scanLines').value=localStorage.getItem('rg_lastscan')||''; }
// ── rack edit ──
async function rackEdit(){
if(!ST.rack) return;
const n=prompt('Rack name:', ST.rack.name||''); if(n==null) return;
await fetch('/nav/rack/'+ST.rack.id,{method:'POST',headers:hdr(),body:JSON.stringify({name:n})});
openRack(ST.rack.id);
}
// ── Reorganize — AZ re-file planner (uses /nav/reorg/scan + /apply) ──
let roItems=[], roMoves=[], roLeftovers=[];
function reorgOpen(){
let o=$('#reorgModal');
if(!o){ o=document.createElement('div'); o.id='reorgModal'; o.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:flex-start;justify-content:center;z-index:300;padding-top:4vh'; document.body.appendChild(o); o.addEventListener('mousedown',e=>{if(e.target===o)o.remove();}); }
o.innerHTML=`<div style="background:#fff;border:1px solid var(--line);border-radius:12px;padding:18px;width:580px;max-height:90vh;overflow:auto">
<div class="bar" style="justify-content:space-between"><b style="font-size:15px">🔀 Reorganize — AZ re-file</b><button class="ghost" onclick="document.getElementById('reorgModal').remove()">✕</button></div>
<div class="muted" style="margin:8px 0 4px">Source crate IDs (comma-separated)</div>
<div class="bar"><input id="roSrc" placeholder="e.g. 474, 475, 476" style="flex:1"><button onclick="reorgScan()">Scan</button></div>
<div id="roScanOut" class="muted" style="margin-top:6px"></div>
<div id="roPlanArea" style="display:none">
<div class="bar" style="margin-top:10px;flex-wrap:wrap"><span class="muted">Price ≤</span><input id="roPmax" type="number" style="width:70px">
<span class="muted">Year</span><input id="roYmin" type="number" placeholder="min" style="width:62px"><input id="roYmax" type="number" placeholder="max" style="width:62px"></div>
<div class="bar" style="margin-top:8px;flex-wrap:wrap"><span class="muted">Start crate</span><input id="roStart" type="number" style="width:78px">
<span class="muted">Count</span><input id="roCount" type="number" value="1" style="width:56px">
<span class="muted">Slots/crate</span><input id="roSlots" type="number" value="50" style="width:56px">
<select id="roSort"><option value="artist">Artist AZ</option><option value="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></select>
<button onclick="reorgPlan()">Plan</button></div>
<div id="roPlanOut" style="margin-top:8px;max-height:42vh;overflow:auto"></div>
</div></div>`;
}
async function reorgScan(){
const ids=$('#roSrc').value.split(',').map(s=>parseInt(s.trim())).filter(Boolean);
if(!ids.length) return;
const d=await fetch('/nav/reorg/scan',{method:'POST',headers:hdr(),body:JSON.stringify({crate_ids:ids})}).then(x=>x.json());
roItems=d.items||[];
$('#roScanOut').innerHTML=`<b>${d.count}</b> items in ${ids.length} crate(s)`;
$('#roStart').value=ids[0]; $('#roCount').value=ids.length;
$('#roPlanArea').style.display='block';
}
function roCmp(m){ return (a,b)=>{
if(m==='title') return (a.title||'').localeCompare(b.title||'');
if(m==='year_asc') return (a.year||0)-(b.year||0);
if(m==='year_desc') return (b.year||0)-(a.year||0);
if(m==='price_asc') return (a.price||0)-(b.price||0);
if(m==='price_desc') return (b.price||0)-(a.price||0);
return (a.artist||'').localeCompare(b.artist||''); }; }
function reorgPlan(){
const pmax=parseFloat($('#roPmax').value)||null, ymin=parseInt($('#roYmin').value)||null, ymax=parseInt($('#roYmax').value)||null;
let items=roItems.filter(i=> (pmax==null||(i.price!=null&&i.price<=pmax)) && (ymin==null||(i.year&&i.year>=ymin)) && (ymax==null||(i.year&&i.year<=ymax)) );
items.sort(roCmp($('#roSort').value));
const start=parseInt($('#roStart').value), count=parseInt($('#roCount').value)||1, slots=parseInt($('#roSlots').value)||50;
const targets=[]; for(let i=0;i<count;i++) targets.push(start+i);
roMoves=[]; roLeftovers=[]; let ti=0, slot=1;
for(const it of items){
if(ti>=targets.length){ roLeftovers.push(it); continue; }
roMoves.push({sku:it.sku, new_crate_id:targets[ti], new_slot:slot, title:it.title, artist:it.artist, from:`${it.crate_id||'—'}/${it.slot||'—'}`});
slot++; if(slot>slots){ slot=1; ti++; }
}
$('#roPlanOut').innerHTML=`<div class="bar" style="justify-content:space-between"><b>${roMoves.length} items → crates ${targets.join(', ')}${roLeftovers.length?' · '+roLeftovers.length+' leftover':''}</b><button onclick="reorgApply()">Apply changes</button></div>`
+'<table style="margin-top:6px"><thead><tr><th>→ Crate/Slot</th><th>Title</th><th>Artist</th><th>From</th></tr></thead><tbody>'
+roMoves.slice(0,300).map(m=>`<tr><td class="pink">${m.new_crate_id} / ${m.new_slot}</td><td>${esc(m.title||m.sku)}</td><td class="muted">${esc(m.artist||'')}</td><td class="muted">${esc(m.from)}</td></tr>`).join('')
+(roMoves.length>300?`<tr><td colspan=4 class=muted>…+${roMoves.length-300} more</td></tr>`:'')+'</tbody></table>';
}
async function reorgApply(){
if(!roMoves.length) return;
if(!confirm(`Apply ${roMoves.length} moves? Items get re-filed into their new crate/slot.`)) return;
const d=await fetch('/nav/reorg/apply',{method:'POST',headers:hdr(),body:JSON.stringify({
matched:roMoves.map(m=>({sku:m.sku,new_crate_id:m.new_crate_id,new_slot:m.new_slot})),
leftovers:roLeftovers.map(l=>({sku:l.sku}))})}).then(x=>x.json());
$('#roPlanOut').innerHTML=`<span class="ok">✓ ${d.matched_updated} re-filed${d.leftovers_archived?', '+d.leftovers_archived+' unfiled':''}</span>`;
if(ST.view!=='store') toStore();
}
// ── 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>