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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HdTEWQNrzyFULijigkmMn
This commit is contained in:
2026-09-11 14:39:05 +00:00
parent f3825cfc57
commit df9b7645d6
9 changed files with 429 additions and 165 deletions

View File

@@ -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<String>,
user: crate::db::User,
Query(page): Query<Page>,
) -> Result<Json<EntryPage>, 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<WebState>,
user: crate::db::User,
Query(page): Query<Page>,
) -> Result<Json<EntryPage>, 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<Json<EntryPage>, 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 }))
}