From 26802d2b239706f450712bd0a668d18906c1b2be Mon Sep 17 00:00:00 2001 From: rays Date: Fri, 18 Sep 2026 12:41:34 +0000 Subject: [PATCH] The page's script is TypeScript in web/src, built and minified with swc - web/src/*.ts: the script that was inline in index.html and login.html, split along its existing sections. Still one scope, concatenated in order, not modules. - web/build.mjs strips the types, puts the script in the page and minifies it with swc; build.rs runs it into OUT_DIR and web.rs include_str!s the result. 137 KB -> 106 KB. - npx tsc -p . type-checks web/src, loosely; the handful of annotations it needed change no behaviour. - The Docker build installs node and swc (npm ci --omit=dev). - Two list requests racing no longer let the older one win, and switching tabs clears the selection it closes, which made a browser test flaky. Closes #23, #24. Co-Authored-By: Claude Opus 5 --- .gitignore | 1 + CHANGELOG.md | 4 + CLAUDE.md | 25 +- Dockerfile | 8 +- build.rs | 14 + docs/architecture.md | 8 +- package-lock.json | 887 ++++++++++++++++++++++- package.json | 17 +- src/web.rs | 14 +- tests/page-smoke.js | 11 +- tsconfig.json | 12 + web/build.mjs | 52 ++ web/index.html | 1621 +----------------------------------------- web/login.html | 18 +- web/src/dialogs.ts | 608 ++++++++++++++++ web/src/events.ts | 46 ++ web/src/feedpage.ts | 177 +++++ web/src/feeds.ts | 120 ++++ web/src/items.ts | 340 +++++++++ web/src/login.ts | 15 + web/src/player.ts | 180 +++++ web/src/util.ts | 157 ++++ 22 files changed, 2669 insertions(+), 1666 deletions(-) create mode 100644 build.rs create mode 100644 tsconfig.json create mode 100644 web/build.mjs create mode 100644 web/src/dialogs.ts create mode 100644 web/src/events.ts create mode 100644 web/src/feedpage.ts create mode 100644 web/src/feeds.ts create mode 100644 web/src/items.ts create mode 100644 web/src/login.ts create mode 100644 web/src/player.ts create mode 100644 web/src/util.ts diff --git a/.gitignore b/.gitignore index 1a0a581..322429c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /node_modules /test-results /playwright-report +/web/dist diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fd5ff0..ba7ff72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,9 +18,13 @@ The long form, with what was wrong before and how it was found, is in - Add a feed asks only for the feed; Popular and Directory in the sidebar are where you browse. - On a phone, an item's files, with play and delete, sit above its show notes rather than below them, where long notes left them looking missing. +- The page is served minified, about a quarter smaller. Its script is now TypeScript in + `web/src`, type-checked, and built with swc; building ipx needs node. ### Fixed +- Switching tabs straight after marking everything read no longer shows the previous tab's + items: of two lists asked for at once, only the later one is shown. - An item you open stays read. A list refresh that crossed with marking it read could put its unread dot back until the next refresh. - On the Unread tab, the item you were reading leaves the list as soon as you move to the next diff --git a/CLAUDE.md b/CLAUDE.md index 8d4ca98..4a21e1f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,18 +61,28 @@ the shell running the command and kills the session (exit 144). This has happene ## Before you touch the page -`web/index.html` is `include_str!`d into the binary, so **every page change needs a rebuild** before -it is visible. It is one file: markup, CSS and script. +The page is markup and CSS in `web/index.html` and TypeScript in `web/src/`. `build.rs` runs +`web/build.mjs`, which uses swc to strip the types, put the script into the page and minify the +whole thing, and the result is `include_str!`d into the binary. So **every page change needs a +rebuild** before it is visible, and building needs node and `npm ci` run once. + +The files in `web/src` are not modules. They are one script split up, concatenated in the order +`web/build.mjs` lists them, sharing one top-level scope as the single inline script did; a new +file goes into that list. Top-level names are kept as they are, because markup calls some by +name (`onclick="closeModal()"`) and the browser tests reach others through `page.evaluate`. After any edit to it: ```sh +npx tsc -p . node tests/page-smoke.js ``` -That loads the script against a stub DOM and checks every selector it wires at load actually -exists. It exists because a patch once anchored on a deleted function, `String.replace` silently -matched nothing, and the whole UI died with a `ReferenceError` while every server-side test passed. +The first type-checks `web/src` (loosely: `strict` is off, and `$` returns `any`). The second +builds the page as shipped and runs its script against a stub DOM, checking every selector it +wires at load actually exists. That check exists because a patch once anchored on a deleted +function, `String.replace` silently matched nothing, and the whole UI died with a +`ReferenceError` while every server-side test passed. Patching that file by guessing an anchor string has failed repeatedly. Read the exact block first (`sed -n 'START,ENDp'`), match it verbatim, and assert the replacement happened rather than hoping. @@ -80,9 +90,10 @@ Patching that file by guessing an anchor string has failed repeatedly. Read the ## Tests ```sh -cargo test # ~51 tests: parsing, filters, retention, schedules, SQL, per-user state +cargo test # ~80 tests: parsing, filters, retention, schedules, SQL, per-user state +npx tsc -p . # type-checks web/src node tests/page-smoke.js -npx playwright test # 16 browser tests against a real daemon on fixture feeds +npx playwright test # 40 browser tests against a real daemon on fixture feeds ``` Things about the browser suite that have cost time: diff --git a/Dockerfile b/Dockerfile index 68ea098..6fab78f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,23 @@ # Build. rusqlite is bundled (compiles SQLite from source) and librqbit needs a C # toolchain, so the builder needs cc. TLS is rustls throughout, so no OpenSSL headers. +# build.rs builds the web pages from TypeScript with swc, which needs node. FROM rust:1-slim-bookworm AS build RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential \ + build-essential nodejs npm \ && rm -rf /var/lib/apt/lists/* WORKDIR /src +# swc only: Playwright and TypeScript are for testing and type-checking, not for building. +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev + # Dependencies first, so editing the source does not rebuild librqbit every time. COPY Cargo.toml Cargo.lock ./ RUN mkdir src && echo 'fn main(){}' > src/main.rs \ && cargo build --release --locked \ && rm -rf src +COPY build.rs ./ COPY src ./src COPY web ./web # cargo skips a rebuild if mtimes look untouched; make sure it does not. diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..488e4d0 --- /dev/null +++ b/build.rs @@ -0,0 +1,14 @@ +//! Builds web/index.html and web/login.html from their TypeScript (web/build.mjs) into OUT_DIR, +//! where src/web.rs include_str!s them. Needs node and `npm ci` run first. +use std::process::Command; + +fn main() { + println!("cargo:rerun-if-changed=web"); + println!("cargo:rerun-if-changed=package-lock.json"); + let out = std::env::var("OUT_DIR").unwrap(); + let status = Command::new("node") + .args(["web/build.mjs", &out]) + .status() + .expect("building the web pages needs node on PATH (and `npm ci` run once)"); + assert!(status.success(), "web/build.mjs failed; run `node web/build.mjs` to see why"); +} diff --git a/docs/architecture.md b/docs/architecture.md index 0755401..168269c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -19,9 +19,13 @@ it to a running daemon. | `src/auth.rs` | Argon2id hashing, session tokens, header names | — | | `src/web.rs` | axum: HTTP API, auth, SSE, media streaming | — | | `src/logbuf.rs` | Ring buffer behind the UI's Log view | — | -| `web/index.html` | The whole front end, `include_str!`d into the binary | — | +| `web/index.html` | The page's markup and CSS | — | +| `web/src/*.ts` | The page's script, one scope split across files, type-checked by `npx tsc` | — | +| `web/build.mjs` | swc: strips the types, puts the script in the page, minifies it | — | +| `build.rs` | Runs `web/build.mjs` into `OUT_DIR`, where `web.rs` `include_str!`s the result | — | -The page is compiled in, so **editing `web/index.html` needs a rebuild**. +The page is compiled in, so **editing `web/index.html` or `web/src` needs a rebuild**, and a +build needs node and `npm ci` run once. ## A scan diff --git a/package-lock.json b/package-lock.json index 28544fb..ec54f06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,17 @@ { - "name": "ipx-ui-tests", + "name": "ipx-web", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "ipx-ui-tests", + "name": "ipx-web", + "dependencies": { + "@swc/core": "^1.16.2", + "@swc/html": "^1.16.2" + }, "devDependencies": { - "@playwright/test": "^1.56.0" + "@playwright/test": "^1.56.0", + "typescript": "^7.0.2" } }, "node_modules/@playwright/test": { @@ -25,6 +30,847 @@ "node": ">=20" } }, + "node_modules/@swc/core": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.16.2.tgz", + "integrity": "sha512-95I4kiSMeveI/Mhi+tE4fiWcWLUMfzfKrk0jtr8LRMqHgOgq+xHS+zExkDqoO4b5OeeuXHMWVdD5MeP3X6sULw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.28" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.16.2", + "@swc/core-darwin-x64": "1.16.2", + "@swc/core-linux-arm-gnueabihf": "1.16.2", + "@swc/core-linux-arm64-gnu": "1.16.2", + "@swc/core-linux-arm64-musl": "1.16.2", + "@swc/core-linux-ppc64-gnu": "1.16.2", + "@swc/core-linux-s390x-gnu": "1.16.2", + "@swc/core-linux-x64-gnu": "1.16.2", + "@swc/core-linux-x64-musl": "1.16.2", + "@swc/core-win32-arm64-msvc": "1.16.2", + "@swc/core-win32-ia32-msvc": "1.16.2", + "@swc/core-win32-x64-msvc": "1.16.2" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.16.2.tgz", + "integrity": "sha512-i/j0HNbnn79qnTVPicvay92Nark8fW8NQqn1e2mGERjUXNpBV0+SwQxlRpk2zBhn6laJ8PDI6Kn1nHZhnz3LCA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.16.2.tgz", + "integrity": "sha512-HrwqHyEyHVXO3qTk8EkNK7/b6sOZSEoNh+pot6RdE5x0LbNqfo8LtJUvi3UTXr+5ja/o5HbJdW80eCXo+NjbiA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.16.2.tgz", + "integrity": "sha512-MdXi83Z/gGp1LIrg+h7HKxiul/z/Bty/ZJSvYAFqDl9zteC1XLSAZdScquKtXPp50rdyXqritTDCqQBhwVfZKA==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.16.2.tgz", + "integrity": "sha512-/jcTmK6Ktz3owM3YtiKvjofV6p3VpHnYzTIrOGwDIOsDigRAAVuZ8east33wYO/7UTdKYFlyHNnJNT0WJqOA3Q==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.16.2.tgz", + "integrity": "sha512-4gFarKaFnlJTSlJYKmMhV4u+3YE4uYfiydpBoYjmgQhCf9lAieOq+WilZaK9vVSHeqLuQpTEiGULZqAdsRX5Dw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-ppc64-gnu": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.16.2.tgz", + "integrity": "sha512-syqSLGd6KlZ1PciNzs6bIUlhOuFztZufebOHaERjc4N4SqNZxyqYd4I+jj/EfOYnpe0kNjccn9HJLN1p5dz3+w==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-s390x-gnu": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.16.2.tgz", + "integrity": "sha512-ZBBLK+ewGyXLzWeMS7wbKtWBdnif6etn7xvPY/iOfbdsjX/+bgkp1pQt2lWF2wlu2hXYZuhJ/tHZE/QR8/apzg==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.16.2.tgz", + "integrity": "sha512-LyHJgxCA4Tje0ysBMbEb0tt/ie8kgUKoFE3JAKFhpevmTmhYEoC0H9s47WuDsqiFckF1ITUguZIXJG6K5e0dvg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.16.2.tgz", + "integrity": "sha512-PghXJlVM1cgtLfNUR1vxFo1z+PDRAe8cWAJlZZ7spmeiN7BospGXg/MHUg7oNSgwSX7Zo//YKv9P5yD9apsFJQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.16.2.tgz", + "integrity": "sha512-StTOSefYBxemvNYYUI3UmO1a8y+hSPjjfHogC2TEHL+Z1PlEBim/XtLas5rS04jAzT9RrNmbtX911SZ42H9jSQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.16.2.tgz", + "integrity": "sha512-fycER209DYIzsibpTMC+chND05OfOjgztWL9U8OE6/uUlsOUZH3eh98isBLEnOymYUhlJLEt5++W1+KL/FOh5Q==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.16.2.tgz", + "integrity": "sha512-cSd1z6ivSrJPVr+moVwOHWjeKy6TpO4/Shwcv5KCrKYXCccxwh4pRy1C3fDioNx2PF1jPZWHKZjtXt+Be9VbaQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" + }, + "node_modules/@swc/html": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/html/-/html-1.16.2.tgz", + "integrity": "sha512-RmWH8m5dePWDFpHpmFKquZCRe5SyD/Sb0FBPxWcWv/tsjtlJl6oHeaxBsTL2edvaHuW385Fy5nPuTjDD/a+GEA==", + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "@swc/html-darwin-arm64": "1.16.2", + "@swc/html-darwin-x64": "1.16.2", + "@swc/html-linux-arm-gnueabihf": "1.16.2", + "@swc/html-linux-arm64-gnu": "1.16.2", + "@swc/html-linux-arm64-musl": "1.16.2", + "@swc/html-linux-ppc64-gnu": "1.16.2", + "@swc/html-linux-s390x-gnu": "1.16.2", + "@swc/html-linux-x64-gnu": "1.16.2", + "@swc/html-linux-x64-musl": "1.16.2", + "@swc/html-win32-arm64-msvc": "1.16.2", + "@swc/html-win32-ia32-msvc": "1.16.2", + "@swc/html-win32-x64-msvc": "1.16.2" + } + }, + "node_modules/@swc/html-darwin-arm64": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/html-darwin-arm64/-/html-darwin-arm64-1.16.2.tgz", + "integrity": "sha512-SNBUxkxLBXD0ATwnOG1rF8mpSrRtFDfqWnEUmbm/g4KwmCt7NuHHv9YYqA3lqfq90Ucc+Xlk7afx8KAW/utz4A==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/html-darwin-x64": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/html-darwin-x64/-/html-darwin-x64-1.16.2.tgz", + "integrity": "sha512-WVBgn6yrBPMZu+DL95/XGAXYcgd1nhd67Ml1UjMtFoFMVKY+VRpCq8JpTZTMXhWbVoRENUHk+3PHu0nNjlE/Fg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/html-linux-arm-gnueabihf": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/html-linux-arm-gnueabihf/-/html-linux-arm-gnueabihf-1.16.2.tgz", + "integrity": "sha512-V9F/Akd2TXrf5nUhdLgdy3FoVFxQbw8pA2AOyqnEOa2Mbm1R7DZJJ0GdShEMcoyMyMDB9r/4pWuWfxNtP4mFHA==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/html-linux-arm64-gnu": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/html-linux-arm64-gnu/-/html-linux-arm64-gnu-1.16.2.tgz", + "integrity": "sha512-jonZVtHc6BesMjC/muUEJGzE1L2kVdgiPVuHc7CL79MrUm0Hjf8LS4Wmtjqe2bLTfRcaMfaYl/60ZcRXHCaYSQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/html-linux-arm64-musl": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/html-linux-arm64-musl/-/html-linux-arm64-musl-1.16.2.tgz", + "integrity": "sha512-dvki9/sgacHk9ouORmnIok5FbpeE9zUE8yqGGhL1kitNJi6/TKzfnMOpRxSxeDk1/ccvJTAdjRGDIGkT45+b3Q==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/html-linux-ppc64-gnu": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/html-linux-ppc64-gnu/-/html-linux-ppc64-gnu-1.16.2.tgz", + "integrity": "sha512-6m0vVWHl9MW7cmWKVgKlFW6yhRv0uahMEaDxNIvXrPC3LdbbiiYZui+ryhyQGIYeVps3OMujzUjc0GihNz/afQ==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/html-linux-s390x-gnu": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/html-linux-s390x-gnu/-/html-linux-s390x-gnu-1.16.2.tgz", + "integrity": "sha512-TOlz6wgKyZjg4THJsNZfDz/rAMO+rBa0s2eewTeHEfuJhI+jGu7H6Co6bdbMpN3oyDvTMG7N1f1ktSbkE0erAg==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/html-linux-x64-gnu": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/html-linux-x64-gnu/-/html-linux-x64-gnu-1.16.2.tgz", + "integrity": "sha512-5EduoVpsnuAAkG9BW8COxcIKAe5swgNAEo+BVkAJCOy1ZMZm0krQYBdvlaDCsGGE9yLDKVPm7rpYIi7vTTZTbA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/html-linux-x64-musl": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/html-linux-x64-musl/-/html-linux-x64-musl-1.16.2.tgz", + "integrity": "sha512-c0Z84dvBd0oh1ZcBHnM18itmvJFLbCZBKFF2lEDHsGBSLQ/1sPbggEKsVO4KgWkkhwQV2l9AB4jnsw1HrwZJCg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/html-win32-arm64-msvc": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/html-win32-arm64-msvc/-/html-win32-arm64-msvc-1.16.2.tgz", + "integrity": "sha512-Aq7V2B5gS23X59DzV2z892c4NBHYtJbwhvsCjJN1MBMx723htjgNE9KVIJp9dQaJBr2PrNfb/u3QFwnWV2tAoQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/html-win32-ia32-msvc": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/html-win32-ia32-msvc/-/html-win32-ia32-msvc-1.16.2.tgz", + "integrity": "sha512-9gslPcsfXxKvAZtOvDkxGuEbM7lqBrONzLAyRsyUtw8KxFcSYkGIO48RDTstGWOkgTgKjjAq/WWqt9qr/NcE3A==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/html-win32-x64-msvc": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/html-win32-x64-msvc/-/html-win32-x64-msvc-1.16.2.tgz", + "integrity": "sha512-Kdb4VdC8FyF5s1MQaFUNeASLckHECrb/oYy/6OCtU+hbgxQ/o/JCgE4uCe8YAg0LCWSOjhx73PCZDGwPf1TpKw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/types": { + "version": "0.1.28", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.28.tgz", + "integrity": "sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==", + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/playwright": { "version": "1.63.0", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", @@ -53,6 +899,41 @@ "engines": { "node": ">=20" } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } } } } diff --git a/package.json b/package.json index 2483bb1..c8d3ec2 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,20 @@ { - "name": "ipx-ui-tests", + "name": "ipx-web", "private": true, - "description": "Browser tests for the ipx web UI. The Rust tests cover the server; these cover the page.", + "description": "Builds the ipx web pages from web/src (web/build.mjs, run by build.rs) and tests them. The Rust tests cover the server.", "scripts": { + "build": "node web/build.mjs", + "typecheck": "tsc -p .", + "smoke": "node tests/page-smoke.js", "test": "playwright test", - "test:headed": "playwright test --headed", - "smoke": "node tests/page-smoke.js" + "test:headed": "playwright test --headed" }, "devDependencies": { - "@playwright/test": "^1.56.0" + "@playwright/test": "^1.56.0", + "typescript": "^7.0.2" + }, + "dependencies": { + "@swc/core": "^1.16.2", + "@swc/html": "^1.16.2" } } diff --git a/src/web.rs b/src/web.rs index bdd5541..1a8b489 100644 --- a/src/web.rs +++ b/src/web.rs @@ -441,7 +441,7 @@ async fn login_page(State(state): State, req: Request) -> Response { if vouched_name(&state.ctx.cfg(), &req).is_some() { return Redirect::to("/").into_response(); } - Html(include_str!("../web/login.html")).into_response() + Html(include_str!(concat!(env!("OUT_DIR"), "/login.html"))).into_response() } /// The 2004 icon, served once for both pages rather than inlined as base64 into each. The @@ -471,8 +471,10 @@ fn constant_time_eq(a: &str, b: &str) -> bool { a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 } -const INDEX: &str = include_str!("../web/index.html"); -const LOG_BUTTON: &str = r#"`).join('')} + + + + + + + +

