From 954173cacfa7e182647153f58be7adac0513e873 Mon Sep 17 00:00:00 2001 From: rays Date: Tue, 15 Sep 2026 16:09:47 +0000 Subject: [PATCH] Directory: chips by kind and category over a grid of cover art Feeds take their channel's first 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 --- CHANGELOG.md | 12 +++++ docs/architecture.md | 2 +- docs/users.md | 4 +- src/db.rs | 61 +++++++++++++++++++-- src/feed.rs | 10 ++++ src/main.rs | 1 + src/web.rs | 16 +++++- tests/data/rss2.xml | 2 + tests/ui/app.spec.js | 31 ++++++++--- tests/ui/fixtures/show.xml | 1 + web/index.html | 105 ++++++++++++++++++++++++++++--------- 11 files changed, 207 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b50ce3..1c84ade 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,18 @@ The long form, with what was wrong before and how it was found, is in ## [Unreleased] +### Added + +- Directory has a row of chips above it: Podcasts, Blogs, and once Podcasts is picked, each + podcast's own iTunes category. Picking one filters the Directory in place. + +### Changed + +- Directory shows each feed as its cover art in a grid, title and subscriber count underneath, + instead of a list. Popular and the Add a feed dialog keep their rows. +- The first scan after upgrading fetches every feed in full once, on its usual schedule, so each + picks up its category without waiting for the publisher to change something. + ## [0.5.5] - 2026-09-14 ### Changed diff --git a/docs/architecture.md b/docs/architecture.md index cc1c184..0755401 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -119,7 +119,7 @@ else a `401`. | `POST /api/enclosures/{id}/download`, `DELETE /api/enclosures/{id}` | `?force=true` overrides the shared-file warning | | `POST /api/fetch` | | | `GET /api/opml`, `POST /api/opml` | export your subscriptions; subscribe to every feed in an OPML | -| `GET /api/popular`, `GET /api/directory`, `POST /api/popular/{id}` | the ten most subscribed feeds, and every listable feed A to Z, with an OPML's feeds in place of the OPML and everyone counted (id, title, art, count, whether it is yours; never a URL, never a private feed); subscribe by id | +| `GET /api/popular`, `GET /api/directory`, `POST /api/popular/{id}` | the ten most subscribed feeds, and every listable feed A to Z, with an OPML's feeds in place of the OPML and everyone counted (id, title, art, count, whether it is yours, the feed's iTunes category, whether it carries audio or video; never a URL, never a private feed); subscribe by id | | `GET /api/settings`, `PATCH /api/settings` | admin-only to write | | `GET /api/users`, `POST /api/users`, `PATCH /api/users/{id}`, `DELETE /api/users/{id}` | admin-only; the only admin cannot be demoted or removed | | `GET /api/events` | SSE, the same broadcast the socket carries | diff --git a/docs/users.md b/docs/users.md index 59f21d2..aea5363 100644 --- a/docs/users.md +++ b/docs/users.md @@ -72,7 +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 lists every one of them A to Z. Your own feeds are marked Subscribed. +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. 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/src/db.rs b/src/db.rs index abde372..b7974db 100644 --- a/src/db.rs +++ b/src/db.rs @@ -17,6 +17,8 @@ CREATE TABLE IF NOT EXISTS feeds ( url TEXT NOT NULL, title TEXT, image TEXT, + -- The channel's first , for the Directory. + category TEXT, etag TEXT, last_modified TEXT, last_checked INTEGER, @@ -170,6 +172,7 @@ fn migrate(conn: &Connection) -> Result<()> { ("users", "created", "INTEGER"), ("users", "last_login", "INTEGER"), ("feeds", "error_since", "INTEGER"), + ("feeds", "category", "TEXT"), ]; let retired: &[(&str, &str)] = &[ // Read state from before accounts, long since moved to entry_state. Two bugs came from @@ -192,6 +195,13 @@ fn migrate(conn: &Connection) -> Result<()> { if !has(table, column)? { tracing::info!(table, column, "adding column"); conn.execute_batch(&format!("ALTER TABLE {table} ADD COLUMN {column} {ty}"))?; + // A category is only read from a 200, and a feed with a validator mostly gets a + // 304, so most would never pick one up until the publisher changed something. + // Dropping the validators once makes each re-read on its normal schedule; leaving + // last_checked alone, unlike clear_validators, keeps them from all coming due at once. + if (*table, *column) == ("feeds", "category") { + conn.execute_batch("UPDATE feeds SET etag = NULL, last_modified = NULL")?; + } } } for (table, column) in retired { @@ -208,6 +218,7 @@ fn migrate(conn: &Connection) -> Result<()> { pub struct FeedSummary { pub title: Option, pub image: Option, + pub category: Option, /// Came from a subscribed OPML that no longer lists it, but it has downloads, so it /// was kept rather than removed. pub orphaned: bool, @@ -256,7 +267,8 @@ impl Db { let conn = self.conn.lock().unwrap(); let mut sum: FeedSummary = conn .query_row( - "SELECT title, image, last_checked, last_error, coalesce(orphaned, 0), error_since + "SELECT title, image, last_checked, last_error, coalesce(orphaned, 0), error_since, + category FROM feeds WHERE id = ?1", [feed_id], |r| { @@ -267,6 +279,7 @@ impl Db { last_error: r.get(3)?, orphaned: r.get::<_, i64>(4)? != 0, error_since: r.get(5)?, + category: r.get(6)?, ..Default::default() }) }, @@ -328,11 +341,14 @@ impl Db { last_modified: Option<&str>, ttl_mins: Option, image: Option<&str>, + category: Option<&str>, ) -> Result<()> { let conn = self.conn.lock().unwrap(); + // category is taken as it comes, unlike title and image: a show that leaves a category + // should leave the Directory's chip too. conn.execute( - "INSERT INTO feeds (id, url, title, etag, last_modified, last_checked, ttl_mins, last_error, image) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8) + "INSERT INTO feeds (id, url, title, etag, last_modified, last_checked, ttl_mins, last_error, image, category) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8, ?9) ON CONFLICT(id) DO UPDATE SET url = excluded.url, title = coalesce(excluded.title, feeds.title), @@ -341,9 +357,10 @@ impl Db { last_checked = excluded.last_checked, ttl_mins = excluded.ttl_mins, image = coalesce(excluded.image, feeds.image), + category = excluded.category, last_error = NULL, error_since = NULL", - rusqlite::params![feed_id, url, title, etag, last_modified, now(), ttl_mins.map(|t| t as i64), image], + rusqlite::params![feed_id, url, title, etag, last_modified, now(), ttl_mins.map(|t| t as i64), image, category], )?; Ok(()) } @@ -972,6 +989,17 @@ impl Db { Ok(out) } + /// Feeds with any audio or video enclosure: the Directory's Podcasts, with the rest Blogs. + /// Reaped files keep their rows, so a show whose files have all been purged still counts. + pub fn media_feeds(&self) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT DISTINCT feed_id FROM enclosures WHERE mime LIKE 'audio/%' OR mime LIKE 'video/%'", + )?; + let out = stmt.query_map([], |r| r.get(0))?.collect::>()?; + Ok(out) + } + /// Who else would miss this file: subscribers other than `user_id` who have starred /// the item or have not read it yet. Deleting is deleting their copy too. pub fn others_wanting(&self, enclosure_id: i64, user_id: i64) -> Result<(i64, i64)> { @@ -1441,6 +1469,31 @@ pub fn now() -> i64 { mod tests { use super::*; + #[test] + fn adding_category_drops_validators_once_and_keeps_the_schedule() { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch(SCHEMA).unwrap(); + // A database from before the column, holding a feed that would answer 304. + conn.execute_batch( + "ALTER TABLE feeds DROP COLUMN category; + INSERT INTO feeds (id, url, etag, last_modified, last_checked) VALUES ('f','u','e','lm',5);", + ) + .unwrap(); + let row = |conn: &Connection| -> (Option, Option, Option) { + conn.query_row("SELECT etag, last_modified, last_checked FROM feeds", [], |r| { + Ok((r.get(0)?, r.get(1)?, r.get(2)?)) + }) + .unwrap() + }; + migrate(&conn).unwrap(); + assert_eq!(row(&conn), (None, None, Some(5)), "re-read on its normal schedule"); + + // Only the once: every later open keeps the validators the next poll stored. + conn.execute_batch("UPDATE feeds SET etag = 'e2'").unwrap(); + migrate(&conn).unwrap(); + assert_eq!(row(&conn).0.as_deref(), Some("e2")); + } + #[test] fn every_sort_column_runs_and_orders_both_ways() { let db = Db::memory().unwrap(); diff --git a/src/feed.rs b/src/feed.rs index 22955dc..c5f7ab6 100644 --- a/src/feed.rs +++ b/src/feed.rs @@ -11,6 +11,8 @@ pub struct ParsedFeed { pub title: Option, pub ttl_mins: Option, pub image: Option, + /// The channel's first ``, for the Directory's chips. + pub category: Option, pub entries: Vec, } @@ -535,6 +537,12 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed { .and_then(|i| i.image()) .map(str::to_owned) .or_else(|| ch.image().map(|i| i.url().to_owned())), + // Only the iTunes one: Apple's list is fixed, while a plain is freeform and + // would fill the Directory with one-off tags. The top level only, for the same reason. + category: ch + .itunes_ext() + .and_then(|i| i.categories().first()) + .and_then(|c| non_empty(Some(c.text().trim()))), entries, } } @@ -591,6 +599,7 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed { title: non_empty(Some(feed.title().as_str())), ttl_mins: None, image: feed.logo().or_else(|| feed.icon()).map(str::to_owned), + category: None, entries, } } @@ -708,6 +717,7 @@ mod tests { assert_eq!(feed.title.as_deref(), Some("Test Cast")); assert_eq!(feed.ttl_mins, Some(45)); + assert_eq!(feed.category.as_deref(), Some("Technology"), "the first, top level only"); assert_eq!(feed.entries.len(), 3); let ep = &feed.entries[0]; diff --git a/src/main.rs b/src/main.rs index e8bb7ba..58274b5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1084,6 +1084,7 @@ async fn scan_one( last_modified.as_deref(), parsed.ttl_mins, parsed.image.as_deref(), + parsed.category.as_deref(), )?; let policy = policy_for(ctx, id, feed_cfg)?; diff --git a/src/web.rs b/src/web.rs index 10f3209..1a1ac09 100644 --- a/src/web.rs +++ b/src/web.rs @@ -627,6 +627,11 @@ struct PopularRow { subscribers: i64, /// Yours already. Everyone counts, you included, so your own feeds are listed too. subscribed: bool, + /// The feed's own iTunes category, if it names one; most blogs do not. + category: Option, + /// Any audio or video enclosure. Unlike category, every feed has an answer, so the + /// Directory's Podcasts and Blogs between them hold everything. + podcast: bool, } /// Every feed that may be listed, with everyone counted, you included, most subscribers @@ -638,6 +643,7 @@ fn popular(state: &WebState, user_id: i64) -> Result> { let mine: std::collections::HashSet = db.subscriptions_for(user_id)?.into_iter().map(|s| s.feed_id).collect(); let counts = db.subscriber_counts()?; + let media = db.media_feeds()?; let catalogue = crate::subscriptions(&state.ctx)?; let by_id: std::collections::HashMap<&str, &crate::config::Feed> = catalogue.iter().map(|s| (s.id.as_str(), &s.cfg)).collect(); @@ -657,7 +663,15 @@ fn popular(state: &WebState, user_id: i64) -> Result> { } let sum = db.feed_summary(&s.id)?; let subscribed = mine.contains(&s.id); - out.push(PopularRow { id: s.id.clone(), title: sum.title, image: sum.image, subscribers: n, subscribed }); + out.push(PopularRow { + id: s.id.clone(), + title: sum.title, + image: sum.image, + subscribers: n, + subscribed, + category: sum.category, + podcast: media.contains(&s.id), + }); } out.sort_by(|a, b| b.subscribers.cmp(&a.subscribers).then_with(|| sort_name(a).cmp(&sort_name(b)))); Ok(out) diff --git a/tests/data/rss2.xml b/tests/data/rss2.xml index 58a0af2..5990e9c 100644 --- a/tests/data/rss2.xml +++ b/tests/data/rss2.xml @@ -6,6 +6,8 @@ A synthetic feed used by the parser tests. 45 no + + Episode One diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index 01b0315..5409c46 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -695,25 +695,42 @@ test('Popular lists what everyone here reads, but never a private feed', async ( expect(top.every(t => ids.includes(t.id))).toBe(true); expect(ids).not.toContain('paid-show'); - // Subscribe from the directory this time; the popular list shares the same rows. + // 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}$`) }); await piper.locator('#feedlist .place', { hasText: 'Directory' }).click(); await expect(piper.locator('#count')).toContainText(`Directory: ${dir.length} feed`); - await expect(offered.filter({ hasText: 'Test Show' })).toBeVisible(); - await expect(offered.filter({ hasText: /Grouped Show|grouped-show/ })).toBeVisible(); - await expect(offered.filter({ hasText: /Test Subscriptions/ })).toHaveCount(0); - await expect(offered.filter({ hasText: /Paid Show|paid-show/ })).toHaveCount(0); + await expect(tiles.filter({ hasText: 'Test Show' })).toBeVisible(); + await expect(tiles.filter({ hasText: /Grouped Show|grouped-show/ })).toBeVisible(); + await expect(tiles.filter({ hasText: /Test Subscriptions/ })).toHaveCount(0); + 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. + 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 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'); + await expect(tiles).toHaveCount(dir.filter(p => p.podcast && p.category === 'Technology').length); + await chip('All').click(); + await expect(tiles).toHaveCount(dir.length); // Add a feed opened over Directory fills its own list, not the pane behind it. await piper.locator('#addFeed').click(); await expect(piper.locator('#modalCard .childrow', { hasText: 'Test Show' })).toBeVisible(); - await expect(offered).toHaveCount(dir.length); + await expect(tiles).toHaveCount(dir.length); await piper.locator('#modalCard button[title="Cancel"]').click(); const row = async () => (await (await piper.request.get('/api/popular')).json()).find(p => p.id === 'test-show'); const before = await row(); expect(before.subscribed).toBe(false); - await offered.filter({ hasText: 'Test Show' }).locator('button[title="Subscribe"]').click(); + await tiles.filter({ hasText: 'Test Show' }).locator('button[title="Subscribe"]').click(); await expect(piper.locator('#feedlist .feed', { hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 }); // Everyone counts, you included: it stays listed, marked as yours, with one more subscriber. diff --git a/tests/ui/fixtures/show.xml b/tests/ui/fixtures/show.xml index fc28031..d918c82 100644 --- a/tests/ui/fixtures/show.xml +++ b/tests/ui/fixtures/show.xml @@ -2,6 +2,7 @@ Test Showhttp://127.0.0.1:8792/A fixture feed. + First Episodeui-1 Mon, 01 Sep 2026 10:00:00 +0000 <p>Show notes for the first one.</p> diff --git a/web/index.html b/web/index.html index 84238cc..44bb1de 100644 --- a/web/index.html +++ b/web/index.html @@ -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='

Nothing yet. Feeds people here subscribe to show up here.

'; async function listFeeds(url,box){ let rows=[]; try{ rows=await api(url)||[]; }catch{} - box.innerHTML=rows.length?'':'

Nothing yet. Feeds people here subscribe to show up here.

'; - for(const p of rows){ - const el=document.createElement('div'); - el.className='childrow'; - el.innerHTML=artHTML(p.image,p.title||p.id)+ - `
${esc(p.title||p.id)}`+ - `${p.subscribers} subscriber${p.subscribers===1?'':'s'}
`+ - // Green, as a downloaded file is: it is already yours. Plus, beside it, is the way to get one. - (p.subscribed?`${ICON.subbed}` - :``); - // 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)+ + `
${esc(p.title||p.id)}`+ + `${p.subscribers} subscriber${p.subscribers===1?'':'s'}
`+ + // Green, as a downloaded file is: it is already yours. Plus, beside it, is the way to get one. + (p.subscribed?`${ICON.subbed}` + :``); + // 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=> + ``).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=`
${v.icon}

${v.title}

${v.blurb} Everyone counts, you included. Private feeds are never listed.
- + ${grid?'
':''} + ${listening?`
Currently Listening

Loading…

`:''}`; $('#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(); }