Compare commits
14 Commits
directory-
...
directory-
| Author | SHA1 | Date | |
|---|---|---|---|
| c957db7f61 | |||
| 4e0b5eed5d | |||
| a9991545f9 | |||
| 60007d4e6a | |||
| 1471b9b9d0 | |||
| ec2b9e87b8 | |||
| af6eae9baf | |||
| 78704e40f2 | |||
| f20660a7d5 | |||
| a5e64e520d | |||
| 7ecbe8293f | |||
| 8d7a30d1f9 | |||
| cece114d98 | |||
| f00adf5da5 |
11
CLAUDE.md
11
CLAUDE.md
@@ -151,13 +151,19 @@ After any edit to it:
|
|||||||
```sh
|
```sh
|
||||||
npx tsc -p .
|
npx tsc -p .
|
||||||
node tests/page-smoke.js
|
node tests/page-smoke.js
|
||||||
|
node --test tests/page-unit.js
|
||||||
```
|
```
|
||||||
|
|
||||||
The first type-checks `web/src` (loosely: `strict` is off, and `$` returns `any`). The second
|
The first type-checks `web/src` in TypeScript's `strict` mode: every parameter and variable has a
|
||||||
|
type, what the API sends is typed in `web/src/types.ts`, which mirrors the Rust structs and changes
|
||||||
|
with them, and a value that may be null has to be checked before use. `$<T>` returns the element
|
||||||
|
type it is given (`$<HTMLInputElement>('#nurl')`) and assumes the element is there; one that may
|
||||||
|
not be is written `$(...)?.`, and the page check below proves the ones wired at load exist. The second
|
||||||
builds the page as shipped and runs its script against a stub DOM, checking every selector it
|
builds the page as shipped and runs its script against a stub DOM, checking every selector it
|
||||||
wires at load actually exists. That check exists because a patch once anchored on a deleted
|
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
|
function, `String.replace` silently matched nothing, and the whole UI died with a
|
||||||
`ReferenceError` while every server-side test passed.
|
`ReferenceError` while every server-side test passed. The third loads the same built script and
|
||||||
|
calls its helpers directly (#234): a pure function added to the page gets its check there.
|
||||||
|
|
||||||
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.
|
||||||
@@ -168,6 +174,7 @@ Patching that file by guessing an anchor string has failed repeatedly. Read the
|
|||||||
cargo test # ~80 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
|
npx tsc -p . # type-checks web/src
|
||||||
node tests/page-smoke.js
|
node tests/page-smoke.js
|
||||||
|
node --test tests/page-unit.js # the page's own functions, called directly, in a second
|
||||||
node tests/native-bridge.js # the page hands playback to a native shell
|
node tests/native-bridge.js # the page hands playback to a native shell
|
||||||
node tests/contrast.js # every theme's palette against WCAG AA
|
node tests/contrast.js # every theme's palette against WCAG AA
|
||||||
npx playwright test # 66 browser tests against a real daemon on fixture feeds
|
npx playwright test # 66 browser tests against a real daemon on fixture feeds
|
||||||
|
|||||||
5
Cargo.lock
generated
5
Cargo.lock
generated
@@ -1820,7 +1820,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipx"
|
name = "ipx"
|
||||||
version = "1.1.0"
|
version = "1.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ammonia",
|
"ammonia",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -1856,12 +1856,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipx-directory"
|
name = "ipx-directory"
|
||||||
version = "1.1.0"
|
version = "1.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ammonia",
|
"ammonia",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
"clap",
|
"clap",
|
||||||
|
"futures-util",
|
||||||
"ipx-feed",
|
"ipx-feed",
|
||||||
"mimalloc",
|
"mimalloc",
|
||||||
"opentelemetry",
|
"opentelemetry",
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ COPY web ./web
|
|||||||
RUN node web/build.mjs /dist
|
RUN node web/build.mjs /dist
|
||||||
|
|
||||||
FROM caddy:2
|
FROM caddy:2
|
||||||
|
# caddy:2's Alpine ships curl, which nothing here runs (no healthcheck since #218): out, so a
|
||||||
|
# curl flaw such as CVE-2026-92392 has nothing to reach in a container facing the internet (#237).
|
||||||
|
RUN apk del --no-cache curl
|
||||||
COPY web/Caddyfile /etc/caddy/Caddyfile
|
COPY web/Caddyfile /etc/caddy/Caddyfile
|
||||||
COPY --from=build /dist /srv
|
COPY --from=build /dist /srv
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|||||||
@@ -10,6 +10,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.1.1] - 2026-10-15
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The directory checks five feeds at a time, so a publisher that does not answer no longer holds up the rest of its round (#227).
|
||||||
|
- A feed's server that takes 10 s to connect, or sends nothing for 15 s, is given up on (#230).
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- A failed feed check is logged without its address's query or password (#238).
|
||||||
|
|
||||||
## [1.1.0] - 2026-10-13
|
## [1.1.0] - 2026-10-13
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -34,6 +45,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- `ipx-directory`, a directory of public podcasts that ipx servers share: each sends the public feeds its people subscribe to, and any server with a token browses them, each feed listed once it has been read. Tokens are in its config file, each read-only, read-write or admin, and revoked one at a time (#204, #205).
|
- `ipx-directory`, a directory of public podcasts that ipx servers share: each sends the public feeds its people subscribe to, and any server with a token browses them, each feed listed once it has been read. Tokens are in its config file, each read-only, read-write or admin, and revoked one at a time (#204, #205).
|
||||||
- Traces: a span a request, joining the trace of the ipx server that called, and a trace a refresher round with a span a feed (#213).
|
- Traces: a span a request, joining the trace of the ipx server that called, and a trace a refresher round with a span a feed (#213).
|
||||||
|
|
||||||
[unreleased]: https://git.sdf1.net/rays/ipx/compare/directory-v1.1.0...main
|
[unreleased]: https://git.sdf1.net/rays/ipx/compare/directory-v1.1.1...main
|
||||||
|
[1.1.1]: https://git.sdf1.net/rays/ipx/compare/directory-v1.1.0...directory-v1.1.1
|
||||||
[1.1.0]: https://git.sdf1.net/rays/ipx/compare/directory-v1.0.0...directory-v1.1.0
|
[1.1.0]: https://git.sdf1.net/rays/ipx/compare/directory-v1.0.0...directory-v1.1.0
|
||||||
[1.0.0]: https://git.sdf1.net/rays/ipx/releases/tag/directory-v1.0.0
|
[1.0.0]: https://git.sdf1.net/rays/ipx/releases/tag/directory-v1.0.0
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ipx-directory"
|
name = "ipx-directory"
|
||||||
version = "1.1.0"
|
version = "1.1.1"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
@@ -12,6 +12,7 @@ ammonia.workspace = true
|
|||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
axum.workspace = true
|
axum.workspace = true
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
|
futures-util.workspace = true
|
||||||
ipx-feed.workspace = true
|
ipx-feed.workspace = true
|
||||||
opentelemetry.workspace = true
|
opentelemetry.workspace = true
|
||||||
opentelemetry-otlp.workspace = true
|
opentelemetry-otlp.workspace = true
|
||||||
|
|||||||
@@ -134,6 +134,8 @@ impl Directory {
|
|||||||
pub fn new(store: Store, tokens: Vec<Token>) -> Result<Shared, reqwest::Error> {
|
pub fn new(store: Store, tokens: Vec<Token>) -> Result<Shared, reqwest::Error> {
|
||||||
// No redirects: ipx_feed::fetch follows them itself, so it sees each hop.
|
// No redirects: ipx_feed::fetch follows them itself, so it sees each hop.
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
|
.connect_timeout(std::time::Duration::from_secs(10))
|
||||||
|
.read_timeout(ipx_feed::FEED_SILENCE)
|
||||||
.redirect(reqwest::redirect::Policy::none())
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
.user_agent(concat!("ipx-directory/", env!("CARGO_PKG_VERSION")))
|
.user_agent(concat!("ipx-directory/", env!("CARGO_PKG_VERSION")))
|
||||||
.build()?;
|
.build()?;
|
||||||
@@ -175,7 +177,7 @@ impl Directory {
|
|||||||
Err(e) => Refreshed::Failed(format!("{e:#}")),
|
Err(e) => Refreshed::Failed(format!("{e:#}")),
|
||||||
};
|
};
|
||||||
if let Refreshed::Failed(e) = &got {
|
if let Refreshed::Failed(e) = &got {
|
||||||
tracing::info!(feed = %due.id, error = %e, "feed check failed");
|
tracing::info!(feed = %due.id, error = %ipx_feed::redact_text(e), "feed check failed");
|
||||||
}
|
}
|
||||||
// ponytail: a feed that moved for good is still asked for at its old address, which
|
// ponytail: a feed that moved for good is still asked for at its old address, which
|
||||||
// redirects; follow the move when a feed's id can change without losing its counts.
|
// redirects; follow the move when a feed's id can change without losing its counts.
|
||||||
@@ -193,12 +195,17 @@ pub async fn refresher(dir: Shared) {
|
|||||||
let round = tracing::info_span!("refresh", feeds = due.len());
|
let round = tracing::info_span!("refresh", feeds = due.len());
|
||||||
tracing::Instrument::instrument(
|
tracing::Instrument::instrument(
|
||||||
async {
|
async {
|
||||||
for d in &due {
|
// A few at a time: in turn, two publishers timing out held a round
|
||||||
|
// for a minute and more (#227).
|
||||||
|
futures_util::StreamExt::for_each_concurrent(futures_util::stream::iter(&due), 5, |d| {
|
||||||
let check = tracing::info_span!("feed", feed = %d.id);
|
let check = tracing::info_span!("feed", feed = %d.id);
|
||||||
if let Err(e) = tracing::Instrument::instrument(dir.refresh(d), check).await {
|
async {
|
||||||
tracing::warn!(feed = %d.id, error = %format!("{e:#}"), "could not store a feed check");
|
if let Err(e) = tracing::Instrument::instrument(dir.refresh(d), check).await {
|
||||||
|
tracing::warn!(feed = %d.id, error = %format!("{e:#}"), "could not store a feed check");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
.await;
|
||||||
// One line a round, for the dashboard's refresher panel (#210); failures have their own.
|
// One line a round, for the dashboard's refresher panel (#210); failures have their own.
|
||||||
tracing::info!(checked = due.len(), "feeds checked");
|
tracing::info!(checked = due.len(), "feeds checked");
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -61,6 +61,12 @@ pub enum Fetched {
|
|||||||
/// downloads, which are not, have no such limit.
|
/// downloads, which are not, have no such limit.
|
||||||
pub const FEED_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
pub const FEED_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||||
|
|
||||||
|
/// The longest a feed's server may send nothing, for the clients that fetch feeds to set: one
|
||||||
|
/// that took the connection and never answered ran the whole 30 s, every scan it was in (#230).
|
||||||
|
/// No feed that answered took over 8 s in a day of traces; one that keeps sending, however
|
||||||
|
/// slowly, still has until FEED_TIMEOUT.
|
||||||
|
pub const FEED_SILENCE: std::time::Duration = std::time::Duration::from_secs(15);
|
||||||
|
|
||||||
/// Feeds fetched and the bytes of their bodies, since the process started, for /metrics to set
|
/// Feeds fetched and the bytes of their bodies, since the process started, for /metrics to set
|
||||||
/// beside memory (#224): a body is read whole, so a night of large feeds is a night of large
|
/// beside memory (#224): a body is read whole, so a night of large feeds is a night of large
|
||||||
/// allocations.
|
/// allocations.
|
||||||
@@ -90,6 +96,21 @@ pub fn redact(url: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Text with each address in it redacted, for what is logged and sent to every open page: an
|
||||||
|
/// error from reqwest names the whole address it fetched, a private feed's key with it (#238).
|
||||||
|
pub fn redact_text(text: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(text.len());
|
||||||
|
let mut rest = text;
|
||||||
|
while let Some(i) = rest.find("http://").into_iter().chain(rest.find("https://")).min() {
|
||||||
|
out.push_str(&rest[..i]);
|
||||||
|
let end = rest[i..].find(|c: char| c.is_whitespace() || "()<>\"'".contains(c)).map_or(rest.len(), |n| i + n);
|
||||||
|
out.push_str(&redact(&rest[i..end]));
|
||||||
|
rest = &rest[end..];
|
||||||
|
}
|
||||||
|
out.push_str(rest);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
#[tracing::instrument(skip_all, fields(url = %redact(feed_url), http.response.body.size))]
|
#[tracing::instrument(skip_all, fields(url = %redact(feed_url), http.response.body.size))]
|
||||||
pub async fn fetch(
|
pub async fn fetch(
|
||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
@@ -1196,6 +1217,13 @@ mod tests {
|
|||||||
assert_eq!(super::redact("https://me:pw@example.com/feed.xml#top"), "https://example.com/feed.xml");
|
assert_eq!(super::redact("https://me:pw@example.com/feed.xml#top"), "https://example.com/feed.xml");
|
||||||
assert_eq!(super::redact("https://example.com/feed.xml"), "https://example.com/feed.xml");
|
assert_eq!(super::redact("https://example.com/feed.xml"), "https://example.com/feed.xml");
|
||||||
assert_eq!(super::redact("not a url?token=SECRET"), "not a url");
|
assert_eq!(super::redact("not a url?token=SECRET"), "not a url");
|
||||||
|
// As reqwest words a failure, and with two addresses and none.
|
||||||
|
assert_eq!(
|
||||||
|
super::redact_text("connecting: error sending request for url (https://www.patreon.com/rss/x?auth=SECRET&show=1): operation timed out"),
|
||||||
|
"connecting: error sending request for url (https://www.patreon.com/rss/x): operation timed out"
|
||||||
|
);
|
||||||
|
assert_eq!(super::redact_text("from http://a/f?k=1 to https://u:p@b/g"), "from http://a/f to https://b/g");
|
||||||
|
assert_eq!(super::redact_text("HTTP 404 Not Found"), "HTTP 404 Not Found");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -12,6 +12,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.2.0] - 2026-10-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `scan_done` on the event stream says which feed or folder the scan was limited to and whether someone asked for it (`only`, `forced`), so a page can tell the end of its own scan from a scheduled one's (#233).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Check every feed looks up the site icons of feeds without artwork a few at a time, alongside the fetches, instead of one after another (#228).
|
||||||
|
- A feed's server that sends nothing for 15 s is given up on, instead of holding a scan for the full 30 s; a feed that keeps sending still has 30 s (#230).
|
||||||
|
- A scan's trace names the feed each fetch is for (#229).
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- A failure's words, and the addresses in an event, go to the log and to every open page without their query or password, where a private feed keeps its key: a failed fetch of a Patreon feed logged its `auth` key (#238).
|
||||||
|
|
||||||
## [1.1.0] - 2026-10-13
|
## [1.1.0] - 2026-10-13
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -816,7 +832,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- 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/ipx/compare/v1.1.0...main
|
[unreleased]: https://git.sdf1.net/rays/ipx/compare/v1.2.0...main
|
||||||
|
[1.2.0]: https://git.sdf1.net/rays/ipx/compare/v1.1.0...v1.2.0
|
||||||
[1.1.0]: https://git.sdf1.net/rays/ipx/compare/v1.0.0...v1.1.0
|
[1.1.0]: https://git.sdf1.net/rays/ipx/compare/v1.0.0...v1.1.0
|
||||||
[1.0.0]: https://git.sdf1.net/rays/ipx/compare/v0.12.0...v1.0.0
|
[1.0.0]: https://git.sdf1.net/rays/ipx/compare/v0.12.0...v1.0.0
|
||||||
[0.12.0]: https://git.sdf1.net/rays/ipx/compare/v0.11.0...v0.12.0
|
[0.12.0]: https://git.sdf1.net/rays/ipx/compare/v0.11.0...v0.12.0
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ipx"
|
name = "ipx"
|
||||||
version = "1.1.0"
|
version = "1.2.0"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -36,7 +36,15 @@ pub enum Event {
|
|||||||
DeadairDone { feed: String, enclosure: i64, saved_s: i64 },
|
DeadairDone { feed: String, enclosure: i64, saved_s: i64 },
|
||||||
Reaped { path: String, bytes: u64 },
|
Reaped { path: String, bytes: u64 },
|
||||||
/// Terminal: a client that asked for work stops reading here.
|
/// Terminal: a client that asked for work stops reading here.
|
||||||
ScanDone { feeds: usize },
|
ScanDone {
|
||||||
|
feeds: usize,
|
||||||
|
/// The feed or folder the scan was limited to, and whether someone asked for it: a page
|
||||||
|
/// stops the button they pressed at the end of their scan, not at a scheduled one's (#233).
|
||||||
|
#[serde(default)]
|
||||||
|
only: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
forced: bool,
|
||||||
|
},
|
||||||
ReapDone { files: usize, bytes: u64 },
|
ReapDone { files: usize, bytes: u64 },
|
||||||
Status { feeds: usize, pending: i64, downloaded: i64 },
|
Status { feeds: usize, pending: i64, downloaded: i64 },
|
||||||
Error { msg: String },
|
Error { msg: String },
|
||||||
@@ -82,7 +90,7 @@ impl Event {
|
|||||||
Event::FeedStart { feed } => format!("{feed}: checking"),
|
Event::FeedStart { feed } => format!("{feed}: checking"),
|
||||||
Event::TorrentDeferred { feed, .. } => format!("{feed}: torrent deferred"),
|
Event::TorrentDeferred { feed, .. } => format!("{feed}: torrent deferred"),
|
||||||
Event::DeadairDone { feed, saved_s, .. } => format!("{feed}: DeadAir found the silences, {saved_s} s shorter"),
|
Event::DeadairDone { feed, saved_s, .. } => format!("{feed}: DeadAir found the silences, {saved_s} s shorter"),
|
||||||
Event::ScanDone { feeds } => format!("scan complete, {feeds} feed(s)"),
|
Event::ScanDone { feeds, .. } => format!("scan complete, {feeds} feed(s)"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,6 +144,7 @@ impl Emitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn emit(&self, e: Event) {
|
pub fn emit(&self, e: Event) {
|
||||||
|
let e = e.redacted();
|
||||||
// Also log it. Scans and downloads travel as events, not tracing calls, so
|
// Also log it. Scans and downloads travel as events, not tracing calls, so
|
||||||
// without this the log view shows only startup and HTTP lines and none of the
|
// without this the log view shows only startup and HTTP lines and none of the
|
||||||
// work the daemon is actually doing. Progress goes to debug: it fires on every
|
// work the daemon is actually doing. Progress goes to debug: it fires on every
|
||||||
@@ -159,6 +168,29 @@ impl Emitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Event {
|
||||||
|
/// The event as it may go out: to the log, to every open page whoever's feed it is, to a
|
||||||
|
/// terminal. Addresses lose their query and password, where a private feed keeps its key, and
|
||||||
|
/// so do the ones in an error's words (#238).
|
||||||
|
fn redacted(mut self) -> Self {
|
||||||
|
use crate::feed::{redact, redact_text};
|
||||||
|
match &mut self {
|
||||||
|
Event::DownloadError { url, msg, .. } => {
|
||||||
|
*url = redact(url);
|
||||||
|
*msg = redact_text(msg);
|
||||||
|
}
|
||||||
|
Event::TorrentDeferred { url, .. } => *url = redact(url),
|
||||||
|
Event::FeedMoved { from, to, .. } => {
|
||||||
|
*from = redact(from);
|
||||||
|
*to = redact(to);
|
||||||
|
}
|
||||||
|
Event::FeedError { msg, .. } | Event::Error { msg } => *msg = redact_text(msg),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// An event, logged once (#91): its words as the message, and `ev`, `feed`, `new` and the rest as
|
/// An event, logged once (#91): its words as the message, and `ev`, `feed`, `new` and the rest as
|
||||||
/// fields, so Loki reads them without parsing the message. A failure also gets `error.type` and,
|
/// fields, so Loki reads them without parsing the message. A failure also gets `error.type` and,
|
||||||
/// from an HTTP error, `http.response.status_code`, so failures group by kind without a regex.
|
/// from an HTTP error, `http.response.status_code`, so failures group by kind without a regex.
|
||||||
@@ -378,6 +410,30 @@ mod tests {
|
|||||||
assert!(matches!(rx.try_recv(), Ok(Event::FeedSkip { feed, .. }) if feed == "g"), "nothing missed");
|
assert!(matches!(rx.try_recv(), Ok(Event::FeedSkip { feed, .. }) if feed == "g"), "nothing missed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn what_goes_out_names_no_private_feeds_key() {
|
||||||
|
let (tx, mut rx) = broadcast::channel(8);
|
||||||
|
let out = Emitter::socket(tx, false);
|
||||||
|
let key = "auth=SECRET";
|
||||||
|
out.emit(Event::FeedError {
|
||||||
|
feed: "f".into(),
|
||||||
|
msg: format!("connecting: error sending request for url (https://x/rss?{key}&show=1): operation timed out"),
|
||||||
|
});
|
||||||
|
out.emit(Event::DownloadError {
|
||||||
|
feed: "f".into(),
|
||||||
|
enclosure: 1,
|
||||||
|
url: format!("https://cdn/ep.mp3?{key}"),
|
||||||
|
msg: format!("connecting: error sending request for url (https://cdn/ep.mp3?{key})"),
|
||||||
|
});
|
||||||
|
out.emit(Event::FeedMoved { feed: "f".into(), from: format!("https://x/rss?{key}"), to: format!("https://y/rss?{key}") });
|
||||||
|
// What every open page is sent, whoever's feed it is (#238).
|
||||||
|
for _ in 0..3 {
|
||||||
|
let sent = serde_json::to_string(&rx.try_recv().unwrap()).unwrap();
|
||||||
|
assert!(!sent.contains("SECRET"), "{sent}");
|
||||||
|
assert!(sent.contains("https://"), "the address is still there, without its key: {sent}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_feed_that_moved_says_so_on_the_wire_and_in_words() {
|
fn a_feed_that_moved_says_so_on_the_wire_and_in_words() {
|
||||||
let e = Event::FeedMoved { feed: "x".into(), from: "http://a/f".into(), to: "https://a/f".into() };
|
let e = Event::FeedMoved { feed: "x".into(), from: "http://a/f".into(), to: "https://a/f".into() };
|
||||||
@@ -434,7 +490,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn only_completion_events_end_a_client_session() {
|
fn only_completion_events_end_a_client_session() {
|
||||||
assert!(Event::ScanDone { feeds: 1 }.is_terminal());
|
assert!(Event::ScanDone { feeds: 1, only: None, forced: false }.is_terminal());
|
||||||
assert!(Event::ReapDone { files: 0, bytes: 0 }.is_terminal());
|
assert!(Event::ReapDone { files: 0, bytes: 0 }.is_terminal());
|
||||||
assert!(!Event::FeedDone {
|
assert!(!Event::FeedDone {
|
||||||
feed: "a".into(),
|
feed: "a".into(),
|
||||||
|
|||||||
@@ -355,6 +355,7 @@ async fn main() -> Result<()> {
|
|||||||
feed_client: reqwest::Client::builder()
|
feed_client: reqwest::Client::builder()
|
||||||
.user_agent(concat!("ipx/", env!("CARGO_PKG_VERSION")))
|
.user_agent(concat!("ipx/", env!("CARGO_PKG_VERSION")))
|
||||||
.connect_timeout(std::time::Duration::from_secs(10))
|
.connect_timeout(std::time::Duration::from_secs(10))
|
||||||
|
.read_timeout(feed::FEED_SILENCE)
|
||||||
.redirect(reqwest::redirect::Policy::none())
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
.build()?,
|
.build()?,
|
||||||
out: if is_daemon { Emitter::socket(events.clone(), false) } else { Emitter::terminal() },
|
out: if is_daemon { Emitter::socket(events.clone(), false) } else { Emitter::terminal() },
|
||||||
@@ -1260,18 +1261,33 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool, scope: &[String]
|
|||||||
const AHEAD: usize = 6;
|
const AHEAD: usize = 6;
|
||||||
let mut bodies = futures_util::StreamExt::buffered(
|
let mut bodies = futures_util::StreamExt::buffered(
|
||||||
futures_util::stream::iter(due.iter().map(|(sub, state)| {
|
futures_util::stream::iter(due.iter().map(|(sub, state)| {
|
||||||
let (client, feed_cfg) = (ctx.feed_client.clone(), sub.cfg.clone());
|
let (client, web, feed_cfg) = (ctx.feed_client.clone(), ctx.client.clone(), sub.cfg.clone());
|
||||||
let (etag, modified) = (state.etag.clone(), state.last_modified.clone());
|
let (etag, modified) = (state.etag.clone(), state.last_modified.clone());
|
||||||
let early = !feed::is_patreon_creator(&feed_cfg.url);
|
let early = !feed::is_patreon_creator(&feed_cfg.url);
|
||||||
|
// Named for its feed: the fetch's own span has only the address, without its query
|
||||||
|
// since #225, which for every YouTube channel is the same (#229).
|
||||||
|
let span = tracing::info_span!("fetch_ahead", feed = %sub.id);
|
||||||
tokio::spawn(tracing::Instrument::instrument(
|
tokio::spawn(tracing::Instrument::instrument(
|
||||||
async move {
|
async move {
|
||||||
if !early {
|
if !early {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let _fetching = metrics::busy(&metrics::STATS.feeds_fetching);
|
let _fetching = metrics::busy(&metrics::STATS.feeds_fetching);
|
||||||
Some(feed::fetch(&client, &feed_cfg.url, feed_cfg.auth(), etag.as_deref(), modified.as_deref()).await)
|
let got = feed::fetch(&client, &feed_cfg.url, feed_cfg.auth(), etag.as_deref(), modified.as_deref()).await;
|
||||||
|
// A forced scan looks for the site's icon of each feed naming no artwork: here,
|
||||||
|
// a few feeds at a time, not each in its turn, 22 s of a 62 s scan (#228).
|
||||||
|
let icon = match &got {
|
||||||
|
Ok((feed::Fetched::Body { bytes, .. }, _)) if force => match feed::parse(bytes) {
|
||||||
|
Ok(feed::ParsedFeed { image: None, site: Some(site), .. }) => {
|
||||||
|
Some(feed::site_icon(&web, &site).await.unwrap_or_default())
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
Some((got, icon))
|
||||||
},
|
},
|
||||||
tracing::Span::current(),
|
span,
|
||||||
))
|
))
|
||||||
})),
|
})),
|
||||||
AHEAD,
|
AHEAD,
|
||||||
@@ -1345,7 +1361,7 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool, scope: &[String]
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ctx.out.emit(Event::ScanDone { feeds: scanned });
|
ctx.out.emit(Event::ScanDone { feeds: scanned, only: only.map(str::to_owned), forced: force });
|
||||||
// What people subscribe to may have changed, and new feeds have titles now: share it.
|
// What people subscribe to may have changed, and new feeds have titles now: share it.
|
||||||
ctx.directory_wake.notify_one();
|
ctx.directory_wake.notify_one();
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1628,9 +1644,14 @@ async fn scan_one(
|
|||||||
feed_cfg: &config::Feed,
|
feed_cfg: &config::Feed,
|
||||||
state: &db::HttpState,
|
state: &db::HttpState,
|
||||||
force: bool,
|
force: bool,
|
||||||
// The body the scan already fetched for it, if it did (#104), and where it moved, if it did.
|
// The body the scan already fetched for it, if it did (#104), and where it moved, if it did;
|
||||||
prefetched: Option<Result<(feed::Fetched, Option<String>)>>,
|
// with the site's icon when a forced scan looked it up meanwhile, "" for none (#228).
|
||||||
|
prefetched: Option<(Result<(feed::Fetched, Option<String>)>, Option<String>)>,
|
||||||
) -> Result<Outcome> {
|
) -> Result<Outcome> {
|
||||||
|
let (prefetched, icon) = match prefetched {
|
||||||
|
Some((got, icon)) => (Some(got), icon),
|
||||||
|
None => (None, None),
|
||||||
|
};
|
||||||
// The queue follows the feed's settings each time it is due, changed or not, so files a
|
// The queue follows the feed's settings each time it is due, changed or not, so files a
|
||||||
// limit or auto-download no longer reaches stop counting as waiting (#113).
|
// limit or auto-download no longer reaches stop counting as waiting (#113).
|
||||||
let policy = policy_for(ctx, id, feed_cfg).await?;
|
let policy = policy_for(ctx, id, feed_cfg).await?;
|
||||||
@@ -1732,13 +1753,16 @@ async fn scan_one(
|
|||||||
&& stored.image.as_deref() != Some(art.as_str())
|
&& stored.image.as_deref() != Some(art.as_str())
|
||||||
&& !feed::is_image(&ctx.client, art).await
|
&& !feed::is_image(&ctx.client, art).await
|
||||||
{
|
{
|
||||||
tracing::info!(feed = id, art = %art, "the feed's artwork is not an image; trying its site's icon");
|
tracing::info!(feed = id, art = %feed::redact(art), "the feed's artwork is not an image; trying its site's icon");
|
||||||
parsed.image = None;
|
parsed.image = None;
|
||||||
}
|
}
|
||||||
if parsed.image.is_none() {
|
if parsed.image.is_none() {
|
||||||
parsed.image = Some(match (&stored.image, &parsed.site) {
|
parsed.image = Some(match (&stored.image, &parsed.site) {
|
||||||
(Some(known), _) if !force => known.clone(),
|
(Some(known), _) if !force => known.clone(),
|
||||||
(_, Some(site)) => feed::site_icon(&ctx.client, site).await.unwrap_or_default(),
|
(_, Some(site)) => match &icon {
|
||||||
|
Some(found) => found.clone(),
|
||||||
|
None => feed::site_icon(&ctx.client, site).await.unwrap_or_default(),
|
||||||
|
},
|
||||||
(_, None) => String::new(),
|
(_, None) => String::new(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2297,7 +2321,7 @@ async fn download_one(ctx: &Arc<Ctx>, id: i64) -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Terminal, so a UI waiting on this request stops here.
|
// Terminal, so a UI waiting on this request stops here.
|
||||||
ctx.out.emit(Event::ScanDone { feeds: 1 });
|
ctx.out.emit(Event::ScanDone { feeds: 1, only: None, forced: false });
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2659,6 +2683,58 @@ mod tests {
|
|||||||
assert!(deadair::plan_for(&ctx, u, "f", 7).await.unwrap().is_none());
|
assert!(deadair::plan_for(&ctx, u, "f", 7).await.unwrap().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn two_people_play_one_file_each_in_their_own_version_and_place() {
|
||||||
|
let ctx = test_ctx(config::Config::default()).await;
|
||||||
|
let db = &ctx.db;
|
||||||
|
for sql in [
|
||||||
|
"INSERT INTO entries (feed_id, guid, first_seen, duration) VALUES ('f', 'g', 1, 600)",
|
||||||
|
"INSERT INTO enclosures (id, feed_id, guid, url, path, state, downloaded_at)
|
||||||
|
VALUES (7, 'f', 'g', 'http://x/a.mp3', '/x/a.mp3', 'done', 100)",
|
||||||
|
] {
|
||||||
|
db.sql(sql).await.unwrap();
|
||||||
|
}
|
||||||
|
db.store_plans(7, r#"{"300": {"cuts": [[60000,120000],[200000,260000]], "pieces": []},
|
||||||
|
"500": {"cuts": [[60000,100000]], "pieces": []}}"#).await.unwrap();
|
||||||
|
// One file on disk, three listeners at once: DeadAir at the usual pause, at a longer
|
||||||
|
// one, and the original.
|
||||||
|
let (usual, long, plain) = (
|
||||||
|
db.create_user("usual", None, false).await.unwrap(),
|
||||||
|
db.create_user("long", None, false).await.unwrap(),
|
||||||
|
db.create_user("plain", None, false).await.unwrap(),
|
||||||
|
);
|
||||||
|
for u in [usual, long, plain] {
|
||||||
|
db.subscribe(u, "f").await.unwrap();
|
||||||
|
}
|
||||||
|
for u in [usual, long] {
|
||||||
|
db.set_deadair(u, "f", Some(true)).await.unwrap();
|
||||||
|
}
|
||||||
|
db.set_deadair_pause(long, "f", 500).await.unwrap();
|
||||||
|
// Each is handed their own playlist, as /media/7/deadair.m3u8 does.
|
||||||
|
let playing = deadair::Playing::default();
|
||||||
|
for u in [usual, long] {
|
||||||
|
let plan = deadair::plan_for(&ctx, u, "f", 7).await.unwrap().unwrap();
|
||||||
|
playing.lock().unwrap().insert((u, "f".into(), "g".into()), Arc::new(plan));
|
||||||
|
}
|
||||||
|
assert!(deadair::plan_for(&ctx, plain, "f", 7).await.unwrap().is_none());
|
||||||
|
// All three save 3:20 of what they hear, as the position route does: each is read in
|
||||||
|
// that person's own clock and kept as a place in the original, theirs alone.
|
||||||
|
let mut kept = vec![];
|
||||||
|
for u in [usual, long, plain] {
|
||||||
|
let (secs, len) = deadair::on_save(&ctx, &playing, u, "f", "g", 200, None).await.unwrap();
|
||||||
|
db.set_position(u, "f", "g", secs, len).await.unwrap();
|
||||||
|
kept.push(secs);
|
||||||
|
}
|
||||||
|
assert_eq!(kept, [320, 240, 200], "two minutes cut before 3:20, forty seconds, none");
|
||||||
|
// Each comes back to 3:20 of their own version, whatever the others saved.
|
||||||
|
let order = db::order_sql("published", "desc");
|
||||||
|
for (u, len) in [(usual, 480), (long, 560), (plain, 600)] {
|
||||||
|
let mut rows = db.entries_in(u, Some("f"), db::Filter::All, None, 0, 10, &order).await.unwrap();
|
||||||
|
deadair::for_reader(&ctx, u, &mut rows).await.unwrap();
|
||||||
|
assert_eq!((rows[0].position, rows[0].duration), (200, Some(len)), "user {u}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn two_changes_to_the_catalogue_at_once_both_keep() {
|
async fn two_changes_to_the_catalogue_at_once_both_keep() {
|
||||||
let ctx = Arc::new(test_ctx(config::Config::default()).await);
|
let ctx = Arc::new(test_ctx(config::Config::default()).await);
|
||||||
@@ -2758,6 +2834,18 @@ mod tests {
|
|||||||
assert_eq!(text().await, ["As it should read."]);
|
assert_eq!(text().await, ["As it should read."]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_forced_scan_takes_the_icon_looked_up_ahead() {
|
||||||
|
let ctx = Arc::new(test_ctx(config::Config::default()).await);
|
||||||
|
// No artwork, and a site nothing answers at: only the icon found ahead can be stored (#228).
|
||||||
|
let body = b"<?xml version=\"1.0\"?><rss version=\"2.0\"><channel><title>T</title><link>http://127.0.0.1:9/</link><item><title>One</title><guid>g1</guid></item></channel></rss>".to_vec();
|
||||||
|
let got = feed::Fetched::Body { bytes: body, etag: None, last_modified: None };
|
||||||
|
let icon = "https://example.com/favicon.ico".to_string();
|
||||||
|
let cfg = config::Feed { url: "http://127.0.0.1:9/feed".into(), ..feed() };
|
||||||
|
scan_one(&ctx, "iconic", &cfg, &db::HttpState::default(), true, Some((Ok((got, None)), Some(icon.clone())))).await.unwrap();
|
||||||
|
assert_eq!(ctx.db.feed_summary("iconic").await.unwrap().image, Some(icon));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn nothing_from_a_feed_that_never_posted_is_an_error_and_from_one_that_has_is_not() {
|
async fn nothing_from_a_feed_that_never_posted_is_an_error_and_from_one_that_has_is_not() {
|
||||||
let base = empty_or_feed_server().await;
|
let base = empty_or_feed_server().await;
|
||||||
|
|||||||
@@ -1907,7 +1907,7 @@ async fn art(State(state): State<WebState>, Query(q): Query<ArtQuery>) -> Respon
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
let why = format!("{e:#}");
|
let why = format!("{e:#}");
|
||||||
// The access line says only that it failed, not which image or why (#169).
|
// The access line says only that it failed, not which image or why (#169).
|
||||||
tracing::warn!(url = %q.u, error = %why, "could not fetch artwork");
|
tracing::warn!(url = %crate::feed::redact(&q.u), error = %crate::feed::redact_text(&why), "could not fetch artwork");
|
||||||
crate::art::fail(&q.u, &why);
|
crate::art::fail(&q.u, &why);
|
||||||
(StatusCode::BAD_GATEWAY, why).into_response()
|
(StatusCode::BAD_GATEWAY, why).into_response()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,8 +51,12 @@ pid = 0
|
|||||||
def panel(kind, title, w, h, x, targets, **extra):
|
def panel(kind, title, w, h, x, targets, **extra):
|
||||||
global pid
|
global pid
|
||||||
pid += 1
|
pid += 1
|
||||||
|
sources = {t.get("datasource", LOKI)["uid"] for t in targets}
|
||||||
|
# A panel with queries on more than one data source must be Mixed, or Grafana runs them all on
|
||||||
|
# the panel's and Downloads, Loki's and Prometheus's, failed (#226).
|
||||||
|
ds = {"type": "datasource", "uid": "-- Mixed --"} if len(sources) > 1 else targets[0].get("datasource", LOKI)
|
||||||
p = {"id": pid, "type": kind, "title": title, "gridPos": {"x": x, "y": y, "w": w, "h": h},
|
p = {"id": pid, "type": kind, "title": title, "gridPos": {"x": x, "y": y, "w": w, "h": h},
|
||||||
"datasource": targets[0].get("datasource", LOKI), "targets": targets}
|
"datasource": ds, "targets": targets}
|
||||||
p.update(extra)
|
p.update(extra)
|
||||||
panels.append(p)
|
panels.append(p)
|
||||||
return p
|
return p
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ipx-web",
|
"name": "ipx-web",
|
||||||
"version": "1.0.1",
|
"version": "1.0.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"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.",
|
"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": {
|
||||||
|
|||||||
132
tests/page-unit.js
Normal file
132
tests/page-unit.js
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
// Unit tests for the page's own functions (#234): the script as shipped, loaded into the stub DOM
|
||||||
|
// page-smoke.js uses, and its helpers called directly. The browser suite tests what people do;
|
||||||
|
// this tests the small pieces it is made of, in a second, with nothing running.
|
||||||
|
//
|
||||||
|
// node --test tests/page-unit.js
|
||||||
|
//
|
||||||
|
// The files in web/src are one script, not modules. Its top-level `function`s are properties of
|
||||||
|
// the context; its top-level `const`s are not, but a script run in the same context sees them, as
|
||||||
|
// a second <script> on a page would, so `run` evaluates an expression there.
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { makeContext, vm } = require('./dom-stub.js');
|
||||||
|
const { buildPage } = require('../web/build.mjs');
|
||||||
|
|
||||||
|
const { html, js } = buildPage('index.html');
|
||||||
|
const { ctx } = makeContext(html, js);
|
||||||
|
vm.createContext(ctx);
|
||||||
|
vm.runInContext(js, ctx, { filename: 'index.html<script>', timeout: 5000 });
|
||||||
|
const run = expr => vm.runInContext(expr, ctx);
|
||||||
|
// Time as the page sees it, for the helpers that count from now.
|
||||||
|
const NOW = 1_800_000_000;
|
||||||
|
run(`Date.now = () => ${NOW * 1000}`);
|
||||||
|
|
||||||
|
test('esc makes text safe to put in markup and attributes', () => {
|
||||||
|
assert.equal(run(`esc('<a href="x">Tom & Jerry\\'s</a>')`), '<a href="x">Tom & Jerry's</a>');
|
||||||
|
assert.equal(run('esc(null)'), '');
|
||||||
|
assert.equal(run('esc(undefined)'), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clock reads seconds as a player does', () => {
|
||||||
|
assert.equal(run('clock(0)'), '0:00');
|
||||||
|
assert.equal(run('clock(65)'), '1:05');
|
||||||
|
assert.equal(run('clock(3661)'), '1:01:01');
|
||||||
|
assert.equal(run('clock(59.9)'), '0:59');
|
||||||
|
assert.equal(run('clock(-5)'), '0:00');
|
||||||
|
assert.equal(run('clock(undefined)'), '0:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mb sizes a file in the unit that reads best', () => {
|
||||||
|
assert.equal(run('mb(0)'), '');
|
||||||
|
// An article's small image is not "0 KB" or "0 MB".
|
||||||
|
assert.equal(run('mb(100)'), '1 KB');
|
||||||
|
assert.equal(run('mb(2048)'), '2 KB');
|
||||||
|
assert.equal(run('mb(5 * 1048576)'), '5 MB');
|
||||||
|
assert.equal(run('mb(1.5 * 1073741824)'), '1.5 GB');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ago says how long since, and never for nothing', () => {
|
||||||
|
assert.equal(run('ago(0)'), 'never');
|
||||||
|
assert.equal(run(`ago(${NOW - 10})`), '1m ago');
|
||||||
|
assert.equal(run(`ago(${NOW - 45 * 60})`), '45m ago');
|
||||||
|
assert.equal(run(`ago(${NOW - 5 * 3600})`), '5h ago');
|
||||||
|
assert.equal(run(`ago(${NOW - 3 * 86400})`), '3d ago');
|
||||||
|
// Past a month, a date: whatever the locale, it names the year.
|
||||||
|
assert.match(run(`ago(${NOW - 90 * 86400})`), /2026/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('due says how long until a feed is checked', () => {
|
||||||
|
assert.equal(run(`due(${NOW - 1})`), 'due now');
|
||||||
|
assert.equal(run(`due(${NOW + 10})`), 'in 1m');
|
||||||
|
assert.equal(run(`due(${NOW + 90 * 60})`), 'in 2h');
|
||||||
|
assert.equal(run(`due(${NOW + 3 * 86400})`), 'in 3d');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DeadAir savings read as time', () => {
|
||||||
|
assert.equal(run('spanText(40)'), '40 s');
|
||||||
|
assert.equal(run('spanText(12 * 60 + 5)'), '12 min');
|
||||||
|
assert.equal(run('spanText(3 * 3600 + 12 * 60)'), '3 h 12 min');
|
||||||
|
assert.equal(run('shorter(45)'), '45 s shorter');
|
||||||
|
assert.equal(run('shorter(150)'), '3 min shorter');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initials and plural', () => {
|
||||||
|
assert.equal(run(`initials('The Verge')`), 'TV');
|
||||||
|
assert.equal(run(`initials('accidental tech podcast')`), 'AT');
|
||||||
|
assert.equal(run(`initials('')`), '?');
|
||||||
|
assert.equal(run(`initials('!!!')`), '?');
|
||||||
|
assert.equal(run(`plural(1, 'feed')`), '1 feed');
|
||||||
|
assert.equal(run(`plural(0, 'feed')`), '0 feeds');
|
||||||
|
assert.equal(run(`plural(2, 'feed')`), '2 feeds');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("kindOf names a file by its type, or else its address's extension", () => {
|
||||||
|
const kind = enc => run(`kindOf(${JSON.stringify(enc)})`);
|
||||||
|
assert.equal(kind({ mime: 'image/png' }), 'image');
|
||||||
|
assert.equal(kind({ mime: 'Video/MP4' }), 'video');
|
||||||
|
assert.equal(kind({ mime: 'audio/mpeg' }), 'audio');
|
||||||
|
assert.equal(kind({ mime: 'application/pdf' }), 'pdf');
|
||||||
|
assert.equal(kind({ mime: 'application/x-bittorrent' }), 'torrent');
|
||||||
|
assert.equal(kind({ url: 'https://x/ep.MP3?token=1' }), 'mp3');
|
||||||
|
assert.equal(kind({ url: 'https://x/download' }), 'file');
|
||||||
|
assert.equal(kind({ url: 'https://x/a.toolongext' }), 'file');
|
||||||
|
assert.equal(kind({}), 'file');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an item without a title is named by its file', () => {
|
||||||
|
const name = e => run(`entryName(${JSON.stringify(e)})`);
|
||||||
|
assert.deepEqual({ ...name({ title: 'Episode 1' }) }, { text: 'Episode 1', derived: false });
|
||||||
|
assert.deepEqual({ ...name({ title: ' ', enclosures: [{ url: 'https://x/My%20Show%20101.mp3?x=1' }] }) },
|
||||||
|
{ text: 'My Show 101', derived: true });
|
||||||
|
// A name that does not decode is shown as it is, not dropped.
|
||||||
|
assert.deepEqual({ ...name({ enclosures: [{ url: 'https://x/100%.mp3' }] }) }, { text: '100%', derived: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the directory sorts by name as people read, and by subscribers', () => {
|
||||||
|
const sorted = (list, by) => run(`${JSON.stringify(list)}.sort(${by}).map(p => p.title)`);
|
||||||
|
assert.deepEqual([...sorted([{ title: 'ep 10' }, { title: 'Ep 2' }, { title: 'ep 1' }], 'byName')], ['ep 1', 'Ep 2', 'ep 10']);
|
||||||
|
assert.deepEqual([...sorted([{ title: 'B', subscribers: 1 }, { title: 'C', subscribers: 5 }, { title: 'A', subscribers: 1 }], 'bySubs')],
|
||||||
|
['C', 'A', 'B']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('feeds with something unread go first, in the order they came', () => {
|
||||||
|
const ids = run(`[{id:'a',unread:0},{id:'b',unread:3},{id:'c',unread:0},{id:'d',unread:1}].sort(unreadFirst).map(f => f.id)`);
|
||||||
|
assert.deepEqual([...ids], ['b', 'd', 'a', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('words for filters split on commas, blanks dropped', () => {
|
||||||
|
assert.deepEqual([...run(`splitWords(' one, two words ,, three ')`)], ['one', 'two words', 'three']);
|
||||||
|
assert.deepEqual([...run(`splitWords('')`)], []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a file plays from DeadAir's playlist when it has one", () => {
|
||||||
|
assert.equal(run(`mediaSrc({ id: 7, deadair: null })`), '/media/7');
|
||||||
|
assert.equal(run(`mediaSrc({ id: 7 })`), '/media/7');
|
||||||
|
assert.equal(run(`mediaSrc({ id: 7, deadair: 0 })`), '/media/7/deadair.m3u8');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a Patreon feed is told by its address', () => {
|
||||||
|
assert.equal(run(`isPatreon({ url: 'https://www.patreon.com/rss/show?auth=x' })`), true);
|
||||||
|
assert.equal(run(`isPatreon({ url: 'https://example.com/feed' })`), false);
|
||||||
|
assert.equal(run('isPatreon(null)'), false);
|
||||||
|
});
|
||||||
@@ -1697,15 +1697,30 @@ test('a pinned feed, even one from inside a folder, goes to the top of the list'
|
|||||||
test('a feed being checked shows a spinner on its row, and no toast', async ({ page }) => {
|
test('a feed being checked shows a spinner on its row, and no toast', async ({ page }) => {
|
||||||
const row = page.locator('#feedlist .feed', { hasText: 'Test Show' });
|
const row = page.locator('#feedlist .feed', { hasText: 'Test Show' });
|
||||||
await expect(row).toBeVisible();
|
await expect(row).toBeVisible();
|
||||||
await page.evaluate(() => setScanning('test-show', true));
|
// A check of a few milliseconds leaves the count where it is, or every count blinked as a scan
|
||||||
|
// went by; the spinner comes once the check has taken a moment (#232).
|
||||||
|
const atOnce = await page.evaluate(() => {
|
||||||
|
setScanning('test-show', true);
|
||||||
|
return document.querySelector('#feedlist .feed[data-id="test-show"]').classList.contains('scanning');
|
||||||
|
});
|
||||||
|
expect(atOnce, 'no spinner the moment a check starts').toBe(false);
|
||||||
await expect(row).toHaveClass(/\bscanning\b/);
|
await expect(row).toHaveClass(/\bscanning\b/);
|
||||||
await expect(row.locator('.badge'), 'the spinner stands in for the count').toBeHidden();
|
await expect(row.locator('.badge'), 'the spinner stands in for the count').toBeHidden();
|
||||||
await page.evaluate(() => setScanning('test-show', false));
|
await page.evaluate(() => setScanning('test-show', false));
|
||||||
await expect(row).not.toHaveClass(/\bscanning\b/);
|
await expect(row).not.toHaveClass(/\bscanning\b/);
|
||||||
await expect(row.locator('.badge')).toBeVisible();
|
await expect(row.locator('.badge')).toBeVisible();
|
||||||
|
// The button turns from the press to the end of the scan it asked for (#233): a scheduled
|
||||||
|
// scan's end, or the end of a scan of one other feed, leaves it turning.
|
||||||
|
const spins = () => document.body.classList.contains('scan-any');
|
||||||
|
expect(await page.evaluate(spins => { spinStart(null); spinEnd({ only: null, forced: false });
|
||||||
|
const a = document.body.classList.contains('scan-any'); spinEnd({ only: 'test-show', forced: true });
|
||||||
|
return a && document.body.classList.contains('scan-any'); })).toBe(true);
|
||||||
|
await page.evaluate(() => spinEnd({ only: null, forced: true }));
|
||||||
|
expect(await page.evaluate(spins)).toBe(false);
|
||||||
// Checking every feed says so on the rows, not in a toast (issue #37).
|
// Checking every feed says so on the rows, not in a toast (issue #37).
|
||||||
await page.locator('#scanAll').click();
|
expect(await page.evaluate(() => { scanAll(); return document.body.classList.contains('scan-any'); }),
|
||||||
await page.waitForTimeout(1500);
|
'turning the moment it is pressed').toBe(true);
|
||||||
|
await expect(page.locator('body')).not.toHaveClass(/\bscan-any\b/, { timeout: 15_000 });
|
||||||
await expect(page.locator('.toast', { hasText: /Scanning|Checking| new/ })).toHaveCount(0);
|
await expect(page.locator('.toast', { hasText: /Scanning|Checking| new/ })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1766,20 +1781,28 @@ test('Share hands the feed, the item and the file to the share sheet', async ({
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a refresh button turns while what it checks is being checked', async ({ page }) => {
|
test('a refresh button turns from the press to the end of the scan it asked for', async ({ page }) => {
|
||||||
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
const spin = sel => page.locator(sel).evaluate(el => getComputedStyle(el).animationName);
|
const spin = sel => page.locator(sel).evaluate(el => getComputedStyle(el).animationName);
|
||||||
const btn = '.fhead [data-a=scan] .i';
|
const btn = '.fhead [data-a=scan] .i';
|
||||||
await expect(page.locator(btn)).toBeVisible();
|
await expect(page.locator(btn)).toBeVisible();
|
||||||
expect(await spin(btn)).toBe('none');
|
expect(await spin(btn)).toBe('none');
|
||||||
await page.evaluate(() => setScanning('test-show', true));
|
// Pressed: this feed's button turns, the toolbar's does not (#78, #233).
|
||||||
|
await page.evaluate(() => spinStart('test-show'));
|
||||||
expect(await spin(btn)).toBe('ipxspin');
|
expect(await spin(btn)).toBe('ipxspin');
|
||||||
expect(await spin('#scanAll .i')).toBe('ipxspin');
|
expect(await spin('#scanAll .i')).toBe('none');
|
||||||
// Another feed being checked turns the toolbar's, not this page's.
|
// Feeds being checked one by one no longer start or stop it (#231).
|
||||||
await page.evaluate(() => { setScanning('test-show', false); setScanning('multi-show', true); });
|
await page.evaluate(() => { setScanning('test-show', true); setScanning('test-show', false); });
|
||||||
|
expect(await spin(btn)).toBe('ipxspin');
|
||||||
|
// Another feed's scan ending, or a scheduled one's, leaves it turning; its own stops it.
|
||||||
|
await page.evaluate(() => { spinEnd({ only: 'multi-show', forced: true }); spinEnd({ only: null, forced: false }); });
|
||||||
|
expect(await spin(btn)).toBe('ipxspin');
|
||||||
|
await page.evaluate(() => spinEnd({ only: 'test-show', forced: true }));
|
||||||
expect(await spin(btn)).toBe('none');
|
expect(await spin(btn)).toBe('none');
|
||||||
|
// The toolbar's turns for a scan of every feed, until that scan ends.
|
||||||
|
await page.evaluate(() => spinStart(null));
|
||||||
expect(await spin('#scanAll .i')).toBe('ipxspin');
|
expect(await spin('#scanAll .i')).toBe('ipxspin');
|
||||||
await page.evaluate(() => setScanning('multi-show', false));
|
await page.evaluate(() => spinEnd({ only: null, forced: true }));
|
||||||
expect(await spin('#scanAll .i')).toBe('none');
|
expect(await spin('#scanAll .i')).toBe('none');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1913,6 +1936,36 @@ test('DeadAir is off until turned on for a show, then plays it shortened, with y
|
|||||||
await page.locator('#ssave').click();
|
await page.locator('#ssave').click();
|
||||||
await expect.poll(async () => (await entry()).enclosures[0].deadair).toBeLessThan(usual);
|
await expect.poll(async () => (await entry()).enclosures[0].deadair).toBeLessThan(usual);
|
||||||
|
|
||||||
|
// Someone else on the same show at the usual pause, at the same address at the same time: one
|
||||||
|
// file on disk, a playlist each, and neither kept by a cache to be handed to the other.
|
||||||
|
{
|
||||||
|
const { execFileSync } = require('child_process');
|
||||||
|
const setup = require('./global-setup');
|
||||||
|
const env = { ...process.env, IPX_CONFIG: `${setup.root}/config/config.toml`, IPX_DATA_DIR: `${setup.root}/data` };
|
||||||
|
try { execFileSync('./target/debug/ipx', ['user', 'add', 'quinn'], { input: 'quinnpassword', env }); }
|
||||||
|
catch (e) { if (!String(e.stderr || e.stdout).includes('already exists')) throw e; }
|
||||||
|
const ctx = await page.context().browser().newContext();
|
||||||
|
const quinn = await ctx.newPage();
|
||||||
|
await quinn.goto('/login');
|
||||||
|
await quinn.locator('#name').fill('quinn');
|
||||||
|
await quinn.locator('#pw').fill('quinnpassword');
|
||||||
|
await quinn.locator('button[type=submit]').click();
|
||||||
|
await expect(quinn.locator('#feedlist')).toBeVisible();
|
||||||
|
await quinn.request.post('/api/feeds', { data: { url: 'http://127.0.0.1:8792/quiet.xml' } });
|
||||||
|
await quinn.request.patch(`/api/feeds/${encodeURIComponent(id)}`, { data: { deadair: true } });
|
||||||
|
const enc = (await entry()).enclosures[0].id;
|
||||||
|
const fetchList = async p => { const r = await p.request.get(`/media/${enc}/deadair.m3u8`);
|
||||||
|
return { status: r.status(), cache: r.headers()['cache-control'], body: await r.text() }; };
|
||||||
|
const [mine, theirs] = await Promise.all([fetchList(page), fetchList(quinn)]);
|
||||||
|
const length = l => l.body.match(/#EXTINF:([\d.]+)/g).reduce((s, x) => s + Number(x.slice(8)), 0);
|
||||||
|
expect([mine.status, theirs.status]).toEqual([200, 200]);
|
||||||
|
expect([mine.cache, theirs.cache]).toEqual(['no-store', 'no-store']);
|
||||||
|
expect(mine.body).not.toBe(theirs.body);
|
||||||
|
expect(length(theirs), 'the usual pause cuts more than the longer one').toBeLessThan(length(mine));
|
||||||
|
await quinn.request.delete(`/api/feeds/${encodeURIComponent(id)}`);
|
||||||
|
await ctx.close();
|
||||||
|
}
|
||||||
|
|
||||||
// Saved without its dead air too (#186): smaller than the original, named to say so.
|
// Saved without its dead air too (#186): smaller than the original, named to say so.
|
||||||
const saved = await page.evaluate(async id => {
|
const saved = await page.evaluate(async id => {
|
||||||
const [d, o, p] = await Promise.all([fetch(`/media/${id}/deadair`), fetch(`/media/${id}`), fetch(`/media/${id}/deadair.mp3`, { headers: { Range: 'bytes=0-99' } })]);
|
const [d, o, p] = await Promise.all([fetch(`/media/${id}/deadair`), fetch(`/media/${id}`), fetch(`/media/${id}/deadair.mp3`, { headers: { Range: 'bytes=0-99' } })]);
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
"target": "es2022",
|
"target": "es2022",
|
||||||
"lib": ["es2022", "dom", "dom.iterable"],
|
"lib": ["es2022", "dom", "dom.iterable"],
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"strict": false,
|
// Strict throughout (#235, #236). $ assumes the element is there: the page check proves the
|
||||||
|
// ones wired at load exist, and a lookup that may find nothing says so with ?.
|
||||||
|
"strict": true,
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true
|
||||||
},
|
},
|
||||||
"include": ["web/src/*.ts"]
|
"include": ["web/src/*.ts"]
|
||||||
|
|||||||
@@ -11,6 +11,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.0.2] - 2026-10-15
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The refresh buttons turn from the moment they are pressed until the scan they asked for has finished, smoothly, instead of jerking back upright between one feed and the next (#231, #233).
|
||||||
|
- A feed's unread count stays put while a scan checks it quickly; the spinner takes its place only when a check takes a moment (#232).
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- The ipx-web image no longer carries curl, which Caddy's base image shipped and nothing used: 8.22.0 there is affected by CVE-2026-92392 (#237).
|
||||||
|
|
||||||
## [1.0.1] - 2026-10-13
|
## [1.0.1] - 2026-10-13
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -38,6 +49,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
- On a phone, the Subscribe button on a Directory feed's page is full size again, not squashed to a sliver (#212).
|
- On a phone, the Subscribe button on a Directory feed's page is full size again, not squashed to a sliver (#212).
|
||||||
|
|
||||||
[unreleased]: https://git.sdf1.net/rays/ipx/compare/web-v1.0.1...main
|
[unreleased]: https://git.sdf1.net/rays/ipx/compare/web-v1.0.2...main
|
||||||
|
[1.0.2]: https://git.sdf1.net/rays/ipx/compare/web-v1.0.1...web-v1.0.2
|
||||||
[1.0.1]: https://git.sdf1.net/rays/ipx/compare/web-v1.0.0...web-v1.0.1
|
[1.0.1]: https://git.sdf1.net/rays/ipx/compare/web-v1.0.0...web-v1.0.1
|
||||||
[1.0.0]: https://git.sdf1.net/rays/ipx/releases/tag/web-v1.0.0
|
[1.0.0]: https://git.sdf1.net/rays/ipx/releases/tag/web-v1.0.0
|
||||||
|
|||||||
@@ -56,17 +56,17 @@ async function drawServer(){
|
|||||||
<div class="field"><label>Download folder</label>
|
<div class="field"><label>Download folder</label>
|
||||||
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>
|
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>
|
||||||
<div class="cardacts"><button class="btn primary" id="gsave">${ICON.check} Save</button></div>`;
|
<div class="cardacts"><button class="btn primary" id="gsave">${ICON.check} Save</button></div>`;
|
||||||
$('#gsave').onclick = async () => {
|
$<HTMLButtonElement>('#gsave').onclick = async () => {
|
||||||
try{
|
try{
|
||||||
await api('/api/settings', {method: 'PATCH', body: JSON.stringify({
|
await api('/api/settings', {method: 'PATCH', body: JSON.stringify({
|
||||||
schedule: `every ${Math.max(1, Number($('#gnum').value) || 1)}${$('#gunit').value}`,
|
schedule: `every ${Math.max(1, Number($<HTMLInputElement>('#gnum').value) || 1)}${$<HTMLSelectElement>('#gunit').value}`,
|
||||||
max_new_per_check: Math.max(0, Number($('#gmax').value) || 0),
|
max_new_per_check: Math.max(0, Number($<HTMLInputElement>('#gmax').value) || 0),
|
||||||
media_types: $('#gtypes').value.split(',').map(t => t.trim()).filter(Boolean),
|
media_types: $<HTMLInputElement>('#gtypes').value.split(',').map((t: string) => t.trim()).filter(Boolean),
|
||||||
max_total_gb: Number($('#gquota').value) || 0,
|
max_total_gb: Number($<HTMLInputElement>('#gquota').value) || 0,
|
||||||
max_age_days: Number($('#gage').value) || 0,
|
max_age_days: Number($<HTMLInputElement>('#gage').value) || 0,
|
||||||
art_cache_mb: Math.max(0, Number($('#gart').value) || 0)})});
|
art_cache_mb: Math.max(0, Number($<HTMLInputElement>('#gart').value) || 0)})});
|
||||||
toast('Settings saved');
|
toast('Settings saved');
|
||||||
}catch(e){ toast(e.message, true); }
|
}catch(e){ toast(why(e), true); }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,15 +99,15 @@ async function drawDirectorySettings(){
|
|||||||
await api('/api/settings', {method: 'PATCH', body: JSON.stringify(patch)});
|
await api('/api/settings', {method: 'PATCH', body: JSON.stringify(patch)});
|
||||||
toast(said);
|
toast(said);
|
||||||
drawDirectorySettings();
|
drawDirectorySettings();
|
||||||
}catch(e){ toast(e.message, true); }
|
}catch(e){ toast(why(e), true); }
|
||||||
};
|
};
|
||||||
$('#dsave').onclick = () => {
|
$<HTMLButtonElement>('#dsave').onclick = () => {
|
||||||
const patch: Record<string, unknown> = {directory_url: $('#durl').value.trim(), directory_share: $('#dshare').checked};
|
const patch: Record<string, unknown> = {directory_url: $<HTMLInputElement>('#durl').value.trim(), directory_share: $<HTMLInputElement>('#dshare').checked};
|
||||||
// Blank leaves the token as it is; Clear is the way to remove it.
|
// Blank leaves the token as it is; Clear is the way to remove it.
|
||||||
if($('#dtoken').value.trim()) patch.directory_token = $('#dtoken').value.trim();
|
if($<HTMLInputElement>('#dtoken').value.trim()) patch.directory_token = $<HTMLInputElement>('#dtoken').value.trim();
|
||||||
save(patch, 'Directory settings saved');
|
save(patch, 'Directory settings saved');
|
||||||
};
|
};
|
||||||
const clear = $('#dclear');
|
const clear = $<HTMLButtonElement>('#dclear');
|
||||||
if(clear) clear.onclick = () => save({directory_token: ''}, 'Token cleared');
|
if(clear) clear.onclick = () => save({directory_token: ''}, 'Token cleared');
|
||||||
drawDirectoryStatus();
|
drawDirectoryStatus();
|
||||||
}
|
}
|
||||||
@@ -115,7 +115,7 @@ async function drawDirectorySettings(){
|
|||||||
async function drawDirectoryStatus(){
|
async function drawDirectoryStatus(){
|
||||||
const el = $('#dstatus');
|
const el = $('#dstatus');
|
||||||
let s;
|
let s;
|
||||||
try{ s = await api('/api/directory-status'); }catch(e){ el.textContent = e.message; return; }
|
try{ s = await api('/api/directory-status'); }catch(e){ el.textContent = why(e); return; }
|
||||||
if(!el.isConnected) return;
|
if(!el.isConnected) return;
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
if(!s.configured) lines.push('Not set up: it needs an address and a token.');
|
if(!s.configured) lines.push('Not set up: it needs an address and a token.');
|
||||||
@@ -137,8 +137,8 @@ async function drawDirectoryStatus(){
|
|||||||
/// sharing with it, and the feeds removed from it.
|
/// sharing with it, and the feeds removed from it.
|
||||||
async function drawDirectoryAdmin(){
|
async function drawDirectoryAdmin(){
|
||||||
const box = $('#dadmin');
|
const box = $('#dadmin');
|
||||||
let a;
|
let a: DirAdmin;
|
||||||
try{ a = await api('/api/directory-admin'); }catch(e){ box.innerHTML = `<p class="hint">${esc(e.message)}</p>`; return; }
|
try{ a = await api('/api/directory-admin'); }catch(e){ box.innerHTML = `<p class="hint">${esc(why(e))}</p>`; return; }
|
||||||
if(!box.isConnected) return;
|
if(!box.isConnected) return;
|
||||||
box.innerHTML = `<h2>Servers</h2>
|
box.innerHTML = `<h2>Servers</h2>
|
||||||
<p class="hint">Each server sharing with the directory, by its token's name. Dropping one removes its
|
<p class="hint">Each server sharing with the directory, by its token's name. Dropping one removes its
|
||||||
@@ -155,29 +155,29 @@ async function drawDirectoryAdmin(){
|
|||||||
<small style="display:block;color:var(--faint)">Removed ${dateOf(b.at)}</small></div>
|
<small style="display:block;color:var(--faint)">Removed ${dateOf(b.at)}</small></div>
|
||||||
<button class="btn" data-a="unblock">Unblock</button></div>`).join('')
|
<button class="btn" data-a="unblock">Unblock</button></div>`).join('')
|
||||||
: '<p class="hint">Nothing is blocked.</p>'}`;
|
: '<p class="hint">Nothing is blocked.</p>'}`;
|
||||||
box.onclick = async ev => {
|
box.onclick = async (ev: MouseEvent) => {
|
||||||
const b = (ev.target as Element).closest<HTMLElement>('[data-a]');
|
const b = (ev.target as Element).closest<HTMLElement>('[data-a]');
|
||||||
if(!b) return;
|
if(!b) return;
|
||||||
const row = b.closest<HTMLElement>('.urow');
|
const row = b.closest<HTMLElement>('.urow');
|
||||||
try{
|
try{
|
||||||
if(b.dataset.a === 'drop'){
|
if(b.dataset.a === 'drop'){
|
||||||
const name = row.dataset.name;
|
const name = row!.dataset.name!;
|
||||||
if(!confirm(`Drop ${name}'s feeds and counts from the directory? They come back the next time ${name} shares.`)) return;
|
if(!confirm(`Drop ${name}'s feeds and counts from the directory? They come back the next time ${name} shares.`)) return;
|
||||||
await api(`/api/directory-instances/${encodeURIComponent(name)}`, {method: 'DELETE'});
|
await api(`/api/directory-instances/${encodeURIComponent(name)}`, {method: 'DELETE'});
|
||||||
toast(`Dropped ${name}'s snapshot`);
|
toast(`Dropped ${name}'s snapshot`);
|
||||||
}else if(b.dataset.a === 'unblock'){
|
}else if(b.dataset.a === 'unblock'){
|
||||||
await api(`/api/directory-blocked/${encodeURIComponent(row.dataset.id)}`, {method: 'DELETE'});
|
await api(`/api/directory-blocked/${encodeURIComponent(row!.dataset.id!)}`, {method: 'DELETE'});
|
||||||
toast('Unblocked. It comes back with the next snapshot that has it');
|
toast('Unblocked. It comes back with the next snapshot that has it');
|
||||||
}
|
}
|
||||||
drawDirectoryAdmin();
|
drawDirectoryAdmin();
|
||||||
}catch(e){ toast(e.message, true); }
|
}catch(e){ toast(why(e), true); }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- accounts ---------------- */
|
/* ---------------- accounts ---------------- */
|
||||||
async function drawAccounts(){
|
async function drawAccounts(){
|
||||||
const box = $('#accounts');
|
const box = $('#accounts');
|
||||||
const users = await api('/api/users') || [];
|
const users: Account[] = await api('/api/users') || [];
|
||||||
box.innerHTML = `<h2>Accounts</h2>
|
box.innerHTML = `<h2>Accounts</h2>
|
||||||
${users.map(u => `<div class="inline urow" data-id="${u.id}">
|
${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>
|
<div style="flex:1;min-width:0"><b style="overflow-wrap:anywhere">${esc(u.name)}</b>
|
||||||
@@ -195,44 +195,44 @@ async function drawAccounts(){
|
|||||||
<span class="hint">At least 8 characters. Leave the password empty for someone who signs in
|
<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>
|
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>`;
|
<div class="cardacts"><button class="btn primary" id="uadd">${ICON.plus} Add</button></div>`;
|
||||||
const change = async (u, opts) => {
|
const change = async (u: Account, opts: RequestInit) => {
|
||||||
try{
|
try{
|
||||||
await api(`/api/users/${u.id}`, opts);
|
await api(`/api/users/${u.id}`, opts);
|
||||||
// Demoting yourself takes this page away; go back to the app rather than stay on a page
|
// 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.
|
// the server no longer answers. Only on success: a refusal's toast has to stay readable.
|
||||||
if(u.name === me?.name){ location.href = '/'; return; }
|
if(u.name === me?.name){ location.href = '/'; return; }
|
||||||
}catch(e){ toast(e.message, true); }
|
}catch(e){ toast(why(e), true); }
|
||||||
drawAccounts(); // on a refusal, this puts the checkbox back where the server left it
|
drawAccounts(); // on a refusal, this puts the checkbox back where the server left it
|
||||||
};
|
};
|
||||||
for(const row of $$('#accounts [data-id]')){
|
for(const row of $$('#accounts [data-id]')){
|
||||||
const u = users.find(x => String(x.id) === row.dataset.id);
|
const u = users.find(x => String(x.id) === row.dataset.id)!;
|
||||||
$('[data-a="admin"]', row).onchange = e =>
|
$('[data-a="admin"]', row).onchange = (e: Event) =>
|
||||||
change(u, {method: 'PATCH', body: JSON.stringify({admin: e.target.checked})});
|
change(u, {method: 'PATCH', body: JSON.stringify({admin: (e.target as HTMLInputElement).checked})});
|
||||||
$('[data-a="rm"]', row).onclick = () => {
|
$('[data-a="rm"]', row).onclick = () => {
|
||||||
if(confirm(`Remove ${u.name}? Their subscriptions and read state go with them. Downloaded files stay.`))
|
if(confirm(`Remove ${u.name}? Their subscriptions and read state go with them. Downloaded files stay.`))
|
||||||
change(u, {method: 'DELETE'});
|
change(u, {method: 'DELETE'});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
$('#uadd').onclick = async () => {
|
$<HTMLButtonElement>('#uadd').onclick = async () => {
|
||||||
try{
|
try{
|
||||||
await api('/api/users', {method: 'POST', body: JSON.stringify({
|
await api('/api/users', {method: 'POST', body: JSON.stringify({
|
||||||
name: $('#uname').value, password: $('#upass').value, admin: $('#uadmin').checked})});
|
name: $<HTMLInputElement>('#uname').value, password: $<HTMLInputElement>('#upass').value, admin: $<HTMLInputElement>('#uadmin').checked})});
|
||||||
toast('Added'); drawAccounts();
|
toast('Added'); drawAccounts();
|
||||||
}catch(e){ toast(e.message, true); } // keep what was typed
|
}catch(e){ toast(why(e), true); } // keep what was typed
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- log ---------------- */
|
/* ---------------- log ---------------- */
|
||||||
let logTimer = null, logSeq = 0, logLines = [], logFilter = '', logLevel = '', logTab = 'all';
|
let logTimer: number|null = null, logSeq = 0, logLines: LogLine[] = [], logFilter = '', logLevel = '', logTab = 'all';
|
||||||
// Which sources belong to each tab. "daemon" is the control protocol itself: every
|
// Which sources belong to each tab. "daemon" is the control protocol itself: every
|
||||||
// command in and every event out, whatever sent it.
|
// command in and every event out, whatever sent it.
|
||||||
const LOG_TABS = {
|
const LOG_TABS: Record<string, ((t: string) => boolean)|null> = {
|
||||||
all: null,
|
all: null,
|
||||||
daemon: t => t === 'ipx::io',
|
daemon: t => t === 'ipx::io',
|
||||||
scan: t => t === 'ipx::scan',
|
scan: t => t === 'ipx::scan',
|
||||||
web: t => t === 'ipx::http',
|
web: t => t === 'ipx::http',
|
||||||
};
|
};
|
||||||
const LEVELS = {ERROR: 3, WARN: 2, INFO: 1, DEBUG: 0, TRACE: 0};
|
const LEVELS: Record<string,number> = {ERROR: 3, WARN: 2, INFO: 1, DEBUG: 0, TRACE: 0};
|
||||||
|
|
||||||
function drawLogView(){
|
function drawLogView(){
|
||||||
logSeq = 0; logLines = [];
|
logSeq = 0; logLines = [];
|
||||||
@@ -259,14 +259,14 @@ function drawLogView(){
|
|||||||
The buffer keeps debug detail even when the terminal does not; <b>IPX_UI_LOG</b> changes
|
The buffer keeps debug detail even when the terminal does not; <b>IPX_UI_LOG</b> changes
|
||||||
what it captures.</span>`;
|
what it captures.</span>`;
|
||||||
for(const b of $$('#logtabs button')) b.onclick = () => {
|
for(const b of $$('#logtabs button')) b.onclick = () => {
|
||||||
logTab = b.dataset.t;
|
logTab = b.dataset.t!;
|
||||||
for(const x of $$('#logtabs button')) x.classList.toggle('on', x.dataset.t === logTab);
|
for(const x of $$('#logtabs button')) x.classList.toggle('on', x.dataset.t === logTab);
|
||||||
drawLog();
|
drawLog();
|
||||||
};
|
};
|
||||||
$('#loglevel').onchange = e => { logLevel = e.target.value; drawLog(); };
|
$<HTMLSelectElement>('#loglevel').onchange = (e: Event) => { logLevel = (e.target as HTMLSelectElement).value; drawLog(); };
|
||||||
$('#logq').oninput = e => { logFilter = e.target.value.toLowerCase(); drawLog(); };
|
$<HTMLInputElement>('#logq').oninput = (e: Event) => { logFilter = (e.target as HTMLInputElement).value.toLowerCase(); drawLog(); };
|
||||||
$('#logcopy').onclick = () => copyText(visibleLog().map(l =>
|
$<HTMLButtonElement>('#logcopy').onclick = () => copyText(visibleLog().map(l =>
|
||||||
`${new Date(l.ts * 1000).toISOString()} ${l.level} ${l.target} ${l.msg}`).join('\n'), $('#logcopy'));
|
`${new Date(l.ts * 1000).toISOString()} ${l.level} ${l.target} ${l.msg}`).join('\n'), $<HTMLButtonElement>('#logcopy'));
|
||||||
pollLog();
|
pollLog();
|
||||||
if(!logTimer) logTimer = setInterval(pollLog, 2000);
|
if(!logTimer) logTimer = setInterval(pollLog, 2000);
|
||||||
}
|
}
|
||||||
@@ -281,7 +281,7 @@ async function pollLog(){
|
|||||||
}else if(!logLines.length){ drawLog(); }
|
}else if(!logLines.length){ drawLog(); }
|
||||||
}catch(e){
|
}catch(e){
|
||||||
const box = $('#logbox');
|
const box = $('#logbox');
|
||||||
if(box) box.innerHTML = `<p class="empty">Lost contact with the daemon: ${esc(e.message)}</p>`;
|
if(box) box.innerHTML = `<p class="empty">Lost contact with the daemon: ${esc(why(e))}</p>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function visibleLog(){
|
function visibleLog(){
|
||||||
@@ -294,7 +294,7 @@ function visibleLog(){
|
|||||||
}
|
}
|
||||||
function drawLog(){
|
function drawLog(){
|
||||||
const box = $('#logbox'); if(!box) return;
|
const box = $('#logbox'); if(!box) return;
|
||||||
const follow = $('#logfollow')?.checked;
|
const follow = $<HTMLInputElement>('#logfollow')?.checked;
|
||||||
const rows = visibleLog();
|
const rows = visibleLog();
|
||||||
box.innerHTML = rows.length ? rows.map(l => {
|
box.innerHTML = rows.length ? rows.map(l => {
|
||||||
const t = new Date(l.ts * 1000).toLocaleTimeString();
|
const t = new Date(l.ts * 1000).toLocaleTimeString();
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ function closeModal(){
|
|||||||
$('#modal').classList.remove('on');
|
$('#modal').classList.remove('on');
|
||||||
}
|
}
|
||||||
|
|
||||||
$('#modal').onclick=e=>{ if(e.target.id==='modal') closeModal(); };
|
$('#modal').onclick=(e: MouseEvent)=>{ if((e.target as Element).id==='modal') closeModal(); };
|
||||||
|
|
||||||
$('#addFeed').onclick=()=>{
|
$<HTMLButtonElement>('#addFeed').onclick=()=>{
|
||||||
openModal(`<h3>Add a feed</h3>
|
openModal(`<h3>Add a feed</h3>
|
||||||
<div class="field"><label>Feed URL</label><input type="text" id="nurl" placeholder="https://example.com/rss">
|
<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>
|
<span class="hint">A Patreon token on its own adds every show from that creator.</span></div>
|
||||||
@@ -20,17 +20,17 @@ $('#addFeed').onclick=()=>{
|
|||||||
<label class="check"><input type="checkbox" id="nexp"> Allow items marked explicit</label>
|
<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>
|
<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>`);
|
<button class="btn ico primary" id="nsave" title="Add feed" aria-label="Add feed">${ICON.plus}</button></div>`);
|
||||||
$('#nurl').focus();
|
$<HTMLInputElement>('#nurl').focus();
|
||||||
$('#nsave').onclick=async()=>{
|
$<HTMLButtonElement>('#nsave').onclick=async()=>{
|
||||||
const url=$('#nurl').value.trim(); if(!url) return;
|
const url=$<HTMLInputElement>('#nurl').value.trim(); if(!url) return;
|
||||||
$('#nsave').disabled=true; $('#nsave').title='Adding…';
|
$<HTMLButtonElement>('#nsave').disabled=true; $<HTMLButtonElement>('#nsave').title='Adding…';
|
||||||
try{
|
try{
|
||||||
const r=await api('/api/feeds',{method:'POST',body:JSON.stringify({
|
const r=await api('/api/feeds',{method:'POST',body:JSON.stringify({
|
||||||
url, allow_explicit:$('#nexp').checked,
|
url, allow_explicit:$<HTMLInputElement>('#nexp').checked,
|
||||||
keywords:$('#nkw').value.split(',').map(s=>s.trim()).filter(Boolean)})});
|
keywords:$<HTMLInputElement>('#nkw').value.split(',').map((s: string)=>s.trim()).filter(Boolean)})});
|
||||||
closeModal(); toast(r.existing?`Already subscribed as ${r.id}`:`Added ${r.id}`);
|
closeModal(); toast(r.existing?`Already subscribed as ${r.id}`:`Added ${r.id}`);
|
||||||
await loadFeeds(true); selectFeed(r.id);
|
await loadFeeds(true); selectFeed(r.id);
|
||||||
}catch(e){ toast(e.message,true); $('#nsave').title='Add feed'; $('#nsave').disabled=false; }
|
}catch(e){ toast(why(e),true); $<HTMLButtonElement>('#nsave').title='Add feed'; $<HTMLButtonElement>('#nsave').disabled=false; }
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ let dirError='';
|
|||||||
/// One listed feed: a tile in the Directory's grid or, given its place, a row in its chart. The
|
/// One listed feed: a tile in the Directory's grid or, given its place, a row in its chart. The
|
||||||
/// parts are the same either way; the class lays them out. Yours opens its feed; any other, its
|
/// parts are the same either way; the class lays them out. Yours opens its feed; any other, its
|
||||||
/// page in the Directory, to see what it is before you take it (#128).
|
/// page in the Directory, to see what it is before you take it (#128).
|
||||||
function listedFeed(p,cls,rank?: number){
|
function listedFeed(p: DirFeed,cls: string,rank?: number){
|
||||||
const el=document.createElement('div');
|
const el=document.createElement('div');
|
||||||
el.className=cls+(p.subscribed?' mine':'');
|
el.className=cls+(p.subscribed?' mine':'');
|
||||||
el.tabIndex=0; el.dataset.id=p.id;
|
el.tabIndex=0; el.dataset.id=p.id;
|
||||||
@@ -64,47 +64,47 @@ function listedFeed(p,cls,rank?: number){
|
|||||||
if(p.subscribed){ el.onclick=()=>selectFeed(p.feed||p.id); return el; }
|
if(p.subscribed){ el.onclick=()=>selectFeed(p.feed||p.id); return el; }
|
||||||
el.onclick=()=>{ dirShow=p.id; $('#content').scrollTop=0; drawDirectory(undefined,'.dback'); };
|
el.onclick=()=>{ dirShow=p.id; $('#content').scrollTop=0; drawDirectory(undefined,'.dback'); };
|
||||||
// Its own click, not the tile's as well.
|
// Its own click, not the tile's as well.
|
||||||
$('[data-a="sub"]',el).onclick=ev=>{ ev.stopPropagation(); subscribeListed(p); };
|
$('[data-a="sub"]',el).onclick=(ev: Event)=>{ ev.stopPropagation(); subscribeListed(p); };
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function subscribeListed(p){
|
async function subscribeListed(p: DirFeed){
|
||||||
try{
|
try{
|
||||||
const r=await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'});
|
const r=await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'});
|
||||||
toast(`Subscribed to ${NAME(p)}`);
|
toast(`Subscribed to ${NAME(p)}`);
|
||||||
await loadFeeds(true); selectFeed(r.id);
|
await loadFeeds(true); selectFeed(r.id);
|
||||||
}catch(e){ toast(e.message,true); }
|
}catch(e){ toast(why(e),true); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Where you are in the Directory: what kind of feed, which category and subcategory, in what
|
// Where you are in the Directory: what kind of feed, which category and subcategory, in what
|
||||||
// order. Out here because a search redraws the page, and the kind and order hold across visits.
|
// order. Out here because a search redraws the page, and the kind and order hold across visits.
|
||||||
let dirKind='All', dirCat=null, dirSub=null, dirSort='az', dirRows=[];
|
let dirKind='All', dirCat: string|null=null, dirSub: string|null=null, dirSort='az', dirRows: DirFeed[]=[];
|
||||||
// The feed whose page is open over all that, by id; going back leaves the rest as it was.
|
// The feed whose page is open over all that, by id; going back leaves the rest as it was.
|
||||||
let dirShow=null;
|
let dirShow: string|null=null;
|
||||||
// The two pages that are not a category: every feed, and the feeds that name none. A category is
|
// The two pages that are not a category: every feed, and the feeds that name none. A category is
|
||||||
// Apple's or one an admin typed, neither of which starts with a colon.
|
// Apple's or one an admin typed, neither of which starts with a colon.
|
||||||
const EVERY=':every', OTHER=':other';
|
const EVERY=':every', OTHER=':other';
|
||||||
const NAME=p=>(p.title||p.id);
|
const NAME=(p: {title: string|null, id: string})=>(p.title||p.id);
|
||||||
const byName=(a,b)=>NAME(a).localeCompare(NAME(b),undefined,{sensitivity:'base',numeric:true});
|
const byName=(a: DirFeed,b: DirFeed)=>NAME(a).localeCompare(NAME(b),undefined,{sensitivity:'base',numeric:true});
|
||||||
const bySubs=(a,b)=>b.subscribers-a.subscribers||byName(a,b);
|
const bySubs=(a: DirFeed,b: DirFeed)=>b.subscribers-a.subscribers||byName(a,b);
|
||||||
/// The grid's orders. It opens A to Z, as the server sends it.
|
/// The grid's orders. It opens A to Z, as the server sends it.
|
||||||
const DIR_SORTS={
|
const DIR_SORTS: Record<string,[string,(a: DirFeed,b: DirFeed)=>number]>={
|
||||||
subscribers:['Most subscribers',bySubs],
|
subscribers:['Most subscribers',bySubs],
|
||||||
az:['Name, A to Z',byName],
|
az:['Name, A to Z',byName],
|
||||||
za:['Name, Z to A',(a,b)=>byName(b,a)],
|
za:['Name, Z to A',(a: DirFeed,b: DirFeed)=>byName(b,a)],
|
||||||
};
|
};
|
||||||
const KINDS={All:()=>true,Podcasts:p=>p.podcast,Blogs:p=>!p.podcast};
|
const KINDS: Record<string,(p: DirFeed)=>boolean>={All:()=>true,Podcasts:p=>p.podcast,Blogs:p=>!p.podcast};
|
||||||
const KIND_WORD={All:'feed',Podcasts:'podcast',Blogs:'blog'};
|
const KIND_WORD: Record<string,string>={All:'feed',Podcasts:'podcast',Blogs:'blog'};
|
||||||
|
|
||||||
/// The Directory, as Apple's is laid out: it opens on the most subscribed feeds and the
|
/// The Directory, as Apple's is laid out: it opens on the most subscribed feeds and the
|
||||||
/// categories, and a category has a page of its own, its subcategories along the top of its
|
/// categories, and a category has a page of its own, its subcategories along the top of its
|
||||||
/// grid. What a feed is (Podcasts, anything with audio or video, or Blogs, the rest) holds across
|
/// grid. What a feed is (Podcasts, anything with audio or video, or Blogs, the rest) holds across
|
||||||
/// every page, and the search box looks through the lot. One fetch draws all of it.
|
/// every page, and the search box looks through the lot. One fetch draws all of it.
|
||||||
async function renderDirectory(url,box){
|
async function renderDirectory(url: string,box: HTMLElement){
|
||||||
// Each visit starts at the front, as going back to a tab does.
|
// Each visit starts at the front, as going back to a tab does.
|
||||||
dirCat=null; dirSub=null; dirShow=null;
|
dirCat=null; dirSub=null; dirShow=null;
|
||||||
dirError='';
|
dirError='';
|
||||||
try{ dirRows=await api(url)||[]; }catch(e){ dirRows=[]; dirError=e.message; }
|
try{ dirRows=await api(url)||[]; }catch(e){ dirRows=[]; dirError=why(e); }
|
||||||
drawDirectory(box);
|
drawDirectory(box);
|
||||||
return dirRows.length;
|
return dirRows.length;
|
||||||
}
|
}
|
||||||
@@ -117,24 +117,24 @@ function drawDirectory(box=$('#directory'), focus?: string){
|
|||||||
const both=dirRows.some(KINDS.Podcasts)&&dirRows.some(KINDS.Blogs);
|
const both=dirRows.some(KINDS.Podcasts)&&dirRows.some(KINDS.Blogs);
|
||||||
if(!both) dirKind='All';
|
if(!both) dirKind='All';
|
||||||
const rows=dirRows.filter(KINDS[dirKind]), word=KIND_WORD[dirKind];
|
const rows=dirRows.filter(KINDS[dirKind]), word=KIND_WORD[dirKind];
|
||||||
const count=n=>`${n.toLocaleString()} ${word}${n===1?'':'s'}`;
|
const count=(n: number)=>`${n.toLocaleString()} ${word}${n===1?'':'s'}`;
|
||||||
const of=c=>p=>c===EVERY||(c===OTHER?!p.category:p.category===c);
|
const of=(c: string)=>(p: DirFeed)=>c===EVERY||(c===OTHER?!p.category:p.category===c);
|
||||||
// A kind can leave a category with nothing in it; that goes back to the front.
|
// A kind can leave a category with nothing in it; that goes back to the front.
|
||||||
if(dirCat&&!rows.some(of(dirCat))) dirCat=null;
|
if(dirCat&&!rows.some(of(dirCat))) dirCat=null;
|
||||||
const catName=c=>c===EVERY?`All ${word}s`:c===OTHER?'Other':c;
|
const catName=(c: string)=>c===EVERY?`All ${word}s`:c===OTHER?'Other':c;
|
||||||
const top=list=>list.filter(p=>p.subscribers).sort(bySubs).slice(0,10);
|
const top=(list: DirFeed[])=>list.filter(p=>p.subscribers).sort(bySubs).slice(0,10);
|
||||||
const btn=(k,v,label,on)=>`<button type="button" data-${k}="${esc(v)}" aria-pressed="${on}"${on?' class="on"':''}>${label}</button>`;
|
const btn=(k: string,v: string,label: string,on: boolean)=>`<button type="button" data-${k}="${esc(v)}" aria-pressed="${on}"${on?' class="on"':''}>${label}</button>`;
|
||||||
// back: where its button goes, '' for the front; none on the front itself.
|
// back: where its button goes, '' for the front; none on the front itself.
|
||||||
const head=(title,sub,back?)=>`<div class="dhead">${back==null?'':
|
const head=(title: string,sub: string,back?: string)=>`<div class="dhead">${back==null?'':
|
||||||
`<button type="button" class="dback" data-go="${esc(back)}">${ICON.left}${esc(back?catName(back):'Directory')}</button>`}
|
`<button type="button" class="dback" data-go="${esc(back)}">${ICON.left}${esc(back?catName(back):'Directory')}</button>`}
|
||||||
<div class="dtitle"><h2>${esc(title)}</h2>${both?`<div class="tabs" role="group" aria-label="Kind">${
|
<div class="dtitle"><h2>${esc(title)}</h2>${both?`<div class="tabs" role="group" aria-label="Kind">${
|
||||||
Object.keys(KINDS).map(k=>btn('kind',k,k,k===dirKind)).join('')}</div>`:''}</div>
|
Object.keys(KINDS).map(k=>btn('kind',k,k,k===dirKind)).join('')}</div>`:''}</div>
|
||||||
<p class="sub">${esc(sub)}</p></div>`;
|
<p class="sub">${esc(sub)}</p></div>`;
|
||||||
const sec=(title,body,extra='')=>`<section class="dsec">${title?`<div class="dsech"><h3>${esc(title)}</h3>${extra}</div>`:''}${body}</section>`;
|
const sec=(title: string,body: string,extra='')=>`<section class="dsec">${title?`<div class="dsech"><h3>${esc(title)}</h3>${extra}</div>`:''}${body}</section>`;
|
||||||
const tools=(pills='')=>`<div class="dtools">${pills||'<span class="grow"></span>'}<select id="dirsort" aria-label="Sort">${
|
const tools=(pills='')=>`<div class="dtools">${pills||'<span class="grow"></span>'}<select id="dirsort" aria-label="Sort">${
|
||||||
Object.entries(DIR_SORTS).map(([k,[label]])=>`<option value="${k}"${k===dirSort?' selected':''}>${label}</option>`).join('')}</select></div>`;
|
Object.entries(DIR_SORTS).map(([k,[label]])=>`<option value="${k}"${k===dirSort?' selected':''}>${label}</option>`).join('')}</select></div>`;
|
||||||
const q=S.q.trim(), grid='<div class="tiles" id="dtiles"></div>', board='<div class="chart" id="dchart"></div>';
|
const q=S.q.trim(), grid='<div class="tiles" id="dtiles"></div>', board='<div class="chart" id="dchart"></div>';
|
||||||
let html, chart=[], tiles=null;
|
let html: string, chart: DirFeed[]=[], tiles: DirFeed[]|null=null;
|
||||||
const show=dirShow&&dirRows.find(p=>p.id===dirShow);
|
const show=dirShow&&dirRows.find(p=>p.id===dirShow);
|
||||||
if(show){
|
if(show){
|
||||||
const n=show.subscribers;
|
const n=show.subscribers;
|
||||||
@@ -154,21 +154,21 @@ function drawDirectory(box=$('#directory'), focus?: string){
|
|||||||
html=head(`Results for “${q}”`,tiles.length?count(tiles.length):`No ${word} here has that in its name.`,dirCat||'')+tools()+grid;
|
html=head(`Results for “${q}”`,tiles.length?count(tiles.length):`No ${word} here has that in its name.`,dirCat||'')+tools()+grid;
|
||||||
}else if(!dirCat){
|
}else if(!dirCat){
|
||||||
chart=top(rows);
|
chart=top(rows);
|
||||||
const cats=new Map<string,any[]>();
|
const cats=new Map<string,DirFeed[]>();
|
||||||
for(const p of rows){ const c=p.category||OTHER; if(!cats.has(c)) cats.set(c,[]); cats.get(c).push(p); }
|
for(const p of rows){ const c=p.category||OTHER; if(!cats.has(c)) cats.set(c,[]); cats.get(c)!.push(p); }
|
||||||
// Other, the feeds with no category, goes last.
|
// Other, the feeds with no category, goes last.
|
||||||
const order=[...cats.keys()].sort((a,b)=>Number(a===OTHER)-Number(b===OTHER)||a.localeCompare(b));
|
const order=[...cats.keys()].sort((a,b)=>Number(a===OTHER)-Number(b===OTHER)||a.localeCompare(b));
|
||||||
html=head('Directory',`${count(rows.length)} shared by iPX servers. Private feeds are never listed.`)
|
html=head('Directory',`${count(rows.length)} shared by iPX servers. Private feeds are never listed.`)
|
||||||
+(chart.length?sec('Most subscribed',board):'')
|
+(chart.length?sec('Most subscribed',board):'')
|
||||||
+sec('Categories',`<div class="cats">${order.map(c=>catTile(c,catName(c),count(cats.get(c).length),cats.get(c))).join('')}</div>`,
|
+sec('Categories',`<div class="cats">${order.map(c=>catTile(c,catName(c),count(cats.get(c)!.length),cats.get(c)!)).join('')}</div>`,
|
||||||
`<button type="button" class="dall" data-go="${EVERY}">See all ${rows.length.toLocaleString()}</button>`);
|
`<button type="button" class="dall" data-go="${EVERY}">See all ${rows.length.toLocaleString()}</button>`);
|
||||||
}else{
|
}else{
|
||||||
const inCat=rows.filter(of(dirCat));
|
const inCat=rows.filter(of(dirCat));
|
||||||
const subs=dirCat===EVERY?[]:[...new Set(inCat.map(p=>p.subcategory).filter(Boolean))].sort();
|
const subs=dirCat===EVERY?[]:[...new Set(inCat.map(p=>p.subcategory).filter((s): s is string=>!!s))].sort();
|
||||||
if(!subs.includes(dirSub)) dirSub=null;
|
if(!dirSub||!subs.includes(dirSub)) dirSub=null;
|
||||||
if(dirCat!==EVERY) chart=top(inCat);
|
if(dirCat!==EVERY) chart=top(inCat);
|
||||||
tiles=inCat.filter(p=>!dirSub||p.subcategory===dirSub);
|
tiles=inCat.filter(p=>!dirSub||p.subcategory===dirSub);
|
||||||
const pill=(v,label,n)=>btn('sub',v,`${esc(label)}<small>${n}</small>`,(dirSub||'')===v);
|
const pill=(v: string,label: string,n: number)=>btn('sub',v,`${esc(label)}<small>${n}</small>`,(dirSub||'')===v);
|
||||||
html=head(catName(dirCat),dirCat===OTHER?`${count(inCat.length)} that name no category`:count(inCat.length),'')
|
html=head(catName(dirCat),dirCat===OTHER?`${count(inCat.length)} that name no category`:count(inCat.length),'')
|
||||||
+(chart.length?sec('Most subscribed',board):'')
|
+(chart.length?sec('Most subscribed',board):'')
|
||||||
// Its own heading only when the chart is above it, to say where one list ends.
|
// Its own heading only when the chart is above it, to say where one list ends.
|
||||||
@@ -178,25 +178,25 @@ function drawDirectory(box=$('#directory'), focus?: string){
|
|||||||
box.innerHTML=html;
|
box.innerHTML=html;
|
||||||
if(show){ $('.show [data-a="sub"]',box).onclick=()=>subscribeListed(show); fillShow(box,show); }
|
if(show){ $('.show [data-a="sub"]',box).onclick=()=>subscribeListed(show); fillShow(box,show); }
|
||||||
const c=$('#dchart',box);
|
const c=$('#dchart',box);
|
||||||
if(c){ c.style.setProperty('--rows',String(Math.ceil(chart.length/2))); chart.forEach((p,i)=>c.appendChild(listedFeed(p,'rank',i+1))); }
|
if(c){ c.style.setProperty('--rows',String(Math.ceil(chart.length/2))); chart.forEach((p: DirFeed,i: number)=>c.appendChild(listedFeed(p,'rank',i+1))); }
|
||||||
const t=$('#dtiles',box);
|
const t=$('#dtiles',box);
|
||||||
if(t) for(const p of tiles.sort(DIR_SORTS[dirSort][1])) t.appendChild(listedFeed(p,'tile'));
|
if(t&&tiles) for(const p of tiles.sort(DIR_SORTS[dirSort][1])) t.appendChild(listedFeed(p,'tile'));
|
||||||
box.onclick=ev=>{
|
box.onclick=(ev: MouseEvent)=>{
|
||||||
const b=(ev.target as Element).closest<HTMLElement>('[data-go],[data-kind],[data-sub],[data-back]');
|
const b=(ev.target as Element).closest<HTMLElement>('[data-go],[data-kind],[data-sub],[data-back]');
|
||||||
if(!b) return;
|
if(!b) return;
|
||||||
// Back from a feed's page, onto the tile or row it was opened from.
|
// Back from a feed's page, onto the tile or row it was opened from.
|
||||||
if(b.dataset.back!=null){ const id=dirShow; dirShow=null; return drawDirectory(box,`[data-id="${CSS.escape(id)}"]`); }
|
if(b.dataset.back!=null){ const id=dirShow??''; dirShow=null; return drawDirectory(box,`[data-id="${CSS.escape(id)}"]`); }
|
||||||
if(b.dataset.kind!=null){ dirKind=b.dataset.kind; return drawDirectory(box,`[data-kind="${CSS.escape(dirKind)}"]`); }
|
if(b.dataset.kind!=null){ dirKind=b.dataset.kind; return drawDirectory(box,`[data-kind="${CSS.escape(dirKind)}"]`); }
|
||||||
if(b.dataset.sub!=null){ dirSub=b.dataset.sub||null; return drawDirectory(box,`[data-sub="${CSS.escape(b.dataset.sub)}"]`); }
|
if(b.dataset.sub!=null){ dirSub=b.dataset.sub||null; return drawDirectory(box,`[data-sub="${CSS.escape(b.dataset.sub)}"]`); }
|
||||||
// Going anywhere ends a search.
|
// Going anywhere ends a search.
|
||||||
if(S.q){ S.q=''; $('#epSearch').value=''; }
|
if(S.q){ S.q=''; $<HTMLInputElement>('#epSearch').value=''; }
|
||||||
const from=dirCat;
|
const from=dirCat;
|
||||||
dirCat=b.dataset.go||null; dirSub=null; dirShow=null;
|
dirCat=b.dataset.go||null; dirSub=null; dirShow=null;
|
||||||
$('#content').scrollTop=0;
|
$('#content').scrollTop=0;
|
||||||
// Into a page, onto its way back; back out, onto the tile you went in by.
|
// Into a page, onto its way back; back out, onto the tile you went in by.
|
||||||
drawDirectory(box,dirCat?'.dback':from?`[data-go="${CSS.escape(from)}"]:not(.dback)`:undefined);
|
drawDirectory(box,dirCat?'.dback':from?`[data-go="${CSS.escape(from)}"]:not(.dback)`:undefined);
|
||||||
};
|
};
|
||||||
const s=$('#dirsort',box);
|
const s=$<HTMLSelectElement>('#dirsort',box);
|
||||||
if(s) s.onchange=()=>{ dirSort=s.value; drawDirectory(box,'#dirsort'); };
|
if(s) s.onchange=()=>{ dirSort=s.value; drawDirectory(box,'#dirsort'); };
|
||||||
if(focus) $(focus,box)?.focus();
|
if(focus) $(focus,box)?.focus();
|
||||||
}
|
}
|
||||||
@@ -204,11 +204,11 @@ function drawDirectory(box=$('#directory'), focus?: string){
|
|||||||
/// A listed feed's page, filled in: what it says it is, its first lines unless you ask for the
|
/// A listed feed's page, filled in: what it says it is, its first lines unless you ask for the
|
||||||
/// rest, and its latest items, each one's title, opening its post on the publisher's site where
|
/// rest, and its latest items, each one's title, opening its post on the publisher's site where
|
||||||
/// it has one, its first lines, and when and how long.
|
/// it has one, its first lines, and when and how long.
|
||||||
async function fillShow(box,p){
|
async function fillShow(box: HTMLElement,p: DirFeed){
|
||||||
const list=$('#dshow',box), about=$('#dabout',box);
|
const list=$('#dshow',box), about=$('#dabout',box);
|
||||||
let got;
|
let got: {description: string|null, failing: string|null, items: ListedItem[], can_admin: boolean};
|
||||||
try{ got=await api(`/api/directory/${encodeURIComponent(p.id)}`); }
|
try{ got=await api(`/api/directory/${encodeURIComponent(p.id)}`); }
|
||||||
catch(e){ list.innerHTML=`<p class="hint">${esc(e.message)}</p>`; return; }
|
catch(e){ list.innerHTML=`<p class="hint">${esc(why(e))}</p>`; return; }
|
||||||
// Gone back, or on to another, while it loaded.
|
// Gone back, or on to another, while it loaded.
|
||||||
if(dirShow!==p.id||!list.isConnected) return;
|
if(dirShow!==p.id||!list.isConnected) return;
|
||||||
// Some feeds give their title again as their description, which says nothing new.
|
// Some feeds give their title again as their description, which says nothing new.
|
||||||
@@ -233,12 +233,12 @@ async function fillShow(box,p){
|
|||||||
+`<button class="btn ico danger" data-a="dremove" title="Remove from the directory" aria-label="Remove from the directory">${ICON.trash}</button>`);
|
+`<button class="btn ico danger" data-a="dremove" title="Remove from the directory" aria-label="Remove from the directory">${ICON.trash}</button>`);
|
||||||
$('[data-a="drefresh"]',acts).onclick=async()=>{
|
$('[data-a="drefresh"]',acts).onclick=async()=>{
|
||||||
try{ await api(`/api/directory/${encodeURIComponent(p.id)}/refresh`,{method:'POST'}); toast('Checked'); fillShow(box,p); }
|
try{ await api(`/api/directory/${encodeURIComponent(p.id)}/refresh`,{method:'POST'}); toast('Checked'); fillShow(box,p); }
|
||||||
catch(e){ toast(e.message,true); }
|
catch(e){ toast(why(e),true); }
|
||||||
};
|
};
|
||||||
$('[data-a="dremove"]',acts).onclick=async()=>{
|
$('[data-a="dremove"]',acts).onclick=async()=>{
|
||||||
if(!confirm(`Remove ${NAME(p)} from the directory? It is blocked, so no server's snapshot brings it back. Admin, Directory can unblock it.`)) return;
|
if(!confirm(`Remove ${NAME(p)} from the directory? It is blocked, so no server's snapshot brings it back. Admin, Directory can unblock it.`)) return;
|
||||||
try{ await api(`/api/directory/${encodeURIComponent(p.id)}`,{method:'DELETE'}); }
|
try{ await api(`/api/directory/${encodeURIComponent(p.id)}`,{method:'DELETE'}); }
|
||||||
catch(e){ return toast(e.message,true); }
|
catch(e){ return toast(why(e),true); }
|
||||||
toast(`Removed ${NAME(p)} from the directory`);
|
toast(`Removed ${NAME(p)} from the directory`);
|
||||||
dirRows=dirRows.filter(r=>r.id!==p.id); dirShow=null;
|
dirRows=dirRows.filter(r=>r.id!==p.id); dirShow=null;
|
||||||
drawDirectory(box);
|
drawDirectory(box);
|
||||||
@@ -256,7 +256,7 @@ async function fillShow(box,p){
|
|||||||
|
|
||||||
/// A category as a tile: its name, how many feeds, and the covers of its three most subscribed
|
/// A category as a tile: its name, how many feeds, and the covers of its three most subscribed
|
||||||
/// fanned in the corner, so each one is coloured by what is in it, as Apple's are.
|
/// fanned in the corner, so each one is coloured by what is in it, as Apple's are.
|
||||||
function catTile(c,name,count,rows){
|
function catTile(c: string,name: string,count: string,rows: DirFeed[]){
|
||||||
// Covers before initials, which are all Other had; the likeliest first, and first is in front:
|
// Covers before initials, which are all Other had; the likeliest first, and first is in front:
|
||||||
// the fan is drawn back to front.
|
// the fan is drawn back to front.
|
||||||
const art=[...rows].sort((a,b)=>Number(!a.image)-Number(!b.image)||bySubs(a,b)).slice(0,3).reverse();
|
const art=[...rows].sort((a,b)=>Number(!a.image)-Number(!b.image)||bySubs(a,b)).slice(0,3).reverse();
|
||||||
@@ -265,20 +265,20 @@ function catTile(c,name,count,rows){
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The Directory and Currently Listening open in the main pane, as the original's Directory did.
|
/// The Directory and Currently Listening open in the main pane, as the original's Directory did.
|
||||||
async function renderListed(v){
|
async function renderListed(v: {title: string, icon: string, url?: string, blurb?: string}){
|
||||||
const box=$('#content');
|
const box=$('#content');
|
||||||
box.classList.add('plain');
|
box.classList.add('plain');
|
||||||
$('#tbRemove').disabled=true;
|
$<HTMLButtonElement>('#tbRemove').disabled=true;
|
||||||
syncTools(null);
|
syncTools(null);
|
||||||
if(v===VIEWS[':directory']){
|
if(v===VIEWS[':directory']){
|
||||||
$('#epSearch').placeholder='Find a feed…';
|
$<HTMLInputElement>('#epSearch').placeholder='Find a feed…';
|
||||||
box.innerHTML='<div id="directory"><p class="hint">Loading…</p></div>';
|
box.innerHTML='<div id="directory"><p class="hint">Loading…</p></div>';
|
||||||
$('#count').textContent=v.title;
|
$('#count').textContent=v.title;
|
||||||
const n=await renderDirectory(v.url,$('#directory'));
|
const n=await renderDirectory(v.url!,$('#directory'));
|
||||||
if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${plural(n,'feed')}`;
|
if(VIEWS[S.feed ?? '']===v) $('#count').textContent=`${v.title}: ${plural(n,'feed')}`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$('#epSearch').placeholder='Search items…';
|
$<HTMLInputElement>('#epSearch').placeholder='Search items…';
|
||||||
box.innerHTML=`
|
box.innerHTML=`
|
||||||
<div class="fhead slim">
|
<div class="fhead slim">
|
||||||
<div class="art">${v.icon}</div>
|
<div class="art">${v.icon}</div>
|
||||||
@@ -287,21 +287,21 @@ async function renderListed(v){
|
|||||||
<div class="childlist" id="listening"><p class="hint">Loading…</p></div>`;
|
<div class="childlist" id="listening"><p class="hint">Loading…</p></div>`;
|
||||||
$('#count').textContent=v.title;
|
$('#count').textContent=v.title;
|
||||||
// The search box looks through these too, title and text, as it does a feed's items (#127).
|
// The search box looks through these too, title and text, as it does a feed's items (#127).
|
||||||
const n=await renderListening(S.q?`${v.url}&q=${encodeURIComponent(S.q)}`:v.url,$('#listening',box));
|
const n=await renderListening(S.q?`${v.url}&q=${encodeURIComponent(S.q)}`:v.url!,$('#listening',box));
|
||||||
if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${plural(n,'episode')}`;
|
if(VIEWS[S.feed ?? '']===v) $('#count').textContent=`${v.title}: ${plural(n,'episode')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Currently Listening: episodes you started and have not finished, across every feed you
|
/// 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
|
/// 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.
|
/// where you left off, not another way to browse. The one in the player pauses instead.
|
||||||
async function renderListening(url,box){
|
async function renderListening(url: string,box: HTMLElement){
|
||||||
let rows=[];
|
let rows: Entry[]=[];
|
||||||
try{ rows=(await api(url)).entries||[]; }catch{}
|
try{ rows=(await api(url)).entries||[]; }catch{}
|
||||||
box.innerHTML=rows.length?'':S.q?`<p class="hint">Nothing you are listening to mentions “${esc(S.q)}”.</p>`
|
box.innerHTML=rows.length?'':S.q?`<p class="hint">Nothing you are listening to mentions “${esc(S.q)}”.</p>`
|
||||||
:'<p class="hint">Nothing in progress. Episodes you start and do not finish show up here.</p>';
|
:'<p class="hint">Nothing in progress. Episodes you start and do not finish show up here.</p>';
|
||||||
for(const e of rows){
|
for(const e of rows){
|
||||||
// Carries its episode, for paintListenRow to repaint as the player moves.
|
// Carries its episode, for paintListenRow to repaint as the player moves.
|
||||||
const el: HTMLDivElement & {entry?: any}=document.createElement('div');
|
const el: HTMLDivElement & {entry?: Entry}=document.createElement('div');
|
||||||
el.className='childrow';
|
el.className='childrow';
|
||||||
el.entry=e;
|
el.entry=e;
|
||||||
el.innerHTML=artHTML(e.image||feedArt(e.feed_id),e.title||'')+
|
el.innerHTML=artHTML(e.image||feedArt(e.feed_id),e.title||'')+
|
||||||
@@ -319,8 +319,8 @@ async function renderListening(url,box){
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// One row's time left, progress and play button, taken from the player when it is the one in it.
|
/// One row's time left, progress and play button, taken from the player when it is the one in it.
|
||||||
function paintListenRow(el){
|
function paintListenRow(el: HTMLElement & {entry?: Entry}){
|
||||||
const e=el.entry, now=player.guid===e.guid&&player.feed===e.feed_id;
|
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.
|
// 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);
|
if(now&&audio.currentTime) e.position=Math.floor(audio.currentTime);
|
||||||
// The player's own length first: a feed's can be minutes out.
|
// The player's own length first: a feed's can be minutes out.
|
||||||
@@ -335,32 +335,32 @@ function paintListenRow(el){
|
|||||||
}
|
}
|
||||||
/// Keeps the list in step with the player. Only a row that is, or was, the one in it changes.
|
/// Keeps the list in step with the player. Only a row that is, or was, the one in it changes.
|
||||||
function syncListening(){
|
function syncListening(){
|
||||||
for(const el of $$('#listening .childrow'))
|
for(const el of $$<HTMLElement & {entry?: Entry}>('#listening .childrow'))
|
||||||
if(el.entry&&(el.classList.contains('now')||player.guid===el.entry.guid)) paintListenRow(el);
|
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
|
/// 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.
|
/// episode with a saved position short of the end, so the position is what has to go.
|
||||||
async function forget(e){
|
async function forget(e: Entry){
|
||||||
// Closed without saving first, or the player's next save would put it straight back.
|
// 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(); }
|
if(player.guid===e.guid){ player.guid=null; $<HTMLButtonElement>('#pclose').click(); }
|
||||||
try{
|
try{
|
||||||
await api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/position`,
|
await api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/position`,
|
||||||
{method:'POST',body:JSON.stringify({secs:0})});
|
{method:'POST',body:JSON.stringify({secs:0})});
|
||||||
}catch(err){ toast(err.message,true); }
|
}catch(err){ toast(why(err),true); }
|
||||||
if(S.feed===':listening') renderListed(VIEWS[':listening']);
|
if(S.feed===':listening') renderListed(VIEWS[':listening']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The toolbar acts on whatever is selected: the feed on the left, the item in the table.
|
// 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); };
|
$<HTMLButtonElement>('#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); };
|
$<HTMLButtonElement>('#tbPlay').onclick=()=>{ const e=cur(); if(e) play(e); };
|
||||||
$('#tbRead').onclick=()=>{ const e=cur(); if(e) epAction('read',e,null); };
|
$<HTMLButtonElement>('#tbRead').onclick=()=>{ const e=cur(); if(e) epAction('read',e,null); };
|
||||||
$('#tbFlag').onclick=()=>{ const e=cur(); if(e) epAction('flag',e,null); };
|
$<HTMLButtonElement>('#tbFlag').onclick=()=>{ const e=cur(); if(e) epAction('flag',e,null); };
|
||||||
let searchT;
|
let searchT: number;
|
||||||
$('#epSearch').oninput=ev=>{ clearTimeout(searchT);
|
$<HTMLInputElement>('#epSearch').oninput=(ev: Event)=>{ clearTimeout(searchT);
|
||||||
// The Directory looks through what it already has; Currently Listening and the rest ask the
|
// The Directory looks through what it already has; Currently Listening and the rest ask the
|
||||||
// server (#126, #127).
|
// server (#126, #127).
|
||||||
searchT=setTimeout(()=>{ S.q=ev.target.value; S.offset=0;
|
searchT=setTimeout(()=>{ S.q=(ev.target as HTMLInputElement).value; S.offset=0;
|
||||||
if(S.feed===':directory'){ dirShow=null; drawDirectory(); }
|
if(S.feed===':directory'){ dirShow=null; drawDirectory(); }
|
||||||
else if(S.feed===':listening') renderListed(VIEWS[':listening']);
|
else if(S.feed===':listening') renderListed(VIEWS[':listening']);
|
||||||
else loadEntries(); },250); };
|
else loadEntries(); },250); };
|
||||||
@@ -368,13 +368,13 @@ $('#epSearch').oninput=ev=>{ clearTimeout(searchT);
|
|||||||
window.matchMedia?.('(max-width:820px)')?.addEventListener?.('change',()=>{ const e=cur(); if(e) showDetail(e); });
|
window.matchMedia?.('(max-width:820px)')?.addEventListener?.('change',()=>{ const e=cur(); if(e) showDetail(e); });
|
||||||
|
|
||||||
let expanded = new Set(JSON.parse(localStorage.getItem('ipx.expanded')||'[]'));
|
let expanded = new Set(JSON.parse(localStorage.getItem('ipx.expanded')||'[]'));
|
||||||
function toggleGroup(id){
|
function toggleGroup(id: string){
|
||||||
expanded.has(id) ? expanded.delete(id) : expanded.add(id);
|
expanded.has(id) ? expanded.delete(id) : expanded.add(id);
|
||||||
try{ localStorage.setItem('ipx.expanded', JSON.stringify([...expanded])); }catch{}
|
try{ localStorage.setItem('ipx.expanded', JSON.stringify([...expanded])); }catch{}
|
||||||
renderFeeds();
|
renderFeeds();
|
||||||
}
|
}
|
||||||
let globalMax = 3;
|
let globalMax = 3;
|
||||||
function due(ts){
|
function due(ts: number){
|
||||||
const d = ts - Date.now()/1000;
|
const d = ts - Date.now()/1000;
|
||||||
if(d <= 0) return 'due now';
|
if(d <= 0) return 'due now';
|
||||||
if(d < 3600) return 'in '+Math.max(1,Math.round(d/60))+'m';
|
if(d < 3600) return 'in '+Math.max(1,Math.round(d/60))+'m';
|
||||||
@@ -385,7 +385,7 @@ function due(ts){
|
|||||||
/// Your settings: the theme, your subscriptions as OPML, and, to read, what the server does
|
/// 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
|
/// 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.
|
/// admin is sent (issue #19); this used to hold them too, shown to admins only.
|
||||||
function deadSavedText(me){
|
function deadSavedText(me: Me|null){
|
||||||
const s=me&&me.deadair_saved||0;
|
const s=me&&me.deadair_saved||0;
|
||||||
return s?`It has saved you ${spanText(s)} of dead air, on episodes you heard three quarters of or more.`
|
return s?`It has saved you ${spanText(s)} of dead air, on episodes you heard three quarters of or more.`
|
||||||
:'Turn it on in a show\'s settings, or beside the speed while it plays, to cut the dead air from it.';
|
:'Turn it on in a show\'s settings, or beside the speed while it plays, to cut the dead air from it.';
|
||||||
@@ -429,45 +429,45 @@ async function prefsModal(){
|
|||||||
<div class="field"><label>Feeds are checked every</label>
|
<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.
|
<span class="hint">${everyText(g.every_mins)}, for every feed that does not set its own.
|
||||||
${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></div>`);
|
${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></div>`);
|
||||||
$('#stheme').onchange=e=>setTheme(e.target.value,undefined,true);
|
$<HTMLSelectElement>('#stheme').onchange=(e: Event)=>setTheme((e.target as HTMLSelectElement).value,undefined,true);
|
||||||
$('#smode').onchange=e=>setTheme(undefined,e.target.value,true);
|
$<HTMLSelectElement>('#smode').onchange=(e: Event)=>setTheme(undefined,(e.target as HTMLSelectElement).value,true);
|
||||||
$('#gopml').onclick=opmlModal;
|
$<HTMLButtonElement>('#gopml').onclick=opmlModal;
|
||||||
drawTokens();
|
drawTokens();
|
||||||
$('#tokadd').onclick=async()=>{
|
$<HTMLButtonElement>('#tokadd').onclick=async()=>{
|
||||||
const name=$('#tokname').value.trim(); if(!name){ $('#tokname').focus(); return; }
|
const name=$<HTMLInputElement>('#tokname').value.trim(); if(!name){ $<HTMLInputElement>('#tokname').focus(); return; }
|
||||||
try{
|
try{
|
||||||
const t=await api('/api/tokens',{method:'POST',body:JSON.stringify({name})});
|
const t=await api('/api/tokens',{method:'POST',body:JSON.stringify({name})});
|
||||||
$('#tokname').value='';
|
$<HTMLInputElement>('#tokname').value='';
|
||||||
await drawTokens();
|
await drawTokens();
|
||||||
// Shown this once: only its hash is kept, so a lost token is revoked and made again.
|
// Shown this once: only its hash is kept, so a lost token is revoked and made again.
|
||||||
$('#tokens').insertAdjacentHTML('afterbegin',`<div class="field" id="toknew"><span class="hint">Your new token,
|
$('#tokens').insertAdjacentHTML('afterbegin',`<div class="field" id="toknew"><span class="hint">Your new token,
|
||||||
${esc(t.name)}. Copy it now: it is not shown again.</span>
|
${esc(t.name)}. Copy it now: it is not shown again.</span>
|
||||||
<div class="inline"><input type="text" id="tokval" readonly value="${esc(t.token)}">
|
<div class="inline"><input type="text" id="tokval" readonly value="${esc(t.token)}">
|
||||||
<button class="btn ico" id="tokcopy" title="Copy" aria-label="Copy">${ICON.copy}</button></div></div>`);
|
<button class="btn ico" id="tokcopy" title="Copy" aria-label="Copy">${ICON.copy}</button></div></div>`);
|
||||||
$('#tokcopy').onclick=()=>copyText(t.token,$('#tokcopy'));
|
$<HTMLButtonElement>('#tokcopy').onclick=()=>copyText(t.token,$<HTMLButtonElement>('#tokcopy'));
|
||||||
$('#tokval').select();
|
$<HTMLInputElement>('#tokval').select();
|
||||||
}catch(err){ toast(err.message,true); }
|
}catch(err){ toast(why(err),true); }
|
||||||
};
|
};
|
||||||
$('#sblock').onchange=async e=>{
|
$<HTMLInputElement>('#sblock').onchange=async (e: Event)=>{
|
||||||
const blocked=splitWords(e.target.value);
|
const blocked=splitWords((e.target as HTMLInputElement).value);
|
||||||
try{
|
try{
|
||||||
await api('/api/me',{method:'PATCH',body:JSON.stringify({blocked})});
|
await api('/api/me',{method:'PATCH',body:JSON.stringify({blocked})});
|
||||||
if(S.me) S.me.blocked=blocked;
|
if(S.me) S.me.blocked=blocked;
|
||||||
toast('Saved'); await loadFeeds(true); loadEntries();
|
toast('Saved'); await loadFeeds(true); loadEntries();
|
||||||
}catch(err){ toast(err.message,true); }
|
}catch(err){ toast(why(err),true); }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Your API tokens, each with when it was made and last used, and a button to revoke it.
|
/// Your API tokens, each with when it was made and last used, and a button to revoke it.
|
||||||
async function drawTokens(){
|
async function drawTokens(){
|
||||||
const box=$('#tokens'); if(!box) return;
|
const box=$('#tokens'); if(!box) return;
|
||||||
const list=await api('/api/tokens').catch(()=>[]);
|
const list: {id: number, name: string, created: number, last_used: number|null}[]=await api('/api/tokens').catch((): []=>[]);
|
||||||
box.innerHTML=list.map(t=>`<div class="tokrow"><span><b>${esc(t.name)}</b>
|
box.innerHTML=list.map(t=>`<div class="tokrow"><span><b>${esc(t.name)}</b>
|
||||||
<span class="hint">made ${dateOf(t.created)}, last used ${ago(t.last_used)}</span></span>
|
<span class="hint">made ${dateOf(t.created)}, last used ${ago(t.last_used)}</span></span>
|
||||||
<button class="btn ico" data-tok="${t.id}" title="Revoke ${esc(t.name)}" aria-label="Revoke ${esc(t.name)}">${ICON.trash}</button></div>`).join('');
|
<button class="btn ico" data-tok="${t.id}" title="Revoke ${esc(t.name)}" aria-label="Revoke ${esc(t.name)}">${ICON.trash}</button></div>`).join('');
|
||||||
for(const b of $$('[data-tok]',box)) b.onclick=async()=>{
|
for(const b of $$('[data-tok]',box)) b.onclick=async()=>{
|
||||||
try{ await api(`/api/tokens/${b.dataset.tok}`,{method:'DELETE'}); toast('Revoked'); drawTokens(); }
|
try{ await api(`/api/tokens/${b.dataset.tok}`,{method:'DELETE'}); toast('Revoked'); drawTokens(); }
|
||||||
catch(err){ toast(err.message,true); }
|
catch(err){ toast(why(err),true); }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,37 +486,37 @@ function chipsHTML(words: string[], input: string){
|
|||||||
placeholder="${words.length?'':CHIP_HINT}" enterkeyhint="done" autocomplete="off" spellcheck="false">`;
|
placeholder="${words.length?'':CHIP_HINT}" enterkeyhint="done" autocomplete="off" spellcheck="false">`;
|
||||||
}
|
}
|
||||||
/// The words in a chip box, with whatever is typed and not yet a chip.
|
/// The words in a chip box, with whatever is typed and not yet a chip.
|
||||||
function chipWords(box){
|
function chipWords(box: ParentNode){
|
||||||
return [...$$('.chip',box).map(c=>c.dataset.w), ...splitWords($('.chipin',box).value)];
|
return [...$$('.chip',box).map(c=>c.dataset.w), ...splitWords($<HTMLInputElement>('.chipin',box).value)];
|
||||||
}
|
}
|
||||||
function wireChips(box){
|
function wireChips(box: HTMLElement){
|
||||||
const input=$('.chipin',box);
|
const input=$<HTMLInputElement>('.chipin',box);
|
||||||
// The hint only in an empty box: beside chips it reads as one more.
|
// The hint only in an empty box: beside chips it reads as one more.
|
||||||
const hint=()=>{ input.placeholder=$$('.chip',box).length?'':CHIP_HINT; };
|
const hint=()=>{ input.placeholder=$$('.chip',box).length?'':CHIP_HINT; };
|
||||||
const add=()=>{
|
const add=()=>{
|
||||||
const have=$$('.chip',box).map(c=>c.dataset.w.toLowerCase());
|
const have=$$('.chip',box).map(c=>c.dataset.w!.toLowerCase());
|
||||||
for(const w of splitWords(input.value)){
|
for(const w of splitWords(input.value)){
|
||||||
if(have.includes(w.toLowerCase())) continue;
|
if(have.includes(w.toLowerCase())) continue;
|
||||||
input.insertAdjacentHTML('beforebegin',chipHTML(w)); have.push(w.toLowerCase());
|
input.insertAdjacentHTML('beforebegin',chipHTML(w)); have.push(w.toLowerCase());
|
||||||
}
|
}
|
||||||
input.value=''; hint();
|
input.value=''; hint();
|
||||||
};
|
};
|
||||||
input.onkeydown=e=>{
|
input.onkeydown=(e: KeyboardEvent)=>{
|
||||||
if(e.key==='Enter'||e.key===','){ e.preventDefault(); add(); }
|
if(e.key==='Enter'||e.key===','){ e.preventDefault(); add(); }
|
||||||
else if(e.key==='Backspace'&&!input.value){ input.previousElementSibling?.remove(); hint(); }
|
else if(e.key==='Backspace'&&!input.value){ input.previousElementSibling?.remove(); hint(); }
|
||||||
};
|
};
|
||||||
// Typed and left without Enter is still meant: Save takes it either way.
|
// Typed and left without Enter is still meant: Save takes it either way.
|
||||||
input.onblur=add;
|
input.onblur=add;
|
||||||
box.onclick=e=>{
|
box.onclick=(e: MouseEvent)=>{
|
||||||
const x=e.target.closest('.chipx');
|
const x=(e.target as Element).closest('.chipx');
|
||||||
if(x){ x.parentElement.remove(); hint(); }
|
if(x){ x.parentElement!.remove(); hint(); }
|
||||||
input.focus();
|
input.focus();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Your settings for one feed, under its address (#167). What it says is what will happen, in this feed's numbers: the download limit was a bare
|
/// Your settings for one feed, under its address (#167). What it says is what will happen, in this feed's numbers: the download limit was a bare
|
||||||
/// box whose blank, number and 0 were explained in bold small print.
|
/// box whose blank, number and 0 were explained in bold small print.
|
||||||
function settingsModal(f, newUrl?: string){
|
function settingsModal(f: Feed, newUrl?: string){
|
||||||
const kids=S.feeds.filter(c=>c.group===f.id).length;
|
const kids=S.feeds.filter(c=>c.group===f.id).length;
|
||||||
const isGroup=kids>0;
|
const isGroup=kids>0;
|
||||||
const parent=f.managed?S.feeds.find(p=>p.id===f.group):null;
|
const parent=f.managed?S.feeds.find(p=>p.id===f.group):null;
|
||||||
@@ -524,7 +524,7 @@ function settingsModal(f, newUrl?: string){
|
|||||||
const name=f.title||f.id;
|
const name=f.title||f.id;
|
||||||
// Blank follows the server's default; 0 is every item (#166).
|
// Blank follows the server's default; 0 is every item (#166).
|
||||||
let mode=f.max_new_per_check==null?'default':f.max_new_per_check===0?'all':'newest';
|
let mode=f.max_new_per_check==null?'default':f.max_new_per_check===0?'all':'newest';
|
||||||
let newest=f.max_new_per_check>0?f.max_new_per_check:(globalMax||3);
|
let newest=f.max_new_per_check&&f.max_new_per_check>0?f.max_new_per_check:(globalMax||3);
|
||||||
const whose=isGroup
|
const whose=isGroup
|
||||||
? `Your settings for the ${plural(kids,'show')} in this ${isPatreon(f)?'Patreon creator':'OPML subscription'}. A show you set on its own keeps its own.`
|
? `Your settings for the ${plural(kids,'show')} in this ${isPatreon(f)?'Patreon creator':'OPML subscription'}. A show you set on its own keeps its own.`
|
||||||
: parent
|
: parent
|
||||||
@@ -576,7 +576,7 @@ function settingsModal(f, newUrl?: string){
|
|||||||
// What only matters while it downloads on its own folds away, out of reach, when it does not.
|
// What only matters while it downloads on its own folds away, out of reach, when it does not.
|
||||||
const deps=$('#sdeps'), inner=$('.fdepsin',deps);
|
const deps=$('#sdeps'), inner=$('.fdepsin',deps);
|
||||||
inner.inert=!f.auto_download;
|
inner.inert=!f.auto_download;
|
||||||
$('#sauto').onchange=e=>{ deps.classList.toggle('off',!e.target.checked); inner.inert=!e.target.checked; };
|
$<HTMLInputElement>('#sauto').onchange=(e: Event)=>{ deps.classList.toggle('off',!(e.target as HTMLInputElement).checked); inner.inert=!(e.target as HTMLInputElement).checked; };
|
||||||
|
|
||||||
// The download limit, said as what it does. A number in a box hid that it is a window on the
|
// The download limit, said as what it does. A number in a box hid that it is a window on the
|
||||||
// newest items, not a pace through the back catalogue (#97).
|
// newest items, not a pace through the back catalogue (#97).
|
||||||
@@ -591,7 +591,7 @@ function settingsModal(f, newUrl?: string){
|
|||||||
? `The server's default: downloads the newest ${globalMax}${each} on its own.${older}`
|
? `The server's default: downloads the newest ${globalMax}${each} on its own.${older}`
|
||||||
: `The server's default: downloads every item${each}, the whole back catalogue.`;
|
: `The server's default: downloads every item${each}, the whole back catalogue.`;
|
||||||
const limit=()=>mode==='all'||(mode==='default'&&!globalMax)?Infinity:mode==='default'?globalMax:newest;
|
const limit=()=>mode==='all'||(mode==='default'&&!globalMax)?Infinity:mode==='default'?globalMax:newest;
|
||||||
let bars=[];
|
let bars: HTMLElement[]=[];
|
||||||
const draw=(words: boolean)=>{
|
const draw=(words: boolean)=>{
|
||||||
// The newest items with a file, as hold_back counts them: one whose files a filter all
|
// The newest items with a file, as hold_back counts them: one whose files a filter all
|
||||||
// skipped holds no place.
|
// skipped holds no place.
|
||||||
@@ -605,21 +605,21 @@ function settingsModal(f, newUrl?: string){
|
|||||||
}
|
}
|
||||||
if(!words) return;
|
if(!words) return;
|
||||||
$('#swcap').innerHTML=caption();
|
$('#swcap').innerHTML=caption();
|
||||||
const n=$('#smax');
|
const n=$<HTMLInputElement>('#smax');
|
||||||
if(n) n.oninput=()=>{ newest=Math.max(1,Math.floor(Number(n.value))||1); draw(false); };
|
if(n) n.oninput=()=>{ newest=Math.max(1,Math.floor(Number(n.value))||1); draw(false); };
|
||||||
};
|
};
|
||||||
for(const r of $$('[name=swin]')) r.onchange=()=>{ mode=r.value; draw(true); };
|
for(const r of $$<HTMLInputElement>('[name=swin]')) r.onchange=()=>{ mode=r.value; draw(true); };
|
||||||
// Breathing room only matters with DeadAir on: greyed out, not hidden, so it can be seen beforehand.
|
// Breathing room only matters with DeadAir on: greyed out, not hidden, so it can be seen beforehand.
|
||||||
$('#sdead').onchange=e=>{ $('#sdpause').disabled=!e.target.checked; };
|
$<HTMLInputElement>('#sdead').onchange=(e: Event)=>{ $<HTMLInputElement>('#sdpause').disabled=!(e.target as HTMLInputElement).checked; };
|
||||||
const pause=$('#sdpause'), pauseText=()=>{ const s=`${pause.value/1000} s`; $('#sdpauseout').textContent=s; pause.setAttribute('aria-valuetext',s); };
|
const pause=$<HTMLInputElement>('#sdpause'), pauseText=()=>{ const s=`${Number(pause.value)/1000} s`; $('#sdpauseout').textContent=s; pause.setAttribute('aria-valuetext',s); };
|
||||||
pause.oninput=pauseText; pauseText();
|
pause.oninput=pauseText; pauseText();
|
||||||
draw(true);
|
draw(true);
|
||||||
// The feed's own newest items, each as tall as it is long, so the strip reads as this show.
|
// The feed's own newest items, each as tall as it is long, so the strip reads as this show.
|
||||||
if(!isGroup) api(`/api/feeds/${encodeURIComponent(f.id)}/entries?limit=40`).then(page=>{
|
if(!isGroup) api(`/api/feeds/${encodeURIComponent(f.id)}/entries?limit=40`).then(page=>{
|
||||||
const items=page?.entries||[];
|
const items: Entry[]=page?.entries||[];
|
||||||
const strip=$('#sepwin'); if(!items.length||!strip) return;
|
const strip=$('#sepwin'); if(!items.length||!strip) return;
|
||||||
const longest=Math.max(1,...items.map(e=>e.duration||0));
|
const longest=Math.max(1,...items.map(e=>e.duration||0));
|
||||||
strip.innerHTML=items.map((e,i)=>{
|
strip.innerHTML=items.map((e: Entry,i: number)=>{
|
||||||
const file=e.enclosures.some(x=>x.state!=='skipped');
|
const file=e.enclosures.some(x=>x.state!=='skipped');
|
||||||
const h=e.duration?Math.round(30+70*Math.sqrt(e.duration/longest)):60;
|
const h=e.duration?Math.round(30+70*Math.sqrt(e.duration/longest)):60;
|
||||||
return `<i class="${file?'':'nofile'}" style="--h:${h}%;--n:${i}"></i>`;
|
return `<i class="${file?'':'nofile'}" style="--h:${h}%;--n:${i}"></i>`;
|
||||||
@@ -634,65 +634,65 @@ function settingsModal(f, newUrl?: string){
|
|||||||
if(x<=b.getBoundingClientRect().right) break;
|
if(x<=b.getBoundingClientRect().right) break;
|
||||||
}
|
}
|
||||||
newest=Math.max(1,n);
|
newest=Math.max(1,n);
|
||||||
if(mode!=='newest'){ mode='newest'; $('[name=swin][value=newest]').checked=true; draw(true); }
|
if(mode!=='newest'){ mode='newest'; $<HTMLInputElement>('[name=swin][value=newest]').checked=true; draw(true); }
|
||||||
else { $('#smax').value=String(newest); draw(false); }
|
else { $<HTMLInputElement>('#smax').value=String(newest); draw(false); }
|
||||||
};
|
};
|
||||||
strip.onpointerdown=e=>{ strip.setPointerCapture(e.pointerId); pick(e.clientX); };
|
strip.onpointerdown=(e: PointerEvent)=>{ strip.setPointerCapture(e.pointerId); pick(e.clientX); };
|
||||||
strip.onpointermove=e=>{ if(strip.hasPointerCapture(e.pointerId)) pick(e.clientX); };
|
strip.onpointermove=(e: PointerEvent)=>{ if(strip.hasPointerCapture(e.pointerId)) pick(e.clientX); };
|
||||||
}).catch(()=>{});
|
}).catch(()=>{});
|
||||||
|
|
||||||
wireChips($('#skw'));
|
wireChips($('#skw'));
|
||||||
if($('#sblock')) wireChips($('#sblock'));
|
if($('#sblock')) wireChips($('#sblock'));
|
||||||
$('#scopy').onclick=()=>copyText($('#surl').value,$('#scopy'));
|
$<HTMLButtonElement>('#scopy').onclick=()=>copyText($<HTMLInputElement>('#surl').value,$<HTMLButtonElement>('#scopy'));
|
||||||
// Opened to follow a feed that moved: the new address is what there is to look at.
|
// Opened to follow a feed that moved: the new address is what there is to look at.
|
||||||
if(newUrl){ $('#surl').scrollIntoView({block:'center'}); $('#surl').focus(); }
|
if(newUrl){ $<HTMLInputElement>('#surl').scrollIntoView({block:'center'}); $<HTMLInputElement>('#surl').focus(); }
|
||||||
|
|
||||||
$('#ssave').onclick=async()=>{
|
$<HTMLButtonElement>('#ssave').onclick=async()=>{
|
||||||
try{
|
try{
|
||||||
const patch: Record<string, unknown>={
|
const patch: Record<string, unknown>={
|
||||||
keywords:chipWords($('#skw')),
|
keywords:chipWords($('#skw')),
|
||||||
max_new_per_check:mode==='default'?null:mode==='all'?0:newest,
|
max_new_per_check:mode==='default'?null:mode==='all'?0:newest,
|
||||||
auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked, deadair:$('#sdead').checked,
|
auto_download:$<HTMLInputElement>('#sauto').checked, allow_explicit:$<HTMLInputElement>('#sexp').checked, deadair:$<HTMLInputElement>('#sdead').checked,
|
||||||
deadair_pause:Number($('#sdpause').value)};
|
deadair_pause:Number($<HTMLInputElement>('#sdpause').value)};
|
||||||
if($('#sblock')) patch.blocked=chipWords($('#sblock'));
|
if($('#sblock')) patch.blocked=chipWords($('#sblock'));
|
||||||
// Sent only when it changed: anyone may mark a feed private, but only an admin unmark it.
|
// Sent only when it changed: anyone may mark a feed private, but only an admin unmark it.
|
||||||
const priv=$('#sprivate');
|
const priv=$<HTMLInputElement>('#sprivate');
|
||||||
if(!priv.disabled&&priv.checked!==!!f.private) patch.private=priv.checked;
|
if(!priv.disabled&&priv.checked!==!!f.private) patch.private=priv.checked;
|
||||||
// The address is the same for everyone: an admin's to change, for a feed that moved, and the
|
// The address is the same for everyone: an admin's to change, for a feed that moved, and the
|
||||||
// API refuses it from anyone else. The folder and category are not this dialog's at all.
|
// API refuses it from anyone else. The folder and category are not this dialog's at all.
|
||||||
if(admin) patch.url=$('#surl').value.trim();
|
if(admin) patch.url=$<HTMLInputElement>('#surl').value.trim();
|
||||||
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify(patch)});
|
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify(patch)});
|
||||||
closeModal(); toast('Saved. It applies from the next check.');
|
closeModal(); toast('Saved. It applies from the next check.');
|
||||||
await loadFeeds(true); renderFeed(); loadEntries();
|
await loadFeeds(true); renderFeed(); loadEntries();
|
||||||
}catch(e){ toast(e.message,true); }
|
}catch(e){ toast(why(e),true); }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadLatestModal(f){
|
function downloadLatestModal(f: Feed){
|
||||||
openModal(`<h3>Download latest items</h3>
|
openModal(`<h3>Download latest items</h3>
|
||||||
<div class="field"><label>How many of the newest undownloaded items?</label>
|
<div class="field"><label>How many of the newest undownloaded items?</label>
|
||||||
<input type="number" id="dcount" min="1" max="100" value="5">
|
<input type="number" id="dcount" min="1" max="100" value="5">
|
||||||
<span class="hint">Queued immediately, ignoring the per-scan limit.</span></div>
|
<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>
|
<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>`);
|
<button class="btn ico primary" id="dgo" title="Download" aria-label="Download">${ICON.download}</button></div>`);
|
||||||
$('#dgo').onclick=async()=>{
|
$<HTMLButtonElement>('#dgo').onclick=async()=>{
|
||||||
const n=Number($('#dcount').value)||5;
|
const n=Number($<HTMLInputElement>('#dcount').value)||5;
|
||||||
try{
|
try{
|
||||||
const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/download-latest`,
|
const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/download-latest`,
|
||||||
{method:'POST',body:JSON.stringify({count:n})});
|
{method:'POST',body:JSON.stringify({count:n})});
|
||||||
closeModal(); toast(`Queued ${r.queued} item${r.queued===1?'':'s'}`);
|
closeModal(); toast(`Queued ${r.queued} item${r.queued===1?'':'s'}`);
|
||||||
}catch(e){ toast(e.message,true); }
|
}catch(e){ toast(why(e),true); }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeFeed(f){
|
function removeFeed(f: Feed){
|
||||||
openModal(`<h3>Unsubscribe?</h3>
|
openModal(`<h3>Unsubscribe?</h3>
|
||||||
<p style="color:var(--dim)">Removes <b>${esc(f.title||f.id)}</b> from your feeds. Anyone else
|
<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.
|
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>
|
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>
|
<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>`);
|
<button class="btn ico danger" id="rgo" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button></div>`);
|
||||||
$('#rgo').onclick=async()=>{
|
$<HTMLButtonElement>('#rgo').onclick=async()=>{
|
||||||
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'DELETE'});
|
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'DELETE'});
|
||||||
closeModal(); toast('Unsubscribed'); S.feed=null;
|
closeModal(); toast('Unsubscribed'); S.feed=null;
|
||||||
await loadFeeds(); if(!S.feeds.length) renderFeed();
|
await loadFeeds(); if(!S.feeds.length) renderFeed();
|
||||||
@@ -711,11 +711,11 @@ function opmlModal(){
|
|||||||
<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>
|
<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>
|
<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>`);
|
<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()=>{
|
$<HTMLButtonElement>('#oimp').onclick=async()=>{
|
||||||
// A chosen file is read here and sent as text, so the server never stores it. Clearing
|
// 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.
|
// the picker lets go of it on this side too, whether it was refused or imported.
|
||||||
const pick=$('#opmlFile'), file=pick.files[0];
|
const pick=$<HTMLInputElement>('#opmlFile'), file=pick.files![0];
|
||||||
const xml=file ? await file.text() : $('#opmlText').value;
|
const xml=file ? await file.text() : $<HTMLTextAreaElement>('#opmlText').value;
|
||||||
const letGo=()=>{ pick.value=''; };
|
const letGo=()=>{ pick.value=''; };
|
||||||
// A quick look before sending anything. The server parses it properly and has the last word.
|
// A quick look before sending anything. The server parses it properly and has the last word.
|
||||||
if(!/<opml[\s>]/i.test(xml)){
|
if(!/<opml[\s>]/i.test(xml)){
|
||||||
@@ -724,17 +724,21 @@ function opmlModal(){
|
|||||||
try{
|
try{
|
||||||
const r=await api('/api/opml',{method:'POST',body:JSON.stringify({xml})});
|
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);
|
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); }
|
}catch(e){ letGo(); toast(why(e),true); }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// No toast: the spinners on the rows being checked say it (issue #37).
|
// No toast: the spinners on the rows being checked say it (issue #37).
|
||||||
async function scanAll(){ await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); }
|
async function scanAll(){
|
||||||
$('#scanAll').onclick=scanAll;
|
spinStart(null);
|
||||||
$('#prefs').onclick=prefsModal;
|
try{ await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); }
|
||||||
|
catch(e){ spinEnd(); throw e; }
|
||||||
|
}
|
||||||
|
$<HTMLButtonElement>('#scanAll').onclick=scanAll;
|
||||||
|
$<HTMLButtonElement>('#prefs').onclick=prefsModal;
|
||||||
// Someone the proxy signed in is signed out by the proxy: ipx's own sign-out cannot stick while
|
// Someone the proxy signed in is signed out by the proxy: ipx's own sign-out cannot stick while
|
||||||
// the proxy still vouches for them. /api/me says where, when that is the case.
|
// 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'; };
|
$<HTMLButtonElement>('#signout').onclick=async()=>{ await api('/api/logout',{method:'POST'}); location.href=S.me?.sign_out||'/login'; };
|
||||||
api('/api/me').then(u=>{
|
api('/api/me').then(u=>{
|
||||||
S.me=u;
|
S.me=u;
|
||||||
$('#who').textContent=u.name+(u.admin?' · admin':'');
|
$('#who').textContent=u.name+(u.admin?' · admin':'');
|
||||||
@@ -746,18 +750,18 @@ api('/api/me').then(u=>{
|
|||||||
// A browser with no theme of its own takes the one the account kept from before, once.
|
// A browser with no theme of its own takes the one the account kept from before, once.
|
||||||
if(u.theme&&!/(^|; )ipx_theme=/.test(document.cookie)) setTheme(u.theme.name,u.theme.mode,true);
|
if(u.theme&&!/(^|; )ipx_theme=/.test(document.cookie)) setTheme(u.theme.name,u.theme.mode,true);
|
||||||
}).catch(()=>{});
|
}).catch(()=>{});
|
||||||
$('#feedFilter').oninput=renderFeeds;
|
$<HTMLInputElement>('#feedFilter').oninput=renderFeeds;
|
||||||
// The feed list from the keyboard: Enter or Space opens a row, Right and Left open and close a
|
// 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.
|
// folder. Handled keys stop here, or the player's own Space and arrows would act on them too.
|
||||||
$('#feedlist').onkeydown=ev=>{
|
$('#feedlist').onkeydown=(ev: KeyboardEvent)=>{
|
||||||
const row=ev.target.closest('[data-id]'); if(!row) return;
|
const row=(ev.target as Element).closest<HTMLElement>('[data-id]'); if(!row) return;
|
||||||
const id=row.dataset.id;
|
const id=row.dataset.id!;
|
||||||
if((ev.key==='Enter'||ev.key===' ')&&ev.target===row) row.click();
|
if((ev.key==='Enter'||ev.key===' ')&&ev.target===row) row.click();
|
||||||
else if(row.classList.contains('group')&&
|
else if(row.classList.contains('group')&&
|
||||||
(ev.key==='ArrowRight'&&!expanded.has(id)||ev.key==='ArrowLeft'&&expanded.has(id))) toggleGroup(id);
|
(ev.key==='ArrowRight'&&!expanded.has(id)||ev.key==='ArrowLeft'&&expanded.has(id))) toggleGroup(id);
|
||||||
else return;
|
else return;
|
||||||
ev.preventDefault(); ev.stopPropagation();
|
ev.preventDefault(); ev.stopPropagation();
|
||||||
};
|
};
|
||||||
$('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open'));
|
$<HTMLButtonElement>('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open'));
|
||||||
$('#scrim').onclick=()=>nav(false);
|
$('#scrim').onclick=()=>nav(false);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
/* ---------------- live events ---------------- */
|
/* ---------------- live events ---------------- */
|
||||||
let sse;
|
let sse: EventSource;
|
||||||
/// The feed list and the open list, taken again from the server. The events keep both current,
|
/// The feed list and the open list, taken again from the server. The events keep both current,
|
||||||
/// and one missed is a count wrong until a reload: reading an item sends none to your other
|
/// and one missed is a count wrong until a reload: reading an item sends none to your other
|
||||||
/// pages, so one read on another device stayed unread here, and an iPhone stops the stream while
|
/// pages, so one read on another device stayed unread here, and an iPhone stops the stream while
|
||||||
/// the app is in the background, when everything is missed (#168).
|
/// the app is in the background, when everything is missed (#168).
|
||||||
const catchUp=(()=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(()=>{
|
const catchUp=(()=>{ let t: number; return ()=>{ clearTimeout(t); t=setTimeout(()=>{
|
||||||
loadFeeds(true).catch(()=>{});
|
loadFeeds(true).catch(()=>{});
|
||||||
if(S.feed) loadEntries().catch(()=>{});
|
if(S.feed) loadEntries().catch(()=>{});
|
||||||
},300); }; })();
|
},300); }; })();
|
||||||
@@ -14,11 +14,11 @@ function connect(){
|
|||||||
sse=new EventSource('/api/events');
|
sse=new EventSource('/api/events');
|
||||||
// Back after a drop: whatever was sent meanwhile went to nobody.
|
// Back after a drop: whatever was sent meanwhile went to nobody.
|
||||||
sse.onopen=()=>{ if(dropped){ dropped=false; catchUp(); } };
|
sse.onopen=()=>{ if(dropped){ dropped=false; catchUp(); } };
|
||||||
const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
|
const soon=(fn: ()=>void,ms=500)=>{ let t: number; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
|
||||||
// Caught: a network gone when one fires (a laptop asleep) was an unhandled rejection (#219).
|
// Caught: a network gone when one fires (a laptop asleep) was an unhandled rejection (#219).
|
||||||
const refreshEntries=soon(()=>{ if(S.feed) loadEntries().catch(()=>{}); });
|
const refreshEntries=soon(()=>{ if(S.feed) loadEntries().catch(()=>{}); });
|
||||||
// Every scan's events reach everyone; only this person's feeds are theirs to show or refresh.
|
// Every scan's events reach everyone; only this person's feeds are theirs to show or refresh.
|
||||||
const mine=id=>S.feeds.some(f=>f.id===id);
|
const mine=(id: string)=>S.feeds.some(f=>f.id===id);
|
||||||
sse.onmessage=m=>{
|
sse.onmessage=m=>{
|
||||||
let ev; try{ ev=JSON.parse(m.data) }catch{ return }
|
let ev; try{ ev=JSON.parse(m.data) }catch{ return }
|
||||||
if(ev.ev==='progress'){
|
if(ev.ev==='progress'){
|
||||||
@@ -26,7 +26,7 @@ function connect(){
|
|||||||
// Only the row actually downloading. Without the enclosure id this used to paint
|
// 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.
|
// 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`);
|
const bar=document.querySelector<HTMLElement>(`.dlbar[data-bar="${ev.enclosure}"] i`);
|
||||||
if(bar){ bar.style.width=pct+'%'; bar.parentElement.classList.add('live'); }
|
if(bar){ bar.style.width=pct+'%'; bar.parentElement!.classList.add('live'); }
|
||||||
$('#count') && ($('#count').textContent=`downloading ${ev.file} — ${pct.toFixed(0)}%`);
|
$('#count') && ($('#count').textContent=`downloading ${ev.file} — ${pct.toFixed(0)}%`);
|
||||||
}
|
}
|
||||||
else if(ev.ev==='download_done'){
|
else if(ev.ev==='download_done'){
|
||||||
@@ -66,7 +66,9 @@ function connect(){
|
|||||||
// every open page reloaded the whole list each time (#103).
|
// every open page reloaded the whole list each time (#103).
|
||||||
// Rows came as feed_row events. ponytail: a feed an OPML drops stays in the list until the
|
// Rows came as feed_row events. ponytail: a feed an OPML drops stays in the list until the
|
||||||
// page reloads; reload the list here when a scan synced an OPML if that ever matters.
|
// page reloads; reload the list here when a scan synced an OPML if that ever matters.
|
||||||
else if(ev.ev==='scan_done'){ scanning.clear(); paintScanning(); if(ev.feeds) refreshEntries(); }
|
else if(ev.ev==='scan_done'){ scanning.clear(); paintScanning(); spinEnd(ev); if(ev.feeds) refreshEntries(); }
|
||||||
|
// The worker's job failed, so the scan someone is waiting on will send no scan_done.
|
||||||
|
else if(ev.ev==='error') spinEnd();
|
||||||
};
|
};
|
||||||
sse.onerror=()=>{ sse.close(); dropped=true; setTimeout(connect,4000); };
|
sse.onerror=()=>{ sse.close(); dropped=true; setTimeout(connect,4000); };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,16 @@ function renderFeed(){
|
|||||||
paintScanning(); // another feed's page may be the one open now
|
paintScanning(); // another feed's page may be the one open now
|
||||||
const box=$('#content');
|
const box=$('#content');
|
||||||
box.classList.remove('plain');
|
box.classList.remove('plain');
|
||||||
const v=VIEWS[S.feed];
|
const v=VIEWS[S.feed ?? ''];
|
||||||
if(v&&v.url) return renderListed(v);
|
if(v&&v.url) return renderListed(v);
|
||||||
const f=v?null:S.feeds.find(x=>x.id===S.feed);
|
const f=v?null:S.feeds.find(x=>x.id===S.feed);
|
||||||
$('#tbRemove').disabled=!f;
|
$<HTMLButtonElement>('#tbRemove').disabled=!f;
|
||||||
syncTools(null);
|
syncTools(null);
|
||||||
if(!v&&!f){ box.innerHTML='<p class="empty">Add a feed to get started.</p>'; $('#count').textContent=''; return; }
|
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;
|
const name=f?(f.title||f.id):v.title;
|
||||||
$('#epSearch').placeholder=`Search ${name}…`;
|
$<HTMLInputElement>('#epSearch').placeholder=`Search ${name}…`;
|
||||||
const kids=f?S.feeds.filter(c=>c.group===f.id):[];
|
const kids=f?S.feeds.filter(c=>c.group===f.id):[];
|
||||||
if(kids.length){ box.classList.add('plain'); renderGroup(f,kids); return; }
|
if(kids.length){ box.classList.add('plain'); renderGroup(f!,kids); return; }
|
||||||
const unreadAll=S.feeds.reduce((n,x)=>n+(x.unread||0),0);
|
const unreadAll=S.feeds.reduce((n,x)=>n+(x.unread||0),0);
|
||||||
box.innerHTML = (f ? `
|
box.innerHTML = (f ? `
|
||||||
<div class="fhead slim">
|
<div class="fhead slim">
|
||||||
@@ -61,7 +61,7 @@ function renderFeed(){
|
|||||||
if(f) pane.className='one';
|
if(f) pane.className='one';
|
||||||
pane.innerHTML='<div id="list">'+sortHead()+
|
pane.innerHTML='<div id="list">'+sortHead()+
|
||||||
'<div id="eps"></div></div><div id="files"></div><div id="grab"></div><div id="detail"></div>';
|
'<div id="eps"></div></div><div id="files"></div><div id="grab"></div><div id="detail"></div>';
|
||||||
$$('.ephead [data-sort]',pane).forEach(b=>b.onclick=()=>sortBy(b.dataset.sort));
|
$$('.ephead [data-sort]',pane).forEach(b=>b.onclick=()=>sortBy(b.dataset.sort!));
|
||||||
box.appendChild(pane);
|
box.appendChild(pane);
|
||||||
pane.style.setProperty('--listh', localStorage.getItem('ipx.listh') || '60%');
|
pane.style.setProperty('--listh', localStorage.getItem('ipx.listh') || '60%');
|
||||||
dragSplit(pane);
|
dragSplit(pane);
|
||||||
@@ -69,9 +69,9 @@ function renderFeed(){
|
|||||||
S.sel=null;
|
S.sel=null;
|
||||||
showDetail(null);
|
showDetail(null);
|
||||||
|
|
||||||
$$('#content .acts .btn').forEach(b=>b.onclick=()=>f?feedAction(b.dataset.a,f):allAction(b.dataset.a));
|
$$('#content .acts .btn').forEach(b=>b.onclick=()=>f?feedAction(b.dataset.a!,f):allAction(b.dataset.a!));
|
||||||
$$('#content .tabs button').forEach(b=>b.onclick=()=>{
|
$$('#content .tabs button').forEach(b=>b.onclick=()=>{
|
||||||
S.filter=b.dataset.f; S.offset=0;
|
S.filter=b.dataset.f!; S.offset=0;
|
||||||
try{ localStorage.setItem('ipx.filter',S.filter); }catch{}
|
try{ localStorage.setItem('ipx.filter',S.filter); }catch{}
|
||||||
renderFeed(); loadEntries();
|
renderFeed(); loadEntries();
|
||||||
});
|
});
|
||||||
@@ -89,7 +89,7 @@ function sortHead(){
|
|||||||
`${on?`<span class="arr ${S.sort.dir}">${ICON.caret}</span>`:''}</button>`;
|
`${on?`<span class="arr ${S.sort.dir}">${ICON.caret}</span>`:''}</button>`;
|
||||||
}).join('')+'<span></span></div>';
|
}).join('')+'<span></span></div>';
|
||||||
}
|
}
|
||||||
function sortBy(col){
|
function sortBy(col: string){
|
||||||
const first=['published','size','kept'].includes(col)?'desc':'asc';
|
const first=['published','size','kept'].includes(col)?'desc':'asc';
|
||||||
S.sort={col,dir:S.sort.col===col?(S.sort.dir==='asc'?'desc':'asc'):first};
|
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{}
|
try{ localStorage.setItem('ipx.sort',JSON.stringify(S.sort)); }catch{}
|
||||||
@@ -99,9 +99,9 @@ function sortBy(col){
|
|||||||
/// An OPML subscription's page lists the feeds inside it rather than items, but keeps
|
/// An OPML subscription's page lists the feeds inside it rather than items, but keeps
|
||||||
/// every action a normal feed has -- it is still an ordinary feed entry underneath.
|
/// every action a normal feed has -- it is still an ordinary feed entry underneath.
|
||||||
// A Patreon creator split into its shows is drawn like an OPML, and named for what it is.
|
// 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||'');
|
const isPatreon=(f: {url: string}|null)=>/patreon\.com\//.test(f&&f.url||'');
|
||||||
|
|
||||||
function renderGroup(f,kids){
|
function renderGroup(f: Feed,kids: Feed[]){
|
||||||
const unread=kids.reduce((n,c)=>n+c.unread,0);
|
const unread=kids.reduce((n,c)=>n+c.unread,0);
|
||||||
const saved=kids.reduce((n,c)=>n+c.downloaded,0);
|
const saved=kids.reduce((n,c)=>n+c.downloaded,0);
|
||||||
const gone=kids.filter(c=>c.orphaned).length;
|
const gone=kids.filter(c=>c.orphaned).length;
|
||||||
@@ -132,10 +132,10 @@ function renderGroup(f,kids){
|
|||||||
<span style="color:var(--faint);font-size:12.5px">${esc(f.url)}</span>
|
<span style="color:var(--faint);font-size:12.5px">${esc(f.url)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="childlist" id="kidlist"></div>`;
|
<div class="childlist" id="kidlist"></div>`;
|
||||||
$$('#content .acts .btn').forEach(b=>b.onclick=()=>feedAction(b.dataset.a,f));
|
$$('#content .acts .btn').forEach(b=>b.onclick=()=>feedAction(b.dataset.a!,f));
|
||||||
wireFailBanner($('#content'),f);
|
wireFailBanner($('#content'),f);
|
||||||
const draw=()=>{
|
const draw=()=>{
|
||||||
const q=($('#kidSearch').value||'').trim().toLowerCase();
|
const q=($<HTMLInputElement>('#kidSearch').value||'').trim().toLowerCase();
|
||||||
const box=$('#kidlist'); box.innerHTML='';
|
const box=$('#kidlist'); box.innerHTML='';
|
||||||
const rows=kids.filter(c=>!q||(c.title||c.id).toLowerCase().includes(q)).sort(unreadFirst);
|
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; }
|
if(!rows.length){ box.innerHTML='<p class="empty">Nothing matches.</p>'; return; }
|
||||||
@@ -154,26 +154,30 @@ function renderGroup(f,kids){
|
|||||||
box.appendChild(el);
|
box.appendChild(el);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
$('#kidSearch').oninput=draw;
|
$<HTMLInputElement>('#kidSearch').oninput=draw;
|
||||||
draw();
|
draw();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function feedAction(a,f){
|
async function feedAction(a: string,f: Feed){
|
||||||
if(a==='scan'){ await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); }
|
if(a==='scan'){
|
||||||
|
spinStart(f.id);
|
||||||
|
try{ await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); }
|
||||||
|
catch(e){ spinEnd(); throw e; }
|
||||||
|
}
|
||||||
if(a==='read'){ const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/read-all`,{method:'POST'}); toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries(); }
|
if(a==='read'){ const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/read-all`,{method:'POST'}); toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries(); }
|
||||||
if(a==='rm') removeFeed(f);
|
if(a==='rm') removeFeed(f);
|
||||||
if(a==='pin'){
|
if(a==='pin'){
|
||||||
try{
|
try{
|
||||||
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({pinned:!f.pinned})});
|
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({pinned:!f.pinned})});
|
||||||
await loadFeeds(true); renderFeed();
|
await loadFeeds(true); renderFeed();
|
||||||
}catch(e){ toast(e.message,true); }
|
}catch(e){ toast(why(e),true); }
|
||||||
}
|
}
|
||||||
if(a==='settings') settingsModal(f);
|
if(a==='settings') settingsModal(f);
|
||||||
if(a==='share') share(f.title||f.id, f.url, $('#content .acts [data-a="share"]'));
|
if(a==='share') share(f.title||f.id, f.url, $('#content .acts [data-a="share"]'));
|
||||||
if(a==='dl') downloadLatestModal(f);
|
if(a==='dl') downloadLatestModal(f);
|
||||||
}
|
}
|
||||||
/// All Subscriptions' own buttons: a feed's, across every feed you read.
|
/// All Subscriptions' own buttons: a feed's, across every feed you read.
|
||||||
async function allAction(a){
|
async function allAction(a: string){
|
||||||
if(a==='scanall') return scanAll();
|
if(a==='scanall') return scanAll();
|
||||||
if(a==='readall'){
|
if(a==='readall'){
|
||||||
const n=S.feeds.reduce((k,x)=>k+(x.unread||0),0);
|
const n=S.feeds.reduce((k,x)=>k+(x.unread||0),0);
|
||||||
@@ -183,7 +187,7 @@ async function allAction(a){
|
|||||||
try{
|
try{
|
||||||
const r=await api('/api/read-all',{method:'POST'});
|
const r=await api('/api/read-all',{method:'POST'});
|
||||||
toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries();
|
toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries();
|
||||||
}catch(e){ toast(e.message,true); }
|
}catch(e){ toast(why(e),true); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
/// One feed's row from the server, in place of the old one (or added, for a feed an OPML just
|
/// One feed's row from the server, in place of the old one (or added, for a feed an OPML just
|
||||||
/// listed). A scan sends dozens; the list is redrawn once a frame, not once each.
|
/// listed). A scan sends dozens; the list is redrawn once a frame, not once each.
|
||||||
let rowsQueued=0;
|
let rowsQueued=0;
|
||||||
function patchFeed(row){
|
function patchFeed(row: Feed){
|
||||||
const i=S.feeds.findIndex(f=>f.id===row.id);
|
const i=S.feeds.findIndex(f=>f.id===row.id);
|
||||||
if(i<0) S.feeds.push(row); else S.feeds[i]=row;
|
if(i<0) S.feeds.push(row); else S.feeds[i]=row;
|
||||||
if(!rowsQueued) rowsQueued=requestAnimationFrame(()=>{ rowsQueued=0; renderFeeds(); });
|
if(!rowsQueued) rowsQueued=requestAnimationFrame(()=>{ rowsQueued=0; renderFeeds(); });
|
||||||
@@ -19,58 +19,75 @@ async function loadFeeds(keepSel?: boolean){
|
|||||||
// brand new account) leaves S.feed alone, so the empty state's own message shows instead.
|
// brand new account) leaves S.feed alone, so the empty state's own message shows instead.
|
||||||
if(!keepSel && S.feeds.length){
|
if(!keepSel && S.feeds.length){
|
||||||
const known = S.feed && (VIEWS[S.feed] || S.feeds.some(f=>f.id===S.feed));
|
const known = S.feed && (VIEWS[S.feed] || S.feeds.some(f=>f.id===S.feed));
|
||||||
selectFeed(known ? S.feed : ':all');
|
selectFeed(known ? S.feed! : ':all');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// An OPML can hold dozens of feeds; the ones with something new go first. sort is stable, so the
|
// 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.
|
// server's alphabetical order still holds within each half.
|
||||||
const unreadFirst=(a,b)=>Number(b.unread>0)-Number(a.unread>0);
|
const unreadFirst=(a: Feed,b: Feed)=>Number(b.unread>0)-Number(a.unread>0);
|
||||||
// The original's source list opened with these, above the feeds. They are places, not feeds:
|
// 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.
|
// an id starting with ':' can never be a feed's, since feed ids are slugs.
|
||||||
const VIEWS={
|
const VIEWS: Record<string,{title: string, icon: string, url?: string, blurb?: string}>={
|
||||||
// The Directory holds the most subscribed feeds too, which had a place of their own, Popular (#124).
|
// The Directory holds the most subscribed feeds too, which had a place of their own, Popular (#124).
|
||||||
':directory':{title:'Directory',icon:ICON.directory,url:'/api/directory'},
|
':directory':{title:'Directory',icon:ICON.directory,url:'/api/directory'},
|
||||||
':listening':{title:'Currently Listening',icon:ICON.audio,url:'/api/entries?filter=in_progress&limit=50',
|
':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.'},
|
blurb:'Episodes you started and have not finished, across every feed you subscribe to. Pick one up where you left off.'},
|
||||||
':all':{title:'All Subscriptions',icon:ICON.all},
|
':all':{title:'All Subscriptions',icon:ICON.all},
|
||||||
};
|
};
|
||||||
/// Feeds being checked right now, from the event stream: their rows, and the row of a folder
|
/// Feeds being checked right now, from the event stream, and since when: their rows, and the row
|
||||||
/// holding one, carry a spinner.
|
/// of a folder holding one, carry a spinner.
|
||||||
const scanning=new Set<string>();
|
const scanning=new Map<string, number>();
|
||||||
|
/// A row's spinner takes the place of its unread count, so it waits this long: most feeds are
|
||||||
|
/// checked in a few milliseconds, and each count blinked out and back as its feed went by.
|
||||||
|
const SPIN_AFTER=300;
|
||||||
function setScanning(id: string, on: boolean){
|
function setScanning(id: string, on: boolean){
|
||||||
if(on) scanning.add(id); else scanning.delete(id);
|
if(on){ scanning.set(id,Date.now()); setTimeout(paintScanning,SPIN_AFTER); } else scanning.delete(id);
|
||||||
paintScanning();
|
paintScanning();
|
||||||
}
|
}
|
||||||
function paintScanning(){
|
function paintScanning(){
|
||||||
|
const slow=(id: string)=>scanning.has(id)&&Date.now()-scanning.get(id)!>=SPIN_AFTER;
|
||||||
for(const row of $$('#feedlist .feed')){
|
for(const row of $$('#feedlist .feed')){
|
||||||
const id=row.dataset.id;
|
const id=row.dataset.id!;
|
||||||
const on=scanning.has(id)||S.feeds.some(c=>c.group===id&&scanning.has(c.id));
|
const on=slow(id)||S.feeds.some(c=>c.group===id&&slow(c.id));
|
||||||
row.classList.toggle('scanning',on);
|
row.classList.toggle('scanning',on);
|
||||||
if(on) row.title='Checking for new items…'; else row.removeAttribute('title');
|
if(on) row.title='Checking for new items…'; else row.removeAttribute('title');
|
||||||
}
|
}
|
||||||
// The refresh buttons turn while what they check is being checked (#78). On <body>, because
|
// A feed's button turns on its own page only, wherever the person has gone since pressing it.
|
||||||
// a feed's page is redrawn as its items come in and would take a class on the button with it.
|
document.body.classList.toggle('scan-this',spinFeed!==null&&spinFeed===S.feed);
|
||||||
const busy=id=>scanning.has(id)||S.feeds.some(c=>c.group===id&&scanning.has(c.id));
|
}
|
||||||
document.body.classList.toggle('scan-this',busy(S.feed));
|
/// The refresh buttons turn from the press until that scan's scan_done (#78, #233). On <body>,
|
||||||
document.body.classList.toggle('scan-any',S.feeds.some(f=>scanning.has(f.id)));
|
/// because a feed's page is redrawn as its items come in and would take a class on the button
|
||||||
|
/// with it. Following the feeds being checked instead, the icon snapped back upright in the gap
|
||||||
|
/// between one feed and the next, many times a second (#231).
|
||||||
|
let spinFeed: string|null=null;
|
||||||
|
function spinStart(feed: string|null){
|
||||||
|
if(feed===null) document.body.classList.add('scan-any');
|
||||||
|
else { spinFeed=feed; paintScanning(); }
|
||||||
|
}
|
||||||
|
/// `only` and `forced` from scan_done: a scheduled scan, or one limited to another feed, leaves
|
||||||
|
/// a pressed button turning, since the scan it asked for is still queued behind it. Nothing
|
||||||
|
/// (an error ended the job) stops them all.
|
||||||
|
function spinEnd(done?: {only: string|null, forced: boolean}){
|
||||||
|
if(!done||done.forced&&done.only==null) document.body.classList.remove('scan-any');
|
||||||
|
if(!done||done.forced&&done.only===spinFeed){ spinFeed=null; paintScanning(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderFeeds(){
|
function renderFeeds(){
|
||||||
const q=$('#feedFilter').value.trim().toLowerCase();
|
const q=$<HTMLInputElement>('#feedFilter').value.trim().toLowerCase();
|
||||||
const list=$('#feedlist'); const top=list.scrollTop;
|
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.
|
// 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 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 back=was&&([was.dataset.id,a.classList.contains('chev')] as [string, boolean]);
|
||||||
const done=()=>{
|
const done=()=>{
|
||||||
list.scrollTop=top;
|
list.scrollTop=top;
|
||||||
const row=back&&list.querySelector(`[data-id="${CSS.escape(back[0])}"]`);
|
const row=back&&list.querySelector<HTMLElement>(`[data-id="${CSS.escape(back[0])}"]`);
|
||||||
if(row) (back[1]&&$('.chev',row)||row).focus();
|
if(row) (back[1]&&$('.chev',row)||row as HTMLElement).focus();
|
||||||
};
|
};
|
||||||
// The images already drawn, to go into the rows that replace theirs. Made anew, every icon in
|
// The images already drawn, to go into the rows that replace theirs. Made anew, every icon in
|
||||||
// the list went blank and loaded again, once a frame through a check of every feed, as each
|
// the list went blank and loaded again, once a frame through a check of every feed, as each
|
||||||
// feed's new row came in (#134). Matched by their markup, so a changed one is made anew.
|
// feed's new row came in (#134). Matched by their markup, so a changed one is made anew.
|
||||||
const drawn=new Map<string,HTMLImageElement[]>();
|
const drawn=new Map<string,HTMLImageElement[]>();
|
||||||
for(const img of $$('img',list)){ const k=img.outerHTML; if(!drawn.has(k)) drawn.set(k,[]); drawn.get(k).push(img); }
|
for(const img of $$<HTMLImageElement>('img',list)){ const k=img.outerHTML; if(!drawn.has(k)) drawn.set(k,[]); drawn.get(k)!.push(img); }
|
||||||
list.innerHTML='';
|
list.innerHTML='';
|
||||||
const unreadAll=S.feeds.reduce((n,f)=>n+(f.unread||0),0);
|
const unreadAll=S.feeds.reduce((n,f)=>n+(f.unread||0),0);
|
||||||
const places=document.createElement('div');
|
const places=document.createElement('div');
|
||||||
@@ -91,9 +108,9 @@ function renderFeeds(){
|
|||||||
// Feeds from a subscribed OPML sit under it, so the group reads as one thing. Pinned feeds
|
// 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.
|
// 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 byId=Object.fromEntries(shown.map(f=>[f.id,f]));
|
||||||
const inside=f=>shown.filter(c=>c.group===f.id&&!c.pinned);
|
const inside=(f: Feed)=>shown.filter(c=>c.group===f.id&&!c.pinned);
|
||||||
const tops=shown.filter(f=>f.pinned||!(f.group&&byId[f.group]));
|
const tops=shown.filter(f=>f.pinned||!(f.group&&byId[f.group]));
|
||||||
const order=[];
|
const order: [Feed, number][]=[];
|
||||||
for(const f of [...tops.filter(f=>f.pinned),...tops.filter(f=>!f.pinned)]){
|
for(const f of [...tops.filter(f=>f.pinned),...tops.filter(f=>!f.pinned)]){
|
||||||
order.push([f,0]);
|
order.push([f,0]);
|
||||||
// A subscription can hold dozens of feeds, so a folder starts closed. Searching
|
// A subscription can hold dozens of feeds, so a folder starts closed. Searching
|
||||||
@@ -110,14 +127,14 @@ function renderFeeds(){
|
|||||||
// A subscription holds no entries itself, so its counts are the sum of what is inside --
|
// 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.
|
// 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 mine=S.feeds.filter(c=>c.group===f.id);
|
||||||
const sum=k=>mine.reduce((n,c)=>n+(c[k]||0),0);
|
const sum=(k: 'unread'|'entries'|'downloaded')=>mine.reduce((n,c)=>n+(c[k]||0),0);
|
||||||
const [unread,eps,saved]=mine.length
|
const [unread,eps,saved]=mine.length
|
||||||
? [sum('unread'),sum('entries'),sum('downloaded')]
|
? [sum('unread'),sum('entries'),sum('downloaded')]
|
||||||
: [f.unread,f.entries,f.downloaded];
|
: [f.unread,f.entries,f.downloaded];
|
||||||
// A group's own row has no error of its own worth mentioning if the OPML itself
|
// 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
|
// 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.
|
// went wrong (failBannerHTML); the list only has to make it findable.
|
||||||
const bad=c=>c.failing?.reason||c.last_error;
|
const bad=(c: Feed)=>c.failing?.reason||c.last_error;
|
||||||
const err=mine.length ? mine.map(bad).find(Boolean) : bad(f);
|
const err=mine.length ? mine.map(bad).find(Boolean) : bad(f);
|
||||||
const nbad=mine.filter(bad).length;
|
const nbad=mine.filter(bad).length;
|
||||||
const el=document.createElement('div');
|
const el=document.createElement('div');
|
||||||
@@ -143,15 +160,15 @@ function renderFeeds(){
|
|||||||
(f.orphaned?'<span class="tag" title="No longer listed, kept because it has downloads">Gone</span>':'')+
|
(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>`;
|
`<span class="badge${unread?'':' zero'}" title="${unread} unread">${unread>999?'999+':unread}</span>`;
|
||||||
el.onclick=()=>{ selectFeed(f.id); nav(false); };
|
el.onclick=()=>{ selectFeed(f.id); nav(false); };
|
||||||
if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); };
|
if(kids) $('.chev',el).onclick=(ev: Event)=>{ ev.stopPropagation(); toggleGroup(f.id); };
|
||||||
for(const img of $$('img',el)){ const was=drawn.get(img.outerHTML)?.shift(); if(was) img.replaceWith(was); }
|
for(const img of $$('img',el)){ const was=drawn.get(img.outerHTML)?.shift(); if(was) img.replaceWith(was); }
|
||||||
list.appendChild(el);
|
list.appendChild(el);
|
||||||
}
|
}
|
||||||
paintScanning();
|
paintScanning();
|
||||||
done();
|
done();
|
||||||
}
|
}
|
||||||
function selectFeed(id){
|
function selectFeed(id: string){
|
||||||
S.feed=id; S.offset=0; S.sel=null; S.q=''; $('#epSearch').value='';
|
S.feed=id; S.offset=0; S.sel=null; S.q=''; $<HTMLInputElement>('#epSearch').value='';
|
||||||
try{ localStorage.setItem('ipx.feed',id); }catch{}
|
try{ localStorage.setItem('ipx.feed',id); }catch{}
|
||||||
renderFeeds(); renderFeed(); loadEntries();
|
renderFeeds(); renderFeed(); loadEntries();
|
||||||
}
|
}
|
||||||
@@ -159,15 +176,15 @@ function selectFeed(id){
|
|||||||
/// A failing feed's error, in plain words with something to do about it, once `failing` is
|
/// 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
|
/// 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.
|
/// src/feed.rs). Anything else still shows the raw error, as before.
|
||||||
function failBannerHTML(f){
|
function failBannerHTML(f: Feed){
|
||||||
if(f.failing) return `<div class="sub" style="color:var(--bad)">${esc(f.failing.reason)}
|
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>${
|
<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>`;
|
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>`;
|
if(f.last_error) return `<div class="sub" style="color:var(--bad)">${esc(f.last_error)}</div>`;
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
function wireFailBanner(box,f){
|
function wireFailBanner(box: ParentNode,f: Feed){
|
||||||
const un=$('[data-ffail="unsub"]',box); if(un) un.onclick=()=>removeFeed(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);
|
const nu=$('[data-ffail="newurl"]',box); if(nu) nu.onclick=()=>settingsModal(f,f.failing?.new_url??undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ document.addEventListener('touchstart', ev => {
|
|||||||
// back button is never swallowed with it.
|
// back button is never swallowed with it.
|
||||||
if(t.clientX < 14 && narrow() && ev.cancelable) ev.preventDefault();
|
if(t.clientX < 14 && narrow() && ev.cancelable) ev.preventDefault();
|
||||||
}
|
}
|
||||||
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};
|
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: false});
|
}, {passive: false});
|
||||||
|
|
||||||
document.addEventListener('touchmove', ev => {
|
document.addEventListener('touchmove', ev => {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ async function loadEntries(append?: boolean){
|
|||||||
const p=new URLSearchParams({offset:String(S.offset),limit:String(LIMIT),filter:S.filter,sort:S.sort.col,dir:S.sort.dir});
|
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);
|
if(S.q) p.set('q',S.q);
|
||||||
const asked=performance.now(), seq=++loadSeq;
|
const asked=performance.now(), seq=++loadSeq;
|
||||||
const r=await api(S.feed===':all' ? `/api/entries?${p}`
|
const r: {total: number, entries: Entry[]}=await api(S.feed===':all' ? `/api/entries?${p}`
|
||||||
: `/api/feeds/${encodeURIComponent(S.feed)}/entries?${p}`);
|
: `/api/feeds/${encodeURIComponent(S.feed)}/entries?${p}`);
|
||||||
// Only the latest list counts: marking everything read reloads the All tab, and clicking
|
// 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.
|
// Unread straight after could have that answer land last and replace the Unread one.
|
||||||
@@ -14,14 +14,14 @@ async function loadEntries(append?: boolean){
|
|||||||
S.total=r.total;
|
S.total=r.total;
|
||||||
for(const e of r.entries){
|
for(const e of r.entries){
|
||||||
const w=readWrites.get(readKey(e));
|
const w=readWrites.get(readKey(e));
|
||||||
if(w && !(w.done<asked)) e.read=w.read;
|
if(w && !(w.done!==undefined&&w.done<asked)) e.read=w.read;
|
||||||
}
|
}
|
||||||
let entries = append ? S.entries.concat(r.entries) : r.entries;
|
let entries = append ? S.entries.concat(r.entries) : r.entries;
|
||||||
// A background scan finishing refreshes the list from the server, which -- on the Unread
|
// 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.
|
// 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.
|
// Keep it until you pick a different one; the next refresh after that no longer protects it.
|
||||||
// The items turned past on the way to it are kept too, so a swipe back still finds them.
|
// The items turned past on the way to it are kept too, so a swipe back still finds them.
|
||||||
const kept=e=>e.guid===S.sel||turned.has(e.guid);
|
const kept=(e: Entry)=>e.guid===S.sel||turned.has(e.guid);
|
||||||
if(S.filter==='unread') entries=entries.filter(e=>!e.read||kept(e));
|
if(S.filter==='unread') entries=entries.filter(e=>!e.read||kept(e));
|
||||||
if(!append && S.sel){
|
if(!append && S.sel){
|
||||||
const gone=S.entries.filter(e=>kept(e)&&!entries.some(n=>n.guid===e.guid));
|
const gone=S.entries.filter(e=>kept(e)&&!entries.some(n=>n.guid===e.guid));
|
||||||
@@ -32,7 +32,7 @@ async function loadEntries(append?: boolean){
|
|||||||
}
|
}
|
||||||
function renderEntries(){
|
function renderEntries(){
|
||||||
const box=$('#eps'); if(!box) return;
|
const box=$('#eps'); if(!box) return;
|
||||||
const f=S.feeds.find(x=>x.id===S.feed), v=VIEWS[S.feed];
|
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);
|
const unread=f?f.unread:S.feeds.reduce((n,x)=>n+(x.unread||0),0);
|
||||||
$('#count').textContent=
|
$('#count').textContent=
|
||||||
`${f?(f.title||f.id):v?v.title:''}: ${S.total} item${S.total===1?'':'s'}, ${unread} unread`;
|
`${f?(f.title||f.id):v?v.title:''}: ${S.total} item${S.total===1?'':'s'}, ${unread} unread`;
|
||||||
@@ -53,7 +53,7 @@ function renderEntries(){
|
|||||||
box.appendChild(b);
|
box.appendChild(b);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function epEl(e){
|
function epEl(e: Entry){
|
||||||
// An item may carry several files. The row summarises the one you would act on --
|
// 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
|
// the playable one, else anything already downloaded, else the first -- and says how
|
||||||
// many others there are; the pane below lists them all.
|
// many others there are; the pane below lists them all.
|
||||||
@@ -78,7 +78,7 @@ function epEl(e){
|
|||||||
num&&`<span>${num}</span>`,
|
num&&`<span>${num}</span>`,
|
||||||
left&&`<span>${left}</span>`,
|
left&&`<span>${left}</span>`,
|
||||||
others>0&&`<span>+${others} more file${others===1?'':'s'}</span>`,
|
others>0&&`<span>+${others} more file${others===1?'':'s'}</span>`,
|
||||||
enc&&enc.deadair>0&&`<span title="DeadAir cut its dead air">${shorter(enc.deadair)}</span>`,
|
enc&&enc.deadair&&enc.deadair>0&&`<span title="DeadAir cut its dead air">${shorter(enc.deadair)}</span>`,
|
||||||
enc&&enc.deadair_pending&&DEAD_PENDING,
|
enc&&enc.deadair_pending&&DEAD_PENDING,
|
||||||
enc&&enc.last_error&&enc.state==='error'&&`<span style="color:var(--bad)">${esc(enc.last_error)}</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>
|
].filter(Boolean).join('<span class="dot"></span>')}</div>
|
||||||
@@ -97,13 +97,13 @@ function epEl(e){
|
|||||||
${has?`<button class="iconbtn" data-a="del" title="Delete file" aria-label="Delete file">${ICON.trash}</button>`:''}
|
${has?`<button class="iconbtn" data-a="del" title="Delete file" aria-label="Delete file">${ICON.trash}</button>`:''}
|
||||||
</div>`;
|
</div>`;
|
||||||
el.onclick=()=>selectEntry(e);
|
el.onclick=()=>selectEntry(e);
|
||||||
$$('button[data-a]',el).forEach(b=>b.onclick=ev=>{ev.stopPropagation();epAction(b.dataset.a,e,el)});
|
$$('button[data-a]',el).forEach(b=>b.onclick=(ev: Event)=>{ev.stopPropagation();epAction(b.dataset.a!,e,el)});
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
/// Whether the browser can play it. Having a file is not the same as being playable:
|
/// 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
|
/// blog feeds put article images in enclosures, and an <audio> element pointed at a JPEG
|
||||||
/// is just a broken player.
|
/// is just a broken player.
|
||||||
function isPlayable(x){
|
function isPlayable(x: Enclosure|undefined){
|
||||||
if(!x || !x.path) return false;
|
if(!x || !x.path) return false;
|
||||||
const m=(x.mime||'').toLowerCase();
|
const m=(x.mime||'').toLowerCase();
|
||||||
if(m.startsWith('audio/')||m.startsWith('video/')) return true;
|
if(m.startsWith('audio/')||m.startsWith('video/')) return true;
|
||||||
@@ -114,7 +114,7 @@ function isPlayable(x){
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// What an enclosure is, for a row that is not media: "image", "pdf", "document".
|
/// What an enclosure is, for a row that is not media: "image", "pdf", "document".
|
||||||
function kindOf(enc){
|
function kindOf(enc: {mime?: string|null, url?: string}){
|
||||||
const m=(enc.mime||'').toLowerCase();
|
const m=(enc.mime||'').toLowerCase();
|
||||||
if(m.startsWith('image/')) return 'image';
|
if(m.startsWith('image/')) return 'image';
|
||||||
if(m.startsWith('video/')) return 'video';
|
if(m.startsWith('video/')) return 'video';
|
||||||
@@ -128,9 +128,9 @@ function kindOf(enc){
|
|||||||
/// What a file is, as one icon coloured by whether it is here: green once downloaded, red when
|
/// 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
|
/// 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.
|
/// either way keeps the column lined up; the words are in its tooltip.
|
||||||
function kindIcon(enc){
|
function kindIcon(enc: Enclosure){
|
||||||
const k=kindOf(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 i=(<Record<string,string>>{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`]
|
const [cls,label]=enc.path ? [' here',`${k}, downloaded`]
|
||||||
: enc.state==='error' ? [' bad',`${k}, download failed${enc.last_error?': '+enc.last_error:''}`]
|
: enc.state==='error' ? [' bad',`${k}, download failed${enc.last_error?': '+enc.last_error:''}`]
|
||||||
: ['',k];
|
: ['',k];
|
||||||
@@ -138,7 +138,7 @@ function kindIcon(enc){
|
|||||||
}
|
}
|
||||||
|
|
||||||
function feedArt(id=S.feed){ const f=S.feeds.find(x=>x.id===id); return f&&f.image; }
|
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; };
|
const feedName=(id: string|null|undefined): string=>{ const f=S.feeds.find(x=>x.id===id); return f?(f.title||f.id):(id??''); };
|
||||||
|
|
||||||
/// What an item is called. Its title, or for one published without (RSS 2.0 makes it optional,
|
/// What an item is called. Its title, or for one published without (RSS 2.0 makes it optional,
|
||||||
/// and Scripting News titles almost none of its posts) the opening of its text, then its file's
|
/// and Scripting News titles almost none of its posts) the opening of its text, then its file's
|
||||||
@@ -147,24 +147,24 @@ const feedName=id=>{ const f=S.feeds.find(x=>x.id===id); return f?(f.title||f.id
|
|||||||
/// Feed HTML as plain words. An inert document: nothing in it loads or runs, where a detached
|
/// Feed HTML as plain words. An inert document: nothing in it loads or runs, where a detached
|
||||||
/// element fetches its images. A line break or paragraph's end is a space, or "2010)<br>Recorded"
|
/// element fetches its images. A line break or paragraph's end is a space, or "2010)<br>Recorded"
|
||||||
/// read "2010)Recorded".
|
/// read "2010)Recorded".
|
||||||
const plainText=(html?: string)=>html
|
const plainText=(html?: string|null)=>html
|
||||||
? (new DOMParser().parseFromString(html.replace(/<(br|\/p|\/div|\/li|\/h\d)\b[^>]*>/gi,' $&'),'text/html').body.textContent||'')
|
? (new DOMParser().parseFromString(html.replace(/<(br|\/p|\/div|\/li|\/h\d)\b[^>]*>/gi,' $&'),'text/html').body.textContent||'')
|
||||||
.replace(/\s+/g,' ').trim() : '';
|
.replace(/\s+/g,' ').trim() : '';
|
||||||
function entryName(e): {text: string, derived: boolean}{
|
function entryName(e: Partial<Entry>): {text: string, derived: boolean}{
|
||||||
if(e.title&&e.title.trim()) return {text:e.title, derived:false};
|
if(e.title&&e.title.trim()) return {text:e.title, derived:false};
|
||||||
const words=plainText(e.description);
|
const words=plainText(e.description);
|
||||||
if(words){
|
if(words){
|
||||||
const cut=words.length>120 ? words.slice(0,120).replace(/\s+\S*$/,'')+'…' : words;
|
const cut=words.length>120 ? words.slice(0,120).replace(/\s+\S*$/,'')+'…' : words;
|
||||||
return {text:cut, derived:true};
|
return {text:cut, derived:true};
|
||||||
}
|
}
|
||||||
const file=((e.enclosures||[])[0]?.url||'').split(/[?#]/)[0].split('/').pop().replace(/\.[a-z0-9]{1,5}$/i,'');
|
const file=((e.enclosures||[])[0]?.url||'').split(/[?#]/)[0].split('/').pop()!.replace(/\.[a-z0-9]{1,5}$/i,'');
|
||||||
if(file){ try{ return {text:decodeURIComponent(file), derived:true}; }catch{ return {text:file, derived:true}; } }
|
if(file){ try{ return {text:decodeURIComponent(file), derived:true}; }catch{ return {text:file, derived:true}; } }
|
||||||
return {text:[feedName(e.feed_id), dateOf(e.published)].filter(Boolean).join(', '), derived:true};
|
return {text:[feedName(e.feed_id), dateOf(e.published??null)].filter(Boolean).join(', '), derived:true};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Selecting an item shows it in the pane below, rather than expanding the row.
|
/// 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.
|
/// Replaces one row with a fresh one, leaving the rest of the list and its scroll alone.
|
||||||
function swapRow(e){
|
function swapRow(e: Entry){
|
||||||
const row=$(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`);
|
const row=$(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`);
|
||||||
if(row){ row.replaceWith(epEl(e)); syncPlayButtons(); }
|
if(row){ row.replaceWith(epEl(e)); syncPlayButtons(); }
|
||||||
}
|
}
|
||||||
@@ -172,9 +172,9 @@ function swapRow(e){
|
|||||||
/// Read and unread as this page last set them, and when the server had it. A list asked for
|
/// 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
|
/// 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).
|
/// the next refresh took it off again (loadEntries).
|
||||||
const readWrites=new Map();
|
const readWrites=new Map<string, {read: boolean, done?: number}>();
|
||||||
const readKey=e=>e.feed_id+'\n'+e.guid;
|
const readKey=(e: Entry)=>e.feed_id+'\n'+e.guid;
|
||||||
function setRead(e,read){
|
function setRead(e: Entry,read: boolean){
|
||||||
e.read=read;
|
e.read=read;
|
||||||
const w: {read: boolean, done?: number}={read}; readWrites.set(readKey(e),w);
|
const w: {read: boolean, done?: number}={read}; readWrites.set(readKey(e),w);
|
||||||
return api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/flags`,
|
return api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/flags`,
|
||||||
@@ -184,7 +184,7 @@ function setRead(e,read){
|
|||||||
|
|
||||||
/// Opening an item is reading it. The row is redrawn where it stands rather than the list
|
/// 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.
|
/// reloaded, so an item does not vanish from under the pointer on the Unread tab.
|
||||||
function markRead(e){
|
function markRead(e: Entry){
|
||||||
if(e.read) return;
|
if(e.read) return;
|
||||||
setRead(e,true).catch(err=>{ e.read=false; readWrites.delete(readKey(e)); toast(err.message,true); });
|
setRead(e,true).catch(err=>{ e.read=false; readWrites.delete(readKey(e)); toast(err.message,true); });
|
||||||
}
|
}
|
||||||
@@ -193,7 +193,7 @@ function markRead(e){
|
|||||||
/// in the list until the reader closes or another item is picked from the list. Dropped as each
|
/// in the list until the reader closes or another item is picked from the list. Dropped as each
|
||||||
/// was left, a swipe back had nothing to go back to: the item just read was already gone.
|
/// was left, a swipe back had nothing to go back to: the item just read was already gone.
|
||||||
const turned=new Set<string>();
|
const turned=new Set<string>();
|
||||||
function dropTurned(keep){
|
function dropTurned(keep: string|null){
|
||||||
const gone=S.entries.filter(x=>turned.has(x.guid)&&x.read&&x.guid!==keep);
|
const gone=S.entries.filter(x=>turned.has(x.guid)&&x.read&&x.guid!==keep);
|
||||||
turned.clear();
|
turned.clear();
|
||||||
if(!gone.length) return;
|
if(!gone.length) return;
|
||||||
@@ -201,7 +201,7 @@ function dropTurned(keep){
|
|||||||
for(const x of gone) $(`#eps .ep[data-guid="${CSS.escape(x.guid)}"]`)?.remove();
|
for(const x of gone) $(`#eps .ep[data-guid="${CSS.escape(x.guid)}"]`)?.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectEntry(e,turning=false){
|
function selectEntry(e: Entry,turning=false){
|
||||||
// On the Unread tab the item you were reading goes as you move on, not whenever a refresh
|
// 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.
|
// next happens to come along, which left a few read ones in the list for a while.
|
||||||
if(S.filter==='unread' && S.sel && S.sel!==e.guid){
|
if(S.filter==='unread' && S.sel && S.sel!==e.guid){
|
||||||
@@ -217,10 +217,10 @@ function selectEntry(e,turning=false){
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Drag the divider between the item list and the item text.
|
/// Drag the divider between the item list and the item text.
|
||||||
function dragSplit(pane){
|
function dragSplit(pane: HTMLElement){
|
||||||
const grab=$('#grab',pane);
|
const grab=$('#grab',pane);
|
||||||
if(!grab) return;
|
if(!grab) return;
|
||||||
const move=ev=>{
|
const move=(ev: MouseEvent)=>{
|
||||||
const box=pane.getBoundingClientRect();
|
const box=pane.getBoundingClientRect();
|
||||||
const pct=Math.min(80,Math.max(12,((ev.clientY-box.top)/box.height)*100));
|
const pct=Math.min(80,Math.max(12,((ev.clientY-box.top)/box.height)*100));
|
||||||
pane.style.setProperty('--listh',pct.toFixed(1)+'%');
|
pane.style.setProperty('--listh',pct.toFixed(1)+'%');
|
||||||
@@ -231,7 +231,7 @@ function dragSplit(pane){
|
|||||||
document.body.style.userSelect='';
|
document.body.style.userSelect='';
|
||||||
try{ localStorage.setItem('ipx.listh', pane.style.getPropertyValue('--listh')); }catch{}
|
try{ localStorage.setItem('ipx.listh', pane.style.getPropertyValue('--listh')); }catch{}
|
||||||
};
|
};
|
||||||
grab.onmousedown=ev=>{
|
grab.onmousedown=(ev: MouseEvent)=>{
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
document.body.style.userSelect='none';
|
document.body.style.userSelect='none';
|
||||||
document.addEventListener('mousemove',move);
|
document.addEventListener('mousemove',move);
|
||||||
@@ -243,24 +243,24 @@ function dragSplit(pane){
|
|||||||
// ponytail: matched by guid alone; two feeds sharing a guid in All Subscriptions would pick
|
// 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.
|
// the first. Key rows by feed as well if that ever happens.
|
||||||
const cur=()=>S.entries.find(x=>x.guid===S.sel);
|
const cur=()=>S.entries.find(x=>x.guid===S.sel);
|
||||||
function syncTools(e){
|
function syncTools(e: Entry|null){
|
||||||
$('#tbPlay').disabled=!(e&&e.enclosures.some(isPlayable));
|
$<HTMLButtonElement>('#tbPlay').disabled=!(e&&e.enclosures.some(isPlayable));
|
||||||
$('#tbRead').disabled=$('#tbFlag').disabled=!e;
|
$<HTMLButtonElement>('#tbRead').disabled=$<HTMLButtonElement>('#tbFlag').disabled=!e;
|
||||||
// The same icons as the item's own buttons beside its title, so the two never disagree. Each
|
// The same icons as the item's own buttons beside its title, so the two never disagree. Each
|
||||||
// shows what is, as the pin always did: read showed what a click would do, so the two side by
|
// shows what is, as the pin always did: read showed what a click would do, so the two side by
|
||||||
// side said opposite things (#74). The shape carries it, not the colour.
|
// side said opposite things (#74). The shape carries it, not the colour.
|
||||||
$('#tbRead').innerHTML=e&&!e.read?ICON.unread:ICON.check;
|
$<HTMLButtonElement>('#tbRead').innerHTML=e&&!e.read?ICON.unread:ICON.check;
|
||||||
$('#tbRead').setAttribute('aria-pressed',String(!!(e&&e.read)));
|
$<HTMLButtonElement>('#tbRead').setAttribute('aria-pressed',String(!!(e&&e.read)));
|
||||||
$('#tbFlag').setAttribute('aria-pressed',String(!!(e&&e.flagged)));
|
$<HTMLButtonElement>('#tbFlag').setAttribute('aria-pressed',String(!!(e&&e.flagged)));
|
||||||
$('#tbFlag').innerHTML=e&&e.flagged?ICON.pinOn:ICON.pin;
|
$<HTMLButtonElement>('#tbFlag').innerHTML=e&&e.flagged?ICON.pinOn:ICON.pin;
|
||||||
if(e){
|
if(e){
|
||||||
$('#tbRead').title=`Mark ${e.read?'unread':'read'}`;
|
$<HTMLButtonElement>('#tbRead').title=`Mark ${e.read?'unread':'read'}`;
|
||||||
$('#tbFlag').title=e.flagged?'Unpin':'Pin, so it is never deleted';
|
$<HTMLButtonElement>('#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.
|
/// The item's text in the pane below the list, and its files in the pane beside it.
|
||||||
function showDetail(e){
|
function showDetail(e: Entry|null){
|
||||||
const box=$('#detail'), files=$('#files'); if(!box) return;
|
const box=$('#detail'), files=$('#files'); if(!box) return;
|
||||||
// A pane that can only say "No files" gives its width to the list instead.
|
// A pane that can only say "No files" gives its width to the list instead.
|
||||||
if(files) files.hidden=!(e&&e.enclosures.length);
|
if(files) files.hidden=!(e&&e.enclosures.length);
|
||||||
@@ -278,15 +278,15 @@ function showDetail(e){
|
|||||||
const narrow=!!window.matchMedia?.('(max-width:820px)')?.matches;
|
const narrow=!!window.matchMedia?.('(max-width:820px)')?.matches;
|
||||||
box.innerHTML=detailHtml(e);
|
box.innerHTML=detailHtml(e);
|
||||||
if(files) files.innerHTML=narrow?'':`<div class="fhd">Files</div>${encs}`;
|
if(files) files.innerHTML=narrow?'':`<div class="fhd">Files</div>${encs}`;
|
||||||
$('#dback').onclick=()=>showDetail(null);
|
$<HTMLButtonElement>('#dback').onclick=()=>showDetail(null);
|
||||||
for(const root of [box,files]) if(root) $$('button[data-a]',root).forEach(b=>
|
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));
|
b.onclick=()=>epAction(b.dataset.a!,e,null,b.dataset.enc?Number(b.dataset.enc):undefined));
|
||||||
syncPlayButtons();
|
syncPlayButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the reader shows for an item. Its own function because a phone also draws the next or
|
/// What the reader shows for an item. Its own function because a phone also draws the next or
|
||||||
/// previous item beside the reader while it is swiped (gestures.ts).
|
/// previous item beside the reader while it is swiped (gestures.ts).
|
||||||
function detailHtml(e){
|
function detailHtml(e: Entry){
|
||||||
const encs=e.enclosures.map(encBox).join('');
|
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.
|
// 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
|
// Below it, a long set of show notes pushed play and delete screens down, and on an iPhone
|
||||||
@@ -317,7 +317,7 @@ function detailHtml(e){
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// One enclosure: a play button when the file is here, otherwise what it is and a way to get it.
|
/// One enclosure: a play button when the file is here, otherwise what it is and a way to get it.
|
||||||
function encBox(x){
|
function encBox(x: Enclosure){
|
||||||
const size=x.length?mb(x.length):'';
|
const size=x.length?mb(x.length):'';
|
||||||
// One file serves everyone reading the feed, so deleting is not a private act.
|
// 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 f=S.feeds.find(y=>y.id===x.feed_id);
|
||||||
@@ -328,8 +328,9 @@ function encBox(x){
|
|||||||
: 'Delete file';
|
: '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 delBtn=`<button class="btn ico danger" data-a="del" data-enc="${x.id}" title="${delLabel}" aria-label="${delLabel}">${ICON.trash}</button>`;
|
||||||
// Played without its dead air, so saved without it too (#186).
|
// Played without its dead air, so saved without it too (#186).
|
||||||
const saveLabel=x.deadair>0?'Save to this computer, without its dead air':'Save to this computer';
|
const dead=(x.deadair??0)>0;
|
||||||
const saveBtn=`<a class="btn ico" href="/media/${x.id}${x.deadair>0?'/deadair':''}" download title="${saveLabel}" aria-label="${saveLabel}">${ICON.save}</a>`;
|
const saveLabel=dead?'Save to this computer, without its dead air':'Save to this computer';
|
||||||
|
const saveBtn=`<a class="btn ico" href="/media/${x.id}${dead?'/deadair':''}" download title="${saveLabel}" aria-label="${saveLabel}">${ICON.save}</a>`;
|
||||||
// The publisher's address, not this server's copy, which only someone signed in here can open.
|
// The publisher's address, not this server's copy, which only someone signed in here can open.
|
||||||
const shareBtn=`<button class="btn ico" data-a="share" data-enc="${x.id}" title="Share the file" aria-label="Share the file">${ICON.share}</button>`;
|
const shareBtn=`<button class="btn ico" data-a="share" data-enc="${x.id}" title="Share the file" aria-label="Share the file">${ICON.share}</button>`;
|
||||||
if(x.path && !isPlayable(x)){
|
if(x.path && !isPlayable(x)){
|
||||||
@@ -369,14 +370,14 @@ function encBox(x){
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function epAction(a: string, e, el, encId?: number){
|
async function epAction(a: string, e: Entry, el: HTMLElement|null, encId?: number){
|
||||||
// Swap the row in place and refresh the text below when it is the one being read.
|
// 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 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 enc=(encId!=null && e.enclosures.find(x=>x.id===encId)) || e.enclosures[0];
|
||||||
const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`;
|
const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`;
|
||||||
try{
|
try{
|
||||||
if(a==='play') play(e, encId!=null ? enc : undefined);
|
if(a==='play') play(e, encId!=null ? enc : undefined);
|
||||||
if(a==='share') await share(entryName(e).text, encId!=null ? enc.url : e.link, el);
|
if(a==='share') await share(entryName(e).text, encId!=null ? enc.url : e.link!, el??undefined);
|
||||||
if(a==='flag'){ e.flagged=!e.flagged; await api(path+'/flags',{method:'POST',body:JSON.stringify({flagged:e.flagged})}); redraw(); }
|
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==='read'){ await setRead(e,!e.read); redraw(); }
|
||||||
if(a==='get'){
|
if(a==='get'){
|
||||||
@@ -396,12 +397,12 @@ async function epAction(a: string, e, el, encId?: number){
|
|||||||
await api(`/api/enclosures/${enc.id}`,{method:'DELETE'});
|
await api(`/api/enclosures/${enc.id}`,{method:'DELETE'});
|
||||||
}catch(err){
|
}catch(err){
|
||||||
// 409: somebody else has it starred or unplayed. Their reason, their words.
|
// 409: somebody else has it starred or unplayed. Their reason, their words.
|
||||||
if(!/one copy of this file/.test(err.message)) throw err;
|
if(!/one copy of this file/.test(why(err))) throw err;
|
||||||
if(!confirm(err.message+'\n\nDelete it anyway?')) return;
|
if(!confirm(why(err)+'\n\nDelete it anyway?')) return;
|
||||||
await api(`/api/enclosures/${enc.id}?force=true`,{method:'DELETE'});
|
await api(`/api/enclosures/${enc.id}?force=true`,{method:'DELETE'});
|
||||||
}
|
}
|
||||||
toast('Deleted'); loadEntries(); loadFeeds(true);
|
toast('Deleted'); loadEntries(); loadFeeds(true);
|
||||||
}
|
}
|
||||||
}catch(err){ toast(err.message,true); }
|
}catch(err){ toast(why(err),true); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
document.getElementById('f').onsubmit = async e => {
|
document.getElementById('f')!.onsubmit = async e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const msg = document.getElementById('msg');
|
const msg = document.getElementById('msg')!;
|
||||||
msg.textContent = '';
|
msg.textContent = '';
|
||||||
const r = await fetch('/api/login', {
|
const r = await fetch('/api/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
/* ---------------- player ---------------- */
|
/* ---------------- player ---------------- */
|
||||||
const audio=$('#audio');
|
const audio=$<HTMLAudioElement>('#audio');
|
||||||
const player: {guid: string|null, feed: string|null, entry: any, enc?: number, src?: string, moved?: boolean,
|
const player: {guid: string|null, feed: string|null, entry: Entry|null, enc?: number, src?: string, moved?: boolean,
|
||||||
saveAt: number, marked: boolean}={guid:null,feed:null,entry:null,saveAt:0,marked:false};
|
saveAt: number, marked: boolean}={guid:null,feed:null,entry:null,saveAt:0,marked:false};
|
||||||
|
|
||||||
/// Where a file plays from: DeadAir's playlist when the server says this person plays it
|
/// Where a file plays from: DeadAir's playlist when the server says this person plays it
|
||||||
/// shortened (#171, #181), whose times the entry is already given in, else the file itself.
|
/// shortened (#171, #181), whose times the entry is already given in, else the file itself.
|
||||||
const mediaSrc=enc=>enc.deadair!=null?`/media/${enc.id}/deadair.m3u8`:`/media/${enc.id}`;
|
const mediaSrc=(enc: {id: number, deadair?: number|null})=>enc.deadair!=null?`/media/${enc.id}/deadair.m3u8`:`/media/${enc.id}`;
|
||||||
|
|
||||||
/// hls.js, playing DeadAir's playlist where the browser cannot itself. Fetched the first time it
|
/// hls.js, playing DeadAir's playlist where the browser cannot itself. Fetched the first time it
|
||||||
/// is needed. Only Apple's browsers play it themselves: Chromium says it can ("maybe"), then fails
|
/// is needed. Only Apple's browsers play it themselves: Chromium says it can ("maybe"), then fails
|
||||||
@@ -31,7 +31,7 @@ function setSrc(url: string): Promise<void>|null{
|
|||||||
return ready.then(()=>{
|
return ready.then(()=>{
|
||||||
// Its playlist and audio requests join the trace of the tap that played them (#192).
|
// Its playlist and audio requests join the trace of the tap that played them (#192).
|
||||||
hls=new w.Hls({xhrSetup:(xhr: XMLHttpRequest)=>xhr.setRequestHeader('traceparent',traceparent())});
|
hls=new w.Hls({xhrSetup:(xhr: XMLHttpRequest)=>xhr.setRequestHeader('traceparent',traceparent())});
|
||||||
hls.on(w.Hls.Events.ERROR,(_,d)=>{ if(d.fatal){ toast('Playback failed: '+d.details,true);
|
hls.on(w.Hls.Events.ERROR,(_: string,d: {fatal: boolean, type: string, details: string})=>{ if(d.fatal){ toast('Playback failed: '+d.details,true);
|
||||||
report('player_error',{msg:`hls.js: ${d.type} ${d.details}`,feed:player.feed,enclosure:player.enc}); } });
|
report('player_error',{msg:`hls.js: ${d.type} ${d.details}`,feed:player.feed,enclosure:player.enc}); } });
|
||||||
hls.loadSource(url); hls.attachMedia(audio);
|
hls.loadSource(url); hls.attachMedia(audio);
|
||||||
});
|
});
|
||||||
@@ -49,8 +49,8 @@ function markPlayed(){
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Plays one of the item's files in the player bar: the one asked for, or its first playable one.
|
/// 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)){
|
function play(e: Entry,enc=e.enclosures.find(isPlayable)){
|
||||||
if(!isPlayable(enc)){
|
if(!enc||!isPlayable(enc)){
|
||||||
toast(e.enclosures.some(x=>x.path) ? 'That file is not audio or video' : 'Not downloaded yet', true);
|
toast(e.enclosures.some(x=>x.path) ? 'That file is not audio or video' : 'Not downloaded yet', true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -76,17 +76,17 @@ function play(e,enc=e.enclosures.find(isPlayable)){
|
|||||||
player.marked=false;
|
player.marked=false;
|
||||||
paintDead();
|
paintDead();
|
||||||
}
|
}
|
||||||
const go=()=>audio.play().catch(err=>toast('Playback failed: '+err.message,true));
|
const go=()=>audio.play().catch((err: Error)=>toast('Playback failed: '+err.message,true));
|
||||||
if(wait) wait.then(go,err=>toast('Playback failed: '+err.message,true)); else go();
|
if(wait) wait.then(go,err=>toast('Playback failed: '+err.message,true)); else go();
|
||||||
renderEntries();
|
renderEntries();
|
||||||
}
|
}
|
||||||
function mediaSession(e,f){
|
function mediaSession(e: Entry,f: Feed|undefined){
|
||||||
if(!('mediaSession' in navigator)) return;
|
if(!('mediaSession' in navigator)) return;
|
||||||
navigator.mediaSession.metadata=new MediaMetadata({
|
navigator.mediaSession.metadata=new MediaMetadata({
|
||||||
title:entryName(e).text, artist:f?(f.title||f.id):'', album:f?(f.title||''):'',
|
title:entryName(e).text, artist:f?(f.title||f.id):'', album:f?(f.title||''):'',
|
||||||
artwork:(e.image||(f&&f.image))?[{src:artSrc(e.image||f.image),sizes:'512x512'}]:[],
|
artwork:(e.image||(f&&f.image))?[{src:artSrc((e.image||f?.image)!),sizes:'512x512'}]:[],
|
||||||
});
|
});
|
||||||
const h={play:()=>audio.play(),pause:()=>audio.pause(),
|
const h: Record<string,()=>void>={play:()=>audio.play(),pause:()=>audio.pause(),
|
||||||
seekbackward:()=>audio.currentTime-=15,seekforward:()=>audio.currentTime+=30};
|
seekbackward:()=>audio.currentTime-=15,seekforward:()=>audio.currentTime+=30};
|
||||||
for(const k in h){ try{navigator.mediaSession.setActionHandler(k as MediaSessionAction,h[k])}catch{} }
|
for(const k in h){ try{navigator.mediaSession.setActionHandler(k as MediaSessionAction,h[k])}catch{} }
|
||||||
}
|
}
|
||||||
@@ -94,7 +94,7 @@ audio.addEventListener('timeupdate',()=>{
|
|||||||
const d=audio.duration||player.entry?.duration||0;
|
const d=audio.duration||player.entry?.duration||0;
|
||||||
$('#pcur').textContent=clock(audio.currentTime);
|
$('#pcur').textContent=clock(audio.currentTime);
|
||||||
$('#pdur').textContent=clock(d);
|
$('#pdur').textContent=clock(d);
|
||||||
if(d) $('#seek').value=String(Math.round(audio.currentTime/d*1000));
|
if(d) $<HTMLInputElement>('#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
|
// 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.
|
// that would write back whatever the list said, however old.
|
||||||
if(!audio.paused) player.moved=true;
|
if(!audio.paused) player.moved=true;
|
||||||
@@ -117,29 +117,29 @@ function savePos(){
|
|||||||
const duration=isFinite(audio.duration)?Math.floor(audio.duration):null;
|
const duration=isFinite(audio.duration)?Math.floor(audio.duration):null;
|
||||||
navigator.sendBeacon?.(
|
navigator.sendBeacon?.(
|
||||||
// A beacon sends no headers, so the trace goes in the address (#192).
|
// A beacon sends no headers, so the trace goes in the address (#192).
|
||||||
`/api/entries/${encodeURIComponent(player.feed)}/${encodeURIComponent(player.guid)}/position?traceparent=${traceparent()}`,
|
`/api/entries/${encodeURIComponent(player.feed!)}/${encodeURIComponent(player.guid!)}/position?traceparent=${traceparent()}`,
|
||||||
new Blob([JSON.stringify({secs:Math.floor(audio.currentTime),duration})],{type:'application/json'}));
|
new Blob([JSON.stringify({secs:Math.floor(audio.currentTime),duration})],{type:'application/json'}));
|
||||||
}
|
}
|
||||||
audio.addEventListener('pause',savePos);
|
audio.addEventListener('pause',savePos);
|
||||||
audio.addEventListener('ended',()=>{savePos();markPlayed();$('#pplay').innerHTML=ICON.play});
|
audio.addEventListener('ended',()=>{savePos();markPlayed();$<HTMLButtonElement>('#pplay').innerHTML=ICON.play});
|
||||||
// body.playing is what sets the EQ bars moving.
|
// body.playing is what sets the EQ bars moving.
|
||||||
audio.addEventListener('play',()=>{ $('#pplay').innerHTML=ICON.pause; document.body.classList.add('playing'); });
|
audio.addEventListener('play',()=>{ $<HTMLButtonElement>('#pplay').innerHTML=ICON.pause; document.body.classList.add('playing'); });
|
||||||
audio.addEventListener('pause',()=>{ $('#pplay').innerHTML=ICON.play; document.body.classList.remove('playing'); });
|
audio.addEventListener('pause',()=>{ $<HTMLButtonElement>('#pplay').innerHTML=ICON.play; document.body.classList.remove('playing'); });
|
||||||
for(const ev of ['play','pause','ended']) audio.addEventListener(ev,syncPlayButtons);
|
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
|
/// 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.
|
/// pane's, the toolbar's. Only the bar's used to change, so the others said play while it played.
|
||||||
function syncPlayButtons(){
|
function syncPlayButtons(){
|
||||||
const on=(guid,enc?)=>!audio.paused&&player.guid===guid&&(enc==null||player.enc===enc);
|
const on=(guid: string|null,enc?: number)=>!audio.paused&&player.guid===guid&&(enc==null||player.enc===enc);
|
||||||
const paint=(b,now,idle)=>{
|
const paint=(b: HTMLElement,now: boolean,idle: string)=>{
|
||||||
const label=now?'Pause':idle;
|
const label=now?'Pause':idle;
|
||||||
if(b.title===label) return;
|
if(b.title===label) return;
|
||||||
b.title=label; b.setAttribute('aria-label',label); b.innerHTML=now?ICON.pause:ICON.play;
|
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 $$('#eps .ep [data-a=play]')) paint(b,on(b.closest<HTMLElement>('.ep')!.dataset.guid!),'Play');
|
||||||
for(const b of $$('#files [data-a=play][data-enc], #detail [data-a=play][data-enc]'))
|
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');
|
paint(b,on(S.sel,Number(b.dataset.enc)),'Play');
|
||||||
const e=cur(); paint($('#tbPlay'),!!e&&on(e.guid),'Play the selected item');
|
const e=cur(); paint($<HTMLButtonElement>('#tbPlay'),!!e&&on(e.guid),'Play the selected item');
|
||||||
}
|
}
|
||||||
for(const ev of ['play','pause','timeupdate']) audio.addEventListener(ev,syncListening);
|
for(const ev of ['play','pause','timeupdate']) audio.addEventListener(ev,syncListening);
|
||||||
window.addEventListener('beforeunload',savePos);
|
window.addEventListener('beforeunload',savePos);
|
||||||
@@ -157,27 +157,27 @@ audio.addEventListener('playing',()=>{
|
|||||||
asked=0; waited=0;
|
asked=0; waited=0;
|
||||||
});
|
});
|
||||||
audio.addEventListener('error',()=>{ if(audio.error) report('player_error',{msg:`media error ${audio.error.code}: ${audio.error.message}`,...playing()}); });
|
audio.addEventListener('error',()=>{ if(audio.error) report('player_error',{msg:`media error ${audio.error.code}: ${audio.error.message}`,...playing()}); });
|
||||||
$('#pplay').onclick=()=>audio.paused?audio.play():audio.pause();
|
$<HTMLButtonElement>('#pplay').onclick=()=>audio.paused?audio.play():audio.pause();
|
||||||
$('#pback').onclick=()=>audio.currentTime-=15;
|
$<HTMLButtonElement>('#pback').onclick=()=>audio.currentTime-=15;
|
||||||
$('#pfwd').onclick=()=>audio.currentTime+=30;
|
$<HTMLButtonElement>('#pfwd').onclick=()=>audio.currentTime+=30;
|
||||||
$('#seek').oninput=e=>{const d=audio.duration;if(d)audio.currentTime=d*e.target.value/1000};
|
$<HTMLInputElement>('#seek').oninput=(e: Event)=>{const d=audio.duration;if(d)audio.currentTime=d*Number((e.target as HTMLInputElement).value)/1000};
|
||||||
$('#rate').onchange=e=>{audio.playbackRate=+e.target.value;localStorage.setItem('ipx.rate',e.target.value)};
|
$<HTMLSelectElement>('#rate').onchange=(e: Event)=>{audio.playbackRate=+(e.target as HTMLSelectElement).value;localStorage.setItem('ipx.rate',(e.target as HTMLSelectElement).value)};
|
||||||
$('#vol').oninput=e=>{audio.volume=e.target.value/100;localStorage.setItem('ipx.vol',e.target.value)};
|
$<HTMLInputElement>('#vol').oninput=(e: Event)=>{audio.volume=Number((e.target as HTMLInputElement).value)/100;localStorage.setItem('ipx.vol',(e.target as HTMLInputElement).value)};
|
||||||
/// DeadAir's switch beside the speed: on or off for the show that is playing. The state is in the
|
/// DeadAir's switch beside the speed: on or off for the show that is playing. The state is in the
|
||||||
/// icon's shape and aria-pressed, never a colour.
|
/// icon's shape and aria-pressed, never a colour.
|
||||||
function paintDead(){
|
function paintDead(){
|
||||||
const b=$('#pdead'), f=S.feeds.find(x=>x.id===player.feed);
|
const b=$<HTMLButtonElement>('#pdead'), f=S.feeds.find(x=>x.id===player.feed);
|
||||||
b.hidden=!f||document.body.classList.contains('has-video');
|
b.hidden=!f||document.body.classList.contains('has-video');
|
||||||
if(b.hidden) return;
|
if(!f||b.hidden) return;
|
||||||
b.setAttribute('aria-pressed',String(!!f.deadair));
|
b.setAttribute('aria-pressed',String(!!f.deadair));
|
||||||
b.innerHTML=f.deadair?ICON.deadairOn:ICON.deadair;
|
b.innerHTML=f.deadair?ICON.deadairOn:ICON.deadair;
|
||||||
b.title=f.deadair?'DeadAir is cutting the dead air from this show':'DeadAir: cut the dead air from this show';
|
b.title=f.deadair?'DeadAir is cutting the dead air from this show':'DeadAir: cut the dead air from this show';
|
||||||
}
|
}
|
||||||
$('#pdead').onclick=async()=>{
|
$<HTMLButtonElement>('#pdead').onclick=async()=>{
|
||||||
const f=S.feeds.find(x=>x.id===player.feed); if(!f) return;
|
const f=S.feeds.find(x=>x.id===player.feed); if(!f) return;
|
||||||
const on=!f.deadair;
|
const on=!f.deadair;
|
||||||
try{ await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({deadair:on})}); }
|
try{ await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({deadair:on})}); }
|
||||||
catch(err){ toast(err.message,true); return; }
|
catch(err){ toast(why(err),true); return; }
|
||||||
f.deadair=on; paintDead();
|
f.deadair=on; paintDead();
|
||||||
const switched=await switchVersion();
|
const switched=await switchVersion();
|
||||||
toast(!on?'DeadAir is off for this show':switched?'DeadAir is cutting the dead air from this show'
|
toast(!on?'DeadAir is off for this show':switched?'DeadAir is cutting the dead air from this show'
|
||||||
@@ -188,14 +188,14 @@ $('#pdead').onclick=async()=>{
|
|||||||
/// turned on or off for its show. True if it changed version: a copy not made yet plays next time.
|
/// turned on or off for its show. True if it changed version: a copy not made yet plays next time.
|
||||||
async function switchVersion(){
|
async function switchVersion(){
|
||||||
if(!player.guid) return false;
|
if(!player.guid) return false;
|
||||||
const [feed,guid]=[player.feed,player.guid].map(encodeURIComponent);
|
const [feed,guid]=[player.feed!,player.guid!].map(s=>encodeURIComponent(s));
|
||||||
// Saved and waited for, not beaconed, so the entry asked for next has this place in it.
|
// Saved and waited for, not beaconed, so the entry asked for next has this place in it.
|
||||||
if(audio.readyState){
|
if(audio.readyState){
|
||||||
await api(`/api/entries/${feed}/${guid}/position`,{method:'POST',body:JSON.stringify({
|
await api(`/api/entries/${feed}/${guid}/position`,{method:'POST',body:JSON.stringify({
|
||||||
secs:Math.floor(audio.currentTime),duration:isFinite(audio.duration)?Math.floor(audio.duration):null})}).catch(()=>{});
|
secs:Math.floor(audio.currentTime),duration:isFinite(audio.duration)?Math.floor(audio.duration):null})}).catch(()=>{});
|
||||||
player.moved=false;
|
player.moved=false;
|
||||||
}
|
}
|
||||||
const e=await api(`/api/entries/${feed}/${guid}`).catch(()=>null);
|
const e: Entry|null=await api(`/api/entries/${feed}/${guid}`).catch((): null=>null);
|
||||||
const enc=e&&e.enclosures.find(x=>x.id===player.enc);
|
const enc=e&&e.enclosures.find(x=>x.id===player.enc);
|
||||||
if(!enc||mediaSrc(enc)===player.src) return false;
|
if(!enc||mediaSrc(enc)===player.src) return false;
|
||||||
const was=!audio.paused;
|
const was=!audio.paused;
|
||||||
@@ -205,13 +205,13 @@ async function switchVersion(){
|
|||||||
if(was) (wait||Promise.resolve()).then(()=>audio.play()).catch(()=>{});
|
if(was) (wait||Promise.resolve()).then(()=>audio.play()).catch(()=>{});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
$('#pclose').onclick=()=>{savePos();audio.pause();dropHls();audio.removeAttribute('src');player.guid=null;$('#player').classList.remove('on');document.body.classList.remove('has-video');renderEntries();
|
$<HTMLButtonElement>('#pclose').onclick=()=>{savePos();audio.pause();dropHls();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.
|
// Called here, not left to the pause event: closing a player already paused fires none.
|
||||||
syncListening()};
|
syncListening()};
|
||||||
(function restore(){
|
(function restore(){
|
||||||
const r=localStorage.getItem('ipx.rate'), v=localStorage.getItem('ipx.vol');
|
const r=localStorage.getItem('ipx.rate'), v=localStorage.getItem('ipx.vol');
|
||||||
if(r){$('#rate').value=r;audio.playbackRate=+r}
|
if(r){$<HTMLSelectElement>('#rate').value=r;audio.playbackRate=+r}
|
||||||
if(v){$('#vol').value=v;audio.volume=Number(v)/100}
|
if(v){$<HTMLInputElement>('#vol').value=v;audio.volume=Number(v)/100}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
document.addEventListener('keydown',ev=>{
|
document.addEventListener('keydown',ev=>{
|
||||||
@@ -223,34 +223,34 @@ document.addEventListener('keydown',ev=>{
|
|||||||
if(ev.key===' '&&player.guid){ev.preventDefault();audio.paused?audio.play():audio.pause()}
|
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==='ArrowLeft'&&player.guid){audio.currentTime-=15}
|
||||||
else if(ev.key==='ArrowRight'&&player.guid){audio.currentTime+=30}
|
else if(ev.key==='ArrowRight'&&player.guid){audio.currentTime+=30}
|
||||||
else if(ev.key==='/'){ev.preventDefault();$('#epSearch')?.focus()}
|
else if(ev.key==='/'){ev.preventDefault();$<HTMLInputElement>('#epSearch')?.focus()}
|
||||||
else if(!ev.ctrlKey&&!ev.metaKey&&!ev.altKey&&!$('#modal').classList.contains('on')) typed(ev);
|
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
|
// 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
|
// 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.
|
// own shortcuts still work, or while a dialog is open.
|
||||||
const GO={a:':all',d:':directory',l:':listening'};
|
const GO: Record<string,string>={a:':all',d:':directory',l:':listening'};
|
||||||
let gAt=0;
|
let gAt=0;
|
||||||
function stepEntry(by){
|
function stepEntry(by: number){
|
||||||
if(VIEWS[S.feed]?.url||!S.entries.length) return;
|
if(VIEWS[S.feed ?? '']?.url||!S.entries.length) return;
|
||||||
const i=S.entries.findIndex(x=>x.guid===S.sel);
|
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))];
|
const e=S.entries[i<0?0:Math.min(S.entries.length-1,Math.max(0,i+by))];
|
||||||
selectEntry(e,true);
|
selectEntry(e,true);
|
||||||
$(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`)?.scrollIntoView({block:'nearest'});
|
$(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`)?.scrollIntoView({block:'nearest'});
|
||||||
}
|
}
|
||||||
function stepFeed(by){
|
function stepFeed(by: number){
|
||||||
const rows=$$('#feedlist [data-id]'), i=rows.findIndex(r=>r.dataset.id===S.feed), id=rows[i+by]?.dataset.id;
|
const rows=$$('#feedlist [data-id]'), i=rows.findIndex(r=>r.dataset.id===S.feed), id=rows[i+by]?.dataset.id;
|
||||||
if(!id) return;
|
if(!id) return;
|
||||||
selectFeed(id);
|
selectFeed(id);
|
||||||
$(`#feedlist [data-id="${CSS.escape(id)}"]`)?.scrollIntoView({block:'nearest'});
|
$(`#feedlist [data-id="${CSS.escape(id)}"]`)?.scrollIntoView({block:'nearest'});
|
||||||
}
|
}
|
||||||
const KEYS={
|
const KEYS: Record<string,()=>void>={
|
||||||
j:()=>stepEntry(1), n:()=>stepEntry(1), k:()=>stepEntry(-1), p:()=>stepEntry(-1),
|
j:()=>stepEntry(1), n:()=>stepEntry(1), k:()=>stepEntry(-1), p:()=>stepEntry(-1),
|
||||||
J:()=>stepFeed(1), K:()=>stepFeed(-1),
|
J:()=>stepFeed(1), K:()=>stepFeed(-1),
|
||||||
// The toolbar's own buttons, so a key does exactly what the click does, and nothing while
|
// The toolbar's own buttons, so a key does exactly what the click does, and nothing while
|
||||||
// they are disabled.
|
// they are disabled.
|
||||||
o:()=>$('#tbPlay').click(), m:()=>$('#tbRead').click(), s:()=>$('#tbFlag').click(),
|
o:()=>$<HTMLButtonElement>('#tbPlay').click(), m:()=>$<HTMLButtonElement>('#tbRead').click(), s:()=>$<HTMLButtonElement>('#tbFlag').click(),
|
||||||
v:()=>{ const e=cur(); if(e&&e.link) window.open(e.link,'_blank','noopener'); },
|
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(),
|
A:()=>$('#content .fhead [data-a="read"], #content .fhead [data-a="readall"]')?.click(),
|
||||||
r:async()=>{ await loadFeeds(true); if(S.feed){ renderFeed(); loadEntries(); } },
|
r:async()=>{ await loadFeeds(true); if(S.feed){ renderFeed(); loadEntries(); } },
|
||||||
@@ -259,7 +259,7 @@ const KEYS={
|
|||||||
'?':()=>keysModal(),
|
'?':()=>keysModal(),
|
||||||
g:()=>{ gAt=Date.now(); },
|
g:()=>{ gAt=Date.now(); },
|
||||||
};
|
};
|
||||||
function typed(ev){
|
function typed(ev: KeyboardEvent){
|
||||||
// The second key of a g pair counts only if it follows within a second and a half.
|
// 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 pair=Date.now()-gAt<1500; gAt=0;
|
||||||
const fn=pair ? (GO[ev.key]&&(()=>selectFeed(GO[ev.key])))||(ev.key==='s'&&prefsModal) : KEYS[ev.key];
|
const fn=pair ? (GO[ev.key]&&(()=>selectFeed(GO[ev.key])))||(ev.key==='s'&&prefsModal) : KEYS[ev.key];
|
||||||
@@ -268,7 +268,7 @@ function typed(ev){
|
|||||||
}
|
}
|
||||||
/// What ? shows: every key, grouped as Feedly's own list is.
|
/// What ? shows: every key, grouped as Feedly's own list is.
|
||||||
function keysModal(){
|
function keysModal(){
|
||||||
const k=s=>`<kbd>${esc(s)}</kbd>`, g=c=>k('g')+' '+k(c);
|
const k=(s: string)=>`<kbd>${esc(s)}</kbd>`, g=(c: string)=>k('g')+' '+k(c);
|
||||||
const rows=[
|
const rows=[
|
||||||
['Go to'],
|
['Go to'],
|
||||||
[g('a'),'All Subscriptions'],[g('d'),'Directory'],
|
[g('a'),'All Subscriptions'],[g('d'),'Directory'],
|
||||||
|
|||||||
@@ -37,13 +37,13 @@ function setTheme(name = theme.name, mode = theme.mode, save = false){
|
|||||||
root.dataset.mode = !both ? 'light'
|
root.dataset.mode = !both ? 'light'
|
||||||
: theme.mode === 'auto' ? (systemDark && !systemDark.matches ? 'light' : 'dark') : theme.mode;
|
: theme.mode === 'auto' ? (systemDark && !systemDark.matches ? 'light' : 'dark') : theme.mode;
|
||||||
// The tab's icon in the same variant as the logo on the page.
|
// The tab's icon in the same variant as the logo on the page.
|
||||||
const fav = $('#favicon'); if(fav) fav.href = fav.dataset[root.dataset.mode];
|
const fav = $<HTMLLinkElement>('#favicon'); if(fav) fav.href = fav.dataset[root.dataset.mode!]!;
|
||||||
// The top bar's colour for an iPhone before iOS 26 to paint behind the status bar, as Android's
|
// The top bar's colour for an iPhone before iOS 26 to paint behind the status bar, as Android's
|
||||||
// toolbar does; the page's where the bar is see-through (Glass). iOS 26 reads the bar itself.
|
// toolbar does; the page's where the bar is see-through (Glass). iOS 26 reads the bar itself.
|
||||||
const tc = $('meta[name=theme-color]'), bar = $('#topbar');
|
const tc = $<HTMLMetaElement>('meta[name=theme-color]'), bar = $('#topbar');
|
||||||
if(tc){ const c = bar ? getComputedStyle(bar).backgroundColor : ''; tc.content = /^rgb\(/.test(c) ? c : getComputedStyle(root).backgroundColor; }
|
if(tc){ const c = bar ? getComputedStyle(bar).backgroundColor : ''; tc.content = /^rgb\(/.test(c) ? c : getComputedStyle(root).backgroundColor; }
|
||||||
const sel = $('#stheme'); if(sel) sel.value = theme.name;
|
const sel = $<HTMLSelectElement>('#stheme'); if(sel) sel.value = theme.name;
|
||||||
const ms = $('#smode'); if(ms) ms.value = theme.mode;
|
const ms = $<HTMLSelectElement>('#smode'); if(ms) ms.value = theme.mode;
|
||||||
const mf = $('#smodefield'); if(mf) mf.hidden = !both;
|
const mf = $('#smodefield'); if(mf) mf.hidden = !both;
|
||||||
if(save) saveTheme();
|
if(save) saveTheme();
|
||||||
}
|
}
|
||||||
@@ -62,6 +62,6 @@ systemDark?.addEventListener?.('change', () => { if(theme.mode === 'auto') setTh
|
|||||||
// becomes its cookie, once.
|
// becomes its cookie, once.
|
||||||
let name: string | null = null, mode: string | null = null;
|
let name: string | null = null, mode: string | null = null;
|
||||||
try{ name = localStorage.getItem('ipx.theme'); mode = localStorage.getItem('ipx.mode'); }catch{}
|
try{ name = localStorage.getItem('ipx.theme'); mode = localStorage.getItem('ipx.mode'); }catch{}
|
||||||
if(OLD_THEMES[name]) [name, mode] = OLD_THEMES[name];
|
if(name&&OLD_THEMES[name]) [name, mode] = OLD_THEMES[name];
|
||||||
setTheme(name ?? undefined, mode ?? undefined, !!name);
|
setTheme(name ?? undefined, mode ?? undefined, !!name);
|
||||||
})();
|
})();
|
||||||
|
|||||||
153
web/src/types.ts
Normal file
153
web/src/types.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
// What ipx's API sends the page, as types for the type-check (#235), each named after the Rust
|
||||||
|
// struct it mirrors in crates/ipx/src. Types only: swc strips this file to nothing, so it is not
|
||||||
|
// in web/build.mjs's list; tsconfig.json's glob reads it. A field added there goes here too.
|
||||||
|
// An Option is `T | null`, as serde writes None.
|
||||||
|
|
||||||
|
/// A feed in your list, `/api/feeds` and the live `feed_row` (web.rs FeedRow).
|
||||||
|
interface Feed {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
title: string | null;
|
||||||
|
image: string | null;
|
||||||
|
folder: string | null;
|
||||||
|
category: string | null;
|
||||||
|
feed_category: string | null;
|
||||||
|
keywords: string[];
|
||||||
|
blocked: string[];
|
||||||
|
deadair: boolean;
|
||||||
|
deadair_pause: number;
|
||||||
|
allow_explicit: boolean;
|
||||||
|
auto_download: boolean;
|
||||||
|
max_new_per_check: number | null;
|
||||||
|
/// The OPML subscription it came from.
|
||||||
|
group: string | null;
|
||||||
|
orphaned: boolean;
|
||||||
|
managed: boolean;
|
||||||
|
schedule: string | null;
|
||||||
|
schedule_mins: number | null;
|
||||||
|
every_mins: number;
|
||||||
|
last_checked: number | null;
|
||||||
|
next_check: number | null;
|
||||||
|
last_error: string | null;
|
||||||
|
failing: {reason: string, new_url: string | null} | null;
|
||||||
|
entries: number;
|
||||||
|
downloaded: number;
|
||||||
|
unread: number;
|
||||||
|
subscribers: number;
|
||||||
|
pinned: boolean;
|
||||||
|
private: boolean;
|
||||||
|
private_by_address: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An item, in a page of `/api/entries` (db.rs EntryRow).
|
||||||
|
interface Entry {
|
||||||
|
guid: string;
|
||||||
|
feed_id: string;
|
||||||
|
title: string | null;
|
||||||
|
link: string | null;
|
||||||
|
published: number | null;
|
||||||
|
description: string | null;
|
||||||
|
read: boolean;
|
||||||
|
flagged: boolean;
|
||||||
|
image: string | null;
|
||||||
|
duration: number | null;
|
||||||
|
episode: number | null;
|
||||||
|
season: number | null;
|
||||||
|
position: number;
|
||||||
|
enclosures: Enclosure[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An item's file (db.rs EncRow).
|
||||||
|
interface Enclosure {
|
||||||
|
id: number;
|
||||||
|
/// Seconds DeadAir's copy saves, when you play it from one.
|
||||||
|
deadair?: number | null;
|
||||||
|
deadair_pending?: boolean;
|
||||||
|
feed_id: string;
|
||||||
|
guid: string;
|
||||||
|
url: string;
|
||||||
|
mime: string | null;
|
||||||
|
length: number | null;
|
||||||
|
path: string | null;
|
||||||
|
state: string;
|
||||||
|
last_error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `/api/me`.
|
||||||
|
interface Me {
|
||||||
|
name: string;
|
||||||
|
admin: boolean;
|
||||||
|
sign_out: string;
|
||||||
|
blocked: string[];
|
||||||
|
deadair_saved: number;
|
||||||
|
theme: {name: string, mode: string} | null;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A feed as the Directory lists it (web.rs PopularRow): never its address.
|
||||||
|
interface DirFeed {
|
||||||
|
id: string;
|
||||||
|
title: string | null;
|
||||||
|
image: string | null;
|
||||||
|
subscribers: number;
|
||||||
|
subscribed: boolean;
|
||||||
|
/// Its id in your list, when it is yours.
|
||||||
|
feed: string | null;
|
||||||
|
category: string | null;
|
||||||
|
subcategory: string | null;
|
||||||
|
podcast: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The page's own state (util.ts `S`).
|
||||||
|
interface State {
|
||||||
|
feeds: Feed[];
|
||||||
|
/// The feed or place open: a feed's id, or a view such as ':all'.
|
||||||
|
feed: string | null;
|
||||||
|
entries: Entry[];
|
||||||
|
total: number;
|
||||||
|
offset: number;
|
||||||
|
filter: string;
|
||||||
|
q: string;
|
||||||
|
/// The selected item's guid.
|
||||||
|
sel: string | null;
|
||||||
|
me: Me | null;
|
||||||
|
sort: {col: string, dir: 'asc' | 'desc'};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An account, `/api/users` (web.rs list_users).
|
||||||
|
interface Account {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
admin: boolean;
|
||||||
|
/// Has a password; none signs in through the proxy.
|
||||||
|
password: boolean;
|
||||||
|
created: number | null;
|
||||||
|
last_login: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A line of the daemon's log, `/api/logs` (logbuf.rs LogLine).
|
||||||
|
interface LogLine {
|
||||||
|
seq: number;
|
||||||
|
ts: number;
|
||||||
|
level: string;
|
||||||
|
target: string;
|
||||||
|
msg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `/api/directory-admin`: the servers sharing with the directory and the feeds it blocks
|
||||||
|
/// (directory lib.rs Instance, Blocked).
|
||||||
|
interface DirAdmin {
|
||||||
|
instances: {name: string, feeds: number, subscribers: number}[];
|
||||||
|
blocked: {id: string, url: string | null, title: string | null, at: number}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One of a listed feed's latest items, on its page in the Directory (web.rs ListedItem): never
|
||||||
|
/// its files.
|
||||||
|
interface ListedItem {
|
||||||
|
guid: string;
|
||||||
|
title: string | null;
|
||||||
|
link: string | null;
|
||||||
|
published: number | null;
|
||||||
|
duration: number | null;
|
||||||
|
description: string | null;
|
||||||
|
}
|
||||||
@@ -1,15 +1,18 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
// `any`: the page reads .value, .dataset and .onclick off whatever it looks up, and the
|
// `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.
|
// smoke test, not the type checker, is what makes sure a selector exists.
|
||||||
const $ = (s: string, r: ParentNode = document): any => r.querySelector(s);
|
const $ = <T extends HTMLElement = HTMLElement>(s: string, r: ParentNode = document): T => r.querySelector<T>(s)!;
|
||||||
const $$ = (s: string, r: ParentNode = document): any[] => [...r.querySelectorAll(s)];
|
const $$ = <T extends HTMLElement = HTMLElement>(s: string, r: ParentNode = document): T[] => [...r.querySelectorAll<T>(s)];
|
||||||
const esc = s => (s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
/// What went wrong, from whatever a catch caught: the page throws Errors, but a catch cannot know.
|
||||||
|
const why = (e: unknown): string => e instanceof Error ? e.message : String(e);
|
||||||
|
const ESCAPED: Record<string,string> = {'&':'&','<':'<','>':'>','"':'"',"'":'''};
|
||||||
|
const esc = (s: string|null|undefined) => (s??'').replace(/[&<>"']/g,c=>ESCAPED[c]);
|
||||||
// Icons: Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com
|
// 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
|
// 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
|
// 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.
|
// 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 fa=(box: string,body: string)=>`<svg class="i" viewBox="${box}" aria-hidden="true">${body}</svg>`;
|
||||||
const ICON={
|
const ICON: Record<string,string>={
|
||||||
share:fa('0 0 512 512','<path fill="currentColor" d="M384 192c53 0 96-43 96-96s-43-96-96-96-96 43-96 96c0 5.4 .5 10.8 1.3 16L159.6 184.1c-16.9-15-39.2-24.1-63.6-24.1-53 0-96 43-96 96s43 96 96 96c24.4 0 46.6-9.1 63.6-24.1L289.3 400c-.9 5.2-1.3 10.5-1.3 16 0 53 43 96 96 96s96-43 96-96-43-96-96-96c-24.4 0-46.6 9.1-63.6 24.1L190.7 272c.9-5.2 1.3-10.5 1.3-16s-.5-10.8-1.3-16l129.7-72.1c16.9 15 39.2 24.1 63.6 24.1z"/>'), // solid/share-nodes
|
share:fa('0 0 512 512','<path fill="currentColor" d="M384 192c53 0 96-43 96-96s-43-96-96-96-96 43-96 96c0 5.4 .5 10.8 1.3 16L159.6 184.1c-16.9-15-39.2-24.1-63.6-24.1-53 0-96 43-96 96s43 96 96 96c24.4 0 46.6-9.1 63.6-24.1L289.3 400c-.9 5.2-1.3 10.5-1.3 16 0 53 43 96 96 96s96-43 96-96-43-96-96-96c-24.4 0-46.6 9.1-63.6 24.1L190.7 272c.9-5.2 1.3-10.5 1.3-16s-.5-10.8-1.3-16l129.7-72.1c16.9 15 39.2 24.1 63.6 24.1z"/>'), // solid/share-nodes
|
||||||
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
|
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
|
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
|
||||||
@@ -58,15 +61,15 @@ 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
|
// One meaning per icon: minus unsubscribes, x closes or cancels, plus adds or subscribes, and a
|
||||||
// dialog's confirm button carries the icon of what it does. Words go in the tooltip.
|
// dialog's confirm button carries the icon of what it does. Words go in the tooltip.
|
||||||
// The page's own buttons name their icon; this draws it in, ahead of any label they carry.
|
// 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]);
|
for(const b of $$('[data-icon]')) b.insertAdjacentHTML('afterbegin',ICON[b.dataset.icon!]);
|
||||||
|
|
||||||
/// An episode whose DeadAir copy is still being made: it plays as it is until then, and the line
|
/// An episode whose DeadAir copy is still being made: it plays as it is until then, and the line
|
||||||
/// becomes how much shorter it is once the copy is ready (the dead_air event).
|
/// becomes how much shorter it is once the copy is ready (the dead_air event).
|
||||||
const DEAD_PENDING='<span class="dapend" title="DeadAir is still working on this episode. Until it is done, it plays at its full length.">Cutting dead air…</span>';
|
const DEAD_PENDING='<span class="dapend" title="DeadAir is still working on this episode. Until it is done, it plays at its full length.">Cutting dead air…</span>';
|
||||||
/// What DeadAir took off an episode, as the list says it.
|
/// What DeadAir took off an episode, as the list says it.
|
||||||
function shorter(secs){ return secs<60?`${secs} s shorter`:`${Math.round(secs/60)} min shorter`; }
|
function shorter(secs: number){ return secs<60?`${secs} s shorter`:`${Math.round(secs/60)} min shorter`; }
|
||||||
/// A total of time saved: "3 h 12 min", "12 min", "40 s".
|
/// A total of time saved: "3 h 12 min", "12 min", "40 s".
|
||||||
function spanText(secs){
|
function spanText(secs: number){
|
||||||
const h=Math.floor(secs/3600), m=Math.floor(secs%3600/60);
|
const h=Math.floor(secs/3600), m=Math.floor(secs%3600/60);
|
||||||
return h?`${h} h ${m} min`:m?`${m} min`:`${secs} s`;
|
return h?`${h} h ${m} min`:m?`${m} min`:`${secs} s`;
|
||||||
}
|
}
|
||||||
@@ -105,12 +108,12 @@ async function api(url: string, opts?: RequestInit): Promise<any>{
|
|||||||
// A page in place of ipx's words is Cloudflare or Caddy answering for it: the message became a
|
// A page in place of ipx's words is Cloudflare or Caddy answering for it: the message became a
|
||||||
// whole 502 page (#219).
|
// whole 502 page (#219).
|
||||||
if(!r.ok) throw new Error((!/html/.test(r.headers.get('Content-Type')||'')&&await r.text().catch(()=>''))||`${r.status} ${r.statusText}`.trim());
|
if(!r.ok) throw new Error((!/html/.test(r.headers.get('Content-Type')||'')&&await r.text().catch(()=>''))||`${r.status} ${r.statusText}`.trim());
|
||||||
return r.status===204?null:r.json().catch(()=>null);
|
return r.status===204?null:r.json().catch((): null=>null);
|
||||||
}
|
}
|
||||||
// navigator.clipboard only exists in a secure context. Served over plain HTTP on a LAN
|
// 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.
|
// address it is undefined, so fall back to the old selection-based copy.
|
||||||
async function copyText(text,btn){
|
async function copyText(text: string,btn?: HTMLElement){
|
||||||
const flash=ok=>{
|
const flash=(ok: boolean)=>{
|
||||||
if(!btn) return;
|
if(!btn) return;
|
||||||
// The button is an icon, so it is the markup that has to come back, not just its text.
|
// The button is an icon, so it is the markup that has to come back, not just its text.
|
||||||
const was=btn.innerHTML;
|
const was=btn.innerHTML;
|
||||||
@@ -144,14 +147,14 @@ async function copyText(text,btn){
|
|||||||
///
|
///
|
||||||
/// ponytail: a guess from the address. A private feed whose key has another name is shared
|
/// ponytail: a guess from the address. A private feed whose key has another name is shared
|
||||||
/// without asking; add its pattern here when one turns up.
|
/// without asking; add its pattern here when one turns up.
|
||||||
async function share(title: string, url: string, btn?){
|
async function share(title: string, url: string, btn?: HTMLElement){
|
||||||
if(/patreon\.com\/|\/\/[^/]*@|[?&][^=]*(auth|token|key|secret|pass|sig)[^=]*=/i.test(url)
|
if(/patreon\.com\/|\/\/[^/]*@|[?&][^=]*(auth|token|key|secret|pass|sig)[^=]*=/i.test(url)
|
||||||
&& !confirm('This address looks like it includes your own access to the feed, and anyone you '
|
&& !confirm('This address looks like it includes your own access to the feed, and anyone you '
|
||||||
+'send it to could use it as you.\n\nShare it anyway?')) return;
|
+'send it to could use it as you.\n\nShare it anyway?')) return;
|
||||||
if(navigator.share){
|
if(navigator.share){
|
||||||
// Cancelling the sheet rejects too, and is not a failure worth a word.
|
// Cancelling the sheet rejects too, and is not a failure worth a word.
|
||||||
try{ await navigator.share({title, url}); }
|
try{ await navigator.share({title, url}); }
|
||||||
catch(e){ if(e.name!=='AbortError') toast(e.message,true); }
|
catch(e){ if((e as Error)?.name!=='AbortError') toast(why(e),true); }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await copyText(url,btn);
|
await copyText(url,btn);
|
||||||
@@ -164,12 +167,12 @@ function toast(msg: string, bad?: boolean){
|
|||||||
$('#toasts').appendChild(t);
|
$('#toasts').appendChild(t);
|
||||||
setTimeout(()=>{t.style.opacity='0';t.style.transition='opacity .3s';setTimeout(()=>t.remove(),320)},bad?6000:3200);
|
setTimeout(()=>{t.style.opacity='0';t.style.transition='opacity .3s';setTimeout(()=>t.remove(),320)},bad?6000:3200);
|
||||||
}
|
}
|
||||||
const clock = s => {
|
const clock = (s: number) => {
|
||||||
s=Math.max(0,Math.floor(s||0));
|
s=Math.max(0,Math.floor(s||0));
|
||||||
const h=Math.floor(s/3600),m=Math.floor(s%3600/60),x=s%60;
|
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')}`;
|
return h?`${h}:${String(m).padStart(2,'0')}:${String(x).padStart(2,'0')}`:`${m}:${String(x).padStart(2,'0')}`;
|
||||||
};
|
};
|
||||||
const ago = t => {
|
const ago = (t: number|null) => {
|
||||||
if(!t) return 'never';
|
if(!t) return 'never';
|
||||||
const d=(Date.now()/1000)-t;
|
const d=(Date.now()/1000)-t;
|
||||||
if(d<3600) return Math.max(1,Math.round(d/60))+'m ago';
|
if(d<3600) return Math.max(1,Math.round(d/60))+'m ago';
|
||||||
@@ -177,16 +180,16 @@ const ago = t => {
|
|||||||
if(d<2592000) return Math.round(d/86400)+'d ago';
|
if(d<2592000) return Math.round(d/86400)+'d ago';
|
||||||
return new Date(t*1000).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'});
|
return new Date(t*1000).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'});
|
||||||
};
|
};
|
||||||
const dateOf = t => t?new Date(t*1000).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'}):'';
|
const dateOf = (t: number|null) => 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".
|
// 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'
|
const mb = (n: number|null) => !n?'' : n<1048576?Math.max(1,Math.round(n/1024))+' KB'
|
||||||
: n<1073741824?Math.round(n/1048576)+' MB' : (n/1073741824).toFixed(1)+' GB';
|
: n<1073741824?Math.round(n/1048576)+' MB' : (n/1073741824).toFixed(1)+' GB';
|
||||||
const initials = s => (s||'?').replace(/[^A-Za-z0-9 ]/g,'').split(/\s+/).filter(Boolean).slice(0,2).map(w=>w[0]).join('').toUpperCase()||'?';
|
const initials = (s: string|null) => (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'}`;
|
const plural=(n: number,word: string)=>`${n} ${word}${n===1?'':'s'}`;
|
||||||
// An initials tile takes one of these, by a hash of the name, so neighbours rarely match.
|
// 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 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]; };
|
const tint=(name: string)=>{ let h=0; for(const c of name||'?') h=(h*31+c.charCodeAt(0))>>>0; return TINTS[h%TINTS.length]; };
|
||||||
function tileHTML(name,cls){
|
function tileHTML(name: string,cls?: string){
|
||||||
return `<div class="art ini ${cls||''}" style="--tint:${tint(name)}">${esc(initials(name))}</div>`;
|
return `<div class="art ini ${cls||''}" style="--tint:${tint(name)}">${esc(initials(name))}</div>`;
|
||||||
}
|
}
|
||||||
/// Every image a feed names comes from iPX, which keeps a copy: fast after the first time, and
|
/// Every image a feed names comes from iPX, which keeps a copy: fast after the first time, and
|
||||||
@@ -195,25 +198,25 @@ function tileHTML(name,cls){
|
|||||||
function artSrc(url: string){
|
function artSrc(url: string){
|
||||||
return /^https?:\/\//i.test(url) ? '/api/art?u='+encodeURIComponent(url) : url;
|
return /^https?:\/\//i.test(url) ? '/api/art?u='+encodeURIComponent(url) : url;
|
||||||
}
|
}
|
||||||
function artHTML(url: string | null, name: string, cls?: string){
|
function artHTML(url: string | null | undefined, name: string, cls?: string){
|
||||||
return url
|
return url
|
||||||
? `<img class="art ${cls||''}" src="${esc(artSrc(url))}" alt="" loading="lazy" onerror="this.outerHTML=${esc(JSON.stringify(tileHTML(name,cls)))}">`
|
? `<img class="art ${cls||''}" src="${esc(artSrc(url))}" alt="" loading="lazy" onerror="this.outerHTML=${esc(JSON.stringify(tileHTML(name,cls)))}">`
|
||||||
: 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.
|
/// A folder's tile is its first four shows' art. With fewer than four to show, the folder's own.
|
||||||
function folderArt(f,kids){
|
function folderArt(f: Feed,kids: Feed[]){
|
||||||
const art=kids.filter(c=>c.image).slice(0,4);
|
const art=kids.filter(c=>c.image).slice(0,4);
|
||||||
if(art.length<4) return artHTML(f.image,f.title||f.id);
|
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.
|
// 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=>
|
return `<div class="art ini mosaic" style="--tint:${tint(f.title||f.id)}">${art.map(c=>
|
||||||
`<img src="${esc(artSrc(c.image))}" alt="" loading="lazy" onerror="this.style.visibility='hidden'">`).join('')}</div>`;
|
`<img src="${esc(artSrc(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.
|
/// 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; }
|
function nav(on: boolean){ $('#sidebar').classList.toggle('open',on); $('#scrim').hidden=!on; }
|
||||||
|
|
||||||
/* ---------------- state ---------------- */
|
/* ---------------- state ---------------- */
|
||||||
const S = {
|
const S: State = {
|
||||||
feeds:[],
|
feeds:[],
|
||||||
// Which feed (or place) and which tab were open last time, so a refresh lands back where
|
// 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.
|
// you were instead of jumping to the first feed alphabetically.
|
||||||
@@ -222,25 +225,25 @@ const S = {
|
|||||||
filter:(()=>{ try{ return localStorage.getItem('ipx.filter'); }catch{ return null; } })()||'all',
|
filter:(()=>{ try{ return localStorage.getItem('ipx.filter'); }catch{ return null; } })()||'all',
|
||||||
q:'', sel:null, me:null,
|
q:'', sel:null, me:null,
|
||||||
// The item table's order, kept across visits. The server sorts: a list arrives fifty at a time.
|
// 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; } })()
|
sort:(()=>{ try{ return JSON.parse(localStorage.getItem('ipx.sort')??'null'); }catch{ return null; } })()
|
||||||
||{col:'published',dir:'desc'},
|
||{col:'published',dir:'desc'},
|
||||||
};
|
};
|
||||||
const LIMIT = 50;
|
const LIMIT = 50;
|
||||||
|
|
||||||
const UNITS = [['m','minutes'],['h','hours'],['d','days'],['w','weeks']];
|
const UNITS = [['m','minutes'],['h','hours'],['d','days'],['w','weeks']];
|
||||||
const UNIT_MINS = {m:1, h:60, d:1440, w:10080};
|
const UNIT_MINS: Record<string,number> = {m:1, h:60, d:1440, w:10080};
|
||||||
|
|
||||||
/// Largest unit that divides evenly, so 120 reads "2 hours" not "120 minutes".
|
/// Largest unit that divides evenly, so 120 reads "2 hours" not "120 minutes".
|
||||||
function splitEvery(m){
|
function splitEvery(m: number){
|
||||||
if(!m) return {n:1, u:'h'};
|
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};
|
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'};
|
return {n:m, u:'m'};
|
||||||
}
|
}
|
||||||
function unitOptions(sel){
|
function unitOptions(sel: string){
|
||||||
return UNITS.map(([v,l]) =>
|
return UNITS.map(([v,l]) =>
|
||||||
`<option value="${v}"${sel===v?' selected':''}>${l}</option>`).join('');
|
`<option value="${v}"${sel===v?' selected':''}>${l}</option>`).join('');
|
||||||
}
|
}
|
||||||
function everyText(m){
|
function everyText(m: number){
|
||||||
if(!m) return '\u2014';
|
if(!m) return '\u2014';
|
||||||
const {n,u} = splitEvery(m);
|
const {n,u} = splitEvery(m);
|
||||||
const name = {m:'min', h:'hour', d:'day', w:'week'}[u];
|
const name = {m:'min', h:'hour', d:'day', w:'week'}[u];
|
||||||
|
|||||||
Reference in New Issue
Block a user