Loading…

+ Daemon I/O is the control protocol itself — every command in and + every event out. Scans is feed and download activity, HTTP is web requests. + The buffer keeps debug detail even when the terminal does not; IPX_UI_LOG changes + what it captures.`, true); + + $$('#logtabs button').forEach(b=>b.onclick=()=>{ + logTab=b.dataset.t; + $$('#logtabs button').forEach(x=>x.classList.toggle('on',x.dataset.t===logTab)); + drawLog(); + }); + $('#loglevel').onchange=e=>{ logLevel=e.target.value; drawLog(); }; + $('#logq').oninput=e=>{ logFilter=e.target.value.toLowerCase(); drawLog(); }; + $('#logcopy').onclick=()=>copyText(visibleLog().map(l=> + `${new Date(l.ts*1000).toISOString()} ${l.level} ${l.target} ${l.msg}`).join('\n'), $('#logcopy')); + + pollLog(); + logTimer=setInterval(pollLog,2000); +} + +async function pollLog(){ + try{ + const r=await api(`/api/logs?after=${logSeq}&limit=500`); + if(r.lines.length){ + logLines=logLines.concat(r.lines).slice(-2000); + logSeq=r.latest; + drawLog(); + }else if(!logLines.length){ drawLog(); } + }catch(e){ + const box=$('#logbox'); + if(box) box.innerHTML=`

