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

@@ -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,