151 lines
7.6 KiB
HTML
151 lines
7.6 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=3"></script>
|
|
<style>
|
|
:root{--pink:#ff5db1;--ink:#0b0b0d;--pn:#141418;--line:#26262c;--mut:#9a9aa6;--ok:#46d18a}
|
|
*{box-sizing:border-box}
|
|
html,body{margin:0;background:var(--ink);color:#f0f0f2;font:14px/1.5 system-ui,sans-serif}
|
|
button{cursor:pointer;border:0;border-radius:8px;font:500 13px system-ui}
|
|
.ghost{background:#1d1d22;color:#ccc;border:1px solid var(--line);padding:7px 10px}
|
|
input{padding:11px 12px;border:1px solid var(--line);border-radius:9px;background:#0e0e11;color:#eee;font:15px system-ui}
|
|
#gate{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:var(--ink);z-index:50}
|
|
#gate .b{width:320px;text-align:center}#gate h1{color:var(--pink)}
|
|
#app{display:none;grid-template-columns:1.25fr 1fr;gap:16px;padding:16px;max-width:1280px;margin:0 auto}
|
|
.panel{background:var(--pn);border:1px solid var(--line);border-radius:12px;padding:14px}
|
|
.bar{display:flex;gap:8px;align-items:center;margin-bottom:12px}
|
|
.bar input{flex:1;font-size:16px}
|
|
.res{display:flex;flex-direction:column;gap:6px;max-height:calc(100vh - 200px);overflow:auto}
|
|
.ri{display:flex;gap:11px;align-items:center;padding:9px;border-radius:9px;cursor:pointer;border:1px solid transparent}
|
|
.ri:hover{background:#17171c}.ri.sel{border-color:var(--pink);background:#1c1419}
|
|
.ri img,.ri .ph{width:46px;height:46px;border-radius:6px;object-fit:cover;background:#222;flex-shrink:0}
|
|
.ri .t{flex:1;min-width:0}.ri .nm{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:500}
|
|
.muted{color:var(--mut);font-size:12px}.pink{color:var(--pink)}.ok{color:var(--ok)}
|
|
.loc{display:inline-block;margin-top:2px;padding:1px 7px;border-radius:20px;background:#221a20;color:var(--pink);font-size:11px}
|
|
.oos{color:#e6694a}
|
|
#map{width:100%;background:#0e0e11;border:1px solid var(--line);border-radius:10px;display:block;cursor:crosshair}
|
|
.maphdr{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px}
|
|
#crateName{font-weight:600}
|
|
.scan{font-size:12px}
|
|
</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">
|
|
<div class="panel">
|
|
<div class="bar">
|
|
<input id="q" placeholder="🔍 search title / artist / SKU — or scan a barcode" autocomplete="off" autofocus>
|
|
<button class="ghost" onclick="run()">Search</button>
|
|
</div>
|
|
<div class="muted scan" id="count">type to search the whole store</div>
|
|
<div id="res" class="res"></div>
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<div class="maphdr">
|
|
<div><span id="crateName" class="muted">Store map</span> <span id="crateMeta" class="muted"></span></div>
|
|
<button class="ghost" id="clearCrate" style="display:none" onclick="pickCrate(null)">show all</button>
|
|
</div>
|
|
<canvas id="map" width="520" height="520"></canvas>
|
|
<div class="muted" style="margin-top:8px">Click a bin to list what's in it. A search hit lights up its bin 📍</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const $=s=>document.querySelector(s);
|
|
let TOKEN=localStorage.getItem('rg_token')||'';
|
|
const hdr=()=>({'Authorization':'Bearer '+TOKEN});
|
|
const money=n=>n==null?'—':'$'+Number(n).toFixed(2);
|
|
const esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
|
let MAP=null, selCrate=null, hiCrate=null, hit={}; // hit = bounding boxes for click test
|
|
|
|
async function signin(){
|
|
TOKEN=$('#tok').value.trim();
|
|
const r=await fetch('/admin/stats',{headers:hdr()});
|
|
if(!r.ok){$('#gerr').textContent='invalid token';return;}
|
|
localStorage.setItem('rg_token',TOKEN);
|
|
$('#gate').style.display='none'; $('#app').style.display='grid';
|
|
$('#q').focus(); loadMap();
|
|
}
|
|
|
|
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();
|
|
const qs=new URLSearchParams(); if(q)qs.set('q',q); if(selCrate)qs.set('crate_id',selCrate);
|
|
if(!q && !selCrate){ $('#res').innerHTML=''; $('#count').textContent='type to search the whole store'; return; }
|
|
const d=await fetch('/admin/inventory?'+qs,{headers:hdr()}).then(r=>r.json());
|
|
$('#count').textContent=`${d.total} match${d.total===1?'':'es'}${selCrate?' in this bin':''}`;
|
|
$('#res').innerHTML=d.items.map((it,i)=>`<div class="ri" data-crate="${it.crate_id||''}" onclick="selRow(this,${it.crate_id||'null'})">
|
|
${it.thumb?`<img src="${it.thumb}">`:'<span class=ph></span>'}
|
|
<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> ${it.in_stock?'':'· <span class="oos">sold</span>'}</div>
|
|
${it.crate?`<span class="loc">📍 ${esc(it.crate)}</span>`:'<span class="muted">· no bin assigned</span>'}</div>
|
|
</div>`).join('') || '<div class="muted" style="padding:10px">no match anywhere in the store</div>';
|
|
}
|
|
function selRow(el,crateId){
|
|
document.querySelectorAll('.ri.sel').forEach(n=>n.classList.remove('sel'));
|
|
el.classList.add('sel'); hiCrate=crateId; draw();
|
|
}
|
|
|
|
async function loadMap(){
|
|
MAP=await fetch('/admin/storemap',{headers:hdr()}).then(r=>r.json());
|
|
draw();
|
|
}
|
|
function draw(){
|
|
if(!MAP) return;
|
|
const cv=$('#map'), g=cv.getContext('2d'), W=cv.width, H=cv.height;
|
|
g.clearRect(0,0,W,H);
|
|
const rw=MAP.space.room_width, rd=MAP.space.room_depth;
|
|
const sc=Math.min(W/rw,H/rd)*0.86;
|
|
const X=x=>W/2 + x*sc, Y=z=>H/2 + z*sc;
|
|
// room
|
|
g.strokeStyle='#33333c'; g.lineWidth=2;
|
|
g.strokeRect(X(-rw/2),Y(-rd/2),rw*sc,rd*sc);
|
|
// racks (shelving) — grey blocks
|
|
g.fillStyle='#202028';
|
|
(MAP.racks||[]).forEach(r=>block(g,X(r.x),Y(r.z),(r.w||1)*sc,(r.d||0.4)*sc,r.rot||0));
|
|
// crates (bins) — coloured by stock, the selected hit glows pink
|
|
hit={};
|
|
(MAP.crates||[]).forEach(c=>{
|
|
const ct=MAP.crate_types[c.crate_type_id]||{w:0.5,d:0.5};
|
|
const w=(ct.w||0.5)*sc, d=(ct.d||0.5)*sc, px=X(c.x), py=Y(c.z);
|
|
const isHi=hiCrate&&c.id===hiCrate, isSel=selCrate&&c.id===selCrate;
|
|
g.fillStyle = isHi?'#ff5db1' : isSel?'#7a3a55' : c.items? '#2e2e38' : '#191920';
|
|
block(g,px,py,Math.max(w,9),Math.max(d,9),c.rot||0);
|
|
if(isHi){ g.fillStyle='#ff5db1'; g.beginPath(); g.arc(px,py-14,4,0,7); g.fill(); }
|
|
hit[c.id]={x:px,y:py,r:Math.max(w,d,11)/2+3,c};
|
|
});
|
|
}
|
|
function block(g,cx,cy,w,h,rotDeg){
|
|
g.save(); g.translate(cx,cy); g.rotate(rotDeg*Math.PI/180);
|
|
g.fillRect(-w/2,-h/2,w,h); g.restore();
|
|
}
|
|
$('#map').addEventListener('click',e=>{
|
|
const r=$('#map').getBoundingClientRect(), sx=$('#map').width/r.width, sy=$('#map').height/r.height;
|
|
const mx=(e.clientX-r.left)*sx, my=(e.clientY-r.top)*sy;
|
|
let best=null,bd=1e9;
|
|
for(const id in hit){ const h=hit[id], dd=Math.hypot(mx-h.x,my-h.y); if(dd<h.r&&dd<bd){bd=dd;best=h;} }
|
|
if(best) pickCrate(best.c); else pickCrate(null);
|
|
});
|
|
function pickCrate(c){
|
|
selCrate=c?c.id:null; hiCrate=null;
|
|
$('#crateName').textContent=c?(c.label_text||c.name||('Bin '+c.id)):'Store map';
|
|
$('#crateName').className=c?'pink':'muted';
|
|
$('#crateMeta').textContent=c?`· ${c.items||0} in stock`:'';
|
|
$('#clearCrate').style.display=c?'inline-block':'none';
|
|
draw(); run();
|
|
}
|
|
if(TOKEN){ $('#tok').value=TOKEN; signin(); }
|
|
</script>
|
|
</body>
|
|
</html>
|