Lost contact with the daemon: ${esc(e.message)}

`; + } +} +function visibleLog(){ + const min=logLevel?LEVELS[logLevel]:-1; + const tab=LOG_TABS[logTab]; + return logLines.filter(l=> + (!tab || tab(l.target)) && + (LEVELS[l.level]??1)>=min && + (!logFilter || (l.msg+' '+l.target).toLowerCase().includes(logFilter))); +} +function drawLog(){ + const box=$('#logbox'); if(!box) return; + const follow=$('#logfollow')?.checked; + const rows=visibleLog(); + box.innerHTML = rows.length ? rows.map(l=>{ + const t=new Date(l.ts*1000).toLocaleTimeString(); + if(logTab==='daemon'){ + const out=l.msg.startsWith('<-'); + return `
`+ + `${out?'out':'in'}`+ + `${esc(l.msg.replace(/^[<-]+\s*/,''))}
`; + } + return `
${esc(l.level)}`+ + `${esc(l.target.replace(/^ipx::?/,''))}${esc(l.msg)}
`; + }).join('') : '

Nothing matches.

'; + if(follow) box.scrollTop=box.scrollHeight; +} +$('#modal').onclick=e=>{ if(e.target.id==='modal') closeModal(); }; + +$('#addFeed').onclick=()=>{ + openModal(`

