Directory: Podcasts and Blogs as a filter, beside the category chips

What a feed is and what it is about are two questions, so they are two
controls that combine: the same .tabs the item filters use for All,
Podcasts and Blogs, and a row of category chips that is always there,
offering only the categories among the feeds the filter lets through. A
picked chip lifts on a second press, so there is no second All.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 16:30:59 +00:00
parent 017cfd7e28
commit cbd16ce3f6
4 changed files with 54 additions and 41 deletions

View File

@@ -12,9 +12,9 @@ The long form, with what was wrong before and how it was found, is in
### Added ### Added
- Directory has a row of chips above it: Podcasts, Blogs, and once Podcasts is picked, each - Directory can be filtered to Podcasts or Blogs, and by each show's own iTunes category as a row
podcast's own iTunes category, the narrower one where a show gives two (Games, not Leisure). of chips, the narrower one where a show gives two (Games, not Leisure). The two combine, and
Picking one filters the Directory in place. both filter in place.
### Changed ### Changed

View File

@@ -72,9 +72,9 @@ re-subscribing does not pull the back catalogue again.
**Popular** and **Directory** sit at the top of the feed list, above your own feeds. Popular, also **Popular** and **Directory** sit at the top of the feed list, above your own feeds. Popular, also
shown in the Add feed dialog, lists the ten feeds with the most subscribers on this server, you shown in the Add feed dialog, lists the ten feeds with the most subscribers on this server, you
included. Directory shows every one of them A to Z as a grid of cover art, with chips above it: included. Directory shows every one of them A to Z as a grid of cover art. Above it, a filter
Podcasts (anything with audio or video), Blogs (the rest), and under Podcasts, the category each picks Podcasts (anything with audio or video) or Blogs (the rest), and chips pick the category
show gives itself in iTunes. Your own feeds are marked Subscribed. each show gives itself in iTunes; the two combine. Your own feeds are marked Subscribed.
It shows a title, artwork and a count, never a URL or who reads it. An OPML subscription is listed It shows a title, artwork and a count, never a URL or who reads it. An OPML subscription is listed
as the feeds inside it, one by one, and never the OPML itself, so you can take just the shows you as the feeds inside it, one by one, and never the OPML itself, so you can take just the shows you
want. Anything that looks private is left out: a login configured for the feed, credentials in its URL, want. Anything that looks private is left out: a login configured for the feed, credentials in its URL,

View File

