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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>`:''}`;
|
||||||
|
|||||||
Reference in New Issue
Block a user