Add a feed

+
+ A Patreon token on its own adds every show from that creator.
+
+
+ Only items matching a keyword are downloaded.
+ +
+
`); + $('#nurl').focus(); + $('#nsave').onclick=async()=>{ + const url=$('#nurl').value.trim(); if(!url) return; + $('#nsave').disabled=true; $('#nsave').title='Adding…'; + try{ + const r=await api('/api/feeds',{method:'POST',body:JSON.stringify({ + url, folder:$('#nfolder').value.trim()||null, allow_explicit:$('#nexp').checked, + keywords:$('#nkw').value.split(',').map(s=>s.trim()).filter(Boolean)})}); + closeModal(); toast(r.existing?`Already subscribed as ${r.id}`:`Added ${r.id}`); + await loadFeeds(true); selectFeed(r.id); + }catch(e){ toast(e.message,true); $('#nsave').title='Add feed'; $('#nsave').disabled=false; } + }; +}; + +// What everyone here reads, you included, as a place to start. The rows carry an id, never a +// URL, so a key in someone's feed address never reaches this page. +const NONE_LISTED='

Nothing yet. Feeds people here subscribe to show up here.

'; +async function listFeeds(url,box){ + let rows=[]; + try{ rows=await api(url)||[]; }catch{} + box.innerHTML=rows.length?'':NONE_LISTED; + for(const p of rows) box.appendChild(listedFeed(p,'childrow')); + return rows.length; +} + +/// One listed feed: a row in Popular and the Add a feed dialog, a tile in Directory's grid. The +/// parts are the same either way; the class lays them out. +function listedFeed(p,cls){ + const el=document.createElement('div'); + el.className=cls; + el.innerHTML=artHTML(p.image,p.title||p.id)+ + `
${esc(p.title||p.id)}`+ + `${p.subscribers} subscriber${p.subscribers===1?'':'s'}
`+ + // Green, as a downloaded file is: it is already yours. Plus, beside it, is the way to get one. + (p.subscribed?`${ICON.subbed}` + :``); + // Yours already: the row opens it instead. + if(p.subscribed){ el.onclick=()=>{ closeModal(); selectFeed(p.id); }; return el; } + $('[data-a="sub"]',el).onclick=async()=>{ + try{ + await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'}); + closeModal(); toast(`Subscribed to ${p.title||p.id}`); + await loadFeeds(true); selectFeed(p.id); + }catch(e){ toast(e.message,true); } + }; + return el; +} + +// Directory's filters. Kept out here because a finished scan redraws the pane, which would +// otherwise clear them. +let dirKind='All', dirCat=null; +const KINDS={All:()=>true,Podcasts:p=>p.podcast,Blogs:p=>!p.podcast}; +/// Directory: every listed feed as its cover art, under two filters that combine: what a feed is +/// (Podcasts, anything with audio or video, or Blogs, the rest) and what it is about (its iTunes +/// category, as chips). Both filter in place, without asking the server again. +async function renderDirectory(url,box){ + let rows=[]; + try{ rows=await api(url)||[]; }catch{} + if(!rows.length){ box.innerHTML=NONE_LISTED; return 0; } + const bar=$('#dirbar'); + // Only a filter when the server has both kinds. + const both=rows.some(KINDS.Podcasts)&&rows.some(KINDS.Blogs); + const btn=(k,v,on)=>``; + const draw=()=>{ + if(!both) dirKind='All'; + const ofKind=rows.filter(KINDS[dirKind]); + // No empty chips: only the categories among the feeds the kind lets through. + const cats=[...new Set(ofKind.map(p=>p.category).filter(Boolean))].sort(); + if(!cats.includes(dirCat)) dirCat=null; + bar.innerHTML= + (both?`
${Object.keys(KINDS).map(k=>btn('kind',k,k===dirKind)).join('')}
`:'')+ + (cats.length?`
${cats.map(c=>btn('cat',c,c===dirCat)).join('')}
`:''); + // A picked chip lifts on a second press. Everything is redrawn, so the keyboard goes back to + // the button just pressed. + for(const b of $$('button',bar)) b.onclick=()=>{ + const k=b.dataset.kind!=null?'kind':'cat', v=b.dataset[k]; + if(k==='kind') dirKind=v; else dirCat=dirCat===v?null:v; + draw(); $(`[data-${k}="${CSS.escape(v)}"]`,bar)?.focus(); + }; + box.innerHTML=''; + for(const p of ofKind.filter(p=>!dirCat||p.category===dirCat)) box.appendChild(listedFeed(p,'tile')); + }; + draw(); + return rows.length; +} + +/// Directory and Popular open in the main pane, as the original's Directory did. +async function renderListed(v){ + const box=$('#content'); + box.classList.add('plain'); + $('#tbRemove').disabled=true; + syncTools(null); + $('#epSearch').placeholder='Search items…'; + const listening=v===VIEWS[':listening'], grid=v===VIEWS[':directory']; + box.innerHTML=` +
+
${v.icon}
+

