feat(collections): interactive editor — click-map crate select, genre/style/price/year rules, live match stats, priority
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
10af3464ac
commit
af3a611667
@ -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)):
|
||||
|
||||
@ -59,10 +59,31 @@
|
||||
</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">
|
||||
<div class="bar" style="justify-content:space-between"><b style="font-size:13px">📚 Collections</b><span id="colCount" class="muted"></span></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 -->
|
||||
@ -232,6 +253,55 @@ async function loadCrate(id){
|
||||
: '<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;
|
||||
@ -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.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){ 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 `<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></div>`;
|
||||
<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){
|
||||
|
||||
Loading…
Reference in New Issue
Block a user