From 95acde30462b7894f64c873e20d271e673cb17ed Mon Sep 17 00:00:00 2001 From: rays Date: Mon, 5 Oct 2026 16:19:38 +0000 Subject: [PATCH] Lay the Directory out as Apple's is, with the most subscribed in it (#124, #126, #128) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Directory was one grid of every listed feed, 1,472 in production, under Podcasts and Blogs tabs, a wall of about 30 category buttons and a sort menu, with a picked category's subcategories appearing as a second row of buttons that looked like the first. It read as a list to scroll, and the two rows were hard to tell apart. It opens now on the ten most subscribed feeds, ranked, and the categories as tiles, each with three of its shows' covers fanned in its corner on the tint its initials would get, so the shows colour it and no theme's palette changes. A category has a page of its own: its most subscribed, its subcategories with how many each holds, and its grid. See all is every feed, as the grid was. Podcasts or Blogs holds across every page. Popular, the ten most subscribed, had a place of its own in the feed list; it is the Directory's first section instead, and its key, g p, is gone. /api/popular stays for scripts. The search box finds a feed in the Directory by name (#126). It said "Search items…" there and did nothing, since loadEntries returns early for a place that lists feeds. A feed you do not subscribe to opens a page of its own (#128): its cover, category (a link to that category's page), how many here subscribe, a subscribe button and its latest twenty items, each with its title, linking to the post where it has one, its first lines, date and length. Before, a click on it did nothing; only its + did. The items come from GET /api/directory/{id}, which answers only for a feed the Directory lists, so a guessed id reaches nothing private, and carries no file or its address. The feed's own description would belong at the top, but the database does not keep one. Every tile and chart row takes the keyboard, as the feed list's rows do. Feed text made plain keeps a space at a line break or a paragraph's end, which glued "2010)Recorded" together. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 +- README.md | 2 +- docs/architecture.md | 1 + docs/users.md | 14 ++- src/web.rs | 40 +++++++ tests/page-smoke.js | 1 - tests/ui/app.spec.js | 129 +++++++++++--------- web/app.css | 108 ++++++++++++++--- web/src/dialogs.ts | 279 +++++++++++++++++++++++++++++-------------- web/src/feeds.ts | 6 +- web/src/items.ts | 13 +- web/src/player.ts | 6 +- web/src/util.ts | 1 - 13 files changed, 426 insertions(+), 181 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35500ad..9d4c2ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- In the Directory, a feed you do not subscribe to opens a page of its own: its cover, its category, how many people here subscribe, and its latest episodes or posts, with a button to subscribe. Before, only its subscribe button did anything. - Feeds that don't set a category, as most blogs don't, now get one automatically, so they show up under a category in the Directory. iPX picks the closest of Apple's podcast categories from the feed's title and latest headlines. This happens once per feed: the first time a new feed is read, or for a feed you already had, the next time you force a refresh. It needs `TYPESAFE_KEY` set. An admin can change the category in the feed's settings. - Artwork is kept on disk once shown and loads from iPX, not from each publisher's server: fast after the first time, and still there when the publisher's server is not. The admin page sets how much is kept (500 MB by default). - `ipx add --list --category News ` puts a feed in the Directory for anyone to subscribe to, @@ -19,7 +20,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- The Directory browses as Apple's does: a row of categories, and once one is picked, a row of its subcategories (Tech News under News, Video Games under Leisure), instead of one row mixing both. +- The Directory is laid out as Apple's is. It opens on the ten most subscribed feeds, ranked, and the categories as tiles showing some of their shows' covers. A category opens a page of its own with its most subscribed feeds, its subcategories (Tech News under News, Video Games under Leisure) and its feeds; See all lists every one. This replaces the grid of every feed under rows of category buttons. +- The most subscribed feeds are the Directory's first section instead of a place of their own: Popular is gone from the feed list, and so is its key, g p. - A pinned item in a list wears the same disc as a pinned feed, in the theme's accent, instead of a plain solid pin. - The Directory can be sorted by name, A to Z or Z to A, or by most subscribers. It still opens A to Z. - API tokens: in Settings, make a named token that lets a script or an agent use iPX as you, sent as `Authorization: Bearer`, and revoke it there. Each shows when it was last used. @@ -27,13 +29,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A feed that has moved for good (a permanent redirect) is followed to its new address, which iPX then reads from, and says so in the log as `feed_moved`. A temporary redirect changes nothing. - The daemon sleeps until the next feed is due, at most ten minutes, instead of looking every minute; refreshing or adding a feed still wakes it at once. - A pinned feed's pin sits on the corner of its artwork, as a failing feed's mark does, instead of before its name. -- The Directory lists feeds nobody subscribes to yet; Popular still lists what people subscribe to. +- The Directory lists feeds nobody subscribes to yet; its most subscribed still lists what people subscribe to. A feed nobody subscribes to is checked once a day, and at once when someone subscribes. A listed feed downloads nothing until someone subscribes. - The Directory loads faster: it asked the database three questions per feed. ### Fixed +- The search box finds a feed in the Directory by name. It did nothing there. - Feeds that file themselves under Apple's Football or Soccer show under Sports, and those under the old Software How-To under Technology, instead of as categories of their own. - The download queue, in `ipx status`, `/api/status` and the dashboard, counts only what iPX will download on its own: older episodes beyond a show's limit, and files of feeds that do not download automatically, are listed but no longer counted as waiting. - Artwork published on http is stored on https when its host serves it there, so the page loads it directly; the rest still comes through iPX. diff --git a/README.md b/README.md index 473e4f6..a985fa3 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ behind iPodderX (2004-2008, Ray Slakinski & August Trometer). ## What it does -- **The web UI.** It has a toolbar, and a feed list that opens with Directory, Popular and All +- **The web UI.** It has a toolbar, and a feed list that opens with the Directory, Currently Listening and All Subscriptions. Items sit in a sortable table with a Files pane, and there is a player bar. It comes in Dark, Light and Classic themes, and works on a phone. - **Several people, one copy.** Each person has their own subscriptions and their own read, pinned diff --git a/docs/architecture.md b/docs/architecture.md index 674094f..d5e0783 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -126,6 +126,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/directory/{id}` | a listed feed's latest twenty items, for its page before you subscribe: title, link, date, length and text, never a file or its address, and only for a feed the directory lists | | `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 | diff --git a/docs/users.md b/docs/users.md index a151088..ee1a805 100644 --- a/docs/users.md +++ b/docs/users.md @@ -70,11 +70,15 @@ list with their own read state. Unsubscribing removes it from their list alone; subscriber leaves does the feed stop being scanned, and even then its files and history stay, so 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. 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. +**Directory** sits at the top of the feed list, above your own feeds. It opens on the ten feeds +with the most subscribers on this server, you included, ranked, then the categories, each a tile +showing a few of its shows' covers. A category has a page of its own: its most subscribed feeds, +then all of them as a grid of cover art, with its subcategories along the top (Tech News under +News, Video Games under Leisure) and a choice of order. See all lists every feed, and the search +box finds one by name. A feed you do not subscribe to opens a page of its own, with its latest +episodes or posts, so you can see what it is first. Every page can be narrowed to Podcasts (anything with audio or video) or +Blogs (the rest). A show's category is the one it gives itself in iTunes, or for one that names +none, one an admin or iPX picked for it. 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/web.rs b/src/web.rs index 851cb61..de613d4 100644 --- a/src/web.rs +++ b/src/web.rs @@ -58,6 +58,7 @@ pub fn router(state: WebState) -> Router { .route("/api/status", get(status)) .route("/api/popular", get(get_popular)) .route("/api/directory", get(get_directory)) + .route("/api/directory/{id}", get(get_listed)) .route("/api/popular/{id}", post(subscribe_popular)) .route("/api/users", get(list_users).post(add_user)) .route("/api/users/{id}", patch(patch_user).delete(remove_user)) @@ -1010,6 +1011,45 @@ async fn get_directory( Ok(Json(rows)) } +/// One of a listed feed's latest items, for its page in the Directory before anyone subscribes +/// (#128): what it is, never its files, whose addresses are where a key would be. +#[derive(Serialize)] +struct ListedItem { + guid: String, + title: Option, + link: Option, + published: Option, + duration: Option, + description: Option, +} + +/// A listed feed's latest twenty items. Only a feed the Directory lists, so a guessed id reaches +/// nothing private, as `subscribe_popular` checks. +async fn get_listed( + State(state): State, + user: crate::db::User, + Path(id): Path, +) -> Result>, ApiError> { + if !popular(&state, user.id, true).await?.iter().any(|p| p.id == id) { + return Err(ApiError::not_found(format!("{id:?} is not in the directory"))); + } + let order = crate::db::order_sql("published", "desc"); + let rows = state.ctx.db.entries_in(user.id, Some(&id), crate::db::Filter::parse("all"), None, 0, 20, &order).await?; + let mut sanitizer = feed_sanitizer(); + Ok(Json( + rows.into_iter() + .map(|e| ListedItem { + description: e.description.as_deref().map(|d| clean_description(&mut sanitizer, d, e.link.as_deref())), + guid: e.guid, + title: e.title, + link: e.link, + published: e.published, + duration: e.duration, + }) + .collect(), + )) +} + fn sort_name(p: &PopularRow) -> String { p.title.clone().unwrap_or_else(|| p.id.clone()).to_lowercase() } diff --git a/tests/page-smoke.js b/tests/page-smoke.js index 35e7c5b..ca1c716 100644 --- a/tests/page-smoke.js +++ b/tests/page-smoke.js @@ -49,7 +49,6 @@ const drive = PAGE === 'admin.html' ? [ ['prefsModal', () => ctx.prefsModal()], ['opmlModal', () => ctx.opmlModal()], ['selectFeed (directory)', () => ctx.selectFeed(':directory')], - ['selectFeed (popular)', () => ctx.selectFeed(':popular')], ['selectFeed (currently listening)', () => ctx.selectFeed(':listening')], ['selectFeed (all subscriptions)', () => ctx.selectFeed(':all')], ['keysModal', () => ctx.keysModal()], diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index 27bb63f..3ceca82 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -242,7 +242,7 @@ test('an item with several enclosures lists them all', async ({ page }) => { await expect(page.locator('#files .encbox').nth(1).locator('.kind[title^="image"]')).toBeVisible(); }); -test('Currently Listening, its own place below Popular, resumes an episode you started or forgets it', async ({ page }) => { +test('Currently Listening, its own place below the Directory, resumes an episode you started or forgets it', async ({ page }) => { // Second Episode (900s) is 42 seconds in and unfinished. An earlier test may have opened it, // and opening marks it read; it is listed all the same, because read is not finished. This // test used to set it unread first, which hid exactly the bug in issue #14. @@ -251,12 +251,12 @@ test('Currently Listening, its own place below Popular, resumes an episode you s await page.evaluate(() => api('/api/entries/test-show/ui-2/position', { method: 'POST', body: JSON.stringify({ secs: 42 }) })); - // Popular lists feeds and nothing else; the episodes have a place of their own under it. - await page.locator('#feedlist .place', { hasText: 'Popular' }).click(); - await expect(page.locator('#popular')).toBeVisible(); + // The Directory lists feeds and nothing else; the episodes have a place of their own under it. + await page.locator('#feedlist .place', { hasText: 'Directory' }).click(); + await expect(page.locator('#directory')).toBeVisible(); await expect(page.locator('#listening')).toHaveCount(0); const places = await page.locator('#feedlist .place b').allTextContents(); - expect(places.indexOf('Currently Listening')).toBe(places.indexOf('Popular') + 1); + expect(places.indexOf('Currently Listening')).toBe(places.indexOf('Directory') + 1); await page.locator('#feedlist .place', { hasText: 'Currently Listening' }).click(); const row = page.locator('#listening .childrow', { hasText: 'Second Episode' }); @@ -812,7 +812,7 @@ test('ipx import subscribes the admin, and ipx export writes the feeds out', asy expect(xml).toContain('http://127.0.0.1:8792/two.xml'); }); -test('Popular lists what everyone here reads, but never a private feed', async ({ browser }) => { +test('the Directory lists what everyone here reads, the most subscribed first, but never a private feed', async ({ browser }) => { const { execFileSync } = require('child_process'); const setup = require('./global-setup'); const env = { @@ -833,21 +833,23 @@ test('Popular lists what everyone here reads, but never a private feed', async ( await piper.locator('button[type=submit]').click(); await expect(piper.locator('#feedlist')).toContainText('No feeds.'); - await piper.locator('#feedlist .place', { hasText: 'Popular' }).click(); - const offered = piper.locator('#popular .childrow'); - await expect(offered.filter({ hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 }); + // The most subscribed are the Directory's first section; Popular, their own place, is gone (#124). + await expect(piper.locator('#feedlist .place', { hasText: 'Popular' })).toHaveCount(0); + await piper.locator('#feedlist .place', { hasText: 'Directory' }).click(); + const chart = piper.locator('#dchart .rank'); + await expect(chart.filter({ hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 }); // An OPML is listed as the feeds inside it, and a key in a URL marks someone's paid feed. - await expect(offered.filter({ hasText: /Test Subscriptions/ })).toHaveCount(0); - await expect(offered.filter({ hasText: /Paid Show|paid-show/ })).toHaveCount(0); + await expect(chart.filter({ hasText: /Test Subscriptions/ })).toHaveCount(0); + await expect(chart.filter({ hasText: /Paid Show|paid-show/ })).toHaveCount(0); // No URL reaches the page at all, so neither can a key, and the server holds the same line. - const listed = await (await piper.request.get('/api/popular')).text(); + const listed = await (await piper.request.get('/api/directory')).text(); expect(listed).not.toContain('secret123'); expect(listed).not.toContain('.xml'); expect((await piper.request.post('/api/popular/paid-show')).status()).toBe(400); - // Popular is the top ten of the directory, and the directory is every listed feed A to Z, - // with an OPML's feeds in place of the OPML in both. + // The chart is /api/popular's top ten, and the directory is every listed feed A to Z, with an + // OPML's feeds in place of the OPML in both. const dir = await (await piper.request.get('/api/directory')).json(); const top = await (await piper.request.get('/api/popular')).json(); const names = dir.map(p => (p.title || p.id).toLowerCase()); @@ -855,41 +857,40 @@ test('Popular lists what everyone here reads, but never a private feed', async ( const ids = dir.map(p => p.id); expect(ids).not.toContain('test-subscriptions'); expect(ids).toEqual(expect.arrayContaining(['grouped-show', 'aardvark-radio'])); - expect(top.length).toBe(Math.min(10, dir.length)); expect(top.every(t => ids.includes(t.id))).toBe(true); expect(ids).not.toContain('paid-show'); - - // Subscribe from the directory this time: the same feeds, as a grid of cover art. - const tiles = piper.locator('#popular .tile'); - const pick = (row, name) => piper.locator(`#dirbar .${row} button`, { hasText: new RegExp(`^${name}$`) }); - await piper.locator('#feedlist .place', { hasText: 'Directory' }).click(); + await expect(chart).toHaveCount(top.length); + await expect(chart.first().locator('.no')).toHaveText('1'); await expect(piper.locator('#count')).toContainText(`Directory: ${dir.length} feed`); + + // Then the categories, as tiles, each a page of its own. + const cat = name => piper.locator('#directory .cat', { hasText: new RegExp(`^${name}\\d`) }); + const tiles = piper.locator('#dtiles .tile'); + 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 cat('Technology').click(); + await expect(piper.locator('#directory h2')).toHaveText('Technology'); + await expect(tiles).toHaveCount(dir.filter(p => p.category === 'Technology').length); await expect(tiles.filter({ hasText: 'Test Show' })).toBeVisible(); + // Its way back lands on the tile it went in by. + await piper.locator('#directory .dback').click(); + await expect(cat('Technology')).toBeFocused(); + + // What a feed is holds on every page. No blog here names Technology, so Blogs has no such tile. + const kind = name => piper.locator('#directory .tabs button', { hasText: new RegExp(`^${name}$`) }); + await kind('Blogs').click(); + await expect(kind('Blogs')).toHaveAttribute('aria-pressed', 'true'); + await expect(cat('Technology')).toHaveCount(0); + await piper.locator('#directory .dall').click(); + await expect(tiles).toHaveCount(dir.filter(p => !p.podcast).length); + await expect(tiles.filter({ hasText: 'Test Show' })).toHaveCount(0); + await kind('All').click(); + await expect(tiles).toHaveCount(dir.length); 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); - - // 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 pick('tabs', 'Blogs').click(); - await expect(tiles).toHaveCount(dir.filter(p => !p.podcast).length); - await expect(tiles.filter({ hasText: 'Test Show' })).toHaveCount(0); - // 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); - // 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); // Sorted by name either way, A to Z as it opens, or by subscribers (#122). const byName = dir.map(p => p.title || p.id).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base', numeric: true })); - await piper.locator('#dirsort').selectOption('az'); await expect(tiles.first()).toContainText(byName[0]); await piper.locator('#dirsort').selectOption('za'); await expect(tiles.first()).toContainText(byName[byName.length - 1]); @@ -898,15 +899,35 @@ test('Popular lists what everyone here reads, but never a private feed', async ( await expect(tiles).toHaveCount(dir.length); await piper.locator('#dirsort').selectOption('az'); - // Add a feed is for an address; Popular and Directory are where you browse (issue #30). + // The search box looks through the Directory by name (#126), and clearing it goes back. + await piper.locator('#epSearch').fill('test show'); + await expect(piper.locator('#directory h2')).toHaveText('Results for “test show”'); + await expect(tiles).toHaveCount(dir.filter(p => (p.title || p.id).toLowerCase().includes('test show')).length); + await piper.locator('#epSearch').fill(''); + await expect(tiles).toHaveCount(dir.length); + + // A feed you do not subscribe to opens its page here: what it is and its latest items (#128). + await tiles.filter({ hasText: 'Test Show' }).locator('.txt').click(); + await expect(piper.locator('.show h2')).toHaveText('Test Show'); + await expect(piper.locator('#dshow .lep', { hasText: 'First Episode' })).toBeVisible(); + // Back, onto the tile it was opened from, the page under it as it was. + await piper.locator('#directory .dback').click(); + await expect(tiles.filter({ hasText: 'Test Show' })).toBeFocused(); + await expect(tiles).toHaveCount(dir.length); + // Only a listed feed has one, and it carries no file or its address. + const preview = await (await piper.request.get('/api/directory/test-show')).text(); + expect(preview).toContain('First Episode'); + expect(preview).not.toContain('.mp3'); + expect((await piper.request.get('/api/directory/paid-show')).status()).toBe(404); + + // Add a feed is for an address; the Directory is where you browse (issue #30). await piper.locator('#addFeed').click(); await expect(piper.locator('#nurl')).toBeVisible(); - await expect(piper.locator('#modalCard .childrow')).toHaveCount(0); - await expect(tiles).toHaveCount(dir.length); + await expect(piper.locator('#modalCard .tile, #modalCard .rank')).toHaveCount(0); 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'); + (await (await piper.request.get('/api/directory')).json()).find(p => p.id === 'test-show'); const before = await row(); expect(before.subscribed).toBe(false); await tiles.filter({ hasText: 'Test Show' }).locator('button[title="Subscribe"]').click(); @@ -914,9 +935,9 @@ test('Popular lists what everyone here reads, but never a private feed', async ( // Everyone counts, you included: it stays listed, marked as yours, with one more subscriber. expect(await row()).toMatchObject({ subscribed: true, subscribers: before.subscribers + 1 }); - await piper.locator('#feedlist .place', { hasText: 'Popular' }).click(); - await expect(offered.filter({ hasText: 'Test Show' }).locator('[title^="Subscribed"]')).toBeVisible(); - await expect(offered.filter({ hasText: 'Test Show' }).locator('button[title="Subscribe"]')).toHaveCount(0); + await piper.locator('#feedlist .place', { hasText: 'Directory' }).click(); + await expect(chart.filter({ hasText: 'Test Show' }).locator('[title^="Subscribed"]')).toBeVisible(); + await expect(chart.filter({ hasText: 'Test Show' }).locator('button[title="Subscribe"]')).toHaveCount(0); // All Subscriptions is every item from piper's feeds and only those: the admin's Picture // Blog is not among them. @@ -1216,14 +1237,14 @@ test('an admin can give a blog its Directory category', async ({ page }) => { await patch('picture-blog', 'Visual Arts'); // Apple's subcategory, under its category, as the Directory browses them (#118). expect(await listed('picture-blog')).toMatchObject({ podcast: false, category: 'Arts', subcategory: 'Visual Arts' }); - // A subcategory's chips show once its category is picked, as Apple's directory does. - const chip = (row, name) => page.locator(`#dirbar .${row} button`, { hasText: new RegExp(`^${name}$`) }); + // A category's subcategories are on its own page, as Apple's directory has them. + const pill = name => page.locator('#directory .pills button', { hasText: new RegExp(`^${name}\\d`) }); await page.locator('#feedlist .place', { hasText: 'Directory' }).click(); - await expect(chip('subs', 'Visual Arts')).toHaveCount(0); - await chip('chips', 'Arts').click(); - await chip('subs', 'Visual Arts').click(); - await expect(chip('subs', 'Visual Arts')).toHaveAttribute('aria-pressed', 'true'); - await expect(page.locator('#popular .tile')).toHaveCount(1); + await expect(page.locator('#directory .pills')).toHaveCount(0); + await page.locator('#directory .cat', { hasText: /^Arts\d/ }).click(); + await pill('Visual Arts').click(); + await expect(pill('Visual Arts')).toHaveAttribute('aria-pressed', 'true'); + await expect(page.locator('#dtiles .tile')).toHaveCount(1); // A feed's own iTunes category wins over one given here. await patch('test-show', 'Comedy'); expect((await listed('test-show')).category).toBe('Technology'); diff --git a/web/app.css b/web/app.css index f831e56..d78d6cf 100644 --- a/web/app.css +++ b/web/app.css @@ -637,27 +637,86 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i, /* Quiet buttons: a filled one on every row outshouted the row that is playing. */ #listening [data-a=play]{color:var(--accent)} #listening [data-a=remove]{color:var(--faint)} -/* 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} +/* The Directory, laid out as Apple's is: a large title, then sections. Every page carries what + kind of feed (.tabs, as the item filters are) in its title row. */ +.dhead{margin-bottom:6px} +.dback{display:inline-flex;align-items:center;gap:5px;margin:0 0 8px -5px;padding:3px 5px;border-radius:6px;font-size:13px;color:var(--accent)} +.dback .i{width:11px;height:11px} +.dback:hover{background:var(--raise)} +.dtitle{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px 18px} +.dtitle h2{margin:0;font-size:30px;line-height:1.15;font-weight:700;letter-spacing:-.02em;overflow-wrap:anywhere} +.dhead .sub{margin:5px 0 0;color:var(--dim);font-size:13px;font-variant-numeric:tabular-nums} +.dsec{margin-top:28px} +.dsech{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:10px} +.dsech h3{margin:0;font-size:19px;line-height:1.25;font-weight:650;letter-spacing:-.01em} +.dall{font-size:13px;color:var(--accent);flex:none} +.dall:hover{text-decoration:underline} +/* The most subscribed, numbered because they are ranked: down one column, then the next. */ +.chart{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-template-rows:repeat(var(--rows,5),auto);grid-auto-flow:column;column-gap:32px} +.rank{display:grid;grid-template-columns:22px 52px minmax(0,1fr) auto;align-items:center;gap:12px;padding:8px 0;border-bottom:1px solid var(--line)} +.rank .no{font-size:17px;font-weight:650;color:var(--faint);text-align:right;font-variant-numeric:tabular-nums} +.rank .art{width:52px;height:52px;font-size:16px} +.rank .txt b{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;overflow-wrap:anywhere;font-weight:500;font-size:14px;line-height:1.3} +.rank .txt small{display:block;margin-top:2px;color:var(--faint);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +/* A category: its name on the tint its initials would get, its three most subscribed shows' + covers fanned in the corner, so the shows are what colour it. The fan opens a little under the + pointer, the only thing on the page that moves. */ +.cats{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:12px} +.cat{ + position:relative;overflow:hidden;height:116px;padding:13px 15px;border-radius:12px;text-align:left; + display:flex;flex-direction:column;align-items:flex-start; + background:color-mix(in srgb,var(--tint) 15%,var(--panel));border:1px solid color-mix(in srgb,var(--tint) 28%,var(--line)); +} +.cat:hover{border-color:var(--accent)} +/* The whole width: the fan sits below two lines of name, and a narrower name broke Government + in two in the wider faces, Classic's and Glass's. */ +.cat b{font-size:16px;line-height:1.2;font-weight:650;color:var(--fg);overflow-wrap:break-word} +.cat small{margin-top:3px;font-size:12px;color:var(--dim);font-variant-numeric:tabular-nums} +.cat .fan{position:absolute;right:14px;bottom:-12px;width:112px;height:80px;pointer-events:none} +.cat .fan .art{position:absolute;bottom:0;width:62px;height:62px;font-size:16px;border-radius:8px;box-shadow:0 3px 10px rgba(0,0,0,.3);transition:transform .2s ease-out} +.cat .fan .art:nth-last-child(1){right:0;transform:rotate(8deg) translateY(-12px)} +.cat .fan .art:nth-last-child(2){right:25px;transform:rotate(-3deg) translateY(-4px)} +.cat .fan .art:nth-last-child(3){right:50px;transform:rotate(-13deg) translateY(4px)} +.cat:hover .fan .art:nth-last-child(1){transform:rotate(12deg) translate(4px,-16px)} +.cat:hover .fan .art:nth-last-child(3){transform:rotate(-18deg) translate(-6px,4px)} +/* A category's subcategories, each with how many it holds, then the order. A picked one is + drawn as a picked tab is. */ +.dtools{display:flex;align-items:center;gap:10px 14px;margin-bottom:14px} +.pills{display:flex;flex-wrap:wrap;gap:6px;flex:1 1 0;min-width:0} +.pills button{flex:none;padding:4px 11px;border:1px solid var(--line);border-radius:999px;font-size:13px;color:var(--dim);white-space:nowrap} +.pills button small{margin-left:6px;color:var(--faint);font-size:11.5px;font-variant-numeric:tabular-nums} +.pills button:hover{border-color:var(--accent);color:var(--fg)} +.pills button.on{background:var(--raise);border-color:var(--raise);color:var(--fg);font-weight:500} +.dtools .grow{flex:1} #dirsort{flex:none;width:auto;padding:4px 6px;font-size:13px} -.chips{display:flex;flex-wrap:wrap;gap:2px 6px;flex:1 1 0;min-width:0} -/* A category's subcategories go on a line of their own, under the category picked. */ -.chips.subs{flex-basis:100%} -.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{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:18px 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{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 2px;align-items:start} .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)} +/* Every one opens: yours its feed, the rest their page here. */ +.tile,.rank{cursor:pointer} +.tile:hover .txt b,.rank:hover .txt b{color:var(--accent)} +/* A feed's page before you subscribe, as Apple's show page: the cover large, what it is and who + here reads it beside it, then what it has published lately. */ +.show{display:flex;gap:22px;align-items:flex-end;margin-top:4px} +.show>.art{width:180px;height:180px;font-size:48px;border-radius:12px;box-shadow:var(--shadow)} +.show .meta{min-width:0;flex:1;display:flex;flex-direction:column;align-items:flex-start;gap:5px} +.show h2{margin:0 0 2px;font-size:30px;line-height:1.15;font-weight:700;letter-spacing:-.02em;overflow-wrap:anywhere} +.show .sub{margin:0;color:var(--dim);font-size:13px;font-variant-numeric:tabular-nums} +.show .acts{margin-top:8px} +.dlink{padding:0;font-size:13px;color:var(--accent);text-align:left} +.dlink:hover{text-decoration:underline} +.latest{max-width:760px} +.lep{padding:12px 0;border-bottom:1px solid var(--line)} +.lep b{display:block;font-size:15px;font-weight:600;line-height:1.35;overflow-wrap:anywhere} +.lep b a{color:var(--fg);text-decoration:none} +.lep b a:hover{color:var(--accent)} +.lep p{margin:4px 0 0;color:var(--dim);font-size:13px;line-height:1.5;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;overflow:hidden} +.lep small{display:flex;align-items:center;gap:7px;margin-top:6px;color:var(--faint);font-size:12px;font-variant-numeric:tabular-nums} /* Outlined, not filled: on --raise the warning and error colours fell short of AA in most light themes, where on the row's own ground they clear it. */ .tag{ @@ -686,7 +745,7 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i, /* A selected row is --raise too, and the zero count's pill vanished into it. */ .sel .badge.zero{background:var(--bg)} /* Counts, sizes and times that change in place should not jiggle the text around them. */ -.badge,.feed small,.childrow small,.tile small,.ep,.fhead .sub,.dmeta,#status,#seekrow{font-variant-numeric:tabular-nums} +.badge,.feed small,.childrow small,.tile small,.rank 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} @@ -1061,9 +1120,22 @@ body:has(#player.on) #status{padding-bottom:3px} #pnow .txt small,#pright #vol{display:none} .wrap.plain{padding:14px} .card{padding:16px} - /* 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} + /* The subcategories get a line of their own and scroll sideways rather than wrap, so they + never push the grid down. */ + .dtitle h2{font-size:25px} + .dsec{margin-top:22px} + .dsech h3{font-size:17px} + .chart{grid-template-columns:minmax(0,1fr);grid-template-rows:none;grid-auto-flow:row} + .cats{grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px} + .cat{height:100px;padding:11px 12px} + .cat b{font-size:15px} + .cat .fan{right:6px;bottom:-16px;transform:scale(.7);transform-origin:bottom right} + .pills{flex:1 1 100%;flex-wrap:nowrap;overflow-x:auto} + .dtools{flex-wrap:wrap} + .show{flex-direction:column;align-items:flex-start;gap:14px} + .show>.art{width:150px;height:150px;font-size:40px} + .show h2{font-size:24px} + #dirsort{margin-left: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. */ diff --git a/web/src/dialogs.ts b/web/src/dialogs.ts index dcbcb0a..d5a8fbc 100644 --- a/web/src/dialogs.ts +++ b/web/src/dialogs.ts @@ -34,114 +34,217 @@ $('#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 rows carry an id, never a URL, so a key in someone's feed address never reaches this page. 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?'':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){ +/// One listed feed: a tile in the Directory's grid or, given its place, a row in its chart. The +/// parts are the same either way; the class lays them out. Yours opens its feed; any other, its +/// page in the Directory, to see what it is before you take it (#128). +function listedFeed(p,cls,rank?: number){ 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'}
`+ + el.className=cls+(p.subscribed?' mine':''); + el.tabIndex=0; el.dataset.id=p.id; + // As the feed list's rows: Enter or Space opens it, and stops there, or Space would also + // play or pause. + el.onkeydown=ev=>{ + if((ev.key==='Enter'||ev.key===' ')&&ev.target===el){ ev.preventDefault(); ev.stopPropagation(); el.click(); } + }; + // A chart says what a show is about, as Apple's does; the grid says who here reads it, when + // anyone does. "0 subscribers" under most of the grid said nothing. + const meta=rank?(p.subcategory||p.category):p.subscribers?plural(p.subscribers,'subscriber'):''; + el.innerHTML=(rank?`${rank}`:'')+artHTML(p.image,NAME(p))+ + `
${esc(NAME(p))}${meta?`${esc(meta)}`:''}
`+ // 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); } - }; + if(p.subscribed){ el.onclick=()=>selectFeed(p.id); return el; } + el.onclick=()=>{ dirShow=p.id; $('#content').scrollTop=0; drawDirectory(undefined,'.dback'); }; + // Its own click, not the tile's as well. + $('[data-a="sub"]',el).onclick=ev=>{ ev.stopPropagation(); subscribeListed(p); }; return el; } -// Directory's filters. Kept out here because a finished scan redraws the pane, which would -// otherwise clear them. -let dirKind='All', dirCat=null, dirSub=null, dirSort='az'; -/// The Directory's orders. It opens A to Z, as the server sends it. -const NAME=p=>(p.title||p.id); -const DIR_SORTS={ - subscribers:['Most subscribers',(a,b)=>b.subscribers-a.subscribers||NAME(a).localeCompare(NAME(b),undefined,{sensitivity:'base',numeric:true})], - az:['Name, A to Z',(a,b)=>NAME(a).localeCompare(NAME(b),undefined,{sensitivity:'base',numeric:true})], - za:['Name, Z to A',(a,b)=>NAME(b).localeCompare(NAME(a),undefined,{sensitivity:'base',numeric:true})], -}; -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, then once one is picked its subcategories, as Apple's directory does). -/// They filter in place, without asking the server again. -async function renderDirectory(url,box){ - let rows=[]; - try{ rows=await api(url)||[]; }catch{} - if(!rows.length){ box.innerHTML=NONE_LISTED; return 0; } - 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(!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; - const ofCat=ofKind.filter(p=>!dirCat||p.category===dirCat); - const subs=dirCat?[...new Set(ofCat.map(p=>p.subcategory).filter(Boolean))].sort():[]; - if(!subs.includes(dirSub)) dirSub=null; - bar.innerHTML= - (both?`
${Object.keys(KINDS).map(k=>btn('kind',k,k===dirKind)).join('')}
`:'')+ - (cats.length?`
${cats.map(c=>btn('cat',c,c===dirCat)).join('')}
`:'')+ - ``+ - (subs.length?`
${subs.map(c=>btn('sub',c,c===dirSub)).join('')}
`:''); - // 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':b.dataset.cat!=null?'cat':'sub', v=b.dataset[k]; - if(k==='kind') dirKind=v; - else if(k==='cat'){ dirCat=dirCat===v?null:v; dirSub=null; } - else dirSub=dirSub===v?null:v; - draw(); $(`[data-${k}="${CSS.escape(v)}"]`,bar)?.focus(); - }; - $('#dirsort',bar).onchange=e=>{ dirSort=e.target.value; draw(); $('#dirsort',bar).focus(); }; - box.innerHTML=''; - for(const p of ofCat.filter(p=>!dirSub||p.subcategory===dirSub).sort(DIR_SORTS[dirSort][1])) box.appendChild(listedFeed(p,'tile')); - }; - draw(); - return rows.length; +async function subscribeListed(p){ + try{ + await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'}); + toast(`Subscribed to ${NAME(p)}`); + await loadFeeds(true); selectFeed(p.id); + }catch(e){ toast(e.message,true); } } -/// Directory and Popular open in the main pane, as the original's Directory did. +// Where you are in the Directory: what kind of feed, which category and subcategory, in what +// order. Out here because a search redraws the page, and the kind and order hold across visits. +let dirKind='All', dirCat=null, dirSub=null, dirSort='az', dirRows=[]; +// The feed whose page is open over all that, by id; going back leaves the rest as it was. +let dirShow=null; +// The two pages that are not a category: every feed, and the feeds that name none. A category is +// Apple's or one an admin typed, neither of which starts with a colon. +const EVERY=':every', OTHER=':other'; +const NAME=p=>(p.title||p.id); +const byName=(a,b)=>NAME(a).localeCompare(NAME(b),undefined,{sensitivity:'base',numeric:true}); +const bySubs=(a,b)=>b.subscribers-a.subscribers||byName(a,b); +/// The grid's orders. It opens A to Z, as the server sends it. +const DIR_SORTS={ + subscribers:['Most subscribers',bySubs], + az:['Name, A to Z',byName], + za:['Name, Z to A',(a,b)=>byName(b,a)], +}; +const KINDS={All:()=>true,Podcasts:p=>p.podcast,Blogs:p=>!p.podcast}; +const KIND_WORD={All:'feed',Podcasts:'podcast',Blogs:'blog'}; + +/// The Directory, as Apple's is laid out: it opens on the most subscribed feeds and the +/// categories, and a category has a page of its own, its subcategories along the top of its +/// grid. What a feed is (Podcasts, anything with audio or video, or Blogs, the rest) holds across +/// every page, and the search box looks through the lot. One fetch draws all of it. +async function renderDirectory(url,box){ + // Each visit starts at the front, as going back to a tab does. + dirCat=null; dirSub=null; dirShow=null; + try{ dirRows=await api(url)||[]; }catch{ dirRows=[]; } + drawDirectory(box); + return dirRows.length; +} + +/// `focus` is the control to put the keyboard back on, since the whole page is redrawn. +function drawDirectory(box=$('#directory'), focus?: string){ + if(!box) return; + if(!dirRows.length){ box.innerHTML=NONE_LISTED; return; } + // Only a choice when the server has both kinds. + const both=dirRows.some(KINDS.Podcasts)&&dirRows.some(KINDS.Blogs); + if(!both) dirKind='All'; + const rows=dirRows.filter(KINDS[dirKind]), word=KIND_WORD[dirKind]; + const count=n=>`${n.toLocaleString()} ${word}${n===1?'':'s'}`; + const of=c=>p=>c===EVERY||(c===OTHER?!p.category:p.category===c); + // A kind can leave a category with nothing in it; that goes back to the front. + if(dirCat&&!rows.some(of(dirCat))) dirCat=null; + const catName=c=>c===EVERY?`All ${word}s`:c===OTHER?'Other':c; + const top=list=>list.filter(p=>p.subscribers).sort(bySubs).slice(0,10); + const btn=(k,v,label,on)=>``; + // back: where its button goes, '' for the front; none on the front itself. + const head=(title,sub,back?)=>`
${back==null?'': + ``} +

