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
+
+
+
New collection
+
+
@@ -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){