379 lines
26 KiB
HTML
379 lines
26 KiB
HTML
<!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 A–Z</option><option value="alpha_title">Title A–Z</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=>({'&':'&','<':'<','>':'>','"':'"'}[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>
|