feat(shop): entity browse pages — artist/label/genre/style storefront views
records.html reads /artist|label|genre|style/{val} from the path, sets the
filter + an entity header (artist/label name via /shop/{kind}/{id}). /shop/browse
gains artist/label EXISTS filters + primary artist_id; release page links
artist→/artist/{id} and genre/style pills→/{kind}/{name}.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
386343cdd3
commit
28a46615a5
@ -107,6 +107,9 @@ for _stub in ("shop", "builder", "admin", "dash", "pos", "search", "records"):
|
||||
app.add_api_route(f"/{_stub}", _page(_stub + ".html"), methods=["GET"], include_in_schema=False)
|
||||
# public storefront release detail — release.html reads the id from the path
|
||||
app.add_api_route("/release/{release_id}", _page("release.html"), methods=["GET"], include_in_schema=False)
|
||||
# entity browse pages — records.html reads the kind+value from the path (/artist/9, /genre/House…)
|
||||
for _ent in ("artist", "label", "genre", "style"):
|
||||
app.add_api_route(f"/{_ent}/{{val}}", _page("records.html"), methods=["GET"], include_in_schema=False)
|
||||
# the login landing too (no-cache), matched before the "/" static mount below
|
||||
app.add_api_route("/", _page("index.html"), methods=["GET"], include_in_schema=False)
|
||||
|
||||
|
||||
@ -43,9 +43,24 @@ async def shop_catalog(q: str = Query(""), page: int = Query(1, ge=1), db=Depend
|
||||
return {"items": items, "page": page, "per": per, "total": total}
|
||||
|
||||
|
||||
@router.get("/artist/{artist_id}")
|
||||
async def shop_artist(artist_id: int, db=Depends(get_db)):
|
||||
r = (await db.execute(text("SELECT id, name, realname FROM disc_artist WHERE id=:i"),
|
||||
{"i": artist_id})).mappings().first()
|
||||
return {"artist": dict(r) if r else {"id": artist_id, "name": f"Artist {artist_id}"}}
|
||||
|
||||
|
||||
@router.get("/label/{label_id}")
|
||||
async def shop_label(label_id: int, db=Depends(get_db)):
|
||||
r = (await db.execute(text("SELECT id, name, profile FROM disc_label WHERE id=:i"),
|
||||
{"i": label_id})).mappings().first()
|
||||
return {"label": dict(r) if r else {"id": label_id, "name": f"Label {label_id}"}}
|
||||
|
||||
|
||||
@router.get("/browse")
|
||||
async def shop_browse(q: str = Query(""), genre: str = Query(""), style: str = Query(""),
|
||||
fmt: str = Query(""), year_min: int | None = None, year_max: int | None = None,
|
||||
fmt: str = Query(""), artist: int | None = None, label: int | None = None,
|
||||
year_min: int | None = None, year_max: int | None = None,
|
||||
price_min: float | None = None, price_max: float | None = None,
|
||||
sort: str = Query("new"), page: int = Query(1, ge=1), db=Depends(get_db)):
|
||||
"""Faceted, release-grouped catalog — the public Browse page. One row per release with its cheapest copy."""
|
||||
@ -60,6 +75,10 @@ async def shop_browse(q: str = Query(""), genre: str = Query(""), style: str = Q
|
||||
where.append("EXISTS (SELECT 1 FROM disc_release_style s WHERE s.release_id=i.release_id AND s.style_name=:style)"); params["style"] = style
|
||||
if fmt:
|
||||
where.append("EXISTS (SELECT 1 FROM disc_release_format f WHERE f.release_id=i.release_id AND f.name ILIKE :fmt)"); params["fmt"] = f"%{fmt}%"
|
||||
if artist:
|
||||
where.append("EXISTS (SELECT 1 FROM disc_release_artist ra WHERE ra.release_id=i.release_id AND ra.artist_id=:artist)"); params["artist"] = artist
|
||||
if label:
|
||||
where.append("EXISTS (SELECT 1 FROM disc_release_label rl WHERE rl.release_id=i.release_id AND rl.label_id=:label)"); params["label"] = label
|
||||
if year_min:
|
||||
where.append("dr.year >= :ymin"); params["ymin"] = year_min
|
||||
if year_max:
|
||||
@ -75,6 +94,7 @@ async def shop_browse(q: str = Query(""), genre: str = Query(""), style: str = Q
|
||||
items = [dict(r) for r in (await db.execute(text(f"""
|
||||
SELECT i.release_id, coalesce(min(i.title), dr.title) AS title, dr.artists_sort AS artist,
|
||||
dr.year, dr.country, min(i.price)::float AS price, count(*) AS copies,
|
||||
(SELECT artist_id FROM disc_release_artist ra WHERE ra.release_id=i.release_id ORDER BY ra.position LIMIT 1) AS artist_id,
|
||||
(SELECT string_agg(DISTINCT genre_name, ', ') FROM disc_release_genre g WHERE g.release_id=i.release_id) AS genre
|
||||
FROM inventory i JOIN disc_release dr ON dr.id = i.release_id
|
||||
WHERE {w}
|
||||
@ -105,6 +125,7 @@ async def shop_release(release_id: int, db=Depends(get_db)):
|
||||
"""Public release detail — metadata + tracklist + in-stock copies (with the Woo buy link)."""
|
||||
dr = (await db.execute(text("""
|
||||
SELECT dr.id, dr.title, dr.artists_sort AS artist, dr.country, dr.year, dr.notes, dr.master_id,
|
||||
(SELECT artist_id FROM disc_release_artist ra WHERE ra.release_id=dr.id ORDER BY ra.position LIMIT 1) AS artist_id,
|
||||
(SELECT string_agg(label_name || coalesce(' ('||catno||')',''), ', ') FROM disc_release_label WHERE release_id=dr.id) AS label,
|
||||
(SELECT string_agg(name || coalesce(' '||descriptions,''), ', ') FROM disc_release_format WHERE release_id=dr.id) AS format,
|
||||
(SELECT string_agg(DISTINCT genre_name, ', ') FROM disc_release_genre WHERE release_id=dr.id) AS genre,
|
||||
|
||||
@ -55,6 +55,7 @@
|
||||
<div class="fgroup"><h4>Format</h4><div id="fFormats"></div></div>
|
||||
</aside>
|
||||
<main>
|
||||
<h2 id="ehdr" style="display:none;margin:0 0 12px;font-weight:600;font-size:22px"></h2>
|
||||
<div class="topbar"><div class="muted" id="count">loading…</div><button class="ghost" onclick="clearAll()">clear filters</button></div>
|
||||
<div class="grid" id="grid"></div>
|
||||
<div class="pg" id="pg"></div>
|
||||
@ -63,9 +64,20 @@
|
||||
<script>
|
||||
const $=s=>document.querySelector(s);
|
||||
const esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||
let CFG={}, ST={q:'',genre:'',style:'',fmt:'',page:1};
|
||||
let CFG={}, ST={q:'',genre:'',style:'',fmt:'',artist:'',label:'',page:1};
|
||||
const money=n=>n==null?'':'$'+Number(n).toFixed(2);
|
||||
|
||||
async function parseEntity(){ // /artist/9 · /label/5 · /genre/Electronic · /style/House → header + filter
|
||||
const m=location.pathname.match(/\/(artist|label|genre|style)\/(.+)$/);
|
||||
if(!m) return '';
|
||||
const kind=m[1], val=decodeURIComponent(m[2]);
|
||||
if(kind==='genre'){ ST.genre=val; return `Genre · <b>${esc(val)}</b>`; }
|
||||
if(kind==='style'){ ST.style=val; return `Style · <b>${esc(val)}</b>`; }
|
||||
if(kind==='artist'){ ST.artist=val; const d=await fetch('/shop/artist/'+val).then(r=>r.json()).catch(()=>({})); return `Artist · <b>${esc((d.artist||{}).name||val)}</b>`; }
|
||||
if(kind==='label'){ ST.label=val; const d=await fetch('/shop/label/'+val).then(r=>r.json()).catch(()=>({})); return `Label · <b>${esc((d.label||{}).name||val)}</b>`; }
|
||||
return '';
|
||||
}
|
||||
|
||||
async function boot(){
|
||||
CFG=await fetch('/shop/config').then(r=>r.json()).catch(()=>({}));
|
||||
const t=CFG.theme||{}; const R=document.documentElement.style;
|
||||
@ -74,6 +86,7 @@ async function boot(){
|
||||
if(t.cardCols) R.setProperty('--cols',t.cardCols);
|
||||
if(t.logo) $('#logo').innerHTML=`<img src="${esc(t.logo)}">`;
|
||||
$('#menu').innerHTML=(CFG.menu||[]).map(m=>`<a href="${esc(m.href||'#')}">${esc(m.label||'')}</a>`).join('');
|
||||
const eh=await parseEntity(); if(eh){ const e=$('#ehdr'); e.innerHTML=eh+' <a href="/records" style="font-size:13px;color:var(--mut)">· all records</a>'; e.style.display='block'; }
|
||||
loadFacets(); reload();
|
||||
}
|
||||
async function loadFacets(){
|
||||
@ -86,9 +99,10 @@ function syncChips(){ document.querySelectorAll('.chip').forEach(c=>c.classList.
|
||||
function applyQ(){ ST.q=$('#q').value.trim(); ST.page=1; reload(); }
|
||||
$('#q')&&$('#q').addEventListener('keydown',e=>{ if(e.key==='Enter') applyQ(); });
|
||||
let dt; function debReload(){ clearTimeout(dt); dt=setTimeout(reload,400); }
|
||||
function clearAll(){ ST={q:'',genre:'',style:'',fmt:'',page:1}; $('#q').value=''; ['pmin','pmax','ymin','ymax'].forEach(i=>$('#'+i).value=''); syncChips(); reload(); }
|
||||
function clearAll(){ ST={q:'',genre:'',style:'',fmt:'',artist:ST.artist,label:ST.label,page:1}; $('#q').value=''; ['pmin','pmax','ymin','ymax'].forEach(i=>$('#'+i).value=''); syncChips(); reload(); }
|
||||
function qs(){
|
||||
const p=new URLSearchParams(); if(ST.q)p.set('q',ST.q); if(ST.genre)p.set('genre',ST.genre); if(ST.style)p.set('style',ST.style); if(ST.fmt)p.set('fmt',ST.fmt);
|
||||
if(ST.artist)p.set('artist',ST.artist); if(ST.label)p.set('label',ST.label);
|
||||
const g=(id,k)=>{ const v=$('#'+id).value; if(v)p.set(k,v); }; g('pmin','price_min');g('pmax','price_max');g('ymin','year_min');g('ymax','year_max');
|
||||
p.set('sort',$('#sort').value); p.set('page',ST.page); return p.toString();
|
||||
}
|
||||
|
||||
@ -60,16 +60,17 @@ async function render(){
|
||||
if(!r){ $('#app').innerHTML=`<div class="muted">release ${esc(RID)} not found</div>`; return; }
|
||||
const meta=[]; if(show('label')&&r.label)meta.push(r.label); if(show('format')&&r.format)meta.push(r.format);
|
||||
if(show('country')&&r.country)meta.push(r.country); if(show('year')&&r.year)meta.push(r.year);
|
||||
const tags=[]; if(show('genre')&&r.genre)tags.push(...r.genre.split(', ')); if(r.style)tags.push(...r.style.split(', '));
|
||||
const tags=[]; if(show('genre')&&r.genre)r.genre.split(', ').forEach(g=>tags.push({n:g,k:'genre'})); if(r.style)r.style.split(', ').forEach(s=>tags.push({n:s,k:'style'}));
|
||||
const aHtml=r.artist_id?`<a href="/artist/${r.artist_id}">${esc(r.artist||'')}</a>`:esc(r.artist||'');
|
||||
const buy=c=> c.product_url ? `<a class="btn" href="${esc(c.product_url)}" target="_blank">Buy online</a>` : `<span class="muted">in store</span>`;
|
||||
$('#app').innerHTML=`
|
||||
<div class="crumb"><a href="/records">Records</a> › ${esc(r.artist||'')}</div>
|
||||
<div class="crumb"><a href="/records">Records</a> › ${aHtml}</div>
|
||||
<div class="top">
|
||||
<div>${show('cover')?`<img class="cov" src="/img/r/${r.id}" onerror="this.style.visibility='hidden'">`:''}</div>
|
||||
<div>
|
||||
${show('title')?`<h1>${esc(r.title||'')}</h1>`:''}${show('artist')?`<div class="artist">${esc(r.artist||'')}</div>`:''}
|
||||
${show('title')?`<h1>${esc(r.title||'')}</h1>`:''}${show('artist')?`<div class="artist">${aHtml}</div>`:''}
|
||||
<div class="meta">${meta.map(m=>`<span class="pill">${esc(String(m))}</span>`).join('')}</div>
|
||||
<div class="meta">${tags.map(t=>`<span class="pill">${esc(t)}</span>`).join('')}</div>
|
||||
<div class="meta">${tags.map(t=>`<a class="pill" href="/${t.k}/${encodeURIComponent(t.n)}">${esc(t.n)}</a>`).join('')}</div>
|
||||
${show('cart')||show('price')?`<div class="copies">${(d.copies||[]).map(c=>`<div class="copy">
|
||||
<div class="cd"><div class="pr">${money(c.price)}</div><div class="muted">${esc(c.condition||'')}${c.sleeve_cond?' / '+esc(c.sleeve_cond):''} · ${esc(c.sku)}</div></div>${buy(c)}</div>`).join('')||'<div class="muted">no copies in stock</div>'}</div>`:''}
|
||||
</div>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user