5 Commits

Author SHA1 Message Date
b5ff57ac0f Release 0.8.1
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 02:13:06 +00:00
35b57fa997 Settings and the shortcuts list close from the corner
Both have nothing to confirm, so their only button was a lone X at the
foot of the card, below the fold of a long Settings card. A dialog with a
confirm keeps Cancel beside it at the bottom, where the pair belongs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 01:45:02 +00:00
43ffafe7ac A UI pass: contrast in every theme, and a sign-in page that fits
Measured every theme's palette against WCAG AA. The unread badge's count
on its --accent2 fill fell as low as 2.6:1 (Flat Remix light), and the
unread dot with it; each theme's --accent2 is taken down until white on
it clears 4.5. Tags were --warn or --bad on --raise, short of AA in most
light themes, so they are outlined on the row's own ground instead.

Nordic dark had --line equal to --panel2, so bordered buttons on a
panel2 ground drew no edge at all. Classic's selected row left the
row's icon buttons grey on the blue. A zero badge on a selected row was
--raise on --raise and vanished.

login.html never had a doctype or viewport meta, so it rendered in
quirks mode and at desktop width on a phone, and its light palette sat
under data-theme="light", which nothing ever set. It follows
prefers-color-scheme now, signed out having no account to ask.

Dropped the unused log and users icons. The Classic theme's label is
now just "Classic".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 01:37:21 +00:00
45cbd3a239 The scan spinner takes the unread count's place
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 01:21:49 +00:00
905dfa0b02 A spinner on the feed being checked, not toasts; check only your own feeds
The scan's events reach everyone, so every browser showed "<feed>: N new" and
"Scanning…" toasts, and refreshed, for everyone's feeds. Now a feed's row, and
its folder's, carries a spinner between feed_start and its done, skip or error;
the list refreshes only for the reader's own feeds; the scan toasts are gone, and
"Downloaded" is said only for a file on screen.

"Check every feed" from the web UI sent a scan of every feed on the server.
Command::Fetch takes an optional `feeds` list -- those feeds and the feeds
inside any OPML among them -- and the web fills it with the asker's
subscriptions. The schedule and the CLI send none, meaning every feed.

Closes #37.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 01:17:40 +00:00
17 changed files with 172 additions and 53 deletions

View File