${esc(title)}

${both?`
${ + Object.keys(KINDS).map(k=>btn('kind',k,k,k===dirKind)).join('')}
`:''}
+

${esc(sub)}

`; + const sec=(title,body,extra='')=>`
${title?`

${esc(title)}

${extra}
`:''}${body}
`; + const tools=(pills='')=>`
${pills||''}
`; + const q=S.q.trim(), grid='
', board='
'; + let html, chart=[], tiles=null; + const show=dirShow&&dirRows.find(p=>p.id===dirShow); + if(show){ + const n=show.subscribers; + html=`
+
${artHTML(show.image,NAME(show))}

${esc(NAME(show))}

${ + // Its category's page is one click away from here. + show.category?``:''} +

${n?`${n} ${n===1?'person here subscribes':'people here subscribe'}`:'Nobody here subscribes yet'}

+
` + +sec(show.podcast?'Latest episodes':'Latest posts','

Loading…

'); + }else if(q){ + // Names only: a category is a tap away, and "news" matching every News feed buried the + // one called that. + tiles=rows.filter(p=>NAME(p).toLocaleLowerCase().includes(q.toLocaleLowerCase())); + html=head(`Results for “${q}”`,tiles.length?count(tiles.length):`No ${word} here has that in its name.`,dirCat||'')+tools()+grid; + }else if(!dirCat){ + chart=top(rows); + const cats=new Map(); + for(const p of rows){ const c=p.category||OTHER; if(!cats.has(c)) cats.set(c,[]); cats.get(c).push(p); } + // Other, the feeds with no category, goes last. + const order=[...cats.keys()].sort((a,b)=>Number(a===OTHER)-Number(b===OTHER)||a.localeCompare(b)); + html=head('Directory',`${count(rows.length)} on this server. Private feeds are never listed.`) + +(chart.length?sec('Most subscribed',board):'') + +sec('Categories',`
${order.map(c=>catTile(c,catName(c),count(cats.get(c).length),cats.get(c))).join('')}
`, + ``); + }else{ + const inCat=rows.filter(of(dirCat)); + const subs=dirCat===EVERY?[]:[...new Set(inCat.map(p=>p.subcategory).filter(Boolean))].sort(); + if(!subs.includes(dirSub)) dirSub=null; + if(dirCat!==EVERY) chart=top(inCat); + tiles=inCat.filter(p=>!dirSub||p.subcategory===dirSub); + const pill=(v,label,n)=>btn('sub',v,`${esc(label)}${n}`,(dirSub||'')===v); + html=head(catName(dirCat),dirCat===OTHER?`${count(inCat.length)} that name no category`:count(inCat.length),'') + +(chart.length?sec('Most subscribed',board):'') + // Its own heading only when the chart is above it, to say where one list ends. + +sec(chart.length?`${word[0].toUpperCase()+word.slice(1)}s`:'',tools(subs.length?`
${ + pill('','All',inCat.length)+subs.map(s=>pill(s,s,inCat.filter(p=>p.subcategory===s).length)).join('')}
`:'')+grid); + } + box.innerHTML=html; + if(show){ $('.show [data-a="sub"]',box).onclick=()=>subscribeListed(show); fillShow($('#dshow',box),show); } + const c=$('#dchart',box); + if(c){ c.style.setProperty('--rows',String(Math.ceil(chart.length/2))); chart.forEach((p,i)=>c.appendChild(listedFeed(p,'rank',i+1))); } + const t=$('#dtiles',box); + if(t) for(const p of tiles.sort(DIR_SORTS[dirSort][1])) t.appendChild(listedFeed(p,'tile')); + box.onclick=ev=>{ + const b=(ev.target as Element).closest('[data-go],[data-kind],[data-sub],[data-back]'); + if(!b) return; + // Back from a feed's page, onto the tile or row it was opened from. + if(b.dataset.back!=null){ const id=dirShow; dirShow=null; return drawDirectory(box,`[data-id="${CSS.escape(id)}"]`); } + if(b.dataset.kind!=null){ dirKind=b.dataset.kind; return drawDirectory(box,`[data-kind="${CSS.escape(dirKind)}"]`); } + if(b.dataset.sub!=null){ dirSub=b.dataset.sub||null; return drawDirectory(box,`[data-sub="${CSS.escape(b.dataset.sub)}"]`); } + // Going anywhere ends a search. + if(S.q){ S.q=''; $('#epSearch').value=''; } + const from=dirCat; + dirCat=b.dataset.go||null; dirSub=null; dirShow=null; + $('#content').scrollTop=0; + // Into a page, onto its way back; back out, onto the tile you went in by. + drawDirectory(box,dirCat?'.dback':from?`[data-go="${CSS.escape(from)}"]:not(.dback)`:undefined); + }; + const s=$('#dirsort',box); + if(s) s.onchange=()=>{ dirSort=s.value; drawDirectory(box,'#dirsort'); }; + if(focus) $(focus,box)?.focus(); +} + +/// A listed feed's latest items, on its page: each one's title, opening its post on the +/// publisher's site where it has one, its first lines, and when and how long. +async function fillShow(list,p){ + let items; + try{ items=await api(`/api/directory/${encodeURIComponent(p.id)}`); } + catch(e){ list.innerHTML=`

${esc(e.message)}

`; return; } + // Gone back, or on to another, while it loaded. + if(dirShow!==p.id||!list.isConnected) return; + list.innerHTML=items.length?items.map(e=>{ + const name=entryName(e), text=name.derived?'':plainText(e.description).slice(0,500); + const when=[dateOf(e.published),e.duration&&clock(e.duration)].filter(Boolean); + return `
${e.link?`${esc(name.text)}`:esc(name.text)}` + +(text?`

${esc(text)}

`:'') + +(when.length?`${when.map(s=>`${s}`).join('')}`:'')+'
'; + }).join(''):`

Nothing read from it yet. A feed nobody here subscribes to is checked once a day.

`; +} + +/// A category as a tile: its name, how many feeds, and the covers of its three most subscribed +/// fanned in the corner, so each one is coloured by what is in it, as Apple's are. +function catTile(c,name,count,rows){ + // Covers before initials, which are all Other had; the likeliest first, and first is in front: + // the fan is drawn back to front. + const art=[...rows].sort((a,b)=>Number(!a.image)-Number(!b.image)||bySubs(a,b)).slice(0,3).reverse(); + return ``; +} + +/// The Directory and Currently Listening open in the main pane, as the original's Directory did. async function renderListed(v){ const box=$('#content'); box.classList.add('plain'); $('#tbRemove').disabled=true; syncTools(null); + if(v===VIEWS[':directory']){ + $('#epSearch').placeholder='Find a feed…'; + box.innerHTML='

Loading…

'; + $('#count').textContent=v.title; + const n=await renderDirectory(v.url,$('#directory')); + if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${plural(n,'feed')}`; + return; + } $('#epSearch').placeholder='Search items…'; - const listening=v===VIEWS[':listening'], grid=v===VIEWS[':directory']; box.innerHTML=`
${v.icon}
-

${v.title}

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

${v.title}

${v.blurb}
- ${grid?'
':''} -

Loading…

`; +

