feat: continuous reader mode with persisted preference

Add a vertical-scroll continuous mode to the reader alongside the
existing single-page mode. A segmented toggle in the reader top bar
switches between them; in continuous mode a gap selector
(None/Small/Medium/Large → 0/12/32/64px) controls the spacing
between stacked pages. Settings page mirrors the same controls.

Backend: new user_preferences table (one row per user, lazily
inserted, ON DELETE CASCADE) and GET/PATCH /api/v1/auth/me/preferences
gated by the existing CurrentUser extractor. Allowed values are
enforced both by API validation and table-level CHECK constraints.
Eight integration tests cover defaults, persistence, partial
updates, validation errors, auth, per-user isolation, and cascade.

Frontend: a new preferences store mirrors the theme-store pattern
with a localStorage shadow so anonymous browsers get a consistent
experience and logged-in users don't flash defaults while the
server response is in flight. Server values that the frontend
doesn't recognize (forward-compat) are ignored rather than poisoning
the UI; non-401 PATCH errors revert the optimistic local update;
logout clears the shadow so user A's settings don't follow user B
on a shared browser.

In continuous mode native scrolling handles Space/PageDown/arrows;
Home/End remain wired and call scrollIntoView() so jumping to chapter
bounds stays one keystroke. Single-page mode (chevrons, arrow-key
pagination, next-page preload) is unchanged.

Versions bumped 0.13.0 → 0.14.0 in lockstep.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-17 13:15:03 +02:00
parent 567d56bfa1
commit 60cc7712fa
18 changed files with 1287 additions and 6 deletions

View File

@@ -0,0 +1,224 @@
mod common;
use axum::http::StatusCode;
use serde_json::json;
use sqlx::PgPool;
use tower::ServiceExt;
#[sqlx::test(migrations = "./migrations")]
async fn get_returns_defaults_for_new_user(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let resp = h
.app
.oneshot(common::get_with_cookie("/api/v1/auth/me/preferences", &cookie))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = common::body_json(resp).await;
assert_eq!(body["reader_mode"], "single");
assert_eq!(body["reader_page_gap"], "none");
assert!(body.get("updated_at").is_some());
// user_id is server-internal — must not leak.
assert!(body.get("user_id").is_none());
}
#[sqlx::test(migrations = "./migrations")]
async fn patch_persists_mode_and_gap(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let resp = h
.app
.clone()
.oneshot(common::patch_json_with_cookie(
"/api/v1/auth/me/preferences",
json!({ "reader_mode": "continuous", "reader_page_gap": "large" }),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = common::body_json(resp).await;
assert_eq!(body["reader_mode"], "continuous");
assert_eq!(body["reader_page_gap"], "large");
// A fresh GET reads the persisted values.
let resp = h
.app
.oneshot(common::get_with_cookie("/api/v1/auth/me/preferences", &cookie))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = common::body_json(resp).await;
assert_eq!(body["reader_mode"], "continuous");
assert_eq!(body["reader_page_gap"], "large");
}
#[sqlx::test(migrations = "./migrations")]
async fn patch_supports_partial_update(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
// Seed both fields away from their defaults.
let _ = h
.app
.clone()
.oneshot(common::patch_json_with_cookie(
"/api/v1/auth/me/preferences",
json!({ "reader_mode": "continuous", "reader_page_gap": "medium" }),
&cookie,
))
.await
.unwrap();
// PATCH only the mode — the gap must not regress to default.
let resp = h
.app
.clone()
.oneshot(common::patch_json_with_cookie(
"/api/v1/auth/me/preferences",
json!({ "reader_mode": "single" }),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = common::body_json(resp).await;
assert_eq!(body["reader_mode"], "single");
assert_eq!(body["reader_page_gap"], "medium");
}
#[sqlx::test(migrations = "./migrations")]
async fn patch_rejects_invalid_mode(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let resp = h
.app
.oneshot(common::patch_json_with_cookie(
"/api/v1/auth/me/preferences",
json!({ "reader_mode": "tachiyomi" }),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let body = common::body_json(resp).await;
assert_eq!(body["error"]["code"], "invalid_input");
}
#[sqlx::test(migrations = "./migrations")]
async fn patch_rejects_invalid_gap(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let resp = h
.app
.oneshot(common::patch_json_with_cookie(
"/api/v1/auth/me/preferences",
json!({ "reader_page_gap": "huge" }),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let body = common::body_json(resp).await;
assert_eq!(body["error"]["code"], "invalid_input");
}
#[sqlx::test(migrations = "./migrations")]
async fn requires_authentication(pool: PgPool) {
let h = common::harness(pool);
let resp = h
.app
.clone()
.oneshot(common::get("/api/v1/auth/me/preferences"))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let resp = h
.app
.oneshot(common::patch_json(
"/api/v1/auth/me/preferences",
json!({ "reader_mode": "continuous" }),
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[sqlx::test(migrations = "./migrations")]
async fn preferences_are_per_user(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie_a) = common::register_user(&h.app).await;
let (_, cookie_b) = common::register_user(&h.app).await;
// A switches to continuous + large; B is untouched and must keep defaults.
let _ = h
.app
.clone()
.oneshot(common::patch_json_with_cookie(
"/api/v1/auth/me/preferences",
json!({ "reader_mode": "continuous", "reader_page_gap": "large" }),
&cookie_a,
))
.await
.unwrap();
let resp_b = h
.app
.clone()
.oneshot(common::get_with_cookie("/api/v1/auth/me/preferences", &cookie_b))
.await
.unwrap();
let body_b = common::body_json(resp_b).await;
assert_eq!(body_b["reader_mode"], "single");
assert_eq!(body_b["reader_page_gap"], "none");
// A still sees their own update.
let resp_a = h
.app
.oneshot(common::get_with_cookie("/api/v1/auth/me/preferences", &cookie_a))
.await
.unwrap();
let body_a = common::body_json(resp_a).await;
assert_eq!(body_a["reader_mode"], "continuous");
assert_eq!(body_a["reader_page_gap"], "large");
}
/// Direct repo + SQL: a deleted user must cascade-delete their preferences
/// row. Guards against accidental loss of `ON DELETE CASCADE` in future
/// migrations.
#[sqlx::test(migrations = "./migrations")]
async fn preferences_cascade_on_user_delete(pool: PgPool) {
use mangalord::repo;
// Insert a user directly so we don't need the API harness here.
let user_id: uuid::Uuid = sqlx::query_scalar(
"INSERT INTO users (username, password_hash) VALUES ($1, $2) RETURNING id",
)
.bind("cascade-test")
.bind("not-a-real-hash")
.fetch_one(&pool)
.await
.unwrap();
repo::user_preferences::upsert(&pool, user_id, "continuous", "small")
.await
.unwrap();
assert!(repo::user_preferences::find(&pool, user_id)
.await
.unwrap()
.is_some());
sqlx::query("DELETE FROM users WHERE id = $1")
.bind(user_id)
.execute(&pool)
.await
.unwrap();
assert!(repo::user_preferences::find(&pool, user_id)
.await
.unwrap()
.is_none());
}