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
- Directory has a row of chips above it: Podcasts, Blogs, and once Podcasts is picked, each
podcast's own iTunes category, the narrower one where a show gives two (Games, not Leisure).
Picking one filters the Directory in place.
- Directory can be filtered to Podcasts or Blogs, and by each show's own iTunes category as a row
of chips, the narrower one where a show gives two (Games, not Leisure). The two combine, and
both filter in place.
### 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
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:
Podcasts (anything with audio or video), Blogs (the rest), and under Podcasts, the category each
show gives itself in iTunes. Your own feeds are marked Subscribed.
included. Directory shows every one of them A to Z as a grid of cover art. Above it, a filter
picks Podcasts (anything with audio or video) or Blogs (the rest), and chips pick the category
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
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,

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.
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 expect(piper.locator('#count')).toContainText(`Directory: ${dir.length} feed`);
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).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 === 'picture-blog')).toMatchObject({ podcast: false });
await expect(chip('Technology')).toHaveCount(0);
await chip('Blogs').click();
await pick('tabs', 'Blogs').click();
await expect(tiles).toHaveCount(dir.filter(p => !p.podcast).length);
await expect(tiles.filter({ hasText: 'Test Show' })).toHaveCount(0);
await chip('Podcasts').click();
await chip('Technology').click();
await expect(chip('Technology')).toHaveAttribute('aria-pressed', 'true');
// No empty chips: no blog here names Technology, so Blogs does not offer it.
await expect(pick('chips', 'Technology')).toHaveCount(0);
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 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);
// 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 .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}
/* Directory's filters: .tabs for what a feed is, as the item filters are, and chips for what it
is about. A picked chip is underlined in --accent2, as the download bar and the now-playing EQ
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:hover{color:var(--fg)}
.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}
.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}
/* The chips get a line of their own and scroll sideways rather than wrap, so they never push
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}
/* 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;
}
// 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.
// Directory's filters. Kept out here because a finished scan redraws the pane, which would
// otherwise clear them.
let dirKind='All', dirCat=null;
const KINDS={All:()=>true,Podcasts:p=>p.podcast,Blogs:p=>!p.podcast};
/// Directory: every listed feed as its cover art, under two filters that combine: what a feed is
/// (Podcasts, anything with audio or video, or Blogs, the rest) and what it is about (its iTunes
/// category, as chips). Both filter in place, without asking the server again.
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 bar=$('#dirbar');
// Only a filter when the server has both kinds.
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=()=>{
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();
if(!both) dirKind='All';
const ofKind=rows.filter(KINDS[dirKind]);
// No empty chips: only the categories among the feeds the kind lets through.
const cats=[...new Set(ofKind.map(p=>p.category).filter(Boolean))].sort();
if(!cats.includes(dirCat)) dirCat=null;
bar.innerHTML=
(both?`<div class="tabs" role="group" aria-label="Kind">${Object.keys(KINDS).map(k=>btn('kind',k,k===dirKind)).join('')}</div>`:'')+
(cats.length?`<div class="chips" role="group" aria-label="Category">${cats.map(c=>btn('cat',c,c===dirCat)).join('')}</div>`:'');
// 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='';
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();
return rows.length;
@@ -1743,7 +1752,7 @@ async function renderListed(v){
<div class="meta"><h2>${v.title}</h2>
<div class="sub">${v.blurb} Everyone counts, you included. Private feeds are never listed.</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>
${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>`:''}`;