Loading…

`; $('#count').textContent=v.title; - const n=await (listening?renderListening:grid?renderDirectory:listFeeds)(v.url,$(listening?'#listening':'#popular',box)); - if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${plural(n,listening?'episode':'feed')}`; + const n=await renderListening(v.url,$('#listening',box)); + if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${plural(n,'episode')}`; } /// Currently Listening: episodes you started and have not finished, across every feed you @@ -210,7 +313,9 @@ $('#tbRead').onclick=()=>{ const e=cur(); if(e) epAction('read',e,null); }; $('#tbFlag').onclick=()=>{ const e=cur(); if(e) epAction('flag',e,null); }; let searchT; $('#epSearch').oninput=ev=>{ clearTimeout(searchT); - searchT=setTimeout(()=>{ S.q=ev.target.value; S.offset=0; loadEntries(); },250); }; + // The Directory looks through what it already has; the rest ask the server (#126). + searchT=setTimeout(()=>{ S.q=ev.target.value; S.offset=0; + if(S.feed===':directory'){ dirShow=null; drawDirectory(); } else loadEntries(); },250); }; // Crossing the phone breakpoint moves the files between their pane and the text. window.matchMedia?.('(max-width:820px)')?.addEventListener?.('change',()=>{ const e=cur(); if(e) showDetail(e); }); diff --git a/web/src/feeds.ts b/web/src/feeds.ts index 6d6f9f1..b2471a2 100644 --- a/web/src/feeds.ts +++ b/web/src/feeds.ts @@ -28,10 +28,8 @@ const unreadFirst=(a,b)=>Number(b.unread>0)-Number(a.unread>0); // The original's source list opened with these, above the feeds. They are places, not feeds: // an id starting with ':' can never be a feed's, since feed ids are slugs. const VIEWS={ - ':directory':{title:'Directory',icon:ICON.directory,url:'/api/directory', - blurb:'Every feed anyone on this server subscribes to. The feeds inside an OPML are listed one by one, not the OPML.'}, - ':popular':{title:'Popular',icon:ICON.popular,url:'/api/popular', - blurb:'The ten feeds with the most subscribers here. The feeds inside an OPML count one by one, not the OPML.'}, + // The Directory holds the most subscribed feeds too, which had a place of their own, Popular (#124). + ':directory':{title:'Directory',icon:ICON.directory,url:'/api/directory'}, ':listening':{title:'Currently Listening',icon:ICON.audio,url:'/api/entries?filter=in_progress&limit=50', blurb:'Episodes you started and have not finished, across every feed you subscribe to. Pick one up where you left off.'}, ':all':{title:'All Subscriptions',icon:ICON.all}, diff --git a/web/src/items.ts b/web/src/items.ts index 6bb6d75..3f5c37a 100644 --- a/web/src/items.ts +++ b/web/src/items.ts @@ -1,7 +1,7 @@ /* ---------------- items ---------------- */ let loadSeq=0; async function loadEntries(append?: boolean){ - // Directory and Popular list feeds, not items. + // The Directory lists feeds, and Currently Listening draws its own episodes. if(!S.feed || VIEWS[S.feed]?.url) return; const p=new URLSearchParams({offset:String(S.offset),limit:String(LIMIT),filter:S.filter,sort:S.sort.col,dir:S.sort.dir}); if(S.q) p.set('q',S.q); @@ -141,12 +141,15 @@ const feedName=id=>{ const f=S.feeds.find(x=>x.id===id); return f?(f.title||f.id /// and Scripting News titles almost none of its posts) the opening of its text, then its file's /// name, then its feed and date: "(untitled)" fifty times down a list said nothing about any of /// them. `derived` marks a name that is not a title, which the list sets as text, not heading. +/// Feed HTML as plain words. An inert document: nothing in it loads or runs, where a detached +/// element fetches its images. A line break or paragraph's end is a space, or "2010)
Recorded" +/// read "2010)Recorded". +const plainText=(html?: string)=>html + ? (new DOMParser().parseFromString(html.replace(/<(br|\/p|\/div|\/li|\/h\d)\b[^>]*>/gi,' $&'),'text/html').body.textContent||'') + .replace(/\s+/g,' ').trim() : ''; function entryName(e): {text: string, derived: boolean}{ if(e.title&&e.title.trim()) return {text:e.title, derived:false}; - // An inert document: nothing in it loads or runs, where a detached element fetches its images. - const words=e.description - ? (new DOMParser().parseFromString(e.description,'text/html').body.textContent||'').replace(/\s+/g,' ').trim() - : ''; + const words=plainText(e.description); if(words){ const cut=words.length>120 ? words.slice(0,120).replace(/\s+\S*$/,'')+'…' : words; return {text:cut, derived:true}; diff --git a/web/src/player.ts b/web/src/player.ts index da745e4..1a512da 100644 --- a/web/src/player.ts +++ b/web/src/player.ts @@ -135,7 +135,7 @@ document.addEventListener('keydown',ev=>{ // Feedly's keys, vim's j and k among them: a letter to move through items or feeds, g and a // letter to go somewhere, ? to list them. None fire with Ctrl, Alt or Cmd held, so the browser's // own shortcuts still work, or while a dialog is open. -const GO={a:':all',d:':directory',p:':popular',l:':listening'}; +const GO={a:':all',d:':directory',l:':listening'}; let gAt=0; function stepEntry(by){ if(VIEWS[S.feed]?.url||!S.entries.length) return; @@ -176,10 +176,10 @@ function keysModal(){ const k=s=>`${esc(s)}`, g=c=>k('g')+' '+k(c); const rows=[ ['Go to'], - [g('a'),'All Subscriptions'],[g('d'),'Directory'],[g('p'),'Popular'], + [g('a'),'All Subscriptions'],[g('d'),'Directory'], [g('l'),'Currently Listening'],[g('s'),'Settings'], [k('Shift')+' '+k('J'),'Next feed'],[k('Shift')+' '+k('K'),'Previous feed'], - [k('/'),'Search items'],[k('r'),'Refresh'],[k('['),'Show or hide the feed list'], + [k('/'),'Search items, or the Directory'],[k('r'),'Refresh'],[k('['),'Show or hide the feed list'], ['Items'], [k('j')+' or '+k('n'),'Next item'],[k('k')+' or '+k('p'),'Previous item'], [k('Shift')+' '+k('A'),'Mark all read'], diff --git a/web/src/util.ts b/web/src/util.ts index b281098..21bf3dc 100644 --- a/web/src/util.ts +++ b/web/src/util.ts @@ -30,7 +30,6 @@ const ICON={ close:fa('0 0 384 512',''), // solid/xmark menu:fa('0 0 448 512',''), // solid/bars directory:fa('0 0 448 512',''), // solid/table-list - popular:fa('0 0 576 512',''), // solid/star all:fa('0 0 512 512',''), // solid/layer-group unread:fa('0 0 512 512',''), // solid/envelope: a closed letter, not a record button audio:fa('0 0 448 512',''), // solid/headphones