diff --git a/app/collections_routes.py b/app/collections_routes.py index 48e5dc4..f1b3286 100644 --- a/app/collections_routes.py +++ b/app/collections_routes.py @@ -85,11 +85,17 @@ async def get_collection(cid: int, ident=Depends(require_token), db=Depends(get_ """), {"ids": crate_ids})).mappings()] order = {cid_: n for n, cid_ in enumerate(crate_ids)} # keep click order crates.sort(key=lambda x: order.get(x["id"], 999)) + gids, sids = _ids(c["genre_ids"]), _ids(c["style_ids"]) + gn = {r["id"]: r["name"] for r in (await db.execute(text( + "SELECT id, name FROM disc_genre WHERE id = ANY(:i)"), {"i": gids})).mappings()} if gids else {} + sn = {r["id"]: r["name"] for r in (await db.execute(text( + "SELECT id, name FROM disc_style WHERE id = ANY(:i)"), {"i": sids})).mappings()} if sids else {} return {"collection": { "id": c["id"], "name": c["name"], "color": c["color"], "priority": c["priority"], "is_active": bool(c["is_active"]), "sort_method": c["sort_method"], - "crate_ids": crate_ids, "genre_ids": _arr(c["genre_ids"]), "style_ids": _arr(c["style_ids"]), - "label_ids": _arr(c["label_ids"]), "format_descriptions": _arr(c["format_descriptions"]), + "crate_ids": crate_ids, "genre_ids": gids, "style_ids": sids, + "genres": [{"id": g, "name": gn.get(g, str(g))} for g in gids], + "styles": [{"id": s, "name": sn.get(s, str(s))} for s in sids], "price_min": float(c["price_min"]) if c["price_min"] is not None else None, "price_max": float(c["price_max"]) if c["price_max"] is not None else None, "year_min": c["year_min"], "year_max": c["year_max"]}, @@ -129,6 +135,61 @@ async def delete_collection(cid: int, ident=Depends(require_token), db=Depends(g return {"ok": True} +@router.get("/genres") +async def genres(ident=Depends(require_token), db=Depends(get_db)): + rows = await db.execute(text("SELECT id, name FROM disc_genre ORDER BY name")) + return {"genres": [dict(r) for r in rows.mappings()]} + + +@router.get("/styles") +async def styles(q: str = Query(""), ident=Depends(require_token), db=Depends(get_db)): + if q.strip(): + rows = await db.execute(text( + "SELECT id, name FROM disc_style WHERE name ILIKE :q ORDER BY name LIMIT 40" + ), {"q": f"%{q.strip()}%"}) + else: + rows = await db.execute(text("SELECT id, name FROM disc_style ORDER BY name LIMIT 40")) + return {"styles": [dict(r) for r in rows.mappings()]} + + +class StatsIn(BaseModel): + crate_ids: list[int] = [] + genre_ids: list[int] = [] + style_ids: list[int] = [] + price_min: float | None = None + price_max: float | None = None + year_min: int | None = None + year_max: int | None = None + + +@router.post("/collections/stats") +async def collection_stats(body: StatsIn, ident=Depends(require_token), db=Depends(get_db)): + """Live match panel for the editor: how many in-stock records the rules select, and how many of + those actually sit in the chosen crates (located) vs not (misfiled).""" + conds, p = ["i.in_stock"], {} + if body.price_min is not None: + conds.append("i.price >= :pmin"); p["pmin"] = body.price_min + if body.price_max is not None: + conds.append("i.price <= :pmax"); p["pmax"] = body.price_max + if body.year_min is not None: + conds.append("dr.year >= :ymin"); p["ymin"] = body.year_min + if body.year_max is not None: + conds.append("dr.year <= :ymax"); p["ymax"] = body.year_max + if body.genre_ids: + conds.append("dr.genre_ids && :genres"); p["genres"] = body.genre_ids + if body.style_ids: + conds.append("dr.style_ids && :styles"); p["styles"] = body.style_ids + if len(conds) == 1: + return {"matching": 0, "located": 0, "not_located": 0} # no rules = matches nothing + base = "FROM inventory i JOIN disc_release dr ON dr.id = i.release_id WHERE " + " AND ".join(conds) + matching = (await db.execute(text(f"SELECT count(*) {base}"), p)).scalar() + located = 0 + if body.crate_ids: + located = (await db.execute(text( + f"SELECT count(*) {base} AND i.crate_id = ANY(:crates)"), {**p, "crates": body.crate_ids})).scalar() + return {"matching": matching, "located": located, "not_located": matching - located} + + @router.get("/locate") async def locate(release_id: int | None = Query(None), sku: str | None = Query(None), ident=Depends(require_token), db=Depends(get_db)): diff --git a/site/search.html b/site/search.html index 12487df..bf5a40f 100644 --- a/site/search.html +++ b/site/search.html @@ -59,10 +59,31 @@
pick a crate on the map β†’
-
-
πŸ“š Collections
+
+
πŸ“š Collections +
loading…
+
@@ -232,6 +253,55 @@ async function loadCrate(id){ : '
empty crate
'; } +// ── 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 `${esc(g.name)}`;}).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 `${esc(label)} βœ•`; } +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 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=>`
${esc(s.name)}
`).join('')||'
no match
'; } +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=`${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(); } } +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; @@ -240,7 +310,9 @@ $('#cv').addEventListener('click',e=>{ 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){ loadCrate(h.id); return; } } + 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; } } } }); @@ -286,7 +358,8 @@ async function loadCollections(){ $('#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 `
-
${esc(c.name)}
${c.crate_count} crates${rule?' Β· '+esc(rule):''}
`; +
${esc(c.name)}
${c.crate_count} crates${rule?' Β· '+esc(rule):''}
+ ✏️`; }).join('')||'
no collections
'; } async function openCollection(id){