18 Commits

Author SHA1 Message Date
a465fa8471 Release 0.7.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:32:10 +00:00
aeb686163b A separate admin page: server settings, accounts and the log
/admin, with Server, Accounts and Log sections chosen by the URL's hash. The
server sends the page and /admin.js to admins only (anyone else asking for the
page goes back to the app, and the script is 403), and removes the header's link
to it from everyone else's page rather than hiding it. The API keeps refusing
all of it to non-admins as before.

Settings becomes personal: theme, OPML import and export, and the schedule and
download folder to read. The server fields, the Users dialog and the Log dialog
move out of dialogs.ts into admin.ts.

The CSS moves out of index.html into web/app.css, which both pages load as
/app.css?v=<hash>, served immutable like the scripts. The smoke test checks both
pages.

Closes #19.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:28:28 +00:00
2d158a4540 Pin a feed to the top of the feed list
subscriptions.pinned, per person, set by PATCH /api/feeds/{id} {pinned} and
returned as FeedRow.pinned. Kept out of Sub, which the scanner merges into its
policy; set_subscription names its columns, so saving a feed's settings leaves
the pin alone (tested).

Pinned feeds come first in the list, a pin before the name and a rule under the
block: a pinned folder with its feeds under it, a feed from inside one lifted out
of it. The pin button is on both the feed and the folder page.

Closes #33.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:14:47 +00:00
fc425bffa6 Play/pause test: play without decoding the fixture
The fixture file does not reliably decode in the test browser; the load error
paused the player, which rightly turned the buttons back to play, and the test
failed in the full run. The test now fakes play and pause, events included, so it
checks what the buttons do and nothing else. The previous commit went up with
this test failing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:09:26 +00:00
42a1136e2e Every play button for what is playing shows pause, and pauses it
Only the player bar's button changed; the files pane's, the row's and the
toolbar's kept showing play while it played. play() now pauses when asked to play
what is already playing, which makes each of them a toggle, and syncPlayButtons()
repaints them on play, pause and ended and whenever the list or reader is drawn.

Closes #34.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:01:03 +00:00
53341264a7 On a phone, no "No files" box above an item that has none
The files sit over the text on a phone, so an item without any showed a box
saying so before its text. Nothing is shown now; the desktop files pane already
hid itself when empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 14:50:36 +00:00
9c16408d04 Keep the theme on the account, not in the browser
- users.theme and users.theme_mode, added by migrate(); GET /api/me returns them
  and PATCH /api/me saves them, refusing anything but a plain name and
  light/dark/auto, since index() writes them into the page's <html> tag.
- The page arrives with data-theme and data-choice already on <html> (and
  data-mode unless Auto), so it is drawn in the account's theme from the start.
- A theme a browser kept in localStorage goes up to the account once, the first
  time an account with none loads the page.
- Saves go one at a time, each with the choice as it stands: sent all at once, a
  quick run through the list could land out of order and keep a theme passed on
  the way. The browser test caught it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 14:33:59 +00:00
9b2537761f Ask for a post's images without a referrer
jeffgeerling.com answers 403 to an image request whose Referer is another
site, so his posts showed a broken image on iOS and the alt text on desktop.
The sanitiser now gives every <img> referrerpolicy="no-referrer".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 14:23:48 +00:00
3b00e2721a Touch gestures: pull to check for new items, swipe between items
- Pull the item list down from its top: checks the feed (or every feed, on All
  Subscriptions) for new items, which arrive as they do from the scan button.
  overscroll-behavior keeps the browser's own pull-to-reload out of it.
- Swipe the item you are reading left for the next, right for the one before,
  or back to the list from the first. A vertical move is a scroll; something
  that scrolls sideways, or takes typing, keeps its own swipe.

Touch events only, so a mouse never sets them off.