@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.8.1] - 2026-09-19
### Changed
- A feed being checked shows a small spinner in place of its unread count (and its folder's),
instead of toasts: no more "Scanning…" or "1 new" pop-ups, and none at all for feeds you do not read. A
"Downloaded" toast is only for a file on your screen.
- "Check every feed" checks every feed you subscribe to, not every feed on the server.
- The Classic theme is listed in Settings as just "Classic".
- Settings and the keyboard shortcuts close from an X in their top corner, not a button at the bottom.
### Fixed
- Unread counts, the unread dot and the Gone and Error tags are readable in every theme: several
light themes (Flat Remix, Paper, Adwaita, Nordic, Modern) drew them below AA contrast.
- The sign-in page follows the system's light or dark setting, and fits a phone's screen instead of
drawing at desktop width.
- In Classic, a selected item's play and delete buttons are white on the blue, not grey.
- In Nordic's dark half, button and toolbar borders show.
- A zero unread count on the selected feed no longer disappears into the selection.
## [0.8.0] - 2026-09-18 ## [0.8.0] - 2026-09-18
### Added ### Added
@@ -503,6 +524,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`. - `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`.
[unreleased]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.1...main [unreleased]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.1...main
[0.8.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.0...v0.8.1
[0.8.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.7.0...v0.8.0 [0.8.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.7.0...v0.8.0
[0.7.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.1...v0.7.0 [0.7.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.1...v0.7.0
[0.6.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.0...v0.6.1 [0.6.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.0...v0.6.1

2
Cargo.lock generated
View File

@@ -1820,7 +1820,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
[[package]] [[package]]
name = "ipx" name = "ipx"
version = "0.8.0" version = "0.8.1"
dependencies = [ dependencies = [
"ammonia", "ammonia",
"anyhow", "anyhow",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "ipx" name = "ipx"
version = "0.8.0" version = "0.8.1"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View File

@@ -88,6 +88,11 @@ pub enum Command {
feed: Option<String>, feed: Option<String>,
#[serde(default)] #[serde(default)]
force: bool, force: bool,
/// Only these feeds, and the feeds inside any of them that is an OPML: "check every feed"
/// from the web UI is every feed of the person asking, not of everyone (issue #37).
/// Empty is every feed, as the schedule and the CLI mean it.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
feeds: Vec<String>,
}, },
Reap { Reap {
#[serde(default)] #[serde(default)]
@@ -301,10 +306,10 @@ mod tests {
#[test] #[test]
fn commands_parse_from_the_wire_form() { fn commands_parse_from_the_wire_form() {
let got: Command = serde_json::from_str(r#"{"cmd":"fetch"}"#).unwrap(); let got: Command = serde_json::from_str(r#"{"cmd":"fetch"}"#).unwrap();
assert!(matches!(got, Command::Fetch { feed: None, force: false })); assert!(matches!(got, Command::Fetch { feed: None, force: false, .. }));
let got: Command = serde_json::from_str(r#"{"cmd":"fetch","feed":"atp","force":true}"#).unwrap(); let got: Command = serde_json::from_str(r#"{"cmd":"fetch","feed":"atp","force":true}"#).unwrap();
assert!(matches!(got, Command::Fetch { feed: Some(f), force: true } if f == "atp")); assert!(matches!(got, Command::Fetch { feed: Some(f), force: true, .. } if f == "atp"));
let got: Command = serde_json::from_str(r#"{"cmd":"reap","dry_run":true}"#).unwrap(); let got: Command = serde_json::from_str(r#"{"cmd":"reap","dry_run":true}"#).unwrap();
assert!(matches!(got, Command::Reap { dry_run: true })); assert!(matches!(got, Command::Reap { dry_run: true }));

View File

@@ -197,7 +197,7 @@ async fn main() -> Result<()> {
// A daemon owns the state; don't have two processes downloading the same thing. // A daemon owns the state; don't have two processes downloading the same thing.
let wire_cmd = match &cli.command { let wire_cmd = match &cli.command {
Command::Fetch { feed, force } => { Command::Fetch { feed, force } => {
Some(Cmd::Fetch { feed: feed.clone(), force: *force }) Some(Cmd::Fetch { feed: feed.clone(), force: *force, feeds: vec![] })
} }
Command::Reap { dry_run } => Some(Cmd::Reap { dry_run: *dry_run }), Command::Reap { dry_run } => Some(Cmd::Reap { dry_run: *dry_run }),
Command::Status => Some(Cmd::Status), Command::Status => Some(Cmd::Status),
@@ -354,10 +354,10 @@ async fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd) -> Result<()> {
async fn run(ctx: &Arc<Ctx>, cmd: Cmd) -> Result<()> { async fn run(ctx: &Arc<Ctx>, cmd: Cmd) -> Result<()> {
match cmd { match cmd {
Cmd::Fetch { feed, force } => { Cmd::Fetch { feed, force, feeds } => {
// Make room before pulling more down, as the original did per download. // Make room before pulling more down, as the original did per download.
reap(ctx, false, false).await?; reap(ctx, false, false).await?;
fetch(ctx, feed.as_deref(), force).await fetch(ctx, feed.as_deref(), force, &feeds).await
} }
Cmd::Reap { dry_run } => reap(ctx, dry_run, true).await, Cmd::Reap { dry_run } => reap(ctx, dry_run, true).await,
Cmd::Download { enclosure } => download_one(ctx, enclosure).await, Cmd::Download { enclosure } => download_one(ctx, enclosure).await,
@@ -510,7 +510,7 @@ async fn daemon(
} }
_ = ticker.tick() => { _ = ticker.tick() => {
// Per-feed schedule and TTL decide what actually gets polled. // Per-feed schedule and TTL decide what actually gets polled.
let job = run(&ctx, Cmd::Fetch { feed: None, force: false }); let job = run(&ctx, Cmd::Fetch { feed: None, force: false, feeds: vec![] });
if !until_stopped(&ctx, &rx_stop, job).await { if !until_stopped(&ctx, &rx_stop, job).await {
break; break;
} }
@@ -892,7 +892,8 @@ async fn reap(ctx: &Ctx, dry_run: bool, standalone: bool) -> Result<()> {
Ok(()) Ok(())
} }
async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool) -> Result<()> { /// `scope`, when not empty, narrows the scan to those feeds and the feeds inside any of them.
async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool, scope: &[String]) -> Result<()> {
let cfg = ctx.cfg(); let cfg = ctx.cfg();
let subs = subscriptions(ctx).await?; let subs = subscriptions(ctx).await?;
if let Some(id) = only if let Some(id) = only
@@ -903,7 +904,10 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool) -> Result<()> {
let mut scanned = 0; let mut scanned = 0;
let mut fresh: Vec<String> = vec![]; let mut fresh: Vec<String> = vec![];
for sub in subs.iter().filter(|s| only.is_none_or(|o| o == s.id)) { let in_scope = |s: &Sub| {
scope.is_empty() || scope.contains(&s.id) || s.cfg.group.as_ref().is_some_and(|g| scope.contains(g))
};
for sub in subs.iter().filter(|s| only.is_none_or(|o| o == s.id) && in_scope(s)) {
let (id, feed_cfg) = (&sub.id, &sub.cfg); let (id, feed_cfg) = (&sub.id, &sub.cfg);
let state = ctx.db.http_state(id).await?; let state = ctx.db.http_state(id).await?;

View File

@@ -1239,7 +1239,7 @@ async fn add_feed(
/// succeeded either way, so a daemon not taking commands is only logged. /// succeeded either way, so a daemon not taking commands is only logged.
async fn scan_soon(state: &WebState, feed: Option<String>) { async fn scan_soon(state: &WebState, feed: Option<String>) {
let force = feed.is_some(); let force = feed.is_some();
if state.cmds.send(Command::Fetch { feed, force }).await.is_err() { if state.cmds.send(Command::Fetch { feed, force, feeds: vec![] }).await.is_err() {
tracing::warn!("could not queue a scan: the daemon is not accepting commands"); tracing::warn!("could not queue a scan: the daemon is not accepting commands");
} }
} }
@@ -1527,11 +1527,21 @@ struct FetchBody {
async fn fetch_now( async fn fetch_now(
State(state): State<WebState>, State(state): State<WebState>,
user: crate::db::User,
Json(body): Json<FetchBody>, Json(body): Json<FetchBody>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
// "Check every feed" is every feed this person reads, not everyone's (issue #37).
let feeds = if body.feed.is_some() {
vec![]
} else {
state.ctx.db.subscriptions_for(user.id).await?.into_iter().map(|s| s.feed_id).collect()
};
if body.feed.is_none() && feeds.is_empty() {
return Ok(StatusCode::ACCEPTED); // nothing of theirs to check; an empty list would mean all
}
state state
.cmds .cmds
.send(Command::Fetch { feed: body.feed, force: body.force }) .send(Command::Fetch { feed: body.feed, force: body.force, feeds })
.await .await
.map_err(|_| anyhow::anyhow!("the daemon is not accepting commands"))?; .map_err(|_| anyhow::anyhow!("the daemon is not accepting commands"))?;
Ok(StatusCode::ACCEPTED) Ok(StatusCode::ACCEPTED)

View File

@@ -576,7 +576,7 @@ test('a second person has their own feeds and their own read state', async ({ br
await page.locator('#prefs').click(); await page.locator('#prefs').click();
await expect(page.locator('#modalCard')).toContainText('Subscriptions'); await expect(page.locator('#modalCard')).toContainText('Subscriptions');
await expect(page.locator('#modalCard')).toContainText('Only an admin changes this'); await expect(page.locator('#modalCard')).toContainText('Only an admin changes this');
await page.locator('#modalCard .cardacts .btn').first().click(); await page.locator('#modalCard .cardx').click();
await expect(page.locator('#admin')).toHaveCount(0); await expect(page.locator('#admin')).toHaveCount(0);
expect(await (await page.request.get('/')).text()).not.toContain('href=/admin'); expect(await (await page.request.get('/')).text()).not.toContain('href=/admin');
// Asking for it anyway goes back to the app, and its script is refused. // Asking for it anyway goes back to the app, and its script is refused.
@@ -1299,3 +1299,45 @@ test('a pinned feed, even one from inside a folder, goes to the top of the list'
await expect(page.locator('.feed.pinned')).toHaveCount(0); await expect(page.locator('.feed.pinned')).toHaveCount(0);
await expect(page.locator('.feed.child', { hasText: name })).toHaveCount(1); await expect(page.locator('.feed.child', { hasText: name })).toHaveCount(1);
}); });
test('a feed being checked shows a spinner on its row, and no toast', async ({ page }) => {
const row = page.locator('#feedlist .feed', { hasText: 'Test Show' });
await expect(row).toBeVisible();
await page.evaluate(() => setScanning('test-show', true));
await expect(row).toHaveClass(/\bscanning\b/);
await expect(row.locator('.badge'), 'the spinner stands in for the count').toBeHidden();
await page.evaluate(() => setScanning('test-show', false));
await expect(row).not.toHaveClass(/\bscanning\b/);
await expect(row.locator('.badge')).toBeVisible();
// Checking every feed says so on the rows, not in a toast (issue #37).
await page.locator('#scanAll').click();
await page.waitForTimeout(1500);
await expect(page.locator('.toast', { hasText: /Scanning|Checking| new/ })).toHaveCount(0);
});
test('"check every feed" checks only the feeds of the person asking', async ({ browser }) => {
// piper, made in an earlier test, subscribes to Test Show alone.
const ctx = await browser.newContext();
const piper = await ctx.newPage();
await piper.goto('/login');
await piper.locator('#name').fill('piper');
await piper.locator('#pw').fill('piperpassword');
await piper.locator('button[type=submit]').click();
await expect(piper.locator('#feedlist .feed', { hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
const mine = await piper.evaluate(() => S.feeds.map(f => f.id));
// Listen as the page does, then ask.
await piper.evaluate(() => {
window.started = []; window.done = false;
const es = new EventSource('/api/events');
es.onmessage = m => { const ev = JSON.parse(m.data);
if (ev.ev === 'feed_start') window.started.push(ev.feed);
if (ev.ev === 'scan_done') window.done = true; };
});
await piper.waitForTimeout(500);
await piper.locator('#scanAll').click();
await expect.poll(() => piper.evaluate(() => window.done), { timeout: 30_000 }).toBe(true);
const started = await piper.evaluate(() => window.started);
expect(started.length, 'something was checked').toBeGreaterThan(0);
for (const id of started) expect(mine, `${id} is not one of piper's feeds`).toContain(id);
await ctx.close();
});

