diff --git a/CHANGELOG.md b/CHANGELOG.md index ac04f43..cfb28db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/users.md b/docs/users.md index aea5363..a126b7a 100644 --- a/docs/users.md +++ b/docs/users.md @@ -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, diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index 5409c46..e49e007 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -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. diff --git a/web/index.html b/web/index.html index 44bb1de..4b7c05d 100644 --- a/web/index.html +++ b/web/index.html @@ -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)=>``; 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=> - ``).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?`
Loading…
Loading…