Directory: chips by kind and category over a grid of cover art
Feeds take their channel's first <itunes:category> 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 <noreply@anthropic.com>
This commit is contained in:
12
CHANGELOG.md
12
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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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,
|
||||
|
||||
61
src/db.rs
61
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 <itunes:category>, 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<String>,
|
||||
pub image: Option<String>,
|
||||
pub category: Option<String>,
|
||||
/// 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<u64>,
|
||||
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<std::collections::HashSet<String>> {
|
||||
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::<rusqlite::Result<_>>()?;
|
||||
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<String>, Option<String>, Option<i64>) {
|
||||
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();
|
||||
|
||||
10
src/feed.rs
10
src/feed.rs
@@ -11,6 +11,8 @@ pub struct ParsedFeed {
|
||||
pub title: Option<String>,
|
||||
pub ttl_mins: Option<u64>,
|
||||
pub image: Option<String>,
|
||||
/// The channel's first `<itunes:category>`, for the Directory's chips.
|
||||
pub category: Option<String>,
|
||||
pub entries: Vec<Entry>,
|
||||
}
|
||||
|
||||
@@ -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 <category> 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];
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
16
src/web.rs
16
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<String>,
|
||||
/// 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<Vec<PopularRow>> {
|
||||
let mine: std::collections::HashSet<String> =
|
||||
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<Vec<PopularRow>> {
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
<description>A synthetic feed used by the parser tests.</description>
|
||||
<ttl>45</ttl>
|
||||
<itunes:explicit>no</itunes:explicit>
|
||||
<itunes:category text="Technology"><itunes:category text="Podcasting"/></itunes:category>
|
||||
<itunes:category text="News"/>
|
||||
|
||||
<item>
|
||||
<title>Episode One</title>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
|
||||
<channel><title>Test Show</title><link>http://127.0.0.1:8792/</link><description>A fixture feed.</description>
|
||||
<itunes:image href="http://127.0.0.1:8792/art.png"/>
|
||||
<itunes:category text="Technology"/>
|
||||
<item><title>First Episode</title><guid>ui-1</guid>
|
||||
<pubDate>Mon, 01 Sep 2026 10:00:00 +0000</pubDate>
|
||||
<description><p>Show notes for the first one.</p></description>
|
||||
|
||||
@@ -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,13 +1664,20 @@ $('#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='<p class="hint">Nothing yet. Feeds people here subscribe to show up here.</p>';
|
||||
async function listFeeds(url,box){
|
||||
let rows=[];
|
||||
try{ rows=await api(url)||[]; }catch{}
|
||||
box.innerHTML=rows.length?'':'<p class="hint">Nothing yet. Feeds people here subscribe to show up here.</p>';
|
||||
for(const p of rows){
|
||||
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='childrow';
|
||||
el.className=cls;
|
||||
el.innerHTML=artHTML(p.image,p.title||p.id)+
|
||||
`<div class="txt"><b>${esc(p.title||p.id)}</b>`+
|
||||
`<small class="meta">${p.subscribers} subscriber${p.subscribers===1?'':'s'}</small></div>`+
|
||||
@@ -1660,7 +1685,7 @@ async function listFeeds(url,box){
|
||||
(p.subscribed?`<span class="subbed" title="Subscribed: click to open it" aria-label="Subscribed">${ICON.subbed}</span>`
|
||||
:`<button class="btn ico" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.subbed}</button>`);
|
||||
// Yours already: the row opens it instead.
|
||||
if(p.subscribed){ el.onclick=()=>{ closeModal(); selectFeed(p.id); }; box.appendChild(el); continue; }
|
||||
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'});
|
||||
@@ -1668,8 +1693,39 @@ async function listFeeds(url,box){
|
||||
await loadFeeds(true); selectFeed(p.id);
|
||||
}catch(e){ toast(e.message,true); }
|
||||
};
|
||||
box.appendChild(el);
|
||||
}
|
||||
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=>
|
||||
`<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();
|
||||
};
|
||||
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=`
|
||||
<div class="fhead slim">
|
||||
<div class="art">${v.icon}</div>
|
||||
<div class="meta"><h2>${v.title}</h2>
|
||||
<div class="sub">${v.blurb} Everyone counts, you included. Private feeds are never listed.</div></div>
|
||||
</div>
|
||||
<div class="childlist" id="popular"><p class="hint">Loading…</p></div>
|
||||
${grid?'<div class="chips" id="chips" role="group" aria-label="Show"></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>`:''}`;
|
||||
$('#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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user