Web dashboard step 7: docs, users page, polish

Users page, README and config.example updates, implementation notes, the
rollout runbook, site-layout 404/500 pages, human-readable download sizes,
dark-mode and narrow-screen polish, and a smoke test over every dashboard
route; also removes zdiff3 ancestor markers left by earlier merges.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
This commit is contained in:
2026-09-03 18:19:08 +00:00
co-authored by Claude Fable 5.1
parent 10cbe9ed79
commit eb960f1b57
16 changed files with 599 additions and 50 deletions
+63 -15
View File
@@ -30,6 +30,7 @@ Gemini (or anything OpenAI-compatible) is a config line plus an API key.
- Full design: [`docs/plans/2026-08-15-the-daily-epub.md`](docs/plans/2026-08-15-the-daily-epub.md) - Full design: [`docs/plans/2026-08-15-the-daily-epub.md`](docs/plans/2026-08-15-the-daily-epub.md)
- Implementation decisions: [`docs/plans/2026-08-15-implementation-notes.md`](docs/plans/2026-08-15-implementation-notes.md) - Implementation decisions: [`docs/plans/2026-08-15-implementation-notes.md`](docs/plans/2026-08-15-implementation-notes.md)
- Web dashboard rollout: [`docs/runbooks/web-dashboard-rollout.md`](docs/runbooks/web-dashboard-rollout.md)
--- ---
@@ -114,7 +115,7 @@ sudo install -m0755 target/release/daily-epub /usr/local/bin/
``` ```
daily-epub generate [--date YYYY-MM-DD] [--dry-run] [--out DIR] [--max-articles N] [--skip-llm] [--skip-embeddings] [--rescore] daily-epub generate [--date YYYY-MM-DD] [--dry-run] [--out DIR] [--max-articles N] [--skip-llm] [--skip-embeddings] [--rescore]
daily-epub serve # rating endpoints + OPDS catalog + downloads daily-epub serve # public site, private dashboard, OPDS, ratings, downloads
daily-epub profile rebuild # regenerate learned profile adjustments daily-epub profile rebuild # regenerate learned profile adjustments
daily-epub ratings list [--days 90] [--label loved|good|down|cleared] daily-epub ratings list [--days 90] [--label loved|good|down|cleared]
daily-epub ratings set --article 42 --label loved --note "excellent" daily-epub ratings set --article 42 --label loved --note "excellent"
@@ -134,6 +135,7 @@ daily-epub users disable USER
daily-epub users enable USER daily-epub users enable USER
daily-epub users list daily-epub users list
daily-epub users logout USER # revoke all of USER's sessions daily-epub users logout USER # revoke all of USER's sessions
daily-epub job run NAME # systemd job-unit entry point; normally not run by hand
``` ```
`--dry-run` does everything except deliver: it still ingests, persists entries and `--dry-run` does everything except deliver: it still ingests, persists entries and
@@ -184,19 +186,58 @@ start with `!`. It exits non-zero only on a validation error — a missing key o
file is a warning, since the run degrades rather than fails — and never opens file is a warning, since the run degrades rather than fails — and never opens
the database or takes the lock, so it is safe to run next to a live `generate`. the database or takes the lock, so it is safe to run next to a live `generate`.
## Web site and dashboard
The server is both the public newspaper index and the private operator UI. An
anonymous visitor sees only titles, authors, sources, metadata and outbound
comment links; generated and scraped text stays private. A signed-in `user`
sees complete issues and article chapters and can download artifacts. An
`admin` can additionally rate articles and use every `/dashboard/*` page,
including settings and jobs. Personalization is shared across accounts for now.
Accounts are deliberately managed on the host, not in the browser. Usernames
are case-insensitive and passwords must be 121024 characters. Bootstrap with
`daily-epub users add <username> --admin`; use `users passwd`, `role`,
`disable`/`enable`, `list`, and `logout` for later administration. Password
changes and disabling a user revoke that user's sessions. `/dashboard/users`
is a read-only view of roles, status, login times, and open sessions.
The Jobs page starts only the fixed job catalogue as
`daily-epub-job@<name>.service`; the web server never runs the pipeline inside
its own process. Install `systemd/daily-epub-job@.service` and the narrowly
scoped `systemd/50-daily-epub.rules` polkit rule, and put the server user in
`systemd-journal` so the page can show status and its configured journal tail.
Set `server.jobs_enabled = false` to make starts unavailable.
The Settings page derives its fields from `Config`, rewrites `config.toml` in
place with `toml_edit`, preserves comments/order and file permissions, validates
before an atomic rename, and records attributed history. It re-reads hand edits
on the next view. `DAILY_EPUB_*` overrides appear locked, and secrets are shown
only as present or absent. Shipped providers (`deepseek`, `anthropic`,
`gemini`) cannot be removed because defaults would restore them; leave one
unreferenced or edit it. Custom providers can be removed after no `[llm]` role
references them. The service needs `/etc/daily-epub` in `ReadWritePaths` for
these writes.
### Web routes ### Web routes
| Route | Access | Purpose | | Route | Access | Purpose |
|---|---|---| |---|---|---|
| `GET /` | Public | Latest issue as a source-link-only index. | | `GET /`, `/issues`, `/issues/{date}`, `/feed.xml` | Public | Latest issue, archive, stripped issue index, and equivalent Atom feed. Signed-in issue views expand to the complete issue. |
| `GET /issues` | Public | Issue archive. | | `GET /issues/{date}/articles/{id}`, `/world`, `/behind` | User or admin | Private article, World Briefing, and Behind the paper chapters. |
| `GET /issues/{date}` | Public | One source-link-only issue index. | | `GET /robots.txt`, `/static/{file}` | Public | Crawler policy and embedded CSS, JavaScript, and favicon. |
| `GET /feed.xml` | Public | Atom feed carrying the same public issue content. | | `GET/POST /login`, `POST /logout` | Public/session | Sign in and out; login attempts are throttled per client IP. |
| `GET /robots.txt`, `/static/{file}` | Public | Crawler policy and embedded site assets. | | `GET /account`, `POST /account/password`, `/account/logout-all` | User or admin | Change the current password or revoke sessions. |
| `GET/POST /login`, `POST /logout`, `GET /account` | Session | Sign in, sign out, and account management. | | `POST /rate` | Admin | Append an attributed dashboard rating event. |
| `GET /dashboard` | Admin | Private operator dashboard. | | `GET /dashboard` | Admin | Run, budget, rating, job, and config overview. |
| `GET /files/epub/{name}`, `/files/xtc/{name}` | Session or Basic auth | Published downloads; existing OPDS clients continue to use Basic auth. | | `GET /dashboard/runs[/{id}]`, `/articles[/{id}]`, `/ratings`, `/stats` | Admin | Pipeline history, article explanations, rating contributions/history, and evaluation stats. |
| `GET /opds/daily.xml`, `/healthz`, `/issues.json`, `/r/...` | Existing policy | OPDS, health, reports, and signed rating links. | | `GET/POST /dashboard/profile`, `POST /dashboard/profile/restore` | Admin | Edit `profile.md`, inspect prompts/adjustments, and restore a version. |
| `GET/POST /dashboard/settings`, `POST /dashboard/settings/providers`, `GET /dashboard/settings/history` | Admin | Edit validated configuration and inspect its audit log. |
| `GET /dashboard/jobs`, `GET /dashboard/jobs/{id}`, `POST /dashboard/jobs/{name}` | Admin | Start fixed systemd jobs and inspect status and logs. |
| `GET /dashboard/users` | Admin | Read-only users and open-session list; edits use the CLI. |
| `GET /files/epub/{name}`, `/files/xtc/{name}` | Public if Basic auth is unset; otherwise session or Basic auth | Published downloads. Keeping them public when Basic auth is absent preserves existing OPDS acquisition links. |
| `GET /opds`, `/opds/`, `/opds/daily.xml` | Existing optional Basic auth | OPDS acquisition feed. |
| `GET /r/...`, `/healthz`, `/issues.json` | Existing policy | HMAC rating links, health, and issue reports. |
--- ---
@@ -387,15 +428,21 @@ sudo setfacl -m u:daily-epub:rwx /srv/bookorbit/libraries/daily-epub # or chow
# units # units
sudo install -m0644 systemd/daily-epub.service systemd/daily-epub-generate.service \ sudo install -m0644 systemd/daily-epub.service systemd/daily-epub-generate.service \
systemd/daily-epub-generate.timer /etc/systemd/system/ systemd/daily-epub-generate.timer systemd/daily-epub-job@.service \
/etc/systemd/system/
sudo install -m0644 systemd/50-daily-epub.rules /etc/polkit-1/rules.d/
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo systemctl enable --now daily-epub.service daily-epub-generate.timer sudo systemctl enable --now daily-epub.service daily-epub-generate.timer
``` ```
> **Keep `ReadWritePaths` in sync.** Both units run under `ProtectSystem=strict` > **Keep `ReadWritePaths` in sync.** The units run under `ProtectSystem=strict`
> and list the publish directories explicitly: > and list the publish directories explicitly; the server additionally lists
> `/etc/daily-epub` so Settings can replace `config.toml`:
> ``` > ```
> # generate and job units
> ReadWritePaths=/home/thallada/bookorbit/books/daily-epub /var/lib/daily-epub/xtc > ReadWritePaths=/home/thallada/bookorbit/books/daily-epub /var/lib/daily-epub/xtc
> # server unit
> ReadWritePaths=/home/thallada/bookorbit/books/daily-epub /var/lib/daily-epub/xtc /etc/daily-epub
> ``` > ```
> If you change `publish.epub_dir` or `publish.xtc_dir` in the config, change > If you change `publish.epub_dir` or `publish.xtc_dir` in the config, change
> these lines too and `systemctl daemon-reload`, or publishing fails with > these lines too and `systemctl daemon-reload`, or publishing fails with
@@ -727,5 +774,6 @@ From spec §7, plus what implementation turned up:
`triage:` / `assess:` log lines (`… 3 rejected (2 recovered on gemini)`), the `triage:` / `assess:` log lines (`… 3 rejected (2 recovered on gemini)`), the
` · N rejected` suffix on the `curation:` line, or ` · N rejected` suffix on the `curation:` line, or
`sqlite3 /var/lib/daily-epub/daily-epub.db "select stage, count(*) from article_assessments where kind = 'provider_rejected' group by stage"`. `sqlite3 /var/lib/daily-epub/daily-epub.db "select stage, count(*) from article_assessments where kind = 'provider_rejected' group by stage"`.
- **One reader, one issue per day.** There is no multi-user support and no - **One shared reader profile, one issue per day.** Multiple login accounts and
weekly/retrospective edition (spec §6). `user`/`admin` roles are supported, but they share one personalization model;
there is no per-user issue or weekly/retrospective edition (spec §6).
+9 -8
View File
@@ -208,14 +208,15 @@ format = "xtch" # xtc (1-bit) | xtch (grayscale)
settings = "/etc/daily-epub/xtc-settings.json" settings = "/etc/daily-epub/xtc-settings.json"
[server] [server]
bind = "127.0.0.1:3499" bind = "127.0.0.1:3499" # listen address; keep loopback when trusting proxy IP headers
public_url = "https://daily.hallada.net" public_url = "https://daily.hallada.net" # base URL for EPUB rating links and same-origin checks
session_days = 30 session_days = 30 # sliding lifetime for web login sessions
login_attempts = 10 login_attempts = 10 # login POSTs allowed per client IP in one throttle window
login_window_minutes = 15 login_window_minutes = 15 # length of the login throttle window
jobs_enabled = true jobs_enabled = true # let the dashboard start daily-epub-job@<name>.service
journal_lines = 300 journal_lines = 300 # job-page journal tail; valid range 10..=5000
# hmac_secret via DAILY_EPUB_SERVER__HMAC_SECRET env (32+ random bytes) # hmac_secret via DAILY_EPUB_SERVER__HMAC_SECRET env (32+ random bytes)
# Optional Basic auth for the XTC OPDS feed and file downloads: # Optional Basic auth for /opds/* and /files/*; without it those existing
# routes remain public. A signed-in web user can download without Basic auth.
# basic_auth_user = "daily" # basic_auth_user = "daily"
# basic_auth_pass = "..." # basic_auth_pass = "..."
@@ -221,3 +221,59 @@ implementer needs that are easy to get wrong:
`assessment_reuse_days`, `--rescore` ignores it, and `admit::hygiene` never treats it as a low `assessment_reuse_days`, `--rescore` ignores it, and `admit::hygiene` never treats it as a low
score. Because a recovered article's row carries the editor's model, reuse accepts rows whose score. Because a recovered article's row carries the editor's model, reuse accepts rows whose
`model` is either configured model (`triage::reusable_models`). `model` is either configured model (`triage::reusable_models`).
## Web dashboard (2026-09-03)
Verified on the production host and against the implemented dependency graph on 2026-09-03:
- **Host authorization and units:** polkit 124 supports JavaScript rules. The installed rule must
grant user `daily-epub` only `org.freedesktop.systemd1.manage-units`, verb `start`, for units
matching `^daily-epub-job@[a-z0-9-]+\.service$`. `daily-epub.service` now needs
`SupplementaryGroups=systemd-journal` to read job logs and `/etc/daily-epub` in
`ReadWritePaths` to atomically replace `config.toml`. The job template deliberately omits
`MemoryDenyWriteExecute` because generate jobs can launch Node's JIT; the server retains it.
- **Settings writes:** direct `toml_edit 0.25` performs typed, comment/order-preserving updates.
A candidate file is loaded through `Config::load` before its permissions are copied and it is
renamed over the original. The process therefore needs write access to the containing
directory, not only the file. Every changed dotted key is written to `config_changes`.
- **Authentication stack:** `axum-login` is pinned to git revision
`151c72d7a1b4646830f86b4332e6bd6e34d719a7`, whose graph contains `tower-sessions 0.15`.
The local `SqliteSessionStore` uses this crate's existing sqlx 0.9 pool because the published
tower SQLx store is incompatible. `password-auth 1.0` supplies Argon2id PHC hashes;
`tower_governor 0.8` throttles login POSTs. Its smart IP extractor trusts forwarded headers,
so production must bind to loopback and accept traffic only from the configured reverse proxy.
- **Test environment:** router tests use `tower::ServiceExt::oneshot`, temp SQLite databases and
`MockRunner`; they do not bind or invoke systemd. This sandbox forbids loopback listeners, so
the four `curate::llm::tests::anthropic_*` tests, the three OpenAI tests using the same fake
listener, `extract::tests::relative_urls_resolve_against_the_url_we_landed_on`, the five
listener-based `server::tests::*`, and `tests/m7_server.rs` are filtered only for sandbox runs.
The complete suite is expected to run outside the sandbox.
Implementation-time decisions recorded while landing dashboard steps 17:
- `/files/*` remains public when Basic auth is not configured, preserving existing OPDS
acquisition behavior. With Basic auth configured, either valid Basic credentials or any valid
web session authorizes a download. This intentionally resolves the plan's conflicting request
to redirect unauthenticated downloads in favor of its compatibility acceptance criterion.
- Final reports are attached to an issue after `finish_run`, when publish timing and status are
complete. Issue snapshots omit article bodies and rehydrate them from `articles`; old rows use
the reduced fallback renderer. The CLI password prompt uses `rpassword` after it became
available to the orchestrator.
- Flash handlers extract the exact tower session installed by the auth layer through
`Extension<Session>`. Dashboard `down` forms persist the established `not_for_me` label.
Ratings-page feed credit includes rating decay because that is what `signals::feed_rates`
actually uses; the stored prompt verdict count is inferred from the prompt text because
`TasteProfile.verdicts` is not persisted.
- Dynamic list SQL is assembled only from fixed fragments and allow-listed sort/filter names,
wrapped in sqlx 0.9's `AssertSqlSafe`; all user values remain bound parameters. Funnel bars
count rows that reached each stage because a row stores the stage where it stopped. SVG/meter
attributes replace inline styles under the CSP. Article pages omit the nominal extract method
because database reconstruction currently hard-codes it and would display misleading data.
- Shipped providers cannot be removed: deleting one would cause `Config::default()` to restore
it. They remain editable and may be unreferenced; custom providers are removable. An absent
setting already equal to its submitted default stays absent. Settings that are captured while
building the server, session, or throttle layers carry restart notices.
- A job start reloads a hand-edited config before inserting and starting the unit, retaining the
last-good config if reload fails. The offline lifecycle test uses `features-prune`; generate
run-id linkage is covered separately. CLI duration statistics retain finished dry runs for
byte-identical output, while dashboard run series and overview sparklines exclude them.
@@ -0,0 +1,96 @@
# Step 7 handoff — docs, users page, and polish
## Landed
- Added the admin-only, read-only `/dashboard/users` page with username, role, enabled/disabled
status, created time, last login, and unexpired session count. The page directs edits to the
existing `daily-epub users` CLI.
- Expanded the README with the public/user/admin boundary, user lifecycle commands, complete
route table, systemd+polkit Jobs setup, comment-preserving Settings behavior, environment
locks, shipped-provider removal limitation, public `/files/*` behavior without Basic auth,
and the `X-Forwarded-For`/loopback trust boundary. Updated `[server]` comments in
`config.example.toml` to match.
- Added the dated Web dashboard section to the implementation notes: polkit 124 and its narrow
rule, service-unit changes, the `toml_edit` write path, the pinned auth/session/password/
throttle stack, sandbox test caveats, and the implementation-time decisions from steps 16.
- Added `docs/runbooks/web-dashboard-rollout.md`, with the plan's nine production rollout steps,
commands, permissions, public-files compatibility note, and smoke checks.
- Completed layout polish: Atom discovery and favicon remain in `<head>`, Users remains in the
admin nav, both navs now wrap cleanly, narrow cards/forms/rating controls collapse, dark-mode
form controls use the site colors, focus states are visible, and all wide tables (including
the nested signals table and new users table) sit in `.scroll-x`.
- Unknown routes and template-render failures now use the site-layout 404/500 pages. Issue
download buttons show human-readable B/KB/MB/GB sizes.
- Removed stale diff3 ancestor markers from the merged CSS/JavaScript and restored the missing
table-filter closure; `node --check src/web/static/app.js` passes.
- Added the users guard/content test, file-size tests, site-layout error test, and one
fixture-backed router smoke test that renders every dashboard route template.
## Acceptance criteria walk-through
1. **Public issue and Atom boundary — met.** `PublicIssue` cannot carry generated/body fields;
existing fixture tests assert titles/authors/sources/comment links/metadata are present and
private Brief/summary/why/body/comment/World text is absent. The feed parses as XML and uses
the same public view.
2. **Complete signed-in issue — met.** Existing router tests cover Brief, summaries, why lines,
article bodies/discussion, World and Behind pages, reduced pre-snapshot fallback, and
existence-gated downloads. All three artifact slots share the same loader; sizes are now
human-readable.
3. **Admin-only ratings — met.** Whole-router role guards and rating tests cover anonymous,
user, and admin outcomes, attributed `source = 'dashboard'` events, clear events, and the
unchanged HMAC path.
4. **Article history/explainability — met.** The articles list/detail routes expose the latest
stage plus full run history, reasons, signals, assessments/facets, utility/cluster/admission,
editor why, issue appearances, neighbours, embedding metadata, ratings, and text explain;
allow-listed query tests and fixture rendering pass.
5. **Run history — met.** Run list/detail renders the cumulative funnel and reasons, admission,
preference state, timings, provider costs, warnings, feeds, near misses, config diff, and
candidate table; seeded counts, sorts, filters, links, and sparklines are tested.
6. **Ratings contributions/history — met.** Current and events tabs cover decay, decayed feed
credit, neighbour weight, prompt/rebuild membership, last-run use, append-only edits/clear,
annotations, filters, and superseded history with hand-checked tests.
7. **Settings — met.** The schema/default/help/env/secret tests cover every config leaf. Writer
tests cover typed changes, comments/order/mode preservation, validation before rename,
history, providers, and reload-on-mtime. The README records why shipped providers cannot be
removed.
8. **Profile — met.** Save/restore/version tests cover the atomic editor and parsed preview;
the page shows OPML interests, prompt, learned adjustments/staleness, and the rebuild job.
9. **Jobs — met.** Fixed-name parsing, polkit/unit files, MockRunner starts/status/logs, duplicate
refusal, failure persistence, lifecycle/run linkage, journal tail, reload-before-start, and
overview links are present and tested. Pipeline work remains in the separate job unit.
10. **Session/security stack — met.** The pinned axum-login/tower-sessions store,
password-auth hashes, cookie properties, session invalidation, origin checks, login throttle,
CSP/security headers, safe redirect checks, and secret redaction have focused tests.
11. **Existing routes — met.** HMAC, OPDS, files, health, and reports remain wired; issue reports
are populated. The reviewed compatibility decision keeps files public only when Basic auth
is absent, while configured Basic auth or a valid session still gates them.
12. **Test coverage — met, with sandbox execution caveat.** All new router tests use `oneshot`;
no new test binds or invokes systemd. The filtered full run passed every runnable target.
The orchestrator should run the complete suite outside this listener-restricted sandbox.
## Prior handoff follow-ups
- Step 1's `rpassword` and direct `toml_edit 0.25` follow-ups were already resolved by the
orchestrator; its public-files deviation is now documented.
- Step 2's raw download sizes are resolved here.
- Step 3's Jobs/ratings targets and overview sparklines, Step 4's settings anchors and profile
rebuild action, and Steps 56's config reload plus combined unit changes are all present after
the merged steps and were included in the smoke/audit pass.
- Nothing remains open against §20. Production deployment itself is intentionally left to the
operator following the new runbook. Plan §21's product deferrals remain intentional: per-user
personalization, OPML editing, body FTS, SSE logs, passkeys, public why lines, old snapshot
backfill, and charts beyond the current sparklines.
## Verification
- `cargo fmt`: pass.
- `cargo clippy --all-targets -- -D warnings`: pass.
- `node --check src/web/static/app.js`: pass.
- Focused new router/size/error tests: pass.
- Raw `cargo test`: **426 passed, 13 failed** before Cargo stopped at the library target; every
failure was `PermissionDenied` while binding a loopback listener (the four documented
Anthropic tests, three OpenAI tests using the same helper, the extractor test, and five server
tests).
- `cargo test` with those 13 listener tests plus the two `tests/m7_server.rs` TCP tests filtered:
**460 passed, 0 failed, 15 filtered out** across library, binary, integration, and doc targets.
+137
View File
@@ -0,0 +1,137 @@
# Runbook — web dashboard rollout
**Written:** 2026-09-03 for the production host. Run these commands from the checked-out
`web-dashboard` tree as an operator with `sudo`. The existing curation-v2 migration should
already be complete.
## 1. Build and install
```sh
cargo test
cargo build --release
sudo install -m0755 target/release/daily-epub /usr/local/bin/daily-epub
daily-epub --help
```
Confirm the help includes both `users` and `job`. Keep the previous binary available until the
smoke tests below pass.
## 2. Install the job unit and polkit rule
```sh
sudo install -m0644 systemd/daily-epub-job@.service /etc/systemd/system/
sudo install -m0644 systemd/50-daily-epub.rules /etc/polkit-1/rules.d/
sudo install -m0644 systemd/daily-epub.service /etc/systemd/system/
sudo systemctl daemon-reload
```
The server unit must contain both:
```ini
SupplementaryGroups=systemd-journal
ReadWritePaths=/home/thallada/bookorbit/books/daily-epub /var/lib/daily-epub/xtc /etc/daily-epub
```
Keep the two publish paths aligned with `[publish]`. The polkit rule permits user `daily-epub`
to start only `daily-epub-job@[a-z0-9-]+.service`; it does not grant stop, restart, or arbitrary
unit management.
## 3. Verify writable paths and ownership
Settings saves create a temporary file and rename it into `/etc/daily-epub`, so the directory
and config file must both belong to the service account:
```sh
sudo chown daily-epub:daily-epub /etc/daily-epub /etc/daily-epub/config.toml
sudo chmod 0750 /etc/daily-epub
sudo chmod 0640 /etc/daily-epub/config.toml
sudo install -d -m0750 -o daily-epub -g daily-epub /var/lib/daily-epub/data
sudo test -f /var/lib/daily-epub/data/profile.md
sudo -u daily-epub test -w /var/lib/daily-epub/data/profile.md
```
Set `profile_path = "/var/lib/daily-epub/data/profile.md"`; `StateDirectory=daily-epub` makes
that location writable. Also re-check the configured publish directories as `daily-epub`.
## 4. Review the new server settings
All keys have defaults, so additions are optional unless production needs overrides:
```toml
[server]
bind = "127.0.0.1:3499"
public_url = "https://daily.hallada.net"
session_days = 30
login_attempts = 10
login_window_minutes = 15
jobs_enabled = true
journal_lines = 300
```
Keep the bind address on loopback: the login throttle trusts `X-Forwarded-For`, so only the
reverse proxy should be able to reach the listener. Keep secrets in `/etc/daily-epub/env`, not
TOML. If `server.basic_auth_user` and `server.basic_auth_pass` are omitted, the existing
`/opds/*` and `/files/*` routes remain public; this is intentional so OPDS acquisition links
continue to work. When Basic auth is configured, signed-in users can still download files with
their session.
## 5. Restart, migrate, and verify public pages
```sh
sudo systemctl restart daily-epub.service
sudo systemctl --no-pager --full status daily-epub.service
curl -fsS https://daily.hallada.net/ | grep -F 'The Daily EPUB'
curl -fsS https://daily.hallada.net/feed.xml >/tmp/daily-epub-feed.xml
python3 -c 'import xml.etree.ElementTree as E; E.parse("/tmp/daily-epub-feed.xml")'
```
Migration `0004_web.sql` applies automatically at start. Anonymous issue pages and the Atom
feed must show the source-link index without summaries, article bodies, comments, the Brief, or
World Briefing text.
## 6. Bootstrap the administrator
```sh
sudo -u daily-epub /usr/local/bin/daily-epub \
--config /etc/daily-epub/config.toml users add tyler --admin
sudo -u daily-epub /usr/local/bin/daily-epub \
--config /etc/daily-epub/config.toml users list
```
The first command prompts twice without echo. Log in at `https://daily.hallada.net/login`, open
`/dashboard`, and confirm `/dashboard/users` shows the admin and an open session. A non-admin
account should receive the site's 403 page for every dashboard route.
## 7. Smoke test a job
Start `features-prune` from `/dashboard/jobs`, open its job detail, and watch the status and log
tail. On the host, confirm the same unit:
```sh
systemctl status daily-epub-job@features-prune.service
journalctl -u daily-epub-job@features-prune.service -n 100 --no-pager
```
If the page reports a permission failure, inspect `journalctl -u polkit` and verify the installed
rule and exact unit name. Do not broaden the rule to arbitrary units.
## 8. Smoke test an in-place settings write
In `/dashboard/settings`, change `curation.ranking.utility_protected` by one and save. Confirm
the next view shows the new value, comments and ordering remain in the file, and the attributed
change appears at `/dashboard/settings/history`:
```sh
sudo -u daily-epub grep -n 'utility_protected' /etc/daily-epub/config.toml
```
Change the value back through the page and confirm the second history row. Environment-overridden
fields should be locked, and no API key, HMAC secret, or Basic-auth password should be displayed.
## 9. Choose the history window
Candidate and assessment history is pruned after
`curation.ranking.telemetry_retention_days` (180 by default). Raise it now if the dashboard
should retain a longer article/run history, then reload `/dashboard/settings` and verify the
effective value. This changes future pruning only; it cannot restore rows already deleted.
+41
View File
@@ -1185,4 +1185,45 @@ pub(crate) mod tests {
assert!(body.contains("2 runs · max $0.11"), "{body}"); assert!(body.contains("2 runs · max $0.11"), "{body}");
assert!(!body.contains("style=\""), "no inline styles under the CSP"); assert!(!body.contains("style=\""), "no inline styles under the CSP");
} }
#[tokio::test]
async fn every_dashboard_route_template_renders_with_fixture_data() {
let seed = seed().await;
sqlx::query(
"INSERT INTO jobs
(id, name, unit, requested_at, started_at, finished_at, status, message, run_id)
VALUES (99, 'features-prune', 'daily-epub-job@features-prune.service',
'2026-09-02T06:00:00Z', '2026-09-02T06:00:01Z',
'2026-09-02T06:00:02Z', 'ok', 'pruned fixture rows', ?)",
)
.bind(seed.run_id)
.execute(seed.db.pool())
.await
.unwrap();
let app = app_with_users(&seed.db).await;
let admin = login_cookie(&app, "admin", "correct horse battery").await;
let routes = vec![
"/dashboard".to_string(),
"/dashboard/runs".to_string(),
format!("/dashboard/runs/{}", seed.run_id),
"/dashboard/articles".to_string(),
"/dashboard/articles/1".to_string(),
"/dashboard/ratings".to_string(),
"/dashboard/ratings?tab=events".to_string(),
"/dashboard/profile".to_string(),
"/dashboard/stats?days=14".to_string(),
"/dashboard/settings".to_string(),
"/dashboard/settings/history".to_string(),
"/dashboard/jobs".to_string(),
"/dashboard/jobs/99".to_string(),
"/dashboard/users".to_string(),
];
for uri in routes {
let response = get(&app, &uri, Some(&admin)).await;
assert_eq!(response.status(), StatusCode::OK, "{uri}");
let body = response_text(response).await;
assert!(body.contains("<!doctype html>"), "{uri}: {body}");
assert!(body.contains("The Daily EPUB"), "{uri}: {body}");
}
}
} }
+72 -2
View File
@@ -1,10 +1,80 @@
//! Dashboard: users pages. Filled in by web dashboard plan step 7. //! Dashboard: the read-only Users page (`/dashboard/users`, plan §6.1).
use askama::Template;
use axum::Router; use axum::Router;
use axum::extract::{Extension, State};
use axum::routing::get;
use axum_login::tower_sessions::Session;
use crate::server::AppState; use crate::server::AppState;
use crate::web::session::{AuthSession, Viewer};
use crate::web::{Html, Page, WebError, format_time, take_flash};
#[derive(Debug)]
struct UserLine {
username: String,
role: String,
disabled: bool,
created: String,
last_login: String,
open_sessions: i64,
}
#[derive(Template)]
#[template(path = "dashboard/users.html")]
struct UsersTemplate {
page: Page,
users: Vec<UserLine>,
}
/// Routes contributed by this page group (merged by `dashboard::router`). /// Routes contributed by this page group (merged by `dashboard::router`).
pub fn routes() -> Router<AppState> { pub fn routes() -> Router<AppState> {
Router::new() Router::new().route("/dashboard/users", get(index))
}
async fn index(
State(state): State<AppState>,
auth: AuthSession,
Extension(session): Extension<Session>,
) -> Result<Html<UsersTemplate>, WebError> {
let viewer = auth.user().await.map(Viewer::from);
let config = state.config();
let users = crate::web::users::list(&state.db)
.await
.map_err(WebError::Internal)?
.into_iter()
.map(|row| UserLine {
username: row.user.username,
role: row.user.role.to_string(),
disabled: row.user.disabled,
created: format_time(row.user.created_at, &config),
last_login: row
.user
.last_login_at
.map(|at| format_time(at, &config))
.unwrap_or_else(|| "never".into()),
open_sessions: row.open_sessions,
})
.collect();
let mut page = Page::new("Users", viewer, "dashboard");
page.flash = take_flash(&session).await?;
Ok(Html(UsersTemplate { page, users }))
}
#[cfg(test)]
mod tests {
use crate::web::dashboard::tests::{app_with_users, assert_admin_only, seed};
#[tokio::test]
async fn users_page_is_admin_only_and_lists_accounts_and_sessions() {
let seed = seed().await;
let app = app_with_users(&seed.db).await;
let body = assert_admin_only(&app, "/dashboard/users").await;
assert!(body.contains("<h1>Users</h1>"), "{body}");
assert!(body.contains("reader"), "{body}");
assert!(body.contains("admin"), "{body}");
assert!(body.contains("Open sessions"), "{body}");
assert!(body.contains("daily-epub users"), "{body}");
}
} }
+28 -1
View File
@@ -25,6 +25,7 @@ pub struct Download {
pub label: String, pub label: String,
pub href: String, pub href: String,
pub size_bytes: u64, pub size_bytes: u64,
pub size: String,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -192,9 +193,26 @@ fn download(
label: label.to_string(), label: label.to_string(),
href: format!("/files/{kind}/{}", crate::web::encode_component(name)), href: format!("/files/{kind}/{}", crate::web::encode_component(name)),
size_bytes: metadata.len(), size_bytes: metadata.len(),
size: format_file_size(metadata.len()),
}) })
} }
fn format_file_size(bytes: u64) -> String {
const KB: f64 = 1024.0;
const MB: f64 = KB * 1024.0;
const GB: f64 = MB * 1024.0;
let bytes_float = bytes as f64;
if bytes_float >= GB {
format!("{:.1} GB", bytes_float / GB)
} else if bytes_float >= MB {
format!("{:.1} MB", bytes_float / MB)
} else if bytes_float >= KB {
format!("{:.1} KB", bytes_float / KB)
} else {
format!("{bytes} B")
}
}
#[derive(Debug)] #[derive(Debug)]
struct FullEntry { struct FullEntry {
title: String, title: String,
@@ -1072,7 +1090,7 @@ mod tests {
Edition::Standard, Edition::Standard,
"epub", "epub",
)); ));
std::fs::write(&standard, b"epub").unwrap(); std::fs::write(&standard, vec![0; 2 * 1024]).unwrap();
let mut config = crate::config::Config::default(); let mut config = crate::config::Config::default();
config.publish.epub_dir = epub_dir; config.publish.epub_dir = epub_dir;
let app = crate::server::router(crate::server::AppState::new(db, config, None)); let app = crate::server::router(crate::server::AppState::new(db, config, None));
@@ -1089,10 +1107,19 @@ mod tests {
.unwrap(); .unwrap();
let issue = response_text(issue).await; let issue = response_text(issue).await;
assert!(issue.contains("Download EPUB")); assert!(issue.contains("Download EPUB"));
assert!(issue.contains("2.0 KB"));
assert!(!issue.contains("2048 bytes"));
assert!(!issue.contains("Download X4 EPUB")); assert!(!issue.contains("Download X4 EPUB"));
assert!(!issue.contains("Download XTC")); assert!(!issue.contains("Download XTC"));
} }
#[test]
fn file_sizes_are_human_readable() {
assert_eq!(format_file_size(42), "42 B");
assert_eq!(format_file_size(1536), "1.5 KB");
assert_eq!(format_file_size(5 * 1024 * 1024), "5.0 MB");
}
#[tokio::test] #[tokio::test]
async fn rating_post_supports_json_forms_attribution_fallback_and_clear() { async fn rating_post_supports_json_forms_attribution_fallback_and_clear() {
let (_dir, db, source) = seeded_issue(true).await; let (_dir, db, source) = seeded_issue(true).await;
+54 -14
View File
@@ -244,7 +244,11 @@ impl<T: Template> IntoResponse for Html<T> {
.into_response(), .into_response(),
Err(error) => { Err(error) => {
tracing::error!(%error, "rendering web template failed"); tracing::error!(%error, "rendering web template failed");
(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response() error_page_response(
StatusCode::INTERNAL_SERVER_ERROR,
"Server error",
"The request could not be completed.",
)
} }
} }
} }
@@ -276,6 +280,22 @@ struct ErrorTemplate {
message: String, message: String,
} }
fn error_page_response(status: StatusCode, heading: &str, message: &str) -> Response {
let rendered = ErrorTemplate {
page: Page::new(heading, None, ""),
heading: heading.to_string(),
message: message.to_string(),
}
.render()
.unwrap_or_else(|_| message.to_string());
(
status,
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
rendered,
)
.into_response()
}
impl IntoResponse for WebError { impl IntoResponse for WebError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
if let Self::Unauthenticated { next } = self { if let Self::Unauthenticated { next } = self {
@@ -317,19 +337,7 @@ impl IntoResponse for WebError {
} }
Self::Unauthenticated { .. } => unreachable!(), Self::Unauthenticated { .. } => unreachable!(),
}; };
let rendered = ErrorTemplate { error_page_response(status, heading, message)
page: Page::new(heading, None, ""),
heading: heading.to_string(),
message: message.to_string(),
}
.render()
.unwrap_or_else(|_| message.to_string());
(
status,
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
rendered,
)
.into_response()
} }
} }
@@ -475,6 +483,7 @@ pub fn router(config: &crate::config::Config) -> axum::Router<crate::server::App
.merge(account) .merge(account)
.merge(full_issues) .merge(full_issues)
.merge(dashboard) .merge(dashboard)
.fallback(|| async { WebError::NotFound })
} }
async fn map_forbidden(request: Request, next: Next) -> Response { async fn map_forbidden(request: Request, next: Next) -> Response {
@@ -1000,4 +1009,35 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(cached.status(), StatusCode::NOT_MODIFIED); assert_eq!(cached.status(), StatusCode::NOT_MODIFIED);
} }
#[tokio::test]
async fn not_found_and_server_error_pages_use_the_site_layout() {
let (_dir, state) = test_state(Config::default()).await;
let app = router(state);
let missing = app
.oneshot(
Request::builder()
.uri("/no-such-page")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
let missing = response_text(missing).await;
assert!(missing.contains("<!doctype html>"), "{missing}");
assert!(missing.contains("The Daily EPUB"), "{missing}");
assert!(missing.contains("That page does not exist"), "{missing}");
let failed = WebError::Internal(anyhow::anyhow!("fixture failure")).into_response();
assert_eq!(failed.status(), StatusCode::INTERNAL_SERVER_ERROR);
let failed = response_text(failed).await;
assert!(failed.contains("<!doctype html>"), "{failed}");
assert!(failed.contains("The Daily EPUB"), "{failed}");
assert!(
failed.contains("The request could not be completed"),
"{failed}"
);
assert!(!failed.contains("fixture failure"), "{failed}");
}
} }
+21 -3
View File
@@ -39,7 +39,7 @@ label { display:grid; gap:.25rem; }
input,textarea,button { font:inherit; padding:.45rem; } input,textarea,button { font:inherit; padding:.45rem; }
.error { color:var(--down); } .error { color:var(--down); }
.dashboard { max-width:1200px; margin:2rem auto; font-family:system-ui,sans-serif; } .dashboard { max-width:1200px; margin:2rem auto; font-family:system-ui,sans-serif; }
.scroll-x { overflow-x:auto; } .scroll-x { max-width:100%; overflow-x:auto; overscroll-behavior-inline:contain; }
table { width:100%; border-collapse:collapse; font:0.9rem/1.35 system-ui,sans-serif; } table { width:100%; border-collapse:collapse; font:0.9rem/1.35 system-ui,sans-serif; }
th,td { border-bottom:1px solid var(--rule); padding:.35rem .5rem; text-align:left; } th,td { border-bottom:1px solid var(--rule); padding:.35rem .5rem; text-align:left; }
thead { position:sticky; top:0; background:var(--bg); } thead { position:sticky; top:0; background:var(--bg); }
@@ -106,7 +106,6 @@ details.explain { margin:1rem 0; }
.picks li { border-bottom:1px solid var(--rule); padding:.5rem 0; } .picks li { border-bottom:1px solid var(--rule); padding:.5rem 0; }
.budget meter { width:60%; max-width:14rem; height:.8rem; vertical-align:middle; margin-right:.5rem; } .budget meter { width:60%; max-width:14rem; height:.8rem; vertical-align:middle; margin-right:.5rem; }
.table-filter { margin:.5rem 0; padding:.3rem; width:20rem; max-width:100%; } .table-filter { margin:.5rem 0; padding:.3rem; width:20rem; max-width:100%; }
||||||| 849231e
/* step 5: settings */ /* step 5: settings */
.settings .card { border:1px solid var(--rule); padding:.75rem 1rem; margin:1rem 0; } .settings .card { border:1px solid var(--rule); padding:.75rem 1rem; margin:1rem 0; }
.settings .card h2 { margin:.2rem 0 .6rem; font-size:1.1rem; font-family:ui-monospace,monospace; } .settings .card h2 { margin:.2rem 0 .6rem; font-size:1.1rem; font-family:ui-monospace,monospace; }
@@ -124,7 +123,6 @@ details.explain { margin:1rem 0; }
.settings .add-provider { max-width:36rem; } .settings .add-provider { max-width:36rem; }
.history pre { margin:0; white-space:pre-wrap; font-size:.8rem; } .history pre { margin:0; white-space:pre-wrap; font-size:.8rem; }
@media (max-width:40rem) { .setting { display:block; } } @media (max-width:40rem) { .setting { display:block; } }
||||||| d36f203
/* step 6: jobs and stats */ /* step 6: jobs and stats */
.sparklines { display:grid; grid-template-columns:repeat(auto-fit,minmax(16rem,1fr)); gap:1rem 1.5rem; margin:1rem 0; } .sparklines { display:grid; grid-template-columns:repeat(auto-fit,minmax(16rem,1fr)); gap:1rem 1.5rem; margin:1rem 0; }
.spark-figure { margin:0; min-width:0; } .spark-figure figcaption { font-size:.9rem; margin-bottom:.2rem; } .spark-figure { margin:0; min-width:0; } .spark-figure figcaption { font-size:.9rem; margin-bottom:.2rem; }
@@ -137,3 +135,23 @@ details.explain { margin:1rem 0; }
.job-cards form.inline { margin:.4rem 0 0; } .job-cards label { display:inline-grid; } .job-cards form.inline { margin:.4rem 0 0; } .job-cards label { display:inline-grid; }
td.message,dd.message { overflow-wrap:anywhere; max-width:32rem; } td.message,dd.message { overflow-wrap:anywhere; max-width:32rem; }
pre.journal { max-height:40rem; } pre.journal { max-height:40rem; }
/* step 7: dark-mode and narrow-screen polish */
.primary,.admin { display:flex; flex-wrap:wrap; justify-content:center; gap:.35rem 1rem; }
.masthead { line-height:1.15; overflow-wrap:anywhere; }
.scroll-x > table { min-width:max-content; }
input,textarea,select,button { max-width:100%; border-color:var(--rule); background:var(--bg); color:var(--fg); }
button { border-style:solid; }
input:focus-visible,textarea:focus-visible,select:focus-visible,button:focus-visible,a:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
code,pre,.message { overflow-wrap:anywhere; }
.prev-next a { overflow-wrap:anywhere; }
@media (max-width:40rem) {
body { padding-inline:.75rem; }
.masthead { margin-top:.75rem; padding-inline:.25rem; }
.primary,.admin { gap:.25rem .75rem; line-height:1.35; }
.dashboard,.reading { margin-block:1.25rem; }
.cards { grid-template-columns:minmax(0,1fr); }
.card { padding:.65rem .75rem; }
.prev-next { display:flex; flex-wrap:wrap; justify-content:space-between; }
.rating { align-items:stretch; }
.rating-prompt,.rating-note { flex-basis:100%; }
}
+1 -1
View File
@@ -56,7 +56,7 @@ document.querySelectorAll("table[data-filter]").forEach((table) => {
row.hidden = needle !== "" && !row.textContent.toLowerCase().includes(needle); row.hidden = needle !== "" && !row.textContent.toLowerCase().includes(needle);
}); });
}); });
||||||| 849231e });
/* step 5: settings — "reset to default" fills the field with its default */ /* step 5: settings — "reset to default" fills the field with its default */
document.addEventListener("click", (event) => { document.addEventListener("click", (event) => {
const button = event.target.closest("button[data-reset]"); const button = event.target.closest("button[data-reset]");
+2 -2
View File
@@ -1,6 +1,6 @@
{% if signals.empty %}<p class="muted">No signals recorded for this row (hygiene exclusion or thin telemetry).</p>{% else %}<div class="signals-body"> {% if signals.empty %}<p class="muted">No signals recorded for this row (hygiene exclusion or thin telemetry).</p>{% else %}<div class="signals-body">
<table class="signals"><thead><tr><th>signal</th><th class="num">raw</th><th class="num">norm</th><th class="num">weight</th><th>present</th></tr></thead> <div class="scroll-x"><table class="signals"><thead><tr><th>signal</th><th class="num">raw</th><th class="num">norm</th><th class="num">weight</th><th>present</th></tr></thead>
<tbody>{% for line in signals.lines %}<tr{% if !line.present %} class="muted"{% endif %}><td>{{ line.name }}</td><td class="num">{{ line.raw }}</td><td class="num">{{ line.norm }}</td><td class="num">{{ line.weight }}</td><td>{% if line.present %}yes{% else %}absent{% endif %}</td></tr>{% endfor %}</tbody></table> <tbody>{% for line in signals.lines %}<tr{% if !line.present %} class="muted"{% endif %}><td>{{ line.name }}</td><td class="num">{{ line.raw }}</td><td class="num">{{ line.norm }}</td><td class="num">{{ line.weight }}</td><td>{% if line.present %}yes{% else %}absent{% endif %}</td></tr>{% endfor %}</tbody></table></div>
<p class="muted">Preliminary blend {{ signals.blend }}{% if let Some(cos) = signals.top1_cos %} · interest top-1 cosine {{ cos }}{% endif %}{% if signals.exploration %} · <span class="badge">exploration</span>{% endif %}{% if signals.auto_include %} · <span class="badge">auto-include</span>{% endif %}</p> <p class="muted">Preliminary blend {{ signals.blend }}{% if let Some(cos) = signals.top1_cos %} · interest top-1 cosine {{ cos }}{% endif %}{% if signals.exploration %} · <span class="badge">exploration</span>{% endif %}{% if signals.auto_include %} · <span class="badge">auto-include</span>{% endif %}</p>
{% if !signals.top_interests.is_empty() %}<p><strong>Top interests</strong></p><ul>{% for interest in signals.top_interests %}<li>{{ interest.name }} <span class="muted">· z {{ interest.z }} · cos {{ interest.cos }}</span></li>{% endfor %}</ul>{% endif %} {% if !signals.top_interests.is_empty() %}<p><strong>Top interests</strong></p><ul>{% for interest in signals.top_interests %}<li>{{ interest.name }} <span class="muted">· z {{ interest.z }} · cos {{ interest.cos }}</span></li>{% endfor %}</ul>{% endif %}
{% if !signals.neighbours.is_empty() %}<p><strong>Nearest rated neighbours</strong></p><ul>{% for neighbour in signals.neighbours %}<li><span class="badge {{ neighbour.label }}">{{ neighbour.label }}</span> <a href="/dashboard/articles/{{ neighbour.article_id }}">{{ neighbour.title }}</a> <span class="muted">· cos {{ neighbour.cos }}</span></li>{% endfor %}</ul>{% endif %} {% if !signals.neighbours.is_empty() %}<p><strong>Nearest rated neighbours</strong></p><ul>{% for neighbour in signals.neighbours %}<li><span class="badge {{ neighbour.label }}">{{ neighbour.label }}</span> <a href="/dashboard/articles/{{ neighbour.article_id }}">{{ neighbour.title }}</a> <span class="muted">· cos {{ neighbour.cos }}</span></li>{% endfor %}</ul>{% endif %}
+15
View File
@@ -0,0 +1,15 @@
{% extends "layout.html" %}{% block content %}<section class="dashboard">
<h1>Users</h1>
<p class="muted">Accounts are read-only here. Create, change, disable, enable, or sign out users with the <code>daily-epub users</code> CLI on the server.</p>
<div class="scroll-x"><table data-filter>
<thead><tr><th>Username</th><th>Role</th><th>Status</th><th>Created</th><th>Last login</th><th class="num">Open sessions</th></tr></thead>
<tbody>{% for user in users %}<tr>
<td>{{ user.username }}</td>
<td><span class="badge">{{ user.role }}</span></td>
<td>{% if user.disabled %}<span class="badge down">disabled</span>{% else %}<span class="badge loved">enabled</span>{% endif %}</td>
<td>{{ user.created }}</td>
<td>{{ user.last_login }}</td>
<td class="num">{{ user.open_sessions }}</td>
</tr>{% endfor %}{% if users.is_empty() %}<tr><td colspan="6">No users. Bootstrap an admin with <code>daily-epub users add &lt;username&gt; --admin</code>.</td></tr>{% endif %}</tbody>
</table></div>
</section>{% endblock %}
+1 -1
View File
@@ -4,7 +4,7 @@
<hr class="rule"> <hr class="rule">
<h1 class="kicker">The Brief</h1> <h1 class="kicker">The Brief</h1>
<div class="editorial">{{ front_page_html|safe }}</div> <div class="editorial">{{ front_page_html|safe }}</div>
{% if !downloads.is_empty() %}<p class="downloads">{% for download in downloads %}<a class="button" href="{{ download.href }}">Download {{ download.label }} <small>({{ download.size_bytes }} bytes)</small></a>{% endfor %}</p>{% endif %} {% if !downloads.is_empty() %}<p class="downloads">{% for download in downloads %}<a class="button" href="{{ download.href }}">Download {{ download.label }} <small>({{ download.size }})</small></a>{% endfor %}</p>{% endif %}
<hr class="rule"> <hr class="rule">
<h1>In This Issue</h1> <h1>In This Issue</h1>
{% for section in sections %}<section><h2>{{ section.name }}</h2><ul class="index-list">{% for entry in section.entries %}<li class="index-entry"> {% for section in sections %}<section><h2>{{ section.name }}</h2><ul class="index-list">{% for entry in section.entries %}<li class="index-entry">
+1 -1
View File
@@ -2,6 +2,6 @@
{% if empty %}<h1>No issue yet</h1><p>The first issue has not been published.</p>{% else %} {% if empty %}<h1>No issue yet</h1><p>The first issue has not been published.</p>{% else %}
<p class="dateline">{{ issue.display_date }} · No. {{ issue.issue_number }}</p><p class="stats">{{ issue.stats_line }}</p> <p class="dateline">{{ issue.display_date }} · No. {{ issue.issue_number }}</p><p class="stats">{{ issue.stats_line }}</p>
<p class="strap">A personal morning paper, assembled daily; the selection is the reader's, the words are the authors'.</p> <p class="strap">A personal morning paper, assembled daily; the selection is the reader's, the words are the authors'.</p>
{% if !downloads.is_empty() %}<p class="downloads">{% for download in downloads %}<a href="{{ download.href }}">{{ download.label }} ({{ download.size_bytes }} bytes)</a>{% endfor %}</p>{% endif %} {% if !downloads.is_empty() %}<p class="downloads">{% for download in downloads %}<a href="{{ download.href }}">{{ download.label }} ({{ download.size }})</a>{% endfor %}</p>{% endif %}
{% for section in issue.sections %}<section><h2>{{ section.name }}</h2>{% for entry in section.entries %}<article{% if entry.is_lead %} class="lead"{% endif %}><h3><a href="{{ entry.url }}">{{ entry.title }}</a></h3><p class="byline">{% match entry.author %}{% when Some with (author) %}{{ author }} · {% when None %}{% endmatch %}{{ entry.source }} ({{ entry.domain }}) · {{ entry.reading_minutes }} min · {{ entry.word_count }} words</p>{% if !entry.comment_links.is_empty() %}<p class="comments">{% for link in entry.comment_links %}<a rel="noopener" target="_blank" href="{{ link.url }}">{{ link.label }}{% if !link.meta.is_empty() %}: {{ link.meta }}{% endif %}</a>{% endfor %}</p>{% endif %}</article>{% endfor %}</section>{% endfor %} {% for section in issue.sections %}<section><h2>{{ section.name }}</h2>{% for entry in section.entries %}<article{% if entry.is_lead %} class="lead"{% endif %}><h3><a href="{{ entry.url }}">{{ entry.title }}</a></h3><p class="byline">{% match entry.author %}{% when Some with (author) %}{{ author }} · {% when None %}{% endmatch %}{{ entry.source }} ({{ entry.domain }}) · {{ entry.reading_minutes }} min · {{ entry.word_count }} words</p>{% if !entry.comment_links.is_empty() %}<p class="comments">{% for link in entry.comment_links %}<a rel="noopener" target="_blank" href="{{ link.url }}">{{ link.label }}{% if !link.meta.is_empty() %}: {{ link.meta }}{% endif %}</a>{% endfor %}</p>{% endif %}</article>{% endfor %}</section>{% endfor %}
<p><a href="/issues">Browse the archive</a></p>{% endif %}</article>{% endblock %} <p><a href="/issues">Browse the archive</a></p>{% endif %}</article>{% endblock %}
+2 -2
View File
@@ -10,8 +10,8 @@
</head> </head>
<body> <body>
<header class="masthead"><a href="/">The Daily EPUB</a></header> <header class="masthead"><a href="/">The Daily EPUB</a></header>
<nav class="primary"><a href="/">Latest</a> · <a href="/issues">Archive</a> · <a href="/feed.xml">Feed</a> · {% match page.viewer %}{% when Some with (viewer) %}<a href="/account">{{ viewer.username }}</a>{% when None %}<a href="/login">Sign in</a>{% endmatch %}</nav> <nav class="primary" aria-label="Site"><a href="/">Latest</a><a href="/issues">Archive</a><a href="/feed.xml">Feed</a>{% match page.viewer %}{% when Some with (viewer) %}<a href="/account">{{ viewer.username }}</a>{% when None %}<a href="/login">Sign in</a>{% endmatch %}</nav>
{% if page.is_admin() %}<nav class="admin"><a href="/dashboard">Overview</a> · <a href="/dashboard/runs">Runs</a> · <a href="/dashboard/articles">Articles</a> · <a href="/dashboard/ratings">Ratings</a> · <a href="/dashboard/profile">Profile</a> · <a href="/dashboard/stats">Stats</a> · <a href="/dashboard/jobs">Jobs</a> · <a href="/dashboard/settings">Settings</a> · <a href="/dashboard/users">Users</a></nav>{% endif %} {% if page.is_admin() %}<nav class="admin" aria-label="Dashboard"><a href="/dashboard">Overview</a><a href="/dashboard/runs">Runs</a><a href="/dashboard/articles">Articles</a><a href="/dashboard/ratings">Ratings</a><a href="/dashboard/profile">Profile</a><a href="/dashboard/stats">Stats</a><a href="/dashboard/jobs">Jobs</a><a href="/dashboard/settings">Settings</a><a href="/dashboard/users">Users</a></nav>{% endif %}
{% match page.flash %}{% when Some with (flash) %}<div class="flash {{ flash.kind }}" role="status">{{ flash.text }}</div>{% when None %}{% endmatch %} {% match page.flash %}{% when Some with (flash) %}<div class="flash {{ flash.kind }}" role="status">{{ flash.text }}</div>{% when None %}{% endmatch %}
<main>{% block content %}{% endblock %}</main> <main>{% block content %}{% endblock %}</main>
<footer>daily-epub {{ page.version }}</footer> <footer>daily-epub {{ page.version }}</footer>