${v.title}

+
${v.blurb}${listening?'':' Everyone counts, you included. Private feeds are never listed.'}
+
+ ${grid?'
':''} +

Loading…

`; + $('#count').textContent=v.title; + const n=await (listening?renderListening:grid?renderDirectory:listFeeds)(v.url,$(listening?'#listening':'#popular',box)); + if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${plural(n,listening?'episode':'feed')}`; +} + +/// Currently Listening: episodes you started and have not finished, across every feed you +/// subscribe to. A row resumes the episode in the player bar on click -- a shortcut back to +/// where you left off, not another way to browse. The one in the player pauses instead. +async function renderListening(url,box){ + let rows=[]; + try{ rows=(await api(url)).entries||[]; }catch{} + box.innerHTML=rows.length?'':'

Nothing in progress. Episodes you start and do not finish show up here.

'; + for(const e of rows){ + // Carries its episode, for paintListenRow to repaint as the player moves. + const el: HTMLDivElement & {entry?: any}=document.createElement('div'); + el.className='childrow'; + el.entry=e; + el.innerHTML=artHTML(e.image||feedArt(e.feed_id),e.title||'')+ + `
${EQ}${esc(e.title||'(untitled)')}`+ + `${esc(feedName(e.feed_id))}
`+ + ``+ + ``+ + `
`; + el.onclick=ev=>(ev.target as Element).closest('[data-a=remove]')?forget(e) + :el.classList.contains('now')&&!audio.paused?audio.pause():play(e); + paintListenRow(el); + box.appendChild(el); + } + return rows.length; +} + +/// One row's time left, progress and play button, taken from the player when it is the one in it. +function paintListenRow(el){ + const e=el.entry, now=player.guid===e.guid&&player.feed===e.feed_id; + // Zero until the player has sought to where you left off; the saved position stands till then. + if(now&&audio.currentTime) e.position=Math.floor(audio.currentTime); + // The player's own length first: a feed's can be minutes out. + const d=(now&&isFinite(audio.duration)&&Math.floor(audio.duration))||e.duration; + el.classList.toggle('now',now); + $('.left',el).textContent=d?`${clock(d-e.position)} left`:`${clock(e.position)} in`; + // With no length there is nothing to show, and an empty rail reads as a heavy border. + const rail=$('.rail',el); rail.hidden=!d; + $('i',rail).style.width=`${d?Math.min(100,e.position/d*100):0}%`; + const b=$('[data-a=play]',el), label=now&&!audio.paused?'Pause':'Resume'; + if(b.title!==label){ b.title=label; b.setAttribute('aria-label',label); b.innerHTML=label==='Pause'?ICON.pause:ICON.play; } +} +/// Keeps the list in step with the player. Only a row that is, or was, the one in it changes. +function syncListening(){ + for(const el of $$('#listening .childrow')) + if(el.entry&&(el.classList.contains('now')||player.guid===el.entry.guid)) paintListenRow(el); +} + +/// Takes an episode off Currently Listening by forgetting where you got to: the list is every +/// episode with a saved position short of the end, so the position is what has to go. +async function forget(e){ + // Closed without saving first, or the player's next save would put it straight back. + if(player.guid===e.guid){ player.guid=null; $('#pclose').click(); } + try{ + await api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/position`, + {method:'POST',body:JSON.stringify({secs:0})}); + }catch(err){ toast(err.message,true); } + if(S.feed===':listening') renderListed(VIEWS[':listening']); +} + +// The toolbar acts on whatever is selected: the feed on the left, the item in the table. +$('#tbRemove').onclick=()=>{ const f=S.feeds.find(x=>x.id===S.feed); if(f) removeFeed(f); }; +$('#tbPlay').onclick=()=>{ const e=cur(); if(e) play(e); }; +$('#tbRead').onclick=()=>{ const e=cur(); if(e) epAction('read',e,null); }; +$('#tbFlag').onclick=()=>{ const e=cur(); if(e) epAction('flag',e,null); }; +let searchT; +$('#epSearch').oninput=ev=>{ clearTimeout(searchT); + searchT=setTimeout(()=>{ S.q=ev.target.value; S.offset=0; loadEntries(); },250); }; +// Crossing the phone breakpoint moves the files between their pane and the text. +window.matchMedia?.('(max-width:820px)')?.addEventListener?.('change',()=>{ const e=cur(); if(e) showDetail(e); }); + +let expanded = new Set(JSON.parse(localStorage.getItem('ipx.expanded')||'[]')); +function toggleGroup(id){ + expanded.has(id) ? expanded.delete(id) : expanded.add(id); + try{ localStorage.setItem('ipx.expanded', JSON.stringify([...expanded])); }catch{} + renderFeeds(); +} +let globalMax = 3; +const UNITS = [['m','minutes'],['h','hours'],['d','days'],['w','weeks']]; +const UNIT_MINS = {m:1, h:60, d:1440, w:10080}; + +/// Largest unit that divides evenly, so 120 reads "2 hours" not "120 minutes". +function splitEvery(m){ + if(!m) return {n:1, u:'h'}; + for(const u of ['w','d','h']) if(m % UNIT_MINS[u] === 0) return {n:m/UNIT_MINS[u], u}; + return {n:m, u:'m'}; +} +function unitOptions(sel){ + return UNITS.map(([v,l]) => + ``).join(''); +} +function everyText(m){ + if(!m) return '\u2014'; + const {n,u} = splitEvery(m); + const name = {m:'min', h:'hour', d:'day', w:'week'}[u]; + return n + ' ' + name + (u!=='m' && n!==1 ? 's' : ''); +} +function due(ts){ + const d = ts - Date.now()/1000; + if(d <= 0) return 'due now'; + if(d < 3600) return 'in '+Math.max(1,Math.round(d/60))+'m'; + if(d < 86400) return 'in '+Math.round(d/3600)+'h'; + return 'in '+Math.round(d/86400)+'d'; +} + +/// Global settings. GET /api/settings is open to anyone signed in; only the PATCH, and the +/// Users screen behind it, are the operator's alone (the server refuses both from anyone +/// else). A non-admin gets the same modal minus those two parts, not no settings at all -- +/// Export/Import are theirs regardless, and seeing the schedule and quota explains why a +/// feed is polled when it is. +async function prefsModal(){ + const g = await api('/api/settings'); + const gs = splitEvery(g.every_mins); + const admin = !!(S.me&&S.me.admin); + openModal(`

