Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f56436033 | |||
| ae900b82ca | |||
| 40c92ad08d | |||
| 7796566dfc | |||
| 162665b010 | |||
| f2fde8cc75 | |||
| a573a07ed5 | |||
| 380a552913 | |||
| bdda9b3d2e | |||
| 868dd673f3 |
80
CHANGELOG.md
80
CHANGELOG.md
@@ -7,6 +7,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.9.0] - 2026-09-28
|
||||
|
||||
### Added
|
||||
|
||||
- A new logo, on every page, in the browser tab and on the home screen: the old radio's screen,
|
||||
its tuning scale and orange needle, drawn flat to Apple's app icon guidelines, with a dark
|
||||
version. The 2004 icon is still at /icon.png.
|
||||
- Words that hide items. Settings has a list for every feed you read, and each feed's settings a
|
||||
list of its own; an item with one of those words or phrases in its title or text is hidden
|
||||
from you and is not downloaded on your account. Other people's lists never hide anything from
|
||||
you.
|
||||
- Share buttons for a feed, an item and a file. On a phone they open the system's share sheet;
|
||||
elsewhere they copy the link. A link that looks like it carries your own access to a paid
|
||||
feed asks before it goes anywhere.
|
||||
|
||||
### Changed
|
||||
|
||||
- iPodderX is now called iPX, on every page and in the tab. The repository is rays/ipx.
|
||||
- On a phone, iPodderX looks like an iOS app: filled round buttons and capsules instead of
|
||||
outlined boxes, no rules between the bars and the page, the feed's name as a large title, a
|
||||
segmented control for the tabs, taller rows with 17px text, and dialogs as sheets from the
|
||||
bottom. Tapping a text box no longer zooms the page in. High contrast, and Increase Contrast,
|
||||
keep their outlines.
|
||||
- The Glass theme's panels catch the light: a bright rim along the top and left edges, a fainter
|
||||
one along the far edges, and a soft sheen from the top-left corner.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A new tab or home-screen icon shows up as soon as it is deployed, instead of a day later.
|
||||
- Opened from the home screen on an iPhone, the top bar, the reader's back button and the top
|
||||
of the feed list keep clear of the Dynamic Island and the status bar instead of sitting under
|
||||
them.
|
||||
- Swiping to the next or previous item takes a longer swipe, a quarter of the screen, so a
|
||||
diagonal scroll no longer jumps ahead by accident. The item follows your finger and slides
|
||||
across as it changes.
|
||||
- Swiping between items on a phone no longer shows the item list through the reader. The next
|
||||
or previous item sits beside the one you are reading and comes across with it, the end of the
|
||||
list says so, and swiping right from the first item draws the reader off the list, dimmed
|
||||
until it is uncovered. A swipe from the screen's left edge stays with the page, rather than
|
||||
Safari taking it as Back.
|
||||
|
||||
## [0.8.4] - 2026-09-19
|
||||
|
||||
### Added
|
||||
@@ -572,22 +613,23 @@ 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.
|
||||
- `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`.
|
||||
|
||||
[unreleased]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.4...main
|
||||
[0.8.4]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.3...v0.8.4
|
||||
[0.8.3]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.2...v0.8.3
|
||||
[0.8.2]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.1...v0.8.2
|
||||
[0.8.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.0...v0.8.1
|
||||
[0.8.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.7.0...v0.8.0
|
||||
[0.7.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.1...v0.7.0
|
||||
[0.6.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.0...v0.6.1
|
||||
[0.6.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.5...v0.6.0
|
||||
[0.5.5]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.4...v0.5.5
|
||||
[0.5.4]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.3...v0.5.4
|
||||
[0.5.3]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.2...v0.5.3
|
||||
[0.5.2]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.1...v0.5.2
|
||||
[0.5.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.0...v0.5.1
|
||||
[0.5.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.4.0...v0.5.0
|
||||
[0.4.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.3.0...v0.4.0
|
||||
[0.3.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.2.0...v0.3.0
|
||||
[0.2.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.1.0...v0.2.0
|
||||
[0.1.0]: https://git.sdf1.net/rays/ipodderx-rs/releases/tag/v0.1.0
|
||||
[unreleased]: https://git.sdf1.net/rays/ipx/compare/v0.9.0...main
|
||||
[0.9.0]: https://git.sdf1.net/rays/ipx/compare/v0.8.4...v0.9.0
|
||||
[0.8.4]: https://git.sdf1.net/rays/ipx/compare/v0.8.3...v0.8.4
|
||||
[0.8.3]: https://git.sdf1.net/rays/ipx/compare/v0.8.2...v0.8.3
|
||||
[0.8.2]: https://git.sdf1.net/rays/ipx/compare/v0.8.1...v0.8.2
|
||||
[0.8.1]: https://git.sdf1.net/rays/ipx/compare/v0.8.0...v0.8.1
|
||||
[0.8.0]: https://git.sdf1.net/rays/ipx/compare/v0.7.0...v0.8.0
|
||||
[0.7.0]: https://git.sdf1.net/rays/ipx/compare/v0.6.1...v0.7.0
|
||||
[0.6.1]: https://git.sdf1.net/rays/ipx/compare/v0.6.0...v0.6.1
|
||||
[0.6.0]: https://git.sdf1.net/rays/ipx/compare/v0.5.5...v0.6.0
|
||||
[0.5.5]: https://git.sdf1.net/rays/ipx/compare/v0.5.4...v0.5.5
|
||||
[0.5.4]: https://git.sdf1.net/rays/ipx/compare/v0.5.3...v0.5.4
|
||||
[0.5.3]: https://git.sdf1.net/rays/ipx/compare/v0.5.2...v0.5.3
|
||||
[0.5.2]: https://git.sdf1.net/rays/ipx/compare/v0.5.1...v0.5.2
|
||||
[0.5.1]: https://git.sdf1.net/rays/ipx/compare/v0.5.0...v0.5.1
|
||||
[0.5.0]: https://git.sdf1.net/rays/ipx/compare/v0.4.0...v0.5.0
|
||||
[0.4.0]: https://git.sdf1.net/rays/ipx/compare/v0.3.0...v0.4.0
|
||||
[0.3.0]: https://git.sdf1.net/rays/ipx/compare/v0.2.0...v0.3.0
|
||||
[0.2.0]: https://git.sdf1.net/rays/ipx/compare/v0.1.0...v0.2.0
|
||||
[0.1.0]: https://git.sdf1.net/rays/ipx/releases/tag/v0.1.0
|
||||
|
||||
28
CLAUDE.md
28
CLAUDE.md
@@ -1,4 +1,4 @@
|
||||
# Working on ipodderx-rs
|
||||
# Working on ipx
|
||||
|
||||
Notes for whoever picks this up next. Read [docs/architecture.md](docs/architecture.md) for how the
|
||||
thing is built; this file is about working on it without repeating mistakes that have already been
|
||||
@@ -6,22 +6,22 @@ made here.
|
||||
|
||||
## Where things are
|
||||
|
||||
Production is the `iPodderX` container on Tower (192.168.1.130), the `ipodderx` service of the
|
||||
Production is the `iPX` container on Tower (192.168.1.130), the `ipx` service of the
|
||||
Arcane project `content`: `/mnt/fast/arcane/projects/content/compose.yaml`. That file is what runs;
|
||||
`docker-compose.yml` in this repo is a copy, and editing it changes nothing in production.
|
||||
|
||||
| | Host | In the container |
|
||||
|---|---|---|
|
||||
| Image | `192.168.1.130:5000/ipodderx:latest` | |
|
||||
| Config | `/mnt/fast/appdata/ipodderx/config.toml`: bind address, token, trusted proxies, torrent, paths. The feeds and server settings are in the database | `/config/config.toml` |
|
||||
| Database | Postgres 18, database `ipodderx`, login `ipodderx`, on the `postgres` container of the Arcane project `databases` (`192.168.1.130:5433`). The URL is in `ipodderx.env` beside the compose file (`/mnt/fast/arcane/projects/content/ipodderx.env`, mode 600), passed to the container as `IPX_DATABASE_URL`. A relative `env_file`: Arcane runs compose in its own container, where `/mnt/fast/appdata` does not exist | |
|
||||
| Old database | `/mnt/user/ipodderx/state.db`, SQLite, used until the move to Postgres on 2026-09-18 and kept for rollback | `/data/state.db` |
|
||||
| Downloads | `/mnt/user/ipodderx/downloads` | `/downloads` |
|
||||
| Image | `192.168.1.130:5000/ipx:latest` | |
|
||||
| Config | `/mnt/fast/appdata/ipx/config.toml`: bind address, token, trusted proxies, torrent, paths. The feeds and server settings are in the database | `/config/config.toml` |
|
||||
| Database | Postgres 18, database `ipodderx`, login `ipodderx`, on the `postgres` container of the Arcane project `databases` (`192.168.1.130:5433`). The URL is in `ipx.env` beside the compose file (`/mnt/fast/arcane/projects/content/ipx.env`, mode 600), passed to the container as `IPX_DATABASE_URL`. A relative `env_file`: Arcane runs compose in its own container, where `/mnt/fast/appdata` does not exist | |
|
||||
| Old database | `/mnt/user/ipx/state.db`, SQLite, used until the move to Postgres on 2026-09-18 and kept for rollback | `/data/state.db` |
|
||||
| Downloads | `/mnt/user/ipx/downloads` | `/downloads` |
|
||||
| Web UI | `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel | `0.0.0.0:8099` |
|
||||
| Sign-in via the tunnel | Cloudflare Access app `ipodderx`, with Authentik as its identity provider; see [docs/sso.md](docs/sso.md) | trusts `Cf-Access-Authenticated-User-Email` from `192.168.16.1`, the `content_default` gateway, and only with a valid `Cf-Access-Jwt-Assertion` (`access_team`, `access_aud`) |
|
||||
|
||||
Work to do lives in the Gitea issues at https://git.sdf1.net/rays/ipodderx-rs/issues, not in a
|
||||
`TODO.md`. `/src/tea` is logged in: `/src/tea issues list --login git.sdf1.net --repo rays/ipodderx-rs`.
|
||||
Work to do lives in the Gitea issues at https://git.sdf1.net/rays/ipx/issues, not in a
|
||||
`TODO.md`. `/src/tea` is logged in: `/src/tea issues list --login git.sdf1.net --repo rays/ipx`.
|
||||
|
||||
**Every problem found gets an issue, before it is fixed.** A bug, a gap or a security hole turned
|
||||
up along the way (reading logs, a review, a test that fails for another reason) is filed as soon
|
||||
@@ -30,7 +30,7 @@ when. Name the issue in the commit that fixes it (`(#40)` in the subject). Once
|
||||
`main` and pushed, comment on the issue with what changed and the commit, then close it:
|
||||
|
||||
```sh
|
||||
R="--login git.sdf1.net --repo rays/ipodderx-rs"
|
||||
R="--login git.sdf1.net --repo rays/ipx"
|
||||
t() { timeout 30 /src/tea "$@" < /dev/null; }
|
||||
t issues create $R -t "Web token printed in the startup log" -L bug -d "What is wrong, where, how it was found."
|
||||
t comment $R 40 "Fixed in 3625cf4: the startup line says where the token is kept, not what it is. Deployed in 0.8.3."
|
||||
@@ -46,10 +46,10 @@ for later stays open, and that is how it gets picked up again.
|
||||
Deploying a change is: build and push the image, then pull it and recreate the container.
|
||||
|
||||
```sh
|
||||
docker buildx build --tag 192.168.1.130:5000/ipodderx:latest . --push
|
||||
docker compose -f /mnt/fast/arcane/projects/content/compose.yaml pull ipodderx
|
||||
docker compose -f /mnt/fast/arcane/projects/content/compose.yaml up -d ipodderx
|
||||
docker logs --tail 20 iPodderX
|
||||
docker buildx build --tag 192.168.1.130:5000/ipx:latest . --push
|
||||
docker compose -f /mnt/fast/arcane/projects/content/compose.yaml pull ipx
|
||||
docker compose -f /mnt/fast/arcane/projects/content/compose.yaml up -d ipx
|
||||
docker logs --tail 20 iPX
|
||||
```
|
||||
|
||||
**Name the service.** A bare `up -d` recreates every container in `content`, beets and immich
|
||||
|
||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -1820,7 +1820,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
||||
|
||||
[[package]]
|
||||
name = "ipx"
|
||||
version = "0.8.4"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"ammonia",
|
||||
"anyhow",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ipx"
|
||||
version = "0.8.4"
|
||||
version = "0.9.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# ipodderx-rs
|
||||
# ipx
|
||||
|
||||
A self-hosted podcatcher for a household. It checks your feeds, downloads the episodes, and serves
|
||||
a web UI modelled on the 2004 Mac app **iPodderX**, for any number of people sharing one copy of
|
||||
@@ -30,7 +30,7 @@ behind iPodderX (2004-2008, Ray Slakinski & August Trometer).
|
||||
With Docker:
|
||||
|
||||
```sh
|
||||
docker build -t ipodderx .
|
||||
docker build -t ipx .
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
services:
|
||||
ipodderx:
|
||||
image: 192.168.1.130:5000/ipodderx:latest
|
||||
container_name: iPodderX
|
||||
ipx:
|
||||
image: 192.168.1.130:5000/ipx:latest
|
||||
container_name: iPX
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PUID: "99"
|
||||
@@ -10,15 +10,15 @@ services:
|
||||
IPX_LOG: "ipx=info"
|
||||
# IPX_DATABASE_URL=postgres://... to use Postgres; without it, /data/state.db (SQLite).
|
||||
env_file:
|
||||
- ipodderx.env # relative: Arcane resolves it inside its own container
|
||||
- ipx.env # relative: Arcane resolves it inside its own container
|
||||
ports:
|
||||
- "8099:8099" # web UI
|
||||
- "6881:6881/tcp" # BitTorrent peers
|
||||
- "6881:6881/udp" # DHT
|
||||
volumes:
|
||||
- /mnt/fast/appdata/ipodderx:/config # config.toml, and the web token
|
||||
- /mnt/user/ipodderx/:/data # state.db
|
||||
- /mnt/user/ipodderx/downloads:/downloads
|
||||
- /mnt/fast/appdata/ipx:/config # config.toml, and the web token
|
||||
- /mnt/user/ipx/:/data # state.db
|
||||
- /mnt/user/ipx/downloads:/downloads
|
||||
healthcheck:
|
||||
test: ["CMD", "ipx", "status"]
|
||||
interval: 30s
|
||||
|
||||
14
docs/sso.md
14
docs/sso.md
@@ -31,7 +31,7 @@ request it forwards through the tunnel, and ipx signs that person in.
|
||||
| Access application | Zero Trust → Access → Applications → `ipodderx` | Domain `ipodderx.sdf1.net`; identity providers: Authentik only, with instant auth; session 730h; policy *Require Login* allows a list of email addresses |
|
||||
| Tunnel route | Zero Trust → Networks → Tunnels → `rays-unraid` → Public hostnames | `ipodderx.sdf1.net` → HTTP `192.168.1.130:8099` |
|
||||
| DNS | `sdf1.net` | `ipodderx` CNAME to the tunnel, proxied |
|
||||
| ipx | `/mnt/fast/appdata/ipodderx/config.toml`, `[web]` | below |
|
||||
| ipx | `/mnt/fast/appdata/ipx/config.toml`, `[web]` | below |
|
||||
|
||||
```toml
|
||||
[web]
|
||||
@@ -51,7 +51,7 @@ on since 2026-09-19. Both can be read without the dashboard: a request to the si
|
||||
out is sent to `https://<team domain>/cdn-cgi/access/login/ipodderx.sdf1.net?kid=<AUD tag>&...`.
|
||||
|
||||
Restart ipx after editing it: `docker compose -f /mnt/fast/arcane/projects/content/compose.yaml
|
||||
restart ipodderx`.
|
||||
restart ipx`.
|
||||
|
||||
### What was missing
|
||||
|
||||
@@ -93,7 +93,7 @@ ordinary user with no feeds. An account made before the proxy can be given the n
|
||||
send:
|
||||
|
||||
```sh
|
||||
docker exec iPodderX ipx user rename <old name> <email address>
|
||||
docker exec iPX ipx user rename <old name> <email address>
|
||||
```
|
||||
|
||||
### Signing out
|
||||
@@ -109,10 +109,10 @@ feeds.
|
||||
|
||||
### The tile in Authentik's library
|
||||
|
||||
Authentik's library lists Authentik's own applications, and ipodderx signs in through the one
|
||||
called `Cloudflare Access`, so ipodderx needs a bookmark of its own to show up there. It is
|
||||
Authentik's library lists Authentik's own applications, and ipx signs in through the one
|
||||
called `Cloudflare Access`, so ipx needs a bookmark of its own to show up there. It is
|
||||
Applications → Applications → `ipodderx`: no provider, launch URL `https://ipodderx.sdf1.net`, and
|
||||
the iPodderX icon. Like Outline's, it has no policy bindings, so everyone in Authentik sees the
|
||||
the ipx logo. Like Outline's, it has no policy bindings, so everyone in Authentik sees the
|
||||
tile. Who actually gets in is still up to the Access policy.
|
||||
|
||||
### Check it
|
||||
@@ -222,7 +222,7 @@ echo -n 'newsecret' | ipx user passwd sam # change a password
|
||||
ipx user rm sam # remove the account
|
||||
```
|
||||
|
||||
In the container, put `docker exec iPodderX` in front, and `docker exec -i iPodderX` for the ones
|
||||
In the container, put `docker exec iPX` in front, and `docker exec -i iPX` for the ones
|
||||
that read a password.
|
||||
|
||||
Set `auto_create_users = false` once everyone who should have an account has one. After that the
|
||||
|
||||
181
src/db.rs
181
src/db.rs
@@ -2,7 +2,7 @@
|
||||
//! per-feed .ipxd plists, history.dat and qmcache.dat.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use crate::entity::{catalogue, enclosures, entries, feeds, sessions, settings, subscriptions, users};
|
||||
use crate::entity::{blocklists, catalogue, enclosures, entries, feeds, hidden, sessions, settings, subscriptions, users};
|
||||
use sea_orm::sea_query::{Expr, Func};
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set,
|
||||
@@ -57,6 +57,8 @@ async fn create_missing(orm: &sea_orm::DatabaseConnection) -> Result<()> {
|
||||
schema.create_table_from_entity(sessions::Entity),
|
||||
schema.create_table_from_entity(catalogue::Entity),
|
||||
schema.create_table_from_entity(settings::Entity),
|
||||
schema.create_table_from_entity(blocklists::Entity),
|
||||
schema.create_table_from_entity(hidden::Entity),
|
||||
] {
|
||||
orm.execute(table.if_not_exists()).await.context("creating the schema")?;
|
||||
}
|
||||
@@ -156,6 +158,10 @@ pub struct Sub {
|
||||
pub auto_download: Option<bool>,
|
||||
pub allow_explicit: Option<bool>,
|
||||
pub max_new_per_check: Option<i64>,
|
||||
/// Words that keep an item from being downloaded for this person: their list for every feed
|
||||
/// and their list for this one together. Filled by `subscribers` only; it is kept apart from
|
||||
/// the settings above (`Db::set_blocklist`).
|
||||
pub blocked: Vec<String>,
|
||||
}
|
||||
|
||||
/// Someone who can sign in. `pass_hash` is None for an account that only ever arrives
|
||||
@@ -200,6 +206,7 @@ impl From<subscriptions::Model> for Sub {
|
||||
auto_download: s.auto_download,
|
||||
allow_explicit: s.allow_explicit,
|
||||
max_new_per_check: s.max_new_per_check,
|
||||
blocked: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -620,8 +627,11 @@ impl Db {
|
||||
FROM enclosures e
|
||||
LEFT JOIN (SELECT feed_id, count(*) AS n FROM subscriptions GROUP BY feed_id) subs
|
||||
ON subs.feed_id = e.feed_id
|
||||
LEFT JOIN (SELECT feed_id, guid, count(*) AS n FROM entry_state
|
||||
WHERE read GROUP BY feed_id, guid) readers
|
||||
-- Someone an item is hidden from is as done with it as someone who read it.
|
||||
LEFT JOIN (SELECT feed_id, guid, count(*) AS n
|
||||
FROM (SELECT user_id, feed_id, guid FROM entry_state WHERE read
|
||||
UNION SELECT user_id, feed_id, guid FROM hidden) done
|
||||
GROUP BY feed_id, guid) readers
|
||||
ON readers.feed_id = e.feed_id AND readers.guid = e.guid
|
||||
WHERE e.path IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM entry_state s
|
||||
@@ -683,13 +693,17 @@ impl Db {
|
||||
.await?;
|
||||
// Whatever went takes everyone's read state with it, rather than leaving rows
|
||||
// pointing at an item that no longer exists.
|
||||
for table in ["entry_state", "hidden"] {
|
||||
self.exec(
|
||||
"DELETE FROM entry_state WHERE NOT EXISTS (
|
||||
&format!(
|
||||
"DELETE FROM {table} WHERE NOT EXISTS (
|
||||
SELECT 1 FROM entries e
|
||||
WHERE e.feed_id = entry_state.feed_id AND e.guid = entry_state.guid)",
|
||||
WHERE e.feed_id = {table}.feed_id AND e.guid = {table}.guid)"
|
||||
),
|
||||
vec![],
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(n as usize)
|
||||
}
|
||||
}
|
||||
@@ -701,7 +715,9 @@ impl Db {
|
||||
"SELECT count(*) AS n FROM entries e
|
||||
LEFT JOIN entry_state s
|
||||
ON s.user_id = $2 AND s.feed_id = e.feed_id AND s.guid = e.guid
|
||||
WHERE e.feed_id = $1 AND NOT coalesce(s.read, false)",
|
||||
WHERE e.feed_id = $1 AND NOT coalesce(s.read, false)
|
||||
AND NOT EXISTS (SELECT 1 FROM hidden h
|
||||
WHERE h.user_id = $2 AND h.feed_id = e.feed_id AND h.guid = e.guid)",
|
||||
vec![feed_id.into(), user_id.into()],
|
||||
)
|
||||
.await?;
|
||||
@@ -776,8 +792,16 @@ fn entries_from(a: &mut Args, user_id: i64, feed_id: Option<&str>, filter: Filte
|
||||
format!(
|
||||
"FROM entries e
|
||||
LEFT JOIN entry_state s ON s.user_id = {user} AND s.feed_id = e.feed_id AND s.guid = e.guid
|
||||
WHERE {scope} AND {} AND {search}",
|
||||
filter.sql()
|
||||
WHERE {scope} AND {} AND {search} AND {}",
|
||||
filter.sql(),
|
||||
not_hidden(&user)
|
||||
)
|
||||
}
|
||||
|
||||
/// Not hidden from this person by their block lists. `e` is entries.
|
||||
fn not_hidden(user: &str) -> String {
|
||||
format!(
|
||||
"NOT EXISTS (SELECT 1 FROM hidden h WHERE h.user_id = {user} AND h.feed_id = e.feed_id AND h.guid = e.guid)"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1079,6 +1103,8 @@ impl Db {
|
||||
("sessions", copy_table::<sessions::Entity>(&from.orm, &tx).await?),
|
||||
("catalogue", copy_table::<catalogue::Entity>(&from.orm, &tx).await?),
|
||||
("settings", copy_table::<settings::Entity>(&from.orm, &tx).await?),
|
||||
("blocklists", copy_table::<blocklists::Entity>(&from.orm, &tx).await?),
|
||||
("hidden", copy_table::<hidden::Entity>(&from.orm, &tx).await?),
|
||||
];
|
||||
if self.orm.get_database_backend() == sea_orm::DbBackend::Postgres {
|
||||
// The copied ids came with the rows; the counters that hand out new ones start past
|
||||
@@ -1166,9 +1192,12 @@ impl Db {
|
||||
"SELECT coalesce(c.keywords, p.keywords) AS keywords,
|
||||
coalesce(c.auto_download, p.auto_download) AS auto_download,
|
||||
coalesce(c.allow_explicit, p.allow_explicit) AS allow_explicit,
|
||||
coalesce(c.max_new_per_check, p.max_new_per_check) AS max_new_per_check
|
||||
coalesce(c.max_new_per_check, p.max_new_per_check) AS max_new_per_check,
|
||||
g.words AS global_blocked, b.words AS blocked
|
||||
FROM subscriptions c
|
||||
LEFT JOIN subscriptions p ON p.user_id = c.user_id AND p.feed_id = $2
|
||||
LEFT JOIN blocklists g ON g.user_id = c.user_id AND g.feed_id = ''
|
||||
LEFT JOIN blocklists b ON b.user_id = c.user_id AND b.feed_id = c.feed_id
|
||||
WHERE c.feed_id = $1",
|
||||
vec![feed_id.into(), group.map(str::to_owned).into()],
|
||||
)
|
||||
@@ -1181,6 +1210,10 @@ impl Db {
|
||||
auto_download: r.try_get("", "auto_download")?,
|
||||
allow_explicit: r.try_get("", "allow_explicit")?,
|
||||
max_new_per_check: r.try_get("", "max_new_per_check")?,
|
||||
blocked: [r.try_get("", "global_blocked")?, r.try_get("", "blocked")?]
|
||||
.into_iter()
|
||||
.flat_map(|w| keywords(w).unwrap_or_default())
|
||||
.collect(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
@@ -1217,11 +1250,13 @@ impl Db {
|
||||
let r = self
|
||||
.rows(
|
||||
"SELECT sum(CASE WHEN st.flagged THEN 1 ELSE 0 END) AS starred,
|
||||
sum(CASE WHEN st.read THEN 0 ELSE 1 END) AS unread
|
||||
sum(CASE WHEN st.read OR h.guid IS NOT NULL THEN 0 ELSE 1 END) AS unread
|
||||
FROM enclosures e
|
||||
JOIN subscriptions s ON s.feed_id = e.feed_id AND s.user_id <> $2
|
||||
LEFT JOIN entry_state st
|
||||
ON st.user_id = s.user_id AND st.feed_id = e.feed_id AND st.guid = e.guid
|
||||
LEFT JOIN hidden h
|
||||
ON h.user_id = s.user_id AND h.feed_id = e.feed_id AND h.guid = e.guid
|
||||
WHERE e.id = $1",
|
||||
vec![enclosure_id.into(), user_id.into()],
|
||||
)
|
||||
@@ -1234,11 +1269,99 @@ impl Db {
|
||||
}
|
||||
|
||||
pub async fn subscribe(&self, user_id: i64, feed_id: &str) -> Result<()> {
|
||||
self.exec(
|
||||
let new = self
|
||||
.exec(
|
||||
"INSERT INTO subscriptions (user_id, feed_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
|
||||
vec![user_id.into(), feed_id.into()],
|
||||
)
|
||||
.await?;
|
||||
if new > 0 {
|
||||
// Their list for every feed applies to this one from the start.
|
||||
self.rehide(feed_id).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Someone's block list: for one feed, or with `feed_id` empty, for every feed.
|
||||
pub async fn blocklist(&self, user_id: i64, feed_id: &str) -> Result<Vec<String>> {
|
||||
Ok(blocklists::Entity::find_by_id((user_id, feed_id.to_owned()))
|
||||
.one(&self.orm)
|
||||
.await?
|
||||
.and_then(|b| keywords(Some(b.words)))
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Replaces a block list and works out again what it hides: in that feed, or with
|
||||
/// `feed_id` empty, in every feed the person reads.
|
||||
pub async fn set_blocklist(&self, user_id: i64, feed_id: &str, words: &[String]) -> Result<()> {
|
||||
if words.is_empty() {
|
||||
blocklists::Entity::delete_by_id((user_id, feed_id.to_owned())).exec(&self.orm).await?;
|
||||
} else {
|
||||
self.exec(
|
||||
"INSERT INTO blocklists (user_id, feed_id, words) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (user_id, feed_id) DO UPDATE SET words = excluded.words",
|
||||
vec![user_id.into(), feed_id.into(), serde_json::to_string(words)?.into()],
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
if feed_id.is_empty() {
|
||||
for sub in self.subscriptions_for(user_id).await? {
|
||||
self.rehide(&sub.feed_id).await?;
|
||||
}
|
||||
} else {
|
||||
self.rehide(feed_id).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Works out again which of a feed's items each subscriber's block lists hide from them.
|
||||
///
|
||||
/// ponytail: every item of the feed for every subscriber, each time the feed is scanned with
|
||||
/// something new or a list changes. Fine at hundreds of items a feed; look only at new items
|
||||
/// on a scan if a feed with thousands makes scans slow.
|
||||
pub async fn rehide(&self, feed_id: &str) -> Result<()> {
|
||||
let lists: Vec<(i64, Vec<String>)> = self
|
||||
.rows(
|
||||
"SELECT s.user_id, g.words AS global, b.words AS feed
|
||||
FROM subscriptions s
|
||||
LEFT JOIN blocklists g ON g.user_id = s.user_id AND g.feed_id = ''
|
||||
LEFT JOIN blocklists b ON b.user_id = s.user_id AND b.feed_id = s.feed_id
|
||||
WHERE s.feed_id = $1",
|
||||
vec![feed_id.into()],
|
||||
)
|
||||
.await?
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let words = [r.try_get("", "global")?, r.try_get("", "feed")?]
|
||||
.into_iter()
|
||||
.flat_map(|w: Option<String>| keywords(w).unwrap_or_default())
|
||||
.collect();
|
||||
Ok((r.try_get("", "user_id")?, words))
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
hidden::Entity::delete_many().filter(hidden::Column::FeedId.eq(feed_id)).exec(&self.orm).await?;
|
||||
let lists: Vec<_> = lists.into_iter().filter(|(_, w)| !w.is_empty()).collect();
|
||||
if lists.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let items = entries::Entity::find().filter(entries::Column::FeedId.eq(feed_id)).all(&self.orm).await?;
|
||||
let rows: Vec<hidden::ActiveModel> = items
|
||||
.iter()
|
||||
.flat_map(|e| {
|
||||
let hay = [e.title.as_deref().unwrap_or(""), e.description.as_deref().unwrap_or("")];
|
||||
lists
|
||||
.iter()
|
||||
.filter(move |(_, words)| crate::download::blocked(words, &hay))
|
||||
.map(|(user, _)| hidden::ActiveModel {
|
||||
user_id: Set(*user),
|
||||
feed_id: Set(feed_id.to_owned()),
|
||||
guid: Set(e.guid.clone()),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if !rows.is_empty() {
|
||||
hidden::Entity::insert_many(rows).exec_without_returning(&self.orm).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2050,6 +2173,42 @@ mod tests {
|
||||
assert_eq!(listening().await, ["h", "e"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn block_lists_hide_items_from_whoever_keeps_them() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
db.exec_for_test(
|
||||
"INSERT INTO users (id, name, is_admin) VALUES (1,'a',true), (2,'b',false);
|
||||
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'), (2,'f');
|
||||
INSERT INTO entries (feed_id, guid, title, description, first_seen) VALUES
|
||||
('f','x','Election night','',1),
|
||||
('f','y','Baking bread','<p>No politics here, honest</p>',2),
|
||||
('f','z','Gardening','',3);",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let shown = async |user| db.count_in(user, Some("f"), Filter::All, None).await.unwrap();
|
||||
let words = |w: &[&str]| w.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
|
||||
db.set_blocklist(1, "", &words(&["election"])).await.unwrap();
|
||||
db.set_blocklist(1, "f", &words(&["politics"])).await.unwrap();
|
||||
assert_eq!(shown(1).await, 1, "a title and a body each hide an item, both lists apply");
|
||||
assert_eq!(db.unread_count(1, "f").await.unwrap(), 1, "hidden is not unread");
|
||||
assert_eq!(shown(2).await, 3, "someone else's list hides nothing from you");
|
||||
|
||||
db.set_blocklist(1, "", &[]).await.unwrap();
|
||||
assert_eq!(shown(1).await, 2, "emptying a list brings its items back");
|
||||
assert!(db.blocklist(1, "").await.unwrap().is_empty());
|
||||
|
||||
// Someone subscribing starts with their list applied, and the scanner sees it.
|
||||
db.set_blocklist(2, "", &words(&["gardening"])).await.unwrap();
|
||||
db.exec_for_test("INSERT INTO entries (feed_id, guid, title, first_seen) VALUES ('g','w','Gardening',4)")
|
||||
.await
|
||||
.unwrap();
|
||||
db.subscribe(2, "g").await.unwrap();
|
||||
assert_eq!(db.count_in(2, Some("g"), Filter::All, None).await.unwrap(), 0);
|
||||
assert_eq!(db.subscribers("g", None).await.unwrap()[0].blocked, ["gardening"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_takes_the_latest_episodes_first() {
|
||||
// A cap of 3 must mean the three newest, not the three recorded first.
|
||||
|
||||
@@ -305,6 +305,25 @@ pub fn matches_keywords(keywords: &[String], haystacks: &[&str]) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether any of these words or phrases appears, as whole words, in the haystacks. Whole words,
|
||||
/// unlike `matches_keywords`, because a block hides things: "ai" should not hide everything that
|
||||
/// "said" something. Case and punctuation are ignored, so "A.I." is not caught by "ai", but
|
||||
/// "Trump's" is by "trump".
|
||||
pub fn blocked(words: &[String], haystacks: &[&str]) -> bool {
|
||||
// Letters and digits only, each run of anything else one space, padded so a phrase matches
|
||||
// at either end: " new york " is in " the new york times ", " york " is not in " yorkshire ".
|
||||
let norm = |s: &str| {
|
||||
let mut out = String::from(" ");
|
||||
for w in s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()) {
|
||||
out.push_str(&w.to_lowercase());
|
||||
out.push(' ');
|
||||
}
|
||||
out
|
||||
};
|
||||
let hay = norm(&haystacks.join(" "));
|
||||
words.iter().map(|w| norm(w)).any(|w| w.len() > 1 && hay.contains(&w))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -404,4 +423,16 @@ mod tests {
|
||||
assert!(!matches_keywords(&kws, &["Just a dive"]), "half a keyword is not a match");
|
||||
assert!(matches_keywords(&[], &["anything"]), "no keywords means take everything");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_matches_whole_words_and_phrases() {
|
||||
let words = vec!["AI".to_string(), "new york".to_string()];
|
||||
assert!(blocked(&words, &["What AI means now"]));
|
||||
assert!(blocked(&words, &["<p>ai, again</p>"]), "markup and punctuation are not words");
|
||||
assert!(!blocked(&words, &["He said so", "Portrait"]), "not inside another word");
|
||||
assert!(blocked(&words, &["Live from", "New York."]), "a phrase spans runs of space");
|
||||
assert!(!blocked(&words, &["New Yorkshire"]));
|
||||
assert!(!blocked(&[" ".to_string()], &["anything"]), "a blank word blocks nothing");
|
||||
assert!(!blocked(&[], &["anything"]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,6 +228,46 @@ pub mod entry_state {
|
||||
owned_by_user!();
|
||||
}
|
||||
|
||||
/// Words someone never wants to see: an item whose title or text has one is hidden from them and
|
||||
/// not downloaded on their account (issue #47). `feed_id` is empty for the list that applies to
|
||||
/// every feed they read.
|
||||
pub mod blocklists {
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "blocklists")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub user_id: i64,
|
||||
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||
pub feed_id: String,
|
||||
/// JSON array of strings.
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub words: String,
|
||||
}
|
||||
|
||||
owned_by_user!();
|
||||
}
|
||||
|
||||
/// The items someone's block lists hide from them, worked out whenever a list or the feed
|
||||
/// changes (`Db::rehide`), since SQL on both databases cannot match whole words itself.
|
||||
pub mod hidden {
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "hidden")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub user_id: i64,
|
||||
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||
pub feed_id: String,
|
||||
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||
pub guid: String,
|
||||
}
|
||||
|
||||
owned_by_user!();
|
||||
}
|
||||
|
||||
pub mod sessions {
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
|
||||
72
src/main.rs
72
src/main.rs
@@ -1254,6 +1254,10 @@ async fn scan_one(
|
||||
}
|
||||
}
|
||||
|
||||
if scan.new_entries > 0 {
|
||||
ctx.db.rehide(id).await?; // what is new may hold someone's blocked words
|
||||
}
|
||||
|
||||
let budget = policy.budget;
|
||||
if policy.auto_download && budget > 0 {
|
||||
let cfg = ctx.cfg();
|
||||
@@ -1458,15 +1462,10 @@ fn reject(
|
||||
entry.description.as_deref().unwrap_or(""),
|
||||
categories.as_str(),
|
||||
];
|
||||
// One file serves everyone subscribed, so an item is wanted if it is wanted by
|
||||
// anyone: any one person's keyword set matching is enough.
|
||||
let wanted_by_someone = policy.keyword_sets.is_empty()
|
||||
|| policy
|
||||
.keyword_sets
|
||||
.iter()
|
||||
.any(|set| download::matches_keywords(set, &haystacks));
|
||||
if !wanted_by_someone {
|
||||
return Some("no keyword match");
|
||||
let text = [entry.title.as_deref().unwrap_or(""), entry.description.as_deref().unwrap_or("")];
|
||||
if !policy.wanted(&haystacks, &text) {
|
||||
// Worded for whichever filter could have let it through, the keywords if none blocked it.
|
||||
return Some(if policy.wanted(&haystacks, &[]) { "blocked word" } else { "no keyword match" });
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -1480,11 +1479,28 @@ fn reject(
|
||||
pub struct Policy {
|
||||
pub auto_download: bool,
|
||||
pub allow_explicit: bool,
|
||||
/// Empty means take everything. Otherwise one set per subscriber who filters.
|
||||
pub keyword_sets: Vec<Vec<String>>,
|
||||
/// What each subscriber fetching the feed wants.
|
||||
pub wants: Vec<Want>,
|
||||
pub budget: usize,
|
||||
}
|
||||
|
||||
/// One person's filters: an item is theirs if it matches their keywords (all of it, with none)
|
||||
/// and none of their blocked words.
|
||||
pub struct Want {
|
||||
pub keywords: Vec<String>,
|
||||
pub blocked: Vec<String>,
|
||||
}
|
||||
|
||||
impl Policy {
|
||||
/// One file serves everyone subscribed, so an item is wanted if anyone wants it. Keywords
|
||||
/// look at `haystacks`, blocked words at `text`, the item's title and body only.
|
||||
fn wanted(&self, haystacks: &[&str], text: &[&str]) -> bool {
|
||||
self.wants.iter().any(|w| {
|
||||
download::matches_keywords(&w.keywords, haystacks) && !download::blocked(&w.blocked, text)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn policy_for(ctx: &Ctx, id: &str, feed_cfg: &config::Feed) -> Result<Policy> {
|
||||
let global = ctx.cfg().general.max_new_per_check;
|
||||
Ok(merge_policy(&ctx.db.subscribers(id, feed_cfg.group.as_deref()).await?, feed_cfg, global))
|
||||
@@ -1497,11 +1513,7 @@ fn merge_policy(subs: &[db::Sub], feed_cfg: &config::Feed, global: usize) -> Pol
|
||||
return Policy {
|
||||
auto_download: feed_cfg.auto_download,
|
||||
allow_explicit: feed_cfg.allow_explicit,
|
||||
keyword_sets: if feed_cfg.keywords.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
vec![feed_cfg.keywords.clone()]
|
||||
},
|
||||
wants: vec![Want { keywords: feed_cfg.keywords.clone(), blocked: vec![] }],
|
||||
budget: cap(feed_cfg.max_new_per_check),
|
||||
};
|
||||
}
|
||||
@@ -1509,7 +1521,7 @@ fn merge_policy(subs: &[db::Sub], feed_cfg: &config::Feed, global: usize) -> Pol
|
||||
let mut policy = Policy {
|
||||
auto_download: false,
|
||||
allow_explicit: false,
|
||||
keyword_sets: vec![],
|
||||
wants: vec![],
|
||||
budget: 0,
|
||||
};
|
||||
for sub in subs {
|
||||
@@ -1521,12 +1533,10 @@ fn merge_policy(subs: &[db::Sub], feed_cfg: &config::Feed, global: usize) -> Pol
|
||||
policy.budget = policy
|
||||
.budget
|
||||
.max(cap(sub.max_new_per_check.map(|n| n as usize).or(feed_cfg.max_new_per_check)));
|
||||
let kw = sub.keywords.clone().unwrap_or_else(|| feed_cfg.keywords.clone());
|
||||
if kw.is_empty() {
|
||||
// Somebody takes everything, so no filter can apply to the shared copy.
|
||||
return Policy { keyword_sets: vec![], ..policy };
|
||||
}
|
||||
policy.keyword_sets.push(kw);
|
||||
policy.wants.push(Want {
|
||||
keywords: sub.keywords.clone().unwrap_or_else(|| feed_cfg.keywords.clone()),
|
||||
blocked: sub.blocked.clone(),
|
||||
});
|
||||
}
|
||||
policy
|
||||
}
|
||||
@@ -1795,6 +1805,7 @@ mod tests {
|
||||
auto_download: auto,
|
||||
allow_explicit: None,
|
||||
max_new_per_check: max,
|
||||
blocked: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1804,7 +1815,7 @@ mod tests {
|
||||
let p = merge_policy(&[], &feed(), 3);
|
||||
assert!(p.auto_download);
|
||||
assert_eq!(p.budget, 3);
|
||||
assert!(p.keyword_sets.is_empty());
|
||||
assert!(p.wanted(&["anything"], &[]));
|
||||
|
||||
// Two filters: an item wanted by either of them is fetched, since one file serves
|
||||
// both. The larger per-scan cap wins for the same reason.
|
||||
@@ -1813,12 +1824,21 @@ mod tests {
|
||||
&feed(),
|
||||
3,
|
||||
);
|
||||
assert_eq!(p.keyword_sets.len(), 2);
|
||||
assert!(p.wanted(&["rust"], &[]) && p.wanted(&["sqlite"], &[]));
|
||||
assert!(!p.wanted(&["python"], &[]));
|
||||
assert_eq!(p.budget, 9);
|
||||
|
||||
// One person taking everything removes the filter for the shared copy.
|
||||
let p = merge_policy(&[sub(Some(&["rust"]), None, None), sub(Some(&[]), None, None)], &feed(), 3);
|
||||
assert!(p.keyword_sets.is_empty());
|
||||
assert!(p.wanted(&["python"], &[]));
|
||||
|
||||
// A blocked word keeps an item from being fetched for that person, not for the rest.
|
||||
let blocking = |w: &str| db::Sub { blocked: vec![w.into()], ..sub(None, None, None) };
|
||||
let p = merge_policy(&[blocking("politics")], &feed(), 3);
|
||||
assert!(!p.wanted(&[], &["Politics today"]));
|
||||
assert!(p.wanted(&[], &["Cooking today"]));
|
||||
let p = merge_policy(&[blocking("politics"), sub(None, None, None)], &feed(), 3);
|
||||
assert!(p.wanted(&[], &["Politics today"]), "someone else still wants it");
|
||||
|
||||
// Everyone has auto-download off: nothing is fetched automatically.
|
||||
let p = merge_policy(&[sub(None, Some(false), None), sub(None, Some(false), None)], &feed(), 3);
|
||||
|
||||
65
src/web.rs
65
src/web.rs
@@ -67,6 +67,7 @@ pub fn router(state: WebState) -> Router {
|
||||
.route("/login", get(login_page))
|
||||
.route("/api/login", post(login))
|
||||
.route("/icon.png", get(icon))
|
||||
.route("/logo.svg", get(logo))
|
||||
.route("/favicon.ico", get(favicon))
|
||||
.route("/favicon.png", get(favicon))
|
||||
.route("/apple-touch-icon.png", get(touch_icon))
|
||||
@@ -332,29 +333,40 @@ async fn me(
|
||||
let url = state.ctx.cfg().web.sign_out_url.clone();
|
||||
let sign_out = (by_proxy && !url.is_empty()).then_some(url);
|
||||
let (theme, mode) = state.ctx.db.theme(user.id).await.unwrap_or_default();
|
||||
let blocked = state.ctx.db.blocklist(user.id, "").await.unwrap_or_default();
|
||||
Json(serde_json::json!({
|
||||
"name": user.name, "admin": user.is_admin, "sign_out": sign_out, "theme": theme, "mode": mode,
|
||||
"blocked": blocked,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MePatch {
|
||||
theme: String,
|
||||
mode: String,
|
||||
theme: Option<String>,
|
||||
mode: Option<String>,
|
||||
/// Words that hide an item in every feed you read.
|
||||
blocked: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Saves the theme to the account, so it follows the person rather than the browser.
|
||||
/// Saves the theme to the account, so it follows the person rather than the browser, and the
|
||||
/// block list for every feed.
|
||||
async fn patch_me(
|
||||
State(state): State<WebState>,
|
||||
user: crate::db::User,
|
||||
Json(body): Json<MePatch>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
if body.theme.is_some() || body.mode.is_some() {
|
||||
let (theme, mode) = (body.theme.unwrap_or_default(), body.mode.unwrap_or_default());
|
||||
// The page's script knows the themes; this only makes sure what is kept is safe to write
|
||||
// into the page's <html> tag, which is where index() puts it.
|
||||
if !theme_ok(&body.theme, &body.mode) {
|
||||
if !theme_ok(&theme, &mode) {
|
||||
return Err(ApiError::bad_request("not a theme"));
|
||||
}
|
||||
state.ctx.db.set_theme(user.id, &body.theme, &body.mode).await?;
|
||||
state.ctx.db.set_theme(user.id, &theme, &mode).await?;
|
||||
}
|
||||
if let Some(words) = body.blocked {
|
||||
state.ctx.db.set_blocklist(user.id, "", &clean_words(words)?).await?;
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -541,8 +553,16 @@ fn script(js: &'static str) -> impl IntoResponse {
|
||||
([(header::CONTENT_TYPE, "text/javascript; charset=utf-8"), (header::CACHE_CONTROL, SCRIPT_CACHE)], js)
|
||||
}
|
||||
|
||||
/// The 2004 icon, served once for both pages rather than inlined as base64 into each. The
|
||||
/// sign-in page shows it, so it sits outside the auth layer with /login.
|
||||
/// The logo the pages show, served once rather than inlined into each. The sign-in page shows
|
||||
/// it, so it sits outside the auth layer with /login.
|
||||
async fn logo() -> impl IntoResponse {
|
||||
(
|
||||
[(header::CONTENT_TYPE, "image/svg+xml"), (header::CACHE_CONTROL, "max-age=86400")],
|
||||
include_str!("../web/logo.svg"),
|
||||
)
|
||||
}
|
||||
|
||||
/// The 2004 icon. Nothing here shows it any more; it stays for whatever outside ipx links to it.
|
||||
async fn icon() -> impl IntoResponse {
|
||||
(
|
||||
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")],
|
||||
@@ -550,9 +570,8 @@ async fn icon() -> impl IntoResponse {
|
||||
)
|
||||
}
|
||||
|
||||
/// The logo, squared up with transparent padding: it is 128x121, and a tab icon that is not
|
||||
/// square can be passed over. /favicon.ico is the same PNG, for a browser that asks for that
|
||||
/// on its own; behind the auth layer it answered 401, and the tab stayed blank.
|
||||
/// web/logo.svg at 128px. /favicon.ico is the same PNG, for a browser that asks for that on its
|
||||
/// own; behind the auth layer it answered 401, and the tab stayed blank.
|
||||
async fn favicon() -> impl IntoResponse {
|
||||
(
|
||||
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")],
|
||||
@@ -560,8 +579,8 @@ async fn favicon() -> impl IntoResponse {
|
||||
)
|
||||
}
|
||||
|
||||
/// For an iPhone's home screen, which paints a transparent icon's background black, so this
|
||||
/// one is on white.
|
||||
/// web/logo.svg at 180px, for an iPhone's home screen. It is opaque edge to edge, as iOS paints
|
||||
/// a transparent icon's background black and cuts its own corners.
|
||||
async fn touch_icon() -> impl IntoResponse {
|
||||
(
|
||||
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")],
|
||||
@@ -639,6 +658,8 @@ struct FeedRow {
|
||||
category: Option<String>,
|
||||
feed_category: Option<String>,
|
||||
keywords: Vec<String>,
|
||||
/// Your words that hide an item of this feed, beside your list for every feed.
|
||||
blocked: Vec<String>,
|
||||
allow_explicit: bool,
|
||||
auto_download: bool,
|
||||
max_new_per_check: Option<usize>,
|
||||
@@ -715,6 +736,7 @@ async fn feeds(
|
||||
.clone()
|
||||
.or_else(|| up.and_then(|u| u.keywords.clone()))
|
||||
.unwrap_or_else(|| feed.keywords.clone()),
|
||||
blocked: state.ctx.db.blocklist(user.id, id).await?,
|
||||
allow_explicit: mine
|
||||
.allow_explicit
|
||||
.or(up.and_then(|u| u.allow_explicit))
|
||||
@@ -1272,6 +1294,7 @@ struct FeedPatch {
|
||||
#[serde(default, deserialize_with = "double_option")]
|
||||
category: Option<Option<String>>,
|
||||
keywords: Option<Vec<String>>,
|
||||
blocked: Option<Vec<String>>,
|
||||
allow_explicit: Option<bool>,
|
||||
auto_download: Option<bool>,
|
||||
#[serde(default, deserialize_with = "double_option")]
|
||||
@@ -1279,6 +1302,21 @@ struct FeedPatch {
|
||||
pinned: Option<bool>,
|
||||
}
|
||||
|
||||
/// A block list as sent: trimmed, blanks and repeats dropped, and bounded, since each word is
|
||||
/// looked for in every item of every feed the list covers.
|
||||
fn clean_words(words: Vec<String>) -> Result<Vec<String>, ApiError> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for w in words.iter().map(|w| w.trim()).filter(|w| !w.is_empty()) {
|
||||
if !out.iter().any(|o| o.eq_ignore_ascii_case(w)) {
|
||||
out.push(w.to_owned());
|
||||
}
|
||||
}
|
||||
if out.len() > 500 || out.iter().any(|w| w.len() > 200) {
|
||||
return Err(ApiError::bad_request("a block list holds up to 500 words of up to 200 characters"));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn double_option<'de, T, D>(de: D) -> Result<Option<Option<T>>, D::Error>
|
||||
where
|
||||
T: Deserialize<'de>,
|
||||
@@ -1327,6 +1365,9 @@ async fn patch_feed(
|
||||
if touched {
|
||||
state.ctx.db.set_subscription(user.id, &mine).await?;
|
||||
}
|
||||
if let Some(v) = body.blocked.clone() {
|
||||
state.ctx.db.set_blocklist(user.id, &id, &clean_words(v)?).await?;
|
||||
}
|
||||
}
|
||||
|
||||
// The rest describes the feed itself -- where its files land, its address, when it is
|
||||
|
||||
@@ -73,6 +73,8 @@ const over = (c, a, g) => g.map((x, i) => c[i] * a + x * (1 - a));
|
||||
for (const [name, sel] of [['glass', ':root[data-theme="glass"]'], ['glass light', ':root[data-theme="glass"][data-mode="light"]']]) {
|
||||
const p = themes[name];
|
||||
const tint = washes.map(w => [w, rgba(sel, w.n)]).reverse(); // the first listed is drawn on top
|
||||
const sm = css.slice(css.indexOf(sel + ' {')).match(/--glass-sheen:rgba\((\d+),(\d+),(\d+),([\d.]+)\)/);
|
||||
const sheen = [[+sm[1], +sm[2], +sm[3]], +sm[4]];
|
||||
const worst = {};
|
||||
for (const [W, H] of [[1440, 900], [390, 844], [1024, 1366]])
|
||||
for (let x = 0; x <= W; x += W / 40) for (let y = 0; y <= H; y += H / 40) {
|
||||
@@ -81,8 +83,10 @@ for (const [name, sel] of [['glass', ':root[data-theme="glass"]'], ['glass light
|
||||
const d = Math.hypot((x - w.cx * W / 100) / (w.rx * W / 100), (y - w.cy * H / 100) / (w.ry * H / 100));
|
||||
g = over(c, a * Math.max(0, 1 - d), g);
|
||||
}
|
||||
// The list sits on the bare wash; the sidebar, detail pane and dialogs on 70% panel over it.
|
||||
for (const [gn, gc] of [['the wash', g], ['a panel', over(rgb(p.panel), .7, g)]])
|
||||
// The list sits on the bare wash; the sidebar, detail pane and dialogs on 70% panel over it,
|
||||
// and a panel's top-left corner under its sheen at full strength.
|
||||
const panel = over(rgb(p.panel), .7, g);
|
||||
for (const [gn, gc] of [['the wash', g], ['a panel', panel], ['a panel\'s sheen', over(...sheen, panel)]])
|
||||
for (const fg of ['fg', 'dim', 'faint', 'accent', 'bad', 'warn']) {
|
||||
const r = ratio(p[fg], hex(gc)), k = `--${fg} on ${gn}`;
|
||||
if (!(k in worst) || r < worst[k]) worst[k] = r;
|
||||
|
||||
@@ -558,10 +558,10 @@ test('a second person has their own feeds and their own read state', async ({ br
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
await page.goto('/login');
|
||||
// The sign-in page shows the icon, so it has to load before anyone has signed in.
|
||||
const icon = await page.request.get('/icon.png');
|
||||
expect(icon.status()).toBe(200);
|
||||
expect(icon.headers()['content-type']).toBe('image/png');
|
||||
// The sign-in page shows the logo, so it has to load before anyone has signed in.
|
||||
const logo = await page.request.get('/logo.svg');
|
||||
expect(logo.status()).toBe(200);
|
||||
expect(logo.headers()['content-type']).toBe('image/svg+xml');
|
||||
await page.locator('#name').fill('sam');
|
||||
await page.locator('#pw').fill('sampassword');
|
||||
await page.locator('button[type=submit]').click();
|
||||
@@ -1179,7 +1179,7 @@ test('a file not yet downloaded has its icon in line with the rest of its row',
|
||||
});
|
||||
|
||||
test('the favicon is the logo, square, from both pages', async ({ page }) => {
|
||||
await expect(page.locator('link[rel="icon"]')).toHaveAttribute('href', '/favicon.png');
|
||||
await expect(page.locator('link[rel="icon"]')).toHaveAttribute('href', /^\/favicon\.png\?v=[0-9a-f]{12}$/);
|
||||
// A browser asks for /favicon.ico on its own, signed in or not.
|
||||
for (const path of ['/favicon.ico', '/favicon.png', '/apple-touch-icon.png']) {
|
||||
const r = await page.request.get(path, { headers: { cookie: '' } });
|
||||
@@ -1211,14 +1211,15 @@ test.describe('touch gestures on a phone', () => {
|
||||
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
|
||||
|
||||
// Playwright's touchscreen only taps; a drag goes through the DevTools protocol.
|
||||
async function drag(page, from, to) {
|
||||
async function drag(page, from, to, lift = true) {
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
const steps = 8;
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [from] });
|
||||
for (let i = 1; i <= steps; i++)
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{
|
||||
x: from.x + (to.x - from.x) * i / steps, y: from.y + (to.y - from.y) * i / steps }] });
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||
if (lift) await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||
return cdp;
|
||||
}
|
||||
|
||||
test('a swipe moves between items, and right from the first goes back to the list', async ({ page }) => {
|
||||
@@ -1231,6 +1232,17 @@ test.describe('touch gestures on a phone', () => {
|
||||
const shown = page.locator('#detail .dt');
|
||||
await expect(shown).toHaveText(titles[0]);
|
||||
|
||||
await drag(page, { x: 300, y: 400 }, { x: 230, y: 410 }); // too short: stays (#49)
|
||||
await expect(shown).toHaveText(titles[0]);
|
||||
await expect(page.locator('#detail')).not.toHaveAttribute('style', /translate/); // springs back
|
||||
// Held partway: the next item is beside the reader, not the list under it (#52), and
|
||||
// let go short of a swipe, it goes again.
|
||||
const held = await drag(page, { x: 300, y: 400 }, { x: 230, y: 405 }, false);
|
||||
await expect(page.locator('#dpeek.next .dt')).toHaveText(titles[1]);
|
||||
await expect(page.locator('#detail')).toHaveCSS('opacity', '1');
|
||||
await held.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||
await expect(page.locator('#dpeek')).toHaveCount(0);
|
||||
await expect(shown).toHaveText(titles[0]);
|
||||
await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left: the next item
|
||||
await expect(shown).toHaveText(titles[1]);
|
||||
await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left on the last: stays
|
||||
@@ -1341,3 +1353,33 @@ test('"check every feed" checks only the feeds of the person asking', async ({ b
|
||||
for (const id of started) expect(mine, `${id} is not one of piper's feeds`).toContain(id);
|
||||
await ctx.close();
|
||||
});
|
||||
|
||||
test('words in Settings hide the items that mention them', async ({ page }) => {
|
||||
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||
await page.locator('.tabs button', { hasText: 'All' }).first().click();
|
||||
await expect(page.locator('.ep', { hasText: 'Second Episode' })).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await page.locator('#prefs').click();
|
||||
await page.locator('#sblock').fill('notes for the second'); // a phrase, in the show notes
|
||||
const saved = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH');
|
||||
await page.locator('#sblock').press('Tab');
|
||||
expect((await saved).status()).toBe(204);
|
||||
await page.locator('#modal .cardx').click();
|
||||
await expect(page.locator('.ep', { hasText: 'Second Episode' })).toHaveCount(0);
|
||||
await expect(page.locator('.ep', { hasText: 'First Episode' })).toBeVisible();
|
||||
|
||||
await page.evaluate(() => api('/api/me', { method: 'PATCH', body: JSON.stringify({ blocked: [] }) }));
|
||||
});
|
||||
|
||||
test('Share hands the feed, the item and the file to the share sheet', async ({ page }) => {
|
||||
await page.evaluate(() => { window.shared = []; navigator.share = async d => { window.shared.push(d); }; });
|
||||
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||
await page.locator('.tabs button', { hasText: 'All' }).first().click();
|
||||
await page.locator('#content .acts [data-a="share"]').click();
|
||||
await page.locator('.ep', { hasText: 'Second Episode' }).click();
|
||||
await page.locator('.encbox [data-a="share"]').first().click();
|
||||
await expect.poll(() => page.evaluate(() => window.shared)).toEqual([
|
||||
{ title: 'Test Show', url: expect.stringContaining('/show.xml') },
|
||||
{ title: 'Second Episode', url: expect.stringContaining('/ep1.mp3?2') },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<title>iPodderX admin</title>
|
||||
<title>iPX admin</title>
|
||||
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
<link rel="stylesheet" data-src="app.css">
|
||||
@@ -12,8 +12,8 @@
|
||||
<body class="adminpage">
|
||||
<!-- The server sends this page, and its script, to admins only. -->
|
||||
<header id="topbar">
|
||||
<a class="btn ico" href="/" title="Back to iPodderX" aria-label="Back to iPodderX" data-icon="left"></a>
|
||||
<img class="logo" src="/icon.png" alt="" width="26">
|
||||
<a class="btn ico" href="/" title="Back to iPX" aria-label="Back to iPX" data-icon="left"></a>
|
||||
<img class="logo" src="/logo.svg" alt="" width="26">
|
||||
<h1>Admin</h1>
|
||||
<span class="grow"></span>
|
||||
<nav class="tabs" id="atabs">
|
||||
|
||||
156
web/app.css
156
web/app.css
@@ -423,7 +423,9 @@
|
||||
--bad:#ff6961;
|
||||
--shadow:0 10px 36px rgba(0,0,0,.45);
|
||||
--glass-edge:rgba(255,255,255,.10);
|
||||
--glass-hi:rgba(255,255,255,.14);
|
||||
--glass-hi:rgba(255,255,255,.26);
|
||||
--glass-lo:rgba(255,255,255,.10);
|
||||
--glass-sheen:rgba(255,255,255,.07);
|
||||
--wash1:rgba(64,120,255,.30);
|
||||
--wash2:rgba(175,82,222,.24);
|
||||
--wash3:rgba(48,176,199,.20);
|
||||
@@ -446,6 +448,8 @@
|
||||
--shadow:0 10px 36px rgba(30,50,90,.16);
|
||||
--glass-edge:rgba(255,255,255,.65);
|
||||
--glass-hi:rgba(255,255,255,.9);
|
||||
--glass-lo:rgba(255,255,255,.45);
|
||||
--glass-sheen:rgba(255,255,255,.5);
|
||||
--wash1:rgba(64,120,255,.22);
|
||||
--wash2:rgba(175,82,222,.16);
|
||||
--wash3:rgba(48,176,199,.18);
|
||||
@@ -491,7 +495,7 @@ a{color:var(--accent)}
|
||||
}
|
||||
.brand{display:flex;align-items:center;gap:9px;padding:14px 14px 10px}
|
||||
.brand .logo{
|
||||
width:30px;height:30px;flex:none;object-fit:contain;
|
||||
width:30px;height:30px;flex:none;border-radius:7px;
|
||||
}
|
||||
.brand h1{font-size:16px;margin:0;font-weight:650;letter-spacing:-.01em;color:var(--fg);flex:1}
|
||||
.iconbtn{
|
||||
@@ -500,11 +504,12 @@ a{color:var(--accent)}
|
||||
}
|
||||
.iconbtn:hover{background:var(--raise);color:var(--fg)}
|
||||
/* One toolbar across the window, as the original had: grouped buttons, search on the right. */
|
||||
/* The display's own intrusions -- the home indicator along the bottom, the notch at one side in
|
||||
landscape. viewport-fit=cover hands the page the whole screen, which is what a standalone
|
||||
/* The display's own intrusions -- the Dynamic Island and status bar along the top (#54), the
|
||||
home indicator along the bottom, the notch at one side in landscape. viewport-fit=cover hands the page the whole screen, which is what a standalone
|
||||
shell and an iOS home-screen app both give it, so the bars along the edges pay for them in
|
||||
padding. A browser with its own chrome reports nought and nothing moves. */
|
||||
:root{
|
||||
--safe-t:env(safe-area-inset-top,0px);
|
||||
--safe-b:env(safe-area-inset-bottom,0px);
|
||||
--safe-l:env(safe-area-inset-left,0px);
|
||||
--safe-r:env(safe-area-inset-right,0px);
|
||||
@@ -514,7 +519,7 @@ a{color:var(--accent)}
|
||||
/* Longhand, and it has to stay longhand: the minifier runs a calc() in a padding shorthand
|
||||
into the value after it -- `calc(12px + var(--safe-r))7px` -- and the browser then throws
|
||||
the whole declaration away, leaving the bar with no padding at all. */
|
||||
padding-top:7px;padding-bottom:7px;
|
||||
padding-top:calc(7px + var(--safe-t));padding-bottom:7px;
|
||||
padding-left:calc(12px + var(--safe-l));padding-right:calc(12px + var(--safe-r));
|
||||
background:var(--panel);border-bottom:1px solid var(--line);
|
||||
}
|
||||
@@ -674,7 +679,7 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
||||
|
||||
/* Three panes, as the original had: feeds beside, items above, the item below. */
|
||||
#split{
|
||||
display:grid;flex:1;min-height:0;
|
||||
display:grid;flex:1;min-height:0;overflow:hidden; /* the reader sliding on a swipe */
|
||||
grid-template-columns:minmax(0,1fr) 270px;
|
||||
grid-template-rows:minmax(90px,var(--listh,60%)) 7px 1fr;
|
||||
grid-template-areas:"list files" "grab grab" "detail detail";
|
||||
@@ -763,7 +768,7 @@ kbd{font:inherit;font-size:12px;color:var(--fg);background:var(--panel2);border:
|
||||
body.adminpage{display:block;overflow:auto}
|
||||
body.adminpage #topbar{position:sticky;top:0;z-index:5}
|
||||
body.adminpage #topbar h1{font-size:16px;margin:0;font-weight:650}
|
||||
body.adminpage #topbar .logo{border-radius:5px}
|
||||
body.adminpage #topbar .logo{border-radius:6px}
|
||||
body.adminpage #topbar > a.btn{text-decoration:none}
|
||||
body.adminpage #admin{max-width:780px;margin:0 auto}
|
||||
body.adminpage #admin h2{font-size:18px;margin:0 0 12px}
|
||||
@@ -962,7 +967,7 @@ body:has(#player.on) #status{padding-bottom:3px}
|
||||
|
||||
@media (max-width:820px){
|
||||
#shell{grid-template-columns:1fr}
|
||||
#sidebar{position:fixed;inset:0 auto 0 0;width:min(300px,86vw);z-index:42;transform:translateX(-100%);transition:transform .2s;box-shadow:var(--shadow)}
|
||||
#sidebar{position:fixed;inset:0 auto 0 0;padding-top:var(--safe-t);width:min(300px,86vw);z-index:42;transform:translateX(-100%);transition:transform .2s;box-shadow:var(--shadow)}
|
||||
#sidebar.open{transform:none}
|
||||
#scrim{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:41}
|
||||
#player{
|
||||
@@ -973,7 +978,10 @@ body:has(#player.on) #status{padding-bottom:3px}
|
||||
|
||||
/* The feed list is reachable whether or not anything is playing. */
|
||||
#burger{display:grid}
|
||||
#topbar{gap:6px;padding:6px 8px}
|
||||
#topbar{
|
||||
gap:6px;padding-top:calc(6px + var(--safe-t));padding-bottom:6px;
|
||||
padding-left:calc(8px + var(--safe-l));padding-right:calc(8px + var(--safe-r));
|
||||
}
|
||||
#topbar .grow,.tgroup.item{display:none}
|
||||
#topbar #epSearch{width:auto;flex:1;min-width:0}
|
||||
.tgroup button,.tgroup a{padding:4px 8px;min-width:32px}
|
||||
@@ -986,9 +994,21 @@ body:has(#player.on) #status{padding-bottom:3px}
|
||||
#files,.ephead,.ep .fd,.ep .file,.ep .size,.ep .rowacts{display:none}
|
||||
.ep,#split.one .ep{grid-template-columns:20px 20px minmax(0,1fr) auto}
|
||||
#grab{display:none}
|
||||
#detail{position:fixed;inset:0;z-index:38;display:none;border-top:0;padding:12px 16px 90px}
|
||||
#detail{position:fixed;inset:0;z-index:38;display:none;border-top:0;padding:12px 16px 90px;padding-top:calc(12px + var(--safe-t))}
|
||||
body.reading #detail{display:block}
|
||||
#dback{display:inline-block;margin-bottom:10px}
|
||||
/* Beside the reader while it is swiped (gestures.ts): the item next to it, with a strip of the
|
||||
page's own background between them, so the list under the reader never shows (#52). */
|
||||
#dpeek{position:fixed;top:0;bottom:0;left:0;width:calc(100% + 16px);z-index:38;background:var(--bg);pointer-events:none}
|
||||
#dpeek>div{width:calc(100% - 16px);height:100%;overflow:hidden;padding:12px 16px 90px;padding-top:calc(12px + var(--safe-t));background:var(--panel)}
|
||||
#dpeek.next>div{margin-left:16px}
|
||||
#dpeek .encbox{margin:0 0 10px}
|
||||
#dpeek.end{display:flex;align-items:center;padding-left:28px;color:var(--faint);font-size:13px}
|
||||
#dpeek.end p{max-width:4.5em;margin:0}
|
||||
/* Going back to the list from the first item: the list waits under a dimmer that lifts as the
|
||||
reader, shadowed along its edge, is drawn off it. */
|
||||
#dpeek.dim{width:100%;z-index:37;background:#000}
|
||||
#detail.popping{box-shadow:-12px 0 32px rgba(0,0,0,.28)}
|
||||
|
||||
#content>.fhead,#content>.toolbar{padding-left:14px;padding-right:14px}
|
||||
#content>.fhead{padding-top:12px}
|
||||
@@ -1020,6 +1040,90 @@ body:has(#player.on) #status{padding-bottom:3px}
|
||||
#logbox .tg{display:none}
|
||||
}
|
||||
|
||||
/* ---------- A phone, drawn the way iOS draws its own apps (#53) ---------- */
|
||||
/* Filled shapes rather than outlines, corners that grow rounder as the shape grows and nest inside
|
||||
each other, 44px to tap and 17px to read. The outlines stay where they are what makes a control
|
||||
visible at all: the high-contrast theme, and anyone who has asked the system for more contrast.
|
||||
--edge is the one switch for that. */
|
||||
@media (max-width:820px){
|
||||
:root{--edge:transparent}
|
||||
:root[data-theme="contrast"]{--edge:var(--line)}
|
||||
/* Glass rules its panels off with an edge of its own; the light along them (--glass-rim) stays. */
|
||||
:root[data-theme="glass"],:root[data-theme="glass"][data-mode="light"]{--glass-edge:transparent}
|
||||
}
|
||||
@media (max-width:820px) and (prefers-contrast:more){
|
||||
:root{--edge:var(--line)}
|
||||
}
|
||||
@media (max-width:820px){
|
||||
/* The bars run into the page instead of being ruled off from it. */
|
||||
#topbar{background:var(--bg);border-bottom-color:var(--edge);gap:8px}
|
||||
#status{background:var(--bg);border-top-color:var(--edge)}
|
||||
#player{border-top-color:var(--edge)}
|
||||
.sidefoot{border-top-color:var(--edge)}
|
||||
|
||||
/* Buttons: filled and round. Hover is reset because Safari keeps it on whatever was last
|
||||
tapped, which left an accent outline on it. */
|
||||
.btn,.sidetools button,.sidefoot button,.tgroup,.toast,kbd,#logbox{border-color:var(--edge)}
|
||||
.btn:hover,.sidetools button:hover,.sidefoot button:hover{border-color:var(--edge)}
|
||||
.btn,.sidetools button,.sidefoot button{border-radius:999px;min-height:40px;padding-left:14px;padding-right:14px}
|
||||
.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px}
|
||||
.btn.ico,#dback{min-width:40px;height:40px;padding:0 10px;display:inline-grid;place-items:center}
|
||||
.btn.tiny{min-height:0;padding:2px 9px}
|
||||
.iconbtn{width:40px;height:40px;border-radius:999px;background:var(--panel2)}
|
||||
.tgroup{border-radius:999px}
|
||||
.tgroup button+button,.tgroup button+a{border-left-color:var(--edge)}
|
||||
.tgroup button,.tgroup a{min-width:36px;height:40px;padding:0 8px}
|
||||
|
||||
/* Text boxes filled, and at 16px or more: Safari zooms the page in on one any smaller. */
|
||||
input[type=search],input[type=text],input[type=password],input[type=number],select{
|
||||
font-size:17px;background:var(--panel2);border-color:var(--edge);border-radius:10px;padding:9px 12px;
|
||||
}
|
||||
#topbar #epSearch{font-size:16px;border-radius:999px;padding:9px 14px}
|
||||
input:focus,select:focus{border-color:var(--accent)}
|
||||
|
||||
/* The feed's name as the page's large title, the way Podcasts heads a show. */
|
||||
.fhead h2,.fhead.slim h2{font-size:26px;font-weight:700;letter-spacing:-.01em;line-height:1.2}
|
||||
.fhead .sub{font-size:15px}
|
||||
.acts{gap:8px}
|
||||
.acts .btn{min-width:44px;height:44px;padding:0 13px;justify-content:center;font-size:15px}
|
||||
|
||||
/* Tabs as a segmented control: equal segments on one track, the chosen one lifted. */
|
||||
.tabs{flex:1 1 100%;border-radius:10px;padding:2px}
|
||||
.tabs button,.tabs a{flex:1;text-align:center;padding:7px 4px;border-radius:8px;font-size:14px}
|
||||
.tabs button.on,.tabs a.on{box-shadow:0 1px 3px rgba(0,0,0,.22)}
|
||||
|
||||
/* Rows: taller, larger type, and a hairline between them that starts where the text does. */
|
||||
.ep{padding:10px 8px;border-radius:10px;min-height:44px}
|
||||
.ep.sel{border-color:var(--edge)}
|
||||
.ep:hover:not(.sel){background-color:transparent}
|
||||
.ep .t{font-size:17px}
|
||||
.ep .line,.ep .date{font-size:15px}
|
||||
.ep+.ep{background-image:linear-gradient(var(--line),var(--line));background-size:calc(100% - 64px) 1px;background-position:64px 0;background-repeat:no-repeat}
|
||||
.ep.sel,.ep.sel+.ep{background-image:none}
|
||||
.feed{padding:9px 10px;min-height:44px;border-radius:10px}
|
||||
.places{border-bottom-color:var(--edge);padding-bottom:10px;margin-bottom:10px}
|
||||
.feed .txt b{font-size:17px}
|
||||
.feed .txt small{font-size:13px}
|
||||
|
||||
/* The reader. */
|
||||
.dt{font-size:26px;font-weight:700;line-height:1.2;letter-spacing:-.01em;margin-bottom:8px}
|
||||
.dmeta{font-size:15px;gap:10px}
|
||||
.encbox{border-color:var(--edge);border-radius:14px;padding:8px 8px 8px 14px}
|
||||
.dbody{font-size:17px;line-height:1.55}
|
||||
|
||||
/* Dialogs are sheets, up from the bottom. */
|
||||
#modal{place-items:end stretch;padding:0}
|
||||
.card,.card.wide{
|
||||
width:100%;max-height:calc(100dvh - var(--safe-t) - 12px);border-color:var(--edge);border-radius:20px 20px 0 0;
|
||||
padding:20px 16px calc(20px + var(--safe-b));animation:sheet .3s cubic-bezier(.2,.8,.2,1);
|
||||
}
|
||||
.card h3{font-size:22px}
|
||||
.field label,.field .hint{font-size:13px}
|
||||
.check{font-size:17px}
|
||||
.toast{border-radius:14px}
|
||||
}
|
||||
@keyframes sheet{from{transform:translateY(100%)}}
|
||||
|
||||
/* ---------- Classic: the 2004 Mac app ---------- */
|
||||
/* After the iPodderX screenshots: brushed-metal chrome, a pale blue-grey source list, Aqua blue
|
||||
for whatever is selected, red unread badges, a striped table and Lucida Grande. */
|
||||
@@ -1082,7 +1186,10 @@ body:has(#player.on) #status{padding-bottom:3px}
|
||||
/* ---------- Glass: after Apple's Liquid Glass ---------- */
|
||||
/* Translucent panels over a coloured wash, blurred and saturated the way macOS and iOS do it.
|
||||
Apple's glass also bends light at its edges; that needs an SVG displacement filter that only
|
||||
Chromium applies to a backdrop, and only on a shape of fixed size, so it is left out (#43). */
|
||||
Chromium applies to a backdrop, and only on a shape of fixed size, so it is left out (#43).
|
||||
What is kept is the light: the rim catches it at the top-left edges and, fainter, the far ones,
|
||||
as it passes through (#51). */
|
||||
:root[data-theme="glass"]{--glass-rim:inset 1px 1px 0 var(--glass-hi),inset -1px -1px 0 var(--glass-lo)}
|
||||
:root[data-theme="glass"] body{
|
||||
font-family:-apple-system,BlinkMacSystemFont,system-ui,Inter,"Segoe UI",Roboto,sans-serif;
|
||||
background:
|
||||
@@ -1097,13 +1204,17 @@ body:has(#player.on) #status{padding-bottom:3px}
|
||||
:root[data-theme="glass"] #status,
|
||||
:root[data-theme="glass"] #files,
|
||||
:root[data-theme="glass"] #detail,
|
||||
:root[data-theme="glass"] #dpeek>div,
|
||||
:root[data-theme="glass"] .card,
|
||||
:root[data-theme="glass"] .toast{
|
||||
background:color-mix(in srgb,var(--panel) 70%,transparent);
|
||||
/* A sheen from the top-left corner, where the light comes from. It is a background layer rather
|
||||
than a pseudo-element because the detail pane, the file list and the dialogs scroll, and an
|
||||
absolutely placed layer inside a scroller scrolls away with the content. */
|
||||
background:linear-gradient(135deg,var(--glass-sheen),transparent 45%),color-mix(in srgb,var(--panel) 70%,transparent);
|
||||
-webkit-backdrop-filter:blur(24px) saturate(180%);
|
||||
backdrop-filter:blur(24px) saturate(180%);
|
||||
border-color:var(--glass-edge);
|
||||
box-shadow:inset 0 1px 0 var(--glass-hi);
|
||||
box-shadow:var(--glass-rim);
|
||||
}
|
||||
:root[data-theme="glass"] .toolbar,
|
||||
:root[data-theme="glass"] .ephead{
|
||||
@@ -1113,8 +1224,7 @@ body:has(#player.on) #status{padding-bottom:3px}
|
||||
backdrop-filter:blur(20px) saturate(180%);
|
||||
}
|
||||
:root[data-theme="glass"] .card,
|
||||
:root[data-theme="glass"] .toast{box-shadow:inset 0 1px 0 var(--glass-hi),var(--shadow)}
|
||||
:root[data-theme="glass"] .card{border-radius:20px}
|
||||
:root[data-theme="glass"] .toast{box-shadow:var(--glass-rim),var(--shadow)}
|
||||
:root[data-theme="glass"] .toast{border-radius:14px}
|
||||
:root[data-theme="glass"] .toast.bad{border-color:var(--bad)}
|
||||
/* A narrow screen slides the feed list over the page, so it keeps its shadow. */
|
||||
@@ -1126,12 +1236,25 @@ body:has(#player.on) #status{padding-bottom:3px}
|
||||
:root[data-theme="glass"] .tgroup,
|
||||
:root[data-theme="glass"] .tabs{
|
||||
background:color-mix(in srgb,var(--panel2) 60%,transparent);
|
||||
border-color:var(--glass-edge);border-radius:12px;box-shadow:inset 0 1px 0 var(--glass-hi);
|
||||
border-color:var(--glass-edge);box-shadow:var(--glass-rim);
|
||||
}
|
||||
/* Rounder than the other themes; a phone has shapes of its own (#53), which these would outrank. */
|
||||
@media (min-width:821px){
|
||||
:root[data-theme="glass"] .card{border-radius:20px}
|
||||
:root[data-theme="glass"] .tgroup,
|
||||
:root[data-theme="glass"] .tabs{border-radius:12px}
|
||||
:root[data-theme="glass"] .btn,
|
||||
:root[data-theme="glass"] .sidetools button,
|
||||
:root[data-theme="glass"] .sidefoot button{border-radius:10px}
|
||||
}
|
||||
:root[data-theme="glass"] .btn.primary{box-shadow:inset 0 1px 0 rgba(255,255,255,.35)}
|
||||
/* On a phone the rim all round a small capsule or bar read as the outline #53 took away, so the
|
||||
light only catches the top edge there, and the bars at the top and bottom have none. */
|
||||
@media (max-width:820px){
|
||||
:root[data-theme="glass"]{--glass-rim:inset 0 1px 0 var(--glass-lo)}
|
||||
:root[data-theme="glass"] #topbar,
|
||||
:root[data-theme="glass"] #status{box-shadow:none}
|
||||
}
|
||||
/* Someone who has asked the system for less transparency, or more contrast, gets solid panels. */
|
||||
@media (prefers-reduced-transparency:reduce),(prefers-contrast:more){
|
||||
:root[data-theme="glass"] body{background:var(--bg)}
|
||||
@@ -1141,6 +1264,7 @@ body:has(#player.on) #status{padding-bottom:3px}
|
||||
:root[data-theme="glass"] #status,
|
||||
:root[data-theme="glass"] #files,
|
||||
:root[data-theme="glass"] #detail,
|
||||
:root[data-theme="glass"] #dpeek>div,
|
||||
:root[data-theme="glass"] .card,
|
||||
:root[data-theme="glass"] .toast,
|
||||
:root[data-theme="glass"] .tgroup,
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 5.5 KiB |
@@ -72,6 +72,11 @@ export function buildPage(name, { minify = true } = {}) {
|
||||
let out = page.replace(marker, `<script src="/${script}?v=${hash(js)}"></script>`);
|
||||
out = out.replace(/<link rel="stylesheet" data-src="[^"]*">/,
|
||||
() => `<link rel="stylesheet" href="/${STYLE}?v=${hash(buildStyle({ minify }))}">`);
|
||||
// The icons are named by their contents too. They are kept a day under a fixed name, and a new
|
||||
// logo went unseen for that day, in browsers and at Cloudflare's edge. The server ignores the
|
||||
// query; /favicon.ico, which a browser asks for on its own, cannot carry one.
|
||||
out = out.replace(/(href|src)="\/(favicon\.png|apple-touch-icon\.png|logo\.svg)"/g,
|
||||
(_, attr, file) => `${attr}="/${file}?v=${hash(fs.readFileSync(path.join(here, file)))}"`);
|
||||
if (!minify) return { html: out, js, script };
|
||||
const r = html.minifySync(out, { minifyJs: false, minifyCss: true, removeComments: true });
|
||||
const bad = (r.errors || []).filter(e => e.level === 'Error');
|
||||
|
||||
BIN
web/favicon.png
BIN
web/favicon.png
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 3.2 KiB |
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<title>iPodderX</title>
|
||||
<title>iPX</title>
|
||||
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
<link rel="stylesheet" data-src="app.css">
|
||||
@@ -34,7 +34,7 @@
|
||||
<div id="shell">
|
||||
<aside id="sidebar">
|
||||
<div class="brand">
|
||||
<img class="logo" src="/icon.png" alt="iPodderX" title="The original iPodderX icon, 2004"><h1>iPodderX</h1>
|
||||
<img class="logo" src="/logo.svg" alt="iPX"><h1>iPX</h1>
|
||||
</div>
|
||||
<div class="searchwrap"><input type="search" id="feedFilter" placeholder="Filter feeds…"></div>
|
||||
<div id="feedlist"></div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<title>Sign in — iPodderX</title>
|
||||
<title>Sign in — iPX</title>
|
||||
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
<style>
|
||||
@@ -55,9 +55,9 @@ form{
|
||||
width:min(360px,100%);background:var(--panel);border:1px solid var(--line);
|
||||
border-radius:14px;padding:22px;box-shadow:var(--shadow);
|
||||
}
|
||||
/* The one place the 2004 icon is shown at the size it was drawn for. */
|
||||
/* The logo is a square app icon, so it gets an app icon's corners. */
|
||||
.brand{display:flex;flex-direction:column;align-items:center;gap:6px;margin-bottom:20px}
|
||||
.brand img{width:96px;height:auto}
|
||||
.brand img{width:72px;height:72px;border-radius:16px}
|
||||
h1{font-size:21px;margin:0;font-weight:650;letter-spacing:-.01em}
|
||||
label{display:block;font-size:12px;color:var(--dim);margin:0 0 4px}
|
||||
input{
|
||||
@@ -74,7 +74,7 @@ button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||||
</style>
|
||||
|
||||
<form id="f">
|
||||
<div class="brand"><img src="/icon.png" alt=""><h1>iPodderX</h1></div>
|
||||
<div class="brand"><img src="/logo.svg" alt=""><h1>iPX</h1></div>
|
||||
<label for="name">Name</label>
|
||||
<input id="name" name="name" autocomplete="username" autofocus required>
|
||||
<label for="pw">Password</label>
|
||||
|
||||
19
web/logo-dark.svg
Normal file
19
web/logo-dark.svg
Normal file
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||
<!-- logo.svg's dark appearance: the same layers in the same places, only recoloured, as the
|
||||
guidelines ask, so the icon reads as itself in either. -->
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#1d4468"/>
|
||||
<stop offset="1" stop-color="#0c2238"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g id="background"><rect width="1024" height="1024" fill="url(#bg)"/></g>
|
||||
<g id="scale" fill="#8fc2ea">
|
||||
<rect x="120" y="520" width="112" height="304" rx="56" opacity=".45"/>
|
||||
<rect x="288" y="360" width="112" height="464" rx="56" opacity=".7"/>
|
||||
<rect x="456" y="440" width="112" height="384" rx="56" opacity=".55"/>
|
||||
<rect x="624" y="280" width="112" height="544" rx="56" opacity=".85"/>
|
||||
<rect x="792" y="600" width="112" height="224" rx="56" opacity=".5"/>
|
||||
</g>
|
||||
<g id="needle"><rect x="728" y="160" width="72" height="704" rx="36" fill="#ff9f2e"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1003 B |
22
web/logo.svg
Normal file
22
web/logo.svg
Normal file
@@ -0,0 +1,22 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||
<!-- The iPodderX radio's screen, reduced to its tuning scale and needle, laid out as Apple's
|
||||
app icon guidelines ask: square and opaque (the system cuts the corners, and paints
|
||||
transparency black), one background and flat foreground layers with hard edges, no
|
||||
highlights or shadows of its own (the system adds them), nothing thin enough to vanish
|
||||
at 32px. Each <g> is a layer, for splitting out into Icon Composer. -->
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#dff0fb"/>
|
||||
<stop offset="1" stop-color="#9fccef"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g id="background"><rect width="1024" height="1024" fill="url(#bg)"/></g>
|
||||
<g id="scale" fill="#2f6aa0">
|
||||
<rect x="120" y="520" width="112" height="304" rx="56" opacity=".45"/>
|
||||
<rect x="288" y="360" width="112" height="464" rx="56" opacity=".7"/>
|
||||
<rect x="456" y="440" width="112" height="384" rx="56" opacity=".55"/>
|
||||
<rect x="624" y="280" width="112" height="544" rx="56" opacity=".85"/>
|
||||
<rect x="792" y="600" width="112" height="224" rx="56" opacity=".5"/>
|
||||
</g>
|
||||
<g id="needle"><rect x="728" y="160" width="72" height="704" rx="36" fill="#f7931e"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -234,6 +234,11 @@ async function prefsModal(){
|
||||
</div>
|
||||
<span class="hint">Export saves your subscriptions as OPML for another podcast app. Import
|
||||
subscribes you to every feed in one.</span></div>
|
||||
<div class="field"><label for="sblock">Hide items mentioning</label>
|
||||
<input type="text" id="sblock" value="${esc((S.me?.blocked||[]).join(', '))}">
|
||||
<span class="hint">Comma separated words or phrases, in every feed you read. An item with
|
||||
one in its title or text is hidden from you and not downloaded for you. Each feed's
|
||||
settings can add more.</span></div>
|
||||
<div class="field"><label>Feeds are checked every</label>
|
||||
<span class="hint">${everyText(g.every_mins)}, for every feed that does not set its own.
|
||||
${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></div>
|
||||
@@ -242,8 +247,18 @@ async function prefsModal(){
|
||||
$('#stheme').onchange=e=>setTheme(e.target.value,undefined,true);
|
||||
$('#smode').onchange=e=>setTheme(undefined,e.target.value,true);
|
||||
$('#gopml').onclick=opmlModal;
|
||||
$('#sblock').onchange=async e=>{
|
||||
const blocked=splitWords(e.target.value);
|
||||
try{
|
||||
await api('/api/me',{method:'PATCH',body:JSON.stringify({blocked})});
|
||||
if(S.me) S.me.blocked=blocked;
|
||||
toast('Saved'); await loadFeeds(true); loadEntries();
|
||||
}catch(err){ toast(err.message,true); }
|
||||
};
|
||||
}
|
||||
|
||||
const splitWords=(s: string)=>s.split(',').map(w=>w.trim()).filter(Boolean);
|
||||
|
||||
function settingsModal(f, newUrl?: string){
|
||||
const isGroup = S.feeds.some(c=>c.group===f.id);
|
||||
openModal(`<h3>${esc(f.title||f.id)}</h3>
|
||||
@@ -257,6 +272,11 @@ function settingsModal(f, newUrl?: string){
|
||||
<div class="field"><label>Keywords</label>
|
||||
<input type="text" id="skw" value="${esc(f.keywords.join(', '))}">
|
||||
<span class="hint">Comma separated. Empty takes everything.</span></div>
|
||||
${isGroup?'':`<div class="field"><label for="sblock">Hide items mentioning</label>
|
||||
<input type="text" id="sblock" value="${esc((f.blocked||[]).join(', '))}">
|
||||
<span class="hint">Comma separated words or phrases. An item with one in its title or text
|
||||
is hidden from you and not downloaded for you, as well as those your Settings hide
|
||||
everywhere.</span></div>`}
|
||||
<div class="field"><label>Max new downloads per scan</label>
|
||||
<input type="number" id="smax" min="0" value="${f.max_new_per_check??''}">
|
||||
<span class="hint">Blank follows the global default (${globalMax}). The rest wait for
|
||||
@@ -294,9 +314,10 @@ function settingsModal(f, newUrl?: string){
|
||||
const max=$('#smax').value;
|
||||
try{
|
||||
const patch: Record<string, unknown>={
|
||||
keywords:$('#skw').value.split(',').map(s=>s.trim()).filter(Boolean),
|
||||
keywords:splitWords($('#skw').value),
|
||||
max_new_per_check:max===''?null:Number(max),
|
||||
auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked};
|
||||
if($('#sblock')) patch.blocked=splitWords($('#sblock').value);
|
||||
// The shared half is an admin's to change, and the API refuses it from anyone else.
|
||||
if(S.me&&S.me.admin){
|
||||
patch.url=$('#surl').value.trim();
|
||||
|
||||
@@ -31,6 +31,7 @@ function renderFeed(){
|
||||
<button class="btn ico" data-a="dl" title="Download latest…" aria-label="Download latest">${ICON.download}</button>
|
||||
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
|
||||
<button class="btn ico" data-a="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button>
|
||||
<button class="btn ico" data-a="share" title="Share" aria-label="Share">${ICON.share}</button>
|
||||
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
|
||||
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
|
||||
</div>
|
||||
@@ -120,6 +121,7 @@ function renderGroup(f,kids){
|
||||
<button class="btn ico primary" data-a="scan" title="Re-read the OPML now" aria-label="Re-read the OPML now">${ICON.scan}</button>
|
||||
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
|
||||
<button class="btn ico" data-a="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button>
|
||||
<button class="btn ico" data-a="share" title="Share" aria-label="Share">${ICON.share}</button>
|
||||
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
|
||||
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
|
||||
</div>
|
||||
@@ -166,6 +168,7 @@ async function feedAction(a,f){
|
||||
}catch(e){ toast(e.message,true); }
|
||||
}
|
||||
if(a==='settings') settingsModal(f);
|
||||
if(a==='share') share(f.title||f.id, f.url, $('#content .acts [data-a="share"]'));
|
||||
if(a==='dl') downloadLatestModal(f);
|
||||
}
|
||||
/// All Subscriptions' own buttons: a feed's, across every feed you read.
|
||||
|
||||
@@ -5,7 +5,12 @@
|
||||
// the document because the panes are rebuilt every time a feed renders.
|
||||
|
||||
const PULL = 70; // px down, from the list's top, that counts as a pull
|
||||
const SWIPE = 60; // px across that counts as a swipe
|
||||
// A quarter of the reader's width across, and never less than 100px: at a flat 60px a thumb
|
||||
// scrolling slightly on the diagonal jumped to the next item by accident (issue #49).
|
||||
const swipeMin = () => Math.max(100, ($('#detail')?.clientWidth || 0) / 4);
|
||||
const GAP = 16; // px of page between two items side by side, so they read as two sheets
|
||||
const narrow = () => !!window.matchMedia?.('(max-width:820px)')?.matches;
|
||||
const still = () => !!window.matchMedia?.('(prefers-reduced-motion:reduce)')?.matches;
|
||||
|
||||
let touch: {x: number, y: number, t: number, pane: 'list' | 'detail', dx: number, dy: number} | null = null;
|
||||
|
||||
@@ -46,9 +51,15 @@ document.addEventListener('touchstart', ev => {
|
||||
const t = ev.touches[0], target = ev.target as Element;
|
||||
const detail = target.closest?.('#detail'), list = target.closest?.('#list');
|
||||
// Reading means an item is open; the reader is its own screen on a phone, a pane otherwise.
|
||||
if(detail && S.sel && !ownsSwipe(target)) touch = {x: t.clientX, y: t.clientY, t: Date.now(), pane: 'detail', dx: 0, dy: 0};
|
||||
if(detail && S.sel && !ownsSwipe(target)){
|
||||
touch = {x: t.clientX, y: t.clientY, t: Date.now(), pane: 'detail', dx: 0, dy: 0};
|
||||
// Safari takes a swipe from the screen's left edge as Back, which leaves the page halfway
|
||||
// through going back to the list. Kept narrower than the reader's padding, so a tap on the
|
||||
// back button is never swallowed with it.
|
||||
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};
|
||||
}, {passive: true});
|
||||
}, {passive: false});
|
||||
|
||||
document.addEventListener('touchmove', ev => {
|
||||
if(!touch) return;
|
||||
@@ -60,11 +71,74 @@ document.addEventListener('touchmove', ev => {
|
||||
pullShow(touch.dy);
|
||||
if(ev.cancelable) ev.preventDefault(); // or the list rubber-bands under the finger as well
|
||||
}else if(Math.abs(touch.dy) > 10 && Math.abs(touch.dy) > Math.abs(touch.dx)){
|
||||
touch = null; // scrolling the text, not swiping; the first few px
|
||||
slide(0); touch = null; // scrolling the text, not swiping; the first few px
|
||||
// decide nothing, being mostly jitter
|
||||
}else if(Math.abs(touch.dx) > 10){
|
||||
// The reader follows the finger, so it is plain before letting go whether this will move on;
|
||||
// with nothing that way it only gives a little.
|
||||
const i = S.entries.findIndex(x => x.guid === S.sel);
|
||||
const end = touch.dx < 0 && i >= S.entries.length - 1;
|
||||
slide(end ? touch.dx / 3 : touch.dx, 0);
|
||||
}
|
||||
}, {passive: false});
|
||||
|
||||
/// On a phone, what lies beside the reader while it is dragged by dx: the item it will move on
|
||||
/// to, a note that there are no more, or, going back to the list, a dimmer over the list. The
|
||||
/// reader sits over the list there, and moving it alone showed the list through the gap it left
|
||||
/// (issue #52). Kept outside #content, which every feed render rebuilds, and inert, since its
|
||||
/// copy of the reader's markup repeats the reader's ids.
|
||||
function beside(dx: number){
|
||||
const i = S.entries.findIndex(x => x.guid === S.sel), e = S.entries[i + (dx < 0 ? 1 : -1)];
|
||||
const kind = dx < 0 ? (e ? 'next' : 'end') : (e ? 'prev' : 'dim');
|
||||
let p = $('#dpeek');
|
||||
if(!p){
|
||||
p = document.createElement('div'); p.id = 'dpeek'; p.inert = true; p.setAttribute('aria-hidden', 'true');
|
||||
document.body.append(p);
|
||||
}
|
||||
if(p.dataset.key !== kind + (e?.guid || '')){
|
||||
p.dataset.key = kind + (e?.guid || ''); p.className = kind;
|
||||
p.innerHTML = e ? `<div>${detailHtml(e)}</div>` : kind === 'end' ? '<p>No more items</p>' : '';
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
/// Moves the reader across by dx over ms, or at once (when following a finger), and what lies
|
||||
/// beside it with it.
|
||||
function slide(dx: number, ms = 160){
|
||||
const d = $('#detail'); if(!d) return;
|
||||
dx = Math.round(dx); // whole pixels, or the seam between the reader and its neighbour blurs
|
||||
const w = d.clientWidth || 1, tr = ms ? `transform ${ms}ms ease-out, opacity ${ms}ms ease-out` : 'none';
|
||||
d.style.transition = tr;
|
||||
d.style.transform = dx ? `translateX(${dx}px)` : '';
|
||||
// A wider screen has the reader as a pane beside the list, with nothing under it to show.
|
||||
if(!narrow()){ d.style.opacity = dx ? String(Math.max(.4, 1 - Math.abs(dx) / w)) : ''; return; }
|
||||
const p = dx ? beside(dx) : $('#dpeek');
|
||||
d.classList.toggle('popping', !!dx && p?.className === 'dim');
|
||||
if(!p) return;
|
||||
p.style.transition = tr;
|
||||
if(p.className === 'dim') p.style.opacity = String(.35 * (1 - Math.abs(dx) / w));
|
||||
else p.style.transform = `translateX(${dx + (p.className === 'prev' ? -w - GAP : w)}px)`;
|
||||
// Gone once the reader is back, unless another drag has already begun.
|
||||
if(!dx){ if(ms) setTimeout(() => { if(!touch) $('#dpeek')?.remove(); }, ms); else p.remove(); }
|
||||
}
|
||||
|
||||
/// The reader carries on the way the finger went, from wherever it was let go, and the item
|
||||
/// beside it becomes the reader in its place. Timers, not transitionend, because with reduced
|
||||
/// motion the stylesheet turns transitions off and transitionend never comes.
|
||||
function slideOn(dx: number, go: () => void){
|
||||
const d = $('#detail'), w = d?.clientWidth || 0, dir = Math.sign(dx);
|
||||
if(!narrow()){
|
||||
// A pane beside the list: off one side, and the next item in from the other.
|
||||
slide(dir * w);
|
||||
setTimeout(() => { go(); slide(-dir * w, 0); requestAnimationFrame(() => requestAnimationFrame(() => slide(0))); }, 160);
|
||||
return;
|
||||
}
|
||||
const to = dir * (w + (beside(dx).className === 'dim' ? 0 : GAP));
|
||||
const ms = still() ? 0 : Math.round(Math.max(120, 250 * (1 - Math.abs(dx) / (w || 1))));
|
||||
slide(to, ms);
|
||||
setTimeout(() => { go(); slide(0, 0); }, ms);
|
||||
}
|
||||
|
||||
document.addEventListener('touchend', () => {
|
||||
const g = touch; touch = null;
|
||||
if(!g) return;
|
||||
@@ -74,9 +148,11 @@ document.addEventListener('touchend', () => {
|
||||
return;
|
||||
}
|
||||
// Across, mostly sideways, and not a slow drag while selecting text.
|
||||
if(Math.abs(g.dx) < SWIPE || Math.abs(g.dx) < 2 * Math.abs(g.dy) || Date.now() - g.t > 800) return;
|
||||
const i = S.entries.findIndex(x => x.guid === S.sel);
|
||||
if(g.dx < 0){ if(i < S.entries.length - 1) stepEntry(1); return; }
|
||||
if(i > 0) stepEntry(-1); else showDetail(null);
|
||||
if(Math.abs(g.dx) < swipeMin() || Math.abs(g.dx) < 2 * Math.abs(g.dy) || Date.now() - g.t > 800
|
||||
|| (g.dx < 0 && i >= S.entries.length - 1)) return slide(0);
|
||||
if(g.dx < 0) return slideOn(g.dx, () => stepEntry(1));
|
||||
if(i > 0) return slideOn(g.dx, () => stepEntry(-1));
|
||||
slideOn(g.dx, () => showDetail(null));
|
||||
});
|
||||
document.addEventListener('touchcancel', () => { if(touch?.pane === 'list') pullShow(0); touch = null; });
|
||||
document.addEventListener('touchcancel', () => { if(touch?.pane === 'list') pullShow(0); else slide(0); touch = null; });
|
||||
|
||||
@@ -230,6 +230,19 @@ function showDetail(e){
|
||||
}
|
||||
// Nothing when there are no files: the pane that would say so is hidden above, and on a phone,
|
||||
// where the files sit over the text, a box saying "No files" only pushed the text down.
|
||||
const encs=e.enclosures.map(encBox).join('');
|
||||
const narrow=!!window.matchMedia?.('(max-width:820px)')?.matches;
|
||||
box.innerHTML=detailHtml(e);
|
||||
if(files) files.innerHTML=narrow?'':`<div class="fhd">Files</div>${encs}`;
|
||||
$('#dback').onclick=()=>showDetail(null);
|
||||
for(const root of [box,files]) if(root) $$('button[data-a]',root).forEach(b=>
|
||||
b.onclick=()=>epAction(b.dataset.a,e,null,b.dataset.enc?Number(b.dataset.enc):null));
|
||||
syncPlayButtons();
|
||||
}
|
||||
|
||||
/// 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).
|
||||
function detailHtml(e){
|
||||
const encs=e.enclosures.map(encBox).join('');
|
||||
// A phone has no room for the files pane, so the files go above the text there instead.
|
||||
// Below it, a long set of show notes pushed play and delete screens down, and on an iPhone
|
||||
@@ -237,7 +250,8 @@ function showDetail(e){
|
||||
const narrow=!!window.matchMedia?.('(max-width:820px)')?.matches;
|
||||
const f=S.feeds.find(x=>x.id===e.feed_id);
|
||||
const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join('');
|
||||
box.innerHTML=`
|
||||
// description was sanitized server-side with ammonia before it ever reached here
|
||||
return `
|
||||
<button class="btn ico" id="dback" title="Back to the items" aria-label="Back to the items">${ICON.left}</button>
|
||||
<h3 class="dt">${esc(e.title||'(untitled)')}</h3>
|
||||
<div class="dmeta">
|
||||
@@ -249,16 +263,11 @@ function showDetail(e){
|
||||
<button class="btn ico" data-a="flag" title="${e.flagged?'Pinned: never deleted. Unpin':'Pin, so it is never deleted'}"
|
||||
aria-label="${e.flagged?'Unpin':'Pin'}">${e.flagged?ICON.pinOn:ICON.pin}</button>
|
||||
${e.link?`<a class="btn ico" href="${esc(e.link)}" target="_blank" rel="noopener noreferrer"
|
||||
title="Open the original" aria-label="Open the original">${ICON.open}</a>`:''}
|
||||
title="Open the original" aria-label="Open the original">${ICON.open}</a>
|
||||
<button class="btn ico" data-a="share" title="Share" aria-label="Share">${ICON.share}</button>`:''}
|
||||
</div>
|
||||
${narrow?encs:''}
|
||||
<div class="dbody">${(e.description&&e.description.trim())||'<em>No show notes.</em>'}</div>`;
|
||||
if(files) files.innerHTML=narrow?'':`<div class="fhd">Files</div>${encs}`;
|
||||
// description was sanitized server-side with ammonia before it ever reached here
|
||||
$('#dback').onclick=()=>showDetail(null);
|
||||
for(const root of [box,files]) if(root) $$('button[data-a]',root).forEach(b=>
|
||||
b.onclick=()=>epAction(b.dataset.a,e,null,b.dataset.enc?Number(b.dataset.enc):null));
|
||||
syncPlayButtons();
|
||||
}
|
||||
|
||||
/// One enclosure: a play button when the file is here, otherwise what it is and a way to get it.
|
||||
@@ -273,6 +282,8 @@ function encBox(x){
|
||||
: 'Delete file';
|
||||
const delBtn=`<button class="btn ico danger" data-a="del" data-enc="${x.id}" title="${delLabel}" aria-label="${delLabel}">${ICON.trash}</button>`;
|
||||
const saveBtn=`<a class="btn ico" href="/media/${x.id}" download title="Save to this computer" aria-label="Save to this computer">${ICON.save}</a>`;
|
||||
// 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>`;
|
||||
if(x.path && !isPlayable(x)){
|
||||
// On disk, but not audio or video: view it, keep it, or remove it -- no player.
|
||||
return `<div class="encbox">
|
||||
@@ -280,6 +291,7 @@ function encBox(x){
|
||||
<span class="meta" style="flex:1">${size}</span>
|
||||
<a class="btn ico" href="/media/${x.id}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">${ICON.open}</a>
|
||||
${saveBtn}
|
||||
${shareBtn}
|
||||
${delBtn}
|
||||
</div>`;
|
||||
}
|
||||
@@ -291,6 +303,7 @@ function encBox(x){
|
||||
<span class="meta" style="flex:1">${size}</span>
|
||||
<button class="btn ico" data-a="play" data-enc="${x.id}" title="Play" aria-label="Play">${ICON.play}</button>
|
||||
${saveBtn}
|
||||
${shareBtn}
|
||||
${delBtn}
|
||||
</div>`;
|
||||
}
|
||||
@@ -303,6 +316,7 @@ function encBox(x){
|
||||
<span class="meta" style="flex:1">${size}</span>
|
||||
${x.state==='error'&&x.last_error?`<span class="err">${esc(x.last_error)}</span>`:''}
|
||||
${viewable?`<a class="btn ico" href="${esc(x.url)}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">${ICON.open}</a>`:''}
|
||||
${shareBtn}
|
||||
<button class="btn ico" data-a="get" data-enc="${x.id}" title="Download to the server" aria-label="Download to the server">${ICON.download}</button>
|
||||
</div>`;
|
||||
}
|
||||
@@ -314,6 +328,7 @@ async function epAction(a: string, e, el, encId?: number){
|
||||
const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`;
|
||||
try{
|
||||
if(a==='play') play(e, encId!=null ? enc : undefined);
|
||||
if(a==='share') await share(e.title||'', encId!=null ? enc.url : e.link, el);
|
||||
if(a==='flag'){ e.flagged=!e.flagged; await api(path+'/flags',{method:'POST',body:JSON.stringify({flagged:e.flagged})}); redraw(); }
|
||||
if(a==='read'){ await setRead(e,!e.read); redraw(); }
|
||||
if(a==='get'){
|
||||
|
||||
@@ -10,6 +10,7 @@ const esc = s => (s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>
|
||||
// the button's own colour. To add one, copy the path from svgs/<style>/<name>.svg at the same tag.
|
||||
const fa=(box,body)=>`<svg class="i" viewBox="${box}" aria-hidden="true">${body}</svg>`;
|
||||
const ICON={
|
||||
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
|
||||
circleMinus:fa('0 0 512 512','<path fill="currentColor" d="M512 256A256 256 0 1 0 0 256a256 256 0 1 0 512 0zM184 232l144 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-144 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z"/>'), // solid/circle-minus, for Unsubscribe
|
||||
play:fa('0 0 448 512','<path fill="currentColor" d="M91.2 36.9c-12.4-6.8-27.4-6.5-39.6 .7S32 57.9 32 72l0 368c0 14.1 7.5 27.2 19.6 34.4s27.2 7.5 39.6 .7l336-184c12.8-7 20.8-20.5 20.8-35.1s-8-28.1-20.8-35.1l-336-184z"/>'), // solid/play
|
||||
@@ -92,6 +93,27 @@ async function copyText(text,btn){
|
||||
}
|
||||
}
|
||||
|
||||
/// The system's share sheet where there is one (phones, Safari, Edge), the clipboard elsewhere.
|
||||
/// Asks first when the address looks like it carries your own access to a paid or private
|
||||
/// feed: a Patreon feed's token, or a key, token or password in it, which would hand whoever
|
||||
/// gets it your subscription (issue #48).
|
||||
///
|
||||
/// 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.
|
||||
async function share(title: string, url: string, btn?){
|
||||
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 '
|
||||
+'send it to could use it as you.\n\nShare it anyway?')) return;
|
||||
if(navigator.share){
|
||||
// Cancelling the sheet rejects too, and is not a failure worth a word.
|
||||
try{ await navigator.share({title, url}); }
|
||||
catch(e){ if(e.name!=='AbortError') toast(e.message,true); }
|
||||
return;
|
||||
}
|
||||
await copyText(url,btn);
|
||||
toast('Link copied');
|
||||
}
|
||||
|
||||
function toast(msg: string, bad?: boolean){
|
||||
const t=document.createElement('div');
|
||||
t.className='toast'+(bad?' bad':''); t.textContent=msg;
|
||||
|
||||
Reference in New Issue
Block a user