Closes #22.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 14:13:28 +00:00
fc09e8a6b7 Favicon, level file icons, and a feed error mark in the triangle's column
- The logo as favicon, squared up (it is 128x121), at /favicon.png and at
  /favicon.ico outside the auth layer, where a browser asking on its own got a
  401; an apple-touch-icon on white (#32).
- An item not yet downloaded had its download bar on a line of its own under the
  file icon, lifting the icon above its row's; the bar now sits under it without
  taking space (#31).
- A feed error is Font Awesome's exclamation, hung in the margin where a folder's
  triangle is, in the same column; a folder holding a failing feed has its
  triangle turn red.

Closes #31, #32.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 14:09:38 +00:00
5483355021 Serve the script as /app.js, cached until a deploy changes it
The page loaded its script inline. It now names /app.js?v=<hash> (login.js for
the sign-in page), the hash of the script's contents: the script is served
immutable for a year and the page no-cache, so a browser fetches the script
again only when a deploy changes it and so its name.

Also fixes a race in the mark-everything-read test: it waited on a badge that
was seldom 0 to begin with, so a mark-unread still in flight could land after
the read-all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 13:03:25 +00:00
e2969bcee8 Themes: Dracula, Material, Adwaita, Flat Remix, Paper, Nordic, each light, dark or Auto
The theme picker lists Modern (the old Dark and Light), Classic and six new
palettes, from Dracula's spec (with Alucard), Material 3's baseline scheme,
libadwaita's CSS variables, Flat Remix's _colors.scss, Paper and Nord. A second
setting picks Light, Dark or Auto where a theme has both; Classic and Paper do
not, so it is hidden for them.

The page gets data-mode, light or dark, and Auto is worked out in theme.ts from
the system, so each palette is written once instead of again under a media
query. Every new palette clears WCAG AA for text on its backgrounds. An old
ipx.theme of dark, light or auto carries over as Modern.

Closes #27.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 12:53:25 +00:00
26802d2b23 The page's script is TypeScript in web/src, built and minified with swc
- web/src/*.ts: the script that was inline in index.html and login.html, split along its
  existing sections. Still one scope, concatenated in order, not modules.
- web/build.mjs strips the types, puts the script in the page and minifies it with swc;
  build.rs runs it into OUT_DIR and web.rs include_str!s the result. 137 KB -> 106 KB.
- npx tsc -p . type-checks web/src, loosely; the handful of annotations it needed
  change no behaviour.
- The Docker build installs node and swc (npm ci --omit=dev).
- Two list requests racing no longer let the older one win, and switching tabs clears
  the selection it closes, which made a browser test flaky.

Closes #23, #24.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 12:41:34 +00:00
fbba447ca6 Add a feed without Popular; a phone shows an item's files above its notes
- The Add a feed dialog no longer lists Popular; the sidebar has it (#30).
- On a phone the files, with play and delete, come before the show notes. Below
  them, long notes buried the delete button and it looked missing on iOS (#21).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 12:21:44 +00:00
d7a4a0b663 Fix the open bugs: read state, Unread tab, feed errors, theme button, log button, relative images
- Opening an item stays read: a list refresh that crossed with the write no longer
  puts the unread dot back (#16).
- On the Unread tab the item you were reading goes when you move to the next (#17).
- Feed errors mark the feed with a red ! instead of a toast per failure (#20).
- The theme is chosen in Settings only (#15).
- The server leaves the Log button out of a non-admin's page, so it no longer flashes (#29).
- Relative images and links in a post resolve against the post's link (#28).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 12:10:05 +00:00
0443177471 Release 0.6.1
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 19:19:44 +00:00
0a83c716eb Time left and finished go by the length the player measured
A feed can be minutes out: ReThinking's gave 41:23 for a 43:48 file,
which read 0:08 left with 2:33 to play. The player's length is kept in
entry_state beside the position, per listener, where no scan can put
the feed's figure back, and preferred to the feed's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 18:39:40 +00:00
b9e0d9f3cb Save a position only from a player that has played since its last save
A tab left paused further into an episode saved its older place as it
reloaded, over where the listener had got to since, and the episode
dropped out of Currently Listening. A jump back is now saved at once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 18:29:10 +00:00
34 changed files with 4574 additions and 2376 deletions

1
.gitignore vendored
View File

@@ -2,3 +2,4 @@
/node_modules /node_modules
/test-results /test-results
/playwright-report /playwright-report
/web/dist

View File

@@ -10,6 +10,71 @@ The long form, with what was wrong before and how it was found, is in
## [Unreleased] ## [Unreleased]
## [0.7.0] - 2026-09-18
### Added
- Six more themes in Settings: Dracula, Material, Adwaita, Flat Remix, Paper and Nordic, beside
Classic and Modern (the existing dark and light). Each that comes both ways has its own Light,
Dark or Auto setting; Classic and Paper come one way only, so that setting is hidden for them.
A theme chosen before this carries over.
- Your theme is kept on your account rather than in the browser, so it follows you to another
browser or computer, and the page arrives in it with no flash of the default. The theme a
browser already had is saved to your account the first time you load the page.
- Pin a feed to the top of the feed list with the pin on its page, a feed from inside an OPML or
Patreon folder included, which comes out of the folder while pinned. Pins are yours alone.
- Touch gestures: pull the item list down from its top to check the feed for new items, and
swipe the item you are reading left for the next one and right for the one before, or back
to the list from the first.
### Changed
- The server's settings, the accounts and the log are on their own admin page, /admin, reached by
the wrench in the header. Only an admin is sent the page, its script, or the link to it.
Settings is now yours alone: your theme and your subscriptions.
- A feed that fails to check gets a red exclamation mark in the feed list, in the margin where a
folder's triangle sits, and its page says why, in place of a pop-up per failure that everyone
saw during a scan of every feed. A folder holding a failing feed has its triangle turn red.
- The theme is chosen in Settings only; the button beside the iPodderX name is gone.
- Add a feed asks only for the feed; Popular and Directory in the sidebar are where you browse.
- On a phone, an item's files, with play and delete, sit above its show notes rather than below
them, where long notes left them looking missing.
- On a phone, an item with no files goes straight to its text, without a box saying "No files".
- The page is served minified, about a quarter smaller. Its script is now TypeScript in
`web/src`, type-checked, and built with swc; building ipx needs node.
- The script is its own file, `/app.js`, rather than inside the page. Your browser keeps it
between visits and fetches it again only when an update changes it.
### Fixed
- Switching tabs straight after marking everything read no longer shows the previous tab's
items: of two lists asked for at once, only the later one is shown.
- ipx has a favicon: the logo, squared up, also at /favicon.ico for browsers that ask there on
their own, and on white for an iPhone's home screen.
- The file icon of an item not yet downloaded sits level with the rest of its row, instead of
higher than a downloaded one's.
- Images in posts from sites that refuse images to other sites' pages, such as Jeff Geerling's,
now show: ipx asks for them without saying it is the page showing them.
- While an episode plays, its play buttons in the files pane, its row and the toolbar show
pause, as the player bar's does, and pause it when pressed.
- An item you open stays read. A list refresh that crossed with marking it read could put its
unread dot back until the next refresh.
- On the Unread tab, the item you were reading leaves the list as soon as you move to the next
one, rather than a few read items lingering until a refresh cleared them.
- The Log button no longer shows for a moment on every load for anyone but an admin; the server
leaves it out of their page.
- An image or link in a post given relative to the post, such as The Observation Deck's, now
points at the post's site rather than at ipx, and shows.
## [0.6.1] - 2026-09-15
### Fixed
- Time left, and when an episode counts as finished, go by the length your player measured
rather than the feed's, which can be minutes out: one episode said 0:08 left with 2:33 to play.
- A player left open in another tab or on another device no longer saves its older place over
where you have got to since, which could drop an episode out of Currently Listening.
## [0.6.0] - 2026-09-15 ## [0.6.0] - 2026-09-15
### Added ### Added
@@ -421,7 +486,9 @@ The long form, with what was wrong before and how it was found, is in
- Torrent enclosures through librqbit, seeding to a ratio or a time, with a stall timeout. - Torrent enclosures through librqbit, seeding to a ratio or a time, with a stall timeout.
- `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.0...main [unreleased]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.1...main
[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.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.5...v0.6.0 [0.6.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.5...v0.6.0
[0.5.5]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.4...v0.5.5 [0.5.5]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.4...v0.5.5
[0.5.4]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.3...v0.5.4 [0.5.4]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.3...v0.5.4

View File

@@ -61,18 +61,34 @@ the shell running the command and kills the session (exit 144). This has happene
## Before you touch the page ## Before you touch the page
`web/index.html` is `include_str!`d into the binary, so **every page change needs a rebuild** before There are three pages: the app (`web/index.html`), the admin page (`web/admin.html`, sent to
it is visible. It is one file: markup, CSS and script. admins only) and sign-in (`web/login.html`). The app and admin pages share one stylesheet,
`web/app.css`, and their script is TypeScript in `web/src/`; `web/build.mjs` lists which files
make up each page's script. `build.rs` runs
`web/build.mjs`, which uses swc to strip the types and minify the script into `app.js` (and
`login.js`), and minifies the page, and the results are `include_str!`d into the binary. The page
loads its script as `/app.js?v=<hash of its contents>`, and `/app.css` the same way: the page is
served `no-cache` and the script and stylesheet `immutable`, so a browser keeps them until a
deploy changes them and their names. So **every page change needs a
rebuild** before it is visible, and building needs node and `npm ci` run once.
The files in `web/src` are not modules. They are one script split up, concatenated in the order
`web/build.mjs` lists them, sharing one top-level scope as the single inline script did; a new
file goes into that list. Top-level names are kept as they are, because markup calls some by
name (`onclick="closeModal()"`) and the browser tests reach others through `page.evaluate`.
After any edit to it: After any edit to it:
```sh ```sh
npx tsc -p .
node tests/page-smoke.js node tests/page-smoke.js
``` ```
That loads the script against a stub DOM and checks every selector it wires at load actually The first type-checks `web/src` (loosely: `strict` is off, and `$` returns `any`). The second
exists. It exists because a patch once anchored on a deleted function, `String.replace` silently builds the page as shipped and runs its script against a stub DOM, checking every selector it
matched nothing, and the whole UI died with a `ReferenceError` while every server-side test passed. wires at load actually exists. That check exists because a patch once anchored on a deleted
function, `String.replace` silently matched nothing, and the whole UI died with a
`ReferenceError` while every server-side test passed.
Patching that file by guessing an anchor string has failed repeatedly. Read the exact block first Patching that file by guessing an anchor string has failed repeatedly. Read the exact block first
(`sed -n 'START,ENDp'`), match it verbatim, and assert the replacement happened rather than hoping. (`sed -n 'START,ENDp'`), match it verbatim, and assert the replacement happened rather than hoping.
@@ -80,9 +96,10 @@ Patching that file by guessing an anchor string has failed repeatedly. Read the
## Tests ## Tests
```sh ```sh
cargo test # ~51 tests: parsing, filters, retention, schedules, SQL, per-user state cargo test # ~80 tests: parsing, filters, retention, schedules, SQL, per-user state
npx tsc -p . # type-checks web/src
node tests/page-smoke.js node tests/page-smoke.js
npx playwright test # 16 browser tests against a real daemon on fixture feeds npx playwright test # 40 browser tests against a real daemon on fixture feeds
``` ```
Things about the browser suite that have cost time: Things about the browser suite that have cost time:

2
Cargo.lock generated
View File

@@ -1605,7 +1605,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
[[package]] [[package]]
name = "ipx" name = "ipx"
version = "0.6.0" version = "0.7.0"
dependencies = [ dependencies = [
"ammonia", "ammonia",
"anyhow", "anyhow",

View File

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

View File

@@ -1,17 +1,23 @@
# Build. rusqlite is bundled (compiles SQLite from source) and librqbit needs a C # Build. rusqlite is bundled (compiles SQLite from source) and librqbit needs a C
# toolchain, so the builder needs cc. TLS is rustls throughout, so no OpenSSL headers. # toolchain, so the builder needs cc. TLS is rustls throughout, so no OpenSSL headers.
# build.rs builds the web pages from TypeScript with swc, which needs node.
FROM rust:1-slim-bookworm AS build FROM rust:1-slim-bookworm AS build
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \ build-essential nodejs npm \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /src WORKDIR /src
# swc only: Playwright and TypeScript are for testing and type-checking, not for building.
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# Dependencies first, so editing the source does not rebuild librqbit every time. # Dependencies first, so editing the source does not rebuild librqbit every time.
COPY Cargo.toml Cargo.lock ./ COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo 'fn main(){}' > src/main.rs \ RUN mkdir src && echo 'fn main(){}' > src/main.rs \
&& cargo build --release --locked \ && cargo build --release --locked \
&& rm -rf src && rm -rf src
COPY build.rs ./
COPY src ./src COPY src ./src
COPY web ./web COPY web ./web
# cargo skips a rebuild if mtimes look untouched; make sure it does not. # cargo skips a rebuild if mtimes look untouched; make sure it does not.

14
build.rs Normal file
View File

@@ -0,0 +1,14 @@
//! Builds web/index.html and web/login.html from their TypeScript (web/build.mjs) into OUT_DIR,
//! where src/web.rs include_str!s them. Needs node and `npm ci` run first.
use std::process::Command;
fn main() {
println!("cargo:rerun-if-changed=web");
println!("cargo:rerun-if-changed=package-lock.json");
let out = std::env::var("OUT_DIR").unwrap();
let status = Command::new("node")
.args(["web/build.mjs", &out])
.status()
.expect("building the web pages needs node on PATH (and `npm ci` run once)");
assert!(status.success(), "web/build.mjs failed; run `node web/build.mjs` to see why");
}

View File

@@ -19,9 +19,15 @@ it to a running daemon.
| `src/auth.rs` | Argon2id hashing, session tokens, header names | — | | `src/auth.rs` | Argon2id hashing, session tokens, header names | — |
| `src/web.rs` | axum: HTTP API, auth, SSE, media streaming | — | | `src/web.rs` | axum: HTTP API, auth, SSE, media streaming | — |
| `src/logbuf.rs` | Ring buffer behind the UI's Log view | — | | `src/logbuf.rs` | Ring buffer behind the UI's Log view | — |
| `web/index.html` | The whole front end, `include_str!`d into the binary | — | | `web/index.html` | The app's markup | — |
| `web/admin.html` | The admin page's markup: server settings, accounts, the log. Sent to admins only | — |
| `web/app.css` | The stylesheet both pages share | — |
| `web/src/*.ts` | The page's script, one scope split across files, type-checked by `npx tsc` | — |
| `web/build.mjs` | swc: strips the types into `app.js`/`login.js`, named in the page by a hash of their contents, and minifies | — |
| `build.rs` | Runs `web/build.mjs` into `OUT_DIR`, where `web.rs` `include_str!`s the result | — |
The page is compiled in, so **editing `web/index.html` needs a rebuild**. The page is compiled in, so **editing `web/index.html` or `web/src` needs a rebuild**, and a
build needs node and `npm ci` run once.
## A scan ## A scan

View File

@@ -6,6 +6,33 @@ reasoning lives. New write-ups go at the top.
See [README.md](../README.md) for what the thing is. See [README.md](../README.md) for what the thing is.
## 2026-09-15 — Kristen Bell again: a length minutes out, and an idle player
After 0.6.0 the Kristen Bell episode dropped out of Currently Listening again, with eleven
minutes left in the player. Its saved position was 41:15 of a file 43:48 long, 94% and finished
by any measure, while the listener was at about 32:48. The first guess was a wrong length from
the feed, and it was wrong: the feed does say 41:23, but no length makes 41:15 unfinished.
The log had the answer. Saves every ten seconds while it played, then two that were not
playback: one beside the only request for the file since the restart, and the last in the same
millisecond as a page load. The likeliest reading was a player left paused further on, in
another tab or on another device, saving its place as its page reloaded, since every save goes
through `savePos` and it saved whatever the player held whether or not anyone had played it
since. That was never confirmed. Fifteen minutes later the listener was playing on from 41:15
and heard 2:33 left, which fits that position having been theirs all along.
`savePos` now saves only once the player has played since its last save, so an idle one never
writes, and only playing counts: the seek to where you left off happens paused, and counting it
would save back whatever the list said, however old. A jump back is also saved at once, where
the ten-second check only ever looked forward. The fixture audio does not decode, so the browser
test stands in for a loaded player and counts what `savePos` sends.
What was certainly wrong was the length. At 41:15 the item list said 0:08 left and the player
2:33: the feed gives 41:23 for a file that decodes to 43:48. The length the player measures now
goes into `entry_state` beside the position, and wins over the feed's for the 90% line and every
time left. Not into `entries`, where each scan writes the feed's figure back, and per listener,
so one person's player never changes what another sees.
## 2026-09-15 — Currently Listening, empty for anyone who opens what they play ## 2026-09-15 — Currently Listening, empty for anyone who opens what they play
Issue #14: an episode 32 minutes into 41 was missing from Currently Listening, which said nothing Issue #14: an episode 32 minutes into 41 was missing from Currently Listening, which said nothing

887
package-lock.json generated
View File

@@ -1,12 +1,17 @@
{ {
"name": "ipx-ui-tests", "name": "ipx-web",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ipx-ui-tests", "name": "ipx-web",
"dependencies": {
"@swc/core": "^1.16.2",
"@swc/html": "^1.16.2"
},
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.56.0" "@playwright/test": "^1.56.0",
"typescript": "^7.0.2"
} }
}, },
"node_modules/@playwright/test": { "node_modules/@playwright/test": {
@@ -25,6 +30,847 @@
"node": ">=20" "node": ">=20"
} }
}, },
"node_modules/@swc/core": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.16.2.tgz",
"integrity": "sha512-95I4kiSMeveI/Mhi+tE4fiWcWLUMfzfKrk0jtr8LRMqHgOgq+xHS+zExkDqoO4b5OeeuXHMWVdD5MeP3X6sULw==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@swc/counter": "^0.1.3",
"@swc/types": "^0.1.28"
},
"engines": {
"node": ">=10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/swc"
},
"optionalDependencies": {
"@swc/core-darwin-arm64": "1.16.2",
"@swc/core-darwin-x64": "1.16.2",
"@swc/core-linux-arm-gnueabihf": "1.16.2",
"@swc/core-linux-arm64-gnu": "1.16.2",
"@swc/core-linux-arm64-musl": "1.16.2",
"@swc/core-linux-ppc64-gnu": "1.16.2",
"@swc/core-linux-s390x-gnu": "1.16.2",
"@swc/core-linux-x64-gnu": "1.16.2",
"@swc/core-linux-x64-musl": "1.16.2",
"@swc/core-win32-arm64-msvc": "1.16.2",
"@swc/core-win32-ia32-msvc": "1.16.2",
"@swc/core-win32-x64-msvc": "1.16.2"
},
"peerDependencies": {
"@swc/helpers": ">=0.5.17"
},
"peerDependenciesMeta": {
"@swc/helpers": {
"optional": true
}
}
},
"node_modules/@swc/core-darwin-arm64": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.16.2.tgz",
"integrity": "sha512-i/j0HNbnn79qnTVPicvay92Nark8fW8NQqn1e2mGERjUXNpBV0+SwQxlRpk2zBhn6laJ8PDI6Kn1nHZhnz3LCA==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-darwin-x64": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.16.2.tgz",
"integrity": "sha512-HrwqHyEyHVXO3qTk8EkNK7/b6sOZSEoNh+pot6RdE5x0LbNqfo8LtJUvi3UTXr+5ja/o5HbJdW80eCXo+NjbiA==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-arm-gnueabihf": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.16.2.tgz",
"integrity": "sha512-MdXi83Z/gGp1LIrg+h7HKxiul/z/Bty/ZJSvYAFqDl9zteC1XLSAZdScquKtXPp50rdyXqritTDCqQBhwVfZKA==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-arm64-gnu": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.16.2.tgz",
"integrity": "sha512-/jcTmK6Ktz3owM3YtiKvjofV6p3VpHnYzTIrOGwDIOsDigRAAVuZ8east33wYO/7UTdKYFlyHNnJNT0WJqOA3Q==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-arm64-musl": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.16.2.tgz",
"integrity": "sha512-4gFarKaFnlJTSlJYKmMhV4u+3YE4uYfiydpBoYjmgQhCf9lAieOq+WilZaK9vVSHeqLuQpTEiGULZqAdsRX5Dw==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-ppc64-gnu": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.16.2.tgz",
"integrity": "sha512-syqSLGd6KlZ1PciNzs6bIUlhOuFztZufebOHaERjc4N4SqNZxyqYd4I+jj/EfOYnpe0kNjccn9HJLN1p5dz3+w==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-s390x-gnu": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.16.2.tgz",
"integrity": "sha512-ZBBLK+ewGyXLzWeMS7wbKtWBdnif6etn7xvPY/iOfbdsjX/+bgkp1pQt2lWF2wlu2hXYZuhJ/tHZE/QR8/apzg==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-x64-gnu": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.16.2.tgz",
"integrity": "sha512-LyHJgxCA4Tje0ysBMbEb0tt/ie8kgUKoFE3JAKFhpevmTmhYEoC0H9s47WuDsqiFckF1ITUguZIXJG6K5e0dvg==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-x64-musl": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.16.2.tgz",
"integrity": "sha512-PghXJlVM1cgtLfNUR1vxFo1z+PDRAe8cWAJlZZ7spmeiN7BospGXg/MHUg7oNSgwSX7Zo//YKv9P5yD9apsFJQ==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-win32-arm64-msvc": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.16.2.tgz",
"integrity": "sha512-StTOSefYBxemvNYYUI3UmO1a8y+hSPjjfHogC2TEHL+Z1PlEBim/XtLas5rS04jAzT9RrNmbtX911SZ42H9jSQ==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-win32-ia32-msvc": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.16.2.tgz",
"integrity": "sha512-fycER209DYIzsibpTMC+chND05OfOjgztWL9U8OE6/uUlsOUZH3eh98isBLEnOymYUhlJLEt5++W1+KL/FOh5Q==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-win32-x64-msvc": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.16.2.tgz",
"integrity": "sha512-cSd1z6ivSrJPVr+moVwOHWjeKy6TpO4/Shwcv5KCrKYXCccxwh4pRy1C3fDioNx2PF1jPZWHKZjtXt+Be9VbaQ==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/counter": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
"license": "Apache-2.0"
},
"node_modules/@swc/html": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/html/-/html-1.16.2.tgz",
"integrity": "sha512-RmWH8m5dePWDFpHpmFKquZCRe5SyD/Sb0FBPxWcWv/tsjtlJl6oHeaxBsTL2edvaHuW385Fy5nPuTjDD/a+GEA==",
"license": "Apache-2.0",
"dependencies": {
"@swc/counter": "^0.1.3"
},
"engines": {
"node": ">=14"
},
"optionalDependencies": {
"@swc/html-darwin-arm64": "1.16.2",
"@swc/html-darwin-x64": "1.16.2",
"@swc/html-linux-arm-gnueabihf": "1.16.2",
"@swc/html-linux-arm64-gnu": "1.16.2",
"@swc/html-linux-arm64-musl": "1.16.2",
"@swc/html-linux-ppc64-gnu": "1.16.2",
"@swc/html-linux-s390x-gnu": "1.16.2",
"@swc/html-linux-x64-gnu": "1.16.2",
"@swc/html-linux-x64-musl": "1.16.2",
"@swc/html-win32-arm64-msvc": "1.16.2",
"@swc/html-win32-ia32-msvc": "1.16.2",
"@swc/html-win32-x64-msvc": "1.16.2"
}
},
"node_modules/@swc/html-darwin-arm64": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/html-darwin-arm64/-/html-darwin-arm64-1.16.2.tgz",
"integrity": "sha512-SNBUxkxLBXD0ATwnOG1rF8mpSrRtFDfqWnEUmbm/g4KwmCt7NuHHv9YYqA3lqfq90Ucc+Xlk7afx8KAW/utz4A==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/html-darwin-x64": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/html-darwin-x64/-/html-darwin-x64-1.16.2.tgz",
"integrity": "sha512-WVBgn6yrBPMZu+DL95/XGAXYcgd1nhd67Ml1UjMtFoFMVKY+VRpCq8JpTZTMXhWbVoRENUHk+3PHu0nNjlE/Fg==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/html-linux-arm-gnueabihf": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/html-linux-arm-gnueabihf/-/html-linux-arm-gnueabihf-1.16.2.tgz",
"integrity": "sha512-V9F/Akd2TXrf5nUhdLgdy3FoVFxQbw8pA2AOyqnEOa2Mbm1R7DZJJ0GdShEMcoyMyMDB9r/4pWuWfxNtP4mFHA==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/html-linux-arm64-gnu": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/html-linux-arm64-gnu/-/html-linux-arm64-gnu-1.16.2.tgz",
"integrity": "sha512-jonZVtHc6BesMjC/muUEJGzE1L2kVdgiPVuHc7CL79MrUm0Hjf8LS4Wmtjqe2bLTfRcaMfaYl/60ZcRXHCaYSQ==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/html-linux-arm64-musl": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/html-linux-arm64-musl/-/html-linux-arm64-musl-1.16.2.tgz",
"integrity": "sha512-dvki9/sgacHk9ouORmnIok5FbpeE9zUE8yqGGhL1kitNJi6/TKzfnMOpRxSxeDk1/ccvJTAdjRGDIGkT45+b3Q==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/html-linux-ppc64-gnu": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/html-linux-ppc64-gnu/-/html-linux-ppc64-gnu-1.16.2.tgz",
"integrity": "sha512-6m0vVWHl9MW7cmWKVgKlFW6yhRv0uahMEaDxNIvXrPC3LdbbiiYZui+ryhyQGIYeVps3OMujzUjc0GihNz/afQ==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/html-linux-s390x-gnu": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/html-linux-s390x-gnu/-/html-linux-s390x-gnu-1.16.2.tgz",
"integrity": "sha512-TOlz6wgKyZjg4THJsNZfDz/rAMO+rBa0s2eewTeHEfuJhI+jGu7H6Co6bdbMpN3oyDvTMG7N1f1ktSbkE0erAg==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/html-linux-x64-gnu": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/html-linux-x64-gnu/-/html-linux-x64-gnu-1.16.2.tgz",
"integrity": "sha512-5EduoVpsnuAAkG9BW8COxcIKAe5swgNAEo+BVkAJCOy1ZMZm0krQYBdvlaDCsGGE9yLDKVPm7rpYIi7vTTZTbA==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/html-linux-x64-musl": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/html-linux-x64-musl/-/html-linux-x64-musl-1.16.2.tgz",
"integrity": "sha512-c0Z84dvBd0oh1ZcBHnM18itmvJFLbCZBKFF2lEDHsGBSLQ/1sPbggEKsVO4KgWkkhwQV2l9AB4jnsw1HrwZJCg==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/html-win32-arm64-msvc": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/html-win32-arm64-msvc/-/html-win32-arm64-msvc-1.16.2.tgz",
"integrity": "sha512-Aq7V2B5gS23X59DzV2z892c4NBHYtJbwhvsCjJN1MBMx723htjgNE9KVIJp9dQaJBr2PrNfb/u3QFwnWV2tAoQ==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/html-win32-ia32-msvc": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/html-win32-ia32-msvc/-/html-win32-ia32-msvc-1.16.2.tgz",
"integrity": "sha512-9gslPcsfXxKvAZtOvDkxGuEbM7lqBrONzLAyRsyUtw8KxFcSYkGIO48RDTstGWOkgTgKjjAq/WWqt9qr/NcE3A==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/html-win32-x64-msvc": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/html-win32-x64-msvc/-/html-win32-x64-msvc-1.16.2.tgz",
"integrity": "sha512-Kdb4VdC8FyF5s1MQaFUNeASLckHECrb/oYy/6OCtU+hbgxQ/o/JCgE4uCe8YAg0LCWSOjhx73PCZDGwPf1TpKw==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/types": {
"version": "0.1.28",
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.28.tgz",
"integrity": "sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==",
"license": "Apache-2.0",
"dependencies": {
"@swc/counter": "^0.1.3"
}
},
"node_modules/@typescript/typescript-aix-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-darwin-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-darwin-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-loong64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-mips64el": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-riscv64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-s390x": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-sunos-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/playwright": { "node_modules/playwright": {
"version": "1.63.0", "version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
@@ -53,6 +899,41 @@
"engines": { "engines": {
"node": ">=20" "node": ">=20"
} }
},
"node_modules/typescript": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc"
},
"engines": {
"node": ">=16.20.0"
},
"optionalDependencies": {
"@typescript/typescript-aix-ppc64": "7.0.2",
"@typescript/typescript-darwin-arm64": "7.0.2",
"@typescript/typescript-darwin-x64": "7.0.2",
"@typescript/typescript-freebsd-arm64": "7.0.2",
"@typescript/typescript-freebsd-x64": "7.0.2",
"@typescript/typescript-linux-arm": "7.0.2",
"@typescript/typescript-linux-arm64": "7.0.2",
"@typescript/typescript-linux-loong64": "7.0.2",
"@typescript/typescript-linux-mips64el": "7.0.2",
"@typescript/typescript-linux-ppc64": "7.0.2",
"@typescript/typescript-linux-riscv64": "7.0.2",
"@typescript/typescript-linux-s390x": "7.0.2",
"@typescript/typescript-linux-x64": "7.0.2",
"@typescript/typescript-netbsd-arm64": "7.0.2",
"@typescript/typescript-netbsd-x64": "7.0.2",
"@typescript/typescript-openbsd-arm64": "7.0.2",
"@typescript/typescript-openbsd-x64": "7.0.2",
"@typescript/typescript-sunos-x64": "7.0.2",
"@typescript/typescript-win32-arm64": "7.0.2",
"@typescript/typescript-win32-x64": "7.0.2"
}
} }
} }
} }

View File

@@ -1,13 +1,20 @@
{ {
"name": "ipx-ui-tests", "name": "ipx-web",
"private": true, "private": true,
"description": "Browser tests for the ipx web UI. The Rust tests cover the server; these cover the page.", "description": "Builds the ipx web pages from web/src (web/build.mjs, run by build.rs) and tests them. The Rust tests cover the server.",
"scripts": { "scripts": {
"build": "node web/build.mjs",
"typecheck": "tsc -p .",
"smoke": "node tests/page-smoke.js",
"test": "playwright test", "test": "playwright test",
"test:headed": "playwright test --headed", "test:headed": "playwright test --headed"
"smoke": "node tests/page-smoke.js"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.56.0" "@playwright/test": "^1.56.0",
"typescript": "^7.0.2"
},
"dependencies": {
"@swc/core": "^1.16.2",
"@swc/html": "^1.16.2"
} }
} }

115
src/db.rs
View File

@@ -81,7 +81,10 @@ CREATE TABLE IF NOT EXISTS users (
is_admin INTEGER NOT NULL DEFAULT 0, is_admin INTEGER NOT NULL DEFAULT 0,
-- For whoever maintains the server. NULL where it is not known. -- For whoever maintains the server. NULL where it is not known.
created INTEGER, created INTEGER,
last_login INTEGER last_login INTEGER,
-- The theme chosen in Settings, and light, dark or auto. NULL until one is chosen.
theme TEXT,
theme_mode TEXT
); );
-- What one person wants from a feed. The feed, its items and its files are shared; this -- What one person wants from a feed. The feed, its items and its files are shared; this
@@ -93,6 +96,8 @@ CREATE TABLE IF NOT EXISTS subscriptions (
auto_download INTEGER, auto_download INTEGER,
allow_explicit INTEGER, allow_explicit INTEGER,
max_new_per_check INTEGER, max_new_per_check INTEGER,
-- Pinned to the top of this person's feed list, a feed inside a folder included.
pinned INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, feed_id) PRIMARY KEY (user_id, feed_id)
); );
@@ -105,6 +110,8 @@ CREATE TABLE IF NOT EXISTS entry_state (
read INTEGER NOT NULL DEFAULT 0, read INTEGER NOT NULL DEFAULT 0,
flagged INTEGER NOT NULL DEFAULT 0, flagged INTEGER NOT NULL DEFAULT 0,
position INTEGER NOT NULL DEFAULT 0, position INTEGER NOT NULL DEFAULT 0,
-- The length this person's player measured, beside the position it is measured against.
duration INTEGER,
PRIMARY KEY (user_id, feed_id, guid) PRIMARY KEY (user_id, feed_id, guid)
); );
@@ -173,6 +180,11 @@ fn migrate(conn: &Connection) -> Result<()> {
("users", "last_login", "INTEGER"), ("users", "last_login", "INTEGER"),
("feeds", "error_since", "INTEGER"), ("feeds", "error_since", "INTEGER"),
("feeds", "category", "TEXT"), ("feeds", "category", "TEXT"),
("entry_state", "duration", "INTEGER"),
// Kept per account so a theme follows you to another browser; it was in localStorage.
("users", "theme", "TEXT"),
("users", "theme_mode", "TEXT"),
("subscriptions", "pinned", "INTEGER NOT NULL DEFAULT 0"),
]; ];
let retired: &[(&str, &str)] = &[ let retired: &[(&str, &str)] = &[
// Read state from before accounts, long since moved to entry_state. Two bugs came from // Read state from before accounts, long since moved to entry_state. Two bugs came from
@@ -704,8 +716,9 @@ pub enum Filter {
Flagged, Flagged,
/// Started (a saved playback position past the first few seconds) and short of the 90% /// Started (a saved playback position past the first few seconds) and short of the 90%
/// where `markPlayed` in the UI calls it finished. Not `read`: opening an item marks it /// where `markPlayed` in the UI calls it finished. Not `read`: opening an item marks it
/// read, so filtering on that hid nearly every episode anyone had started. An episode of /// read, so filtering on that hid nearly every episode anyone had started. The length is the
/// unknown length counts as unfinished until the player reports one (`set_position`). /// one this person's player measured where there is one (`set_position`), else the feed's;
/// with neither, the episode counts as unfinished.
/// Currently Listening, below Popular, is this filter on every feed at once. /// Currently Listening, below Popular, is this filter on every feed at once.
InProgress, InProgress,
} }
@@ -734,7 +747,8 @@ impl Filter {
} }
Self::InProgress => { Self::InProgress => {
"coalesce(s.position, 0) > 5 "coalesce(s.position, 0) > 5
AND (coalesce(e.duration, 0) = 0 OR s.position * 10 < e.duration * 9)" AND (coalesce(s.duration, e.duration, 0) = 0
OR s.position * 10 < coalesce(s.duration, e.duration) * 9)"
} }
} }
} }
@@ -773,7 +787,8 @@ impl Db {
.unwrap_or_default(); .unwrap_or_default();
let sql = format!( let sql = format!(
"SELECT e.guid, e.feed_id, e.title, e.link, e.published, e.description, "SELECT e.guid, e.feed_id, e.title, e.link, e.published, e.description,
coalesce(s.read, 0), coalesce(s.flagged, 0), e.image, e.duration, coalesce(s.read, 0), coalesce(s.flagged, 0), e.image,
coalesce(s.duration, e.duration),
e.episode, e.season, coalesce(s.position, 0) e.episode, e.season, coalesce(s.position, 0)
FROM entries e FROM entries e
LEFT JOIN entry_state s LEFT JOIN entry_state s
@@ -871,9 +886,11 @@ impl Db {
} }
/// Where playback got to, so it resumes there next time -- for this listener only. /// Where playback got to, so it resumes there next time -- for this listener only.
/// `duration` is the length the player measured. It fills in an episode whose feed gives /// `duration` is the length their player measured, kept beside the position and preferred to
/// none, and never replaces one the feed gave: without a length, Currently Listening cannot /// the feed's for time left and for when an episode counts as finished. A feed can be minutes
/// tell a finished episode from a started one, and would keep it listed for good. /// out: ReThinking's gave 41:23 for a 43:48 file, which said 0:08 left with 2:33 to play.
/// Not written to `entries`, where every scan puts the feed's figure back, and kept per
/// listener so one person's player never changes what anyone else sees.
pub fn set_position( pub fn set_position(
&self, &self,
user_id: i64, user_id: i64,
@@ -884,17 +901,12 @@ impl Db {
) -> Result<()> { ) -> Result<()> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
conn.execute( conn.execute(
"INSERT INTO entry_state (user_id, feed_id, guid, position) VALUES (?1, ?2, ?3, ?4) "INSERT INTO entry_state (user_id, feed_id, guid, position, duration)
ON CONFLICT(user_id, feed_id, guid) DO UPDATE SET position = excluded.position", VALUES (?1, ?2, ?3, ?4, ?5)
rusqlite::params![user_id, feed_id, guid, secs.max(0)], ON CONFLICT(user_id, feed_id, guid) DO UPDATE SET position = excluded.position,
duration = coalesce(excluded.duration, duration)",
rusqlite::params![user_id, feed_id, guid, secs.max(0), duration.filter(|d| *d > 0)],
)?; )?;
if let Some(d) = duration.filter(|d| *d > 0) {
conn.execute(
"UPDATE entries SET duration = ?3
WHERE feed_id = ?1 AND guid = ?2 AND coalesce(duration, 0) = 0",
rusqlite::params![feed_id, guid, d],
)?;
}
Ok(()) Ok(())
} }
@@ -1051,6 +1063,24 @@ impl Db {
Ok(()) Ok(())
} }
/// The feeds this person pinned to the top of their list. Kept apart from `Sub`, which is
/// what the scanner merges into its policy, and which a pin has nothing to do with.
pub fn pinned_feeds(&self, user_id: i64) -> Result<std::collections::HashSet<String>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT feed_id FROM subscriptions WHERE user_id = ?1 AND pinned")?;
let ids = stmt.query_map([user_id], |r| r.get(0))?.collect::<rusqlite::Result<_>>()?;
Ok(ids)
}
/// False when they do not subscribe to it, since there is then no row in their list to pin.
pub fn set_pinned(&self, user_id: i64, feed_id: &str, on: bool) -> Result<bool> {
let conn = self.conn.lock().unwrap();
Ok(conn.execute(
"UPDATE subscriptions SET pinned = ?3 WHERE user_id = ?1 AND feed_id = ?2",
params![user_id, feed_id, on as i64],
)? > 0)
}
pub fn unsubscribe(&self, user_id: i64, feed_id: &str) -> Result<()> { pub fn unsubscribe(&self, user_id: i64, feed_id: &str) -> Result<()> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
conn.execute( conn.execute(
@@ -1177,6 +1207,23 @@ impl Db {
Ok(()) Ok(())
} }
/// The theme this person chose, and light, dark or auto; None for either until they choose.
pub fn theme(&self, user_id: i64) -> Result<(Option<String>, Option<String>)> {
let conn = self.conn.lock().unwrap();
Ok(conn.query_row("SELECT theme, theme_mode FROM users WHERE id = ?1", [user_id], |r| {
Ok((r.get(0)?, r.get(1)?))
})?)
}
pub fn set_theme(&self, user_id: i64, theme: &str, mode: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE users SET theme = ?2, theme_mode = ?3 WHERE id = ?1",
params![user_id, theme, mode],
)?;
Ok(())
}
/// The user behind a session cookie, if it is still live. Idle sessions expire after /// The user behind a session cookie, if it is still live. Idle sessions expire after
/// `max_idle_secs`; touching `seen` is what keeps a session in daily use alive. /// `max_idle_secs`; touching `seen` is what keeps a session in daily use alive.
pub fn session_user(&self, token: &str, max_idle_secs: i64) -> Result<Option<User>> { pub fn session_user(&self, token: &str, max_idle_secs: i64) -> Result<Option<User>> {
@@ -1680,7 +1727,7 @@ mod tests {
// Starring and position are just as private. // Starring and position are just as private.
db.set_entry_flag(1, "f", "b", EntryFlag::Flagged, true).unwrap(); db.set_entry_flag(1, "f", "b", EntryFlag::Flagged, true).unwrap();
db.set_position(2, "f", "b", 42, None).unwrap(); db.set_position(2, "f", "b", 42, Some(600)).unwrap();
let order = order_sql("published", "desc"); let order = order_sql("published", "desc");
let page = |user| db.entries_in(user, Some("f"), Filter::All, None, 0, 50, &order).unwrap(); let page = |user| db.entries_in(user, Some("f"), Filter::All, None, 0, 50, &order).unwrap();
let (ray, sam) = (page(1), page(2)); let (ray, sam) = (page(1), page(2));
@@ -1688,6 +1735,9 @@ mod tests {
let sam_b = sam.iter().find(|e| e.guid == "b").unwrap(); let sam_b = sam.iter().find(|e| e.guid == "b").unwrap();
assert!(ray_b.flagged && ray_b.position == 0); assert!(ray_b.flagged && ray_b.position == 0);
assert!(!sam_b.flagged && sam_b.position == 42); assert!(!sam_b.flagged && sam_b.position == 42);
// So is the length sam's player measured.
assert_ne!(ray_b.duration, Some(600));
assert_eq!(sam_b.duration, Some(600));
// Marking a whole feed read is likewise one person's business. // Marking a whole feed read is likewise one person's business.
assert_eq!(db.mark_all_read(2, &["f".to_string()]).unwrap(), 2); assert_eq!(db.mark_all_read(2, &["f".to_string()]).unwrap(), 2);
@@ -1874,11 +1924,17 @@ mod tests {
}; };
assert_eq!(listening(), ["h", "d"]); assert_eq!(listening(), ["h", "d"]);
// The player's measured length fills in the one d's feed left out, which settles it: // The player's measured length is the one that counts, in place of a missing one or over
// 42 of 45 seconds is finished. It never replaces a feed's own length. // the feed's: d, 42 of a measured 45 seconds, is finished; e, which its feed calls 45
// seconds long, is 42 into a 9000-second file and is not. Times left use it too.
db.set_position(7, "f", "d", 42, Some(45)).unwrap(); db.set_position(7, "f", "d", 42, Some(45)).unwrap();
db.set_position(7, "f", "e", 42, Some(9000)).unwrap(); db.set_position(7, "f", "e", 42, Some(9000)).unwrap();
assert_eq!(listening(), ["h"]); assert_eq!(listening(), ["h", "e"]);
let rows = db.entries_in(7, Some("f"), Filter::All, None, 0, 50, &order).unwrap();
assert_eq!(rows.iter().find(|r| r.guid == "e").unwrap().duration, Some(9000));
// A save without one (before the player knows) keeps the length already measured.
db.set_position(7, "f", "e", 43, None).unwrap();
assert_eq!(listening(), ["h", "e"]);
} }
#[test] #[test]
@@ -1994,6 +2050,21 @@ mod tests {
assert_eq!(after.error_since, None, "a clean check ends the run of failures"); assert_eq!(after.error_since, None, "a clean check ends the run of failures");
} }
#[test]
fn a_pin_survives_saving_the_feeds_settings_and_needs_a_subscription() {
let db = Db::memory().unwrap();
let me = db.create_user("pat", None, false).unwrap();
assert!(!db.set_pinned(me, "f", true).unwrap(), "not subscribed: nothing to pin");
db.subscribe(me, "f").unwrap();
assert!(db.set_pinned(me, "f", true).unwrap());
// set_subscription writes the rest of the row; it must leave the pin alone.
db.set_subscription(me, &Sub { feed_id: "f".into(), auto_download: Some(false), ..Default::default() })
.unwrap();
assert!(db.pinned_feeds(me).unwrap().contains("f"));
db.set_pinned(me, "f", false).unwrap();
assert!(db.pinned_feeds(me).unwrap().is_empty());
}
#[test] #[test]
fn enclosure_url_is_the_dedupe_key() { fn enclosure_url_is_the_dedupe_key() {
let db = Db::memory().unwrap(); let db = Db::memory().unwrap();

View File

@@ -36,7 +36,7 @@ pub struct WebState {
pub fn router(state: WebState) -> Router { pub fn router(state: WebState) -> Router {
Router::new() Router::new()
.route("/", get(index)) .route("/", get(index))
.route("/api/me", get(me)) .route("/api/me", get(me).patch(patch_me))
.route("/api/logout", post(logout)) .route("/api/logout", post(logout))
.route("/api/feeds", get(feeds).post(add_feed)) .route("/api/feeds", get(feeds).post(add_feed))
.route("/api/feeds/{id}", patch(patch_feed).delete(remove_feed)) .route("/api/feeds/{id}", patch(patch_feed).delete(remove_feed))
@@ -59,12 +59,20 @@ pub fn router(state: WebState) -> Router {
.route("/api/users/{id}", patch(patch_user).delete(remove_user)) .route("/api/users/{id}", patch(patch_user).delete(remove_user))
.route("/api/logs", get(logs)) .route("/api/logs", get(logs))
.route("/api/events", get(events)) .route("/api/events", get(events))
.route("/admin", get(admin_page))
.route("/admin.js", get(admin_js))
.route("/media/{id}", get(media)) .route("/media/{id}", get(media))
.layer(middleware::from_fn_with_state(state.clone(), auth)) .layer(middleware::from_fn_with_state(state.clone(), auth))
// Signing in cannot require being signed in, so these sit outside the auth layer. // Signing in cannot require being signed in, so these sit outside the auth layer.
.route("/login", get(login_page)) .route("/login", get(login_page))
.route("/api/login", post(login)) .route("/api/login", post(login))
.route("/icon.png", get(icon)) .route("/icon.png", get(icon))
.route("/favicon.ico", get(favicon))
.route("/favicon.png", get(favicon))
.route("/apple-touch-icon.png", get(touch_icon))
.route("/app.js", get(app_js))
.route("/app.css", get(app_css))
.route("/login.js", get(login_js))
.route("/inter.woff2", get(inter)) .route("/inter.woff2", get(inter))
.layer(middleware::from_fn(access_log)) .layer(middleware::from_fn(access_log))
.with_state(state) .with_state(state)
@@ -312,7 +320,37 @@ async fn me(
) -> Json<serde_json::Value> { ) -> Json<serde_json::Value> {
let url = state.ctx.cfg().web.sign_out_url.clone(); let url = state.ctx.cfg().web.sign_out_url.clone();
let sign_out = (by_proxy && !url.is_empty()).then_some(url); let sign_out = (by_proxy && !url.is_empty()).then_some(url);
Json(serde_json::json!({ "name": user.name, "admin": user.is_admin, "sign_out": sign_out })) let (theme, mode) = state.ctx.db.theme(user.id).unwrap_or_default();
Json(serde_json::json!({
"name": user.name, "admin": user.is_admin, "sign_out": sign_out, "theme": theme, "mode": mode,
}))
}
#[derive(Deserialize)]
struct MePatch {
theme: String,
mode: String,
}
/// Saves the theme to the account, so it follows the person rather than the browser.
async fn patch_me(
State(state): State<WebState>,
user: crate::db::User,
Json(body): Json<MePatch>,
) -> Result<StatusCode, ApiError> {
// The page's script knows the themes; this only makes sure what is kept is safe to write
// into the page's <html> tag, which is where index() puts it.
if !theme_ok(&body.theme, &body.mode) {
return Err(ApiError::bad_request("not a theme"));
}
state.ctx.db.set_theme(user.id, &body.theme, &body.mode)?;
Ok(StatusCode::NO_CONTENT)
}
fn theme_ok(theme: &str, mode: &str) -> bool {
(1..=20).contains(&theme.len())
&& theme.bytes().all(|b| b.is_ascii_lowercase())
&& ["light", "dark", "auto"].contains(&mode)
} }
// ---- accounts: admin only ---- // ---- accounts: admin only ----
@@ -441,7 +479,55 @@ async fn login_page(State(state): State<WebState>, req: Request) -> Response {
if vouched_name(&state.ctx.cfg(), &req).is_some() { if vouched_name(&state.ctx.cfg(), &req).is_some() {
return Redirect::to("/").into_response(); return Redirect::to("/").into_response();
} }
Html(include_str!("../web/login.html")).into_response() ([(header::CACHE_CONTROL, PAGE_CACHE)], Html(include_str!(concat!(env!("OUT_DIR"), "/login.html"))))
.into_response()
}
/// The pages are checked on every visit, so a browser always has the one naming the current
/// scripts; the scripts, named by a hash of their contents (/app.js?v=<hash>, see
/// web/build.mjs), are kept a year and never asked for again. A deploy that changes a script
/// changes its name in the page, and the browser fetches it.
const PAGE_CACHE: &str = "no-cache";
const SCRIPT_CACHE: &str = "public, max-age=31536000, immutable";
/// The page's script, and the sign-in page's. Outside the auth layer, like the icon: the sign-in
/// page needs its own before anyone has signed in, and neither holds anything private.
async fn app_js() -> impl IntoResponse {
script(include_str!(concat!(env!("OUT_DIR"), "/app.js")))
}
/// The stylesheet the app and admin pages share, named by hash in each as the scripts are.
async fn app_css() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "text/css; charset=utf-8"), (header::CACHE_CONTROL, SCRIPT_CACHE)],
include_str!(concat!(env!("OUT_DIR"), "/app.css")),
)
}
/// The admin page and its script go to admins only: not just hidden from everyone else, never
/// sent. Anyone else asking for the page is sent back to the app.
async fn admin_page(State(state): State<WebState>, user: crate::db::User) -> Response {
if !user.is_admin {
return Redirect::to("/").into_response();
}
let theme = state.ctx.db.theme(user.id).unwrap_or_default();
let page = with_theme(include_str!(concat!(env!("OUT_DIR"), "/admin.html")), theme);
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(page)).into_response()
}
async fn admin_js(user: crate::db::User) -> Response {
if !user.is_admin {
return (StatusCode::FORBIDDEN, "only an admin").into_response();
}
script(include_str!(concat!(env!("OUT_DIR"), "/admin.js"))).into_response()
}
async fn login_js() -> impl IntoResponse {
script(include_str!(concat!(env!("OUT_DIR"), "/login.js")))
}
fn script(js: &'static str) -> impl IntoResponse {
([(header::CONTENT_TYPE, "text/javascript; charset=utf-8"), (header::CACHE_CONTROL, SCRIPT_CACHE)], js)
} }
/// The 2004 icon, served once for both pages rather than inlined as base64 into each. The /// The 2004 icon, served once for both pages rather than inlined as base64 into each. The
@@ -453,6 +539,25 @@ async fn icon() -> impl IntoResponse {
) )
} }
/// The logo, squared up with transparent padding: it is 128x121, and a tab icon that is not
/// square can be passed over. /favicon.ico is the same PNG, for a browser that asks for that
/// on its own; behind the auth layer it answered 401, and the tab stayed blank.
async fn favicon() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")],
include_bytes!("../web/favicon.png").as_slice(),
)
}
/// For an iPhone's home screen, which paints a transparent icon's background black, so this
/// one is on white.
async fn touch_icon() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")],
include_bytes!("../web/apple-touch-icon.png").as_slice(),
)
}
/// Inter, the pages' typeface, served from the binary as the icon is, so neither page loads /// Inter, the pages' typeface, served from the binary as the icon is, so neither page loads
/// anything from anyone else. Outside the auth layer for the sign-in page. Its licence, the SIL /// anything from anyone else. Outside the auth layer for the sign-in page. Its licence, the SIL
/// Open Font License, is web/Inter-LICENSE.txt. /// Open Font License, is web/Inter-LICENSE.txt.
@@ -471,8 +576,45 @@ fn constant_time_eq(a: &str, b: &str) -> bool {
a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
} }
async fn index() -> Html<&'static str> { // Built by build.rs from web/index.html and web/src, and minified: attribute values lose their
Html(include_str!("../web/index.html")) // quotes, so ADMIN_LINK and HTML_TAG are spelled the way the minifier leaves them.
const INDEX: &str = include_str!(concat!(env!("OUT_DIR"), "/index.html"));
const ADMIN_LINK: &str = "<a id=admin ";
/// The page, with the log button left out for anyone but an admin. Hiding it from the page's
/// script instead showed it for a moment on every load, until /api/me answered.
async fn index(State(state): State<WebState>, user: crate::db::User) -> impl IntoResponse {
let theme = state.ctx.db.theme(user.id).unwrap_or_default();
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(page_for(user.is_admin, theme)))
}
const HTML_TAG: &str = "<html lang=en>";
/// The page for this person: their theme on its <html> tag, and the link to /admin only if they
/// are an admin. Not hidden for everyone else but left out: hiding it from the page's script
/// showed it for a moment on every load, until /api/me answered (issue #29).
fn page_for(admin: bool, theme: (Option<String>, Option<String>)) -> String {
let mut page = with_theme(INDEX, theme);
if !admin
&& let Some(at) = page.find(ADMIN_LINK)
&& let Some(len) = page[at..].find("</a>")
{
page.replace_range(at..at + len + "</a>".len(), "");
}
page
}
/// A page with the account's theme on its <html> tag, so it is drawn in it from the first frame
/// on any browser.
fn with_theme(page: &str, theme: (Option<String>, Option<String>)) -> String {
let (Some(t), Some(m)) = theme else { return page.to_owned() };
if !theme_ok(&t, &m) {
return page.to_owned();
}
// data-choice is light, dark or auto; data-mode, what the CSS reads, is only known here for
// the first two. theme.ts works Auto out from the system.
let mode = if m == "auto" { String::new() } else { format!(" data-mode={m}") };
page.replacen(HTML_TAG, &format!("<html lang=en data-theme={t} data-choice={m}{mode}>"), 1)
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -510,6 +652,8 @@ struct FeedRow {
unread: i64, unread: i64,
/// Including you. More than one means every file here is shared. /// Including you. More than one means every file here is shared.
subscribers: i64, subscribers: i64,
/// Pinned to the top of your list.
pinned: bool,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -537,6 +681,7 @@ async fn feeds(
.map(|s| (s.feed_id.clone(), s)) .map(|s| (s.feed_id.clone(), s))
.collect(); .collect();
let counts = state.ctx.db.subscriber_counts()?; let counts = state.ctx.db.subscriber_counts()?;
let pinned = state.ctx.db.pinned_feeds(user.id)?;
let mut out = Vec::with_capacity(mine.len()); let mut out = Vec::with_capacity(mine.len());
for sub in &subs { for sub in &subs {
let (id, feed) = (&sub.id, &sub.cfg); let (id, feed) = (&sub.id, &sub.cfg);
@@ -597,6 +742,7 @@ async fn feeds(
downloaded: s.downloaded, downloaded: s.downloaded,
unread: state.ctx.db.unread_count(user.id, id)?, unread: state.ctx.db.unread_count(user.id, id)?,
subscribers: counts.get(id).copied().unwrap_or(0), subscribers: counts.get(id).copied().unwrap_or(0),
pinned: pinned.contains(id),
}); });
} }
Ok(Json(out)) Ok(Json(out))
@@ -780,6 +926,13 @@ impl ApiError {
status: StatusCode::FORBIDDEN, status: StatusCode::FORBIDDEN,
} }
} }
fn not_found(msg: impl Into<String>) -> Self {
Self {
error: anyhow::Error::msg(msg.into()),
status: StatusCode::NOT_FOUND,
}
}
} }
impl IntoResponse for ApiError { impl IntoResponse for ApiError {
@@ -795,6 +948,38 @@ impl IntoResponse for ApiError {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn a_relative_image_resolves_against_the_post() {
let mut b = feed_sanitizer();
let html = r#"<img src="images/a.webp"><a href="/about">x</a><script>bad()</script>"#;
let out = clean_description(&mut b, html, Some("https://example.com/2026/04/post/"));
assert!(out.contains(r#"src="https://example.com/2026/04/post/images/a.webp""#), "{out}");
assert!(out.contains(r#"href="https://example.com/about""#), "{out}");
assert!(!out.contains("bad()"), "{out}");
assert!(out.contains(r#"referrerpolicy="no-referrer""#), "{out}");
assert!(clean_description(&mut b, html, None).contains(r#"src="images/a.webp""#));
}
#[test]
fn only_an_admin_is_sent_the_admin_link() {
// If the markup drifts from ADMIN_LINK, find matches nothing and says nothing.
assert!(page_for(true, (None, None)).contains(ADMIN_LINK));
let page = page_for(false, (None, None));
assert!(!page.contains(ADMIN_LINK) && !page.contains("href=/admin"), "the link is gone");
assert!(page.contains("id=prefs"), "and only the link: the settings button beside it stays");
}
#[test]
fn the_page_arrives_in_the_theme_the_account_chose() {
let page = |t: &str, m: &str| page_for(true, (Some(t.into()), Some(m.into())));
// If the minifier ever writes the tag differently, HTML_TAG matches nothing, silently.
assert!(page("dracula", "dark").contains("<html lang=en data-theme=dracula data-choice=dark data-mode=dark>"));
assert!(page("nordic", "auto").contains("<html lang=en data-theme=nordic data-choice=auto>"));
// Whatever is in the column is written into markup, so only a plain name gets there.
assert!(!page("x onload=alert(1)", "dark").contains("onload"));
assert!(page("modern", "dark\"").contains("<html lang=en>"));
}
#[test] #[test]
fn a_feed_with_a_credential_is_never_popular() { fn a_feed_with_a_credential_is_never_popular() {
let f = |url: &str| crate::config::Feed { let f = |url: &str| crate::config::Feed {
@@ -967,20 +1152,40 @@ fn entry_page(
); );
let mut rows = let mut rows =
db.entries_in(user_id, feed, filter, search, page.offset, page.limit.clamp(1, 200), &order)?; 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. let mut sanitizer = feed_sanitizer();
// Every link opens in a new tab -- ammonia's default rel="noopener noreferrer" already
// keeps that safe -- so following one in show notes never navigates away from ipx.
let mut sanitizer = ammonia::Builder::new();
sanitizer.add_tag_attributes("a", &["target"]).set_tag_attribute_value("a", "target", "_blank");
for row in &mut rows { for row in &mut rows {
if let Some(d) = &row.description { if let Some(d) = &row.description {
row.description = Some(sanitizer.clean(d).to_string()); row.description = Some(clean_description(&mut sanitizer, d, row.link.as_deref()));
} }
} }
let total = db.count_in(user_id, feed, filter, search)?; let total = db.count_in(user_id, feed, filter, search)?;
Ok(Json(EntryPage { total, entries: rows })) Ok(Json(EntryPage { total, entries: rows }))
} }
/// Feed HTML is untrusted: it reaches the page only after ammonia has been through it.
fn feed_sanitizer() -> ammonia::Builder<'static> {
let mut b = ammonia::Builder::new();
// Every link opens in a new tab -- ammonia's default rel="noopener noreferrer" already
// keeps that safe -- so following one in show notes never navigates away from ipx.
b.add_tag_attributes("a", &["target"]).set_tag_attribute_value("a", "target", "_blank");
// An image is asked for without saying it is shown on ipx. A site that refuses images to
// other sites' pages goes by that: jeffgeerling.com answers 403, and his posts showed a
// broken image on iOS and only the alt text on desktop.
b.add_tag_attributes("img", &["referrerpolicy"]).set_tag_attribute_value("img", "referrerpolicy", "no-referrer");
b
}
/// A relative `src` or `href` in a post means relative to the post, not to ipx: The
/// Observation Deck's `images/37k-a-day-bro.webp` came up as a broken image.
fn clean_description(sanitizer: &mut ammonia::Builder, html: &str, link: Option<&str>) -> String {
let base = link.and_then(|l| url::Url::parse(l).ok());
sanitizer.url_relative(match base {
Some(b) => ammonia::UrlRelative::RewriteWithBase(b),
None => ammonia::UrlRelative::PassThrough,
});
sanitizer.clean(html).to_string()
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct NewFeed { struct NewFeed {
url: String, url: String,
@@ -1064,6 +1269,7 @@ struct FeedPatch {
auto_download: Option<bool>, auto_download: Option<bool>,
#[serde(default, deserialize_with = "double_option")] #[serde(default, deserialize_with = "double_option")]
max_new_per_check: Option<Option<usize>>, max_new_per_check: Option<Option<usize>>,
pinned: Option<bool>,
} }
fn double_option<'de, T, D>(de: D) -> Result<Option<Option<T>>, D::Error> fn double_option<'de, T, D>(de: D) -> Result<Option<Option<T>>, D::Error>
@@ -1080,6 +1286,12 @@ async fn patch_feed(
user: crate::db::User, user: crate::db::User,
Json(body): Json<FeedPatch>, Json(body): Json<FeedPatch>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
// Pinning is yours alone too, and means nothing for a feed you do not subscribe to.
if let Some(on) = body.pinned
&& !state.ctx.db.set_pinned(user.id, &id, on)?
{
return Err(ApiError::not_found("you do not subscribe to that feed"));
}
// What one person wants -- which items, whether to fetch them, how many at a time -- // What one person wants -- which items, whether to fetch them, how many at a time --
// is theirs. It goes on their subscription and nobody else sees the change. // is theirs. It goes on their subscription and nobody else sees the change.
if state.ctx.db.subscription(user.id, &id)?.is_some() { if state.ctx.db.subscription(user.id, &id)?.is_some() {

View File

@@ -1,4 +1,5 @@
// Executes web/index.html's script against a stub DOM and fails on anything thrown. // Builds a page from web/src as build.rs does, runs its script against a stub DOM, and fails
// on anything thrown: web/index.html, then web/admin.html in a second run of this file.
// //
// This exists because a ReferenceError at load once blanked the whole UI: a patch // This exists because a ReferenceError at load once blanked the whole UI: a patch
// anchored on a function that no longer existed, so `prefsModal` was referenced but // anchored on a function that no longer existed, so `prefsModal` was referenced but
@@ -6,13 +7,20 @@
// and every server-side test passed too, because the server was fine. // and every server-side test passed too, because the server was fine.
// //
// node tests/page-smoke.js // node tests/page-smoke.js
const fs = require('fs');
const path = require('path');
const vm = require('vm'); const vm = require('vm');
const PAGE = process.argv[2] || 'index.html';
const html = fs.readFileSync(path.join(__dirname, '..', 'web', 'index.html'), 'utf8'); const { buildPage } = require('../web/build.mjs');
const script = html.split('<script>')[1].split('</script>')[0]; // What ships: minified, so an id may have lost its quotes.
const ids = new Set([...html.matchAll(/id="([^"]+)"/g)].map(m => m[1])); const { html, js: script, script: file } = buildPage(PAGE);
if (!/<link rel=stylesheet href="?\/app\.css\?v=[0-9a-f]{12}"?>/.test(html) && PAGE !== 'login.html') {
console.error(`FAIL: ${PAGE} does not load /app.css?v=<hash>`); process.exit(1);
}
if (!new RegExp(`<script src="?/${file.replace('.', '\\.')}\\?v=[0-9a-f]{12}"?>`).test(html)) {
console.error(`FAIL: the page does not load /${file}?v=<hash>`); process.exit(1);
}
// Ids in the page, and in the markup the script builds for its dialogs.
const ids = new Set([...(html + script).matchAll(/\bid=(?:"([^"]+)"|([^\s>"']+))/g)].map(m => m[1] || m[2]));
const missing = []; const missing = [];
const el = (name) => new Proxy({ style: { setProperty(){}, getPropertyValue(){ return ''; } }, dataset: {}, classList: { add(){}, remove(){}, toggle(){}, contains(){ return false; } }, const el = (name) => new Proxy({ style: { setProperty(){}, getPropertyValue(){ return ''; } }, dataset: {}, classList: { add(){}, remove(){}, toggle(){}, contains(){ return false; } },
@@ -60,11 +68,12 @@ const ctx = {
URLSearchParams, encodeURIComponent, decodeURIComponent, parseInt, parseFloat, isNaN, URLSearchParams, encodeURIComponent, decodeURIComponent, parseInt, parseFloat, isNaN,
}; };
ctx.globalThis = ctx; ctx.globalThis = ctx;
ctx.window.location = { href: '' }; ctx.window.location = { href: '', hash: '' };
ctx.location = ctx.window.location;
try { try {
vm.createContext(ctx); vm.createContext(ctx);
vm.runInContext(script, ctx, { filename: 'index.html<script>', timeout: 5000 }); vm.runInContext(script, ctx, { filename: `${PAGE}<script>`, timeout: 5000 });
} catch (e) { } catch (e) {
console.error('FAIL: the page script threw while loading\n ' + e.stack.split('\n').slice(0, 3).join('\n ')); console.error('FAIL: the page script threw while loading\n ' + e.stack.split('\n').slice(0, 3).join('\n '));
process.exit(1); process.exit(1);
@@ -78,19 +87,21 @@ const feed = {
schedule: 'every 6h', schedule_mins: 360, every_mins: 360, schedule: 'every 6h', schedule_mins: 360, every_mins: 360,
last_checked: 1, next_check: 2, entries: 1, downloaded: 0, unread: 1, last_error: null, last_checked: 1, next_check: 2, entries: 1, downloaded: 0, unread: 1, last_error: null,
}; };
const drive = [ const drive = PAGE === 'admin.html' ? [
['drawServer', () => ctx.drawServer()],
['drawAccounts', () => ctx.drawAccounts()],
['drawLogView', () => ctx.drawLogView()],
] : [
['settingsModal', () => ctx.settingsModal(feed)], ['settingsModal', () => ctx.settingsModal(feed)],
['settingsModal (no override)', () => ctx.settingsModal({ ...feed, schedule: null, schedule_mins: null })], ['settingsModal (no override)', () => ctx.settingsModal({ ...feed, schedule: null, schedule_mins: null })],
['downloadLatestModal', () => ctx.downloadLatestModal(feed)], ['downloadLatestModal', () => ctx.downloadLatestModal(feed)],
['removeFeed', () => ctx.removeFeed(feed)], ['removeFeed', () => ctx.removeFeed(feed)],
['prefsModal', () => ctx.prefsModal()], ['prefsModal', () => ctx.prefsModal()],
['usersModal', () => ctx.usersModal()],
['opmlModal', () => ctx.opmlModal()], ['opmlModal', () => ctx.opmlModal()],
['selectFeed (directory)', () => ctx.selectFeed(':directory')], ['selectFeed (directory)', () => ctx.selectFeed(':directory')],
['selectFeed (popular)', () => ctx.selectFeed(':popular')], ['selectFeed (popular)', () => ctx.selectFeed(':popular')],
['selectFeed (currently listening)', () => ctx.selectFeed(':listening')], ['selectFeed (currently listening)', () => ctx.selectFeed(':listening')],
['selectFeed (all subscriptions)', () => ctx.selectFeed(':all')], ['selectFeed (all subscriptions)', () => ctx.selectFeed(':all')],
['logsModal', () => ctx.logsModal()],
['keysModal', () => ctx.keysModal()], ['keysModal', () => ctx.keysModal()],
// `const S` is not reachable from here: top-level const/let do not become properties // `const S` is not reachable from here: top-level const/let do not become properties
// of a vm context the way var and function declarations do. // of a vm context the way var and function declarations do.
@@ -108,10 +119,18 @@ for (const [name, fn] of drive) {
} }
} }
// theme.ts keeps the Settings theme controls in step when Settings is open, and looks before it
// touches them. The admin page has no Settings, so those are the ones it may ask for and not find.
const OPTIONAL = new Set(['#stheme', '#smode', '#smodefield']);
missing.splice(0, missing.length, ...missing.filter(sel => !OPTIONAL.has(sel)));
if (missing.length) { if (missing.length) {
console.error('FAIL: handlers wired to elements that do not exist: ' + [...new Set(missing)].join(', ')); console.error('FAIL: handlers wired to elements that do not exist: ' + [...new Set(missing)].join(', '));
process.exit(1); process.exit(1);
} }
console.log('OK: page script loads clean, every selector it wires at load exists'); console.log(`OK: ${PAGE}: its script loads clean, every selector it wires at load exists`);
// logsModal arms a poll timer; without this the pending interval keeps node alive. // The admin page's log arms a poll timer; without an exit the pending interval keeps node alive.
if (PAGE === 'index.html') {
const r = require('child_process').spawnSync(process.execPath, [__filename, 'admin.html'], { stdio: 'inherit' });
process.exit(r.status);
}
process.exit(0); process.exit(0);

View File

@@ -20,62 +20,130 @@ test('the page loads and lists the configured feeds', async ({ page }) => {
expect(errors, 'the page script must not throw at load').toEqual([]); expect(errors, 'the page script must not throw at load').toEqual([]);
}); });
test('the theme toggle actually changes the theme', async ({ page }) => { test('the script is its own file, cached until a deploy changes it', async ({ page }) => {
// Regression: this button was wired after a line that threw, so it did nothing. const js = page.waitForResponse(r => new URL(r.url()).pathname === '/app.js');
const before = await page.evaluate(() => document.documentElement.dataset.theme || 'system'); const doc = await page.reload();
await page.locator('#theme').click(); const r = await js;
await expect // The page is checked every visit, so it always names the current script...
.poll(() => page.evaluate(() => document.documentElement.dataset.theme)) expect(doc.headers()['cache-control']).toBe('no-cache');
.not.toBe(before); // ...by a hash of its contents, which is why the script itself can be kept for a year.
expect(new URL(r.url()).searchParams.get('v')).toMatch(/^[0-9a-f]{12}$/);
expect(r.headers()['cache-control']).toContain('immutable');
expect(r.headers()['content-type']).toContain('javascript');
expect(await page.locator('script:not([src])').count(), 'no inline script').toBe(0);
// The sign-in page's script loads before signing in.
const login = await page.request.get('/login.js', { headers: { cookie: '' } });
expect(login.status()).toBe(200);
}); });
test('the theme button steps through dark, light and classic, and remembers', async ({ page }) => { test('Settings picks a theme and, where it has both, light, dark or Auto', async ({ page }) => {
const theme = () => page.evaluate(() => document.documentElement.dataset.theme); const root = () => page.evaluate(() => [document.documentElement.dataset.theme, document.documentElement.dataset.mode]);
for (let i = 0; i < 3 && (await theme()) !== 'classic'; i++) await page.locator('#theme').click(); const bg = () => page.evaluate(() => getComputedStyle(document.body).backgroundColor);
expect(await theme()).toBe('classic'); await page.locator('#prefs').click();
await expect(page.locator('#theme')).toHaveAttribute('title', /Classic.*Click for Auto/); await expect(page.locator('#stheme')).toHaveValue((await root())[0]);
await page.locator('#stheme').selectOption('modern');
await page.locator('#smode').selectOption('auto');
// Auto follows the system, live, with no reload.
await page.emulateMedia({ colorScheme: 'light' });
await expect.poll(root).toEqual(['modern', 'light']);
await expect.poll(bg).toBe('rgb(242, 244, 247)'); // Modern's light --bg
await page.emulateMedia({ colorScheme: 'dark' });
await expect.poll(root).toEqual(['modern', 'dark']);
await expect.poll(bg).toBe('rgb(14, 19, 27)'); // Modern's dark --bg
// Dracula, then its light half, Alucard.
await page.locator('#stheme').selectOption('dracula');
await page.locator('#smode').selectOption('dark');
await expect.poll(bg).toBe('rgb(40, 42, 54)'); // #282A36
await page.locator('#smode').selectOption('light');
await expect.poll(bg).toBe('rgb(255, 251, 235)'); // #FFFBEB
// Classic and Paper come one way only, so there is nothing to choose.
await page.locator('#stheme').selectOption('paper');
await expect(page.locator('#smode')).toBeHidden();
await expect.poll(bg).toBe('rgb(242, 238, 222)'); // #F2EEDE
// The save that says Classic: the ones before it may still be answering.
const saved = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH'
&& r.request().postDataJSON().theme === 'classic');
await page.locator('#stheme').selectOption('classic');
await expect(page.locator('#smode')).toBeHidden();
await saved; // kept on the account, not the browser
await page.reload(); await page.reload();
await expect.poll(theme).toBe('classic'); await expect.poll(root).toEqual(['classic', 'light']);
// The 2004 Mac app set its type in Lucida Grande. // The 2004 Mac app set its type in Lucida Grande.
expect(await page.evaluate(() => getComputedStyle(document.body).fontFamily)).toContain('Lucida Grande'); expect(await page.evaluate(() => getComputedStyle(document.body).fontFamily)).toContain('Lucida Grande');
expect(await page.locator('#theme').count(), 'the theme lives in Settings only').toBe(0);
// Back to Nordic, dark: the mode chosen before Classic is kept for the themes that have one.
await page.locator('#prefs').click();
await page.locator('#stheme').selectOption('nordic');
await expect(page.locator('#smode')).toHaveValue('light');
await page.locator('#smode').selectOption('dark');
await expect.poll(bg).toBe('rgb(46, 52, 64)'); // nord0
}); });
test('the theme dropdown in Settings jumps straight to a theme, including Auto', async ({ page }) => { test('the theme is kept on the account, and follows it to another browser', async ({ page, browser }) => {
const theme = () => page.evaluate(() => document.documentElement.dataset.theme);
await page.locator('#prefs').click(); await page.locator('#prefs').click();
await expect(page.locator('#stheme')).toHaveValue(await theme()); const saved = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH');
await page.locator('#stheme').selectOption('flatremix');
expect((await saved).status()).toBe(204);
const saved2 = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH');
await page.locator('#smode').selectOption('light');
await saved2;
await page.locator('#stheme').selectOption('auto'); // Another browser: nothing in its localStorage, and the page still arrives in the theme,
await expect.poll(theme).toBe('auto'); // written onto <html> by the server rather than set once the script has run.
// Auto follows the system; emulating a light system must show the light palette live, const other = await browser.newContext();
// no reload needed, since it is a media query rather than something JS picks per click. const p2 = await other.newPage();
await page.emulateMedia({ colorScheme: 'light' }); const res = await p2.goto(`/?token=${TOKEN}`);
await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).backgroundColor)) expect(await res.text()).toContain('data-theme=flatremix data-choice=light data-mode=light');
.toBe('rgb(242, 244, 247)'); // --bg in the light palette expect(await p2.evaluate(() => [document.documentElement.dataset.theme, document.documentElement.dataset.mode]))
await page.emulateMedia({ colorScheme: 'dark' }); .toEqual(['flatremix', 'light']);
await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).backgroundColor)) await other.close();
.toBe('rgb(14, 19, 27)'); // the bare :root is already dark; Auto adds nothing here
// The header button and the dropdown are the same one setting, not two.
await page.locator('#modalCard .cardacts .btn').first().click(); // Cancel, closing the modal
await page.locator('#theme').click();
expect(await theme()).toBe('dark');
}); });
test('settings opens and saves the global schedule', async ({ page }) => { test('a theme this browser kept before themes were on the account goes up to it once', async ({ browser }) => {
await page.locator('#prefs').click(); // A new account, made by the proxy header on first sight, so it has no theme of its own yet.
await expect(page.locator('#modal.on')).toBeVisible(); const who = `theme-${Date.now()}@example.com`;
await expect(page.locator('#gnum')).toBeVisible(); const ctx = await browser.newContext({ extraHTTPHeaders: { 'X-Test-User': who } });
// From before light and dark: ipx.theme alone, 'light' meaning Modern, light.
await ctx.addInitScript(() => { localStorage.setItem('ipx.theme', 'light'); localStorage.removeItem('ipx.mode'); });
const page = await ctx.newPage();
const saved = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH');
await page.goto('/');
expect(await page.evaluate(() => [document.documentElement.dataset.theme, document.documentElement.dataset.mode]))
.toEqual(['modern', 'light']);
expect((await saved).request().postDataJSON()).toEqual({ theme: 'modern', mode: 'light' });
await ctx.close();
// Anywhere else now, it comes from the account.
const fresh = await browser.newContext({ extraHTTPHeaders: { 'X-Test-User': who } });
const p2 = await fresh.newPage();
const res = await p2.goto('/');
expect(await res.text()).toContain('data-theme=modern data-choice=light');
await fresh.close();
});
test('the admin page saves the global schedule', async ({ page }) => {
// Reached from the header's Admin link, and on its own page, not in Settings (issue #19).
await page.locator('#admin').click();
await expect(page).toHaveURL(/\/admin$/);
await expect(page.locator('#atabs a.on')).toHaveText('Server');
await page.locator('#gnum').fill('4'); await page.locator('#gnum').fill('4');
await page.locator('#gunit').selectOption('h'); await page.locator('#gunit').selectOption('h');
await page.locator('#gsave').click(); await page.locator('#gsave').click();
await expect(page.locator('#modal.on')).toBeHidden(); await expect(page.locator('#toasts')).toContainText('Settings saved');
// It must survive a reload, i.e. actually reach the config. // It must survive a reload, i.e. actually reach the config.
await page.locator('#prefs').click(); await page.reload();
await expect(page.locator('#gnum')).toHaveValue('4'); await expect(page.locator('#gnum')).toHaveValue('4');
await expect(page.locator('#gunit')).toHaveValue('h'); await expect(page.locator('#gunit')).toHaveValue('h');
// And Settings in the app no longer has it.
await page.goto('/');
await page.locator('#prefs').click();
await expect(page.locator('#modalCard')).toContainText('Theme');
await expect(page.locator('#gnum')).toHaveCount(0);
}); });
test('episodes show with their metadata, and the text opens below', async ({ page }) => { test('episodes show with their metadata, and the text opens below', async ({ page }) => {
@@ -120,6 +188,35 @@ test('the three panes are there and the item text lands in the bottom one', asyn
await expect(page.locator('#files [data-a="play"]')).toHaveCount(0); await expect(page.locator('#files [data-a="play"]')).toHaveCount(0);
}); });
test('while an episode plays, its play buttons all say pause, and pause it', async ({ page }) => {
await page.locator('.feed', { hasText: 'Test Show' }).click();
await page.locator('.tabs button', { hasText: 'All' }).first().click();
const downloaded = page.locator('.ep', { has: page.locator('.kind.here') }).first();
await expect(downloaded).toBeVisible({ timeout: 20_000 });
await downloaded.click();
// What the buttons do, not whether this browser decodes the fixture: it does not, reliably,
// and a load error pauses the player, rightly turning every button back to play. So the
// player plays and pauses here as a real one does, events and all, with no file behind it.
await page.evaluate(() => {
let paused = true;
Object.defineProperty(audio, 'paused', { get: () => paused, configurable: true });
audio.play = async () => { paused = false; audio.dispatchEvent(new Event('play')); };
audio.pause = () => { paused = true; audio.dispatchEvent(new Event('pause')); };
});
const pane = page.locator('#files [data-a="play"]');
await pane.click();
await expect.poll(() => page.evaluate(() => !audio.paused)).toBe(true);
// The files pane, the row and the toolbar all follow the player bar, not only the bar.
await expect(pane).toHaveAttribute('title', 'Pause');
await expect(downloaded.locator('[data-a="play"]')).toHaveAttribute('title', 'Pause');
await expect(page.locator('#tbPlay')).toHaveAttribute('title', 'Pause');
await pane.click(); // and pressing it pauses
await expect.poll(() => page.evaluate(() => audio.paused)).toBe(true);
await expect(pane).toHaveAttribute('title', 'Play');
await expect(page.locator('#tbPlay')).toHaveAttribute('title', 'Play the selected item');
await page.locator('#pclose').click();
});
test('a downloaded file that is not audio gets no player', async ({ page }) => { test('a downloaded file that is not audio gets no player', async ({ page }) => {
// Regression: anything with a file got an <audio> element and a play button, so a blog's // Regression: anything with a file got an <audio> element and a play button, so a blog's
// header image rendered as a broken player. // header image rendered as a broken player.
@@ -200,6 +297,26 @@ test('Currently Listening, its own place below Popular, resumes an episode you s
await expect(row.locator('.eq')).toBeHidden(); await expect(row.locator('.eq')).toBeHidden();
}); });
test('a player nobody has played since it last saved does not save again', async ({ page }) => {
// A tab left paused at 41:15 saved that as it reloaded, over the 32:48 another had reached,
// and the episode dropped out of Currently Listening. The fixture audio does not decode, so
// this stands in for a loaded file paused at 2 seconds and counts what savePos sends.
const sent = await page.evaluate(() => {
Object.defineProperty(audio, 'readyState', { get: () => 4 });
Object.defineProperty(audio, 'currentTime', { get: () => 2, set() {} });
let n = 0;
navigator.sendBeacon = () => (n++, true);
player.guid = 'ui-2'; player.feed = 'test-show'; player.entry = null; player.moved = false;
savePos(); // what a reload, a pause or the close button calls
const idle = n;
player.moved = true; // what playing sets
savePos();
savePos(); // and once saved, it is idle again
return [idle, n];
});
expect(sent).toEqual([0, 1]);
});
test('the filter tabs change what is listed', async ({ page }) => { test('the filter tabs change what is listed', async ({ page }) => {
await page.locator('.feed', { hasText: 'Test Show' }).click(); await page.locator('.feed', { hasText: 'Test Show' }).click();
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 }); await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
@@ -213,6 +330,32 @@ test('the filter tabs change what is listed', async ({ page }) => {
await expect(page.locator('#count')).toContainText('0 items'); await expect(page.locator('#count')).toContainText('0 items');
}); });
test('on the Unread tab an item stays while you read it and goes when you move on', async ({ page }) => {
await page.locator('.feed', { hasText: 'Test Show' }).click();
await page.locator('.tabs button', { hasText: 'All' }).first().click();
await expect(page.locator('.ep').nth(1)).toBeVisible({ timeout: 20_000 });
// Earlier tests read things; make the first two unread with their own dots.
for (const i of [0, 1]) {
const row = page.locator('.ep').nth(i);
if (await row.evaluate(r => r.classList.contains('read'))) {
await row.locator('[data-a="read"]').click();
await expect(page.locator('.ep').nth(i)).not.toHaveClass(/\bread\b/);
}
}
await page.locator('.tabs button', { hasText: 'Unread' }).first().click();
const first = page.locator('.ep').first();
const guid = await first.getAttribute('data-guid');
await first.click();
const it = page.locator(`.ep[data-guid="${guid}"]`);
await expect(it).toHaveClass(/\bread\b/);
await page.waitForTimeout(1500); // past the SSE refresh debounce and loadFeeds
await expect(it).toBeVisible();
await page.locator('.ep').nth(1).click();
await expect(it).toHaveCount(0);
await page.locator('.tabs button', { hasText: 'All' }).first().click();
});
test('a feed URL is editable and has a copy button', async ({ page }) => { test('a feed URL is editable and has a copy button', async ({ page }) => {
await page.locator('.feed', { hasText: 'Test Show' }).click(); await page.locator('.feed', { hasText: 'Test Show' }).click();
await page.locator('#content .acts [data-a="settings"]').click(); await page.locator('#content .acts [data-a="settings"]').click();
@@ -228,7 +371,7 @@ test('a feed URL is editable and has a copy button', async ({ page }) => {
}); });
test('the log view has tabs and shows daemon traffic', async ({ page }) => { test('the log view has tabs and shows daemon traffic', async ({ page }) => {
await page.locator('#logs').click(); await page.goto('/admin#log');
await expect(page.locator('#logbox')).toBeVisible(); await expect(page.locator('#logbox')).toBeVisible();
await expect(page.locator('#logtabs button')).toHaveCount(4); await expect(page.locator('#logtabs button')).toHaveCount(4);
@@ -426,18 +569,22 @@ test('a second person has their own feeds and their own read state', async ({ br
// Sam subscribes to nothing yet, so sees nothing -- the admin's feeds are not theirs. // Sam subscribes to nothing yet, so sees nothing -- the admin's feeds are not theirs.
await expect(page.locator('#feedlist')).toContainText('No feeds.'); await expect(page.locator('#feedlist')).toContainText('No feeds.');
// Settings stays: Sam has their own subscriptions to export and import, and the // Settings stays: Sam has their own theme and subscriptions. The admin page -- the server's
// schedule and quota are worth seeing even without a say in them. Only the log and the // settings, the accounts and the log -- is an admin's alone, and Sam is not even sent the
// users screen -- and the server -- are an admin's alone. // link to it, never mind the page.
await expect(page.locator('#prefs')).toBeVisible(); await expect(page.locator('#prefs')).toBeVisible();
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('#gsave')).toBeHidden(); await expect(page.locator('#modalCard')).toContainText('Only an admin changes this');
await expect(page.locator('#gusers')).toBeHidden();
await page.locator('#modalCard .cardacts .btn').first().click(); await page.locator('#modalCard .cardacts .btn').first().click();
// Hiding the button is not the guard; the server is. await expect(page.locator('#admin')).toHaveCount(0);
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.
await page.goto('/admin');
await expect(page).toHaveURL(/:8791\/$/);
expect((await page.request.get('/admin.js')).status()).toBe(403);
// Hiding the way in is not the guard; the server is.
expect((await page.request.get('/api/users')).status()).toBe(403); expect((await page.request.get('/api/users')).status()).toBe(403);
await expect(page.locator('#logs')).toBeHidden();
expect((await page.request.get('/api/logs')).status()).toBe(403); expect((await page.request.get('/api/logs')).status()).toBe(403);
// Subscribing to a feed the admin already has costs no second fetch: same feed, same // Subscribing to a feed the admin already has costs no second fetch: same feed, same
@@ -493,11 +640,12 @@ test('deleting a shared file warns that it is everyone\'s copy', async ({ page }
// Every row says "Admin" on its checkbox, so match the name exactly. // Every row says "Admin" on its checkbox, so match the name exactly.
const userRow = (page, name) => const userRow = (page, name) =>
page.locator('#modalCard [data-id]').filter({ has: page.locator('b', { hasText: new RegExp(`^${name}$`) }) }); page.locator('#accounts [data-id]').filter({ has: page.locator('b', { hasText: new RegExp(`^${name}$`) }) });
async function openUsers(page) { async function openUsers(page) {
await page.locator('#prefs').click(); await page.goto('/admin');
await page.locator('#gusers').click(); await page.locator('#atabs a', { hasText: 'Accounts' }).click();
await expect(page).toHaveURL(/\/admin#accounts$/);
await expect(userRow(page, 'admin')).toBeVisible(); await expect(userRow(page, 'admin')).toBeVisible();
} }
@@ -743,9 +891,10 @@ test('Popular lists what everyone here reads, but never a private feed', async (
await pick('tabs', 'All').click(); await pick('tabs', 'All').click();
await expect(tiles).toHaveCount(dir.length); await expect(tiles).toHaveCount(dir.length);
// Add a feed opened over Directory fills its own list, not the pane behind it. // Add a feed is for an address; Popular and Directory are where you browse (issue #30).
await piper.locator('#addFeed').click(); await piper.locator('#addFeed').click();
await expect(piper.locator('#modalCard .childrow', { hasText: 'Test Show' })).toBeVisible(); await expect(piper.locator('#nurl')).toBeVisible();
await expect(piper.locator('#modalCard .childrow')).toHaveCount(0);
await expect(tiles).toHaveCount(dir.length); await expect(tiles).toHaveCount(dir.length);
await piper.locator('#modalCard button[title="Cancel"]').click(); await piper.locator('#modalCard button[title="Cancel"]').click();
@@ -822,9 +971,7 @@ test('one action, one icon: the toolbar, the page and every dialog agree', async
const dialogs = [ const dialogs = [
() => page.locator('#addFeed').click(), () => page.locator('#addFeed').click(),
() => page.locator('#prefs').click(), () => page.locator('#prefs').click(),
async () => { await page.locator('#prefs').click(); await page.locator('#gusers').click(); },
async () => { await page.locator('#prefs').click(); await page.locator('#gopml').click(); }, async () => { await page.locator('#prefs').click(); await page.locator('#gopml').click(); },
() => page.locator('#logs').click(),
() => page.locator('#content .acts [data-a="settings"]').click(), () => page.locator('#content .acts [data-a="settings"]').click(),
() => page.locator('#content .acts [data-a="dl"]').click(), () => page.locator('#content .acts [data-a="dl"]').click(),
() => page.locator('#content .acts [data-a="rm"]').click(), () => page.locator('#content .acts [data-a="rm"]').click(),
@@ -849,6 +996,9 @@ test('All Subscriptions marks everything read, across every feed', async ({ page
// its own button makes it unread again. // its own button makes it unread again.
await page.locator('.ep').first().click(); await page.locator('.ep').first().click();
await page.locator('#detail [data-a="read"][title="Mark unread"]').click(); await page.locator('#detail [data-a="read"][title="Mark unread"]').click();
// The button turns only once the server has it. The badge was no proof: it was seldom 0 to
// begin with, and a mark-unread still in flight could land after the read-all below.
await expect(page.locator('#detail [data-a="read"][title="Mark read"]')).toBeVisible();
await expect(all.locator('.badge')).not.toHaveText('0'); await expect(all.locator('.badge')).not.toHaveText('0');
page.once('dialog', d => d.accept()); page.once('dialog', d => d.accept());
@@ -995,3 +1145,157 @@ test('the pinned heading sits over its pins, and the page is set in Inter', asyn
expect((await page.request.get('/inter.woff2')).headers()['content-type']).toBe('font/woff2'); expect((await page.request.get('/inter.woff2')).headers()['content-type']).toBe('font/woff2');
expect(await page.evaluate(() => document.fonts.ready.then(() => document.fonts.check('14px Inter')))).toBe(true); expect(await page.evaluate(() => document.fonts.ready.then(() => document.fonts.check('14px Inter')))).toBe(true);
}); });
test.describe('on a phone', () => {
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
test('a downloaded file can be deleted, from above the show notes', async ({ page }) => {
await page.locator('#burger').click();
await page.locator('.feed', { hasText: 'Test Show' }).click();
await page.locator('.tabs button', { hasText: 'Downloaded' }).first().click();
await page.locator('.ep').first().click();
const del = page.locator('#detail [data-a="del"]');
await expect(del).toBeInViewport();
// Below a long set of notes it was screens down and looked missing (issue #21).
expect(await page.locator('#detail').evaluate(d =>
!!(d.querySelector('.encbox').compareDocumentPosition(d.querySelector('.dbody')) & Node.DOCUMENT_POSITION_FOLLOWING)))
.toBe(true);
});
});
test('a file not yet downloaded has its icon in line with the rest of its row', async ({ page }) => {
await page.locator('#feedlist .place', { hasText: 'All Subscriptions' }).click();
await expect(page.locator('.ep .dlbar').first()).toBeAttached({ timeout: 20_000 });
// The icon's middle against the date's, downloaded or not. The download bar used to take a
// line of its own and lift the icon of every pending file (issue #31).
const offsets = await page.$$eval('.ep', rows => rows.map(r => {
const k = r.querySelector('.file .kind'), d = r.querySelector('.date');
if (!k || !d) return null;
const a = k.getBoundingClientRect(), b = d.getBoundingClientRect();
return Math.round((a.top + a.height / 2) - (b.top + b.height / 2));
}).filter(x => x !== null));
expect(offsets.length).toBeGreaterThan(1);
for (const o of offsets) expect(Math.abs(o)).toBeLessThanOrEqual(1);
});
test('the favicon is the logo, square, from both pages', async ({ page }) => {
await expect(page.locator('link[rel="icon"]')).toHaveAttribute('href', '/favicon.png');
// A browser asks for /favicon.ico on its own, signed in or not.
for (const path of ['/favicon.ico', '/favicon.png', '/apple-touch-icon.png']) {
const r = await page.request.get(path, { headers: { cookie: '' } });
expect(r.status(), path).toBe(200);
expect(r.headers()['content-type'], path).toBe('image/png');
}
});
test('a feed error is marked in the same column as the folder triangles', async ({ page }) => {
await expect(page.locator('.feed.group .chev').first()).toBeVisible();
if ((await page.locator('.feed.group .chev').first().getAttribute('aria-expanded')) !== 'true')
await page.locator('.feed.group .chev').first().click();
// Faked in the page: no fixture feed fails. A feed on its own, and one inside a folder.
await page.evaluate(() => {
S.feeds.find(f => f.group).last_error = 'HTTP 404';
S.feeds.find(f => !f.group && !S.feeds.some(c => c.group === f.id)).last_error = 'timed out';
renderFeeds();
});
await expect(page.locator('.ferr')).toHaveCount(2);
await expect(page.locator('.ferr svg')).toHaveCount(2); // the icon, not a "!"
await expect(page.locator('.chev.bad')).toHaveCount(1); // the folder holding one
const xs = await page.$$eval('.chev, .ferr', els =>
els.map(e => { const r = e.getBoundingClientRect(); return Math.round(r.left + r.width / 2); }));
expect(new Set(xs).size, JSON.stringify(xs)).toBe(1);
await page.reload(); // put the real list back
});
test.describe('touch gestures on a phone', () => {
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
// Playwright's touchscreen only taps; a drag goes through the DevTools protocol.
async function drag(page, from, to) {
const cdp = await page.context().newCDPSession(page);
const steps = 8;
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [from] });
for (let i = 1; i <= steps; i++)
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{
x: from.x + (to.x - from.x) * i / steps, y: from.y + (to.y - from.y) * i / steps }] });
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
}
test('a swipe moves between items, and right from the first goes back to the list', async ({ page }) => {
await page.locator('#burger').click();
await page.locator('.feed', { hasText: 'Test Show' }).click();
await page.locator('.tabs button', { hasText: 'All' }).first().click();
await expect(page.locator('.ep').nth(1)).toBeVisible({ timeout: 20_000 });
const titles = await page.locator('.ep .t').allTextContents();
await page.locator('.ep').first().click();
const shown = page.locator('#detail .dt');
await expect(shown).toHaveText(titles[0]);
await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left: the next item
await expect(shown).toHaveText(titles[1]);
await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left on the last: stays
await expect(shown).toHaveText(titles[1]);
await drag(page, { x: 80, y: 400 }, { x: 300, y: 410 }); // right: the one before
await expect(shown).toHaveText(titles[0]);
await drag(page, { x: 200, y: 300 }, { x: 210, y: 600 }); // down: a scroll, not a swipe
await expect(shown).toHaveText(titles[0]);
await drag(page, { x: 80, y: 400 }, { x: 300, y: 410 }); // right on the first: the list
await expect(page.locator('body')).not.toHaveClass(/reading/);
});
test('pulling the list down from its top checks the feed for new items', async ({ page }) => {
await page.locator('#burger').click();
await page.locator('.feed', { hasText: 'Test Show' }).click();
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
const box = await page.locator('#list').boundingBox();
const fetch = page.waitForRequest(r => r.url().endsWith('/api/fetch') && r.method() === 'POST');
await drag(page, { x: 200, y: box.y + 20 }, { x: 200, y: box.y + 220 });
expect((await fetch).postDataJSON()).toEqual({ feed: 'test-show', force: true });
await expect(page.locator('#pulltip')).toHaveCount(0); // the note goes on letting go
});
});
test.describe('an item with no files, on a phone', () => {
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
test('shows no files box at all', async ({ page }) => {
await page.locator('#burger').click();
await page.locator('#feedlist .place', { hasText: 'All Subscriptions' }).click();
await page.locator('.tabs button', { hasText: 'All' }).first().click();
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
// An item with no enclosure, found from the list the page itself has.
const guid = await page.evaluate(() => S.entries.find(e => !e.enclosures.length)?.guid);
expect(guid, 'the fixtures have an item with no files').toBeTruthy();
await page.locator(`.ep[data-guid="${guid}"]`).click();
await expect(page.locator('#detail .dt')).toBeVisible();
await expect(page.locator('#detail .encbox')).toHaveCount(0);
await expect(page.locator('#detail')).not.toContainText('No files');
});
});
test('a pinned feed, even one from inside a folder, goes to the top of the list', async ({ page }) => {
const rows = page.locator('#feedlist .feed');
const group = page.locator('.feed.group').first();
if ((await group.locator('.chev').getAttribute('aria-expanded')) !== 'true') await group.locator('.chev').click();
const child = page.locator('.feed.child').first();
const name = (await child.locator('.txt b').textContent()).trim();
await child.click();
await page.locator('#content .acts [data-a="pin"]').click();
// First in the list, out of its folder, marked, and with the rule under it.
await expect(rows.first().locator('.txt b')).toHaveText(name);
await expect(rows.first()).toHaveClass(/\bpinned\b/);
await expect(rows.first()).not.toHaveClass(/\bchild\b/);
await expect(rows.first()).toHaveClass(/\blastpin\b/);
await expect(page.locator('.feed.child', { hasText: name })).toHaveCount(0);
await expect(page.locator('#content .acts [data-a="pin"]')).toHaveAttribute('aria-pressed', 'true');
// It is on the account: a reload keeps it.
await page.reload();
await expect(rows.first().locator('.txt b')).toHaveText(name);
// Unpinned, it goes back into its folder.
await rows.first().click();
await page.locator('#content .acts [data-a="pin"]').click();
await expect(page.locator('.feed.pinned')).toHaveCount(0);
await expect(page.locator('.feed.child', { hasText: name })).toHaveCount(1);
});

12
tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
// Type-checks web/src (npx tsc). Nothing is emitted: web/build.mjs does that with swc.
// The files are one script in one scope, not modules, which is why there are no imports.
"compilerOptions": {
"target": "es2022",
"lib": ["es2022", "dom", "dom.iterable"],
"noEmit": true,
"strict": false,
"skipLibCheck": true
},
"include": ["web/src/*.ts"]
}

34
web/admin.html Normal file
View File

@@ -0,0 +1,34 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light">
<title>iPodderX admin</title>
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="stylesheet" data-src="app.css">
</head>
<body class="adminpage">
<!-- The server sends this page, and its script, to admins only. -->
<header id="topbar">
<a class="btn ico" href="/" title="Back to iPodderX" aria-label="Back to iPodderX" data-icon="left"></a>
<img class="logo" src="/icon.png" alt="" width="26">
<h1>Admin</h1>
<span class="grow"></span>
<nav class="tabs" id="atabs">
<a href="#server" data-t="server">Server</a>
<a href="#accounts" data-t="accounts">Accounts</a>
<a href="#log" data-t="log">Log</a>
</nav>
</header>
<main class="wrap plain" id="admin">
<section id="server" hidden></section>
<section id="accounts" hidden></section>
<section id="log" hidden></section>
</main>
<div id="toasts"></div>
<script data-src="web/src"></script>
</body>
</html>

847
web/app.css Normal file
View File

@@ -0,0 +1,847 @@
/* Inter, served by ipx itself (/inter.woff2) rather than a font CDN, so the page asks nothing of
anyone else. One variable file covers every weight used here; italics are synthesized. Classic
keeps Lucida Grande, the 2004 app's face. */
@font-face{font-family:Inter;src:url(/inter.woff2) format("woff2");font-weight:100 900;font-display:swap}
/* Palette taken from the 2004 iPodderX icon: the silver device body, the blue
screen, and the amber EQ bars. Hex values in comments are sampled straight from it. */
:root {
--bg:#0e131b; /* the screen's navy (#314B74), taken right down */
--panel:#151c27;
--panel2:#1c2431;
--raise:#25303f;
--line:#2c3849;
--fg:#f5f5f5; /* #F5F5F5 device highlight */
--dim:#95a0b1; /* #95A0B1 straight from the icon's blue-grey */
--faint:#7a8799; /* lifted from the icon ramp until it clears AA at small sizes */
--accent:#92b2e6; /* #92B2E6 the screen blue */
--accent2:#f49e2c; /* #F49E2C the EQ bars */
--ink:#0e131b; /* text on an accent fill */
--good:#6fbf8b;
--warn:#f49e2c; /* the amber doubles as the pending colour */
--bad:#e2705f;
--shadow:0 8px 28px rgba(6,10,16,.55);
}
/* Every other theme overrides the same variables, under data-theme and data-mode. The page's
script sets data-mode to light or dark, working Auto out from the system, so each theme has
one block per variant here and none needs repeating under a media query. */
:root[data-theme="modern"][data-mode="light"] {
--bg:#f2f4f7;
--panel:#ffffff; /* #FFFFFF device body */
--panel2:#e9edf3;
--raise:#dde3ec;
--line:#d6d6d6; /* #D6D6D6 device edge */
--fg:#1a1a1a; /* #1A1A1A icon outline */
--dim:#606060; /* #606060 */
--faint:#767676; /* between the icon's #929292 and #606060, to clear AA */
--accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */
--accent2:#9a5f0a; /* the EQ amber, taken down until white on it clears AA */
--ink:#ffffff;
--good:#2f7d4f;
--warn:#b06f10;
--bad:#b3402f;
--shadow:0 8px 28px rgba(45,83,145,.14);
}
/* Dracula, and Alucard, its light half, from draculatheme.com/spec. The spec's comment colour
(#6272A4, #6C664B) is too faint for small text on its own background, so --faint is lifted. */
:root[data-theme="dracula"] {
--bg:#282a36;
--panel:#21222c;
--panel2:#343746;
--raise:#44475a; /* selection */
--line:#414558;
--fg:#f8f8f2;
--dim:#c9cbd6;
--faint:#9ea8c7;
--accent:#bd93f9; /* purple */
--accent2:#ff79c6; /* pink */
--ink:#282a36;
--good:#50fa7b;
--warn:#ffb86c;
--bad:#ff8080;
--shadow:0 8px 28px rgba(0,0,0,.45);
}
:root[data-theme="dracula"][data-mode="light"] {
--bg:#fffbeb;
--panel:#f7f2df;
--panel2:#efe9d3;
--raise:#cfcfde; /* selection */
--line:#ddd6bd;
--fg:#1f1f1f;
--dim:#454137;
--faint:#635d44;
--accent:#644ac9;
--accent2:#a3144d;
--ink:#ffffff;
--good:#14710a;
--warn:#a34d14;
--bad:#b83322;
--shadow:0 8px 28px rgba(108,102,75,.18);
}
/* Material 3's baseline scheme: surface, the surface containers, outline and primary. */
:root[data-theme="material"] {
--bg:#141218; /* surface */
--panel:#1d1b20; /* surface-container-low */
--panel2:#211f26; /* surface-container */
--raise:#36343b; /* surface-container-highest */
--line:#49454f; /* outline-variant */
--fg:#e6e0e9; /* on-surface */
--dim:#cac4d0; /* on-surface-variant */
--faint:#9a95a0; /* outline, a shade up to clear AA on the containers */
--accent:#d0bcff; /* primary */
--accent2:#efb8c8; /* tertiary */
--ink:#381e72; /* on-primary */
--good:#8fd6a0;
--warn:#f2c46b;
--bad:#f2b8b5; /* error */
--shadow:0 8px 28px rgba(0,0,0,.5);
}
:root[data-theme="material"][data-mode="light"] {
--bg:#fef7ff;
--panel:#f7f2fa;
--panel2:#f3edf7;
--raise:#e6e0e9;
--line:#cac4d0;
--fg:#1d1b20;
--dim:#49454f;
--faint:#67626c; /* outline (#79747E), a shade down to clear AA on the containers */
--accent:#6750a4;
--accent2:#7d5260;
--ink:#ffffff;
--good:#2b6c3c;
--warn:#7c5800;
--bad:#b3261e;
--shadow:0 8px 28px rgba(103,80,164,.14);
}
/* Adwaita, from libadwaita's own CSS variables: view, window and sidebar backgrounds, the blue
accent, and its destructive, success and warning colours. */
:root[data-theme="adwaita"] {
--bg:#1d1d20; /* view */
--panel:#2e2e32; /* sidebar, headerbar */
--panel2:#222226; /* window */
--raise:#3a3a3f;
--line:#3f3f45;
--fg:#ffffff;
--dim:#c4c4c8;
--faint:#a0a0a7;
--accent:#81d0ff;
--accent2:#3584e4;
--ink:#1d1d20;
--good:#78e9ab;
--warn:#ffc252;
--bad:#ff938c;
--shadow:0 8px 28px rgba(0,0,0,.5);
}
:root[data-theme="adwaita"][data-mode="light"] {
--bg:#ffffff;
--panel:#ebebed;
--panel2:#fafafb;
--raise:#dcdce0;
--line:#d9d9dd;
--fg:#333338; /* rgb(0 0 6 / 80%) on white */
--dim:#57575d;
--faint:#66666c;
--accent:#0461be;
--accent2:#3584e4;
--ink:#ffffff;
--good:#00753a;
--warn:#905400;
--bad:#c30000;
--shadow:0 8px 28px rgba(0,0,6,.12);
}
/* Flat Remix, from its GTK theme's _colors.scss: base and bg, fg, the selection blue, and its
link colours, which are the selection blue taken down (light) or up (dark) until readable. */
:root[data-theme="flatremix"] {
--bg:#272a34; /* base */
--panel:#23262f; /* bg: base darkened 2% */
--panel2:#2e323d;
--raise:#363b48;
--line:#3a3f4c;
--fg:#eeeeec;
--dim:#babec8;
--faint:#959baa;
--accent:#8fb6ff; /* link */
--accent2:#fd7d00; /* warning orange */
--ink:#1a1c23;
--good:#4cc28c;
--warn:#ff9a3c;
--bad:#ff6b6b;
--shadow:0 8px 28px rgba(0,0,0,.3);
}
:root[data-theme="flatremix"][data-mode="light"] {
--bg:#fafafa; /* base */
--panel:#ffffff; /* bg */
--panel2:#f0f0f1;
--raise:#e4e5e7;
--line:#d9d9d9; /* borders: bg darkened 15% */
--fg:#22252b;
--dim:#5c616c; /* fg */
--faint:#686d78;
--accent:#0060f0; /* link */
--accent2:#fd7d00;
--ink:#ffffff;
--good:#23794f;
--warn:#a85400;
--bad:#d41919;
--shadow:0 8px 28px rgba(0,0,0,.1);
}
/* Paper: a single light palette, black on the colour of paper, colour kept for what matters.
Its blue and red are taken down a shade to clear AA on that background. */
:root[data-theme="paper"] {
--bg:#f2eede;
--panel:#ebe7d7;
--panel2:#e4e0d0;
--raise:#d8d5c7; /* highlight */
--line:#cdc9b9;
--fg:#000000;
--dim:#3d3a33;
--faint:#5a564c;
--accent:#1a5fae;
--accent2:#b58900;
--ink:#ffffff;
--good:#216609;
--warn:#795a00;
--bad:#b1331f;
--shadow:0 8px 28px rgba(0,0,0,.12);
}
/* Nordic: the Nord palette. Polar Night for the dark half, Snow Storm for the light, Frost for
the accent and Aurora for the rest. Aurora red and Frost blue are shifted until they read. */
:root[data-theme="nordic"] {
--bg:#2e3440; /* nord0 */
--panel:#3b4252; /* nord1 */
--panel2:#434c5e; /* nord2 */
--raise:#4c566a; /* nord3 */
--line:#434c5e;
--fg:#eceff4; /* nord6 */
--dim:#d8dee9; /* nord4 */
--faint:#b4bccb;
--accent:#93c9d8; /* nord8, a shade up */
--accent2:#ebcb8b; /* nord13 */
--ink:#2e3440;
--good:#b0c99b; /* nord14, a shade up */
--warn:#ebcb8b;
--bad:#f2aeb4;
--shadow:0 8px 28px rgba(0,0,0,.35);
}
:root[data-theme="nordic"][data-mode="light"] {
--bg:#eceff4; /* nord6 */
--panel:#e5e9f0; /* nord5 */
--panel2:#d8dee9; /* nord4 */
--raise:#cdd4e0;
--line:#c5cedb;
--fg:#2e3440; /* nord0 */
--dim:#3b4252; /* nord1 */
--faint:#4c566a; /* nord3 */
--accent:#3a5e8a;
--accent2:#b0674e;
--ink:#ffffff;
--good:#446632;
--warn:#7e590e;
--bad:#9c3f49;
--shadow:0 8px 28px rgba(46,52,64,.15);
}
/* Classic: the 2004 Mac app. Colours here; the chrome it needs is at the end of the sheet. */
:root[data-theme="classic"] {
color-scheme:light;
--bg:#ffffff;
--panel:#e7ebf1; /* the source list's pale blue-grey */
--panel2:#f2f2f2;
--raise:#d5dce7;
--line:#a9a9a9;
--fg:#000000;
--dim:#444444;
--faint:#666666;
--accent:#3875d7; /* Aqua selection blue */
--accent2:#2a5db0;
--ink:#ffffff;
--good:#237a23;
--warn:#a15f00;
--bad:#c42b1c;
--shadow:0 4px 16px rgba(0,0,0,.28);
}
*{box-sizing:border-box}
/* A rule that sets display beats the UA's [hidden], and several below do. */
[hidden]{display:none!important}
html,body{height:100%}
body{
margin:0;background:var(--bg);color:var(--fg);
font:14.5px/1.55 Inter,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
display:grid;grid-template-rows:auto 1fr auto auto;overflow:hidden;
/* iOS Safari's address bar collapses and expands without firing a resize, so 100vh is
measured against whichever state happened to be current -- sized too tall while the bar
is showing, which puts the topbar (the hamburger included) under Safari's own chrome,
where a tap never reaches the page. Only a hard refresh reset it, forcing 100vh to be
recomputed. 100dvh tracks the real visible viewport as the bar moves; the 100vh above is
the fallback for a browser that does not know dvh. */
height:100vh;height:100dvh;
}
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
a{color:var(--accent)}
/* Inset wherever a parent clips its overflow, or the toolbar and the feed list cut the ring off. */
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.tgroup button:focus-visible,.tgroup a:focus-visible,.feed:focus-visible,.place:focus-visible,.chev:focus-visible{outline-offset:-2px}
@media (prefers-reduced-motion:reduce){
*,*::before,*::after{animation:none!important;transition:none!important}
}
::-webkit-scrollbar{width:10px;height:10px}
::-webkit-scrollbar-thumb{background:var(--raise);border-radius:6px}
::-webkit-scrollbar-track{background:transparent}
/* ---------- shell ---------- */
#shell{display:grid;grid-template-columns:290px 1fr;min-height:0;min-width:0;overflow:hidden}
/* [ hides the feed list. A phone slides it over the page instead, so this is for wider screens. */
@media (min-width:821px){
body.nosb #shell{grid-template-columns:minmax(0,1fr)}
body.nosb #sidebar{display:none}
}
#sidebar{
background:var(--panel);border-right:1px solid var(--line);
display:flex;flex-direction:column;min-height:0;
}
.brand{display:flex;align-items:center;gap:9px;padding:14px 14px 10px}
.brand .logo{
width:30px;height:30px;flex:none;object-fit:contain;
}
.brand h1{font-size:16px;margin:0;font-weight:650;letter-spacing:-.01em;color:var(--fg);flex:1}
.iconbtn{
width:30px;height:30px;border-radius:8px;display:grid;place-items:center;
color:var(--dim);flex:none;
}
.iconbtn:hover{background:var(--raise);color:var(--fg)}
/* One toolbar across the window, as the original had: grouped buttons, search on the right. */
#topbar{
display:flex;align-items:center;gap:10px;padding:7px 12px;min-width:0;overflow:hidden;
background:var(--panel);border-bottom:1px solid var(--line);
}
#topbar .grow{flex:1}
#topbar #epSearch{width:260px;flex:none}
.tgroup{display:flex;flex:none;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--panel2)}
.tgroup button,.tgroup a{padding:5px 11px;min-width:34px;color:var(--dim);font-size:14px;line-height:1.3}
.tgroup a{display:inline-grid;place-items:center}
.tgroup button+button,.tgroup button+a{border-left:1px solid var(--line)}
.tgroup button:hover:not(:disabled),.tgroup a:hover{background:var(--raise);color:var(--fg)}
.tgroup button:disabled{opacity:.35;cursor:default}
.sidetools button,.sidefoot button{
flex:1;background:var(--panel2);border:1px solid var(--line);border-radius:8px;
padding:6px 8px;font-size:12.5px;color:var(--dim);
}
.sidetools button:hover,.sidefoot button:hover{border-color:var(--accent);color:var(--fg)}
/* Settings and the log are housekeeping: they sit under the feeds, out of the way. */
.sidefoot{
display:flex;flex-direction:column;gap:7px;padding:8px 12px;
border-top:1px solid var(--line);flex:none;
}
.sidefoot .row{display:flex;gap:6px}
.who{display:flex;align-items:center;gap:8px;font-size:11.5px;color:var(--faint)}
.who span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.who button{flex:none;background:none;border:0;padding:2px 4px;color:var(--faint);text-decoration:underline}
.who button:hover{color:var(--fg)}
.sidefoot button{display:inline-flex;align-items:center;justify-content:center;gap:6px}
.sidefoot i{font-style:normal;opacity:.75}
.searchwrap{padding:0 12px 8px}
input[type=search],input[type=text],input[type=password],input[type=number],select{
width:100%;background:var(--bg);border:1px solid var(--line);color:var(--fg);
border-radius:8px;padding:7px 10px;font:inherit;font-size:13.5px;
}
input:focus,select:focus{outline:0;border-color:var(--accent)}
/* The wider left gutter is the folder triangle's; everything in the list shifts with it, so the
feeds still line up with the places above. */
#feedlist{overflow-y:auto;padding:0 8px 12px 16px;flex:1;min-height:0}
.feed{
display:flex;gap:10px;align-items:center;padding:7px 8px;border-radius:9px;
cursor:pointer;margin-bottom:1px;position:relative;
}
.feed:hover{background:var(--panel2)}
.feed.sel{background:var(--raise)}
/* A show sits under its folder's title, a size down, so an open folder reads as one. */
.feed.child{margin-left:11px}
/* Pinned feeds, at the top of the list: a small pin before the name, and a rule under the last. */
.feed .fpin .i{width:10px;height:10px;margin-right:5px;vertical-align:-1px;color:var(--accent)}
.feed.lastpin{margin-bottom:9px}
.feed.lastpin::after{content:"";position:absolute;left:8px;right:8px;bottom:-5px;border-bottom:1px solid var(--line)}
.feed.child .art{width:28px;height:28px;font-size:11px}
/* Only a folder has a triangle, hung in the margin so every feed's art lines up with the places
above it. The button is the row's full height and 24 px wide: a near miss used to open the
folder's page, and 16 px left the triangle cramped against the art. */
.chev{
position:absolute;left:-16px;top:0;bottom:0;width:24px;display:grid;place-items:center;
border-radius:4px;color:var(--faint);
}
.chev:hover{color:var(--fg)}
.chev.bad,.chev.bad:hover{color:var(--bad)}
/* 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. */
.ferr{position:absolute;left:-16px;top:0;bottom:0;width:24px;display:grid;place-items:center;color:var(--bad)}
.ferr .i{width:12px;height:12px}
.feed.child .ferr{left:-27px}
.chev .i{width:12px;height:12px;transition:transform .12s}
.chev[aria-expanded="true"] .i{transform:rotate(90deg)}
.childlist{display:grid;grid-template-columns:minmax(0,1fr);gap:4px;margin-top:10px}
.childrow{
display:flex;gap:10px;align-items:center;padding:7px 9px;border:1px solid var(--line);
border-radius:9px;cursor:pointer;background:var(--panel);
}
.childrow:hover{border-color:var(--accent)}
.childrow .art{width:32px;height:32px;font-size:12px}
.childrow .txt{flex:1;min-width:0}
.childrow .txt b{display:block;font-weight:500;font-size:13.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* Currently Listening: how far into each episode you are, as a rail along the foot of its row.
The one in the player is marked as it is everywhere else, by the amber EQ bars, and its rail
and time left move as it plays. The rest stay neutral, so that one is what stands out. */
#listening .childrow{position:relative;overflow:hidden;gap:12px;padding:10px 10px 13px}
#listening .childrow .art{width:44px;height:44px;font-size:13px}
#listening .txt b{display:flex;align-items:center;gap:7px}
#listening .txt b span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#listening .txt small{display:flex;gap:12px;margin-top:3px;color:var(--faint);font-size:11.5px}
#listening .txt small .fd{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#listening .txt small .left{flex:none;color:var(--dim)}
#listening .childrow:not(.now) .eq{display:none}
#listening .rail{position:absolute;left:0;right:0;bottom:0;height:3px;background:var(--raise)}
#listening .rail i{display:block;height:100%;width:0;background:var(--faint);transition:width .25s linear}
#listening .now{border-color:color-mix(in srgb,var(--accent2) 60%,var(--line))}
#listening .now .eq,#listening .now .left{color:var(--accent2)}
#listening .now .rail i{background:var(--accent2)}
/* Quiet buttons: a filled one on every row outshouted the row that is playing. */
#listening [data-a=play]{color:var(--accent)}
#listening [data-a=remove]{color:var(--faint)}
/* Directory's filters: .tabs for what a feed is, as the item filters are, and chips for what it
is about. A picked chip is underlined in --accent2, as the download bar and the now-playing EQ
are; a .badge's fill already means unread in the sidebar. */
.dirbar{display:flex;flex-wrap:wrap;align-items:center;gap:8px 14px;margin-top:4px}
.dirbar .tabs{flex:none}
.chips{display:flex;flex-wrap:wrap;gap:2px 6px;flex:1 1 0;min-width:0}
.chips button{flex:none;padding:4px 6px;font-size:13px;color:var(--dim);white-space:nowrap;border-bottom:2px solid transparent}
.chips button:hover{color:var(--fg)}
.chips button[aria-pressed="true"]{color:var(--fg);border-bottom-color:var(--accent2)}
/* Square cover art, title underneath: podcast art is made to be known at a glance. The subscribe
button stays visible, since a button that only shows on hover cannot be reached on a phone. */
.tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:18px 14px;margin-top:14px}
.tiles>.hint{grid-column:1/-1}
.tile{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 2px;align-items:start;cursor:pointer}
.tile .art{grid-column:1/-1;width:100%;height:auto;aspect-ratio:1;font-size:34px}
.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:hover .txt b{color:var(--accent)}
.tag{
font-size:11px;font-weight:600;
padding:1px 5px;border-radius:4px;background:var(--raise);color:var(--warn);flex:none;
}
.art{
border-radius:7px;object-fit:cover;background:var(--raise);flex:none;
display:grid;place-items:center;color:var(--faint);font-weight:600;overflow:hidden;
}
/* Initials, tinted by a hash of the name so two "TS" in a row can be told apart. Mixed into the
page ground, not --raise: that is also the selected row, and a selected tile vanished into it. */
.art.ini{background:color-mix(in srgb,var(--tint) 24%,var(--bg));color:var(--tint)}
/* A folder: its first four shows' art, as iTunes built a playlist's from its albums. */
.art.mosaic{grid-template:1fr 1fr/1fr 1fr;place-items:stretch}
.art.mosaic img{width:100%;height:100%;min-height:0;object-fit:cover;display:block}
.feed .art{width:34px;height:34px;font-size:13px}
.feed .txt{min-width:0;flex:1}
.feed .txt b{display:block;font-weight:500;font-size:13.5px;line-height:1.35;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.feed .txt small{display:block;color:var(--faint);font-size:11.5px;line-height:1.35;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.badge{
background:var(--accent2);color:var(--ink);border-radius:20px;padding:1px 7px;
font-size:11px;font-weight:600;flex:none;min-width:26px;text-align:center;
}
.badge.zero{background:var(--raise);color:var(--faint)}
/* 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}
/* ---------- main ---------- */
#main{overflow:hidden;min-height:0;min-width:0;display:flex;flex-direction:column}
.wrap{flex:1;min-height:0;min-width:0;display:flex;flex-direction:column}
.wrap.plain{overflow-y:auto;padding:18px 22px 40px;display:block}
#content{display:flex;flex-direction:column;min-height:0;min-width:0;flex:1}
#content>.fhead,#content>.toolbar{padding-left:20px;padding-right:20px;flex:none}
#content>.fhead{padding-top:16px}
/* Three panes, as the original had: feeds beside, items above, the item below. */
#split{
display:grid;flex:1;min-height:0;
grid-template-columns:minmax(0,1fr) 270px;
grid-template-rows:minmax(90px,var(--listh,60%)) 7px 1fr;
grid-template-areas:"list files" "grab grab" "detail detail";
}
#list{grid-area:list;overflow:auto;padding:0 14px 10px;overscroll-behavior-y:contain}
/* Pull-to-refresh's note (gestures.ts). overscroll-behavior keeps the browser's own pull, which
reloads the page, from starting on top of it. */
#pulltip{display:flex;align-items:flex-end;justify-content:center;padding-bottom:6px;overflow:hidden;
font-size:12px;color:var(--faint)}
/* The selected item's files, beside the list, as the original's Files pane was. */
#files{grid-area:files;overflow-y:auto;padding:8px 12px;border-left:1px solid var(--line);background:var(--panel)}
/* Nothing selected, or an item with no files: the list takes the width. */
#split:has(>#files[hidden]){grid-template-columns:minmax(0,1fr) 0}
#files .encbox{margin-top:8px}
#files .empty{padding:24px 0}
.fhd{font-size:12px;color:var(--faint)}
#grab{grid-area:grab;cursor:row-resize;background:var(--line)}
#grab:hover{background:var(--accent)}
#detail{grid-area:detail;overflow-y:auto;padding:16px 20px 28px;background:var(--panel);border-top:1px solid var(--line)}
.dt{font-size:18px;font-weight:650;margin:0 0 5px;line-height:1.3;overflow-wrap:anywhere}
.dmeta{color:var(--faint);font-size:12.5px;display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:14px}
.dmeta .btn{padding:3px 9px;font-size:12px}
#detail .encbox{margin:0 0 10px}
.dbody{font-size:14.5px;line-height:1.65;overflow-wrap:anywhere;color:var(--fg)}
.dbody img{max-width:100%;height:auto;border-radius:6px}
.dbody a{color:var(--accent)}
.dbody pre{overflow-x:auto;background:var(--panel2);padding:10px;border-radius:8px}
.encbox{
display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:14px;
padding:10px 12px;border:1px solid var(--line);border-radius:9px;background:var(--panel2);
}
.fhead{display:flex;gap:18px;margin-bottom:18px}
.fhead .art{width:118px;height:118px;font-size:34px;box-shadow:var(--shadow)}
.fhead .meta{min-width:0;flex:1;display:flex;flex-direction:column}
.fhead h2{margin:0 0 3px;font-size:23px;line-height:1.2;overflow-wrap:anywhere}
.fhead .sub{color:var(--dim);font-size:13px;margin-bottom:8px}
.fhead .sub a{color:var(--dim);text-decoration:none}
.fhead .sub a:hover{color:var(--accent)}
.acts{display:flex;gap:7px;flex-wrap:wrap;align-items:center;margin-top:auto}
.acts .btn{display:inline-flex;align-items:center;gap:6px;padding:7px 13px;border-radius:999px}
.acts .btn i{font-style:normal;font-size:13px;line-height:1;opacity:.7}
.acts .btn.primary i{opacity:.9}
.acts .btn:hover{background:var(--raise)}
.acts .btn.primary:hover{background:var(--accent)}
.btn{
background:var(--panel2);border:1px solid var(--line);border-radius:8px;
padding:6px 12px;font-size:13px;
}
.btn:hover{border-color:var(--accent)}
a.btn{text-decoration:none;color:inherit}
.btn.primary{background:var(--accent);border-color:var(--accent);color:var(--ink);font-weight:600}
.btn.primary:hover{filter:brightness(1.08)}
.btn.danger:hover{border-color:var(--bad);color:var(--bad)}
/* An icon in place of a word; the word is in its tooltip. */
.btn.ico{padding:4px 9px;min-width:32px;font-size:14px;line-height:1.25;text-align:center}
/* Inline in a sentence, next to a plain-word error explanation. */
.btn.tiny{padding:2px 7px;font-size:11.5px;border-radius:6px;margin-left:2px}
/* The keys ? lists. */
.keys{border-collapse:collapse;width:100%;font-size:13px}
.keys th{text-align:left;font-weight:600;padding:12px 0 4px;color:var(--fg)}
.keys td{padding:3px 0;color:var(--dim)}
.keys td:first-child{width:10em;white-space:nowrap}
kbd{font:inherit;font-size:12px;color:var(--fg);background:var(--panel2);border:1px solid var(--line);border-radius:4px;padding:0 5px}
/* An icon (Font Awesome, embedded as SVG) in the button's own colour. */
.i{display:inline-block;width:16px;height:16px;vertical-align:-3px;flex:none;fill:currentColor}
/* A file's type as an icon, in place of the old DOWNLOADED / PENDING / audio chips: green once
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{
display:flex;gap:10px;align-items:center;margin-bottom:12px;flex-wrap:wrap;
position:sticky;top:0;background:var(--bg);padding:6px 0 8px;z-index:3;
}
.tabs{display:flex;gap:2px;background:var(--panel2);border-radius:9px;padding:3px}
.tabs button{padding:5px 12px;border-radius:7px;font-size:13px;color:var(--dim)}
.tabs button.on{background:var(--raise);color:var(--fg);font-weight:500}
.tabs a{padding:5px 12px;border-radius:7px;font-size:13px;color:var(--dim);text-decoration:none}
.tabs a.on{background:var(--raise);color:var(--fg);font-weight:500}
/* ---------- the admin page (admin.html) ---------- */
/* One column that scrolls under the same header, rather than the app's fixed panes. */
body.adminpage{display:block;overflow:auto}
body.adminpage #topbar{position:sticky;top:0;z-index:5}
body.adminpage #topbar h1{font-size:16px;margin:0;font-weight:650}
body.adminpage #topbar .logo{border-radius:5px}
body.adminpage #topbar > a.btn{text-decoration:none}
body.adminpage #admin{max-width:780px;margin:0 auto}
body.adminpage #admin h2{font-size:18px;margin:0 0 12px}
body.adminpage #log{max-width:none}
.urow{padding:9px 0;border-bottom:1px solid var(--line)}
.toolbar .grow{flex:1;min-width:150px;max-width:320px}
/* ---------- items ---------- */
/* 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) 56px minmax(0,72px) minmax(0,92px) 64px;
}
/* A pixel more side padding than a row's: a row has a 1px border the heading has not, and without
it every heading sat a pixel left of its column. */
.ephead{
position:sticky;top:0;z-index:2;background:var(--bg);padding:7px 11px 5px;
border-bottom:1px solid var(--line);margin-bottom:3px;
font-size:12px;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)}
/* An icon heading is centred over its column, as the icons under it are, and its caret hangs
outside so sorting by it does not push the icon off line. */
.ephead .hs.h-ic{justify-content:center;position:relative;width:100%}
.ephead .hs.h-ic .arr{position:absolute;left:100%}
.ep{padding:4px 10px;border-radius:7px;border:1px solid transparent;cursor:pointer;margin-bottom:1px}
.ep>*{min-width:0}
.ep.sel{background:var(--raise);border-color:var(--line)}
.ep:hover{background:var(--panel)}
/* Amber means new: the unread dot, the badges and a download under way. Blue is for the one
primary action and links, so a count no longer looks like a button. */
/* No padding: a button keeps the browser's side padding, which left too little room for the 16px
icon, so it spilled 3px right of centre and off line with its heading. */
.ep .st,.ep .fl{width:22px;height:22px;padding:0;border-radius:5px;display:grid;place-items:center;font-size:11px;color:var(--accent2)}
.ep .fl{color:var(--faint);font-size:13px}
.ep .fl.on{color:var(--fg)}
/* The icon's EQ bars mark what is playing: standing still, and moving only while it plays. A
paused animation was tried first; the frames it held were a pixel apart and read as dots. */
.eq{display:inline-flex;align-items:flex-end;gap:2px;width:13px;height:12px;flex:none}
.eq i{flex:1;height:100%;background:currentColor;border-radius:1px;transform-origin:bottom}
.eq i:nth-child(1){height:60%}
.eq i:nth-child(3){height:40%}
body.playing .eq i{animation:eq .9s ease-in-out infinite alternate}
body.playing .eq i:nth-child(1){animation-delay:-.3s}
body.playing .eq i:nth-child(3){animation-delay:-.6s}
@keyframes eq{from{transform:scaleY(.35)}}
.ep .st:hover,.ep .fl:hover{background:var(--raise)}
.ep .t{font-weight:600;font-size:13.5px;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ep.read .t{color:var(--dim);font-weight:500}
.ep .line{display:flex;gap:9px;align-items:center;flex-wrap:wrap;color:var(--faint);font-size:11.5px}
.ep .line:empty{display:none}
.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;position:relative;color:var(--faint);font-size:11.5px}
/* Under the icon, not beside it or on a line of its own: taking a line, it lifted the icon of
every file not yet downloaded above the downloaded ones' (issue #31). */
.ep .file .dlbar{position:absolute;left:0;right:0;bottom:-5px;margin:0}
.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) 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}
.ep .rowacts{display:flex;gap:2px;align-items:flex-start;opacity:0;transition:opacity .12s}
.ep:hover .rowacts{opacity:1}
.dlbar{height:3px;background:transparent;border-radius:2px;overflow:hidden;margin-top:7px}
.dlbar.live{background:var(--raise)}
.dlbar i{display:block;height:100%;width:0;background:var(--accent2);transition:width .25s}
.empty{color:var(--faint);text-align:center;padding:50px 0}
#more{display:block;width:100%;margin-top:10px}
/* ---------- player ---------- */
#player{
border-top:1px solid var(--line);background:var(--panel);
display:none;grid-template-columns:auto 1fr auto;gap:14px;align-items:center;
padding:9px 16px;box-shadow:0 -6px 24px rgba(6,10,16,.4);
}
#player.on{display:grid}
/* #audio is a <video> playing double duty as the audio element (see its tag). Only a video
file needs to be seen, floated above the bar rather than laid into it, so an audio episode's
layout is unchanged. */
#audio{display:none}
body.has-video #audio{
display:block;position:fixed;z-index:45;right:16px;bottom:120px;
width:360px;max-width:calc(100vw - 32px);aspect-ratio:16/9;background:#000;
border-radius:10px;box-shadow:0 10px 30px rgba(0,0,0,.5);
}
#pnow{display:flex;gap:11px;align-items:center;min-width:0;width:250px}
#pnow .art{width:44px;height:44px;font-size:13px}
#pnow .txt{min-width:0}
#pnow b{display:block;font-size:13px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#pnow .eq{color:var(--accent2);margin-right:6px}
#pnow small{color:var(--faint);font-size:11.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block}
#pmid{display:flex;flex-direction:column;gap:3px;min-width:0}
#pbtns{display:flex;gap:5px;align-items:center;justify-content:center}
#pbtns .iconbtn{width:34px;height:34px}
#pplay{
width:40px!important;height:40px!important;background:var(--fg);color:var(--bg);
border-radius:50%;
}
#pplay:hover{background:var(--accent);color:var(--ink)}
#seekrow{display:flex;gap:9px;align-items:center;font-variant-numeric:tabular-nums;font-size:11.5px;color:var(--faint)}
input[type=range]{
-webkit-appearance:none;appearance:none;height:4px;border-radius:3px;flex:1;
background:var(--raise);cursor:pointer;
}
input[type=range]::-webkit-slider-thumb{
-webkit-appearance:none;width:12px;height:12px;border-radius:50%;background:var(--accent);
}
input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radius:50%;background:var(--accent)}
#pright{display:flex;gap:8px;align-items:center}
#pright select{width:auto;padding:4px 6px;font-size:12px}
#vol{width:78px;flex:none}
/* ---------- overlays ---------- */
#modal{
position:fixed;inset:0;background:rgba(0,0,0,.6);display:none;
place-items:center;z-index:50;padding:20px;
}
#modal.on{display:grid}
.card{
background:var(--panel);border:1px solid var(--line);border-radius:14px;
padding:20px;width:min(460px,100%);box-shadow:var(--shadow);max-height:88vh;overflow:auto;
}
.card.wide{width:min(1000px,100%)}
#logbox{
background:var(--bg);border:1px solid var(--line);border-radius:9px;padding:10px 12px;
height:min(60vh,520px);overflow:auto;font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;
}
#logbox .l{display:flex;gap:9px;white-space:pre-wrap;overflow-wrap:anywhere}
#logbox time{color:var(--faint);flex:none}
#logbox .lv{flex:none;width:42px;font-weight:700}
#logbox .lv.ERROR{color:var(--bad)} #logbox .lv.WARN{color:var(--warn)}
#logbox .lv.INFO{color:var(--accent)} #logbox .lv.DEBUG,#logbox .lv.TRACE{color:var(--faint)}
#logbox .tg{color:var(--faint);flex:none}
.logbar{display:flex;gap:8px;align-items:center;margin-bottom:9px;flex-wrap:wrap}
.logbar .grow{flex:1;min-width:120px}
.card h3{margin:0 0 14px;font-size:17px}
.field{display:grid;gap:4px;margin-bottom:12px}
.field label{font-size:12px;color:var(--dim)}
.field .hint{font-size:11.5px;color:var(--faint)}
.check{display:flex;gap:8px;align-items:center;font-size:13.5px;margin-bottom:9px}
.inline{display:flex;gap:6px;align-items:stretch}
.inline input{flex:1;min-width:0}
.inline .btn{white-space:nowrap;flex:none}
.inline select{flex:none;width:auto}
.inline input[type=number]{flex:none;width:90px}
.check input{width:16px;height:16px;accent-color:var(--accent)}
.cardacts{display:flex;gap:8px;justify-content:flex-end;margin-top:16px}
#toasts{position:fixed;bottom:88px;right:18px;display:flex;flex-direction:column;gap:8px;z-index:60}
.toast{
background:var(--raise);border:1px solid var(--line);border-radius:9px;
padding:9px 13px;font-size:13px;box-shadow:var(--shadow);animation:in .18s;max-width:340px;
}
.toast.bad{border-color:var(--bad);color:var(--bad)}
@keyframes in{from{opacity:0;transform:translateY(6px)}}
#burger,#dback{display:none}
/* Totals for what is showing, along the bottom, as the original's status bar. */
#status{
padding:3px 14px;min-height:22px;font-size:12px;color:var(--faint);background:var(--panel);
border-top:1px solid var(--line);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
}
/* The feed's own header, kept to one line so the table starts high, as it did. */
.fhead.slim{align-items:center;gap:8px 12px;margin-bottom:10px;flex-wrap:wrap}
.fhead.slim .art{width:44px;height:44px;font-size:15px;box-shadow:none}
.fhead.slim .meta{flex:1 1 260px}
/* One line each, or a long title wraps to three and pushes the table down. */
.fhead.slim h2,.fhead.slim .sub.stat{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.fhead.slim h2{font-size:17px}
.fhead.slim .sub{margin-bottom:0;font-size:12.5px}
.fhead.slim .acts{margin-top:0;flex:none}
.fhead.slim .acts .btn{padding:5px 11px;font-size:12.5px}
/* Places above the feeds: not subscriptions, but where to look. */
.places{border-bottom:1px solid var(--line);margin:0 0 6px;padding-bottom:6px}
.place{display:flex;gap:10px;align-items:center;padding:6px 8px;border-radius:9px;cursor:pointer}
.place:hover{background:var(--panel2)}
.place.sel{background:var(--raise)}
.place .ico{width:18px;text-align:center;color:var(--accent);flex:none}
.place b{flex:1;font-weight:500;font-size:13.5px}
@media (max-width:820px){
#shell{grid-template-columns:1fr}
#sidebar{position:fixed;inset:0 auto 0 0;width:min(300px,86vw);z-index:42;transform:translateX(-100%);transition:transform .2s;box-shadow:var(--shadow)}
#sidebar.open{transform:none}
#scrim{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:41}
#player{position:relative;z-index:39;padding:7px 10px;gap:8px}
/* The feed list is reachable whether or not anything is playing. */
#burger{display:grid}
#topbar{gap:6px;padding:6px 8px}
#topbar .grow,.tgroup.item{display:none}
#topbar #epSearch{width:auto;flex:1;min-width:0}
.tgroup button,.tgroup a{padding:4px 8px;min-width:32px}
.fhead.slim .acts{flex:1 1 100%}
.iconbtn{width:38px;height:38px}
/* One pane at a time: the list, then the item over it. */
#split{grid-template-columns:1fr;grid-template-rows:1fr;grid-template-areas:"list"}
/* Title and date only; the files sit above the item's text in the reader instead. */
#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}
body.reading #detail{display:block}
#dback{display:inline-block;margin-bottom:10px}
#content>.fhead,#content>.toolbar{padding-left:14px;padding-right:14px}
#content>.fhead{padding-top:12px}
.fhead{gap:12px;margin-bottom:12px}
.fhead .art{width:64px;height:64px;font-size:20px}
.fhead h2{font-size:18px}
.fhead .sub{font-size:12px;margin-bottom:6px}
.acts{gap:6px}
.acts .btn{padding:7px 10px;font-size:12.5px}
.toolbar{gap:8px}
#list{padding:0 12px 10px}
#player{grid-template-columns:1fr auto;grid-template-areas:"now right" "mid mid";gap:2px 8px}
#pnow{grid-area:now;width:auto}
#pright{grid-area:right}
#pmid{grid-area:mid}
#pnow .art{width:38px;height:38px}
#pnow .txt small,#pright #vol{display:none}
.wrap.plain{padding:14px}
.card{padding:16px}
/* The chips get a line of their own and scroll sideways rather than wrap, so they never push
the grid down. */
.chips{flex:1 1 100%;flex-wrap:nowrap;overflow-x:auto}
.tiles{grid-template-columns:repeat(auto-fill,minmax(104px,1fr));gap:14px 10px}
/* A log line has no room for four columns: keep time and message, wrap as prose. */
#logbox{font-size:11.5px;padding:8px}
#logbox .l{flex-wrap:wrap;gap:6px}
#logbox .lv{width:auto}
#logbox .tg{display:none}
}
/* ---------- Classic: the 2004 Mac app ---------- */
/* After the iPodderX screenshots: brushed-metal chrome, a pale blue-grey source list, Aqua blue
for whatever is selected, red unread badges, a striped table and Lucida Grande. */
:root[data-theme="classic"] body{
font-family:"Lucida Grande","Lucida Sans Unicode","Lucida Sans",Geneva,Verdana,sans-serif;font-size:13px;
}
:root[data-theme="classic"] #topbar,
:root[data-theme="classic"] #status,
:root[data-theme="classic"] #player{background:linear-gradient(#ececec,#c9c9c9);border-color:#8e8e8e;color:#1a1a1a}
:root[data-theme="classic"] #status{text-align:center}
:root[data-theme="classic"] #sidebar{background:#e7ebf1;border-right-color:#a3a3a3}
:root[data-theme="classic"] #files{background:#f4f4f4}
:root[data-theme="classic"] .tgroup{background:none;border-color:#8e8e8e;border-radius:6px}
:root[data-theme="classic"] .tgroup button+button,:root[data-theme="classic"] .tgroup button+a{border-left-color:#8e8e8e}
:root[data-theme="classic"] .tgroup button,
:root[data-theme="classic"] .tgroup a,
:root[data-theme="classic"] .btn,
:root[data-theme="classic"] .tabs{background:linear-gradient(#ffffff,#d9d9d9);border-color:#8e8e8e;color:#1a1a1a}
:root[data-theme="classic"] .tabs{border:1px solid #8e8e8e;border-radius:6px}
:root[data-theme="classic"] .tgroup button:hover:not(:disabled),
:root[data-theme="classic"] .tgroup a:hover,
:root[data-theme="classic"] .btn:hover{background:linear-gradient(#f5f9ff,#cbdbf4)}
:root[data-theme="classic"] .btn.primary,
:root[data-theme="classic"] .tabs button.on,
:root[data-theme="classic"] .feed.sel,
:root[data-theme="classic"] .place.sel{background:linear-gradient(#86b0ec,#3a73cf);border-color:#2d5eae;color:#fff}
:root[data-theme="classic"] .feed.sel .txt small,
:root[data-theme="classic"] .place.sel .ico{color:#e6eeff}
:root[data-theme="classic"] #epSearch{border-radius:14px}
:root[data-theme="classic"] .badge{background:#c9302c;color:#fff}
:root[data-theme="classic"] .badge.zero{background:#b4bdc9;color:#fff}
:root[data-theme="classic"] .ephead,
:root[data-theme="classic"] .fhd{
background:linear-gradient(#fbfbfb,#dcdcdc);color:#1a1a1a;font-size:11px;
}
:root[data-theme="classic"] .fhd{padding:3px 6px;border:1px solid #c2c2c2}
:root[data-theme="classic"] .ephead{border-bottom-color:#a3a3a3}
:root[data-theme="classic"] .ep{border-radius:0;margin:0}
:root[data-theme="classic"] #eps .ep:nth-child(even){background:#edf3fe}
:root[data-theme="classic"] .ep .t{color:#1c4fa8}
:root[data-theme="classic"] .ep.read .t{color:#333}
: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,
:root[data-theme="classic"] .ep.sel .fl,
:root[data-theme="classic"] .ep.sel .file,
:root[data-theme="classic"] .ep.sel .kind{color:#fff}
/* 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.bad{color:#ffb8ad}
/* Lists were white in the original; the pale blue-grey belongs to the source list alone. */
:root[data-theme="classic"] .childrow{background:#fff}
:root[data-theme="classic"] .dt{background:linear-gradient(#80aae6,#3f78cf);color:#fff;padding:6px 12px;border-radius:4px}

BIN
web/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

84
web/build.mjs Normal file
View File

@@ -0,0 +1,84 @@
// Builds the pages ipx serves: each page's TypeScript from web/src, types stripped and minified
// by swc into a script of its own (app.js, login.js), and the page minified, its
// <script data-src> pointing at that script. build.rs runs it into OUT_DIR, where web.rs
// include_str!s the results, so the binary still carries everything and nothing is served
// from disk.
//
// The page names its script with a hash of the script's contents, /app.js?v=<hash>, and the
// server lets a browser keep that for a year without asking again. A changed script is a new
// URL, and the page, which the browser checks on every visit, is what carries it.
//
// node web/build.mjs [out-dir] default out-dir: web/dist
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import swc from '@swc/core';
import html from '@swc/html';
const here = path.dirname(fileURLToPath(import.meta.url));
// The files are one script, concatenated in this order, not modules: they share one top-level
// scope, as the single inline script did, and code that runs at load needs what came before it.
const PAGES = {
'index.html': { script: 'app.js', src: ['util', 'theme', 'feeds', 'feedpage', 'items', 'player', 'dialogs', 'gestures', 'events'] },
'admin.html': { script: 'admin.js', src: ['util', 'theme', 'admin'] },
'login.html': { script: 'login.js', src: ['login'] },
};
// The stylesheet the app and admin pages share, served and named by hash as the scripts are.
const STYLE = 'app.css';
const hash = s => crypto.createHash('sha256').update(s).digest('hex').slice(0, 12);
/// The shared stylesheet, minified. @swc/html minifies CSS inside a page, so it goes through as
/// one; the doctype only keeps it from complaining that a fragment has none.
export function buildStyle({ minify = true } = {}) {
const css = fs.readFileSync(path.join(here, STYLE), 'utf8');
if (!minify) return css;
const r = html.minifySync(`<!doctype html><style>${css}</style>`, { minifyCss: true, removeComments: true });
const bad = (r.errors || []).filter(e => e.level === 'error' || e.level === 'Error');
if (bad.length) throw new Error(`${STYLE}: ${bad.map(e => e.message).join('; ')}`);
return r.code.slice(r.code.indexOf('<style>') + 7, r.code.lastIndexOf('</style>'));
}
/// The page and its script, built: { html, js, script }, where script is the file's name.
export function buildPage(name, { minify = true } = {}) {
const { script, src: files } = PAGES[name];
const src = files.map(f => fs.readFileSync(path.join(here, 'src', f + '.ts'), 'utf8')).join('\n');
const js = swc.transformSync(src, {
filename: name + '.ts',
jsc: {
parser: { syntax: 'typescript' },
target: 'es2022',
// Top-level names stay as they are: markup calls some of them by name (onclick="closeModal()")
// and the browser tests reach others (player, savePos) through page.evaluate.
minify: minify ? { compress: { toplevel: false }, mangle: { toplevel: false } } : undefined,
},
isModule: false,
minify,
}).code;
const page = fs.readFileSync(path.join(here, name), 'utf8');
const marker = /<script data-src="[^"]*"><\/script>/;
if (!marker.test(page)) throw new Error(`${name} has no <script data-src> to put its script in`);
// Where the inline script was, and a plain <script src>, so it still runs in the same place:
// after the markup it wires up, before anything else.
let out = page.replace(marker, `<script src="/${script}?v=${hash(js)}"></script>`);
out = out.replace(/<link rel="stylesheet" data-src="[^"]*">/,
() => `<link rel="stylesheet" href="/${STYLE}?v=${hash(buildStyle({ minify }))}">`);
if (!minify) return { html: out, js, script };
const r = html.minifySync(out, { minifyJs: false, minifyCss: true, removeComments: true });
const bad = (r.errors || []).filter(e => e.level === 'Error');
if (bad.length) throw new Error(`${name}: ${bad.map(e => e.message).join('; ')}`);
return { html: r.code, js, script };
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const out = process.argv[2] || path.join(here, 'dist');
fs.mkdirSync(out, { recursive: true });
fs.writeFileSync(path.join(out, STYLE), buildStyle());
for (const name of Object.keys(PAGES)) {
const { html, js, script } = buildPage(name);
fs.writeFileSync(path.join(out, name), html);
fs.writeFileSync(path.join(out, script), js);
}
}

BIN
web/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
<title>Sign in — iPodderX</title> <title>Sign in — iPodderX</title>
<link rel="icon" href="/icon.png"> <link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<style> <style>
/* Inter, from ipx itself; see the same rule in index.html. */ /* Inter, from ipx itself; see the same rule in index.html. */
@font-face{font-family:Inter;src:url(/inter.woff2) format("woff2");font-weight:100 900;font-display:swap} @font-face{font-family:Inter;src:url(/inter.woff2) format("woff2");font-weight:100 900;font-display:swap}
@@ -76,20 +77,4 @@ button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
<p class="msg" id="msg"></p> <p class="msg" id="msg"></p>
</form> </form>
<script> <script data-src="web/src"></script>
document.getElementById('f').onsubmit = async e => {
e.preventDefault();
const msg = document.getElementById('msg');
msg.textContent = '';
const r = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: document.getElementById('name').value,
password: document.getElementById('pw').value,
}),
});
if (r.ok) location.href = '/';
else msg.textContent = await r.text() || 'Sign in failed';
};
</script>

204
web/src/admin.ts Normal file
View File

@@ -0,0 +1,204 @@
/* ---------------- admin page ---------------- */
// /admin: the server's settings, the accounts, and the log, each a section chosen by the URL's
// hash so a link can go straight to one. The server sends this page and this script to admins
// only, and refuses every call below to anyone else; they were parts of Settings and the header,
// shown or hidden by the main page's script (issue #19).
let me: {name: string} | null = null;
api('/api/me').then(u => { me = u; }).catch(() => {});
const SECTIONS: Record<string, () => void> = {server: drawServer, accounts: drawAccounts, log: drawLogView};
function showSection(){
const t = SECTIONS[location.hash.slice(1)] ? location.hash.slice(1) : 'server';
for(const s of $$('#admin > section')) s.hidden = s.id !== t;
for(const a of $$('#atabs a')) a.classList.toggle('on', a.dataset.t === t);
// The log polls every two seconds while it is showing, and not otherwise.
if(t !== 'log' && logTimer){ clearInterval(logTimer); logTimer = null; }
SECTIONS[t]();
}
window.addEventListener('hashchange', showSection);
/* ---------------- server ---------------- */
async function drawServer(){
const box = $('#server');
const g = await api('/api/settings');
const gs = splitEvery(g.every_mins);
box.innerHTML = `<h2>Server</h2>
<p class="hint">These apply to everyone. Each person's own choices, such as keywords or how
many items a feed downloads for them, are in that feed's settings.</p>
<div class="field"><label>Check feeds every</label>
<div class="inline">
<input type="number" id="gnum" min="1" max="999" value="${gs.n}">
<select id="gunit">${unitOptions(gs.u)}</select>
</div>
<span class="hint">Applies to every feed that does not set its own. A feed's suggested
interval (its <b>ttl</b>) is still honoured when it asks to be polled less often.</span></div>
<div class="field"><label>Max new downloads per scan, per feed</label>
<input type="number" id="gmax" min="0" max="999" value="${g.max_new_per_check}">
<span class="hint">Applies to any feed that does not set its own — including every feed
inside an OPML subscription. <b>0 means unlimited</b>, which will pull a whole back
catalogue the first time a feed is scanned.</span></div>
<div class="field"><label>Download these media types automatically</label>
<input type="text" id="gtypes" value="${esc((g.media_types||[]).join(', '))}" placeholder="audio, video">
<span class="hint">Anything else is still listed and can be downloaded by hand — blog feeds
put article images in enclosures, and those are not worth keeping. Empty takes everything.</span></div>
<div class="field"><label>Disk quota (GB, 0 = unlimited)</label>
<input type="number" id="gquota" min="0" step="0.5" value="${g.max_total_gb}">
<span class="hint">Over this, the oldest played items are deleted first. Pinned
items are never touched.</span></div>
<div class="field"><label>Delete items older than (days, 0 = keep)</label>
<input type="number" id="gage" min="0" value="${g.max_age_days}"></div>
<div class="field"><label>Download folder</label>
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>
<div class="cardacts"><button class="btn primary" id="gsave">${ICON.check} Save</button></div>`;
$('#gsave').onclick = async () => {
try{
await api('/api/settings', {method: 'PATCH', body: JSON.stringify({
schedule: `every ${Math.max(1, Number($('#gnum').value) || 1)}${$('#gunit').value}`,
max_new_per_check: Math.max(0, Number($('#gmax').value) || 0),
media_types: $('#gtypes').value.split(',').map(t => t.trim()).filter(Boolean),
max_total_gb: Number($('#gquota').value) || 0,
max_age_days: Number($('#gage').value) || 0})});
toast('Settings saved');
}catch(e){ toast(e.message, true); }
};
}
/* ---------------- accounts ---------------- */
async function drawAccounts(){
const box = $('#accounts');
const users = await api('/api/users') || [];
box.innerHTML = `<h2>Accounts</h2>
${users.map(u => `<div class="inline urow" data-id="${u.id}">
<div style="flex:1;min-width:0"><b style="overflow-wrap:anywhere">${esc(u.name)}</b>
<small style="display:block;color:var(--faint)">${u.created ? `Added ${dateOf(u.created)}` : 'Added before this was kept'} · ${
u.last_login ? `signed in ${ago(u.last_login)}` : 'never signed in'}</small></div>
${u.password ? '' : '<span class="tag" title="No password: signs in through the proxy">Proxy</span>'}
<label class="check" style="margin:0"><input type="checkbox" data-a="admin" ${u.admin ? 'checked' : ''}> Admin</label>
<button class="btn ico danger" data-a="rm" title="Remove ${esc(u.name)}" aria-label="Remove ${esc(u.name)}">${ICON.trash}</button></div>`).join('')}
<div class="field" style="margin-top:20px"><label>Add someone</label>
<div class="inline">
<input type="text" id="uname" placeholder="Name" autocomplete="off" spellcheck="false">
<input type="password" id="upass" placeholder="Password" autocomplete="new-password">
</div>
<label class="check" style="margin-top:8px"><input type="checkbox" id="uadmin"> Admin</label>
<span class="hint">At least 8 characters. Leave the password empty for someone who signs in
through the proxy. New people start with no feeds.</span></div>
<div class="cardacts"><button class="btn primary" id="uadd">${ICON.plus} Add</button></div>`;
const change = async (u, opts) => {
try{
await api(`/api/users/${u.id}`, opts);
// Demoting yourself takes this page away; go back to the app rather than stay on a page
// the server no longer answers. Only on success: a refusal's toast has to stay readable.
if(u.name === me?.name){ location.href = '/'; return; }
}catch(e){ toast(e.message, true); }
drawAccounts(); // on a refusal, this puts the checkbox back where the server left it
};
for(const row of $$('#accounts [data-id]')){
const u = users.find(x => String(x.id) === row.dataset.id);
$('[data-a="admin"]', row).onchange = e =>
change(u, {method: 'PATCH', body: JSON.stringify({admin: e.target.checked})});
$('[data-a="rm"]', row).onclick = () => {
if(confirm(`Remove ${u.name}? Their subscriptions and read state go with them. Downloaded files stay.`))
change(u, {method: 'DELETE'});
};
}
$('#uadd').onclick = async () => {
try{
await api('/api/users', {method: 'POST', body: JSON.stringify({
name: $('#uname').value, password: $('#upass').value, admin: $('#uadmin').checked})});
toast('Added'); drawAccounts();
}catch(e){ toast(e.message, true); } // keep what was typed
};
}
/* ---------------- log ---------------- */
let logTimer = null, logSeq = 0, logLines = [], logFilter = '', logLevel = '', logTab = 'all';
// Which sources belong to each tab. "daemon" is the control protocol itself: every
// command in and every event out, whatever sent it.
const LOG_TABS = {
all: null,
daemon: t => t === 'ipx::io',
scan: t => t === 'ipx::scan',
web: t => t === 'ipx::http',
};
const LEVELS = {ERROR: 3, WARN: 2, INFO: 1, DEBUG: 0, TRACE: 0};
function drawLogView(){
logSeq = 0; logLines = [];
$('#log').innerHTML = `<h2>Log</h2>
<div class="logbar">
<div class="tabs" id="logtabs">
${Object.keys(LOG_TABS).map(t =>
`<button data-t="${t}" class="${logTab === t ? 'on' : ''}">${
{all: 'All', daemon: 'Daemon I/O', scan: 'Scans', web: 'HTTP'}[t]}</button>`).join('')}
</div>
<select id="loglevel" style="width:auto">
<option value="">All levels</option>
<option value="INFO">Info and above</option>
<option value="WARN">Warnings and errors</option>
<option value="ERROR">Errors only</option>
</select>
<input type="search" id="logq" class="grow" placeholder="Filter…">
<label class="check" style="margin:0"><input type="checkbox" id="logfollow" checked> Follow</label>
<button class="btn ico" id="logcopy" title="Copy what is showing" aria-label="Copy what is showing">${ICON.copy}</button>
</div>
<div id="logbox"><p class="empty">Loading…</p></div>
<span class="hint"><b>Daemon I/O</b> is the control protocol itself — every command in and
every event out. <b>Scans</b> is feed and download activity, <b>HTTP</b> is web requests.
The buffer keeps debug detail even when the terminal does not; <b>IPX_UI_LOG</b> changes
what it captures.</span>`;
for(const b of $$('#logtabs button')) b.onclick = () => {
logTab = b.dataset.t;
for(const x of $$('#logtabs button')) x.classList.toggle('on', x.dataset.t === logTab);
drawLog();
};
$('#loglevel').onchange = e => { logLevel = e.target.value; drawLog(); };
$('#logq').oninput = e => { logFilter = e.target.value.toLowerCase(); drawLog(); };
$('#logcopy').onclick = () => copyText(visibleLog().map(l =>
`${new Date(l.ts * 1000).toISOString()} ${l.level} ${l.target} ${l.msg}`).join('\n'), $('#logcopy'));
pollLog();
if(!logTimer) logTimer = setInterval(pollLog, 2000);
}
async function pollLog(){
try{
const r = await api(`/api/logs?after=${logSeq}&limit=500`);
if(r.lines.length){
logLines = logLines.concat(r.lines).slice(-2000);
logSeq = r.latest;
drawLog();
}else if(!logLines.length){ drawLog(); }
}catch(e){
const box = $('#logbox');
if(box) box.innerHTML = `<p class="empty">Lost contact with the daemon: ${esc(e.message)}</p>`;
}
}
function visibleLog(){
const min = logLevel ? LEVELS[logLevel] : -1;
const tab = LOG_TABS[logTab];
return logLines.filter(l =>
(!tab || tab(l.target)) &&
(LEVELS[l.level] ?? 1) >= min &&
(!logFilter || (l.msg + ' ' + l.target).toLowerCase().includes(logFilter)));
}
function drawLog(){
const box = $('#logbox'); if(!box) return;
const follow = $('#logfollow')?.checked;
const rows = visibleLog();
box.innerHTML = rows.length ? rows.map(l => {
const t = new Date(l.ts * 1000).toLocaleTimeString();
if(logTab === 'daemon'){
const out = l.msg.startsWith('<-');
return `<div class="l"><time>${t}</time>` +
`<span class="lv" style="color:${out ? 'var(--good)' : 'var(--accent)'}">${out ? 'out' : 'in'}</span>` +
`<span>${esc(l.msg.replace(/^[<-]+\s*/, ''))}</span></div>`;
}
return `<div class="l"><time>${t}</time><span class="lv ${esc(l.level)}">${esc(l.level)}</span>` +
`<span class="tg">${esc(l.target.replace(/^ipx::?/, ''))}</span><span>${esc(l.msg)}</span></div>`;
}).join('') : '<p class="empty">Nothing matches.</p>';
if(follow) box.scrollTop = box.scrollHeight;
}
showSection();

398
web/src/dialogs.ts Normal file
View File

@@ -0,0 +1,398 @@
/* ---------------- modals ---------------- */
function openModal(html: string, wide?: boolean){
$('#modalCard').innerHTML=html;
$('#modalCard').classList.toggle('wide',!!wide);
$('#modal').classList.add('on');
}
function closeModal(){
$('#modal').classList.remove('on');
}
$('#modal').onclick=e=>{ if(e.target.id==='modal') closeModal(); };
$('#addFeed').onclick=()=>{
openModal(`<h3>Add a feed</h3>
<div class="field"><label>Feed URL</label><input type="text" id="nurl" placeholder="https://example.com/rss">
<span class="hint">A Patreon token on its own adds every show from that creator.</span></div>
<div class="field"><label>Folder (optional)</label><input type="text" id="nfolder" placeholder="Defaults to the feed title"></div>
<div class="field"><label>Keywords (optional, comma separated)</label>
<input type="text" id="nkw"><span class="hint">Only items matching a keyword are downloaded.</span></div>
<label class="check"><input type="checkbox" id="nexp"> Allow items marked explicit</label>
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
<button class="btn ico primary" id="nsave" title="Add feed" aria-label="Add feed">${ICON.plus}</button></div>`);
$('#nurl').focus();
$('#nsave').onclick=async()=>{
const url=$('#nurl').value.trim(); if(!url) return;
$('#nsave').disabled=true; $('#nsave').title='Adding…';
try{
const r=await api('/api/feeds',{method:'POST',body:JSON.stringify({
url, folder:$('#nfolder').value.trim()||null, allow_explicit:$('#nexp').checked,
keywords:$('#nkw').value.split(',').map(s=>s.trim()).filter(Boolean)})});
closeModal(); toast(r.existing?`Already subscribed as ${r.id}`:`Added ${r.id}`);
await loadFeeds(true); selectFeed(r.id);
}catch(e){ toast(e.message,true); $('#nsave').title='Add feed'; $('#nsave').disabled=false; }
};
};
// What everyone here reads, you included, as a place to start. The rows carry an id, never a
// URL, so a key in someone's feed address never reaches this page.
const NONE_LISTED='<p class="hint">Nothing yet. Feeds people here subscribe to show up here.</p>';
async function listFeeds(url,box){
let rows=[];
try{ rows=await api(url)||[]; }catch{}
box.innerHTML=rows.length?'':NONE_LISTED;
for(const p of rows) box.appendChild(listedFeed(p,'childrow'));
return rows.length;
}
/// One listed feed: a row in Popular and the Add a feed dialog, a tile in Directory's grid. The
/// parts are the same either way; the class lays them out.
function listedFeed(p,cls){
const el=document.createElement('div');
el.className=cls;
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>`+
// 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.subbed}</button>`);
// Yours already: the row opens it instead.
if(p.subscribed){ el.onclick=()=>{ closeModal(); selectFeed(p.id); }; return el; }
$('[data-a="sub"]',el).onclick=async()=>{
try{
await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'});
closeModal(); toast(`Subscribed to ${p.title||p.id}`);
await loadFeeds(true); selectFeed(p.id);
}catch(e){ toast(e.message,true); }
};
return el;
}
// Directory's filters. Kept out here because a finished scan redraws the pane, which would
// otherwise clear them.
let dirKind='All', dirCat=null;
const KINDS={All:()=>true,Podcasts:p=>p.podcast,Blogs:p=>!p.podcast};
/// Directory: every listed feed as its cover art, under two filters that combine: what a feed is
/// (Podcasts, anything with audio or video, or Blogs, the rest) and what it is about (its iTunes
/// category, as chips). Both filter in place, without asking the server again.
async function renderDirectory(url,box){
let rows=[];
try{ rows=await api(url)||[]; }catch{}
if(!rows.length){ box.innerHTML=NONE_LISTED; return 0; }
const bar=$('#dirbar');
// Only a filter when the server has both kinds.
const both=rows.some(KINDS.Podcasts)&&rows.some(KINDS.Blogs);
const btn=(k,v,on)=>`<button type="button" data-${k}="${esc(v)}" class="${on?'on':''}" aria-pressed="${on}">${esc(v)}</button>`;
const draw=()=>{
if(!both) dirKind='All';
const ofKind=rows.filter(KINDS[dirKind]);
// No empty chips: only the categories among the feeds the kind lets through.
const cats=[...new Set(ofKind.map(p=>p.category).filter(Boolean))].sort();
if(!cats.includes(dirCat)) dirCat=null;
bar.innerHTML=
(both?`<div class="tabs" role="group" aria-label="Kind">${Object.keys(KINDS).map(k=>btn('kind',k,k===dirKind)).join('')}</div>`:'')+
(cats.length?`<div class="chips" role="group" aria-label="Category">${cats.map(c=>btn('cat',c,c===dirCat)).join('')}</div>`:'');
// A picked chip lifts on a second press. Everything is redrawn, so the keyboard goes back to
// the button just pressed.
for(const b of $$('button',bar)) b.onclick=()=>{
const k=b.dataset.kind!=null?'kind':'cat', v=b.dataset[k];
if(k==='kind') dirKind=v; else dirCat=dirCat===v?null:v;
draw(); $(`[data-${k}="${CSS.escape(v)}"]`,bar)?.focus();
};
box.innerHTML='';
for(const p of ofKind.filter(p=>!dirCat||p.category===dirCat)) box.appendChild(listedFeed(p,'tile'));
};
draw();
return rows.length;
}
/// Directory and Popular open in the main pane, as the original's Directory did.
async function renderListed(v){
const box=$('#content');
box.classList.add('plain');
$('#tbRemove').disabled=true;
syncTools(null);
$('#epSearch').placeholder='Search items…';
const listening=v===VIEWS[':listening'], grid=v===VIEWS[':directory'];
box.innerHTML=`
<div class="fhead slim">
<div class="art">${v.icon}</div>
<div class="meta"><h2>${v.title}</h2>
<div class="sub">${v.blurb}${listening?'':' Everyone counts, you included. Private feeds are never listed.'}</div></div>
</div>
${grid?'<div class="dirbar" id="dirbar"></div>':''}
<div class="${grid?'tiles':'childlist'}" id="${listening?'listening':'popular'}"><p class="hint">Loading…</p></div>`;
$('#count').textContent=v.title;
const n=await (listening?renderListening:grid?renderDirectory:listFeeds)(v.url,$(listening?'#listening':'#popular',box));
if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${plural(n,listening?'episode':'feed')}`;
}
/// Currently Listening: episodes you started and have not finished, across every feed you
/// subscribe to. A row resumes the episode in the player bar on click -- a shortcut back to
/// where you left off, not another way to browse. The one in the player pauses instead.
async function renderListening(url,box){
let rows=[];
try{ rows=(await api(url)).entries||[]; }catch{}
box.innerHTML=rows.length?'':'<p class="hint">Nothing in progress. Episodes you start and do not finish show up here.</p>';
for(const e of rows){
// Carries its episode, for paintListenRow to repaint as the player moves.
const el: HTMLDivElement & {entry?: any}=document.createElement('div');
el.className='childrow';
el.entry=e;
el.innerHTML=artHTML(e.image||feedArt(e.feed_id),e.title||'')+
`<div class="txt"><b>${EQ}<span>${esc(e.title||'(untitled)')}</span></b>`+
`<small><span class="fd">${esc(feedName(e.feed_id))}</span><span class="left"></span></small></div>`+
`<button class="iconbtn" data-a="play"></button>`+
`<button class="iconbtn" data-a="remove" title="Remove from Currently Listening" aria-label="Remove from Currently Listening">${ICON.close}</button>`+
`<div class="rail"><i></i></div>`;
el.onclick=ev=>(ev.target as Element).closest('[data-a=remove]')?forget(e)
:el.classList.contains('now')&&!audio.paused?audio.pause():play(e);
paintListenRow(el);
box.appendChild(el);
}
return rows.length;
}
/// One row's time left, progress and play button, taken from the player when it is the one in it.
function paintListenRow(el){
const e=el.entry, now=player.guid===e.guid&&player.feed===e.feed_id;
// Zero until the player has sought to where you left off; the saved position stands till then.
if(now&&audio.currentTime) e.position=Math.floor(audio.currentTime);
// The player's own length first: a feed's can be minutes out.
const d=(now&&isFinite(audio.duration)&&Math.floor(audio.duration))||e.duration;
el.classList.toggle('now',now);
$('.left',el).textContent=d?`${clock(d-e.position)} left`:`${clock(e.position)} in`;
// With no length there is nothing to show, and an empty rail reads as a heavy border.
const rail=$('.rail',el); rail.hidden=!d;
$('i',rail).style.width=`${d?Math.min(100,e.position/d*100):0}%`;
const b=$('[data-a=play]',el), label=now&&!audio.paused?'Pause':'Resume';
if(b.title!==label){ b.title=label; b.setAttribute('aria-label',label); b.innerHTML=label==='Pause'?ICON.pause:ICON.play; }
}
/// Keeps the list in step with the player. Only a row that is, or was, the one in it changes.
function syncListening(){
for(const el of $$('#listening .childrow'))
if(el.entry&&(el.classList.contains('now')||player.guid===el.entry.guid)) paintListenRow(el);
}
/// Takes an episode off Currently Listening by forgetting where you got to: the list is every
/// episode with a saved position short of the end, so the position is what has to go.
async function forget(e){
// Closed without saving first, or the player's next save would put it straight back.
if(player.guid===e.guid){ player.guid=null; $('#pclose').click(); }
try{
await api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/position`,
{method:'POST',body:JSON.stringify({secs:0})});
}catch(err){ toast(err.message,true); }
if(S.feed===':listening') renderListed(VIEWS[':listening']);
}
// The toolbar acts on whatever is selected: the feed on the left, the item in the table.
$('#tbRemove').onclick=()=>{ const f=S.feeds.find(x=>x.id===S.feed); if(f) removeFeed(f); };
$('#tbPlay').onclick=()=>{ const e=cur(); if(e) play(e); };
$('#tbRead').onclick=()=>{ const e=cur(); if(e) epAction('read',e,null); };
$('#tbFlag').onclick=()=>{ const e=cur(); if(e) epAction('flag',e,null); };
let searchT;
$('#epSearch').oninput=ev=>{ clearTimeout(searchT);
searchT=setTimeout(()=>{ S.q=ev.target.value; S.offset=0; loadEntries(); },250); };
// Crossing the phone breakpoint moves the files between their pane and the text.
window.matchMedia?.('(max-width:820px)')?.addEventListener?.('change',()=>{ const e=cur(); if(e) showDetail(e); });
let expanded = new Set(JSON.parse(localStorage.getItem('ipx.expanded')||'[]'));
function toggleGroup(id){
expanded.has(id) ? expanded.delete(id) : expanded.add(id);
try{ localStorage.setItem('ipx.expanded', JSON.stringify([...expanded])); }catch{}
renderFeeds();
}
let globalMax = 3;
function due(ts){
const d = ts - Date.now()/1000;
if(d <= 0) return 'due now';
if(d < 3600) return 'in '+Math.max(1,Math.round(d/60))+'m';
if(d < 86400) return 'in '+Math.round(d/3600)+'h';
return 'in '+Math.round(d/86400)+'d';
}
/// Your settings: the theme, your subscriptions as OPML, and, to read, what the server does
/// with feeds. The server's own settings, the accounts and the log are on /admin, which only an
/// admin is sent (issue #19); this used to hold them too, shown to admins only.
async function prefsModal(){
const g = await api('/api/settings');
const admin = !!(S.me&&S.me.admin);
openModal(`<h3>Settings</h3>
<div class="field"><label>Theme</label>
<select id="stheme">${Object.entries(THEMES).map(([k,t])=>
`<option value="${k}"${theme.name===k?' selected':''}>${esc(t.name)}</option>`).join('')}</select></div>
<div class="field" id="smodefield"${THEMES[theme.name].modes?'':' hidden'}><label>Light or dark</label>
<select id="smode">${Object.entries(MODES).map(([k,t])=>
`<option value="${k}"${theme.mode===k?' selected':''}>${esc(t)}</option>`).join('')}</select>
<span class="hint">Auto follows your system's light/dark setting.</span></div>
<div class="field"><label>Subscriptions</label>
<div class="inline">
<!-- Words as well as icons: a floppy disk and a plus mean nothing on their own here. -->
<a class="btn" href="/api/opml" download="ipx-subscriptions.opml" title="Export OPML" aria-label="Export OPML">${ICON.save} Export</a>
<button class="btn" id="gopml" title="Import OPML…" aria-label="Import OPML">${ICON.plus} Import…</button>
</div>
<span class="hint">Export saves your subscriptions as OPML for another podcast app. Import
subscribes you to every feed in one.</span></div>
<div class="field"><label>Feeds are checked every</label>
<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>
<div class="field"><label>Download folder</label>
<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);
$('#smode').onchange=e=>setTheme(undefined,e.target.value,true);
$('#gopml').onclick=opmlModal;
}
function settingsModal(f, newUrl?: string){
const isGroup = S.feeds.some(c=>c.group===f.id);
openModal(`<h3>${esc(f.title||f.id)}</h3>
${isGroup?`<p class="hint" style="margin:-6px 0 12px">This is ${isPatreon(f)?'a Patreon creator':'an OPML subscription'}. These
settings apply to it and are inherited by every feed inside it.</p>`:''}
${f.managed?`<p class="hint" style="margin:-6px 0 12px">This feed comes from
${isPatreon(S.feeds.find(p=>p.id===f.group))?'a Patreon creator':'an OPML subscription'} and follows its settings. Saving anything here gives it its own entry in
config.toml, and it stops following the subscription's settings.</p>`:''}
<p class="hint" style="margin:-4px 0 10px">These are <b>your</b> settings for this feed.
Everyone else keeps their own.</p>
<div class="field"><label>Keywords</label>
<input type="text" id="skw" value="${esc(f.keywords.join(', '))}">
<span class="hint">Comma separated. Empty takes everything.</span></div>
<div class="field"><label>Max new downloads per scan</label>
<input type="number" id="smax" min="0" value="${f.max_new_per_check??''}">
<span class="hint">Blank follows the global default (${globalMax}). The rest wait for
the next scan.</span></div>
<label class="check"><input type="checkbox" id="sauto" ${f.auto_download?'checked':''}> Download new items automatically</label>
<label class="check"><input type="checkbox" id="sexp" ${f.allow_explicit?'checked':''}> Allow items marked explicit</label>
<div class="field"><label>Feed URL</label>
<div class="inline">
<input type="text" id="surl" value="${esc(newUrl||f.url)}" spellcheck="false" ${S.me&&S.me.admin?'':'readonly'}>
<button type="button" class="btn ico" id="scopy" title="Copy the URL" aria-label="Copy the URL">${ICON.copy}</button>
</div>
<span class="hint">${S.me&&S.me.admin
? `Shared with everyone reading this feed. Editing it keeps every item and download —
handy when an auth token in the URL is rotated. The feed is re-checked from scratch
on the next scan.`
: `The same for everyone reading this feed, so only an admin can change it.`}</span></div>
${S.me&&S.me.admin?`<div class="field"><label>Download folder (shared)</label>
<input type="text" id="sfolder" value="${esc(f.folder||'')}" placeholder="${esc(f.title||f.id)}">
<span class="hint">Where the files land. There is one copy however many people
subscribe, so this is the same for everyone.</span></div>`:''}
${S.me&&S.me.admin?`<div class="field"><label>Directory category (shared)</label>
<input type="text" id="scat" list="scats" value="${esc(f.category||'')}" placeholder="${esc(f.feed_category||'None')}">
<datalist id="scats"></datalist>
<span class="hint">${f.feed_category
? `The feed names its own, ${esc(f.feed_category)}, and the Directory uses that.`
: `The feed names none, so the Directory files it under this. Pick one already listed where it fits.`}</span></div>`:''}
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
<button class="btn ico primary" id="ssave" title="Save" aria-label="Save">${ICON.check}</button></div>`);
$('#scopy').onclick=()=>copyText($('#surl').value,$('#scopy'));
// Offer the categories the Directory already shows, so a blog about games joins Games rather
// than starting a second chip beside it.
if($('#scats')) api('/api/directory').then(rows=>{ $('#scats').innerHTML=[...new Set((rows||[])
.map(p=>p.category).filter(Boolean))].sort().map(c=>`<option value="${esc(c)}">`).join(''); }).catch(()=>{});
$('#ssave').onclick=async()=>{
const max=$('#smax').value;
try{
const patch: Record<string, unknown>={
keywords:$('#skw').value.split(',').map(s=>s.trim()).filter(Boolean),
max_new_per_check:max===''?null:Number(max),
auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked};
// The shared half is an admin's to change, and the API refuses it from anyone else.
if(S.me&&S.me.admin){
patch.url=$('#surl').value.trim();
patch.folder=$('#sfolder').value.trim()||null;
patch.category=$('#scat').value.trim()||null;
}
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify(patch)});
closeModal(); toast('Saved — applies on the next scan');
await loadFeeds(true); renderFeed(); loadEntries();
}catch(e){ toast(e.message,true); }
};
}
function downloadLatestModal(f){
openModal(`<h3>Download latest items</h3>
<div class="field"><label>How many of the newest undownloaded items?</label>
<input type="number" id="dcount" min="1" max="100" value="5">
<span class="hint">Queued immediately, ignoring the per-scan limit.</span></div>
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
<button class="btn ico primary" id="dgo" title="Download" aria-label="Download">${ICON.download}</button></div>`);
$('#dgo').onclick=async()=>{
const n=Number($('#dcount').value)||5;
try{
const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/download-latest`,
{method:'POST',body:JSON.stringify({count:n})});
closeModal(); toast(`Queued ${r.queued} item${r.queued===1?'':'s'}`);
}catch(e){ toast(e.message,true); }
};
}
function removeFeed(f){
openModal(`<h3>Unsubscribe?</h3>
<p style="color:var(--dim)">Removes <b>${esc(f.title||f.id)}</b> from your feeds. Anyone else
reading it keeps it, along with their own read state.
Downloaded files and history are kept, so re-adding it will not pull the back catalogue again.</p>
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
<button class="btn ico danger" id="rgo" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button></div>`);
$('#rgo').onclick=async()=>{
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'DELETE'});
closeModal(); toast('Unsubscribed'); S.feed=null;
await loadFeeds(); if(!S.feeds.length) renderFeed();
};
}
function opmlModal(){
openModal(`<h3>OPML</h3>
<p style="color:var(--dim);font-size:13.5px">Move subscriptions between podcast apps.</p>
<div class="field"><label>Export: save your subscriptions as OPML</label>
<div class="inline">
<a class="btn ico" href="/api/opml" download="ipx-subscriptions.opml" title="Export OPML" aria-label="Export OPML">${ICON.save}</a>
</div></div>
<div class="field" style="margin-top:16px"><label>Import: choose a file, or paste OPML</label>
<input type="file" id="opmlFile" accept=".opml,.xml,text/x-opml,text/xml,application/xml" style="margin-bottom:8px">
<textarea id="opmlText" rows="6" style="width:100%;background:var(--bg);border:1px solid var(--line);color:var(--fg);border-radius:8px;padding:8px;font:12px monospace"></textarea></div>
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button>
<button class="btn ico primary" id="oimp" title="Import: subscribe to every feed in it" aria-label="Import">${ICON.plus}</button></div>`);
$('#oimp').onclick=async()=>{
// A chosen file is read here and sent as text, so the server never stores it. Clearing
// the picker lets go of it on this side too, whether it was refused or imported.
const pick=$('#opmlFile'), file=pick.files[0];
const xml=file ? await file.text() : $('#opmlText').value;
const letGo=()=>{ pick.value=''; };
// A quick look before sending anything. The server parses it properly and has the last word.
if(!/<opml[\s>]/i.test(xml)){
letGo(); toast(`${file?file.name:'That'} is not an OPML file`,true); return;
}
try{
const r=await api('/api/opml',{method:'POST',body:JSON.stringify({xml})});
letGo(); closeModal(); toast(`Subscribed to ${r.added} feed(s)`+(r.already?`, ${r.already} you already had`:'')); loadFeeds(true);
}catch(e){ letGo(); toast(e.message,true); }
};
}
async function scanAll(){ toast('Scanning all feeds…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); }
$('#scanAll').onclick=scanAll;
$('#prefs').onclick=prefsModal;
// Someone the proxy signed in is signed out by the proxy: ipx's own sign-out cannot stick while
// the proxy still vouches for them. /api/me says where, when that is the case.
$('#signout').onclick=async()=>{ await api('/api/logout',{method:'POST'}); location.href=S.me?.sign_out||'/login'; };
api('/api/me').then(u=>{
S.me=u;
$('#who').textContent=u.name+(u.admin?' · admin':'');
}).catch(()=>{});
$('#feedFilter').oninput=renderFeeds;
// The feed list from the keyboard: Enter or Space opens a row, Right and Left open and close a
// folder. Handled keys stop here, or the player's own Space and arrows would act on them too.
$('#feedlist').onkeydown=ev=>{
const row=ev.target.closest('[data-id]'); if(!row) return;
const id=row.dataset.id;
if((ev.key==='Enter'||ev.key===' ')&&ev.target===row) row.click();
else if(row.classList.contains('group')&&
(ev.key==='ArrowRight'&&!expanded.has(id)||ev.key==='ArrowLeft'&&expanded.has(id))) toggleGroup(id);
else return;
ev.preventDefault(); ev.stopPropagation();
};
$('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open'));
$('#scrim').onclick=()=>nav(false);

46
web/src/events.ts Normal file
View File

@@ -0,0 +1,46 @@
/* ---------------- live events ---------------- */
let sse;
function connect(){
sse=new EventSource('/api/events');
const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
const refreshFeeds=soon(()=>loadFeeds(true));
const refreshEntries=soon(()=>{ if(S.feed) loadEntries(); });
let fresh={};
const tellNew=soon(()=>{
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=>{
let ev; try{ ev=JSON.parse(m.data) }catch{ return }
if(ev.ev==='progress'){
const pct=ev.total?ev.done/ev.total*100:0;
// Only the row actually downloading. Without the enclosure id this used to paint
// every pending bar at once, so adding a feed looked like it was fetching the lot.
const bar=document.querySelector<HTMLElement>(`.dlbar[data-bar="${ev.enclosure}"] i`);
if(bar){ bar.style.width=pct+'%'; bar.parentElement.classList.add('live'); }
$('#count') && ($('#count').textContent=`downloading ${ev.file} — ${pct.toFixed(0)}%`);
}
else if(ev.ev==='download_done'){
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
if(bar) bar.classList.remove('live');
toast('Downloaded '+ev.path.split('/').pop()); refreshEntries(); refreshFeeds();
}
else if(ev.ev==='download_error'){
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
if(bar) bar.classList.remove('live');
toast('Download failed: '+ev.msg,true); refreshEntries();
}
else if(ev.ev==='feed_done'){
if(ev.new){ fresh[ev.feed]=(fresh[ev.feed]||0)+ev.new; tellNew(); }
refreshFeeds(); if(ev.feed===S.feed||S.feed===':all') refreshEntries();
}
// 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.
else if(ev.ev==='feed_error') refreshFeeds();
else if(ev.ev==='scan_done'){ refreshFeeds(); refreshEntries(); }
};
sse.onerror=()=>{ sse.close(); setTimeout(connect,4000); };
}
connect();
loadFeeds();

185
web/src/feedpage.ts Normal file
View File

@@ -0,0 +1,185 @@
/* ---------------- feed page ---------------- */
function renderFeed(){
const box=$('#content');
box.classList.remove('plain');
const v=VIEWS[S.feed];
if(v&&v.url) return renderListed(v);
const f=v?null:S.feeds.find(x=>x.id===S.feed);
$('#tbRemove').disabled=!f;
syncTools(null);
if(!v&&!f){ box.innerHTML='<p class="empty">Add a feed to get started.</p>'; $('#count').textContent=''; return; }
const name=f?(f.title||f.id):v.title;
$('#epSearch').placeholder=`Search ${name}…`;
const kids=f?S.feeds.filter(c=>c.group===f.id):[];
if(kids.length){ box.classList.add('plain'); renderGroup(f,kids); return; }
const unreadAll=S.feeds.reduce((n,x)=>n+(x.unread||0),0);
box.innerHTML = (f ? `
<div class="fhead slim">
${artHTML(f.image,name)}
<div class="meta">
<h2>${esc(name)}</h2>
<div class="sub stat" title="Checked every ${everyText(f.every_mins)}${f.next_check?`, next ${due(f.next_check)}`:''}">${
plural(f.entries,'item')}, ${f.downloaded} downloaded · checked ${ago(f.last_checked)}${
f.subscribers>1?` · shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'}`:''}</div>
${failBannerHTML(f)}
${f.orphaned?`<div class="sub" style="color:var(--warn)">This feed is no longer listed in its
OPML subscription. It was kept rather than removed because it has downloaded items.</div>`:''}
${f.group?`<div class="sub">From the OPML subscription <b>${esc(f.group)}</b></div>`:''}
</div>
<div class="acts">
<button class="btn ico primary" data-a="scan" title="Check this feed now" aria-label="Check this feed now">${ICON.scan}</button>
<button class="btn ico" data-a="dl" title="Download latest…" aria-label="Download latest">${ICON.download}</button>
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
<button class="btn ico" data-a="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button>
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
</div>
</div>` : `
<div class="fhead slim">
<div class="art">${v.icon}</div>
<div class="meta">
<h2>${v.title}</h2>
<div class="sub">Every item from the ${S.feeds.length} feed${S.feeds.length===1?'':'s'} you
subscribe to, newest first · ${unreadAll} unread</div>
</div>
<div class="acts">
<button class="btn ico primary" data-a="scanall" title="Check every feed now" aria-label="Check every feed now">${ICON.scan}</button>
<button class="btn ico" data-a="readall" title="Mark everything read" aria-label="Mark everything read">${ICON.checks}</button>
</div>
</div>`) + `
<div class="toolbar">
<div class="tabs">
${[['all','All'],['unread','Unread'],['downloaded','Downloaded'],['flagged','Pinned']].map(([t,label])=>
`<button data-f="${t}" class="${S.filter===t?'on':''}">${label}</button>`).join('')}
</div>
</div>`;
const pane=document.createElement('div');
pane.id='split';
if(f) pane.className='one';
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') || '60%');
dragSplit(pane);
// Nothing is open any more, so nothing is kept on the Unread tab for being open.
S.sel=null;
showDetail(null);
$$('#content .acts .btn').forEach(b=>b.onclick=()=>f?feedAction(b.dataset.a,f):allAction(b.dataset.a));
$$('#content .tabs button').forEach(b=>b.onclick=()=>{
S.filter=b.dataset.f; S.offset=0;
try{ localStorage.setItem('ipx.filter',S.filter); }catch{}
renderFeed(); loadEntries();
});
if(f) wireFailBanner(box,f);
}
/// 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','Pinned',ICON.pin],['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':''}${icon?' h-ic':''}" 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.
// A Patreon creator split into its shows is drawn like an OPML, and named for what it is.
const isPatreon=f=>/patreon\.com\//.test(f&&f.url||'');
function renderGroup(f,kids){
const unread=kids.reduce((n,c)=>n+c.unread,0);
const saved=kids.reduce((n,c)=>n+c.downloaded,0);
const gone=kids.filter(c=>c.orphaned).length;
$('#count').textContent=`${f.title||f.id}: ${kids.length} feed${kids.length===1?'':'s'}, ${unread} unread`;
// The same header as a feed's, buttons in the same places: it is a feed underneath.
$('#content').innerHTML = `
<div class="fhead slim">
${folderArt(f,kids)}
<div class="meta">
<h2>${esc(f.title||f.id)}</h2>
<div class="sub stat" title="Checked every ${everyText(f.every_mins)}">${isPatreon(f)?'Patreon creator':'OPML subscription'}
· ${plural(kids.length,'feed')}, ${unread} unread, ${saved} downloaded · checked ${ago(f.last_checked)}</div>
${failBannerHTML(f)}
${gone?`<div class="sub" style="color:var(--warn)">${gone} feed${gone===1?' is':'s are'} no longer
listed but kept because ${gone===1?'it has':'they have'} downloads.</div>`:''}
</div>
<div class="acts">
<button class="btn ico primary" data-a="scan" title="Re-read the OPML now" aria-label="Re-read the OPML now">${ICON.scan}</button>
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
<button class="btn ico" data-a="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button>
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
</div>
</div>
<div class="toolbar">
<input type="search" class="grow" id="kidSearch" placeholder="Search these feeds…">
<span style="color:var(--faint);font-size:12.5px">${esc(f.url)}</span>
</div>
<div class="childlist" id="kidlist"></div>`;
$$('#content .acts .btn').forEach(b=>b.onclick=()=>feedAction(b.dataset.a,f));
wireFailBanner($('#content'),f);
const draw=()=>{
const q=($('#kidSearch').value||'').trim().toLowerCase();
const box=$('#kidlist'); box.innerHTML='';
const rows=kids.filter(c=>!q||(c.title||c.id).toLowerCase().includes(q)).sort(unreadFirst);
if(!rows.length){ box.innerHTML='<p class="empty">Nothing matches.</p>'; return; }
for(const c of rows){
const el=document.createElement('div');
el.className='childrow';
el.innerHTML = artHTML(c.image,c.title||c.id)+
`<div class="txt"><b>${esc(c.title||c.id)}</b>`+
`<small class="meta">${plural(c.entries,'item')} · ${c.downloaded} downloaded`+
(c.failing?` · <span style="color:var(--bad)" title="${esc(c.failing.reason)}">error</span>`
:c.last_error?` · <span style="color:var(--bad)">error</span>`:'')+`</small></div>`+
(c.orphaned?'<span class="tag">Gone</span>':'')+
(c.failing?`<span class="tag" style="color:var(--bad)" title="${esc(c.failing.reason)}">Error</span>`:'')+
`<span class="badge${c.unread?'':' zero'}">${c.unread}</span>`;
el.onclick=()=>selectFeed(c.id);
box.appendChild(el);
}
};
$('#kidSearch').oninput=draw;
draw();
}
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==='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==='pin'){
try{
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({pinned:!f.pinned})});
await loadFeeds(true); renderFeed();
}catch(e){ toast(e.message,true); }
}
if(a==='settings') settingsModal(f);
if(a==='dl') downloadLatestModal(f);
}
/// All Subscriptions' own buttons: a feed's, across every feed you read.
async function allAction(a){
if(a==='scanall') return scanAll();
if(a==='readall'){
const n=S.feeds.reduce((k,x)=>k+(x.unread||0),0);
if(!n){ toast('Nothing unread'); return; }
// One click across every feed is a lot to take back, so this one asks first.
if(!confirm(`Mark all ${n} unread item${n===1?'':'s'} read, in every feed you subscribe to?`)) return;
try{
const r=await api('/api/read-all',{method:'POST'});
toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries();
}catch(e){ toast(e.message,true); }
}
}

129
web/src/feeds.ts Normal file
View File

@@ -0,0 +1,129 @@
/* ---------------- feeds ---------------- */
async function loadFeeds(keepSel?: boolean){
S.feeds = await api('/api/feeds');
api('/api/settings').then(g=>{globalMax=g.max_new_per_check}).catch(()=>{});
renderFeeds();
// Land back where you were; a feed you no longer subscribe to, or a first visit, goes to
// All Subscriptions rather than picking one alphabetically. Nothing to land on at all (a
// brand new account) leaves S.feed alone, so the empty state's own message shows instead.
if(!keepSel && S.feeds.length){
const known = S.feed && (VIEWS[S.feed] || S.feeds.some(f=>f.id===S.feed));
selectFeed(known ? S.feed : ':all');
}
}
// An OPML can hold dozens of feeds; the ones with something new go first. sort is stable, so the
// server's alphabetical order still holds within each half.
const unreadFirst=(a,b)=>Number(b.unread>0)-Number(a.unread>0);
// The original's source list opened with these, above the feeds. They are places, not feeds:
// an id starting with ':' can never be a feed's, since feed ids are slugs.
const VIEWS={
':directory':{title:'Directory',icon:ICON.directory,url:'/api/directory',
blurb:'Every feed anyone on this server subscribes to, A to Z. The feeds inside an OPML are listed one by one, not the OPML.'},
':popular':{title:'Popular',icon:ICON.popular,url:'/api/popular',
blurb:'The ten feeds with the most subscribers here. The feeds inside an OPML count one by one, not the OPML.'},
':listening':{title:'Currently Listening',icon:ICON.audio,url:'/api/entries?filter=in_progress&limit=50',
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},
};
function renderFeeds(){
const q=$('#feedFilter').value.trim().toLowerCase();
const list=$('#feedlist'); const top=list.scrollTop;
// Every row is replaced, so put the keyboard back on the row, or the triangle, it was on.
const a=document.activeElement, was=a&&a.closest&&a.closest<HTMLElement>('#feedlist [data-id]');
const back=was&&([was.dataset.id,a.classList.contains('chev')] as [string, boolean]);
const done=()=>{
list.scrollTop=top;
const row=back&&list.querySelector(`[data-id="${CSS.escape(back[0])}"]`);
if(row) (back[1]&&$('.chev',row)||row).focus();
};
list.innerHTML='';
const unreadAll=S.feeds.reduce((n,f)=>n+(f.unread||0),0);
const places=document.createElement('div');
places.className='places';
for(const [id,v] of Object.entries(VIEWS)){
const el=document.createElement('div');
el.className='place'+(S.feed===id?' sel':'');
el.tabIndex=0; el.dataset.id=id;
el.innerHTML=`<span class="ico">${v.icon}</span><b>${v.title}</b>`+(id===':all'
?`<span class="badge${unreadAll?'':' zero'}" title="${unreadAll} unread">${unreadAll>999?'999+':unreadAll}</span>`:'');
el.onclick=()=>{ selectFeed(id); nav(false); };
places.appendChild(el);
}
list.appendChild(places);
const shown=S.feeds.filter(f=>!q||(f.title||f.id).toLowerCase().includes(q));
if(!shown.length){ list.insertAdjacentHTML('beforeend','<p class="empty" style="padding:20px 8px">No feeds.</p>'); return done(); }
// Feeds from a subscribed OPML sit under it, so the group reads as one thing. Pinned feeds
// go first: a folder with its feeds under it, a feed from inside one lifted out of it.
const byId=Object.fromEntries(shown.map(f=>[f.id,f]));
const inside=f=>shown.filter(c=>c.group===f.id&&!c.pinned);
const tops=shown.filter(f=>f.pinned||!(f.group&&byId[f.group]));
const order=[];
for(const f of [...tops.filter(f=>f.pinned),...tops.filter(f=>!f.pinned)]){
order.push([f,0]);
// A subscription can hold dozens of feeds, so a folder starts closed. Searching
// opens them all, or matches inside a closed folder would be invisible.
if(expanded.has(f.id) || q)
for(const c of inside(f).sort(unreadFirst)) order.push([c,1]);
}
// The rule under the pinned block goes under its last row: a pinned folder's last feed when
// it is open.
const lastTop=order.findIndex(([f,d])=>!d&&!f.pinned);
const lastPin=(lastTop<0?order.filter(([f])=>f.pinned):order.slice(0,lastTop)).pop()?.[0];
for(const [f,depth] of order){
const kids=inside(f).length;
// A subscription holds no entries itself, so its counts are the sum of what is inside --
// taken from every feed it holds, not just the ones a filter left showing.
const mine=S.feeds.filter(c=>c.group===f.id);
const sum=k=>mine.reduce((n,c)=>n+(c[k]||0),0);
const [unread,eps,saved]=mine.length
? [sum('unread'),sum('entries'),sum('downloaded')]
: [f.unread,f.entries,f.downloaded];
// A group's own row has no error of its own worth mentioning if the OPML itself
// reads fine; it is failing when any feed inside it is. The feed's own page says what
// went wrong (failBannerHTML); the list only has to make it findable.
const bad=c=>c.failing?.reason||c.last_error;
const err=mine.length ? mine.map(bad).find(Boolean) : bad(f);
const el=document.createElement('div');
el.className='feed'+(S.feed===f.id?' sel':'')+(depth?' child':'')+(kids?' group':'')+
(f.pinned&&!depth?' pinned':'')+(f===lastPin&&lastTop>=0?' lastpin':'');
el.tabIndex=0; el.dataset.id=f.id;
const open = !!(kids && (expanded.has(f.id) || q));
el.innerHTML =
// The error mark hangs in the margin where a folder's triangle does. A folder already has
// its triangle there, so that turns red instead, and the feed inside shows the mark.
(kids?`<button class="chev${err?' bad':''}" aria-expanded="${open}" title="${err?`A feed inside has a problem: ${esc(err)}`:'Show or hide the feeds inside'}" aria-label="Show or hide the feeds inside">${ICON.caret}</button>`
:err?`<span class="ferr" role="img" title="${esc(err)}" aria-label="Error: ${esc(err)}">${ICON.alert}</span>`:'')+
(mine.length?folderArt(f,mine):artHTML(f.image,f.title||f.id))+
`<div class="txt"><b>${f.pinned?`<span class="fpin" title="Pinned">${ICON.pinOn}</span>`:''}${esc(f.title||f.id)}</b><small>`+
`${mine.length?plural(mine.length,'feed'):plural(eps,'item')} · ${saved} downloaded`+
`</small></div>`+
(f.orphaned?'<span class="tag" title="No longer listed, kept because it has downloads">Gone</span>':'')+
`<span class="badge${unread?'':' zero'}" title="${unread} unread">${unread>999?'999+':unread}</span>`;
el.onclick=()=>{ selectFeed(f.id); nav(false); };
if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); };
list.appendChild(el);
}
done();
}
function selectFeed(id){
S.feed=id; S.offset=0; S.sel=null; S.q=''; $('#epSearch').value='';
try{ localStorage.setItem('ipx.feed',id); }catch{}
renderFeeds(); renderFeed(); loadEntries();
}
/// A failing feed's error, in plain words with something to do about it, once `failing` is
/// set (it has been failing for a day and is a kind worth naming -- see `explain_failure` in
/// src/feed.rs). Anything else still shows the raw error, as before.
function failBannerHTML(f){
if(f.failing) return `<div class="sub" style="color:var(--bad)">${esc(f.failing.reason)}
<button type="button" class="btn tiny" data-ffail="unsub">Unsubscribe</button>${
f.failing.new_url?` <button type="button" class="btn tiny" data-ffail="newurl">Use the new address</button>`:''}</div>`;
if(f.last_error) return `<div class="sub" style="color:var(--bad)">${esc(f.last_error)}</div>`;
return '';
}
function wireFailBanner(box,f){
const un=$('[data-ffail="unsub"]',box); if(un) un.onclick=()=>removeFeed(f);
const nu=$('[data-ffail="newurl"]',box); if(nu) nu.onclick=()=>settingsModal(f,f.failing.new_url);
}

83
web/src/gestures.ts Normal file
View File

@@ -0,0 +1,83 @@
/* ---------------- touch gestures ---------------- */
// On a touch screen: pull the item list down from its top to check the feed for new items, and
// swipe the item you are reading left for the next one, right for the one before, or back to
// the list from the first. Touch events only, so a mouse never sets these off. Listened for on
// the document because the panes are rebuilt every time a feed renders.
const PULL = 70; // px down, from the list's top, that counts as a pull
const SWIPE = 60; // px across that counts as a swipe
let touch: {x: number, y: number, t: number, pane: 'list' | 'detail', dx: number, dy: number} | null = null;
/// Something that scrolls sideways, or takes typing, keeps its own gestures: a wide code block
/// or table in a post, the player's seek bar, a text box.
function ownsSwipe(el: Element | null){
for(let n = el; n && n.id !== 'detail'; n = n.parentElement){
if(/^(INPUT|TEXTAREA|SELECT|AUDIO|VIDEO)$/.test(n.tagName)) return true;
if(n.scrollWidth > n.clientWidth + 1 && /(auto|scroll)/.test(getComputedStyle(n).overflowX)) return true;
}
return false;
}
/// How far the list has been pulled: a note at its top, growing with the pull and pushing the
/// items down, that says what letting go will do.
function pullShow(dy: number){
const list = $('#list'); if(!list) return;
const d = Math.round(Math.min(dy, PULL * 1.6) / 2);
let tip = $('#pulltip');
if(!d){ tip?.remove(); return; }
if(!tip){ tip = document.createElement('div'); tip.id = 'pulltip'; list.prepend(tip); }
tip.style.height = d + 'px';
tip.textContent = dy >= PULL ? 'Release to check for new items' : 'Pull to check for new items';
}
function refreshFeed(){
const f = S.feeds.find(x => x.id === S.feed);
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.
api('/api/fetch', {method: 'POST', body: JSON.stringify(f ? {feed: f.id, force: true} : {force: true})})
.catch(e => toast(e.message, true));
loadEntries();
}
document.addEventListener('touchstart', ev => {
touch = null;
if(ev.touches.length !== 1 || $('#modal')?.classList.contains('on')) return;
const t = ev.touches[0], target = ev.target as Element;
const detail = target.closest?.('#detail'), list = target.closest?.('#list');
// Reading means an item is open; the reader is its own screen on a phone, a pane otherwise.
if(detail && S.sel && !ownsSwipe(target)) touch = {x: t.clientX, y: t.clientY, t: Date.now(), pane: 'detail', dx: 0, dy: 0};
else if(list && list.scrollTop <= 0 && !VIEWS[S.feed]?.url) touch = {x: t.clientX, y: t.clientY, t: Date.now(), pane: 'list', dx: 0, dy: 0};
}, {passive: true});
document.addEventListener('touchmove', ev => {
if(!touch) return;
const t = ev.touches[0];
touch.dx = t.clientX - touch.x; touch.dy = t.clientY - touch.y;
if(touch.pane === 'list'){
// Only a pull that starts at the top and goes down; anything else is an ordinary scroll.
if(touch.dy < 0 || $('#list').scrollTop > 0){ pullShow(0); touch = null; return; }
pullShow(touch.dy);
if(ev.cancelable) ev.preventDefault(); // or the list rubber-bands under the finger as well
}else if(Math.abs(touch.dy) > 10 && Math.abs(touch.dy) > Math.abs(touch.dx)){
touch = null; // scrolling the text, not swiping; the first few px
// decide nothing, being mostly jitter
}
}, {passive: false});
document.addEventListener('touchend', () => {
const g = touch; touch = null;
if(!g) return;
if(g.pane === 'list'){
pullShow(0);
if(g.dy >= PULL) refreshFeed();
return;
}
// Across, mostly sideways, and not a slow drag while selecting text.
if(Math.abs(g.dx) < SWIPE || Math.abs(g.dx) < 2 * Math.abs(g.dy) || Date.now() - g.t > 800) return;
const i = S.entries.findIndex(x => x.guid === S.sel);
if(g.dx < 0){ if(i < S.entries.length - 1) stepEntry(1); return; }
if(i > 0) stepEntry(-1); else showDetail(null);
});
document.addEventListener('touchcancel', () => { if(touch?.pane === 'list') pullShow(0); touch = null; });

344
web/src/items.ts Normal file
View File

@@ -0,0 +1,344 @@
/* ---------------- items ---------------- */
let loadSeq=0;
async function loadEntries(append?: boolean){
// Directory and Popular list feeds, not items.
if(!S.feed || VIEWS[S.feed]?.url) return;
const p=new URLSearchParams({offset:String(S.offset),limit:String(LIMIT),filter:S.filter,sort:S.sort.col,dir:S.sort.dir});
if(S.q) p.set('q',S.q);
const asked=performance.now(), seq=++loadSeq;
const r=await api(S.feed===':all' ? `/api/entries?${p}`
: `/api/feeds/${encodeURIComponent(S.feed)}/entries?${p}`);
// Only the latest list counts: marking everything read reloads the All tab, and clicking
// Unread straight after could have that answer land last and replace the Unread one.
if(seq!==loadSeq) return;
S.total=r.total;
for(const e of r.entries){
const w=readWrites.get(readKey(e));
if(w && !(w.done<asked)) e.read=w.read;
}
let entries = append ? S.entries.concat(r.entries) : r.entries;
// A background scan finishing refreshes the list from the server, which -- on the Unread
// tab -- would drop the item you have open the moment reading it took it off the filter.
// Keep it until you pick a different one; the next refresh after that no longer protects it.
if(S.filter==='unread') entries=entries.filter(e=>!e.read||e.guid===S.sel);
if(!append && S.sel && !entries.some(e=>e.guid===S.sel)){
const open=S.entries.find(e=>e.guid===S.sel);
if(open) entries=[open,...entries];
}
S.entries = entries;
renderEntries();
}
function renderEntries(){
const box=$('#eps'); if(!box) return;
const f=S.feeds.find(x=>x.id===S.feed), v=VIEWS[S.feed];
const unread=f?f.unread:S.feeds.reduce((n,x)=>n+(x.unread||0),0);
$('#count').textContent=
`${f?(f.title||f.id):v?v.title:''}: ${S.total} item${S.total===1?'':'s'}, ${unread} unread`;
const pane=$('#list'), top=pane?pane.scrollTop:0;
box.innerHTML='';
if(!S.entries.length){
box.innerHTML=`<p class="empty">${S.q?'Nothing matches that search.':'Nothing here yet — try Scan now.'}</p>`;
return;
}
for(const e of S.entries) box.appendChild(epEl(e));
syncPlayButtons();
if(pane) pane.scrollTop=top;
if(S.entries.length < S.total){
const b=document.createElement('button');
b.className='btn'; b.id='more'; b.textContent=`Load more (${S.entries.length} of ${S.total})`;
b.onclick=()=>{S.offset+=LIMIT;loadEntries(true)};
box.appendChild(b);
}
}
function epEl(e){
// An item may carry several files. The row summarises the one you would act on --
// the playable one, else anything already downloaded, else the first -- and says how
// many others there are; the pane below lists them all.
const enc=e.enclosures.find(isPlayable) || e.enclosures.find(x=>x.path) || e.enclosures[0];
const has=!!(enc&&enc.path);
const playable=isPlayable(enc);
const others=e.enclosures.length-1;
const el=document.createElement('div');
el.className='ep'+(e.read?' read':'')+(S.sel===e.guid?' sel':'');
el.dataset.guid=e.guid;
const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join('');
const left = e.position>10 && e.duration ? `${clock(e.duration-e.position)} left` : (e.duration?clock(e.duration):'');
el.innerHTML=`
<button class="st" data-a="read" title="Mark ${e.read?'unread':'read'}">${
player.guid===e.guid?EQ:(e.read?'':'●')}</button>
<button class="fl${e.flagged?' on':''}" data-a="flag" title="${
e.flagged?'Unpin':'Pin, so it is never deleted'}">${e.flagged?ICON.pinOn:ICON.pin}</button>
<div class="body">
<span class="t">${esc(e.title||'(untitled)')}</span>
<div class="line">${[
num&&`<span>${num}</span>`,
left&&`<span>${left}</span>`,
others>0&&`<span>+${others} more file${others===1?'':'s'}</span>`,
enc&&enc.last_error&&enc.state==='error'&&`<span style="color:var(--bad)">${esc(enc.last_error)}</span>`,
].filter(Boolean).join('<span class="dot"></span>')}</div>
</div>
<span class="fd">${esc(feedName(e.feed_id))}</span>
<div class="file">
${enc?kindIcon(enc):''}
${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>`:
(enc&&!has?`<button class="iconbtn" data-a="get" title="Download this ${
enc.state==='skipped'?kindOf(enc):'file'}">${ICON.download}</button>`:'')}
${has?`<button class="iconbtn" data-a="del" title="Delete file" aria-label="Delete file">${ICON.trash}</button>`:''}
</div>`;
el.onclick=()=>selectEntry(e);
$$('button[data-a]',el).forEach(b=>b.onclick=ev=>{ev.stopPropagation();epAction(b.dataset.a,e,el)});
return el;
}
/// Whether the browser can play it. Having a file is not the same as being playable:
/// blog feeds put article images in enclosures, and an <audio> element pointed at a JPEG
/// is just a broken player.
function isPlayable(x){
if(!x || !x.path) return false;
const m=(x.mime||'').toLowerCase();
if(m.startsWith('audio/')||m.startsWith('video/')) return true;
if(m) return false;
// No declared type: fall back to the file's extension.
return /\.(mp3|m4a|m4b|aac|ogg|oga|opus|flac|wav|mp4|m4v|mov|webm|mkv)$/i
.test((x.path||x.url||'').split('?')[0]);
}
/// What an enclosure is, for a row that is not media: "image", "pdf", "document".
function kindOf(enc){
const m=(enc.mime||'').toLowerCase();
if(m.startsWith('image/')) return 'image';
if(m.startsWith('video/')) return 'video';
if(m.startsWith('audio/')) return 'audio';
if(m.includes('pdf')) return 'pdf';
if(m.includes('torrent')) return 'torrent';
const ext=(enc.url||'').split('?')[0].split('.').pop();
return (ext && ext.length<=5) ? ext.toLowerCase() : 'file';
}
/// What a file is, as one icon coloured by whether it is here: green once downloaded, red when
/// the download failed, plain otherwise, so a file waiting and one deleted read alike. One icon
/// either way keeps the column lined up; the words are in its tooltip.
function kindIcon(enc){
const k=kindOf(enc);
const i={audio:ICON.audio,video:ICON.video,image:ICON.image,pdf:ICON.doc,torrent:ICON.torrent}[k]||ICON.file;
const [cls,label]=enc.path ? [' here',`${k}, downloaded`]
: enc.state==='error' ? [' bad',`${k}, download failed${enc.last_error?': '+enc.last_error:''}`]
: ['',k];
return `<span class="kind${cls}" title="${esc(label)}" aria-label="${esc(label)}">${i}</span>`;
}
function feedArt(id=S.feed){ const f=S.feeds.find(x=>x.id===id); return f&&f.image; }
const feedName=id=>{ const f=S.feeds.find(x=>x.id===id); return f?(f.title||f.id):id; };
/// Selecting an item shows it in the pane below, rather than expanding the row.
/// Replaces one row with a fresh one, leaving the rest of the list and its scroll alone.
function swapRow(e){
const row=$(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`);
if(row){ row.replaceWith(epEl(e)); syncPlayButtons(); }
}
/// Read and unread as this page last set them, and when the server had it. A list asked for
/// before then answers with the old state: it put the dot back on an item just read, until
/// the next refresh took it off again (loadEntries).
const readWrites=new Map();
const readKey=e=>e.feed_id+'\n'+e.guid;
function setRead(e,read){
e.read=read;
const w: {read: boolean, done?: number}={read}; readWrites.set(readKey(e),w);
return api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/flags`,
{method:'POST',body:JSON.stringify({read})})
.then(()=>{ w.done=performance.now(); loadFeeds(true); });
}
/// Opening an item is reading it. The row is redrawn where it stands rather than the list
/// reloaded, so an item does not vanish from under the pointer on the Unread tab.
function markRead(e){
if(e.read) return;
setRead(e,true).catch(err=>{ e.read=false; readWrites.delete(readKey(e)); toast(err.message,true); });
}
function selectEntry(e){
// On the Unread tab the item you were reading goes as you move on, not whenever a refresh
// next happens to come along, which left a few read ones in the list for a while.
const prev=S.filter==='unread' && S.sel!==e.guid && S.entries.find(x=>x.guid===S.sel);
if(prev&&prev.read){
S.entries=S.entries.filter(x=>x!==prev); S.total--;
$(`#eps .ep[data-guid="${CSS.escape(prev.guid)}"]`)?.remove();
}
S.sel=e.guid;
markRead(e);
$$('#eps .ep').forEach(x=>x.classList.toggle('sel', x.dataset.guid===e.guid));
swapRow(e);
showDetail(e);
const d=$('#detail'); if(d) d.scrollTop=0;
}
/// Drag the divider between the item list and the item text.
function dragSplit(pane){
const grab=$('#grab',pane);
if(!grab) return;
const move=ev=>{
const box=pane.getBoundingClientRect();
const pct=Math.min(80,Math.max(12,((ev.clientY-box.top)/box.height)*100));
pane.style.setProperty('--listh',pct.toFixed(1)+'%');
};
const stop=()=>{
document.removeEventListener('mousemove',move);
document.removeEventListener('mouseup',stop);
document.body.style.userSelect='';
try{ localStorage.setItem('ipx.listh', pane.style.getPropertyValue('--listh')); }catch{}
};
grab.onmousedown=ev=>{
ev.preventDefault();
document.body.style.userSelect='none';
document.addEventListener('mousemove',move);
document.addEventListener('mouseup',stop);
};
}
/// Which item the toolbar's play, read and keep buttons act on.
// ponytail: matched by guid alone; two feeds sharing a guid in All Subscriptions would pick
// the first. Key rows by feed as well if that ever happens.
const cur=()=>S.entries.find(x=>x.guid===S.sel);
function syncTools(e){
$('#tbPlay').disabled=!(e&&e.enclosures.some(isPlayable));
$('#tbRead').disabled=$('#tbFlag').disabled=!e;
// The same icons as the item's own buttons beside its title, so the two never disagree.
$('#tbRead').innerHTML=e&&e.read?ICON.unread:ICON.check;
$('#tbFlag').innerHTML=e&&e.flagged?ICON.pinOn:ICON.pin;
if(e){
$('#tbRead').title=`Mark ${e.read?'unread':'read'}`;
$('#tbFlag').title=e.flagged?'Unpin':'Pin, so it is never deleted';
}
}
/// The item's text in the pane below the list, and its files in the pane beside it.
function showDetail(e){
const box=$('#detail'), files=$('#files'); if(!box) return;
// A pane that can only say "No files" gives its width to the list instead.
if(files) files.hidden=!(e&&e.enclosures.length);
document.body.classList.toggle('reading',!!e);
syncTools(e);
if(!e){
box.innerHTML='<p class="empty">Pick an item to read it.</p>';
if(files) files.innerHTML='<p class="empty">No files</p>';
return;
}
// Nothing when there are no files: the pane that would say so is hidden above, and on a phone,
// where the files sit over the text, a box saying "No files" only pushed the text down.
const encs=e.enclosures.map(encBox).join('');
// A phone has no room for the files pane, so the files go above the text there instead.
// Below it, a long set of show notes pushed play and delete screens down, and on an iPhone
// it looked as if a downloaded file could not be deleted at all (issue #21).
const narrow=!!window.matchMedia?.('(max-width:820px)')?.matches;
const f=S.feeds.find(x=>x.id===e.feed_id);
const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join('');
box.innerHTML=`
<button class="btn ico" id="dback" title="Back to the items" aria-label="Back to the items">${ICON.left}</button>
<h3 class="dt">${esc(e.title||'(untitled)')}</h3>
<div class="dmeta">
${/* Joined, so a missing date or number leaves no stray dot behind. */
[f&&esc(f.title||f.id), num, dateOf(e.published), e.duration&&clock(e.duration)]
.filter(Boolean).map(s=>`<span>${s}</span>`).join('<span class="dot"></span>')}
<button class="btn ico" data-a="read" title="Mark ${e.read?'unread':'read'}"
aria-label="Mark ${e.read?'unread':'read'}">${e.read?ICON.unread:ICON.check}</button>
<button class="btn ico" data-a="flag" title="${e.flagged?'Pinned: never deleted. Unpin':'Pin, so it is never deleted'}"
aria-label="${e.flagged?'Unpin':'Pin'}">${e.flagged?ICON.pinOn:ICON.pin}</button>
${e.link?`<a class="btn ico" href="${esc(e.link)}" target="_blank" rel="noopener noreferrer"
title="Open the original" aria-label="Open the original">${ICON.open}</a>`:''}
</div>
${narrow?encs:''}
<div class="dbody">${(e.description&&e.description.trim())||'<em>No show notes.</em>'}</div>`;
if(files) files.innerHTML=narrow?'':`<div class="fhd">Files</div>${encs}`;
// description was sanitized server-side with ammonia before it ever reached here
$('#dback').onclick=()=>showDetail(null);
for(const root of [box,files]) if(root) $$('button[data-a]',root).forEach(b=>
b.onclick=()=>epAction(b.dataset.a,e,null,b.dataset.enc?Number(b.dataset.enc):null));
syncPlayButtons();
}
/// One enclosure: a play button when the file is here, otherwise what it is and a way to get it.
function encBox(x){
const size=x.length?mb(x.length):'';
// One file serves everyone reading the feed, so deleting is not a private act.
const f=S.feeds.find(y=>y.id===x.feed_id);
const shared=f&&f.subscribers>1;
// Icons, with the words in the tooltip and for screen readers.
const delLabel=shared
? `Delete for everyone (shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'} reading this feed)`
: 'Delete file';
const delBtn=`<button class="btn ico danger" data-a="del" data-enc="${x.id}" title="${delLabel}" aria-label="${delLabel}">${ICON.trash}</button>`;
const saveBtn=`<a class="btn ico" href="/media/${x.id}" download title="Save to this computer" aria-label="Save to this computer">${ICON.save}</a>`;
if(x.path && !isPlayable(x)){
// On disk, but not audio or video: view it, keep it, or remove it -- no player.
return `<div class="encbox">
${kindIcon(x)}
<span class="meta" style="flex:1">${size}</span>
<a class="btn ico" href="/media/${x.id}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">${ICON.open}</a>
${saveBtn}
${delBtn}
</div>`;
}
if(x.path){
// One player, the bar at the bottom. This pane had an <audio> of its own, and playing it
// started the bar as well, so the same file played twice at once.
return `<div class="encbox">
${kindIcon(x)}
<span class="meta" style="flex:1">${size}</span>
<button class="btn ico" data-a="play" data-enc="${x.id}" title="Play" aria-label="Play">${ICON.play}</button>
${saveBtn}
${delBtn}
</div>`;
}
// Nothing on disk. For an image or a PDF you usually just want to look at it, so link
// straight to the publisher's copy in a new tab -- no download, and nothing proxied
// through here, which would make ipx a fetch-anything relay.
const viewable = !isPlayable(x) && x.state !== 'pending';
return `<div class="encbox">
${kindIcon(x)}
<span class="meta" style="flex:1">${size}</span>
${x.state==='error'&&x.last_error?`<span class="err">${esc(x.last_error)}</span>`:''}
${viewable?`<a class="btn ico" href="${esc(x.url)}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">${ICON.open}</a>`:''}
<button class="btn ico" data-a="get" data-enc="${x.id}" title="Download to the server" aria-label="Download to the server">${ICON.download}</button>
</div>`;
}
async function epAction(a: string, e, el, encId?: number){
// Swap the row in place and refresh the text below when it is the one being read.
const redraw=()=>{ swapRow(e); if(!el || S.sel===e.guid) showDetail(e); };
const enc=(encId!=null && e.enclosures.find(x=>x.id===encId)) || e.enclosures[0];
const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`;
try{
if(a==='play') play(e, encId!=null ? enc : undefined);
if(a==='flag'){ e.flagged=!e.flagged; await api(path+'/flags',{method:'POST',body:JSON.stringify({flagged:e.flagged})}); redraw(); }
if(a==='read'){ await setRead(e,!e.read); redraw(); }
if(a==='get'){
if(!enc) return;
await api(`/api/enclosures/${enc.id}/download`,{method:'POST'});
toast('Queued: '+(e.title||'item'));
}
if(a==='del'){
const f=S.feeds.find(x=>x.id===e.feed_id);
const shared=f&&f.subscribers>1;
if(!confirm(shared
? `Delete this file?\n\nThere is one copy, shared with ${f.subscribers-1} other `
+`${f.subscribers===2?'person':'people'} reading this feed. The item stays listed `
+`and will not be downloaded again automatically.`
: 'Delete the downloaded file?\n\nThe item stays listed and will not be downloaded again automatically.')) return;
try{
await api(`/api/enclosures/${enc.id}`,{method:'DELETE'});
}catch(err){
// 409: somebody else has it starred or unplayed. Their reason, their words.
if(!/one copy of this file/.test(err.message)) throw err;
if(!confirm(err.message+'\n\nDelete it anyway?')) return;
await api(`/api/enclosures/${enc.id}?force=true`,{method:'DELETE'});
}
toast('Deleted'); loadEntries(); loadFeeds(true);
}
}catch(err){ toast(err.message,true); }
}

15
web/src/login.ts Normal file
View File

@@ -0,0 +1,15 @@
document.getElementById('f').onsubmit = async e => {
e.preventDefault();
const msg = document.getElementById('msg');
msg.textContent = '';
const r = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: (document.getElementById('name') as HTMLInputElement).value,
password: (document.getElementById('pw') as HTMLInputElement).value,
}),
});
if (r.ok) location.href = '/';
else msg.textContent = await r.text() || 'Sign in failed';
};

198
web/src/player.ts Normal file
View File

@@ -0,0 +1,198 @@
/* ---------------- player ---------------- */
const audio=$('#audio');
const player: {guid: string|null, feed: string|null, entry: any, enc?: number, moved?: boolean,
saveAt: number, marked: boolean}={guid:null,feed:null,entry:null,saveAt:0,marked:false};
// Marked read when an item has actually been listened to -- at the end, or past 90%.
// NOT on play: doing that made the item vanish from the Unread list the instant
// you pressed play, which looks exactly like it went missing.
function markPlayed(){
if(!player.guid||player.marked) return;
player.marked=true;
const e=player.entry;
if(!e||e.read) return;
setRead(e,true).catch(()=>{});
}
/// Plays one of the item's files in the player bar: the one asked for, or its first playable one.
function play(e,enc=e.enclosures.find(isPlayable)){
if(!isPlayable(enc)){
toast(e.enclosures.some(x=>x.path) ? 'That file is not audio or video' : 'Not downloaded yet', true);
return;
}
// The same file carries on where it was; another of the item's files starts from its top.
const resuming = player.guid===e.guid && player.enc===enc.id;
// Every play button is a pause button for what is playing, as the player bar's is.
if(resuming && !audio.paused){ audio.pause(); return; }
if(!resuming){
player.guid=e.guid; player.feed=e.feed_id; player.entry=e; player.enc=enc.id; player.moved=false;
document.body.classList.toggle('has-video', kindOf(enc)==='video');
audio.src=`/media/${enc.id}`;
audio.currentTime=0;
if(e.position>5) audio.addEventListener('loadedmetadata',()=>{audio.currentTime=e.position},{once:true});
// Initials, if it comes to that, are the feed's: the episode's read as "SE" beside the feed's art.
$('#partwrap').innerHTML=artHTML(e.image||feedArt(e.feed_id),feedName(e.feed_id));
$('#ptitle').textContent=e.title||'(untitled)';
const f=S.feeds.find(x=>x.id===e.feed_id);
$('#pfeed').textContent=f?(f.title||f.id):'';
$('#player').classList.add('on');
mediaSession(e,f);
player.marked=false;
}
audio.play().catch(err=>toast('Playback failed: '+err.message,true));
renderEntries();
}
function mediaSession(e,f){
if(!('mediaSession' in navigator)) return;
navigator.mediaSession.metadata=new MediaMetadata({
title:e.title||'', artist:f?(f.title||f.id):'', album:f?(f.title||''):'',
artwork:(e.image||(f&&f.image))?[{src:e.image||f.image,sizes:'512x512'}]:[],
});
const h={play:()=>audio.play(),pause:()=>audio.pause(),
seekbackward:()=>audio.currentTime-=15,seekforward:()=>audio.currentTime+=30};
for(const k in h){ try{navigator.mediaSession.setActionHandler(k as MediaSessionAction,h[k])}catch{} }
}
audio.addEventListener('timeupdate',()=>{
const d=audio.duration||player.entry?.duration||0;
$('#pcur').textContent=clock(audio.currentTime);
$('#pdur').textContent=clock(d);
if(d) $('#seek').value=String(Math.round(audio.currentTime/d*1000));
// Only playing counts as moving: the seek to where you left off happens paused, and saving
// that would write back whatever the list said, however old.
if(!audio.paused) player.moved=true;
// Persist roughly every 10s so a reload resumes where you were. Either way: a jump back used
// to wait for the next pause to be saved.
if(player.guid && Math.abs(audio.currentTime-player.saveAt)>10){ savePos(); }
if(d && audio.currentTime/d >= 0.9) markPlayed();
});
function savePos(){
// Before the file has loaded, currentTime is 0 rather than where you are: saving it then --
// a failed load, or a pause before the seek to where you left off -- wiped the position.
// Nor from a player nobody has played since it last saved: one left paused in another tab
// saved its older place as that tab reloaded, over where you had got to since.
if(!player.guid||!audio.readyState||!player.moved) return;
player.moved=false;
player.saveAt=audio.currentTime;
if(player.entry) player.entry.position=Math.floor(audio.currentTime);
// The measured length stands in for one the feed left out: without it Currently Listening
// cannot tell a finished episode from a started one. NaN before metadata, Infinity on a stream.
const duration=isFinite(audio.duration)?Math.floor(audio.duration):null;
navigator.sendBeacon?.(
`/api/entries/${encodeURIComponent(player.feed)}/${encodeURIComponent(player.guid)}/position`,
new Blob([JSON.stringify({secs:Math.floor(audio.currentTime),duration})],{type:'application/json'}));
}
audio.addEventListener('pause',savePos);
audio.addEventListener('ended',()=>{savePos();markPlayed();$('#pplay').innerHTML=ICON.play});
// body.playing is what sets the EQ bars moving.
audio.addEventListener('play',()=>{ $('#pplay').innerHTML=ICON.pause; document.body.classList.add('playing'); });
audio.addEventListener('pause',()=>{ $('#pplay').innerHTML=ICON.play; document.body.classList.remove('playing'); });
for(const ev of ['play','pause','ended']) audio.addEventListener(ev,syncPlayButtons);
/// Every play button for what is playing shows pause, like the player bar's: a row's, the files
/// pane's, the toolbar's. Only the bar's used to change, so the others said play while it played.
function syncPlayButtons(){
const on=(guid,enc?)=>!audio.paused&&player.guid===guid&&(enc==null||player.enc===enc);
const paint=(b,now,idle)=>{
const label=now?'Pause':idle;
if(b.title===label) return;
b.title=label; b.setAttribute('aria-label',label); b.innerHTML=now?ICON.pause:ICON.play;
};
for(const b of $$('#eps .ep [data-a=play]')) paint(b,on(b.closest('.ep').dataset.guid),'Play');
for(const b of $$('#files [data-a=play][data-enc], #detail [data-a=play][data-enc]'))
paint(b,on(S.sel,Number(b.dataset.enc)),'Play');
const e=cur(); paint($('#tbPlay'),!!e&&on(e.guid),'Play the selected item');
}
for(const ev of ['play','pause','timeupdate']) audio.addEventListener(ev,syncListening);
window.addEventListener('beforeunload',savePos);
$('#pplay').onclick=()=>audio.paused?audio.play():audio.pause();
$('#pback').onclick=()=>audio.currentTime-=15;
$('#pfwd').onclick=()=>audio.currentTime+=30;
$('#seek').oninput=e=>{const d=audio.duration;if(d)audio.currentTime=d*e.target.value/1000};
$('#rate').onchange=e=>{audio.playbackRate=+e.target.value;localStorage.setItem('ipx.rate',e.target.value)};
$('#vol').oninput=e=>{audio.volume=e.target.value/100;localStorage.setItem('ipx.vol',e.target.value)};
$('#pclose').onclick=()=>{savePos();audio.pause();audio.removeAttribute('src');player.guid=null;$('#player').classList.remove('on');document.body.classList.remove('has-video');renderEntries();
// Called here, not left to the pause event: closing a player already paused fires none.
syncListening()};
(function restore(){
const r=localStorage.getItem('ipx.rate'), v=localStorage.getItem('ipx.vol');
if(r){$('#rate').value=r;audio.playbackRate=+r}
if(v){$('#vol').value=v;audio.volume=Number(v)/100}
})();
document.addEventListener('keydown',ev=>{
// Escape leaves a dialog even from inside one of its boxes. It used to sit below the check
// that follows, so Add feed, which opens with the cursor in its URL box, ignored it.
if(ev.key==='Escape'){closeModal();nav(false);return}
// The rest are single keys that would otherwise eat what you type.
if(/^(INPUT|TEXTAREA|SELECT)$/.test((ev.target as Element).tagName)) return;
if(ev.key===' '&&player.guid){ev.preventDefault();audio.paused?audio.play():audio.pause()}
else if(ev.key==='ArrowLeft'&&player.guid){audio.currentTime-=15}
else if(ev.key==='ArrowRight'&&player.guid){audio.currentTime+=30}
else if(ev.key==='/'){ev.preventDefault();$('#epSearch')?.focus()}
else if(!ev.ctrlKey&&!ev.metaKey&&!ev.altKey&&!$('#modal').classList.contains('on')) typed(ev);
});
// Feedly's keys, vim's j and k among them: a letter to move through items or feeds, g and a
// letter to go somewhere, ? to list them. None fire with Ctrl, Alt or Cmd held, so the browser's
// own shortcuts still work, or while a dialog is open.
const GO={a:':all',d:':directory',p:':popular',l:':listening'};
let gAt=0;
function stepEntry(by){
if(VIEWS[S.feed]?.url||!S.entries.length) return;
const i=S.entries.findIndex(x=>x.guid===S.sel);
const e=S.entries[i<0?0:Math.min(S.entries.length-1,Math.max(0,i+by))];
selectEntry(e);
$(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`)?.scrollIntoView({block:'nearest'});
}
function stepFeed(by){
const rows=$$('#feedlist [data-id]'), i=rows.findIndex(r=>r.dataset.id===S.feed), id=rows[i+by]?.dataset.id;
if(!id) return;
selectFeed(id);
$(`#feedlist [data-id="${CSS.escape(id)}"]`)?.scrollIntoView({block:'nearest'});
}
const KEYS={
j:()=>stepEntry(1), n:()=>stepEntry(1), k:()=>stepEntry(-1), p:()=>stepEntry(-1),
J:()=>stepFeed(1), K:()=>stepFeed(-1),
// The toolbar's own buttons, so a key does exactly what the click does, and nothing while
// they are disabled.
o:()=>$('#tbPlay').click(), m:()=>$('#tbRead').click(), s:()=>$('#tbFlag').click(),
v:()=>{ const e=cur(); if(e&&e.link) window.open(e.link,'_blank','noopener'); },
A:()=>$('#content .fhead [data-a="read"], #content .fhead [data-a="readall"]')?.click(),
r:async()=>{ await loadFeeds(true); if(S.feed){ renderFeed(); loadEntries(); } },
'[':()=>matchMedia('(max-width:820px)').matches
? nav(!$('#sidebar').classList.contains('open')) : document.body.classList.toggle('nosb'),
'?':()=>keysModal(),
g:()=>{ gAt=Date.now(); },
};
function typed(ev){
// The second key of a g pair counts only if it follows within a second and a half.
const pair=Date.now()-gAt<1500; gAt=0;
const fn=pair ? (GO[ev.key]&&(()=>selectFeed(GO[ev.key])))||(ev.key==='s'&&prefsModal) : KEYS[ev.key];
if(!fn) return;
ev.preventDefault(); fn();
}
/// What ? shows: every key, grouped as Feedly's own list is.
function keysModal(){
const k=s=>`<kbd>${esc(s)}</kbd>`, g=c=>k('g')+' '+k(c);
const rows=[
['Go to'],
[g('a'),'All Subscriptions'],[g('d'),'Directory'],[g('p'),'Popular'],
[g('l'),'Currently Listening'],[g('s'),'Settings'],
[k('Shift')+' '+k('J'),'Next feed'],[k('Shift')+' '+k('K'),'Previous feed'],
[k('/'),'Search items'],[k('r'),'Refresh'],[k('['),'Show or hide the feed list'],
['Items'],
[k('j')+' or '+k('n'),'Next item'],[k('k')+' or '+k('p'),'Previous item'],
[k('Shift')+' '+k('A'),'Mark all read'],
['The selected item'],
[k('o'),'Play it'],[k('m'),'Mark it read or unread'],[k('s'),'Pin it, or unpin it'],
[k('v'),'Open the original in a new tab'],
['The player'],
[k('Space'),'Play or pause'],[k('←')+' '+k('→'),'Back 15 seconds, forward 30'],
['Anywhere'],
[k('?'),'This list'],[k('Esc'),'Close a dialog'],
];
openModal(`<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>
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button></div>`);
}

58
web/src/theme.ts Normal file
View File

@@ -0,0 +1,58 @@
/* ---------------- theme ---------------- */
// A theme, and for those that come in both, light, dark or Auto, chosen in Settings and kept on
// the account, so it follows you to another browser or computer. The server writes it onto the
// page's <html> tag (data-theme, data-choice) so the page is drawn in it from the start. The
// page gets data-mode, light or dark, which is all the CSS reads: Auto is worked out here, from
// the system, so no palette is written twice.
const THEMES: Record<string, {name: string, modes: boolean}> = {
modern: {name: 'Modern', modes: true},
classic: {name: 'Classic, the 2004 Mac app', modes: false},
dracula: {name: 'Dracula', modes: true},
material: {name: 'Material', modes: true},
adwaita: {name: 'Adwaita', modes: true},
flatremix: {name: 'Flat Remix', modes: true},
paper: {name: 'Paper', modes: false},
nordic: {name: 'Nordic', modes: true},
};
const MODES: Record<string, string> = {auto: 'Auto (matches your system)', light: 'Light', dark: 'Dark'};
// Before themes came in light and dark, ipx.theme in localStorage held one of these.
const OLD_THEMES: Record<string, [string, string]> = {dark: ['modern', 'dark'], light: ['modern', 'light'], auto: ['modern', 'auto']};
const systemDark = window.matchMedia?.('(prefers-color-scheme: dark)');
const theme = {name: 'modern', mode: 'dark'};
/// `save` for a choice made in Settings, which goes to the account; not for applying one.
function setTheme(name = theme.name, mode = theme.mode, save = false){
theme.name = THEMES[name] ? name : 'modern';
theme.mode = MODES[mode] ? mode : 'dark';
const both = THEMES[theme.name].modes;
const root = document.documentElement;
root.dataset.theme = theme.name;
// A theme with one palette has it whatever the mode; both of those are light.
root.dataset.mode = !both ? 'light'
: theme.mode === 'auto' ? (systemDark && !systemDark.matches ? 'light' : 'dark') : theme.mode;
const sel = $('#stheme'); if(sel) sel.value = theme.name;
const ms = $('#smode'); if(ms) ms.value = theme.mode;
const mf = $('#smodefield'); if(mf) mf.hidden = !both;
if(save) saveTheme();
}
/// One save at a time, each sending the choice as it stands when it goes. Sent as they came,
/// several at once, a quick run through the list could reach the server out of order and
/// leave the account on a theme passed on the way.
let themeSaving = Promise.resolve();
function saveTheme(){
themeSaving = themeSaving
.then(() => api('/api/me', {method: 'PATCH', body: JSON.stringify({theme: theme.name, mode: theme.mode})}))
.catch(e => toast(`Your theme was not saved: ${e.message}`, true));
}
systemDark?.addEventListener?.('change', () => { if(theme.mode === 'auto') setTheme(); });
(() => {
const root = document.documentElement;
if(root.dataset.choice) return setTheme(root.dataset.theme, root.dataset.choice);
// Nothing on the account yet. A theme this browser kept, from before themes were kept on the
// account, goes up to it once, so nobody has to choose again.
let name: string | null = null, mode: string | null = null;
try{ name = localStorage.getItem('ipx.theme'); mode = localStorage.getItem('ipx.mode'); }catch{}
if(OLD_THEMES[name]) [name, mode] = OLD_THEMES[name];
setTheme(name ?? undefined, mode ?? undefined, !!name);
})();

178
web/src/util.ts Normal file
View File

@@ -0,0 +1,178 @@
'use strict';
// `any`: the page reads .value, .dataset and .onclick off whatever it looks up, and the
// smoke test, not the type checker, is what makes sure a selector exists.
const $ = (s: string, r: ParentNode = document): any => r.querySelector(s);
const $$ = (s: string, r: ParentNode = document): any[] => [...r.querySelectorAll(s)];
const esc = s => (s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
// Icons: Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com
// License - https://fontawesome.com/license/free (Icons: CC BY 4.0). Embedded as SVG, only the
// ones used, so there is no font to download and nothing is fetched from anyone else. Each takes
// the button's own colour. To add one, copy the path from svgs/<style>/<name>.svg at the same tag.
const fa=(box,body)=>`<svg class="i" viewBox="${box}" aria-hidden="true">${body}</svg>`;
const ICON={
plus:fa('0 0 448 512','<path fill="currentColor" d="M256 64c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 160-160 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l160 0 0 160c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160 160 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-160 0 0-160z"/>'), // solid/plus
circleMinus:fa('0 0 512 512','<path fill="currentColor" d="M512 256A256 256 0 1 0 0 256a256 256 0 1 0 512 0zM184 232l144 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-144 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z"/>'), // solid/circle-minus, for Unsubscribe
play:fa('0 0 448 512','<path fill="currentColor" d="M91.2 36.9c-12.4-6.8-27.4-6.5-39.6 .7S32 57.9 32 72l0 368c0 14.1 7.5 27.2 19.6 34.4s27.2 7.5 39.6 .7l336-184c12.8-7 20.8-20.5 20.8-35.1s-8-28.1-20.8-35.1l-336-184z"/>'), // solid/play
check:fa('0 0 448 512','<path fill="currentColor" d="M434.8 70.1c14.3 10.4 17.5 30.4 7.1 44.7l-256 352c-5.5 7.6-14 12.3-23.4 13.1s-18.5-2.7-25.1-9.3l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l101.5 101.5 234-321.7c10.4-14.3 30.4-17.5 44.7-7.1z"/>'), // solid/check
checks:fa('0 0 384 512','<path fill="currentColor" d="M249.9 66.8c10.4-14.3 7.2-34.3-7.1-44.7s-34.3-7.2-44.7 7.1l-106 145.7-37.5-37.5c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l64 64c6.6 6.6 15.8 10 25.1 9.3s17.9-5.5 23.4-13.1l128-176zm128 136c10.4-14.3 7.2-34.3-7.1-44.7s-34.3-7.2-44.7 7.1l-170 233.7-69.5-69.5c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l96 96c6.6 6.6 15.8 10 25.1 9.3s17.9-5.5 23.4-13.1l192-264z"/>'), // solid/check-double
// Pinned is the solid thumbtack; not pinned, the same shape outlined, as the flag had its regular
// and solid pair (Font Awesome's free set has no regular thumbtack). Both share a viewBox padded
// for the outline's stroke, so the two draw the same size.
pin:fa('-18 -18 420 548','<path fill="none" stroke="currentColor" stroke-width="36" stroke-linejoin="round" d="M32 32C32 14.3 46.3 0 64 0L320 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-29.5 0 11.4 148.2c36.7 19.9 65.7 53.2 79.5 94.7l1 3c3.3 9.8 1.6 20.5-4.4 28.8s-15.7 13.3-26 13.3L32 352c-10.3 0-19.9-4.9-26-13.3s-7.7-19.1-4.4-28.8l1-3c13.8-41.5 42.8-74.8 79.5-94.7L93.5 64 64 64C46.3 64 32 49.7 32 32zM160 384l64 0 0 96c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-96z"/>'), // solid/thumbtack, outlined
pinOn:fa('-18 -18 420 548','<path fill="currentColor" d="M32 32C32 14.3 46.3 0 64 0L320 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-29.5 0 11.4 148.2c36.7 19.9 65.7 53.2 79.5 94.7l1 3c3.3 9.8 1.6 20.5-4.4 28.8s-15.7 13.3-26 13.3L32 352c-10.3 0-19.9-4.9-26-13.3s-7.7-19.1-4.4-28.8l1-3c13.8-41.5 42.8-74.8 79.5-94.7L93.5 64 64 64C46.3 64 32 49.7 32 32zM160 384l64 0 0 96c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-96z"/>'), // solid/thumbtack
scan:fa('0 0 512 512','<path fill="currentColor" d="M65.9 228.5c13.3-93 93.4-164.5 190.1-164.5 53 0 101 21.5 135.8 56.2 .2 .2 .4 .4 .6 .6l7.6 7.2-47.9 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l128 0c17.7 0 32-14.3 32-32l0-128c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 53.4-11.3-10.7C390.5 28.6 326.5 0 256 0 127 0 20.3 95.4 2.6 219.5 .1 237 12.2 253.2 29.7 255.7s33.7-9.7 36.2-27.1zm443.5 64c2.5-17.5-9.7-33.7-27.1-36.2s-33.7 9.7-36.2 27.1c-13.3 93-93.4 164.5-190.1 164.5-53 0-101-21.5-135.8-56.2-.2-.2-.4-.4-.6-.6l-7.6-7.2 47.9 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L32 320c-8.5 0-16.7 3.4-22.7 9.5S-.1 343.7 0 352.3l1 127c.1 17.7 14.6 31.9 32.3 31.7S65.2 496.4 65 478.7l-.4-51.5 10.7 10.1c46.3 46.1 110.2 74.7 180.7 74.7 129 0 235.7-95.4 253.4-219.5z"/>'), // solid/arrows-rotate
download:fa('0 0 448 512','<path fill="currentColor" d="M256 32c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 210.7-41.4-41.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l96 96c12.5 12.5 32.8 12.5 45.3 0l96-96c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L256 242.7 256 32zM64 320c-35.3 0-64 28.7-64 64l0 32c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-32c0-35.3-28.7-64-64-64l-46.9 0-56.6 56.6c-31.2 31.2-81.9 31.2-113.1 0L110.9 320 64 320zm304 56a24 24 0 1 1 0 48 24 24 0 1 1 0-48z"/>'), // solid/download
save:fa('0 0 448 512','<path fill="currentColor" d="M64 32C28.7 32 0 60.7 0 96L0 416c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-242.7c0-17-6.7-33.3-18.7-45.3L352 50.7C340 38.7 323.7 32 306.7 32L64 32zm32 96c0-17.7 14.3-32 32-32l160 0c17.7 0 32 14.3 32 32l0 64c0 17.7-14.3 32-32 32l-160 0c-17.7 0-32-14.3-32-32l0-64zM224 288a64 64 0 1 1 0 128 64 64 0 1 1 0-128z"/>'), // solid/floppy-disk
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
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
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
popular:fa('0 0 576 512','<path fill="currentColor" d="M309.5-18.9c-4.1-8-12.4-13.1-21.4-13.1s-17.3 5.1-21.4 13.1L193.1 125.3 33.2 150.7c-8.9 1.4-16.3 7.7-19.1 16.3s-.5 18 5.8 24.4l114.4 114.5-25.2 159.9c-1.4 8.9 2.3 17.9 9.6 23.2s16.9 6.1 25 2L288.1 417.6 432.4 491c8 4.1 17.7 3.3 25-2s11-14.2 9.6-23.2L441.7 305.9 556.1 191.4c6.4-6.4 8.6-15.8 5.8-24.4s-10.1-14.9-19.1-16.3L383 125.3 309.5-18.9z"/>'), // solid/star
all:fa('0 0 512 512','<path fill="currentColor" d="M232.5 5.2c14.9-6.9 32.1-6.9 47 0l218.6 101c8.5 3.9 13.9 12.4 13.9 21.8s-5.4 17.9-13.9 21.8l-218.6 101c-14.9 6.9-32.1 6.9-47 0L13.9 149.8C5.4 145.8 0 137.3 0 128s5.4-17.9 13.9-21.8L232.5 5.2zM48.1 218.4l164.3 75.9c27.7 12.8 59.6 12.8 87.3 0l164.3-75.9 34.1 15.8c8.5 3.9 13.9 12.4 13.9 21.8s-5.4 17.9-13.9 21.8l-218.6 101c-14.9 6.9-32.1 6.9-47 0L13.9 277.8C5.4 273.8 0 265.3 0 256s5.4-17.9 13.9-21.8l34.1-15.8zM13.9 362.2l34.1-15.8 164.3 75.9c27.7 12.8 59.6 12.8 87.3 0l164.3-75.9 34.1 15.8c8.5 3.9 13.9 12.4 13.9 21.8s-5.4 17.9-13.9 21.8l-218.6 101c-14.9 6.9-32.1 6.9-47 0L13.9 405.8C5.4 401.8 0 393.3 0 384s5.4-17.9 13.9-21.8z"/>'), // solid/layer-group
unread:fa('0 0 512 512','<path fill="currentColor" d="M48 64c-26.5 0-48 21.5-48 48 0 15.1 7.1 29.3 19.2 38.4l208 156c17.1 12.8 40.5 12.8 57.6 0l208-156c12.1-9.1 19.2-23.3 19.2-38.4 0-26.5-21.5-48-48-48L48 64zM0 196L0 384c0 35.3 28.7 64 64 64l384 0c35.3 0 64-28.7 64-64l0-188-198.4 148.8c-34.1 25.6-81.1 25.6-115.2 0L0 196z"/>'), // solid/envelope: a closed letter, not a record button
audio:fa('0 0 448 512','<path fill="currentColor" d="M64 224c0-88.4 71.6-160 160-160s160 71.6 160 160l0 37.5c-10-3.5-20.8-5.5-32-5.5l-16 0c-26.5 0-48 21.5-48 48l0 128c0 26.5 21.5 48 48 48l16 0c53 0 96-43 96-96l0-160C448 100.3 347.7 0 224 0S0 100.3 0 224L0 384c0 53 43 96 96 96l16 0c26.5 0 48-21.5 48-48l0-128c0-26.5-21.5-48-48-48l-16 0c-11.2 0-22 1.9-32 5.5L64 224z"/>'), // solid/headphones
video:fa('0 0 576 512','<path fill="currentColor" d="M96 64c-35.3 0-64 28.7-64 64l0 256c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-256c0-35.3-28.7-64-64-64L96 64zM464 336l73.5 58.8c4.2 3.4 9.4 5.2 14.8 5.2 13.1 0 23.7-10.6 23.7-23.7l0-240.6c0-13.1-10.6-23.7-23.7-23.7-5.4 0-10.6 1.8-14.8 5.2L464 176 464 336z"/>'), // solid/video
image:fa('0 0 448 512','<path fill="currentColor" d="M64 32C28.7 32 0 60.7 0 96L0 416c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-320c0-35.3-28.7-64-64-64L64 32zm64 80a48 48 0 1 1 0 96 48 48 0 1 1 0-96zM272 224c8.4 0 16.1 4.4 20.5 11.5l88 144c4.5 7.4 4.7 16.7 .5 24.3S368.7 416 360 416L88 416c-8.9 0-17.2-5-21.3-12.9s-3.5-17.5 1.6-24.8l56-80c4.5-6.4 11.8-10.2 19.7-10.2s15.2 3.8 19.7 10.2l26.4 37.8 61.4-100.5c4.4-7.1 12.1-11.5 20.5-11.5z"/>'), // solid/image
doc:fa('0 0 576 512','<path fill="currentColor" d="M96 0C60.7 0 32 28.7 32 64l0 384c0 35.3 28.7 64 64 64l80 0 0-112c0-35.3 28.7-64 64-64l176 0 0-165.5c0-17-6.7-33.3-18.7-45.3L290.7 18.7C278.7 6.7 262.5 0 245.5 0L96 0zM357.5 176L264 176c-13.3 0-24-10.7-24-24L240 58.5 357.5 176zM240 380c-11 0-20 9-20 20l0 128c0 11 9 20 20 20s20-9 20-20l0-28 12 0c33.1 0 60-26.9 60-60s-26.9-60-60-60l-32 0zm32 80l-12 0 0-40 12 0c11 0 20 9 20 20s-9 20-20 20zm96-80c-11 0-20 9-20 20l0 128c0 11 9 20 20 20l32 0c28.7 0 52-23.3 52-52l0-64c0-28.7-23.3-52-52-52l-32 0zm20 128l0-88 12 0c6.6 0 12 5.4 12 12l0 64c0 6.6-5.4 12-12 12l-12 0zm88-108l0 128c0 11 9 20 20 20s20-9 20-20l0-44 28 0c11 0 20-9 20-20s-9-20-20-20l-28 0 0-24 28 0c11 0 20-9 20-20s-9-20-20-20l-48 0c-11 0-20 9-20 20z"/>'), // solid/file-pdf
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
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
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
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
alert:fa('0 0 128 512','<path fill="currentColor" d="M64 432c22.1 0 40 17.9 40 40s-17.9 40-40 40-40-17.9-40-40c0-22.1 17.9-40 40-40zM64 0c26.5 0 48 21.5 48 48 0 .6 0 1.1 0 1.7l-16 304c-.9 17-15 30.3-32 30.3S33 370.7 32 353.7L16 49.7c0-.6 0-1.1 0-1.7 0-26.5 21.5-48 48-48z"/>'), // solid/exclamation
admin:fa('0 0 576 512','<path fill="currentColor" d="M70.8-6.7c5.4-5.4 13.8-6.2 20.2-2L209.9 70.5c8.9 5.9 14.2 15.9 14.2 26.6l0 49.6 90.8 90.8c33.3-15 73.9-8.9 101.2 18.5L542.2 382.1c18.7 18.7 18.7 49.1 0 67.9l-60.1 60.1c-18.7 18.7-49.1 18.7-67.9 0L288.1 384c-27.4-27.4-33.5-67.9-18.5-101.2l-90.8-90.8-49.6 0c-10.7 0-20.7-5.3-26.6-14.2L23.4 58.9c-4.2-6.3-3.4-14.8 2-20.2L70.8-6.7zm145 303.5c-6.3 36.9 2.3 75.9 26.2 107.2l-94.9 95c-28.1 28.1-73.7 28.1-101.8 0s-28.1-73.7 0-101.8l135.4-135.5 35.2 35.1zM384.1 0c20.1 0 39.4 3.7 57.1 10.5 10 3.8 11.8 16.5 4.3 24.1L388.8 91.3c-3 3-4.7 7.1-4.7 11.3l0 41.4c0 8.8 7.2 16 16 16l41.4 0c4.2 0 8.3-1.7 11.3-4.7l56.7-56.7c7.6-7.5 20.3-5.7 24.1 4.3 6.8 17.7 10.5 37 10.5 57.1 0 43.2-17.2 82.3-45 111.1l-49.1-49.1c-33.1-33-78.5-45.7-121.1-38.4l-56.8-56.8 0-29.7-.2-5c-.8-12.4-4.4-24.3-10.5-34.9 29.4-35 73.4-57.2 122.7-57.3z"/>'), // solid/screwdriver-wrench
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
};
// What is playing, as the icon's EQ bars; the stylesheet moves them.
const EQ='<span class="eq" aria-hidden="true"><i></i><i></i><i></i></span>';
// 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.
// The page's own buttons name their icon; this draws it in, ahead of any label they carry.
for(const b of $$('[data-icon]')) b.insertAdjacentHTML('afterbegin',ICON[b.dataset.icon]);
async function api(url: string, opts?: RequestInit): Promise<any>{
const r = await fetch(url,{headers:{'Content-Type':'application/json'},...opts});
if(r.status===401){ location.href='/login'; throw new Error('signed out'); }
if(!r.ok) throw new Error(await r.text().catch(()=>'')||String(r.status));
return r.status===204?null:r.json().catch(()=>null);
}
// navigator.clipboard only exists in a secure context. Served over plain HTTP on a LAN
// address it is undefined, so fall back to the old selection-based copy.
async function copyText(text,btn){
const flash=ok=>{
if(!btn) return;
// The button is an icon, so it is the markup that has to come back, not just its text.
const was=btn.innerHTML;
btn.textContent=ok?'Copied':'Failed';
setTimeout(()=>btn.innerHTML=was,1300);
};
try{
if(navigator.clipboard&&window.isSecureContext){
await navigator.clipboard.writeText(text);
}else{
const ta=document.createElement('textarea');
ta.value=text; ta.setAttribute('readonly','');
ta.style.cssText='position:fixed;top:-1000px;opacity:0';
document.body.appendChild(ta);
ta.select(); ta.setSelectionRange(0,ta.value.length);
const ok=document.execCommand('copy');
ta.remove();
if(!ok) throw new Error('copy rejected');
}
flash(true);
}catch(e){
flash(false);
toast('Could not copy automatically — select the URL and copy it manually',true);
}
}
function toast(msg: string, bad?: boolean){
const t=document.createElement('div');
t.className='toast'+(bad?' bad':''); t.textContent=msg;
$('#toasts').appendChild(t);
setTimeout(()=>{t.style.opacity='0';t.style.transition='opacity .3s';setTimeout(()=>t.remove(),320)},bad?6000:3200);
}
const clock = s => {
s=Math.max(0,Math.floor(s||0));
const h=Math.floor(s/3600),m=Math.floor(s%3600/60),x=s%60;
return h?`${h}:${String(m).padStart(2,'0')}:${String(x).padStart(2,'0')}`:`${m}:${String(x).padStart(2,'0')}`;
};
const ago = t => {
if(!t) return 'never';
const d=(Date.now()/1000)-t;
if(d<3600) return Math.max(1,Math.round(d/60))+'m ago';
if(d<86400) return Math.round(d/3600)+'h ago';
if(d<2592000) return Math.round(d/86400)+'d ago';
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'}):'';
// 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 plural=(n,word)=>`${n} ${word}${n===1?'':'s'}`;
// An initials tile takes one of these, by a hash of the name, so neighbours rarely match.
const TINTS=['var(--accent)','var(--good)','var(--dim)','color-mix(in srgb,var(--accent),var(--good))'];
const tint=name=>{ let h=0; for(const c of name||'?') h=(h*31+c.charCodeAt(0))>>>0; return TINTS[h%TINTS.length]; };
function tileHTML(name,cls){
return `<div class="art ini ${cls||''}" style="--tint:${tint(name)}">${esc(initials(name))}</div>`;
}
function artHTML(url: string | null, name: string, cls?: string){
return url
? `<img class="art ${cls||''}" src="${esc(url)}" alt="" loading="lazy" onerror="this.outerHTML=${esc(JSON.stringify(tileHTML(name,cls)))}">`
: tileHTML(name,cls);
}
/// A folder's tile is its first four shows' art. With fewer than four to show, the folder's own.
function folderArt(f,kids){
const art=kids.filter(c=>c.image).slice(0,4);
if(art.length<4) return artHTML(f.image,f.title||f.id);
// Tinted underneath, so art that fails to load leaves colour behind rather than a hole.
return `<div class="art ini mosaic" style="--tint:${tint(f.title||f.id)}">${art.map(c=>
`<img src="${esc(c.image)}" alt="" loading="lazy" onerror="this.style.visibility='hidden'">`).join('')}</div>`;
}
/// The sidebar slides over the page on a phone, so it needs a scrim to tap away.
function nav(on){ $('#sidebar').classList.toggle('open',on); $('#scrim').hidden=!on; }
/* ---------------- state ---------------- */
const S = {
feeds:[],
// Which feed (or place) and which tab were open last time, so a refresh lands back where
// you were instead of jumping to the first feed alphabetically.
feed:(()=>{ try{ return localStorage.getItem('ipx.feed'); }catch{ return null; } })(),
entries:[], total:0, offset:0,
filter:(()=>{ try{ return localStorage.getItem('ipx.filter'); }catch{ return null; } })()||'all',
q:'', sel:null, 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 UNITS = [['m','minutes'],['h','hours'],['d','days'],['w','weeks']];
const UNIT_MINS = {m:1, h:60, d:1440, w:10080};
/// Largest unit that divides evenly, so 120 reads "2 hours" not "120 minutes".
function splitEvery(m){
if(!m) return {n:1, u:'h'};
for(const u of ['w','d','h']) if(m % UNIT_MINS[u] === 0) return {n:m/UNIT_MINS[u], u};
return {n:m, u:'m'};
}
function unitOptions(sel){
return UNITS.map(([v,l]) =>
`<option value="${v}"${sel===v?' selected':''}>${l}</option>`).join('');
}
function everyText(m){
if(!m) return '\u2014';
const {n,u} = splitEvery(m);
const name = {m:'min', h:'hour', d:'day', w:'week'}[u];
return n + ' ' + name + (u!=='m' && n!==1 ? 's' : '');
}