View File

@@ -32,12 +32,12 @@
--line:#d6d6d6; /* #D6D6D6 device edge */ --line:#d6d6d6; /* #D6D6D6 device edge */
--fg:#1a1a1a; /* #1A1A1A icon outline */ --fg:#1a1a1a; /* #1A1A1A icon outline */
--dim:#606060; /* #606060 */ --dim:#606060; /* #606060 */
--faint:#767676; /* between the icon's #929292 and #606060, to clear AA */ --faint:#6b6b6b; /* between the icon's #929292 and #606060, to clear AA on the page ground */
--accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */ --accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */
--accent2:#9a5f0a; /* the EQ amber, taken down until white on it clears AA */ --accent2:#985e0a; /* the EQ amber, taken down until white on it clears AA */
--ink:#ffffff; --ink:#ffffff;
--good:#2f7d4f; --good:#2f7d4f;
--warn:#b06f10; --warn:#985e0a; /* the same amber; the lighter one failed AA as text */
--bad:#b3402f; --bad:#b3402f;
--shadow:0 8px 28px rgba(45,83,145,.14); --shadow:0 8px 28px rgba(45,83,145,.14);
} }
@@ -124,7 +124,7 @@
--dim:#c4c4c8; --dim:#c4c4c8;
--faint:#a0a0a7; --faint:#a0a0a7;
--accent:#81d0ff; --accent:#81d0ff;
--accent2:#3584e4; --accent2:#3685e4; /* a hair lighter so the badge's dark count clears AA */
--ink:#1d1d20; --ink:#1d1d20;
--good:#78e9ab; --good:#78e9ab;
--warn:#ffc252; --warn:#ffc252;
@@ -141,7 +141,7 @@
--dim:#57575d; --dim:#57575d;
--faint:#66666c; --faint:#66666c;
--accent:#0461be; --accent:#0461be;
--accent2:#3584e4; --accent2:#1c71d8; /* blue 4, a step down from the accent blue, so white on it clears AA */
--ink:#ffffff; --ink:#ffffff;
--good:#00753a; --good:#00753a;
--warn:#905400; --warn:#905400;
@@ -177,7 +177,7 @@
--dim:#5c616c; /* fg */ --dim:#5c616c; /* fg */
--faint:#686d78; --faint:#686d78;
--accent:#0060f0; /* link */ --accent:#0060f0; /* link */
--accent2:#fd7d00; --accent2:#bb5c00; /* the warning orange taken down until white on it clears AA */
--ink:#ffffff; --ink:#ffffff;
--good:#23794f; --good:#23794f;
--warn:#a85400; --warn:#a85400;
@@ -196,7 +196,7 @@
--dim:#3d3a33; --dim:#3d3a33;
--faint:#5a564c; --faint:#5a564c;
--accent:#1a5fae; --accent:#1a5fae;
--accent2:#b58900; --accent2:#957100; /* taken down until white on it clears AA */
--ink:#ffffff; --ink:#ffffff;
--good:#216609; --good:#216609;
--warn:#795a00; --warn:#795a00;
@@ -210,7 +210,7 @@
--panel:#3b4252; /* nord1 */ --panel:#3b4252; /* nord1 */
--panel2:#434c5e; /* nord2 */ --panel2:#434c5e; /* nord2 */
--raise:#4c566a; /* nord3 */ --raise:#4c566a; /* nord3 */
--line:#434c5e; --line:#4c566a; /* nord3: nord2 is --panel2, and borders on it vanished */
--fg:#eceff4; /* nord6 */ --fg:#eceff4; /* nord6 */
--dim:#d8dee9; /* nord4 */ --dim:#d8dee9; /* nord4 */
--faint:#b4bccb; --faint:#b4bccb;
@@ -232,7 +232,7 @@
--dim:#3b4252; /* nord1 */ --dim:#3b4252; /* nord1 */
--faint:#4c566a; /* nord3 */ --faint:#4c566a; /* nord3 */
--accent:#3a5e8a; --accent:#3a5e8a;
--accent2:#b0674e; --accent2:#ab644c;
--ink:#ffffff; --ink:#ffffff;
--good:#446632; --good:#446632;
--warn:#7e590e; --warn:#7e590e;
@@ -254,7 +254,7 @@
--accent2:#2a5db0; --accent2:#2a5db0;
--ink:#ffffff; --ink:#ffffff;
--good:#237a23; --good:#237a23;
--warn:#a15f00; --warn:#9b5b00;
--bad:#c42b1c; --bad:#c42b1c;
--shadow:0 4px 16px rgba(0,0,0,.28); --shadow:0 4px 16px rgba(0,0,0,.28);
} }
@@ -368,6 +368,13 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
} }
.chev:hover{color:var(--fg)} .chev:hover{color:var(--fg)}
.chev.bad,.chev.bad:hover{color:var(--bad)} .chev.bad,.chev.bad:hover{color:var(--bad)}
/* A feed being checked: a small spinner in place of its unread count (issue #37). */
.feed.scanning .badge{display:none}
.feed.scanning::after{
content:"";flex:none;width:12px;height:12px;margin:0 6px;border-radius:50%;
border:2px solid var(--line);border-top-color:var(--accent);animation:ipxspin .8s linear infinite;
}
@keyframes ipxspin{to{transform:rotate(360deg)}}
/* A feed's error mark, in the triangle's place: the same column as every folder's triangle, /* A feed's error mark, in the triangle's place: the same column as every folder's triangle,
a child's included, which is why it moves left by the child's indent. */ a child's included, which is why it moves left by the child's indent. */
.ferr{position:absolute;left:-16px;top:0;bottom:0;width:24px;display:grid;place-items:center;color:var(--bad)} .ferr{position:absolute;left:-16px;top:0;bottom:0;width:24px;display:grid;place-items:center;color:var(--bad)}
@@ -421,9 +428,11 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
.tile .txt b{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;overflow-wrap:anywhere;font-weight:500;font-size:13px;line-height:1.3} .tile .txt b{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;overflow-wrap:anywhere;font-weight:500;font-size:13px;line-height:1.3}
.tile .txt small{display:block;color:var(--faint);font-size:11.5px} .tile .txt small{display:block;color:var(--faint);font-size:11.5px}
.tile:hover .txt b{color:var(--accent)} .tile:hover .txt b{color:var(--accent)}
/* Outlined, not filled: on --raise the warning and error colours fell short of AA in most light
themes, where on the row's own ground they clear it. */
.tag{ .tag{
font-size:11px;font-weight:600; font-size:11px;font-weight:600;
padding:1px 5px;border-radius:4px;background:var(--raise);color:var(--warn);flex:none; padding:0 4px;border-radius:4px;border:1px solid;color:var(--warn);flex:none;
} }
.art{ .art{
border-radius:7px;object-fit:cover;background:var(--raise);flex:none; border-radius:7px;object-fit:cover;background:var(--raise);flex:none;
@@ -444,6 +453,8 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
font-size:11px;font-weight:600;flex:none;min-width:26px;text-align:center; font-size:11px;font-weight:600;flex:none;min-width:26px;text-align:center;
} }
.badge.zero{background:var(--raise);color:var(--faint)} .badge.zero{background:var(--raise);color:var(--faint)}
/* A selected row is --raise too, and the zero count's pill vanished into it. */
.sel .badge.zero{background:var(--bg)}
/* Counts, sizes and times that change in place should not jiggle the text around them. */ /* Counts, sizes and times that change in place should not jiggle the text around them. */
.badge,.feed small,.childrow small,.tile small,.ep,.fhead .sub,.dmeta,#status,#seekrow{font-variant-numeric:tabular-nums} .badge,.feed small,.childrow small,.tile small,.ep,.fhead .sub,.dmeta,#status,#seekrow{font-variant-numeric:tabular-nums}
@@ -690,6 +701,9 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
.logbar{display:flex;gap:8px;align-items:center;margin-bottom:9px;flex-wrap:wrap} .logbar{display:flex;gap:8px;align-items:center;margin-bottom:9px;flex-wrap:wrap}
.logbar .grow{flex:1;min-width:120px} .logbar .grow{flex:1;min-width:120px}
.card h3{margin:0 0 14px;font-size:17px} .card h3{margin:0 0 14px;font-size:17px}
/* A dialog with nothing to confirm closes from the corner, beside its title, not from a lone button
at the foot of a long card. */
.cardx{float:right;margin:-5px -6px 0 8px}
.field{display:grid;gap:4px;margin-bottom:12px} .field{display:grid;gap:4px;margin-bottom:12px}
.field label{font-size:12px;color:var(--dim)} .field label{font-size:12px;color:var(--dim)}
.field .hint{font-size:11.5px;color:var(--faint)} .field .hint{font-size:11.5px;color:var(--faint)}
@@ -838,7 +852,8 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
:root[data-theme="classic"] .ep.sel .st, :root[data-theme="classic"] .ep.sel .st,
:root[data-theme="classic"] .ep.sel .fl, :root[data-theme="classic"] .ep.sel .fl,
:root[data-theme="classic"] .ep.sel .file, :root[data-theme="classic"] .ep.sel .file,
:root[data-theme="classic"] .ep.sel .kind{color:#fff} :root[data-theme="classic"] .ep.sel .kind,
:root[data-theme="classic"] .ep.sel .iconbtn:not(:hover){color:#fff}
/* Green and red stay green and red on the blue, just lighter so they read. */ /* Green and red stay green and red on the blue, just lighter so they read. */
:root[data-theme="classic"] .ep.sel .kind.here{color:#a6f3a6} :root[data-theme="classic"] .ep.sel .kind.here{color:#a6f3a6}
:root[data-theme="classic"] .ep.sel .kind.bad{color:#ffb8ad} :root[data-theme="classic"] .ep.sel .kind.bad{color:#ffb8ad}

View File

@@ -1,3 +1,8 @@
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light">
<title>Sign in — iPodderX</title> <title>Sign in — iPodderX</title>
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png"> <link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png"> <link rel="apple-touch-icon" href="/apple-touch-icon.png">
@@ -22,7 +27,8 @@
--shadow:0 8px 28px rgba(6,10,16,.55); --shadow:0 8px 28px rgba(6,10,16,.55);
--r:10px; --r:10px;
} }
:root[data-theme="light"] { /* Signed out, there is no account to take a theme from, so this follows the system. */
@media (prefers-color-scheme:light){:root {
--bg:#f2f4f7; --bg:#f2f4f7;
--panel:#ffffff; /* #FFFFFF device body */ --panel:#ffffff; /* #FFFFFF device body */
--panel2:#e9edf3; --panel2:#e9edf3;
@@ -30,15 +36,15 @@
--line:#d6d6d6; /* #D6D6D6 device edge */ --line:#d6d6d6; /* #D6D6D6 device edge */
--fg:#1a1a1a; /* #1A1A1A icon outline */ --fg:#1a1a1a; /* #1A1A1A icon outline */
--dim:#606060; /* #606060 */ --dim:#606060; /* #606060 */
--faint:#767676; /* between the icon's #929292 and #606060, to clear AA */ --faint:#6b6b6b;
--accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */ --accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */
--accent2:#b06f10; --accent2:#985e0a;
--ink:#ffffff; --ink:#ffffff;
--good:#2f7d4f; --good:#2f7d4f;
--warn:#b06f10; --warn:#985e0a;
--bad:#b3402f; --bad:#b3402f;
--shadow:0 8px 28px rgba(45,83,145,.14); --shadow:0 8px 28px rgba(45,83,145,.14);
} }}
*{box-sizing:border-box} *{box-sizing:border-box}
html,body{height:100%} html,body{height:100%}
body{ body{

View File

@@ -218,7 +218,7 @@ function due(ts){
async function prefsModal(){ async function prefsModal(){
const g = await api('/api/settings'); const g = await api('/api/settings');
const admin = !!(S.me&&S.me.admin); const admin = !!(S.me&&S.me.admin);
openModal(`<h3>Settings</h3> openModal(`<button class="iconbtn cardx" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button><h3>Settings</h3>
<div class="field"><label>Theme</label> <div class="field"><label>Theme</label>
<select id="stheme">${Object.entries(THEMES).map(([k,t])=> <select id="stheme">${Object.entries(THEMES).map(([k,t])=>
`<option value="${k}"${theme.name===k?' selected':''}>${esc(t.name)}</option>`).join('')}</select></div> `<option value="${k}"${theme.name===k?' selected':''}>${esc(t.name)}</option>`).join('')}</select></div>
@@ -238,8 +238,7 @@ async function prefsModal(){
<span class="hint">${everyText(g.every_mins)}, for every feed that does not set its own. <span class="hint">${everyText(g.every_mins)}, for every feed that does not set its own.
${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></div> ${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></div>
<div class="field"><label>Download folder</label> <div class="field"><label>Download folder</label>
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div> <span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>`);
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button></div>`);
$('#stheme').onchange=e=>setTheme(e.target.value,undefined,true); $('#stheme').onchange=e=>setTheme(e.target.value,undefined,true);
$('#smode').onchange=e=>setTheme(undefined,e.target.value,true); $('#smode').onchange=e=>setTheme(undefined,e.target.value,true);
$('#gopml').onclick=opmlModal; $('#gopml').onclick=opmlModal;
@@ -371,7 +370,8 @@ function opmlModal(){
}; };
} }
async function scanAll(){ toast('Scanning all feeds…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); } // No toast: the spinners on the rows being checked say it (issue #37).
async function scanAll(){ await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); }
$('#scanAll').onclick=scanAll; $('#scanAll').onclick=scanAll;
$('#prefs').onclick=prefsModal; $('#prefs').onclick=prefsModal;
// Someone the proxy signed in is signed out by the proxy: ipx's own sign-out cannot stick while // Someone the proxy signed in is signed out by the proxy: ipx's own sign-out cannot stick while

View File

@@ -5,12 +5,8 @@ function connect(){
const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; }; const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
const refreshFeeds=soon(()=>loadFeeds(true)); const refreshFeeds=soon(()=>loadFeeds(true));
const refreshEntries=soon(()=>{ if(S.feed) loadEntries(); }); const refreshEntries=soon(()=>{ if(S.feed) loadEntries(); });
let fresh={}; // Every scan's events reach everyone; only this person's feeds are theirs to show or refresh.
const tellNew=soon(()=>{ const mine=id=>S.feeds.some(f=>f.id===id);
const feeds=Object.keys(fresh), n=feeds.reduce((a,k)=>a+fresh[k],0);
if(n) toast(feeds.length===1 ? `${feeds[0]}: ${n} new` : `${n} new in ${feeds.length} feeds`);
fresh={};
},900);
sse.onmessage=m=>{ sse.onmessage=m=>{
let ev; try{ ev=JSON.parse(m.data) }catch{ return } let ev; try{ ev=JSON.parse(m.data) }catch{ return }
if(ev.ev==='progress'){ if(ev.ev==='progress'){
@@ -23,22 +19,28 @@ function connect(){
} }
else if(ev.ev==='download_done'){ else if(ev.ev==='download_done'){
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`); const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
if(bar) bar.classList.remove('live'); // Said only for a file on screen, as one downloaded by hand is: the scheduled downloads of
toast('Downloaded '+ev.path.split('/').pop()); refreshEntries(); refreshFeeds(); // everyone's feeds used to announce themselves to everyone.
if(bar){ bar.classList.remove('live'); toast('Downloaded '+ev.path.split('/').pop()); }
refreshEntries(); refreshFeeds();
} }
else if(ev.ev==='download_error'){ else if(ev.ev==='download_error'){
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`); const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
if(bar) bar.classList.remove('live'); if(bar) bar.classList.remove('live');
toast('Download failed: '+ev.msg,true); refreshEntries(); toast('Download failed: '+ev.msg,true); refreshEntries();
} }
// A spinner on the feed's row while it is checked, in place of a toast per feed (issue #37).
else if(ev.ev==='feed_start') setScanning(ev.feed,true);
else if(ev.ev==='feed_skip') setScanning(ev.feed,false);
else if(ev.ev==='feed_done'){ else if(ev.ev==='feed_done'){
if(ev.new){ fresh[ev.feed]=(fresh[ev.feed]||0)+ev.new; tellNew(); } setScanning(ev.feed,false);
if(!mine(ev.feed)) return;
refreshFeeds(); if(ev.feed===S.feed||S.feed===':all') refreshEntries(); refreshFeeds(); if(ev.feed===S.feed||S.feed===':all') refreshEntries();
} }
// No toast: a scan of every feed raised one per failure, to everyone. The feed list's // No toast: a scan of every feed raised one per failure, to everyone. The feed list's
// red ! marks the feed instead, and its page says why. // red ! marks the feed instead, and its page says why.
else if(ev.ev==='feed_error') refreshFeeds(); else if(ev.ev==='feed_error'){ setScanning(ev.feed,false); if(mine(ev.feed)) refreshFeeds(); }
else if(ev.ev==='scan_done'){ refreshFeeds(); refreshEntries(); } else if(ev.ev==='scan_done'){ scanning.clear(); paintScanning(); refreshFeeds(); refreshEntries(); }
}; };
sse.onerror=()=>{ sse.close(); setTimeout(connect,4000); }; sse.onerror=()=>{ sse.close(); setTimeout(connect,4000); };
} }

View File

@@ -156,7 +156,7 @@ function renderGroup(f,kids){
} }
async function feedAction(a,f){ async function feedAction(a,f){
if(a==='scan'){ toast('Scanning '+(f.title||f.id)+'…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); } if(a==='scan'){ await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); }
if(a==='read'){ const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/read-all`,{method:'POST'}); toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries(); } if(a==='read'){ const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/read-all`,{method:'POST'}); toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries(); }
if(a==='rm') removeFeed(f); if(a==='rm') removeFeed(f);
if(a==='pin'){ if(a==='pin'){

View File

@@ -25,6 +25,22 @@ const VIEWS={
blurb:'Episodes you started and have not finished, across every feed you subscribe to. Pick one up where you left off.'}, blurb:'Episodes you started and have not finished, across every feed you subscribe to. Pick one up where you left off.'},
':all':{title:'All Subscriptions',icon:ICON.all}, ':all':{title:'All Subscriptions',icon:ICON.all},
}; };
/// Feeds being checked right now, from the event stream: their rows, and the row of a folder
/// holding one, carry a spinner.
const scanning=new Set<string>();
function setScanning(id: string, on: boolean){
if(on) scanning.add(id); else scanning.delete(id);
paintScanning();
}
function paintScanning(){
for(const row of $$('#feedlist .feed')){
const id=row.dataset.id;
const on=scanning.has(id)||S.feeds.some(c=>c.group===id&&scanning.has(c.id));
row.classList.toggle('scanning',on);
if(on) row.title='Checking for new items…'; else row.removeAttribute('title');
}
}
function renderFeeds(){ function renderFeeds(){
const q=$('#feedFilter').value.trim().toLowerCase(); const q=$('#feedFilter').value.trim().toLowerCase();
const list=$('#feedlist'); const top=list.scrollTop; const list=$('#feedlist'); const top=list.scrollTop;
@@ -104,6 +120,7 @@ function renderFeeds(){
if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); }; if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); };
list.appendChild(el); list.appendChild(el);
} }
paintScanning();
done(); done();
} }
function selectFeed(id){ function selectFeed(id){

View File

@@ -34,7 +34,6 @@ function pullShow(dy: number){
function refreshFeed(){ function refreshFeed(){
const f = S.feeds.find(x => x.id === S.feed); const f = S.feeds.find(x => x.id === S.feed);
if(!f && S.feed !== ':all') return; if(!f && S.feed !== ':all') return;
toast(f ? `Checking ${f.title || f.id} for new items…` : 'Checking every feed for new items…');
// New items arrive by the event stream when the scan finishes, as they do for a button press. // New items arrive by the event stream when the scan finishes, as they do for a button press.
api('/api/fetch', {method: 'POST', body: JSON.stringify(f ? {feed: f.id, force: true} : {force: true})}) api('/api/fetch', {method: 'POST', body: JSON.stringify(f ? {feed: f.id, force: true} : {force: true})})
.catch(e => toast(e.message, true)); .catch(e => toast(e.message, true));

View File

@@ -191,8 +191,7 @@ function keysModal(){
['Anywhere'], ['Anywhere'],
[k('?'),'This list'],[k('Esc'),'Close a dialog'], [k('?'),'This list'],[k('Esc'),'Close a dialog'],
]; ];
openModal(`<h3>Keyboard shortcuts</h3><table class="keys">${rows.map(([a,b])=>b===undefined openModal(`<button class="iconbtn cardx" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button><h3>Keyboard shortcuts</h3><table class="keys">${rows.map(([a,b])=>b===undefined
?`<tr><th colspan="2">${a}</th></tr>`:`<tr><td>${a}</td><td>${b}</td></tr>`).join('')}</table> ?`<tr><th colspan="2">${a}</th></tr>`:`<tr><td>${a}</td><td>${b}</td></tr>`).join('')}</table>`);
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button></div>`);
} }

View File

@@ -6,7 +6,7 @@
// the system, so no palette is written twice. // the system, so no palette is written twice.
const THEMES: Record<string, {name: string, modes: boolean}> = { const THEMES: Record<string, {name: string, modes: boolean}> = {
modern: {name: 'Modern', modes: true}, modern: {name: 'Modern', modes: true},
classic: {name: 'Classic, the 2004 Mac app', modes: false}, classic: {name: 'Classic', modes: false},
dracula: {name: 'Dracula', modes: true}, dracula: {name: 'Dracula', modes: true},
material: {name: 'Material', modes: true}, material: {name: 'Material', modes: true},
adwaita: {name: 'Adwaita', modes: true}, adwaita: {name: 'Adwaita', modes: true},

View File

@@ -26,7 +26,6 @@ const ICON={
trash:fa('0 0 448 512','<path fill="currentColor" d="M136.7 5.9C141.1-7.2 153.3-16 167.1-16l113.9 0c13.8 0 26 8.8 30.4 21.9L320 32 416 32c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 96C14.3 96 0 81.7 0 64S14.3 32 32 32l96 0 8.7-26.1zM32 144l384 0 0 304c0 35.3-28.7 64-64 64L96 512c-35.3 0-64-28.7-64-64l0-304zm88 64c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24z"/>'), // solid/trash-can trash:fa('0 0 448 512','<path fill="currentColor" d="M136.7 5.9C141.1-7.2 153.3-16 167.1-16l113.9 0c13.8 0 26 8.8 30.4 21.9L320 32 416 32c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 96C14.3 96 0 81.7 0 64S14.3 32 32 32l96 0 8.7-26.1zM32 144l384 0 0 304c0 35.3-28.7 64-64 64L96 512c-35.3 0-64-28.7-64-64l0-304zm88 64c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24z"/>'), // solid/trash-can
open:fa('0 0 512 512','<path fill="currentColor" d="M320 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l82.7 0-201.4 201.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L448 109.3 448 192c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160c0-17.7-14.3-32-32-32L320 0zM80 96C35.8 96 0 131.8 0 176L0 432c0 44.2 35.8 80 80 80l256 0c44.2 0 80-35.8 80-80l0-80c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 80c0 8.8-7.2 16-16 16L80 448c-8.8 0-16-7.2-16-16l0-256c0-8.8 7.2-16 16-16l80 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L80 96z"/>'), // solid/arrow-up-right-from-square open:fa('0 0 512 512','<path fill="currentColor" d="M320 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l82.7 0-201.4 201.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L448 109.3 448 192c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160c0-17.7-14.3-32-32-32L320 0zM80 96C35.8 96 0 131.8 0 176L0 432c0 44.2 35.8 80 80 80l256 0c44.2 0 80-35.8 80-80l0-80c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 80c0 8.8-7.2 16-16 16L80 448c-8.8 0-16-7.2-16-16l0-256c0-8.8 7.2-16 16-16l80 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L80 96z"/>'), // solid/arrow-up-right-from-square
settings:fa('0 0 512 512','<path fill="currentColor" d="M195.1 9.5C198.1-5.3 211.2-16 226.4-16l59.8 0c15.2 0 28.3 10.7 31.3 25.5L332 79.5c14.1 6 27.3 13.7 39.3 22.8l67.8-22.5c14.4-4.8 30.2 1.2 37.8 14.4l29.9 51.8c7.6 13.2 4.9 29.8-6.5 39.9L447 233.3c.9 7.4 1.3 15 1.3 22.7s-.5 15.3-1.3 22.7l53.4 47.5c11.4 10.1 14 26.8 6.5 39.9l-29.9 51.8c-7.6 13.1-23.4 19.2-37.8 14.4l-67.8-22.5c-12.1 9.1-25.3 16.7-39.3 22.8l-14.4 69.9c-3.1 14.9-16.2 25.5-31.3 25.5l-59.8 0c-15.2 0-28.3-10.7-31.3-25.5l-14.4-69.9c-14.1-6-27.2-13.7-39.3-22.8L73.5 432.3c-14.4 4.8-30.2-1.2-37.8-14.4L5.8 366.1c-7.6-13.2-4.9-29.8 6.5-39.9l53.4-47.5c-.9-7.4-1.3-15-1.3-22.7s.5-15.3 1.3-22.7L12.3 185.8c-11.4-10.1-14-26.8-6.5-39.9L35.7 94.1c7.6-13.2 23.4-19.2 37.8-14.4l67.8 22.5c12.1-9.1 25.3-16.7 39.3-22.8L195.1 9.5zM256.3 336a80 80 0 1 0 -.6-160 80 80 0 1 0 .6 160z"/>'), // solid/gear settings:fa('0 0 512 512','<path fill="currentColor" d="M195.1 9.5C198.1-5.3 211.2-16 226.4-16l59.8 0c15.2 0 28.3 10.7 31.3 25.5L332 79.5c14.1 6 27.3 13.7 39.3 22.8l67.8-22.5c14.4-4.8 30.2 1.2 37.8 14.4l29.9 51.8c7.6 13.2 4.9 29.8-6.5 39.9L447 233.3c.9 7.4 1.3 15 1.3 22.7s-.5 15.3-1.3 22.7l53.4 47.5c11.4 10.1 14 26.8 6.5 39.9l-29.9 51.8c-7.6 13.1-23.4 19.2-37.8 14.4l-67.8-22.5c-12.1 9.1-25.3 16.7-39.3 22.8l-14.4 69.9c-3.1 14.9-16.2 25.5-31.3 25.5l-59.8 0c-15.2 0-28.3-10.7-31.3-25.5l-14.4-69.9c-14.1-6-27.2-13.7-39.3-22.8L73.5 432.3c-14.4 4.8-30.2-1.2-37.8-14.4L5.8 366.1c-7.6-13.2-4.9-29.8 6.5-39.9l53.4-47.5c-.9-7.4-1.3-15-1.3-22.7s.5-15.3 1.3-22.7L12.3 185.8c-11.4-10.1-14-26.8-6.5-39.9L35.7 94.1c7.6-13.2 23.4-19.2 37.8-14.4l67.8 22.5c12.1-9.1 25.3-16.7 39.3-22.8L195.1 9.5zM256.3 336a80 80 0 1 0 -.6-160 80 80 0 1 0 .6 160z"/>'), // solid/gear
log:fa('0 0 384 512','<path fill="currentColor" d="M0 64C0 28.7 28.7 0 64 0L213.5 0c17 0 33.3 6.7 45.3 18.7L365.3 125.3c12 12 18.7 28.3 18.7 45.3L384 448c0 35.3-28.7 64-64 64L64 512c-35.3 0-64-28.7-64-64L0 64zm208-5.5l0 93.5c0 13.3 10.7 24 24 24L325.5 176 208 58.5zM120 256c-13.3 0-24 10.7-24 24s10.7 24 24 24l144 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-144 0zm0 96c-13.3 0-24 10.7-24 24s10.7 24 24 24l144 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-144 0z"/>'), // solid/file-lines
close:fa('0 0 384 512','<path fill="currentColor" d="M55.1 73.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L147.2 256 9.9 393.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192.5 301.3 329.9 438.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.8 256 375.1 118.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192.5 210.7 55.1 73.4z"/>'), // solid/xmark close:fa('0 0 384 512','<path fill="currentColor" d="M55.1 73.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L147.2 256 9.9 393.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192.5 301.3 329.9 438.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.8 256 375.1 118.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192.5 210.7 55.1 73.4z"/>'), // solid/xmark
menu:fa('0 0 448 512','<path fill="currentColor" d="M0 96C0 78.3 14.3 64 32 64l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 128C14.3 128 0 113.7 0 96zM0 256c0-17.7 14.3-32 32-32l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 288c-17.7 0-32-14.3-32-32zM448 416c0 17.7-14.3 32-32 32L32 448c-17.7 0-32-14.3-32-32s14.3-32 32-32l384 0c17.7 0 32 14.3 32 32z"/>'), // solid/bars menu:fa('0 0 448 512','<path fill="currentColor" d="M0 96C0 78.3 14.3 64 32 64l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 128C14.3 128 0 113.7 0 96zM0 256c0-17.7 14.3-32 32-32l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 288c-17.7 0-32-14.3-32-32zM448 416c0 17.7-14.3 32-32 32L32 448c-17.7 0-32-14.3-32-32s14.3-32 32-32l384 0c17.7 0 32 14.3 32 32z"/>'), // solid/bars
directory:fa('0 0 448 512','<path fill="currentColor" d="M0 96C0 60.7 28.7 32 64 32l320 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zm64 0l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64zM64 224l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64zM64 352l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64z"/>'), // solid/table-list directory:fa('0 0 448 512','<path fill="currentColor" d="M0 96C0 60.7 28.7 32 64 32l320 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zm64 0l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64zM64 224l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64zM64 352l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64z"/>'), // solid/table-list
@@ -40,7 +39,6 @@ const ICON={
torrent:fa('0 0 448 512','<path fill="currentColor" d="M0 176L0 288C0 411.7 100.3 512 224 512S448 411.7 448 288l0-112-128 0 0 112c0 53-43 96-96 96s-96-43-96-96l0-112-128 0zm0-48l128 0 0-64c0-17.7-14.3-32-32-32L32 32C14.3 32 0 46.3 0 64l0 64zm320 0l128 0 0-64c0-17.7-14.3-32-32-32l-64 0c-17.7 0-32 14.3-32 32l0 64z"/>'), // solid/magnet torrent:fa('0 0 448 512','<path fill="currentColor" d="M0 176L0 288C0 411.7 100.3 512 224 512S448 411.7 448 288l0-112-128 0 0 112c0 53-43 96-96 96s-96-43-96-96l0-112-128 0zm0-48l128 0 0-64c0-17.7-14.3-32-32-32L32 32C14.3 32 0 46.3 0 64l0 64zm320 0l128 0 0-64c0-17.7-14.3-32-32-32l-64 0c-17.7 0-32 14.3-32 32l0 64z"/>'), // solid/magnet
file:fa('0 0 384 512','<path fill="currentColor" d="M64 0C28.7 0 0 28.7 0 64L0 448c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-277.5c0-17-6.7-33.3-18.7-45.3L258.7 18.7C246.7 6.7 230.5 0 213.5 0L64 0zM325.5 176L232 176c-13.3 0-24-10.7-24-24L208 58.5 325.5 176z"/>'), // solid/file file:fa('0 0 384 512','<path fill="currentColor" d="M64 0C28.7 0 0 28.7 0 64L0 448c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-277.5c0-17-6.7-33.3-18.7-45.3L258.7 18.7C246.7 6.7 230.5 0 213.5 0L64 0zM325.5 176L232 176c-13.3 0-24-10.7-24-24L208 58.5 325.5 176z"/>'), // solid/file
copy:fa('0 0 448 512','<path fill="currentColor" d="M192 0c-35.3 0-64 28.7-64 64l0 256c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-200.6c0-17.4-7.1-34.1-19.7-46.2L370.6 17.8C358.7 6.4 342.8 0 326.3 0L192 0zM64 128c-35.3 0-64 28.7-64 64L0 448c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-16-64 0 0 16-192 0 0-256 16 0 0-64-16 0z"/>'), // solid/copy copy:fa('0 0 448 512','<path fill="currentColor" d="M192 0c-35.3 0-64 28.7-64 64l0 256c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-200.6c0-17.4-7.1-34.1-19.7-46.2L370.6 17.8C358.7 6.4 342.8 0 326.3 0L192 0zM64 128c-35.3 0-64 28.7-64 64L0 448c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-16-64 0 0 16-192 0 0-256 16 0 0-64-16 0z"/>'), // solid/copy
users:fa('0 0 640 512','<path fill="currentColor" d="M320 16a104 104 0 1 1 0 208 104 104 0 1 1 0-208zM96 88a72 72 0 1 1 0 144 72 72 0 1 1 0-144zM0 416c0-70.7 57.3-128 128-128 12.8 0 25.2 1.9 36.9 5.4-32.9 36.8-52.9 85.4-52.9 138.6l0 16c0 11.4 2.4 22.2 6.7 32L32 480c-17.7 0-32-14.3-32-32l0-32zm521.3 64c4.3-9.8 6.7-20.6 6.7-32l0-16c0-53.2-20-101.8-52.9-138.6 11.7-3.5 24.1-5.4 36.9-5.4 70.7 0 128 57.3 128 128l0 32c0 17.7-14.3 32-32 32l-86.7 0zM472 160a72 72 0 1 1 144 0 72 72 0 1 1 -144 0zM160 432c0-88.4 71.6-160 160-160s160 71.6 160 160l0 16c0 17.7-14.3 32-32 32l-256 0c-17.7 0-32-14.3-32-32l0-16z"/>'), // solid/users
signout:fa('0 0 512 512','<path fill="currentColor" d="M505 273c9.4-9.4 9.4-24.6 0-33.9L361 95c-6.9-6.9-17.2-8.9-26.2-5.2S320 102.3 320 112l0 80-112 0c-26.5 0-48 21.5-48 48l0 32c0 26.5 21.5 48 48 48l112 0 0 80c0 9.7 5.8 18.5 14.8 22.2s19.3 1.7 26.2-5.2L505 273zM160 96c17.7 0 32-14.3 32-32s-14.3-32-32-32L96 32C43 32 0 75 0 128L0 384c0 53 43 96 96 96l64 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-64 0c-17.7 0-32-14.3-32-32l0-256c0-17.7 14.3-32 32-32l64 0z"/>'), // solid/right-from-bracket signout:fa('0 0 512 512','<path fill="currentColor" d="M505 273c9.4-9.4 9.4-24.6 0-33.9L361 95c-6.9-6.9-17.2-8.9-26.2-5.2S320 102.3 320 112l0 80-112 0c-26.5 0-48 21.5-48 48l0 32c0 26.5 21.5 48 48 48l112 0 0 80c0 9.7 5.8 18.5 14.8 22.2s19.3 1.7 26.2-5.2L505 273zM160 96c17.7 0 32-14.3 32-32s-14.3-32-32-32L96 32C43 32 0 75 0 128L0 384c0 53 43 96 96 96l64 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-64 0c-17.7 0-32-14.3-32-32l0-256c0-17.7 14.3-32 32-32l64 0z"/>'), // solid/right-from-bracket
back:fa('0 0 512 512','<path fill="currentColor" d="M24 192l144 0c9.7 0 18.5-5.8 22.2-14.8s1.7-19.3-5.2-26.2l-46.7-46.7c75.3-58.6 184.3-53.3 253.5 15.9 75 75 75 196.5 0 271.5s-196.5 75-271.5 0c-10.2-10.2-19-21.3-26.4-33-9.5-14.9-29.3-19.3-44.2-9.8s-19.3 29.3-9.8 44.2C49.7 408.7 61.4 423.5 75 437 175 537 337 537 437 437S537 175 437 75C342.8-19.3 193.3-24.7 92.7 58.8L41 7C34.1 .2 23.8-1.9 14.8 1.8S0 14.3 0 24L0 168c0 13.3 10.7 24 24 24z"/>'), // solid/rotate-left back:fa('0 0 512 512','<path fill="currentColor" d="M24 192l144 0c9.7 0 18.5-5.8 22.2-14.8s1.7-19.3-5.2-26.2l-46.7-46.7c75.3-58.6 184.3-53.3 253.5 15.9 75 75 75 196.5 0 271.5s-196.5 75-271.5 0c-10.2-10.2-19-21.3-26.4-33-9.5-14.9-29.3-19.3-44.2-9.8s-19.3 29.3-9.8 44.2C49.7 408.7 61.4 423.5 75 437 175 537 337 537 437 437S537 175 437 75C342.8-19.3 193.3-24.7 92.7 58.8L41 7C34.1 .2 23.8-1.9 14.8 1.8S0 14.3 0 24L0 168c0 13.3 10.7 24 24 24z"/>'), // solid/rotate-left
fwd:fa('0 0 512 512','<path fill="currentColor" d="M488 192l-144 0c-9.7 0-18.5-5.8-22.2-14.8s-1.7-19.3 5.2-26.2l46.7-46.7c-75.3-58.6-184.3-53.3-253.5 15.9-75 75-75 196.5 0 271.5s196.5 75 271.5 0c8.2-8.2 15.5-16.9 21.9-26.1 10.1-14.5 30.1-18 44.6-7.9s18 30.1 7.9 44.6c-8.5 12.2-18.2 23.8-29.1 34.7-100 100-262.1 100-362 0S-25 175 75 75c94.3-94.3 243.7-99.6 344.3-16.2L471 7c6.9-6.9 17.2-8.9 26.2-5.2S512 14.3 512 24l0 144c0 13.3-10.7 24-24 24z"/>'), // solid/rotate-right fwd:fa('0 0 512 512','<path fill="currentColor" d="M488 192l-144 0c-9.7 0-18.5-5.8-22.2-14.8s-1.7-19.3 5.2-26.2l46.7-46.7c-75.3-58.6-184.3-53.3-253.5 15.9-75 75-75 196.5 0 271.5s196.5 75 271.5 0c8.2-8.2 15.5-16.9 21.9-26.1 10.1-14.5 30.1-18 44.6-7.9s18 30.1 7.9 44.6c-8.5 12.2-18.2 23.8-29.1 34.7-100 100-262.1 100-362 0S-25 175 75 75c94.3-94.3 243.7-99.6 344.3-16.2L471 7c6.9-6.9 17.2-8.9 26.2-5.2S512 14.3 512 24l0 144c0 13.3-10.7 24-24 24z"/>'), // solid/rotate-right