Settings

+
+ + Auto follows your system's light/dark setting. The same toggle is in + the header, one click at a time; this jumps straight to the one you want.
+
+ ${admin?`
+ + +
+ Applies to every feed that does not set its own. A feed's suggested + interval (its ttl) is still honoured when it asks to be polled less often.` + :`${everyText(g.every_mins)}, for every feed that does not set its + own. Only an admin changes this.`}
+ ${admin?`
+ + Applies to any feed that does not set its own — including every feed + inside an OPML subscription. 0 means unlimited, which will pull a whole back + catalogue the first time a feed is scanned.
+
+ + Anything else is still listed and can be downloaded by hand — blog feeds + put article images in enclosures, and those are not worth keeping. Empty takes everything.
+
+ + Over this, the oldest played items are deleted first. Pinned + items are never touched.
+
+
`:''} +
+ ${esc(g.download_dir)}
+
+
+ + ${ICON.save} Export + +
+ Export saves your subscriptions as OPML for another podcast app. Import + subscribes you to every feed in one.
+ ${admin?`
+
+ Add and remove the people who can sign in, and choose who is an admin.
`:''} +
+ ${admin?``:''}
`); + $('#stheme').onchange=e=>setTheme(e.target.value); + $('#gopml').onclick=opmlModal; + if(!admin) return; + $('#gusers').onclick=usersModal; + $('#gsave').onclick=async()=>{ + try{ + await api('/api/settings',{method:'PATCH',body:JSON.stringify({ + schedule:`every ${Math.max(1,Number($('#gnum').value)||1)}${$('#gunit').value}`, + max_new_per_check:Math.max(0,Number($('#gmax').value)||0), + media_types:$('#gtypes').value.split(',').map(t=>t.trim()).filter(Boolean), + max_total_gb:Number($('#gquota').value)||0, + max_age_days:Number($('#gage').value)||0})}); + closeModal(); toast('Settings saved'); + await loadFeeds(true); if(S.feed){ renderFeed(); loadEntries(); } + }catch(e){ toast(e.message,true); } + }; +} + +// Admin only, and the server enforces that: this screen is just the way in. +async function usersModal(){ + const users = await api('/api/users') || []; + openModal(`