@@ -697,7 +697,7 @@ test('Popular lists what everyone here reads, but never a private feed', async (
// Subscribe from the directory this time: the same feeds, as a grid of cover art. // Subscribe from the directory this time: the same feeds, as a grid of cover art.
const tiles = piper.locator('#popular .tile'); const tiles = piper.locator('#popular .tile');
const chip = name => piper.locator('#chips button', { hasText: new RegExp(`^${name}$`) }); const pick = (row, name) => piper.locator(`#dirbar .${row} button`, { hasText: new RegExp(`^${name}$`) });
await piper.locator('#feedlist .place', { hasText: 'Directory' }).click(); await piper.locator('#feedlist .place', { hasText: 'Directory' }).click();
await expect(piper.locator('#count')).toContainText(`Directory: ${dir.length} feed`); await expect(piper.locator('#count')).toContainText(`Directory: ${dir.length} feed`);
await expect(tiles.filter({ hasText: 'Test Show' })).toBeVisible(); await expect(tiles.filter({ hasText: 'Test Show' })).toBeVisible();
@@ -706,18 +706,22 @@ test('Popular lists what everyone here reads, but never a private feed', async (
await expect(tiles.filter({ hasText: /Paid Show|paid-show/ })).toHaveCount(0); await expect(tiles.filter({ hasText: /Paid Show|paid-show/ })).toHaveCount(0);
await expect(tiles).toHaveCount(dir.length); await expect(tiles).toHaveCount(dir.length);
// What a feed is before what it is about: a podcast's categories open under Podcasts. // Two filters that combine: what a feed is, and what it is about.
expect(dir.find(p => p.id === 'test-show')).toMatchObject({ podcast: true, category: 'Technology' }); expect(dir.find(p => p.id === 'test-show')).toMatchObject({ podcast: true, category: 'Technology' });
expect(dir.find(p => p.id === 'picture-blog')).toMatchObject({ podcast: false }); expect(dir.find(p => p.id === 'picture-blog')).toMatchObject({ podcast: false });
await expect(chip('Technology')).toHaveCount(0); await pick('tabs', 'Blogs').click();
await chip('Blogs').click();
await expect(tiles).toHaveCount(dir.filter(p => !p.podcast).length); await expect(tiles).toHaveCount(dir.filter(p => !p.podcast).length);
await expect(tiles.filter({ hasText: 'Test Show' })).toHaveCount(0); await expect(tiles.filter({ hasText: 'Test Show' })).toHaveCount(0);
await chip('Podcasts').click(); // No empty chips: no blog here names Technology, so Blogs does not offer it.
await chip('Technology').click(); await expect(pick('chips', 'Technology')).toHaveCount(0);
await expect(chip('Technology')).toHaveAttribute('aria-pressed', 'true'); await pick('tabs', 'Podcasts').click();
await pick('chips', 'Technology').click();
await expect(pick('chips', 'Technology')).toHaveAttribute('aria-pressed', 'true');
await expect(tiles).toHaveCount(dir.filter(p => p.podcast && p.category === 'Technology').length); await expect(tiles).toHaveCount(dir.filter(p => p.podcast && p.category === 'Technology').length);
await chip('All').click(); // A second press lifts the chip and leaves the kind as it was.
await pick('chips', 'Technology').click();
await expect(tiles).toHaveCount(dir.filter(p => p.podcast).length);
await pick('tabs', 'All').click();
await expect(tiles).toHaveCount(dir.length); await expect(tiles).toHaveCount(dir.length);
// Add a feed opened over Directory fills its own list, not the pane behind it. // Add a feed opened over Directory fills its own list, not the pane behind it.

View File

@@ -195,9 +195,12 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
.childrow .art{width:32px;height:32px;font-size:12px} .childrow .art{width:32px;height:32px;font-size:12px}
.childrow .txt{flex:1;min-width:0} .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} .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 /* Directory's filters: .tabs for what a feed is, as the item filters are, and chips for what it
download bar and the now-playing EQ are; a .badge's fill already means unread in the sidebar. */ is about. A picked chip is underlined in --accent2, as the download bar and the now-playing EQ
.chips{display:flex;flex-wrap:wrap;gap:2px 6px;margin-top:4px} are; a .badge's fill already means unread in the sidebar. */
.dirbar{display:flex;flex-wrap:wrap;align-items:center;gap:8px 14px;margin-top:4px}
.dirbar .tabs{flex:none}
.chips{display:flex;flex-wrap:wrap;gap:2px 6px;flex:1 1 0;min-width:0}
.chips button{flex:none;padding:4px 6px;font-size:13px;color:var(--dim);white-space:nowrap;border-bottom:2px solid transparent} .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:hover{color:var(--fg)}
.chips button[aria-pressed="true"]{color:var(--fg);border-bottom-color:var(--accent2)} .chips button[aria-pressed="true"]{color:var(--fg);border-bottom-color:var(--accent2)}
@@ -530,8 +533,9 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
#pnow .txt small,#pright #vol{display:none} #pnow .txt small,#pright #vol{display:none}
.wrap.plain{padding:14px} .wrap.plain{padding:14px}
.card{padding:16px} .card{padding:16px}
/* The chips scroll sideways rather than wrap, so they never push the grid down. */ /* The chips get a line of their own and scroll sideways rather than wrap, so they never push
.chips{flex-wrap:nowrap;overflow-x:auto} the grid down. */
.chips{flex:1 1 100%;flex-wrap:nowrap;overflow-x:auto}
.tiles{grid-template-columns:repeat(auto-fill,minmax(104px,1fr));gap:14px 10px} .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. */ /* A log line has no room for four columns: keep time and message, wrap as prose. */
@@ -1696,34 +1700,39 @@ function listedFeed(p,cls){
return el; return el;
} }
// The chip Directory has picked. Kept out here because a finished scan redraws the pane, which // Directory's filters. Kept out here because a finished scan redraws the pane, which would
// would otherwise put it back to All. // otherwise clear them.
let dirPick='All'; let dirKind='All', dirCat=null;
/// Directory: every listed feed as its cover art, filtered in place by the chips above it. const KINDS={All:()=>true,Podcasts:p=>p.podcast,Blogs:p=>!p.podcast};
/// Podcasts and Blogs lead because almost no blog carries an iTunes category, and a rail of /// Directory: every listed feed as its cover art, under two filters that combine: what a feed is
/// categories alone left two thirds of the feeds under All. A podcast's categories open once /// (Podcasts, anything with audio or video, or Blogs, the rest) and what it is about (its iTunes
/// Podcasts is picked. /// category, as chips). Both filter in place, without asking the server again.
async function renderDirectory(url,box){ async function renderDirectory(url,box){
let rows=[]; let rows=[];
try{ rows=await api(url)||[]; }catch{} try{ rows=await api(url)||[]; }catch{}
const chips=$('#chips');
if(!rows.length){ box.innerHTML=NONE_LISTED; return 0; } 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 bar=$('#dirbar');
const pass={All:()=>true,Podcasts:p=>p.podcast,Blogs:p=>!p.podcast}; // Only a filter when the server has both kinds.
for(const c of cats) pass['c:'+c]=p=>p.podcast&&p.category===c; const both=rows.some(KINDS.Podcasts)&&rows.some(KINDS.Blogs);
const btn=(k,v,on)=>`<button type="button" data-${k}="${esc(v)}" class="${on?'on':''}" aria-pressed="${on}">${esc(v)}</button>`;
const draw=()=>{ const draw=()=>{
if(!pass[dirPick]) dirPick='All'; if(!both) dirKind='All';
const inPodcasts=dirPick==='Podcasts'||dirPick.startsWith('c:'); const ofKind=rows.filter(KINDS[dirKind]);
// No empty chips: a server without a blog gets no Blogs. // No empty chips: only the categories among the feeds the kind lets through.
const keys=['All','Podcasts','Blogs',...(inPodcasts?cats.map(c=>'c:'+c):[])].filter(k=>rows.some(pass[k])); const cats=[...new Set(ofKind.map(p=>p.category).filter(Boolean))].sort();
chips.innerHTML=keys.map(k=> if(!cats.includes(dirCat)) dirCat=null;
`<button type="button" data-k="${esc(k)}" aria-pressed="${k===dirPick}">${esc(k.replace(/^c:/,''))}</button>`).join(''); bar.innerHTML=
// The chips are redrawn too, so the keyboard goes back to the one just picked. (both?`<div class="tabs" role="group" aria-label="Kind">${Object.keys(KINDS).map(k=>btn('kind',k,k===dirKind)).join('')}</div>`:'')+
for(const b of $$('button',chips)) b.onclick=()=>{ (cats.length?`<div class="chips" role="group" aria-label="Category">${cats.map(c=>btn('cat',c,c===dirCat)).join('')}</div>`:'');
dirPick=b.dataset.k; draw(); $(`[data-k="${CSS.escape(dirPick)}"]`,chips)?.focus(); // A picked chip lifts on a second press. Everything is redrawn, so the keyboard goes back to
// the button just pressed.
for(const b of $$('button',bar)) b.onclick=()=>{
const k=b.dataset.kind!=null?'kind':'cat', v=b.dataset[k];
if(k==='kind') dirKind=v; else dirCat=dirCat===v?null:v;
draw(); $(`[data-${k}="${CSS.escape(v)}"]`,bar)?.focus();
}; };
box.innerHTML=''; box.innerHTML='';
for(const p of rows.filter(pass[dirPick])) box.appendChild(listedFeed(p,'tile')); for(const p of ofKind.filter(p=>!dirCat||p.category===dirCat)) box.appendChild(listedFeed(p,'tile'));
}; };
draw(); draw();
return rows.length; return rows.length;
@@ -1743,7 +1752,7 @@ async function renderListed(v){
<div class="meta"><h2>${v.title}</h2> <div class="meta"><h2>${v.title}</h2>
<div class="sub">${v.blurb} Everyone counts, you included. Private feeds are never listed.</div></div> <div class="sub">${v.blurb} Everyone counts, you included. Private feeds are never listed.</div></div>
</div> </div>
${grid?'<div class="chips" id="chips" role="group" aria-label="Show"></div>':''} ${grid?'<div class="dirbar" id="dirbar"></div>':''}
<div class="${grid?'tiles':'childlist'}" id="popular"><p class="hint">Loading…</p></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> ${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>`:''}`; <div class="childlist" id="listening"><p class="hint">Loading…</p></div>`:''}`;