From df9b7645d682be738ddd55dfa1d062e2f7fe0e78 Mon Sep 17 00:00:00 2001 From: rays Date: Fri, 11 Sep 2026 14:39:05 +0000 Subject: [PATCH] The original iPodderX layout: toolbar, places, item table, Files pane - A toolbar across the window with the original's groups: add and unsubscribe, play, mark read and keep for the selected item, scan, a search box for what is showing, and Settings and Log (admins only). - Directory, Popular and All Subscriptions sit at the top of the feed list and open in the main pane; the Popular and Directory buttons and their dialogs are gone. - All Subscriptions lists every item from every feed you subscribe to: GET /api/entries, the per-feed query with its scope widened. The enclosure lookup after it matches files to rows by feed and guid, since a page can now span feeds. - Items are a table (unread, kept, item, feed, file, published) with a Files pane beside it, the text below, and a status bar with totals. On a phone the files follow the text and the table is title and date. - Tests: enclosures are checked in #files; the toolbar's read, keep and play act on the selected item; All Subscriptions holds only your feeds. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016HdTEWQNrzyFULijigkmMn --- CHANGELOG.md | 16 +- docs/architecture.md | 1 + docs/history.md | 26 +++ docs/users.md | 6 +- src/db.rs | 52 +++++- src/web.rs | 28 +++- tests/page-smoke.js | 9 +- tests/ui/app.spec.js | 70 ++++++-- web/index.html | 386 +++++++++++++++++++++++++++++-------------- 9 files changed, 429 insertions(+), 165 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2fd1be..a2967bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,16 +12,18 @@ The long form, with what was wrong before and how it was found, is in ### Added -- A Popular button at the top of the feed list opens the popular list without going through - Add feed. -- A Directory button lists every feed anyone on this server subscribes to, A to Z - (`GET /api/directory`), with the same rules as Popular. +- A toolbar across the top, after the original iPodderX: add and unsubscribe, play, mark read + and keep for the selected item, scan, a search box for what is showing, and Settings and Log. +- Directory, Popular and All Subscriptions at the top of the feed list, opening in the main pane. + Directory lists every feed anyone here subscribes to, A to Z (`GET /api/directory`). All + Subscriptions lists every item from every feed you subscribe to (`GET /api/entries`). +- Items show as a table (unread, kept, item, feed, file, published) with a Files pane beside it, + and a status bar with the totals. ### Changed -- Popular shows the top 10, not 20. -- The popular list counts everyone, you included. Your own feeds stay on it, marked Subscribed, - and clicking one opens it. +- Popular shows the top 10, not 20, and counts everyone, you included. Your own feeds stay on it, + marked Subscribed, and clicking one opens it. ## [0.3.0] - 2026-09-11 diff --git a/docs/architecture.md b/docs/architecture.md index deb3738..1818025 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -105,6 +105,7 @@ else a `401`. | `GET /api/feeds`, `POST /api/feeds` | your subscriptions; subscribe | | `PATCH /api/feeds/{id}`, `DELETE /api/feeds/{id}` | your settings or (admin) the feed's; unsubscribe | | `GET /api/feeds/{id}/entries` | paged, filtered, searchable | +| `GET /api/entries` | the same, across every feed you subscribe to (All Subscriptions) | | `POST /api/feeds/{id}/read-all`, `POST /api/feeds/{id}/download-latest` | | | `POST /api/entries/{feed}/{guid}/flags`, `…/position` | your read, starred, position | | `POST /api/enclosures/{id}/download`, `DELETE /api/enclosures/{id}` | `?force=true` overrides the shared-file warning | diff --git a/docs/history.md b/docs/history.md index 1b1232f..4843912 100644 --- a/docs/history.md +++ b/docs/history.md @@ -6,6 +6,32 @@ reasoning lives. New write-ups go at the top. See [README.md](../README.md) for what the thing is. +## 2026-09-11 — The original's layout + +Ray pointed at a screenshot of the Mac app (techpp.com, 2012) and asked for its panes, its +toolbar, and a Directory that lives in the feed list rather than behind a button. What it had, and +what ipx now does: + +- A toolbar across the window: subscribe and unsubscribe, play, flag, refresh, and a search box + scoped to the feed on show. ipx's has the same groups, acting on the selected feed and item, with + Settings and Log at the right end, for admins only. +- A source list opening with Directory, Playlist Builder and All Subscriptions above the feeds. + ipx has Directory, Popular and All Subscriptions there, opening in the main pane. Playlist + Builder is left out, since nothing here builds playlists. +- The entries as a table, with a Files pane beside it and the entry below. The columns are unread, + kept, the item, its feed, its file and when. Sorting by column is not done yet. +- A status bar with the totals for what is on show. + +All Subscriptions needed one new endpoint, `GET /api/entries`. It is the per-feed query with +`feed_id = ?` swapped for the person's subscriptions. The enclosure lookup that follows it used to +filter by feed as well; a page can now span feeds, so each file is matched to its row by feed and +guid instead. + +A phone has no room for a pane beside the table, so there the files follow the item's text in the +full-screen reader, and the table drops to title and date. + +--- + ## 2026-09-11 — Popular on this server The old iPodderX had a directory of podcasts and a top-feeds list. The open-sourced engine shows how diff --git a/docs/users.md b/docs/users.md index 5eaf452..048abd1 100644 --- a/docs/users.md +++ b/docs/users.md @@ -70,9 +70,9 @@ 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**, at the top of the feed list and in the Add feed dialog, lists the ten feeds with the -most subscribers on this server, you included. **Directory**, beside it, lists every one of them -A to Z. Your own feeds are marked Subscribed. +**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. It shows a title, artwork and a count, never a URL or who reads it. Feeds from an OPML subscription are left out, since they come with the OPML. So is anything that looks private: a login configured for the feed, credentials in its URL, or a key such as `auth=` or `token=` in the diff --git a/src/db.rs b/src/db.rs index 29ad0e4..27a220b 100644 --- a/src/db.rs +++ b/src/db.rs @@ -609,6 +609,17 @@ pub struct EntryRow { const SEARCH: &str = "(?2 = '' OR lower(coalesce(e.title, '')) LIKE ?2 OR lower(coalesce(e.description, '')) LIKE ?2)"; +/// Which feeds a query covers: one, or every feed the person subscribes to. Both forms +/// mention `?1`, since binding a parameter the statement does not use is an error. +fn scope_sql(feed_id: Option<&str>, user_param: u8) -> String { + match feed_id { + Some(_) => "e.feed_id = ?1".into(), + None => format!( + "?1 IS NULL AND e.feed_id IN (SELECT feed_id FROM subscriptions WHERE user_id = ?{user_param})" + ), + } +} + /// Which slice of a feed the UI is asking for. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Filter { @@ -667,6 +678,20 @@ impl Db { search: Option<&str>, offset: i64, limit: i64, + ) -> Result> { + self.entries_in(user_id, Some(feed_id), filter, search, offset, limit) + } + + /// `entries` for one feed, or across every feed the person subscribes to when `feed_id` + /// is None: the All Subscriptions view. + pub fn entries_in( + &self, + user_id: i64, + feed_id: Option<&str>, + filter: Filter, + search: Option<&str>, + offset: i64, + limit: i64, ) -> Result> { let conn = self.conn.lock().unwrap(); let like = search @@ -679,9 +704,10 @@ impl Db { FROM entries e LEFT JOIN entry_state s ON s.user_id = ?5 AND s.feed_id = e.feed_id AND s.guid = e.guid - WHERE e.feed_id = ?1 AND {} AND {SEARCH} + WHERE {} AND {} AND {SEARCH} ORDER BY coalesce(e.published, e.first_seen) DESC, e.rowid DESC LIMIT ?4 OFFSET ?3", + scope_sql(feed_id, 5), filter.sql() ); let mut stmt = conn.prepare(&sql)?; @@ -711,14 +737,14 @@ impl Db { return Ok(rows); } - // Only the guids on this page, so a feed with thousands of entries stays cheap. + // Only the guids on this page, so a feed with thousands of entries stays cheap. A page + // can span feeds, so each file is matched to its row by feed as well as guid, below. let placeholders = std::iter::repeat_n("?", rows.len()).collect::>().join(","); let sql = format!( "SELECT id, feed_id, guid, url, mime, length, path, state, last_error - FROM enclosures WHERE feed_id = ? AND guid IN ({placeholders}) ORDER BY id" + FROM enclosures WHERE guid IN ({placeholders}) ORDER BY id" ); - let mut params: Vec<&dyn rusqlite::ToSql> = Vec::with_capacity(rows.len() + 1); - params.push(&feed_id); + let mut params: Vec<&dyn rusqlite::ToSql> = Vec::with_capacity(rows.len()); for row in &rows { params.push(&row.guid); } @@ -740,7 +766,7 @@ impl Db { .collect::>>()?; for enc in encs { - if let Some(row) = rows.iter_mut().find(|r| r.guid == enc.guid) { + if let Some(row) = rows.iter_mut().find(|r| r.guid == enc.guid && r.feed_id == enc.feed_id) { row.enclosures.push(enc); } } @@ -754,6 +780,17 @@ impl Db { feed_id: &str, filter: Filter, search: Option<&str>, + ) -> Result { + self.count_in(user_id, Some(feed_id), filter, search) + } + + /// `count_entries` for one feed, or across every feed the person subscribes to. + pub fn count_in( + &self, + user_id: i64, + feed_id: Option<&str>, + filter: Filter, + search: Option<&str>, ) -> Result { let conn = self.conn.lock().unwrap(); let like = search @@ -763,7 +800,8 @@ impl Db { "SELECT count(*) FROM entries e LEFT JOIN entry_state s ON s.user_id = ?3 AND s.feed_id = e.feed_id AND s.guid = e.guid - WHERE e.feed_id = ?1 AND {} AND {SEARCH}", + WHERE {} AND {} AND {SEARCH}", + scope_sql(feed_id, 3), filter.sql() ); Ok(conn.query_row(&sql, rusqlite::params![feed_id, like, user_id], |r| r.get(0))?) diff --git a/src/web.rs b/src/web.rs index 0ac0471..73fa2f4 100644 --- a/src/web.rs +++ b/src/web.rs @@ -65,6 +65,7 @@ pub fn router(state: WebState) -> Router { .route("/api/feeds", get(feeds).post(add_feed)) .route("/api/feeds/{id}", patch(patch_feed).delete(remove_feed)) .route("/api/feeds/{id}/entries", get(entries)) + .route("/api/entries", get(all_entries)) .route("/api/entries/{feed_id}/{guid}/flags", post(set_flags)) .route("/api/entries/{feed_id}/{guid}/position", post(set_position)) .route("/api/feeds/{id}/read-all", post(read_all)) @@ -824,20 +825,37 @@ async fn entries( Path(id): Path, user: crate::db::User, Query(page): Query, +) -> Result, ApiError> { + entry_page(&state, user.id, Some(&id), &page) +} + +/// Every subscribed feed's items together, newest first: All Subscriptions. +async fn all_entries( + State(state): State, + user: crate::db::User, + Query(page): Query, +) -> Result, ApiError> { + entry_page(&state, user.id, None, &page) +} + +/// One feed's page of items, or every subscribed feed's when `feed` is None. +fn entry_page( + state: &WebState, + user_id: i64, + feed: Option<&str>, + page: &Page, ) -> Result, ApiError> { let filter = crate::db::Filter::parse(page.filter.as_deref().unwrap_or("all")); let search = page.q.as_deref().map(str::trim).filter(|q| !q.is_empty()); - let mut rows = state - .ctx - .db - .entries(user.id, &id, filter, search, page.offset, page.limit.clamp(1, 200))?; + let db = &state.ctx.db; + let mut rows = db.entries_in(user_id, feed, filter, search, page.offset, page.limit.clamp(1, 200))?; // Feed HTML is untrusted: it reaches the page only after ammonia has been through it. for row in &mut rows { if let Some(d) = &row.description { row.description = Some(ammonia::clean(d)); } } - let total = state.ctx.db.count_entries(user.id, &id, filter, search)?; + let total = db.count_in(user_id, feed, filter, search)?; Ok(Json(EntryPage { total, entries: rows })) } diff --git a/tests/page-smoke.js b/tests/page-smoke.js index f9b4695..2ed4fd4 100644 --- a/tests/page-smoke.js +++ b/tests/page-smoke.js @@ -15,7 +15,7 @@ const script = html.split('')[0]; const ids = new Set([...html.matchAll(/id="([^"]+)"/g)].map(m => m[1])); const missing = []; -const el = (name) => new Proxy({ style: {}, dataset: {}, classList: { add(){}, remove(){}, toggle(){}, contains(){ return false; } }, +const el = (name) => new Proxy({ style: { setProperty(){}, getPropertyValue(){ return ''; } }, dataset: {}, classList: { add(){}, remove(){}, toggle(){}, contains(){ return false; } }, value: '', textContent: '', innerHTML: '', hidden: false, children: [], firstElementChild: null, appendChild(){}, removeChild(){}, remove(){}, insertAdjacentHTML(){}, addEventListener(){}, setAttribute(){}, getAttribute(){ return null; }, select(){}, setSelectionRange(){}, focus(){}, @@ -49,7 +49,7 @@ const ctx = { : /\/api\/(popular|directory)/.test(String(url)) ? [{ id: 'f', title: 'A Feed', image: null, subscribers: 2, subscribed: true }, { id: 'g', title: null, image: null, subscribers: 1, subscribed: false }] - : []), + : /entries/.test(String(url)) ? { total: 0, entries: [] } : []), }), EventSource: function () { this.close = () => {}; }, MediaMetadata: function () {}, @@ -86,8 +86,9 @@ const drive = [ ['prefsModal', () => ctx.prefsModal()], ['usersModal', () => ctx.usersModal()], ['opmlModal', () => ctx.opmlModal()], - ['listedModal (popular)', () => ctx.listedModal('Popular', 'Top ten.', '/api/popular')], - ['listedModal (directory)', () => ctx.listedModal('Directory', 'A to Z.', '/api/directory')], + ['selectFeed (directory)', () => ctx.selectFeed(':directory')], + ['selectFeed (popular)', () => ctx.selectFeed(':popular')], + ['selectFeed (all subscriptions)', () => ctx.selectFeed(':all')], ['logsModal', () => ctx.logsModal()], // `const S` is not reachable from here: top-level const/let do not become properties // of a vm context the way var and function declarations do. diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index b9c2801..9f06acd 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -69,20 +69,21 @@ test('the three panes are there and the item text lands in the bottom one', asyn await page.locator('.ep', { hasText: 'First Episode' }).click(); await expect(page.locator('#detail .dt')).toHaveText('First Episode'); - // The enclosure travels with the item, into the same pane. - await expect(page.locator('#detail .encbox')).toHaveCount(1); + // The enclosure goes to the Files pane beside the list, as the original's did. + await expect(page.locator('#files')).toBeVisible(); + await expect(page.locator('#files .encbox')).toHaveCount(1); // Only the downloaded one gets a player, and max_new_per_check is 1, so find it by // its chip rather than assuming which episode the daemon happened to fetch. const downloaded = page.locator('.ep', { hasText: 'downloaded' }).first(); await downloaded.click(); - await expect(page.locator('#detail audio')).toBeVisible(); - await expect(page.locator('#detail .encbox .btn', { hasText: 'Save' })).toBeVisible(); + await expect(page.locator('#files audio')).toBeVisible(); + await expect(page.locator('#files .encbox .btn', { hasText: 'Save' })).toBeVisible(); // Selecting another item replaces the pane rather than stacking. await page.locator('.ep', { hasText: 'First Episode' }).click(); await expect(page.locator('#detail .dt')).toHaveText('First Episode'); - await expect(page.locator('#detail audio')).toHaveCount(0); + await expect(page.locator('#files audio')).toHaveCount(0); }); test('a downloaded file that is not audio gets no player', async ({ page }) => { @@ -96,12 +97,12 @@ test('a downloaded file that is not audio gets no player', async ({ page }) => { await row.click(); await expect(page.locator('#detail .dt')).toHaveText('An Article'); - await expect(page.locator('#detail audio')).toHaveCount(0); - await expect(page.locator('#detail .encbox')).toContainText('image'); - await expect(page.locator('#detail .encbox')).toContainText('downloaded'); + await expect(page.locator('#files audio')).toHaveCount(0); + await expect(page.locator('#files .encbox')).toContainText('image'); + await expect(page.locator('#files .encbox')).toContainText('downloaded'); // Still offered as a file, just not as an episode: viewable and keepable. - await expect(page.locator('#detail .btn', { hasText: 'Save' })).toBeVisible(); - const view = page.locator('#detail a', { hasText: 'View' }); + await expect(page.locator('#files .btn', { hasText: 'Save' })).toBeVisible(); + const view = page.locator('#files a', { hasText: 'View' }); await expect(view).toHaveAttribute('target', '_blank'); await expect(view).toHaveAttribute('rel', /noopener/); await expect(view).toHaveAttribute('href', /\/media\/\d+/); @@ -115,9 +116,9 @@ test('an item with several enclosures lists them all', async ({ page }) => { await expect(row).toContainText('+1 more file'); await row.click(); - // The pane below lists every one: the audio and the image. - await expect(page.locator('#detail .encbox')).toHaveCount(2); - await expect(page.locator('#detail .encbox').nth(1)).toContainText('image'); + // The Files pane lists every one: the audio and the image. + await expect(page.locator('#files .encbox')).toHaveCount(2); + await expect(page.locator('#files .encbox').nth(1)).toContainText('image'); }); test('the filter tabs change what is listed', async ({ page }) => { @@ -291,6 +292,30 @@ test('opening an item marks it read, and the toggle flips it back', async ({ pag expect(errors).toEqual([]); }); +test('the toolbar acts on the selected item', async ({ page }) => { + await page.getByText('Test Show').click(); + const row = () => page.locator('.ep', { hasText: 'Second Episode' }); + await expect(row()).toBeVisible({ timeout: 20_000 }); + // Nothing selected, nothing to act on. + await expect(page.locator('#tbRead')).toBeDisabled(); + + await row().click(); // opening it reads it + await expect(row()).toHaveClass(/read/); + await page.locator('#tbRead').click(); + await expect(row()).not.toHaveClass(/read/); + + await page.locator('#tbFlag').click(); + await expect(row().locator('.fl')).toHaveClass(/on/); + await page.locator('#tbFlag').click(); // and back, so later tests see it unkept + await expect(row().locator('.fl')).not.toHaveClass(/on/); + + // Second Episode is the one the daemon downloaded, so it plays from the toolbar. + await expect(page.locator('#tbPlay')).toBeEnabled(); + await page.locator('#tbPlay').click(); + await expect(page.locator('#player')).toBeVisible(); + await page.locator('#pclose').click(); +}); + test('a second person has their own feeds and their own read state', async ({ browser }) => { const { execFileSync } = require('child_process'); const setup = require('./global-setup'); @@ -350,7 +375,7 @@ test('deleting a shared file warns that it is everyone\'s copy', async ({ page } await expect(row).toBeVisible({ timeout: 20_000 }); await row.click(); - const del = page.locator('#detail button', { hasText: 'Delete' }); + const del = page.locator('#files button', { hasText: 'Delete' }); await expect(del).toHaveText('Delete for everyone'); // Two prompts: the page's own, then the server's, because someone else has not played @@ -566,7 +591,7 @@ 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('#popularFeeds').click(); + 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 }); // An OPML's own feeds ride on the OPML, and a key in a URL marks someone's paid feed. @@ -589,8 +614,8 @@ test('Popular lists what everyone here reads, but never a private feed', async ( expect(dir.map(p => p.id)).not.toContain('paid-show'); // Subscribe from the directory this time; the popular list shares the same rows. - await piper.keyboard.press('Escape'); - await piper.locator('#directoryFeeds').click(); + 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: /Paid Show|paid-show/ })).toHaveCount(0); @@ -603,7 +628,16 @@ 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('#popularFeeds').click(); + await piper.locator('#feedlist .place', { hasText: 'Popular' }).click(); await expect(offered.filter({ hasText: 'Test Show' })).toContainText('Subscribed'); + + // All Subscriptions is every item from piper's feeds and only those: the admin's Picture + // Blog is not among them. + await piper.locator('#feedlist .place', { hasText: 'All Subscriptions' }).click(); + const first = piper.locator('.ep', { hasText: 'First Episode' }); + await expect(first).toBeVisible({ timeout: 20_000 }); + await expect(first.locator('.fd')).toHaveText('Test Show'); + await expect(piper.locator('.ep', { hasText: 'An Article' })).toHaveCount(0); + await expect(piper.locator('#count')).toContainText('All Subscriptions:'); await ctx.close(); }); diff --git a/web/index.html b/web/index.html index 128db58..0e198fd 100644 --- a/web/index.html +++ b/web/index.html @@ -51,7 +51,7 @@ html,body{height:100%} body{ margin:0;background:var(--bg);color:var(--fg); font:14.5px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; - display:grid;grid-template-rows:1fr auto;height:100vh;overflow:hidden; + display:grid;grid-template-rows:auto 1fr auto auto;height:100vh;overflow:hidden; } button{font:inherit;color:inherit;background:none;border:0;cursor:pointer} a{color:var(--accent)} @@ -75,7 +75,18 @@ a{color:var(--accent)} color:var(--dim);flex:none; } .iconbtn:hover{background:var(--raise);color:var(--fg)} -.sidetools{display:flex;flex-wrap:wrap;gap:6px;padding:0 12px 10px} +/* One toolbar across the window, as the original had: grouped buttons, search on the right. */ +#topbar{ + display:flex;align-items:center;gap:10px;padding:7px 12px;min-width:0;overflow:hidden; + background:var(--panel);border-bottom:1px solid var(--line); +} +#topbar .grow{flex:1} +#topbar #epSearch{width:260px;flex:none} +.tgroup{display:flex;flex:none;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--panel2)} +.tgroup button{padding:5px 11px;min-width:34px;color:var(--dim);font-size:14px;line-height:1.3} +.tgroup button+button{border-left:1px solid var(--line)} +.tgroup button:hover:not(:disabled){background:var(--raise);color:var(--fg)} +.tgroup button:disabled{opacity:.35;cursor:default} .sidetools button,.sidefoot button{ flex:1;background:var(--panel2);border:1px solid var(--line);border-radius:8px; padding:6px 8px;font-size:12.5px;color:var(--dim); @@ -150,11 +161,22 @@ input:focus,select:focus{outline:0;border-color:var(--accent)} #content>.fhead{padding-top:16px} /* Three panes, as the original had: feeds beside, items above, the item below. */ -#split{display:grid;grid-template-rows:minmax(90px,var(--listh,40%)) 7px 1fr;flex:1;min-height:0} -#list{overflow-y:auto;padding:0 14px 10px} -#grab{cursor:row-resize;background:var(--line)} +#split{ + display:grid;flex:1;min-height:0; + grid-template-columns:minmax(0,1fr) 270px; + grid-template-rows:minmax(90px,var(--listh,40%)) 7px 1fr; + grid-template-areas:"list files" "grab grab" "detail detail"; +} +#list{grid-area:list;overflow:auto;padding:0 14px 10px} +/* The selected item's files, beside the list, as the original's Files pane was. */ +#files{grid-area:files;overflow-y:auto;padding:8px 12px;border-left:1px solid var(--line);background:var(--panel)} +#files .encbox{margin-top:8px} +#files .encbox audio{min-width:0;width:100%;flex-basis:100%} +#files .empty{padding:24px 0} +.fhd{font-size:10.5px;text-transform:uppercase;letter-spacing:.05em;color:var(--faint)} +#grab{grid-area:grab;cursor:row-resize;background:var(--line)} #grab:hover{background:var(--accent)} -#detail{overflow-y:auto;padding:16px 20px 28px;background:var(--panel);border-top:1px solid var(--line)} +#detail{grid-area:detail;overflow-y:auto;padding:16px 20px 28px;background:var(--panel);border-top:1px solid var(--line)} .dt{font-size:18px;font-weight:650;margin:0 0 5px;line-height:1.3;overflow-wrap:anywhere} .dmeta{color:var(--faint);font-size:12.5px;display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:14px} .dmeta .btn{padding:3px 9px;font-size:12px} @@ -202,24 +224,33 @@ a.btn{text-decoration:none;color:inherit} .toolbar .grow{flex:1;min-width:150px;max-width:320px} /* ---------- items ---------- */ -.ep{ - display:flex;gap:11px;padding:8px 10px;border-radius:var(--r);align-items:center; - border:1px solid transparent;margin-bottom:2px;position:relative;cursor:pointer; +/* A table, as the original's was: unread, kept, the item, its feed, its file, when. */ +.ephead,.ep{ + display:grid;gap:8px;align-items:center; + grid-template-columns:22px 22px minmax(120px,1fr) minmax(0,160px) minmax(0,112px) minmax(0,92px) 64px; } +.ephead{ + position:sticky;top:0;z-index:2;background:var(--bg);padding:7px 10px 5px; + border-bottom:1px solid var(--line);margin-bottom:3px; + font-size:10.5px;text-transform:uppercase;letter-spacing:.05em;color:var(--faint); +} +.ep{padding:4px 10px;border-radius:7px;border:1px solid transparent;cursor:pointer;margin-bottom:1px} +.ep>*{min-width:0} .ep.sel{background:var(--raise);border-color:var(--line)} -.ep .art{width:34px;height:34px;font-size:12px} .ep:hover{background:var(--panel)} -.ep.playing{background:var(--panel);border-color:var(--accent)} -.ep .art{width:52px;height:52px;font-size:16px;cursor:pointer;position:relative} -.ep .art .ovl{ - position:absolute;inset:0;display:grid;place-items:center; - background:rgba(0,0,0,.5);opacity:0;transition:opacity .12s;color:#fff; -} -.ep .art:hover .ovl,.ep.playing .art .ovl{opacity:1} -.ep .body{flex:1;min-width:0} -.ep .t{font-weight:600;font-size:14.5px;cursor:pointer;display:block} +.ep.playing{border-color:var(--accent)} +.ep .st,.ep .fl{width:22px;height:22px;border-radius:5px;display:grid;place-items:center;font-size:11px;color:var(--accent)} +.ep .fl{color:var(--faint);font-size:13px} +.ep .fl.on{color:var(--accent2)} +.ep .st:hover,.ep .fl:hover{background:var(--raise)} +.ep .t{font-weight:600;font-size:13.5px;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .ep.read .t{color:var(--dim);font-weight:500} -.ep .line{display:flex;gap:9px;align-items:center;flex-wrap:wrap;color:var(--faint);font-size:12px;margin-top:2px} +.ep .line{display:flex;gap:9px;align-items:center;flex-wrap:wrap;color:var(--faint);font-size:11.5px} +.ep .line:empty{display:none} +.ep .fd,.ep .date{color:var(--dim);font-size:12.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.ep .file{display:flex;gap:6px;align-items:center;flex-wrap:wrap;color:var(--faint);font-size:11.5px} +.ep .file .dlbar{flex-basis:100%;margin-top:2px} +.ep .rowacts{justify-content:flex-end} .dot{width:3px;height:3px;border-radius:50%;background:var(--faint);flex:none} .chip{ font-size:10.5px;text-transform:uppercase;letter-spacing:.05em;font-weight:700; @@ -312,7 +343,27 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu } .toast.bad{border-color:var(--bad);color:var(--bad)} @keyframes in{from{opacity:0;transform:translateY(6px)}} -#mbar,#dback{display:none} +#burger,#dback{display:none} +/* Totals for what is showing, along the bottom, as the original's status bar. */ +#status{ + padding:3px 14px;min-height:22px;font-size:12px;color:var(--faint);background:var(--panel); + border-top:1px solid var(--line);white-space:nowrap;overflow:hidden;text-overflow:ellipsis; +} +/* The feed's own header, kept to one line so the table starts high, as it did. */ +.fhead.slim{align-items:center;gap:12px;margin-bottom:10px} +.fhead.slim .art{width:44px;height:44px;font-size:15px;box-shadow:none} +.fhead.slim h2{font-size:17px} +.fhead.slim .sub{margin-bottom:0;font-size:12.5px} +.fhead.slim .acts{margin-top:0;flex:none} +.fhead.slim .acts .btn{padding:5px 11px;font-size:12.5px} +.fhead.slim .acts .btn.last{margin-left:0} +/* Places above the feeds: not subscriptions, but where to look. */ +.places{border-bottom:1px solid var(--line);margin:0 0 6px;padding-bottom:6px} +.place{display:flex;gap:10px;align-items:center;padding:6px 8px;border-radius:9px;cursor:pointer} +.place:hover{background:var(--panel2)} +.place.sel{background:var(--raise)} +.place .ico{width:18px;text-align:center;color:var(--accent);flex:none} +.place b{flex:1;font-weight:600;font-size:13.5px} @media (max-width:820px){ #shell{grid-template-columns:1fr} @@ -323,14 +374,20 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu #player{position:relative;z-index:39;padding:7px 10px;gap:8px} /* The feed list is reachable whether or not anything is playing. */ - #mbar{ - display:flex;align-items:center;gap:8px;flex:none;padding:7px 10px; - border-bottom:1px solid var(--line);background:var(--panel);font-weight:650; - } + #burger{display:grid} + #topbar{gap:6px;padding:6px 8px} + #topbar .grow,.tgroup.item{display:none} + #topbar #epSearch{width:auto;flex:1;min-width:0} + .tgroup button{padding:4px 8px;min-width:32px} + .fhead.slim{flex-wrap:wrap} + .fhead.slim .acts{flex:1 1 100%} .iconbtn{width:38px;height:38px} /* One pane at a time: the list, then the item over it. */ - #split{grid-template-rows:1fr} + #split{grid-template-columns:1fr;grid-template-rows:1fr;grid-template-areas:"list"} + /* Title and date only; the files follow the item's text in the reader instead. */ + #files,.ephead,.ep .fd,.ep .file,.ep .rowacts{display:none} + .ep{grid-template-columns:20px 20px minmax(0,1fr) auto} #grab{display:none} #detail{position:fixed;inset:0;z-index:38;display:none;border-top:0;padding:12px 16px 90px} body.reading #detail{display:block} @@ -365,37 +422,47 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu +
+ +
+ + +
+
+ +
+
+ + +
+
+ +
+ + +
+ + +
+
-
- - iPodderX -
+
@@ -518,11 +585,32 @@ async function loadFeeds(keepSel){ // An OPML can hold dozens of feeds; the ones with something new go first. sort is stable, so the // server's alphabetical order still holds within each half. const unreadFirst=(a,b)=>(b.unread>0)-(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:'☷',url:'/api/directory', + blurb:'Every feed anyone on this server subscribes to, A to Z.'}, + ':popular':{title:'Popular',icon:'★',url:'/api/popular', + blurb:'The ten feeds with the most subscribers here.'}, + ':all':{title:'All Subscriptions',icon:'≡'}, +}; function renderFeeds(){ const q=$('#feedFilter').value.trim().toLowerCase(); const list=$('#feedlist'); const top=list.scrollTop; list.innerHTML=''; + const unreadAll=S.feeds.reduce((n,f)=>n+(f.unread||0),0); + const places=document.createElement('div'); + places.className='places'; + for(const [id,v] of Object.entries(VIEWS)){ + const el=document.createElement('div'); + el.className='place'+(S.feed===id?' sel':''); + el.innerHTML=`${v.icon}${v.title}`+(id===':all' + ?`${unreadAll>999?'999+':unreadAll}`:''); + el.onclick=()=>{ selectFeed(id); nav(false); }; + places.appendChild(el); + } + list.appendChild(places); const shown=S.feeds.filter(f=>!q||(f.title||f.id).toLowerCase().includes(q)); - if(!shown.length){ list.innerHTML='

No feeds.

'; return; } + if(!shown.length){ list.insertAdjacentHTML('beforeend','

No feeds.

'); return; } // Feeds from a subscribed OPML sit under it, so the group reads as one thing. const byId=Object.fromEntries(shown.map(f=>[f.id,f])); @@ -563,22 +651,30 @@ function renderFeeds(){ list.scrollTop=top; } function selectFeed(id){ - S.feed=id; S.offset=0; S.sel=null; S.q=''; + S.feed=id; S.offset=0; S.sel=null; S.q=''; $('#epSearch').value=''; renderFeeds(); renderFeed(); loadEntries(); } /* ---------------- feed page ---------------- */ function renderFeed(){ - const f=S.feeds.find(x=>x.id===S.feed); - if(!f){ $('#content').innerHTML='

Add a feed to get started.

'; return; } - $('#main .wrap').classList.remove('plain'); - const kids=S.feeds.filter(c=>c.group===f.id); - if(kids.length){ $('#main .wrap').classList.add('plain'); renderGroup(f,kids); return; } - $('#content').innerHTML = ` -
- ${artHTML(f.image,f.title||f.id)} + const box=$('#content'); + box.classList.remove('plain'); + const v=VIEWS[S.feed]; + if(v&&v.url) return renderListed(v); + const f=v?null:S.feeds.find(x=>x.id===S.feed); + $('#tbRemove').disabled=!f; + syncTools(null); + if(!v&&!f){ box.innerHTML='

Add a feed to get started.

'; $('#count').textContent=''; return; } + const name=f?(f.title||f.id):v.title; + $('#epSearch').placeholder=`Search ${name}…`; + const kids=f?S.feeds.filter(c=>c.group===f.id):[]; + if(kids.length){ box.classList.add('plain'); renderGroup(f,kids); return; } + const unreadAll=S.feeds.reduce((n,x)=>n+(x.unread||0),0); + box.innerHTML = (f ? ` +
+ ${artHTML(f.image,name)}
-

${esc(f.title||f.id)}

+

${esc(name)}

${f.entries} items · ${f.downloaded} downloaded · checked ${ago(f.last_checked)} · every ${everyText(f.every_mins)}${f.next_check?` · next ${due(f.next_check)}`:''}${ f.subscribers>1?` · shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'}`:''}
@@ -586,36 +682,42 @@ function renderFeed(){ ${f.orphaned?`
This feed is no longer listed in its OPML subscription. It was kept rather than removed because it has downloaded items.
`:''} ${f.group?`
From the OPML subscription ${esc(f.group)}
`:''} -
- - - - - -
-
+
+ + + + + +
+
` : ` +
+
${v.icon}
+
+

${v.title}

+
Every item from the ${S.feeds.length} feed${S.feeds.length===1?'':'s'} you + subscribe to, newest first · ${unreadAll} unread
+
+
`) + `
${['all','unread','downloaded','flagged'].map(t=> ``).join('')}
- - -
- `; +
`; const pane=document.createElement('div'); pane.id='split'; - pane.innerHTML='
'; - $('#content').appendChild(pane); + pane.innerHTML='
'+ + 'ItemFeedFilePublished
'+ + '
'; + box.appendChild(pane); pane.style.setProperty('--listh', localStorage.getItem('ipx.listh') || '40%'); - dragSplit(); + dragSplit(pane); showDetail(null); - $$('#content .acts .btn').forEach(b=>b.onclick=()=>feedAction(b.dataset.a,f)); + if(f) $$('#content .acts .btn').forEach(b=>b.onclick=()=>feedAction(b.dataset.a,f)); $$('#content .tabs button').forEach(b=>b.onclick=()=>{S.filter=b.dataset.f;S.offset=0;renderFeed();loadEntries()}); - let t; $('#epSearch').oninput=e=>{clearTimeout(t);t=setTimeout(()=>{S.q=e.target.value;S.offset=0;loadEntries()},250)}; } /// An OPML subscription's page lists the feeds inside it rather than items, but keeps @@ -624,6 +726,7 @@ function renderGroup(f,kids){ const unread=kids.reduce((n,c)=>n+c.unread,0); const saved=kids.reduce((n,c)=>n+c.downloaded,0); const gone=kids.filter(c=>c.orphaned).length; + $('#count').textContent=`${f.title||f.id}: ${kids.length} feed${kids.length===1?'':'s'}, ${unread} unread`; $('#content').innerHTML = `
${artHTML(f.image,f.title||f.id)} @@ -681,17 +784,22 @@ async function feedAction(a,f){ /* ---------------- items ---------------- */ async function loadEntries(append){ - if(!S.feed) return; + // Directory and Popular list feeds, not items. + if(!S.feed || VIEWS[S.feed]?.url) return; const p=new URLSearchParams({offset:S.offset,limit:LIMIT,filter:S.filter}); if(S.q) p.set('q',S.q); - const r=await api(`/api/feeds/${encodeURIComponent(S.feed)}/entries?${p}`); + const r=await api(S.feed===':all' ? `/api/entries?${p}` + : `/api/feeds/${encodeURIComponent(S.feed)}/entries?${p}`); S.total=r.total; S.entries = append ? S.entries.concat(r.entries) : r.entries; renderEntries(); } function renderEntries(){ const box=$('#eps'); if(!box) return; - const c=$('#count'); if(c) c.textContent=`${S.total} item${S.total===1?'':'s'}`; + const f=S.feeds.find(x=>x.id===S.feed), v=VIEWS[S.feed]; + const unread=f?f.unread:S.feeds.reduce((n,x)=>n+(x.unread||0),0); + $('#count').textContent= + `${f?(f.title||f.id):v?v.title:''}: ${S.total} item${S.total===1?'':'s'}, ${unread} unread`; const pane=$('#list'), top=pane?pane.scrollTop:0; box.innerHTML=''; if(!S.entries.length){ @@ -722,40 +830,35 @@ function epEl(e){ const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join(''); const left = e.position>10 && e.duration ? `${clock(e.duration-e.position)} left` : (e.duration?clock(e.duration):''); el.innerHTML=` - ${artHTML(e.image||feedArt(),e.title,'')} + +
${esc(e.title||'(untitled)')} -
- ${e.read?'':'new'} - ${num?`${num}`:''} - ${dateOf(e.published)} - ${left?''+left+'':''} - ${enc?`${ - has?'downloaded':(enc.state==='skipped'?kindOf(enc):esc(enc.state))}`:''} - ${enc&&enc.length?`${mb(enc.length)}`:''} - ${others>0?`+${others} more file${others===1?'':'s'}`:''} - ${e.flagged?'★ kept':''} -
- ${enc&&!has?`
`:''} - ${enc&&enc.last_error&&enc.state==='error' - ?`
${esc(enc.last_error)}
`:''} +
${[ + num&&`${num}`, + left&&`${left}`, + others>0&&`+${others} more file${others===1?'':'s'}`, + enc&&enc.last_error&&enc.state==='error'&&`${esc(enc.last_error)}`, + ].filter(Boolean).join('')}
+ ${esc(feedName(e.feed_id))} +
+ ${enc?`${ + has?'downloaded':(enc.state==='skipped'?kindOf(enc):esc(enc.state))}`:''} + ${enc&&enc.length?`${mb(enc.length)}`:''} + ${enc&&!has?`
`:''} +
+ ${dateOf(e.published)}
${playable?``: (enc&&!has?``:'')} - - ${has?``:''}
`; - - const art=$('.art',el); - if(art&&playable){ - art.insertAdjacentHTML('beforeend',''); - art.onclick=ev=>{ ev.stopPropagation(); play(e); }; - } el.onclick=()=>selectEntry(e); - $$('.rowacts .iconbtn',el).forEach(b=>b.onclick=ev=>{ev.stopPropagation();epAction(b.dataset.a,e,el)}); + $$('button[data-a]',el).forEach(b=>b.onclick=ev=>{ev.stopPropagation();epAction(b.dataset.a,e,el)}); return el; } /// Whether the browser can play it. Having a file is not the same as being playable: @@ -783,7 +886,8 @@ function kindOf(enc){ return (ext && ext.length<=5) ? ext.toLowerCase() : 'file'; } -function feedArt(){ const f=S.feeds.find(x=>x.id===S.feed); return f&&f.image; } +function feedArt(id=S.feed){ const f=S.feeds.find(x=>x.id===id); return f&&f.image; } +const feedName=id=>{ const f=S.feeds.find(x=>x.id===id); return f?(f.title||f.id):id; }; /// Selecting an item shows it in the pane below, rather than expanding the row. /// Replaces one row with a fresh one, leaving the rest of the list and its scroll alone. @@ -812,9 +916,9 @@ function selectEntry(e){ } /// Drag the divider between the item list and the item text. -function dragSplit(){ - const grab=$('#grab'), pane=$('#split'); - if(!grab||!pane) return; +function dragSplit(pane){ + const grab=$('#grab',pane); + if(!grab) return; const move=ev=>{ const box=pane.getBoundingClientRect(); const pct=Math.min(80,Math.max(12,((ev.clientY-box.top)/box.height)*100)); @@ -834,11 +938,32 @@ function dragSplit(){ }; } -/// The item's text and its enclosures, in the pane below the list. +/// Which item the toolbar's play, read and keep buttons act on. +// ponytail: matched by guid alone; two feeds sharing a guid in All Subscriptions would pick +// the first. Key rows by feed as well if that ever happens. +const cur=()=>S.entries.find(x=>x.guid===S.sel); +function syncTools(e){ + $('#tbPlay').disabled=!(e&&e.enclosures.some(isPlayable)); + $('#tbRead').disabled=$('#tbFlag').disabled=!e; + if(e){ + $('#tbRead').title=`Mark ${e.read?'unread':'read'}`; + $('#tbFlag').title=e.flagged?'Stop keeping':'Keep, so it is never deleted'; + } +} + +/// The item's text in the pane below the list, and its files in the pane beside it. function showDetail(e){ - const box=$('#detail'); if(!box) return; + const box=$('#detail'), files=$('#files'); if(!box) return; document.body.classList.toggle('reading',!!e); - if(!e){ box.innerHTML='

Pick an item to read it.

'; return; } + syncTools(e); + if(!e){ + box.innerHTML='

Pick an item to read it.

'; + if(files) files.innerHTML='

No files

'; + return; + } + const encs=e.enclosures.map(encBox).join('')||'

No files

'; + // A phone has no room for the files pane, so the files follow the text there instead. + const narrow=!!window.matchMedia?.('(max-width:820px)')?.matches; const f=S.feeds.find(x=>x.id===e.feed_id); const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join(''); box.innerHTML=` @@ -854,13 +979,14 @@ function showDetail(e){ ${e.link?`Open original \u2197`:''}
${(e.description&&e.description.trim())||'No show notes.'}
- ${e.enclosures.map(encBox).join('')}`; + ${narrow?encs:''}`; + if(files) files.innerHTML=narrow?'':`
Files
${encs}`; // description was sanitized server-side with ammonia before it ever reached here $('#dback').onclick=()=>showDetail(null); - $$('button[data-a]',box).forEach(b=> + for(const root of [box,files]) if(root) $$('button[data-a]',root).forEach(b=> b.onclick=()=>epAction(b.dataset.a,e,null,b.dataset.enc?Number(b.dataset.enc):null)); for(const x of e.enclosures){ - const a=$(`#audio-${x.id}`,box); + const a=$(`#audio-${x.id}`); if(a) a.onplay=()=>play(e); } } @@ -869,7 +995,7 @@ function showDetail(e){ function encBox(x){ const size=x.length?mb(x.length):''; // One file serves everyone reading the feed, so deleting is not a private act. - const f=S.feeds.find(y=>y.id===S.feed); + const f=S.feeds.find(y=>y.id===x.feed_id); const shared=f&&f.subscribers>1; const delBtn=`
`); - listFeeds(url); +/// Directory and Popular 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); + $('#epSearch').placeholder='Search items…'; + box.innerHTML=` +
+
${v.icon}
+

${v.title}

+
${v.blurb} Everyone counts, you included. Feeds inside an OPML + subscription, and private feeds, are never listed.
+
+ `; + $('#count').textContent=v.title; + const n=await listFeeds(v.url); + if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${n} feed${n===1?'':'s'}`; } -$('#popularFeeds').onclick=()=>listedModal('Popular on this server', - 'The ten feeds with the most subscribers here.','/api/popular'); -$('#directoryFeeds').onclick=()=>listedModal('Directory', - 'Every feed anyone on this server subscribes to, A to Z.','/api/directory'); + +// The toolbar acts on whatever is selected: the feed on the left, the item in the table. +$('#tbRemove').onclick=()=>{ const f=S.feeds.find(x=>x.id===S.feed); if(f) removeFeed(f); }; +$('#tbPlay').onclick=()=>{ const e=cur(); if(e) play(e); }; +$('#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); }; +// 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); }); let expanded = new Set(JSON.parse(localStorage.getItem('ipx.expanded')||'[]')); function toggleGroup(id){ @@ -1472,7 +1616,7 @@ api('/api/me').then(u=>{ $('#who').textContent=u.name+(u.admin?' · admin':''); // Scanning, quotas, accounts and the log are the operator's business. The server refuses // them too; hiding the buttons just stops offering what would fail. - if(!u.admin){ $('#prefs').hidden=true; $('#logs').hidden=true; } + if(!u.admin) $('#admintools').hidden=true; }).catch(()=>{}); on('#logs','onclick',logsModal); $('#feedFilter').oninput=renderFeeds; @@ -1519,7 +1663,7 @@ function connect(){ } else if(ev.ev==='feed_done'){ if(ev.new){ fresh[ev.feed]=(fresh[ev.feed]||0)+ev.new; tellNew(); } - refreshFeeds(); if(ev.feed===S.feed) refreshEntries(); + refreshFeeds(); if(ev.feed===S.feed||S.feed===':all') refreshEntries(); } else if(ev.ev==='feed_error'){ toast(ev.feed+': '+ev.msg,true); refreshFeeds(); } else if(ev.ev==='scan_done'){ refreshFeeds(); refreshEntries(); }