Users

+ ${users.map(u=>`
+
${esc(u.name)} + ${u.created?`Added ${dateOf(u.created)}`:'Added before this was kept'} · ${ + u.last_login?`signed in ${ago(u.last_login)}`:'never signed in'}
+ ${u.password?'':'Proxy'} + +
`).join('')} +
+
+ + +
+ + At least 8 characters. Leave the password empty for someone who signs in + through the proxy. New people start with no feeds.
+
+
`); + const change=async(u,opts)=>{ + try{ + await api(`/api/users/${u.id}`,opts); + // Demoting yourself takes this screen away; reload so the page stops offering it. Only + // on success: reloading after a refusal wiped the toast that said why. + if(u.name===S.me?.name){ location.reload(); return; } + }catch(e){ toast(e.message,true); } + usersModal(); // on a refusal, this puts the checkbox back where the server left it + }; + $$('#modalCard [data-id]').forEach(row=>{ + const u=users.find(x=>String(x.id)===row.dataset.id); + $('[data-a="admin"]',row).onchange=e=> + change(u,{method:'PATCH',body:JSON.stringify({admin:e.target.checked})}); + $('[data-a="rm"]',row).onclick=()=>{ + if(confirm(`Remove ${u.name}? Their subscriptions and read state go with them. Downloaded files stay.`)) + change(u,{method:'DELETE'}); + }; + }); + $('#uadd').onclick=async()=>{ + try{ + await api('/api/users',{method:'POST',body:JSON.stringify({ + name:$('#uname').value, password:$('#upass').value, admin:$('#uadmin').checked})}); + toast('Added'); usersModal(); + }catch(e){ toast(e.message,true); } // keep what was typed + }; +} + +function settingsModal(f, newUrl?: string){ + const isGroup = S.feeds.some(c=>c.group===f.id); + openModal(`

