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.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
@@ -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:
|
||||
- Log, Add feed, Users, Unsubscribe and OPML.
|
||||
- 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 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.
|
||||
- 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
|
||||
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.
|
||||
|
||||
@@ -104,7 +104,7 @@ else a `401`.
|
||||
| `GET /login`, `POST /api/login`, `POST /api/logout`, `GET /api/me` | sign-in |
|
||||
| `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/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) |
|
||||
| `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) |
|
||||
|
||||
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.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Filter {
|
||||
@@ -679,7 +698,7 @@ impl Db {
|
||||
offset: i64,
|
||||
limit: i64,
|
||||
) -> 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`
|
||||
@@ -692,6 +711,7 @@ impl Db {
|
||||
search: Option<&str>,
|
||||
offset: i64,
|
||||
limit: i64,
|
||||
order: &str,
|
||||
) -> Result<Vec<EntryRow>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let like = search
|
||||
@@ -705,7 +725,7 @@ impl Db {
|
||||
LEFT JOIN entry_state s
|
||||
ON s.user_id = ?5 AND s.feed_id = e.feed_id AND s.guid = e.guid
|
||||
WHERE {} AND {} AND {SEARCH}
|
||||
ORDER BY coalesce(e.published, e.first_seen) DESC, e.rowid DESC
|
||||
ORDER BY {order}
|
||||
LIMIT ?4 OFFSET ?3",
|
||||
scope_sql(feed_id, 5),
|
||||
filter.sql()
|
||||
@@ -1357,6 +1377,40 @@ pub fn now() -> i64 {
|
||||
mod tests {
|
||||
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]
|
||||
fn deleting_a_shared_file_asks_about_everyone_else() {
|
||||
let db = Db::memory().unwrap();
|
||||
|
||||
13
src/web.rs
13
src/web.rs
@@ -824,6 +824,12 @@ struct Page {
|
||||
filter: Option<String>,
|
||||
#[serde(default)]
|
||||
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 {
|
||||
@@ -864,7 +870,12 @@ fn entry_page(
|
||||
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 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.
|
||||
for row in &mut rows {
|
||||
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.
|
||||
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' })).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
|
||||
// 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 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. */
|
||||
.kind{display:inline-grid;place-items:center;color:var(--dim)}
|
||||
.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)}
|
||||
.fhead .art .i{width:22px;height:22px}
|
||||
.toolbar{
|
||||
@@ -254,16 +256,24 @@ a.btn{text-decoration:none;color:inherit}
|
||||
.toolbar .grow{flex:1;min-width:150px;max-width:320px}
|
||||
|
||||
/* ---------- 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{
|
||||
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{
|
||||
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);
|
||||
}
|
||||
/* 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>*{min-width:0}
|
||||
.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 .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 .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 .dlbar{flex-basis:100%;margin-top:2px}
|
||||
.ep .rowacts{justify-content:flex-end}
|
||||
/* 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}
|
||||
.dot{width:3px;height:3px;border-radius:50%;background:var(--faint);flex:none}
|
||||
.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. */
|
||||
#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}
|
||||
#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}
|
||||
#grab{display:none}
|
||||
#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"] .ep.sel .t,
|
||||
: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 .line,
|
||||
: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
|
||||
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
|
||||
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
|
||||
// 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'});
|
||||
};
|
||||
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()||'?';
|
||||
function artHTML(url,name,cls){
|
||||
return url
|
||||
@@ -708,6 +722,9 @@ function nav(on){ $('#sidebar').classList.toggle('open',on); $('#scrim').hidden=
|
||||
const S = {
|
||||
feeds:[], feed:null, entries:[], total:0, offset:0, limit:50,
|
||||
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;
|
||||
|
||||
@@ -849,9 +866,9 @@ function renderFeed(){
|
||||
const pane=document.createElement('div');
|
||||
pane.id='split';
|
||||
if(f) pane.className='one';
|
||||
pane.innerHTML='<div id="list"><div class="ephead"><span></span><span title="Kept">'+ICON.flag+'</span>'+
|
||||
'<span>Item</span><span class="h-fd">Feed</span><span>File</span><span>Published</span><span></span></div>'+
|
||||
pane.innerHTML='<div id="list">'+sortHead()+
|
||||
'<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);
|
||||
pane.style.setProperty('--listh', localStorage.getItem('ipx.listh') || '40%');
|
||||
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()});
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// every action a normal feed has -- it is still an ordinary feed entry underneath.
|
||||
function renderGroup(f,kids){
|
||||
@@ -942,7 +977,7 @@ async function allAction(a){
|
||||
async function loadEntries(append){
|
||||
// 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});
|
||||
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);
|
||||
const r=await api(S.feed===':all' ? `/api/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>
|
||||
<div class="file">
|
||||
${enc?kindIcon(enc):''}
|
||||
${enc&&enc.length?`<span>${mb(enc.length)}</span>`:''}
|
||||
${enc&&!has?`<div class="dlbar" data-bar="${enc.id}"><i></i></div>`:''}
|
||||
</div>
|
||||
<span class="size">${enc?mb(enc.length):''}</span>
|
||||
<span class="date">${dateOf(e.published)}</span>
|
||||
<div class="rowacts">
|
||||
${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)+
|
||||
`<div class="txt"><b>${esc(p.title||p.id)}</b>`+
|
||||
`<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>`);
|
||||
// Yours already: the row opens it instead.
|
||||
if(p.subscribed){ el.onclick=()=>{ closeModal(); selectFeed(p.id); }; box.appendChild(el); continue; }
|
||||
|
||||
Reference in New Issue
Block a user