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:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1033,7 +1033,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "mangalord"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mangalord"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
||||
8
backend/migrations/0008_user_preferences.sql
Normal file
8
backend/migrations/0008_user_preferences.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE user_preferences (
|
||||
user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
reader_mode text NOT NULL DEFAULT 'single'
|
||||
CHECK (reader_mode IN ('single', 'continuous')),
|
||||
reader_page_gap text NOT NULL DEFAULT 'none'
|
||||
CHECK (reader_page_gap IN ('none', 'small', 'medium', 'large')),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -19,7 +19,8 @@ use crate::auth::extractor::{CurrentUser, SESSION_COOKIE_NAME};
|
||||
use crate::auth::password::{hash_password, verify_password};
|
||||
use crate::auth::token::{generate_token, hash_token};
|
||||
use crate::config::AuthConfig;
|
||||
use crate::domain::{ApiToken, User};
|
||||
use crate::domain::user_preferences::{READER_GAPS, READER_MODES};
|
||||
use crate::domain::{ApiToken, User, UserPreferences};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::repo;
|
||||
|
||||
@@ -30,6 +31,10 @@ pub fn routes() -> Router<AppState> {
|
||||
.route("/auth/logout", post(logout))
|
||||
.route("/auth/me", get(me))
|
||||
.route("/auth/me/password", patch(change_password))
|
||||
.route(
|
||||
"/auth/me/preferences",
|
||||
get(get_preferences).patch(update_preferences),
|
||||
)
|
||||
.route("/auth/tokens", post(create_token))
|
||||
.route("/auth/tokens/:id", delete(delete_token))
|
||||
}
|
||||
@@ -56,6 +61,12 @@ pub struct ChangePassword {
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdatePreferences {
|
||||
pub reader_mode: Option<String>,
|
||||
pub reader_page_gap: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreatedTokenResponse {
|
||||
#[serde(flatten)]
|
||||
@@ -161,6 +172,58 @@ async fn change_password(
|
||||
Ok((StatusCode::NO_CONTENT, jar))
|
||||
}
|
||||
|
||||
/// `GET /api/v1/auth/me/preferences` — return the caller's stored reader
|
||||
/// preferences, or the in-memory defaults if they have never customised them.
|
||||
async fn get_preferences(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
) -> AppResult<Json<UserPreferences>> {
|
||||
let prefs = repo::user_preferences::find(&state.db, user.id)
|
||||
.await?
|
||||
.unwrap_or_else(|| UserPreferences::defaults_for(user.id));
|
||||
Ok(Json(prefs))
|
||||
}
|
||||
|
||||
/// `PATCH /api/v1/auth/me/preferences` — partial update. Unspecified fields
|
||||
/// keep their current values. Each field is validated against its allowed-
|
||||
/// value list; invalid values return 400 `invalid_input`.
|
||||
async fn update_preferences(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Json(input): Json<UpdatePreferences>,
|
||||
) -> AppResult<Json<UserPreferences>> {
|
||||
let current = repo::user_preferences::find(&state.db, user.id)
|
||||
.await?
|
||||
.unwrap_or_else(|| UserPreferences::defaults_for(user.id));
|
||||
|
||||
let reader_mode = match input.reader_mode {
|
||||
Some(m) => {
|
||||
if !READER_MODES.contains(&m.as_str()) {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"reader_mode must be one of {READER_MODES:?}"
|
||||
)));
|
||||
}
|
||||
m
|
||||
}
|
||||
None => current.reader_mode,
|
||||
};
|
||||
let reader_page_gap = match input.reader_page_gap {
|
||||
Some(g) => {
|
||||
if !READER_GAPS.contains(&g.as_str()) {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"reader_page_gap must be one of {READER_GAPS:?}"
|
||||
)));
|
||||
}
|
||||
g
|
||||
}
|
||||
None => current.reader_page_gap,
|
||||
};
|
||||
|
||||
let saved =
|
||||
repo::user_preferences::upsert(&state.db, user.id, &reader_mode, &reader_page_gap).await?;
|
||||
Ok(Json(saved))
|
||||
}
|
||||
|
||||
async fn create_token(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod manga;
|
||||
pub mod page;
|
||||
pub mod session;
|
||||
pub mod user;
|
||||
pub mod user_preferences;
|
||||
|
||||
pub use api_token::ApiToken;
|
||||
pub use bookmark::{Bookmark, BookmarkSummary};
|
||||
@@ -13,3 +14,4 @@ pub use manga::Manga;
|
||||
pub use page::Page;
|
||||
pub use session::Session;
|
||||
pub use user::User;
|
||||
pub use user_preferences::UserPreferences;
|
||||
|
||||
30
backend/src/domain/user_preferences.rs
Normal file
30
backend/src/domain/user_preferences.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Serialize;
|
||||
use sqlx::FromRow;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub const READER_MODE_SINGLE: &str = "single";
|
||||
pub const READER_MODE_CONTINUOUS: &str = "continuous";
|
||||
pub const READER_MODES: &[&str] = &[READER_MODE_SINGLE, READER_MODE_CONTINUOUS];
|
||||
|
||||
pub const READER_GAPS: &[&str] = &["none", "small", "medium", "large"];
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow, PartialEq, Eq)]
|
||||
pub struct UserPreferences {
|
||||
#[serde(skip)]
|
||||
pub user_id: Uuid,
|
||||
pub reader_mode: String,
|
||||
pub reader_page_gap: String,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl UserPreferences {
|
||||
pub fn defaults_for(user_id: Uuid) -> Self {
|
||||
Self {
|
||||
user_id,
|
||||
reader_mode: READER_MODE_SINGLE.to_string(),
|
||||
reader_page_gap: "none".to_string(),
|
||||
updated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,3 +5,4 @@ pub mod manga;
|
||||
pub mod page;
|
||||
pub mod session;
|
||||
pub mod user;
|
||||
pub mod user_preferences;
|
||||
|
||||
48
backend/src/repo/user_preferences.rs
Normal file
48
backend/src/repo/user_preferences.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
//! Per-user reader preferences. Rows are lazily inserted on the first
|
||||
//! upsert — a user that has never customised their settings has no row,
|
||||
//! and reads fall back to the in-memory defaults in `UserPreferences::defaults_for`.
|
||||
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::domain::UserPreferences;
|
||||
use crate::error::AppResult;
|
||||
|
||||
pub async fn find(pool: &PgPool, user_id: Uuid) -> AppResult<Option<UserPreferences>> {
|
||||
let row = sqlx::query_as::<_, UserPreferences>(
|
||||
r#"
|
||||
SELECT user_id, reader_mode, reader_page_gap, updated_at
|
||||
FROM user_preferences
|
||||
WHERE user_id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
pub async fn upsert(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
reader_mode: &str,
|
||||
reader_page_gap: &str,
|
||||
) -> AppResult<UserPreferences> {
|
||||
let row = sqlx::query_as::<_, UserPreferences>(
|
||||
r#"
|
||||
INSERT INTO user_preferences (user_id, reader_mode, reader_page_gap)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (user_id) DO UPDATE
|
||||
SET reader_mode = EXCLUDED.reader_mode,
|
||||
reader_page_gap = EXCLUDED.reader_page_gap,
|
||||
updated_at = now()
|
||||
RETURNING user_id, reader_mode, reader_page_gap, updated_at
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(reader_mode)
|
||||
.bind(reader_page_gap)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
224
backend/tests/api_preferences.rs
Normal file
224
backend/tests/api_preferences.rs
Normal 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());
|
||||
}
|
||||
Reference in New Issue
Block a user