${esc(f.title||f.id)}

+ ${isGroup?`

This is ${isPatreon(f)?'a Patreon creator':'an OPML subscription'}. These + settings apply to it and are inherited by every feed inside it.

`:''} + ${f.managed?`

This feed comes from + ${isPatreon(S.feeds.find(p=>p.id===f.group))?'a Patreon creator':'an OPML subscription'} and follows its settings. Saving anything here gives it its own entry in + config.toml, and it stops following the subscription's settings.

`:''} +

These are your settings for this feed. + Everyone else keeps their own.

+
+ + Comma separated. Empty takes everything.
+
+ + Blank follows the global default (${globalMax}). The rest wait for + the next scan.
+ + +
+
+ + +
+ ${S.me&&S.me.admin + ? `Shared with everyone reading this feed. Editing it keeps every item and download — + handy when an auth token in the URL is rotated. The feed is re-checked from scratch + on the next scan.` + : `The same for everyone reading this feed, so only an admin can change it.`}
+ ${S.me&&S.me.admin?`
+ + Where the files land. There is one copy however many people + subscribe, so this is the same for everyone.
`:''} + ${S.me&&S.me.admin?`
+ + + ${f.feed_category + ? `The feed names its own, ${esc(f.feed_category)}, and the Directory uses that.` + : `The feed names none, so the Directory files it under this. Pick one already listed where it fits.`}
`:''} +
+
`); + $('#scopy').onclick=()=>copyText($('#surl').value,$('#scopy')); + // Offer the categories the Directory already shows, so a blog about games joins Games rather + // than starting a second chip beside it. + if($('#scats')) api('/api/directory').then(rows=>{ $('#scats').innerHTML=[...new Set((rows||[]) + .map(p=>p.category).filter(Boolean))].sort().map(c=>`