From c4b544373d095e0a1c410a061dbaf299ce0b8aa7 Mon Sep 17 00:00:00 2001 From: type-two Date: Tue, 23 Jun 2026 09:58:18 +1000 Subject: [PATCH] feat(nav): unified tabbed Search & Inventory workstation (WowPlatter parity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One persistent central store/rack map with a top tab strip (Scanner / Returns / Reorganize / Collections / Stock Finder) all driving the shared map, + Store/Rack/ Crate/Item view buttons and an omni scan bar (release/SKU/c12/r3). Reorganize: click crates IN ORDER (numbered pick badges feed the A–Z distribution planner) + drag racks to move them on the store map (persists pos_x/pos_z; RackEditIn gained position fields). Collections edit highlights picked crates on the map. Returns = stub (manual path via Scanner + Release Info for now). Co-Authored-By: Claude Opus 4.8 --- app/navigator_routes.py | 4 + site/search.html | 583 +++++++++++++++++++++++----------------- 2 files changed, 342 insertions(+), 245 deletions(-) diff --git a/app/navigator_routes.py b/app/navigator_routes.py index 6a5f84d..24d020e 100644 --- a/app/navigator_routes.py +++ b/app/navigator_routes.py @@ -83,6 +83,10 @@ async def rack_detail(rack_id: int, ident=Depends(require_token), db=Depends(get class RackEditIn(BaseModel): name: str | None = None + pos_x: float | None = None # move the rack on the store map (metres) + pos_z: float | None = None + direction: str | None = None # forward | back | left | right + rotation_y: float | None = None # fine rotation (degrees) @router.post("/rack/{rack_id}") diff --git a/site/search.html b/site/search.html index b44f612..f194bfa 100644 --- a/site/search.html +++ b/site/search.html @@ -3,7 +3,7 @@ -RecordGod — search & locate +RecordGod — Search & Inventory -

RecordGod · search

+

RecordGod · Search

- -
-
-
- - -
-
type to search — click a hit to locate it
-
-
-
-
-
Crate contents
- -
- - -
pick a crate on the map →
-
-
-
💿 Release Info -
-
click a record in a crate, or look one up →
-
-
-
📚 Collections -
-
loading…
-
- +
+

Search & Inventory

+ +
- -
- @@ -118,57 +128,91 @@ 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 post=(u,b)=>fetch(u,{method:'POST',headers:hdr(),body:JSON.stringify(b)}).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 ? `` : (it.thumb?``:''); +function chip(label,ondel,num){ return `${esc(label)} `; } -// ── facing convention (copied from WowPlatter production, see RECORDGOD_NAVIGATOR_PLAN §3) ── +// facing convention (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 + return 0; } 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:[]}; +let ST={tab:'scanner', view:'store', space:null, racks:[], rack:null, level:1, crates:[], levels:[], + selCrate:null, hit:[], storeTf:null, itemShown:false, + pick:[], pickNames:{}, edit:null, crateInfo:null}; 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(); + $('#gate').style.display='none'; $('#app').style.display='block'; + await loadSpaces(); toStore(); setTab('scanner'); $('#omni').focus(); } - async function loadSpaces(){ const d=await get('/nav/spaces'); $('#spaceSel').innerHTML=(d.spaces||[]).map(s=>``).join(''); $('#spaceSel').onchange=()=>toStore(); } -// ── STORE VIEW ── +// ───────────── tabs ───────────── +function setTab(t){ + ST.tab=t; + document.querySelectorAll('.tab').forEach(b=>b.classList.toggle('on',b.dataset.t===t)); + $('#selModeHint').style.display = t==='reorganize' ? 'inline' : 'none'; + renderTool(t); + redraw(); // pick / collection-edit highlights + click behaviour depend on the tab +} +function renderTool(t){ + const p=$('#toolPanel'); + if(t==='scanner'){ p.innerHTML=toolScanner(); renderScanner(); } + else if(t==='returns'){ p.innerHTML=toolReturns(); } + else if(t==='reorganize'){ p.innerHTML=toolReorg(); renderPick(); } + else if(t==='collections'){ p.innerHTML=toolCollections(); loadCollections(); } + else if(t==='finder'){ p.innerHTML=toolFinder(); $('#q').focus(); } +} + +// ───────────── view modes ───────────── +function setViewBtns(){ + document.querySelectorAll('#viewSeg button').forEach(b=>b.classList.toggle('on',b.dataset.v===ST.view)); + $('#viewSeg [data-v=rack]').disabled=!ST.rack; + $('#viewSeg [data-v=crate]').disabled=!ST.selCrate; + $('#viewSeg [data-v=item]').disabled=!ST.itemShown; +} +function setView(m){ + if(m==='store') return toStore(); + if(m==='rack'){ if(ST.rack) openRack(ST.rack.id, ST.selCrate); return; } + if(m==='crate'){ if(ST.selCrate){ ST.view='crate'; setViewBtns(); $('#midcol').scrollIntoView({behavior:'smooth',block:'nearest'}); } return; } + if(m==='item'){ if(ST.itemShown){ ST.view='item'; setViewBtns(); $('#relBody').scrollIntoView({behavior:'smooth',block:'nearest'}); } return; } +} +function redraw(){ if(ST.view==='store') drawStore(); else if(ST.rack) drawRack(); } + +// ───────────── 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'; + $('#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='rack — click to open'; - drawStore(); + $('#legend').innerHTML='rack — click to open'+(ST.tab==='reorganize'?' · drag to move':'')+''; + drawStore(); setViewBtns(); } 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)); + ST.storeTf={minX,minZ,sc,pad}; 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; @@ -184,29 +228,29 @@ function drawStore(){ }); } -// ── RACK VIEW ── +// ───────────── 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=''; + $('#rackEditBtn').style.display=''; $('#crumb').innerHTML='Store / '+esc(ST.rack.name||('Rack '+ST.rack.id))+''; - drawRack(); + drawRack(); setViewBtns(); + if(ST.tab==='reorganize') renderPick(); } 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}; + const lv=ST.rack.levels||1, cur=ST.level; + return {lv, label: lv>1?`Level ${cur} of ${lv}${cur===1?' (BOTTOM)':(cur===lv?' (TOP)':'')}`:'Single level'}; } +function crateLabel(id){ const c=(ST.crates||[]).find(x=>x.id===id); return c&&(c.label_text||c.name)||ST.pickNames[id]||('Crate '+id); } 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='crate↑↓←→ = crate facing · BACK/FRONT = rack frame · green=items'; + $('#legend').innerHTML='crate↑↓←→ = facing · green=items'+(ST.tab==='reorganize'?' · click in order to pick':ST.tab==='collections'&&ST.edit?' · click to add to collection':'')+''; 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=[]; @@ -214,52 +258,57 @@ function drawRack(){ 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(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 + let slot=c.slot; if(!slot||slot<1){ autoSlot++; slot=autoSlot; } 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; + const picked=ST.tab==='reorganize' && ST.pick.indexOf(c.id); + const inColl=ST.tab==='collections' && ST.edit && ST.edit.crate_ids.includes(c.id); + g.fillStyle = inColl?'rgba(70,180,90,.28)' : sel?'rgba(255,46,147,.30)' : 'rgba(30,144,255,.16)'; + g.strokeStyle = inColl?'#2e8b57' : 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); + // ordered-pick badge (Reorganize) + if(ST.tab==='reorganize'){ const pi=ST.pick.indexOf(c.id); + if(pi>=0){ g.fillStyle='#d10f7a'; g.beginPath(); g.arc(x+12,y+h-12,10,0,2*Math.PI); g.fill(); + g.fillStyle='#fff'; g.font='bold 11px system-ui'; g.textAlign='center'; g.textBaseline='middle'; g.fillText(pi+1, x+12, 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 rackEdit(){ + if(!ST.rack) return; + const n=prompt('Rack name:', ST.rack.name||''); if(n==null) return; + await post('/nav/rack/'+ST.rack.id,{name:n}); openRack(ST.rack.id, ST.selCrate); +} + +// ───────────── crate contents (centre column) ───────────── async function loadCrate(id){ - ST.selCrate=id; if(ST.view==='rack') drawRack(); + ST.selCrate=id; redraw(); 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'; + ST.crateInfo=c; + $('#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} · compress`; $('#ccList').innerHTML=d.items.length? ''+ @@ -268,6 +317,8 @@ async function loadCrate(id){ `).join('')+'
SlotTitleArtistGenre/StylePrice
${esc(i.artist||'')}${esc(i.genre||'')}${i.style?' · '+esc(i.style):''} ${money(i.price)}
' : '
empty crate
'; + setViewBtns(); + if(ST.tab==='scanner') renderScanner(); } async function compressCrate(id){ if(!confirm('Renumber this crate\'s slots 1..N (close gaps)?')) return; @@ -276,7 +327,7 @@ async function compressCrate(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 + const d=await get('/nav/locate?sku='+encodeURIComponent(q)); if(d.release_id) showRelease(d.release_id); else $('#relBody').innerHTML='
no release found for that SKU
'; } @@ -288,7 +339,7 @@ function relItems(items){ } async function showRelease(rid){ if(!rid){ $('#relBody').innerHTML='
that item has no release id
'; return; } - $('#relQ').value=rid; + $('#relQ').value=rid; ST.itemShown=true; setViewBtns(); const d=await get('/nav/release/'+rid); const r=d.release; if(!r){ $('#relBody').innerHTML=`
release ${rid} isn't in the local mirror
`+relItems(d.items); return; } $('#relBody').innerHTML=` @@ -302,42 +353,31 @@ async function showRelease(rid){
Inventory copies (${d.items.length})
${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=`
- -
`; -} -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=`
🔫 Scan into ${esc(c.label_text||('Crate '+ST.selCrate))} — Release IDs / SKUs / barcodes, one per line
- -
+
- - ${last?'':''}
-
`; -} -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 }; + + ${last?'':''}
+
`; } +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()); + const d=await post('/nav/scan',b); $('#scanOut').innerHTML=`Preview — ${d.count} slots:`+(d.plan||[]).map(p=>`
${p.slot}. ${esc(p.title||p.sku)} ${esc(p.artist||'')}
`).join('') +(d.not_found.length?`
not found: ${d.not_found.map(esc).join(', ')}
`:''); } @@ -345,49 +385,54 @@ 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()); + const d=await post('/nav/scan',b); $('#scanOut').innerHTML=`✓ ${d.assigned} assigned to slots`+(d.not_found.length?`
not found: ${d.not_found.map(esc).join(', ')}
`:''); 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); -} +// ───────────── TOOL: Returns (stub) ───────────── +function toolReturns(){ return `
+ ↩ Returns +
Restock a sold copy: look it up in Release Info, then put it back in its crate via the Scanner.
+
A one-scan "return → restock" flow lands here next.
`; } -// ── Reorganize — A–Z re-file planner (uses /nav/reorg/scan + /apply) ── +// ───────────── TOOL: Reorganize ───────────── +function toolReorg(){ return `
+
📦 Source Crate Management
+
Click crates on the map in order, or type IDs.
+
+
+
+
`; } +function renderPick(){ const el=$('#pickList'); if(!el) return; + el.innerHTML=ST.pick.map((id,i)=>chip(`#${i+1} `+crateLabel(id), `unpick(${id})`, true)).join('')||'none picked'; } +function togglePick(id){ const i=ST.pick.indexOf(id); + if(i<0){ ST.pick.push(id); const c=(ST.crates||[]).find(x=>x.id===id); if(c) ST.pickNames[id]=c.label_text||c.name||('Crate '+id); } + else ST.pick.splice(i,1); + renderPick(); drawRack(); } +function unpick(id){ ST.pick=ST.pick.filter(x=>x!==id); renderPick(); redraw(); } +function pickFromInput(){ ST.pick=($('#roSrc').value.match(/\d+/g)||[]).map(Number); renderPick(); redraw(); } +function clearPick(){ ST.pick=[]; renderPick(); redraw(); } 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=`
-
🔀 Reorganize — A–Z re-file
-
Source crate IDs (comma-separated)
-
-
-
`; -} 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=`${d.count} items in ${ids.length} crate(s)`; - $('#roStart').value=ids[0]; $('#roCount').value=ids.length; - $('#roPlanArea').style.display='block'; + const ids = ST.pick.length ? ST.pick : ($('#roSrc').value.match(/\d+/g)||[]).map(Number); + if(!ids.length){ $('#roScanOut').textContent='pick crates first'; return; } + ST.pick=ids; renderPick(); + const d=await post('/nav/reorg/scan',{crate_ids:ids}); roItems=d.items||[]; + $('#roScanOut').innerHTML=`${d.count} items · ${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||''); @@ -401,14 +446,14 @@ function reorgPlan(){ 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=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=`
${roMoves.length} items → crates ${targets.join(', ')}${roLeftovers.length?' · '+roLeftovers.length+' leftover':''}
` + $('#roPlanOut').innerHTML=`
${roMoves.length} → crates ${targets.join(', ')}${roLeftovers.length?' · '+roLeftovers.length+' leftover':''}
` +'' +roMoves.slice(0,300).map(m=>``).join('') +(roMoves.length>300?``:'')+'
→ Crate/SlotTitleArtistFrom
${m.new_crate_id} / ${m.new_slot}${esc(m.title||m.sku)}${esc(m.artist||'')}${esc(m.from)}
…+${roMoves.length-300} more
'; @@ -416,16 +461,37 @@ function reorgPlan(){ 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()); + const d=await post('/nav/reorg/apply',{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}))}); $('#roPlanOut').innerHTML=`✓ ${d.matched_updated} re-filed${d.leftovers_archived?', '+d.leftovers_archived+' unfiled':''}`; if(ST.view!=='store') toStore(); } -// ── collection editor ── +// ───────────── TOOL: Collections ───────────── +function toolCollections(){ return `
+
📚 Collections +
+
loading…
+ `; } function showEditor(){ $('#colPanel').style.display='none'; $('#editPanel').style.display=''; } -function edCancel(){ ST.edit=null; $('#editPanel').style.display='none'; $('#colPanel').style.display=''; } +function edCancel(){ ST.edit=null; $('#editPanel').style.display='none'; $('#colPanel').style.display=''; redraw(); } 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); @@ -434,7 +500,7 @@ function edTogGenre(id){ const a=ST.edit.genre_ids, i=a.indexOf(id); if(i<0)a.pu 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(); } + ['edPmin','edPmax','edYmin','edYmax'].forEach(k=>$('#'+k).value=''); loadGenres(); edRender(); showEditor(); redraw(); } 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)); @@ -442,18 +508,17 @@ async function edLoad(id){ const d=await get('/nav/collections/'+id); const c=d. $('#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 `${esc(label)} `; } + await loadGenres(); edRender(); showEditor(); redraw(); } 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('')||'none — click crates on the map'; 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 edAddCrate(id,label){ if(!ST.edit.crate_ids.includes(id)){ ST.edit.crate_ids.push(id); ST.edit.crateNames[id]=label||('crate '+id); edRender(); redraw(); } } +function edDelCrate(id){ ST.edit.crate_ids=ST.edit.crate_ids.filter(x=>x!==id); edRender(); redraw(); } 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); }); +document.addEventListener('input',e=>{ if(e.target&&e.target.id==='edStyleQ'){ 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=>`
${esc(s.name)}
`).join('')||'
no match
'; } @@ -464,66 +529,14 @@ function edDraft(){ const v=k=>{const x=$('#'+k).value; return x===''?null:+x;}; 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()); + const r=await post('/nav/collections/stats',edDraft()); $('#edStats').innerHTML=`${r.matching} matching · ${r.located} located · ${r.not_located} 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(); } } + const r=await post('/nav/collections',d); 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.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=>`
- ${imgTag(it)} -
${esc(it.title||it.sku)}
-
${esc(it.artist||'')} ${it.condition?'· '+esc(it.condition):''} · ${money(it.price)}
- ${it.crate?`📍 ${esc(it.crate)}`:'· no bin'}
`).join('') - || '
no match anywhere in the store
'; -} -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='
this record isn\'t filed and no collection rule matches it yet
'; } -} -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=>`
${esc(c.label||('Crate '+c.id))}
go →
`).join('') - : '
collection has no crates assigned
'; -} - -// ── collections ── async function loadCollections(){ - const d=await get('/nav/collections'); + const d=await get('/nav/collections'); if(!$('#colList')) return; $('#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(' · '); @@ -540,6 +553,86 @@ async function openCollection(id){ : '
no crates in this collection
'; } +// ───────────── TOOL: Stock Finder ───────────── +function toolFinder(){ return `
+ 🔎 Stock Finder +
+
type to search — click a hit to locate it
+
`; } +let st; +document.addEventListener('input',e=>{ if(e.target&&e.target.id==='q'){ clearTimeout(st); st=setTimeout(runSearch,200); } }); +document.addEventListener('keydown',e=>{ if(e.target&&e.target.id==='q'&&e.key==='Enter'){ clearTimeout(st); runSearch(); } }); +async function runSearch(){ + const q=$('#q')?$('#q').value.trim():''; if(!$('#finderRes')) return; + if(!q){ $('#finderRes').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`; + $('#finderRes').innerHTML=(d.items||[]).map(it=>`
+ ${imgTag(it)} +
${esc(it.title||it.sku)}
+
${esc(it.artist||'')} ${it.condition?'· '+esc(it.condition):''} · ${money(it.price)}
+ ${it.crate?`📍 ${esc(it.crate)}`:'· no bin'}
`).join('') + || '
no match anywhere in the store
'; +} +async function locate(crateId, sku){ + if(crateId){ goCrate(crateId); return; } + 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='
this record isn\'t filed and no collection rule matches it yet
'; } +} +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=>`
${esc(c.label||('Crate '+c.id))}
go →
`).join('') + : '
collection has no crates assigned
'; +} + +// ───────────── omni bar ───────────── +async function omni(){ + const v=$('#omni').value.trim(); if(!v) return; + let m; + if(m=v.match(/^c\s*(\d+)$/i)) return goCrate(+m[1]); + if(m=v.match(/^r\s*(\d+)$/i)) return openRack(+m[1]); + if(/^\d+$/.test(v)) return showRelease(+v); + setTab('finder'); $('#q').value=v; runSearch(); +} +$('#omni').addEventListener('keydown',e=>{ if(e.key==='Enter') omni(); }); + +// ───────────── canvas: unified click + rack drag ───────────── +function cvXY(e){ const cv=$('#cv'),r=cv.getBoundingClientRect(); return {mx:(e.clientX-r.left)*cv.width/r.width, my:(e.clientY-r.top)*cv.height/r.height}; } +function hitRack(mx,my){ let best=null,bd=1e9; for(const h of ST.hit){ if(h.type!=='rack')continue; const dd=Math.hypot(mx-h.cx,my-h.cy); if(dd=h.x&&mx<=h.x+h.w&&my>=h.y&&my<=h.y+h.h) return h; } return null; } +let down=null; +$('#cv').addEventListener('mousedown',e=>{ + const {mx,my}=cvXY(e); down={mx,my,moved:false,dragRack:null}; + if(ST.tab==='reorganize' && ST.view==='store'){ const h=hitRack(mx,my); if(h) down.dragRack=h.id; } +}); +$('#cv').addEventListener('mousemove',e=>{ + if(!down||!down.dragRack) return; + const {mx,my}=cvXY(e); + if(Math.hypot(mx-down.mx,my-down.my)>5) down.moved=true; + if(down.moved){ const tf=ST.storeTf, r=ST.racks.find(x=>x.id===down.dragRack); + r.x=tf.minX+(mx-tf.pad)/tf.sc; r.z=tf.minZ+(my-tf.pad)/tf.sc; drawStore(); } +}); +window.addEventListener('mouseup',async e=>{ + if(!down) return; const d=down; down=null; + if(d.dragRack && d.moved){ const r=ST.racks.find(x=>x.id===d.dragRack); + await post('/nav/rack/'+d.dragRack,{pos_x:+r.x.toFixed(3),pos_z:+r.z.toFixed(3)}); return; } + if(d.moved) return; // a non-rack drag — ignore + // a real click → dispatch by view + tab + const {mx,my}=cvXY(e); + if(ST.view==='store'){ const h=hitRack(mx,my); if(h) openRack(h.id); return; } + const h=hitCrate(mx,my); if(!h) return; + if(ST.tab==='reorganize'){ togglePick(h.id); loadCrate(h.id); } + else if(ST.tab==='collections' && ST.edit){ const cr=ST.crates.find(c=>c.id===h.id); edAddCrate(h.id, cr&&(cr.label_text||cr.name)); loadCrate(h.id); } + else loadCrate(h.id); +}); + if(TOKEN){ $('#tok').value=TOKEN; signin(); }