Directory: chips by kind and category over a grid of cover art

Feeds take their channel's first <itunes:category> into a new feeds.category
column; the migration drops ETag and Last-Modified once so every feed re-reads
on its normal schedule and picks one up. /api/popular and /api/directory carry
category and podcast (any audio or video enclosure). Directory becomes a grid of
cover-art tiles under a chip rail: All, Podcasts, Blogs, and a podcast's
categories once Podcasts is picked. Popular and Add a feed keep their rows.

Closes #4, closes #5, closes #6, closes #7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 16:09:47 +00:00
parent 97934d641a
commit 954173cacf
11 changed files with 207 additions and 38 deletions

View File

@@ -195,6 +195,21 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
.childrow .art{width:32px;height:32px;font-size:12px}
.childrow .txt{flex:1;min-width:0}
.childrow .txt b{display:block;font-weight:500;font-size:13.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* Directory's chips pick what its grid shows. The picked one is underlined in --accent2, as the
download bar and the now-playing EQ are; a .badge's fill already means unread in the sidebar. */
.chips{display:flex;flex-wrap:wrap;gap:2px 6px;margin-top:4px}
.chips button{flex:none;padding:4px 6px;font-size:13px;color:var(--dim);white-space:nowrap;border-bottom:2px solid transparent}
.chips button:hover{color:var(--fg)}
.chips button[aria-pressed="true"]{color:var(--fg);border-bottom-color:var(--accent2)}
/* Square cover art, title underneath: podcast art is made to be known at a glance. The subscribe
button stays visible, since a button that only shows on hover cannot be reached on a phone. */
.tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:18px 14px;margin-top:14px}
.tiles>.hint{grid-column:1/-1}
.tile{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 2px;align-items:start;cursor:pointer}
.tile .art{grid-column:1/-1;width:100%;height:auto;aspect-ratio:1;font-size:34px}
.tile .txt b{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;overflow-wrap:anywhere;font-weight:500;font-size:13px;line-height:1.3}
.tile .txt small{display:block;color:var(--faint);font-size:11.5px}
.tile:hover .txt b{color:var(--accent)}
.tag{
font-size:11px;font-weight:600;
padding:1px 5px;border-radius:4px;background:var(--raise);color:var(--warn);flex:none;
@@ -219,7 +234,7 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
}
.badge.zero{background:var(--raise);color:var(--faint)}
/* Counts, sizes and times that change in place should not jiggle the text around them. */
.badge,.feed small,.childrow small,.ep,.fhead .sub,.dmeta,#status,#seekrow{font-variant-numeric:tabular-nums}
.badge,.feed small,.childrow small,.tile small,.ep,.fhead .sub,.dmeta,#status,#seekrow{font-variant-numeric:tabular-nums}
/* ---------- main ---------- */
#main{overflow:hidden;min-height:0;min-width:0;display:flex;flex-direction:column}
@@ -515,6 +530,9 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
#pnow .txt small,#pright #vol{display:none}
.wrap.plain{padding:14px}
.card{padding:16px}
/* The chips scroll sideways rather than wrap, so they never push the grid down. */
.chips{flex-wrap:nowrap;overflow-x:auto}
.tiles{grid-template-columns:repeat(auto-fill,minmax(104px,1fr));gap:14px 10px}
/* A log line has no room for four columns: keep time and message, wrap as prose. */
#logbox{font-size:11.5px;padding:8px}
@@ -1646,30 +1664,68 @@ $('#addFeed').onclick=()=>{
// What everyone here reads, you included, as a place to start. The rows carry an id, never a
// URL, so a key in someone's feed address never reaches this page. The caller hands over the
// box: looked up by id, the Add a feed dialog's list landed in the Directory pane behind it.
const NONE_LISTED='<p class="hint">Nothing yet. Feeds people here subscribe to show up here.</p>';
async function listFeeds(url,box){
let rows=[];
try{ rows=await api(url)||[]; }catch{}
box.innerHTML=rows.length?'':'<p class="hint">Nothing yet. Feeds people here subscribe to show up here.</p>';
for(const p of rows){
const el=document.createElement('div');
el.className='childrow';
el.innerHTML=artHTML(p.image,p.title||p.id)+
`<div class="txt"><b>${esc(p.title||p.id)}</b>`+
`<small class="meta">${p.subscribers} subscriber${p.subscribers===1?'':'s'}</small></div>`+
// Green, as a downloaded file is: it is already yours. Plus, beside it, is the way to get one.
(p.subscribed?`<span class="subbed" title="Subscribed: click to open it" aria-label="Subscribed">${ICON.subbed}</span>`
:`<button class="btn ico" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.subbed}</button>`);
// Yours already: the row opens it instead.
if(p.subscribed){ el.onclick=()=>{ closeModal(); selectFeed(p.id); }; box.appendChild(el); continue; }
$('[data-a="sub"]',el).onclick=async()=>{
try{
await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'});
closeModal(); toast(`Subscribed to ${p.title||p.id}`);
await loadFeeds(true); selectFeed(p.id);
}catch(e){ toast(e.message,true); }
box.innerHTML=rows.length?'':NONE_LISTED;
for(const p of rows) box.appendChild(listedFeed(p,'childrow'));
return rows.length;
}
/// One listed feed: a row in Popular and the Add a feed dialog, a tile in Directory's grid. The
/// parts are the same either way; the class lays them out.
function listedFeed(p,cls){
const el=document.createElement('div');
el.className=cls;
el.innerHTML=artHTML(p.image,p.title||p.id)+
`<div class="txt"><b>${esc(p.title||p.id)}</b>`+
`<small class="meta">${p.subscribers} subscriber${p.subscribers===1?'':'s'}</small></div>`+
// Green, as a downloaded file is: it is already yours. Plus, beside it, is the way to get one.
(p.subscribed?`<span class="subbed" title="Subscribed: click to open it" aria-label="Subscribed">${ICON.subbed}</span>`
:`<button class="btn ico" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.subbed}</button>`);
// Yours already: the row opens it instead.
if(p.subscribed){ el.onclick=()=>{ closeModal(); selectFeed(p.id); }; return el; }
$('[data-a="sub"]',el).onclick=async()=>{
try{
await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'});
closeModal(); toast(`Subscribed to ${p.title||p.id}`);
await loadFeeds(true); selectFeed(p.id);
}catch(e){ toast(e.message,true); }
};
return el;
}
// The chip Directory has picked. Kept out here because a finished scan redraws the pane, which
// would otherwise put it back to All.
let dirPick='All';
/// Directory: every listed feed as its cover art, filtered in place by the chips above it.
/// Podcasts and Blogs lead because almost no blog carries an iTunes category, and a rail of
/// categories alone left two thirds of the feeds under All. A podcast's categories open once
/// Podcasts is picked.
async function renderDirectory(url,box){
let rows=[];
try{ rows=await api(url)||[]; }catch{}
const chips=$('#chips');
if(!rows.length){ box.innerHTML=NONE_LISTED; return 0; }
const cats=[...new Set(rows.filter(p=>p.podcast&&p.category).map(p=>p.category))].sort();
const pass={All:()=>true,Podcasts:p=>p.podcast,Blogs:p=>!p.podcast};
for(const c of cats) pass['c:'+c]=p=>p.podcast&&p.category===c;
const draw=()=>{
if(!pass[dirPick]) dirPick='All';
const inPodcasts=dirPick==='Podcasts'||dirPick.startsWith('c:');
// No empty chips: a server without a blog gets no Blogs.
const keys=['All','Podcasts','Blogs',...(inPodcasts?cats.map(c=>'c:'+c):[])].filter(k=>rows.some(pass[k]));
chips.innerHTML=keys.map(k=>
`<button type="button" data-k="${esc(k)}" aria-pressed="${k===dirPick}">${esc(k.replace(/^c:/,''))}</button>`).join('');
// The chips are redrawn too, so the keyboard goes back to the one just picked.
for(const b of $$('button',chips)) b.onclick=()=>{
dirPick=b.dataset.k; draw(); $(`[data-k="${CSS.escape(dirPick)}"]`,chips)?.focus();
};
box.appendChild(el);
}
box.innerHTML='';
for(const p of rows.filter(pass[dirPick])) box.appendChild(listedFeed(p,'tile'));
};
draw();
return rows.length;
}
@@ -1680,18 +1736,19 @@ async function renderListed(v){
$('#tbRemove').disabled=true;
syncTools(null);
$('#epSearch').placeholder='Search items…';
const listening=v===VIEWS[':popular'];
const listening=v===VIEWS[':popular'], grid=v===VIEWS[':directory'];
box.innerHTML=`
<div class="fhead slim">
<div class="art">${v.icon}</div>
<div class="meta"><h2>${v.title}</h2>
<div class="sub">${v.blurb} Everyone counts, you included. Private feeds are never listed.</div></div>
</div>
<div class="childlist" id="popular"><p class="hint">Loading…</p></div>
${grid?'<div class="chips" id="chips" role="group" aria-label="Show"></div>':''}
<div class="${grid?'tiles':'childlist'}" id="popular"><p class="hint">Loading…</p></div>
${listening?`<div class="sub" style="margin:18px 0 8px;font-weight:600;color:var(--fg)">Currently Listening</div>
<div class="childlist" id="listening"><p class="hint">Loading…</p></div>`:''}`;
$('#count').textContent=v.title;
const n=await listFeeds(v.url,$('#popular',box));
const n=await (grid?renderDirectory:listFeeds)(v.url,$('#popular',box));
if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${n} feed${n===1?'':'s'}`;
if(listening) renderListening();
}