Sortable item table, Size in its own column, Subscribed as an icon
- Every column heading sorts (kept, title, feed, file type, size, published); a second click reverses it. The server sorts through a fixed whitelist (order_sql), so it covers the whole list, not the fifty loaded; the choice is remembered in the browser. - Size is its own column and shows KB for small files instead of "0 MB". The Item heading is Title. - Popular/Directory/Add feed: Subscribed is a green circle-check. - Tests: every sort column runs and orders both ways (db); the table sorts by title both ways and remembers across a reload (browser). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0173mGu6rK18Ne7UGTwAaVJV
This commit is contained in:
@@ -24,6 +24,9 @@ The long form, with what was wrong before and how it was found, is in
|
|||||||
and a status bar with the totals.
|
and a status bar with the totals.
|
||||||
- Mark everything read from All Subscriptions, across every feed you subscribe to
|
- Mark everything read from All Subscriptions, across every feed you subscribe to
|
||||||
(`POST /api/read-all`). It asks first. All Subscriptions can also check every feed from its header.
|
(`POST /api/read-all`). It asks first. All Subscriptions can also check every feed from its header.
|
||||||
|
- Click a column heading in the item table to sort by it (kept, title, feed, file type, size,
|
||||||
|
published); click again to reverse. The server sorts, so it covers the whole list, not just the
|
||||||
|
fifty shown, and the choice is remembered.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
@@ -45,10 +48,13 @@ The long form, with what was wrong before and how it was found, is in
|
|||||||
and read as closing the page. The remaining word buttons are icons too:
|
and read as closing the page. The remaining word buttons are icons too:
|
||||||
- Log, Add feed, Users, Unsubscribe and OPML.
|
- Log, Add feed, Users, Unsubscribe and OPML.
|
||||||
- Popular's Subscribe, Copy and Sign out.
|
- Popular's Subscribe, Copy and Sign out.
|
||||||
|
- The Subscribed label in Popular, the Directory and Add feed, which is now a green check.
|
||||||
- The player's back, play, forward and close, which were font characters, and the folder arrow.
|
- The player's back, play, forward and close, which were font characters, and the folder arrow.
|
||||||
- The toolbar's read and keep buttons show the selected item's state, with the same icons as the
|
- The toolbar's read and keep buttons show the selected item's state, with the same icons as the
|
||||||
item's own buttons. Play, read and keep sit together, and Scan sits with add and unsubscribe.
|
item's own buttons. Play, read and keep sit together, and Scan sits with add and unsubscribe.
|
||||||
- An OPML subscription's page has the same header as a feed's, with its buttons in the same places.
|
- An OPML subscription's page has the same header as a feed's, with its buttons in the same places.
|
||||||
|
- The item table's size has its own column, apart from the file's type, and shows KB for small
|
||||||
|
files instead of "0 MB". The Item heading is now Title.
|
||||||
- A file's type is an icon (audio, video, image, PDF, torrent, other), green once it is
|
- A file's type is an icon (audio, video, image, PDF, torrent, other), green once it is
|
||||||
downloaded and red when the download failed, with the details in its tooltip. One icon per
|
downloaded and red when the download failed, with the details in its tooltip. One icon per
|
||||||
row keeps the column lined up. The DOWNLOADED and PENDING labels are gone.
|
row keeps the column lined up. The DOWNLOADED and PENDING labels are gone.
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ else a `401`.
|
|||||||
| `GET /login`, `POST /api/login`, `POST /api/logout`, `GET /api/me` | sign-in |
|
| `GET /login`, `POST /api/login`, `POST /api/logout`, `GET /api/me` | sign-in |
|
||||||
| `GET /api/feeds`, `POST /api/feeds` | your subscriptions; subscribe |
|
| `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 |
|
| `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/feeds/{id}/entries` | paged, filtered, searchable, sortable (`sort` = kept, title, feed, type, size or published; `dir` = asc or desc) |
|
||||||
| `GET /api/entries` | the same, across every feed you subscribe to (All Subscriptions) |
|
| `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/feeds/{id}/read-all`, `POST /api/feeds/{id}/download-latest` | |
|
||||||
| `POST /api/read-all` | everything read in every feed you subscribe to (All Subscriptions) |
|
| `POST /api/read-all` | everything read in every feed you subscribe to (All Subscriptions) |
|
||||||
|
|||||||
58
src/db.rs
58
src/db.rs
@@ -620,6 +620,25 @@ fn scope_sql(feed_id: Option<&str>, user_param: u8) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The item table's ORDER BY. The column name picks one of these fixed expressions, so nothing
|
||||||
|
/// the caller sends reaches the query, and anything unrecognised is newest first. Ties fall back
|
||||||
|
/// to newest first too, so a page boundary is stable across "Load more".
|
||||||
|
///
|
||||||
|
/// ponytail: file type and size look at the item's first and largest file. The row shows the file
|
||||||
|
/// it summarises, which is almost always that one; sort by that one if they ever disagree.
|
||||||
|
pub fn order_sql(col: &str, dir: &str) -> String {
|
||||||
|
let expr = match col {
|
||||||
|
"kept" => "coalesce(s.flagged, 0)",
|
||||||
|
"title" => "lower(coalesce(e.title, ''))",
|
||||||
|
"feed" => "(SELECT lower(coalesce(f.title, f.id)) FROM feeds f WHERE f.id = e.feed_id)",
|
||||||
|
"type" => "(SELECT min(x.mime) FROM enclosures x WHERE x.feed_id = e.feed_id AND x.guid = e.guid)",
|
||||||
|
"size" => "(SELECT max(x.length) FROM enclosures x WHERE x.feed_id = e.feed_id AND x.guid = e.guid)",
|
||||||
|
_ => "coalesce(e.published, e.first_seen)",
|
||||||
|
};
|
||||||
|
let dir = if dir == "asc" { "ASC" } else { "DESC" };
|
||||||
|
format!("{expr} {dir}, coalesce(e.published, e.first_seen) DESC, e.rowid DESC")
|
||||||
|
}
|
||||||
|
|
||||||
/// Which slice of a feed the UI is asking for.
|
/// Which slice of a feed the UI is asking for.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum Filter {
|
pub enum Filter {
|
||||||
@@ -679,7 +698,7 @@ impl Db {
|
|||||||
offset: i64,
|
offset: i64,
|
||||||
limit: i64,
|
limit: i64,
|
||||||
) -> Result<Vec<EntryRow>> {
|
) -> Result<Vec<EntryRow>> {
|
||||||
self.entries_in(user_id, Some(feed_id), filter, search, offset, limit)
|
self.entries_in(user_id, Some(feed_id), filter, search, offset, limit, &order_sql("published", "desc"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `entries` for one feed, or across every feed the person subscribes to when `feed_id`
|
/// `entries` for one feed, or across every feed the person subscribes to when `feed_id`
|
||||||
@@ -692,6 +711,7 @@ impl Db {
|
|||||||
search: Option<&str>,
|
search: Option<&str>,
|
||||||
offset: i64,
|
offset: i64,
|
||||||
limit: i64,
|
limit: i64,
|
||||||
|
order: &str,
|
||||||
) -> Result<Vec<EntryRow>> {
|
) -> Result<Vec<EntryRow>> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let like = search
|
let like = search
|
||||||
@@ -705,7 +725,7 @@ impl Db {
|
|||||||
LEFT JOIN entry_state s
|
LEFT JOIN entry_state s
|
||||||
ON s.user_id = ?5 AND s.feed_id = e.feed_id AND s.guid = e.guid
|
ON s.user_id = ?5 AND s.feed_id = e.feed_id AND s.guid = e.guid
|
||||||
WHERE {} AND {} AND {SEARCH}
|
WHERE {} AND {} AND {SEARCH}
|
||||||
ORDER BY coalesce(e.published, e.first_seen) DESC, e.rowid DESC
|
ORDER BY {order}
|
||||||
LIMIT ?4 OFFSET ?3",
|
LIMIT ?4 OFFSET ?3",
|
||||||
scope_sql(feed_id, 5),
|
scope_sql(feed_id, 5),
|
||||||
filter.sql()
|
filter.sql()
|
||||||
@@ -1357,6 +1377,40 @@ pub fn now() -> i64 {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_sort_column_runs_and_orders_both_ways() {
|
||||||
|
let db = Db::memory().unwrap();
|
||||||
|
db.exec_for_test(
|
||||||
|
"INSERT INTO users (id, name, is_admin, created) VALUES (1,'ray',1,0);
|
||||||
|
INSERT INTO subscriptions (user_id, feed_id, created) VALUES (1,'f',0),(1,'g',0);
|
||||||
|
INSERT INTO feeds (id, url, title) VALUES ('f','u','Zebra'),('g','v','Aardvark');
|
||||||
|
INSERT INTO entries (feed_id, guid, title, first_seen) VALUES
|
||||||
|
('f','a','banana',100),('g','b','Apple',200),('f','c','cherry',300);
|
||||||
|
INSERT INTO enclosures (id, feed_id, guid, url, mime, length, state) VALUES
|
||||||
|
(1,'f','a','u1','audio/mpeg',300,'pending'),(2,'g','b','u2','image/png',10,'pending'),
|
||||||
|
(3,'f','c','u3','video/mp4',2000,'pending');",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let order = |col: &str, dir: &str| -> Vec<String> {
|
||||||
|
db.entries_in(1, None, Filter::All, None, 0, 50, &order_sql(col, dir))
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| e.guid)
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
assert_eq!(order("title", "asc"), ["b", "a", "c"], "Apple, banana, cherry: case folded");
|
||||||
|
assert_eq!(order("title", "desc"), ["c", "a", "b"]);
|
||||||
|
assert_eq!(order("feed", "asc"), ["b", "c", "a"], "Aardvark, then Zebra's newest first");
|
||||||
|
assert_eq!(order("type", "asc"), ["a", "b", "c"], "audio, image, video");
|
||||||
|
assert_eq!(order("size", "desc"), ["c", "a", "b"]);
|
||||||
|
assert_eq!(order("published", "desc"), ["c", "b", "a"]);
|
||||||
|
db.set_entry_flag(1, "f", "a", EntryFlag::Flagged, true).unwrap();
|
||||||
|
assert_eq!(order("kept", "desc")[0], "a");
|
||||||
|
// An unknown column or direction is newest first; the name itself never reaches the SQL.
|
||||||
|
assert_eq!(order("title; DROP TABLE entries", "sideways"), ["c", "b", "a"]);
|
||||||
|
assert!(!order_sql("x'; --", "asc").contains("x'"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn deleting_a_shared_file_asks_about_everyone_else() {
|
fn deleting_a_shared_file_asks_about_everyone_else() {
|
||||||
let db = Db::memory().unwrap();
|
let db = Db::memory().unwrap();
|
||||||
|
|||||||
13
src/web.rs
13
src/web.rs
@@ -824,6 +824,12 @@ struct Page {
|
|||||||
filter: Option<String>,
|
filter: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
q: Option<String>,
|
q: Option<String>,
|
||||||
|
/// A column name and asc or desc. Anything unrecognised is newest first: the name picks a
|
||||||
|
/// fixed expression in the query and never reaches it itself.
|
||||||
|
#[serde(default)]
|
||||||
|
sort: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
dir: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fifty() -> i64 {
|
fn fifty() -> i64 {
|
||||||
@@ -864,7 +870,12 @@ fn entry_page(
|
|||||||
let filter = crate::db::Filter::parse(page.filter.as_deref().unwrap_or("all"));
|
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 search = page.q.as_deref().map(str::trim).filter(|q| !q.is_empty());
|
||||||
let db = &state.ctx.db;
|
let db = &state.ctx.db;
|
||||||
let mut rows = db.entries_in(user_id, feed, filter, search, page.offset, page.limit.clamp(1, 200))?;
|
let order = crate::db::order_sql(
|
||||||
|
page.sort.as_deref().unwrap_or("published"),
|
||||||
|
page.dir.as_deref().unwrap_or("desc"),
|
||||||
|
);
|
||||||
|
let mut rows =
|
||||||
|
db.entries_in(user_id, feed, filter, search, page.offset, page.limit.clamp(1, 200), &order)?;
|
||||||
// Feed HTML is untrusted: it reaches the page only after ammonia has been through it.
|
// Feed HTML is untrusted: it reaches the page only after ammonia has been through it.
|
||||||
for row in &mut rows {
|
for row in &mut rows {
|
||||||
if let Some(d) = &row.description {
|
if let Some(d) = &row.description {
|
||||||
|
|||||||
@@ -642,7 +642,8 @@ 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.
|
// Everyone counts, you included: it stays listed, marked as yours, with one more subscriber.
|
||||||
expect(await row()).toMatchObject({ subscribed: true, subscribers: before.subscribers + 1 });
|
expect(await row()).toMatchObject({ subscribed: true, subscribers: before.subscribers + 1 });
|
||||||
await piper.locator('#feedlist .place', { hasText: 'Popular' }).click();
|
await piper.locator('#feedlist .place', { hasText: 'Popular' }).click();
|
||||||
await expect(offered.filter({ hasText: 'Test Show' })).toContainText('Subscribed');
|
await expect(offered.filter({ hasText: 'Test Show' }).locator('[title^="Subscribed"]')).toBeVisible();
|
||||||
|
await expect(offered.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
|
// All Subscriptions is every item from piper's feeds and only those: the admin's Picture
|
||||||
// Blog is not among them.
|
// Blog is not among them.
|
||||||
@@ -738,3 +739,31 @@ test('All Subscriptions marks everything read, across every feed', async ({ page
|
|||||||
await page.locator('.tabs button', { hasText: 'Unread' }).click();
|
await page.locator('.tabs button', { hasText: 'Unread' }).click();
|
||||||
await expect(page.locator('.ep')).toHaveCount(0);
|
await expect(page.locator('.ep')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the item table sorts by any column, both ways, and remembers', async ({ page }) => {
|
||||||
|
const all = page.locator('#feedlist .place', { hasText: 'All Subscriptions' });
|
||||||
|
await all.click();
|
||||||
|
const head = k => page.locator(`#list .ephead [data-sort="${k}"]`);
|
||||||
|
const titles = () => page.locator('#eps .ep .t').allTextContents();
|
||||||
|
// Byte order on lower case, which is what SQLite gives for lower(...).
|
||||||
|
const cmp = (a, b) => (a.toLowerCase() < b.toLowerCase() ? -1 : a.toLowerCase() > b.toLowerCase() ? 1 : 0);
|
||||||
|
const sorted = (t, dir) => JSON.stringify(t) === JSON.stringify([...t].sort((a, b) => cmp(a, b) * dir));
|
||||||
|
await expect(page.locator('#eps .ep').nth(2)).toBeVisible({ timeout: 20_000 });
|
||||||
|
expect(new Set(await titles()).size).toBeGreaterThan(2); // or both orders would prove nothing
|
||||||
|
|
||||||
|
await expect(head('title')).toHaveText('Title');
|
||||||
|
await head('title').click();
|
||||||
|
await expect.poll(async () => sorted(await titles(), 1)).toBe(true);
|
||||||
|
await head('title').click();
|
||||||
|
await expect.poll(async () => sorted(await titles(), -1)).toBe(true);
|
||||||
|
|
||||||
|
// Kept across a reload.
|
||||||
|
await page.reload();
|
||||||
|
await all.click();
|
||||||
|
await expect(head('title').locator('.arr.desc')).toBeVisible();
|
||||||
|
await expect.poll(async () => sorted(await titles(), -1)).toBe(true);
|
||||||
|
|
||||||
|
// Size has its own column; the file column is just what the file is.
|
||||||
|
await expect(page.locator('#eps .ep .size', { hasText: /\d/ }).first()).toBeVisible();
|
||||||
|
await expect(page.locator('#eps .ep .file', { hasText: /\d/ })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|||||||
@@ -242,6 +242,8 @@ a.btn{text-decoration:none;color:inherit}
|
|||||||
it is here, red when the download failed. */
|
it is here, red when the download failed. */
|
||||||
.kind{display:inline-grid;place-items:center;color:var(--dim)}
|
.kind{display:inline-grid;place-items:center;color:var(--dim)}
|
||||||
.kind.here{color:var(--good)}
|
.kind.here{color:var(--good)}
|
||||||
|
.subbed{display:inline-flex;padding:0 9px;color:var(--good)}
|
||||||
|
.subbed .i{width:18px;height:18px}
|
||||||
.kind.bad{color:var(--bad)}
|
.kind.bad{color:var(--bad)}
|
||||||
.fhead .art .i{width:22px;height:22px}
|
.fhead .art .i{width:22px;height:22px}
|
||||||
.toolbar{
|
.toolbar{
|
||||||
@@ -254,16 +256,24 @@ a.btn{text-decoration:none;color:inherit}
|
|||||||
.toolbar .grow{flex:1;min-width:150px;max-width:320px}
|
.toolbar .grow{flex:1;min-width:150px;max-width:320px}
|
||||||
|
|
||||||
/* ---------- items ---------- */
|
/* ---------- items ---------- */
|
||||||
/* A table, as the original's was: unread, kept, the item, its feed, its file, when. */
|
/* A table, as the original's was: unread, kept, title, feed, file, size, published. */
|
||||||
.ephead,.ep{
|
.ephead,.ep{
|
||||||
display:grid;gap:8px;align-items:center;
|
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;
|
grid-template-columns:22px 22px minmax(120px,1fr) minmax(0,160px) 56px minmax(0,72px) minmax(0,92px) 64px;
|
||||||
}
|
}
|
||||||
.ephead{
|
.ephead{
|
||||||
position:sticky;top:0;z-index:2;background:var(--bg);padding:7px 10px 5px;
|
position:sticky;top:0;z-index:2;background:var(--bg);padding:7px 10px 5px;
|
||||||
border-bottom:1px solid var(--line);margin-bottom:3px;
|
border-bottom:1px solid var(--line);margin-bottom:3px;
|
||||||
font-size:10.5px;text-transform:uppercase;letter-spacing:.05em;color:var(--faint);
|
font-size:10.5px;text-transform:uppercase;letter-spacing:.05em;color:var(--faint);
|
||||||
}
|
}
|
||||||
|
/* Each heading sorts by its column; the caret says which way. */
|
||||||
|
.ephead .hs{display:flex;align-items:center;gap:4px;min-width:0;padding:0;border:0;background:none;
|
||||||
|
font:inherit;color:inherit;text-transform:inherit;letter-spacing:inherit;text-align:left;cursor:pointer}
|
||||||
|
.ephead .hs:hover,.ephead .hs.on{color:var(--fg)}
|
||||||
|
.ephead .hs .arr{display:inline-flex}
|
||||||
|
.ephead .hs .arr .i{width:8px;height:8px}
|
||||||
|
.ephead .hs .arr.asc{transform:rotate(-90deg)}
|
||||||
|
.ephead .hs .arr.desc{transform:rotate(90deg)}
|
||||||
.ep{padding:4px 10px;border-radius:7px;border:1px solid transparent;cursor:pointer;margin-bottom:1px}
|
.ep{padding:4px 10px;border-radius:7px;border:1px solid transparent;cursor:pointer;margin-bottom:1px}
|
||||||
.ep>*{min-width:0}
|
.ep>*{min-width:0}
|
||||||
.ep.sel{background:var(--raise);border-color:var(--line)}
|
.ep.sel{background:var(--raise);border-color:var(--line)}
|
||||||
@@ -277,12 +287,12 @@ a.btn{text-decoration:none;color:inherit}
|
|||||||
.ep.read .t{color:var(--dim);font-weight:500}
|
.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:11.5px}
|
.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 .line:empty{display:none}
|
||||||
.ep .fd,.ep .date{color:var(--dim);font-size:12.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
.ep .fd,.ep .size,.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{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 .file .dlbar{flex-basis:100%;margin-top:2px}
|
||||||
.ep .rowacts{justify-content:flex-end}
|
.ep .rowacts{justify-content:flex-end}
|
||||||
/* One feed's own table has no need of a Feed column; All Subscriptions does. */
|
/* One feed's own table has no need of a Feed column; All Subscriptions does. */
|
||||||
#split.one .ephead,#split.one .ep{grid-template-columns:22px 22px minmax(120px,1fr) minmax(0,112px) minmax(0,92px) 64px}
|
#split.one .ephead,#split.one .ep{grid-template-columns:22px 22px minmax(120px,1fr) 56px minmax(0,72px) minmax(0,92px) 64px}
|
||||||
#split.one .h-fd,#split.one .ep .fd{display:none}
|
#split.one .h-fd,#split.one .ep .fd{display:none}
|
||||||
.dot{width:3px;height:3px;border-radius:50%;background:var(--faint);flex:none}
|
.dot{width:3px;height:3px;border-radius:50%;background:var(--faint);flex:none}
|
||||||
.chip{
|
.chip{
|
||||||
@@ -422,7 +432,7 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
|||||||
/* One pane at a time: the list, then the item over it. */
|
/* One pane at a time: the list, then the item over it. */
|
||||||
#split{grid-template-columns:1fr;grid-template-rows:1fr;grid-template-areas:"list"}
|
#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. */
|
/* 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}
|
#files,.ephead,.ep .fd,.ep .file,.ep .size,.ep .rowacts{display:none}
|
||||||
.ep,#split.one .ep{grid-template-columns:20px 20px minmax(0,1fr) auto}
|
.ep,#split.one .ep{grid-template-columns:20px 20px minmax(0,1fr) auto}
|
||||||
#grab{display:none}
|
#grab{display:none}
|
||||||
#detail{position:fixed;inset:0;z-index:38;display:none;border-top:0;padding:12px 16px 90px}
|
#detail{position:fixed;inset:0;z-index:38;display:none;border-top:0;padding:12px 16px 90px}
|
||||||
@@ -498,6 +508,7 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
|||||||
:root[data-theme="classic"] #eps .ep.sel{background:#3875d7;border-color:#3875d7}
|
:root[data-theme="classic"] #eps .ep.sel{background:#3875d7;border-color:#3875d7}
|
||||||
:root[data-theme="classic"] .ep.sel .t,
|
:root[data-theme="classic"] .ep.sel .t,
|
||||||
:root[data-theme="classic"] .ep.sel .fd,
|
:root[data-theme="classic"] .ep.sel .fd,
|
||||||
|
:root[data-theme="classic"] .ep.sel .size,
|
||||||
:root[data-theme="classic"] .ep.sel .date,
|
:root[data-theme="classic"] .ep.sel .date,
|
||||||
:root[data-theme="classic"] .ep.sel .line,
|
:root[data-theme="classic"] .ep.sel .line,
|
||||||
:root[data-theme="classic"] .ep.sel .st,
|
:root[data-theme="classic"] .ep.sel .st,
|
||||||
@@ -631,6 +642,7 @@ const ICON={
|
|||||||
pause:fa('0 0 384 512','<path fill="currentColor" d="M48 32C21.5 32 0 53.5 0 80L0 432c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48L48 32zm224 0c-26.5 0-48 21.5-48 48l0 352c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48l-64 0z"/>'), // solid/pause
|
pause:fa('0 0 384 512','<path fill="currentColor" d="M48 32C21.5 32 0 53.5 0 80L0 432c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48L48 32zm224 0c-26.5 0-48 21.5-48 48l0 352c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48l-64 0z"/>'), // solid/pause
|
||||||
caret:fa('0 0 256 512','<path fill="currentColor" d="M249.3 235.8c10.2 12.6 9.5 31.1-2.2 42.8l-128 128c-9.2 9.2-22.9 11.9-34.9 6.9S64.5 396.9 64.5 384l0-256c0-12.9 7.8-24.6 19.8-29.6s25.7-2.2 34.9 6.9l128 128 2.2 2.4z"/>'), // solid/caret-right
|
caret:fa('0 0 256 512','<path fill="currentColor" d="M249.3 235.8c10.2 12.6 9.5 31.1-2.2 42.8l-128 128c-9.2 9.2-22.9 11.9-34.9 6.9S64.5 396.9 64.5 384l0-256c0-12.9 7.8-24.6 19.8-29.6s25.7-2.2 34.9 6.9l128 128 2.2 2.4z"/>'), // solid/caret-right
|
||||||
left:fa('0 0 512 512','<path fill="currentColor" d="M9.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l160 160c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L109.3 288 480 288c17.7 0 32-14.3 32-32s-14.3-32-32-32l-370.7 0 105.4-105.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-160 160z"/>'), // solid/arrow-left
|
left:fa('0 0 512 512','<path fill="currentColor" d="M9.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l160 160c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L109.3 288 480 288c17.7 0 32-14.3 32-32s-14.3-32-32-32l-370.7 0 105.4-105.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-160 160z"/>'), // solid/arrow-left
|
||||||
|
subbed:fa('0 0 512 512','<path fill="currentColor" d="M256 512a256 256 0 1 1 0-512 256 256 0 1 1 0 512zM374 145.7c-10.7-7.8-25.7-5.4-33.5 5.3L221.1 315.2 169 263.1c-9.4-9.4-24.6-9.4-33.9 0s-9.4 24.6 0 33.9l72 72c5 5 11.8 7.5 18.8 7s13.4-4.1 17.5-9.8L379.3 179.2c7.8-10.7 5.4-25.7-5.3-33.5z"/>'), // solid/circle-check
|
||||||
};
|
};
|
||||||
// One meaning per icon: minus unsubscribes, x closes or cancels, plus adds or subscribes, and a
|
// One meaning per icon: minus unsubscribes, x closes or cancels, plus adds or subscribes, and a
|
||||||
// dialog's confirm button carries the icon of what it does. Words go in the tooltip.
|
// dialog's confirm button carries the icon of what it does. Words go in the tooltip.
|
||||||
@@ -693,7 +705,9 @@ const ago = t => {
|
|||||||
return new Date(t*1000).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'});
|
return new Date(t*1000).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'});
|
||||||
};
|
};
|
||||||
const dateOf = t => t?new Date(t*1000).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'}):'';
|
const dateOf = t => t?new Date(t*1000).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'}):'';
|
||||||
const mb = n => n?(n/1048576).toFixed(0)+' MB':'';
|
// A podcast episode is tens of MB, an article's image a few KB: whole MB made the small ones "0 MB".
|
||||||
|
const mb = n => !n?'' : n<1048576?Math.max(1,Math.round(n/1024))+' KB'
|
||||||
|
: n<1073741824?Math.round(n/1048576)+' MB' : (n/1073741824).toFixed(1)+' GB';
|
||||||
const initials = s => (s||'?').replace(/[^A-Za-z0-9 ]/g,'').split(/\s+/).filter(Boolean).slice(0,2).map(w=>w[0]).join('').toUpperCase()||'?';
|
const initials = s => (s||'?').replace(/[^A-Za-z0-9 ]/g,'').split(/\s+/).filter(Boolean).slice(0,2).map(w=>w[0]).join('').toUpperCase()||'?';
|
||||||
function artHTML(url,name,cls){
|
function artHTML(url,name,cls){
|
||||||
return url
|
return url
|
||||||
@@ -708,6 +722,9 @@ function nav(on){ $('#sidebar').classList.toggle('open',on); $('#scrim').hidden=
|
|||||||
const S = {
|
const S = {
|
||||||
feeds:[], feed:null, entries:[], total:0, offset:0, limit:50,
|
feeds:[], feed:null, entries:[], total:0, offset:0, limit:50,
|
||||||
filter:'all', q:'', sel:null, busy:new Set(), me:null,
|
filter:'all', q:'', sel:null, busy:new Set(), me:null,
|
||||||
|
// The item table's order, kept across visits. The server sorts: a list arrives fifty at a time.
|
||||||
|
sort:(()=>{ try{ return JSON.parse(localStorage.getItem('ipx.sort')); }catch{ return null; } })()
|
||||||
|
||{col:'published',dir:'desc'},
|
||||||
};
|
};
|
||||||
const LIMIT = 50;
|
const LIMIT = 50;
|
||||||
|
|
||||||
@@ -849,9 +866,9 @@ function renderFeed(){
|
|||||||
const pane=document.createElement('div');
|
const pane=document.createElement('div');
|
||||||
pane.id='split';
|
pane.id='split';
|
||||||
if(f) pane.className='one';
|
if(f) pane.className='one';
|
||||||
pane.innerHTML='<div id="list"><div class="ephead"><span></span><span title="Kept">'+ICON.flag+'</span>'+
|
pane.innerHTML='<div id="list">'+sortHead()+
|
||||||
'<span>Item</span><span class="h-fd">Feed</span><span>File</span><span>Published</span><span></span></div>'+
|
|
||||||
'<div id="eps"></div></div><div id="files"></div><div id="grab"></div><div id="detail"></div>';
|
'<div id="eps"></div></div><div id="files"></div><div id="grab"></div><div id="detail"></div>';
|
||||||
|
$$('.ephead [data-sort]',pane).forEach(b=>b.onclick=()=>sortBy(b.dataset.sort));
|
||||||
box.appendChild(pane);
|
box.appendChild(pane);
|
||||||
pane.style.setProperty('--listh', localStorage.getItem('ipx.listh') || '40%');
|
pane.style.setProperty('--listh', localStorage.getItem('ipx.listh') || '40%');
|
||||||
dragSplit(pane);
|
dragSplit(pane);
|
||||||
@@ -861,6 +878,24 @@ function renderFeed(){
|
|||||||
$$('#content .tabs button').forEach(b=>b.onclick=()=>{S.filter=b.dataset.f;S.offset=0;renderFeed();loadEntries()});
|
$$('#content .tabs button').forEach(b=>b.onclick=()=>{S.filter=b.dataset.f;S.offset=0;renderFeed();loadEntries()});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The item table's headings, each a button that sorts by its column. The first click goes the
|
||||||
|
/// natural way round (A to Z; newest, largest and kept first) and the next one reverses it.
|
||||||
|
const COLS=[['kept','Kept',ICON.flag],['title','Title'],['feed','Feed'],['type','File'],['size','Size'],['published','Published']];
|
||||||
|
function sortHead(){
|
||||||
|
return '<div class="ephead"><span></span>'+COLS.map(([k,label,icon])=>{
|
||||||
|
const on=S.sort.col===k;
|
||||||
|
return `<button class="hs${on?' on':''}${k==='feed'?' h-fd':''}" data-sort="${k}"`+
|
||||||
|
` title="Sort by ${label.toLowerCase()}" aria-label="Sort by ${label.toLowerCase()}">${icon||label}`+
|
||||||
|
`${on?`<span class="arr ${S.sort.dir}">${ICON.caret}</span>`:''}</button>`;
|
||||||
|
}).join('')+'<span></span></div>';
|
||||||
|
}
|
||||||
|
function sortBy(col){
|
||||||
|
const first=['published','size','kept'].includes(col)?'desc':'asc';
|
||||||
|
S.sort={col,dir:S.sort.col===col?(S.sort.dir==='asc'?'desc':'asc'):first};
|
||||||
|
try{ localStorage.setItem('ipx.sort',JSON.stringify(S.sort)); }catch{}
|
||||||
|
S.offset=0; renderFeed(); loadEntries();
|
||||||
|
}
|
||||||
|
|
||||||
/// An OPML subscription's page lists the feeds inside it rather than items, but keeps
|
/// An OPML subscription's page lists the feeds inside it rather than items, but keeps
|
||||||
/// every action a normal feed has -- it is still an ordinary feed entry underneath.
|
/// every action a normal feed has -- it is still an ordinary feed entry underneath.
|
||||||
function renderGroup(f,kids){
|
function renderGroup(f,kids){
|
||||||
@@ -942,7 +977,7 @@ async function allAction(a){
|
|||||||
async function loadEntries(append){
|
async function loadEntries(append){
|
||||||
// Directory and Popular list feeds, not items.
|
// Directory and Popular list feeds, not items.
|
||||||
if(!S.feed || VIEWS[S.feed]?.url) return;
|
if(!S.feed || VIEWS[S.feed]?.url) return;
|
||||||
const p=new URLSearchParams({offset:S.offset,limit:LIMIT,filter:S.filter});
|
const p=new URLSearchParams({offset:S.offset,limit:LIMIT,filter:S.filter,sort:S.sort.col,dir:S.sort.dir});
|
||||||
if(S.q) p.set('q',S.q);
|
if(S.q) p.set('q',S.q);
|
||||||
const r=await api(S.feed===':all' ? `/api/entries?${p}`
|
const r=await api(S.feed===':all' ? `/api/entries?${p}`
|
||||||
: `/api/feeds/${encodeURIComponent(S.feed)}/entries?${p}`);
|
: `/api/feeds/${encodeURIComponent(S.feed)}/entries?${p}`);
|
||||||
@@ -1002,9 +1037,9 @@ function epEl(e){
|
|||||||
<span class="fd">${esc(feedName(e.feed_id))}</span>
|
<span class="fd">${esc(feedName(e.feed_id))}</span>
|
||||||
<div class="file">
|
<div class="file">
|
||||||
${enc?kindIcon(enc):''}
|
${enc?kindIcon(enc):''}
|
||||||
${enc&&enc.length?`<span>${mb(enc.length)}</span>`:''}
|
|
||||||
${enc&&!has?`<div class="dlbar" data-bar="${enc.id}"><i></i></div>`:''}
|
${enc&&!has?`<div class="dlbar" data-bar="${enc.id}"><i></i></div>`:''}
|
||||||
</div>
|
</div>
|
||||||
|
<span class="size">${enc?mb(enc.length):''}</span>
|
||||||
<span class="date">${dateOf(e.published)}</span>
|
<span class="date">${dateOf(e.published)}</span>
|
||||||
<div class="rowacts">
|
<div class="rowacts">
|
||||||
${playable?`<button class="iconbtn" data-a="play" title="Play" aria-label="Play">${ICON.play}</button>`:
|
${playable?`<button class="iconbtn" data-a="play" title="Play" aria-label="Play">${ICON.play}</button>`:
|
||||||
@@ -1480,7 +1515,8 @@ async function listFeeds(url){
|
|||||||
el.innerHTML=artHTML(p.image,p.title||p.id)+
|
el.innerHTML=artHTML(p.image,p.title||p.id)+
|
||||||
`<div class="txt"><b>${esc(p.title||p.id)}</b>`+
|
`<div class="txt"><b>${esc(p.title||p.id)}</b>`+
|
||||||
`<small class="meta">${p.subscribers} subscriber${p.subscribers===1?'':'s'}</small></div>`+
|
`<small class="meta">${p.subscribers} subscriber${p.subscribers===1?'':'s'}</small></div>`+
|
||||||
(p.subscribed?'<span class="tag">Subscribed</span>'
|
// Green, as a downloaded file is: it is already yours. Plus, beside it, is the way to get one.
|
||||||
|
(p.subscribed?`<span class="subbed" title="Subscribed: click to open it" aria-label="Subscribed">${ICON.subbed}</span>`
|
||||||
:`<button class="btn ico" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.plus}</button>`);
|
:`<button class="btn ico" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.plus}</button>`);
|
||||||
// Yours already: the row opens it instead.
|
// Yours already: the row opens it instead.
|
||||||
if(p.subscribed){ el.onclick=()=>{ closeModal(); selectFeed(p.id); }; box.appendChild(el); continue; }
|
if(p.subscribed){ el.onclick=()=>{ closeModal(); selectFeed(p.id); }; box.appendChild(el); continue; }
|
||||||
|
|||||||
Reference in New Issue
Block a user