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>
127 lines
9.5 KiB
HTML
127 lines
9.5 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Records</title>
|
||
<style>
|
||
:root{--primary:#ff5db1;--accent:#46d18a;--bg:#0c0c0e;--panel:#141418;--text:#f0f0f2;--mut:#8a8a98;--line:#26262c;--radius:12px;--cols:4;--font:system-ui}
|
||
*{box-sizing:border-box}
|
||
html,body{margin:0;background:var(--bg);color:var(--text);font-family:var(--font),system-ui,sans-serif}
|
||
a{color:inherit;text-decoration:none}
|
||
header{display:flex;align-items:center;gap:16px;padding:14px 22px;border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--bg);z-index:10}
|
||
.logo{font-weight:800;font-size:20px}.logo b{color:var(--primary)}
|
||
.logo img{height:30px;vertical-align:middle}
|
||
nav.menu{display:flex;gap:16px;flex:1}nav.menu a{color:var(--mut);font-size:14px}nav.menu a:hover{color:var(--text)}
|
||
.sbox{display:flex;gap:6px}.sbox input{padding:9px 12px;border:1px solid var(--line);border-radius:9px;background:var(--panel);color:var(--text);width:220px;font:inherit}
|
||
button,.btn{cursor:pointer;border:0;border-radius:9px;font:600 13px var(--font),system-ui;padding:9px 13px;background:var(--primary);color:#10070c}
|
||
.ghost{background:var(--panel);color:var(--text);border:1px solid var(--line)}
|
||
.wrap{display:grid;grid-template-columns:230px 1fr;gap:22px;max-width:1340px;margin:0 auto;padding:20px}
|
||
.filters{align-self:start;position:sticky;top:74px}
|
||
.fgroup{margin-bottom:16px}.fgroup h4{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--mut);margin:0 0 8px}
|
||
.chip{display:inline-block;padding:4px 9px;margin:2px 3px 2px 0;border:1px solid var(--line);border-radius:20px;font-size:12px;cursor:pointer;color:var(--mut)}
|
||
.chip:hover{color:var(--text)}.chip.on{background:var(--primary);color:#10070c;border-color:var(--primary)}
|
||
.fnum{display:flex;gap:6px}.fnum input{width:100%;padding:7px;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--text);font:inherit}
|
||
.topbar{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px}
|
||
select{padding:8px 10px;border:1px solid var(--line);border-radius:9px;background:var(--panel);color:var(--text);font:inherit}
|
||
.grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:16px}
|
||
.card{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);overflow:hidden;transition:transform .12s,box-shadow .12s}
|
||
.card:hover{transform:translateY(-3px);box-shadow:0 8px 24px rgba(0,0,0,.35)}
|
||
.card .cov{width:100%;aspect-ratio:1;object-fit:cover;background:#222;display:block}
|
||
.card .cb{padding:10px}
|
||
.card .ti{font-weight:600;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.card .ar{color:var(--mut);font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.card .ge{color:var(--mut);font-size:11px;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.card .pr{color:var(--primary);font-weight:800;font-size:16px;margin-top:6px}
|
||
.card .cp{color:var(--mut);font-size:11px}
|
||
.pg{display:flex;gap:8px;align-items:center;justify-content:center;margin:22px 0}
|
||
.muted{color:var(--mut)}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<a class="logo" href="/records" id="logo">Record<b>God</b></a>
|
||
<nav class="menu" id="menu"></nav>
|
||
<div class="sbox"><input id="q" placeholder="search records…"><button onclick="applyQ()">Search</button></div>
|
||
</header>
|
||
<div class="wrap">
|
||
<aside class="filters">
|
||
<div class="fgroup"><h4>Sort</h4>
|
||
<select id="sort" onchange="reload()"><option value="new">Newest in</option><option value="price_asc">Price ↑</option><option value="price_desc">Price ↓</option><option value="year">Year</option><option value="artist">Artist A–Z</option><option value="title">Title A–Z</option></select></div>
|
||
<div class="fgroup"><h4>Price</h4><div class="fnum"><input id="pmin" type="number" placeholder="$ min" oninput="debReload()"><input id="pmax" type="number" placeholder="$ max" oninput="debReload()"></div></div>
|
||
<div class="fgroup"><h4>Year</h4><div class="fnum"><input id="ymin" type="number" placeholder="from" oninput="debReload()"><input id="ymax" type="number" placeholder="to" oninput="debReload()"></div></div>
|
||
<div class="fgroup"><h4>Genre</h4><div id="fGenres"></div></div>
|
||
<div class="fgroup"><h4>Style</h4><div id="fStyles"></div></div>
|
||
<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>
|
||
</main>
|
||
</div>
|
||
<script>
|
||
const $=s=>document.querySelector(s);
|
||
const esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||
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;
|
||
for(const [k,v] of Object.entries({'--primary':t.primary,'--accent':t.accent,'--bg':t.bg,'--panel':t.panel,'--text':t.text,'--font':t.font})) if(v) R.setProperty(k,v);
|
||
if(t.radius) R.setProperty('--radius',t.radius+'px');
|
||
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(){
|
||
const f=await fetch('/shop/facets').then(r=>r.json()).catch(()=>({}));
|
||
const chip=(arr,key)=>(arr||[]).map(x=>`<span class="chip" data-k="${key}" data-v="${esc(x.name)}" onclick="toggleFacet('${key}','${esc(x.name).replace(/'/g,"'")}')">${esc(x.name)} <span class="muted">${x.n}</span></span>`).join('');
|
||
$('#fGenres').innerHTML=chip(f.genres,'genre'); $('#fStyles').innerHTML=chip(f.styles,'style'); $('#fFormats').innerHTML=chip(f.formats,'fmt');
|
||
}
|
||
function toggleFacet(k,v){ ST[k]=ST[k]===v?'':v; ST.page=1; syncChips(); reload(); }
|
||
function syncChips(){ document.querySelectorAll('.chip').forEach(c=>c.classList.toggle('on', ST[c.dataset.k]===c.dataset.v)); }
|
||
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:'',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();
|
||
}
|
||
async function reload(){
|
||
const d=await fetch('/shop/browse?'+qs()).then(r=>r.json());
|
||
const show=k=>(CFG.card||['cover','title','artist','price','condition']).includes(k);
|
||
$('#count').textContent=`${d.total.toLocaleString()} records`;
|
||
$('#grid').innerHTML=(d.items||[]).map(r=>`<a class="card" href="/release/${r.release_id}">
|
||
${show('cover')?`<img class="cov" src="/img/r/${r.release_id}" loading="lazy" onerror="this.style.visibility='hidden'">`:''}
|
||
<div class="cb">${show('title')?`<div class="ti">${esc(r.title||'')}</div>`:''}${show('artist')?`<div class="ar">${esc(r.artist||'')}</div>`:''}
|
||
${show('genre')?`<div class="ge">${esc(r.genre||'')}${r.year?' · '+r.year:''}</div>`:''}
|
||
${show('price')?`<div class="pr">${money(r.price)}${r.copies>1?` <span class="cp">· ${r.copies} copies</span>`:''}</div>`:''}</div></a>`).join('')
|
||
|| '<div class="muted" style="grid-column:1/-1;padding:30px;text-align:center">no records match</div>';
|
||
const pages=Math.max(1,Math.ceil(d.total/d.per));
|
||
$('#pg').innerHTML=`<button class="ghost" ${ST.page<=1?'disabled':''} onclick="goPage(-1)">‹ prev</button><span class="muted">page ${ST.page} / ${pages}</span><button class="ghost" ${ST.page>=pages?'disabled':''} onclick="goPage(1)">next ›</button>`;
|
||
}
|
||
function goPage(n){ ST.page+=n; window.scrollTo(0,0); reload(); }
|
||
boot();
|
||
</script>
|
||
</body>
|
||
</html>
|