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]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.13.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.13.0"
|
version = "0.14.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[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::password::{hash_password, verify_password};
|
||||||
use crate::auth::token::{generate_token, hash_token};
|
use crate::auth::token::{generate_token, hash_token};
|
||||||
use crate::config::AuthConfig;
|
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::error::{AppError, AppResult};
|
||||||
use crate::repo;
|
use crate::repo;
|
||||||
|
|
||||||
@@ -30,6 +31,10 @@ pub fn routes() -> Router<AppState> {
|
|||||||
.route("/auth/logout", post(logout))
|
.route("/auth/logout", post(logout))
|
||||||
.route("/auth/me", get(me))
|
.route("/auth/me", get(me))
|
||||||
.route("/auth/me/password", patch(change_password))
|
.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", post(create_token))
|
||||||
.route("/auth/tokens/:id", delete(delete_token))
|
.route("/auth/tokens/:id", delete(delete_token))
|
||||||
}
|
}
|
||||||
@@ -56,6 +61,12 @@ pub struct ChangePassword {
|
|||||||
pub new_password: String,
|
pub new_password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UpdatePreferences {
|
||||||
|
pub reader_mode: Option<String>,
|
||||||
|
pub reader_page_gap: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct CreatedTokenResponse {
|
pub struct CreatedTokenResponse {
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
@@ -161,6 +172,58 @@ async fn change_password(
|
|||||||
Ok((StatusCode::NO_CONTENT, jar))
|
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(
|
async fn create_token(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
CurrentUser(user): CurrentUser,
|
CurrentUser(user): CurrentUser,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ pub mod manga;
|
|||||||
pub mod page;
|
pub mod page;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
pub mod user_preferences;
|
||||||
|
|
||||||
pub use api_token::ApiToken;
|
pub use api_token::ApiToken;
|
||||||
pub use bookmark::{Bookmark, BookmarkSummary};
|
pub use bookmark::{Bookmark, BookmarkSummary};
|
||||||
@@ -13,3 +14,4 @@ pub use manga::Manga;
|
|||||||
pub use page::Page;
|
pub use page::Page;
|
||||||
pub use session::Session;
|
pub use session::Session;
|
||||||
pub use user::User;
|
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 page;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod user;
|
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());
|
||||||
|
}
|
||||||
215
frontend/e2e/reader-mode.spec.ts
Normal file
215
frontend/e2e/reader-mode.spec.ts
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
const mangaId = '22222222-2222-2222-2222-222222222222';
|
||||||
|
const mangaFixture = {
|
||||||
|
id: mangaId,
|
||||||
|
title: 'Vagabond',
|
||||||
|
author: 'Takehiko Inoue',
|
||||||
|
description: null,
|
||||||
|
cover_image_path: null,
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
updated_at: '2026-01-01T00:00:00Z'
|
||||||
|
};
|
||||||
|
const chapterFixture = {
|
||||||
|
id: 'c1',
|
||||||
|
manga_id: mangaId,
|
||||||
|
number: 1,
|
||||||
|
title: null,
|
||||||
|
page_count: 3,
|
||||||
|
created_at: '2026-01-01T00:00:00Z'
|
||||||
|
};
|
||||||
|
const pagesFixture = [
|
||||||
|
{
|
||||||
|
id: 'p1',
|
||||||
|
chapter_id: 'c1',
|
||||||
|
page_number: 1,
|
||||||
|
storage_key: 'mangas/m2/chapters/c1/pages/0001.png',
|
||||||
|
content_type: 'image/png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'p2',
|
||||||
|
chapter_id: 'c1',
|
||||||
|
page_number: 2,
|
||||||
|
storage_key: 'mangas/m2/chapters/c1/pages/0002.png',
|
||||||
|
content_type: 'image/png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'p3',
|
||||||
|
chapter_id: 'c1',
|
||||||
|
page_number: 3,
|
||||||
|
storage_key: 'mangas/m2/chapters/c1/pages/0003.png',
|
||||||
|
content_type: 'image/png'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
async function mockReaderApis(page: Page) {
|
||||||
|
// Anonymous user — both `me` and preferences fall back to localStorage.
|
||||||
|
await page.route('**/api/v1/auth/me', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 401,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ error: { code: 'unauthenticated', message: '' } })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/auth/me/preferences', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 401,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ error: { code: 'unauthenticated', message: '' } })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/me/bookmarks*', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 401,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ error: { code: 'unauthenticated', message: '' } })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route(`**/api/v1/mangas/${mangaId}`, (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(mangaFixture)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route(`**/api/v1/mangas/${mangaId}/chapters?*`, (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
items: [chapterFixture],
|
||||||
|
page: { limit: 50, offset: 0, total: null }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route(`**/api/v1/mangas/${mangaId}/chapters`, (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
items: [chapterFixture],
|
||||||
|
page: { limit: 50, offset: 0, total: null }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route(`**/api/v1/mangas/${mangaId}/chapters/1`, (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(chapterFixture)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route(`**/api/v1/mangas/${mangaId}/chapters/1/pages`, (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ pages: pagesFixture })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const png = Buffer.from(
|
||||||
|
'89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c63000100000005000158a3b62a0000000049454e44ae426082',
|
||||||
|
'hex'
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/files/**', (route) =>
|
||||||
|
route.fulfill({ status: 200, contentType: 'image/png', body: png })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeEach(async ({ context }) => {
|
||||||
|
// Clear the localStorage shadow so each test starts in the default
|
||||||
|
// single-page mode regardless of order.
|
||||||
|
await context.clearCookies();
|
||||||
|
await context.addInitScript(() => {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem('mangalord-reader-mode');
|
||||||
|
localStorage.removeItem('mangalord-reader-gap');
|
||||||
|
} catch {
|
||||||
|
// ignore — about:blank doesn't expose localStorage yet.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('switching to continuous mode stacks all pages and hides chevrons', async ({ page }) => {
|
||||||
|
await mockReaderApis(page);
|
||||||
|
await page.goto(`/manga/${mangaId}/chapter/1`);
|
||||||
|
|
||||||
|
// Default single-page mode is active.
|
||||||
|
await expect(page.getByTestId('reader-page')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('page-indicator')).toHaveText('Page 1 / 3');
|
||||||
|
|
||||||
|
await page.getByTestId('reader-mode-continuous').click();
|
||||||
|
|
||||||
|
await expect(page.getByTestId('reader-continuous')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('reader-page-1')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('reader-page-3')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('reader-prev')).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId('reader-next')).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId('page-indicator')).toHaveText('3 pages');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('arrow keys do not paginate while in continuous mode', async ({ page }) => {
|
||||||
|
await mockReaderApis(page);
|
||||||
|
await page.goto(`/manga/${mangaId}/chapter/1`);
|
||||||
|
await page.getByTestId('reader-mode-continuous').click();
|
||||||
|
await expect(page.getByTestId('reader-continuous')).toBeVisible();
|
||||||
|
|
||||||
|
await page.keyboard.press('ArrowRight');
|
||||||
|
await page.keyboard.press('j');
|
||||||
|
|
||||||
|
// Still in continuous mode, still showing every page.
|
||||||
|
await expect(page.getByTestId('reader-continuous')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('reader-page-1')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('reader-page-3')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gap select updates the inline gap on the continuous container', async ({ page }) => {
|
||||||
|
await mockReaderApis(page);
|
||||||
|
await page.goto(`/manga/${mangaId}/chapter/1`);
|
||||||
|
await page.getByTestId('reader-mode-continuous').click();
|
||||||
|
|
||||||
|
const container = page.getByTestId('reader-continuous');
|
||||||
|
// Default gap is "none" → 0px.
|
||||||
|
await expect(container).toHaveAttribute('style', /gap:\s*0px/);
|
||||||
|
|
||||||
|
await page.getByTestId('reader-gap').selectOption('medium');
|
||||||
|
await expect(container).toHaveAttribute('style', /gap:\s*32px/);
|
||||||
|
|
||||||
|
await page.getByTestId('reader-gap').selectOption('large');
|
||||||
|
await expect(container).toHaveAttribute('style', /gap:\s*64px/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reader-mode preference set on one page is honored when the reader opens', async ({
|
||||||
|
page,
|
||||||
|
context
|
||||||
|
}) => {
|
||||||
|
// Simulate a user who has already chosen continuous + medium on the
|
||||||
|
// settings page (which writes through the preferences store to
|
||||||
|
// localStorage). The reader should pick up that choice on first
|
||||||
|
// render without any in-tab navigation.
|
||||||
|
await context.addInitScript(() => {
|
||||||
|
localStorage.setItem('mangalord-reader-mode', 'continuous');
|
||||||
|
localStorage.setItem('mangalord-reader-gap', 'medium');
|
||||||
|
});
|
||||||
|
await mockReaderApis(page);
|
||||||
|
|
||||||
|
await page.goto(`/manga/${mangaId}/chapter/1`);
|
||||||
|
await expect(page.getByTestId('reader-continuous')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('page-indicator')).toHaveText('3 pages');
|
||||||
|
await expect(page.getByTestId('reader-continuous')).toHaveAttribute(
|
||||||
|
'style',
|
||||||
|
/gap:\s*32px/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('settings page hides the gap picker while in single-page mode', async ({ page }) => {
|
||||||
|
// Visually verifies the conditional render. The radio-click semantics
|
||||||
|
// are exercised in src/lib/preferences.svelte.test.ts; the visible
|
||||||
|
// mode toggle in the reader top bar covers the cross-route propagation
|
||||||
|
// path in the test above.
|
||||||
|
await mockReaderApis(page);
|
||||||
|
await page.goto('/settings');
|
||||||
|
|
||||||
|
await expect(page.getByTestId('reader-mode-radio-single')).toBeAttached();
|
||||||
|
await expect(page.getByTestId('reader-mode-radio-continuous')).toBeAttached();
|
||||||
|
await expect(page.getByTestId('reader-gap-radio-medium')).toHaveCount(0);
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.13.0",
|
"version": "0.14.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
98
frontend/src/lib/api/preferences.test.ts
Normal file
98
frontend/src/lib/api/preferences.test.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
it,
|
||||||
|
expect,
|
||||||
|
vi,
|
||||||
|
beforeEach,
|
||||||
|
afterEach,
|
||||||
|
type MockInstance
|
||||||
|
} from 'vitest';
|
||||||
|
import { getPreferences, updatePreferences, GAP_PX } from './preferences';
|
||||||
|
|
||||||
|
function ok(body: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { 'content-type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function envelope(status: number, code: string, message: string): Response {
|
||||||
|
return new Response(JSON.stringify({ error: { code, message } }), {
|
||||||
|
status,
|
||||||
|
headers: { 'content-type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefsFixture = {
|
||||||
|
reader_mode: 'continuous' as const,
|
||||||
|
reader_page_gap: 'medium' as const,
|
||||||
|
updated_at: '2026-05-17T12:00:00Z'
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('preferences api client', () => {
|
||||||
|
let fetchSpy: MockInstance<typeof globalThis.fetch>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GAP_PX exposes the four named options with increasing values', () => {
|
||||||
|
expect(GAP_PX.none).toBe(0);
|
||||||
|
expect(GAP_PX.small).toBeGreaterThan(GAP_PX.none);
|
||||||
|
expect(GAP_PX.medium).toBeGreaterThan(GAP_PX.small);
|
||||||
|
expect(GAP_PX.large).toBeGreaterThan(GAP_PX.medium);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getPreferences GETs /v1/auth/me/preferences and returns the row', async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(ok(prefsFixture));
|
||||||
|
await expect(getPreferences()).resolves.toEqual(prefsFixture);
|
||||||
|
const url = fetchSpy.mock.calls[0][0] as string;
|
||||||
|
expect(url).toMatch(/\/v1\/auth\/me\/preferences$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getPreferences returns null on 401 (anonymous browsing)', async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(envelope(401, 'unauthenticated', 'unauthenticated'));
|
||||||
|
await expect(getPreferences()).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getPreferences re-throws non-401 errors', async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(envelope(500, 'internal_error', 'boom'));
|
||||||
|
await expect(getPreferences()).rejects.toMatchObject({ status: 500 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updatePreferences PATCHes JSON and returns the new row', async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(ok(prefsFixture));
|
||||||
|
const r = await updatePreferences({
|
||||||
|
reader_mode: 'continuous',
|
||||||
|
reader_page_gap: 'medium'
|
||||||
|
});
|
||||||
|
expect(r).toEqual(prefsFixture);
|
||||||
|
const url = fetchSpy.mock.calls[0][0] as string;
|
||||||
|
expect(url).toMatch(/\/v1\/auth\/me\/preferences$/);
|
||||||
|
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||||
|
expect(init.method).toBe('PATCH');
|
||||||
|
expect(JSON.parse(init.body as string)).toEqual({
|
||||||
|
reader_mode: 'continuous',
|
||||||
|
reader_page_gap: 'medium'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updatePreferences sends only the keys it was given (partial)', async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(ok(prefsFixture));
|
||||||
|
await updatePreferences({ reader_mode: 'single' });
|
||||||
|
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||||
|
expect(JSON.parse(init.body as string)).toEqual({ reader_mode: 'single' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updatePreferences surfaces 400 invalid_input via ApiError', async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(
|
||||||
|
envelope(400, 'invalid_input', 'reader_mode must be one of ...')
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
updatePreferences({ reader_mode: 'continuous' })
|
||||||
|
).rejects.toMatchObject({ status: 400, code: 'invalid_input' });
|
||||||
|
});
|
||||||
|
});
|
||||||
50
frontend/src/lib/api/preferences.ts
Normal file
50
frontend/src/lib/api/preferences.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { ApiError, request } from './client';
|
||||||
|
|
||||||
|
export type ReaderMode = 'single' | 'continuous';
|
||||||
|
export type ReaderPageGap = 'none' | 'small' | 'medium' | 'large';
|
||||||
|
|
||||||
|
export type Preferences = {
|
||||||
|
reader_mode: ReaderMode;
|
||||||
|
reader_page_gap: ReaderPageGap;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a named gap option to a CSS pixel value. Centralised here so the
|
||||||
|
* settings UI, the reader, and any future surfaces all show the same
|
||||||
|
* spacing.
|
||||||
|
*/
|
||||||
|
export const GAP_PX: Record<ReaderPageGap, number> = {
|
||||||
|
none: 0,
|
||||||
|
small: 12,
|
||||||
|
medium: 32,
|
||||||
|
large: 64
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current user's stored reader preferences, or `null` if there
|
||||||
|
* is no valid session. Anything other than 401 is re-thrown so the caller
|
||||||
|
* can surface real errors.
|
||||||
|
*/
|
||||||
|
export async function getPreferences(): Promise<Preferences | null> {
|
||||||
|
try {
|
||||||
|
return await request<Preferences>('/v1/auth/me/preferences');
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError && e.status === 401) return null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Partially updates the user's reader preferences. Unspecified fields are
|
||||||
|
* left unchanged on the server.
|
||||||
|
*/
|
||||||
|
export async function updatePreferences(
|
||||||
|
patch: Partial<Pick<Preferences, 'reader_mode' | 'reader_page_gap'>>
|
||||||
|
): Promise<Preferences> {
|
||||||
|
return request<Preferences>('/v1/auth/me/preferences', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(patch)
|
||||||
|
});
|
||||||
|
}
|
||||||
151
frontend/src/lib/preferences.svelte.test.ts
Normal file
151
frontend/src/lib/preferences.svelte.test.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
it,
|
||||||
|
expect,
|
||||||
|
vi,
|
||||||
|
beforeEach,
|
||||||
|
afterEach,
|
||||||
|
type MockInstance
|
||||||
|
} from 'vitest';
|
||||||
|
import { preferences } from './preferences.svelte';
|
||||||
|
|
||||||
|
function ok(body: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { 'content-type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function envelope(status: number, code: string, message: string): Response {
|
||||||
|
return new Response(JSON.stringify({ error: { code, message } }), {
|
||||||
|
status,
|
||||||
|
headers: { 'content-type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const flush = () => new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
describe('preferences store', () => {
|
||||||
|
let fetchSpy: MockInstance<typeof globalThis.fetch>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
localStorage.clear();
|
||||||
|
// Default fetch implementation returns 401 so the reset calls below
|
||||||
|
// don't escape as real network requests (jsdom can't resolve
|
||||||
|
// /api/...). Each test overrides with mockResolvedValueOnce.
|
||||||
|
fetchSpy = vi
|
||||||
|
.spyOn(globalThis, 'fetch')
|
||||||
|
.mockResolvedValue(envelope(401, 'unauthenticated', 'reset'));
|
||||||
|
// Reset the singleton's state for the next test.
|
||||||
|
preferences.setMode('single');
|
||||||
|
preferences.setGap('none');
|
||||||
|
await flush();
|
||||||
|
fetchSpy.mockReset();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('init hydrates from localStorage immediately and pulls server values when authenticated', async () => {
|
||||||
|
localStorage.setItem('mangalord-reader-mode', 'continuous');
|
||||||
|
localStorage.setItem('mangalord-reader-gap', 'small');
|
||||||
|
|
||||||
|
fetchSpy.mockResolvedValueOnce(
|
||||||
|
ok({
|
||||||
|
reader_mode: 'single',
|
||||||
|
reader_page_gap: 'large',
|
||||||
|
updated_at: '2026-05-17T12:00:00Z'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const p = preferences.init();
|
||||||
|
// Synchronous hydration from localStorage happens before the await.
|
||||||
|
expect(preferences.readerMode).toBe('continuous');
|
||||||
|
expect(preferences.readerPageGap).toBe('small');
|
||||||
|
|
||||||
|
await p;
|
||||||
|
// Server response overrides the localStorage values.
|
||||||
|
expect(preferences.readerMode).toBe('single');
|
||||||
|
expect(preferences.readerPageGap).toBe('large');
|
||||||
|
expect(localStorage.getItem('mangalord-reader-mode')).toBe('single');
|
||||||
|
expect(localStorage.getItem('mangalord-reader-gap')).toBe('large');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('init keeps localStorage values when the user is anonymous (401)', async () => {
|
||||||
|
localStorage.setItem('mangalord-reader-mode', 'continuous');
|
||||||
|
localStorage.setItem('mangalord-reader-gap', 'medium');
|
||||||
|
fetchSpy.mockResolvedValueOnce(envelope(401, 'unauthenticated', 'no session'));
|
||||||
|
|
||||||
|
await preferences.init();
|
||||||
|
expect(preferences.readerMode).toBe('continuous');
|
||||||
|
expect(preferences.readerPageGap).toBe('medium');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setMode updates state, writes localStorage, and PATCHes the server', async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(
|
||||||
|
ok({
|
||||||
|
reader_mode: 'continuous',
|
||||||
|
reader_page_gap: 'none',
|
||||||
|
updated_at: '2026-05-17T12:00:00Z'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
preferences.setMode('continuous');
|
||||||
|
expect(preferences.readerMode).toBe('continuous');
|
||||||
|
expect(localStorage.getItem('mangalord-reader-mode')).toBe('continuous');
|
||||||
|
|
||||||
|
await flush();
|
||||||
|
expect(fetchSpy).toHaveBeenCalledOnce();
|
||||||
|
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||||
|
expect(init.method).toBe('PATCH');
|
||||||
|
expect(JSON.parse(init.body as string)).toEqual({ reader_mode: 'continuous' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setGap survives a 401 (guest) without throwing', async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(envelope(401, 'unauthenticated', 'no session'));
|
||||||
|
preferences.setGap('large');
|
||||||
|
expect(preferences.readerPageGap).toBe('large');
|
||||||
|
await flush();
|
||||||
|
// localStorage still holds the choice — the guest preference persists.
|
||||||
|
expect(localStorage.getItem('mangalord-reader-gap')).toBe('large');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setMode reverts the optimistic update when the server returns 5xx', async () => {
|
||||||
|
// Suppress the expected console.error so the test output stays clean.
|
||||||
|
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
fetchSpy.mockResolvedValueOnce(envelope(500, 'internal_error', 'boom'));
|
||||||
|
preferences.setMode('continuous');
|
||||||
|
expect(preferences.readerMode).toBe('continuous');
|
||||||
|
await flush();
|
||||||
|
expect(preferences.readerMode).toBe('single');
|
||||||
|
expect(localStorage.getItem('mangalord-reader-mode')).toBe('single');
|
||||||
|
errSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearForLogout resets state and removes localStorage entries', () => {
|
||||||
|
localStorage.setItem('mangalord-reader-mode', 'continuous');
|
||||||
|
localStorage.setItem('mangalord-reader-gap', 'large');
|
||||||
|
preferences.clearForLogout();
|
||||||
|
expect(preferences.readerMode).toBe('single');
|
||||||
|
expect(preferences.readerPageGap).toBe('none');
|
||||||
|
expect(localStorage.getItem('mangalord-reader-mode')).toBeNull();
|
||||||
|
expect(localStorage.getItem('mangalord-reader-gap')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('init ignores an unknown reader_page_gap from the server (forward compat)', async () => {
|
||||||
|
localStorage.setItem('mangalord-reader-mode', 'continuous');
|
||||||
|
localStorage.setItem('mangalord-reader-gap', 'medium');
|
||||||
|
fetchSpy.mockResolvedValueOnce(
|
||||||
|
ok({
|
||||||
|
reader_mode: 'continuous',
|
||||||
|
reader_page_gap: 'huge',
|
||||||
|
updated_at: '2026-05-17T12:00:00Z'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await preferences.init();
|
||||||
|
// Mode comes through (known value); unknown gap is rejected, the
|
||||||
|
// pre-existing 'medium' value is retained.
|
||||||
|
expect(preferences.readerMode).toBe('continuous');
|
||||||
|
expect(preferences.readerPageGap).toBe('medium');
|
||||||
|
});
|
||||||
|
});
|
||||||
136
frontend/src/lib/preferences.svelte.ts
Normal file
136
frontend/src/lib/preferences.svelte.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
// Reader preferences. Stored server-side per user, with a localStorage
|
||||||
|
// shadow so anonymous browsers still get a consistent experience across
|
||||||
|
// page loads and so logged-in users don't flash defaults while the
|
||||||
|
// server response is in flight.
|
||||||
|
//
|
||||||
|
// Mutated client-side only — same pattern as session.svelte.ts. SSR
|
||||||
|
// always sees the post-construct defaults; `init()` is called from the
|
||||||
|
// root layout's onMount and reads localStorage / pulls the server row.
|
||||||
|
|
||||||
|
import {
|
||||||
|
getPreferences,
|
||||||
|
updatePreferences,
|
||||||
|
type Preferences,
|
||||||
|
type ReaderMode,
|
||||||
|
type ReaderPageGap
|
||||||
|
} from './api/preferences';
|
||||||
|
|
||||||
|
const MODE_KEY = 'mangalord-reader-mode';
|
||||||
|
const GAP_KEY = 'mangalord-reader-gap';
|
||||||
|
|
||||||
|
const MODES: ReaderMode[] = ['single', 'continuous'];
|
||||||
|
const GAPS: ReaderPageGap[] = ['none', 'small', 'medium', 'large'];
|
||||||
|
|
||||||
|
function readStoredMode(): ReaderMode {
|
||||||
|
if (typeof localStorage === 'undefined') return 'single';
|
||||||
|
const v = localStorage.getItem(MODE_KEY);
|
||||||
|
return (MODES as string[]).includes(v ?? '') ? (v as ReaderMode) : 'single';
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredGap(): ReaderPageGap {
|
||||||
|
if (typeof localStorage === 'undefined') return 'none';
|
||||||
|
const v = localStorage.getItem(GAP_KEY);
|
||||||
|
return (GAPS as string[]).includes(v ?? '') ? (v as ReaderPageGap) : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
class PreferencesStore {
|
||||||
|
readerMode: ReaderMode = $state('single');
|
||||||
|
readerPageGap: ReaderPageGap = $state('none');
|
||||||
|
loaded = $state(false);
|
||||||
|
// Bumped before each server fetch so a slow response that resolves
|
||||||
|
// after a newer one can't clobber the state.
|
||||||
|
private seq = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot init: hydrate from localStorage (instant), then ask the
|
||||||
|
* server (slow but authoritative). Safe to call multiple times — the
|
||||||
|
* seq guard makes overlapping calls converge on the latest result.
|
||||||
|
*/
|
||||||
|
async init(): Promise<void> {
|
||||||
|
this.readerMode = readStoredMode();
|
||||||
|
this.readerPageGap = readStoredGap();
|
||||||
|
await this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh(): Promise<void> {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const seq = ++this.seq;
|
||||||
|
try {
|
||||||
|
const server = await getPreferences();
|
||||||
|
if (seq !== this.seq) return;
|
||||||
|
if (server) this.apply(server);
|
||||||
|
} finally {
|
||||||
|
if (seq === this.seq) this.loaded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset to defaults and clear the localStorage shadow. Called on
|
||||||
|
/// logout so user A's settings don't follow user B (or an anonymous
|
||||||
|
/// browser) on a shared device.
|
||||||
|
clearForLogout(): void {
|
||||||
|
this.seq++;
|
||||||
|
this.readerMode = 'single';
|
||||||
|
this.readerPageGap = 'none';
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
localStorage.removeItem(MODE_KEY);
|
||||||
|
localStorage.removeItem(GAP_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setMode(mode: ReaderMode): void {
|
||||||
|
const prev = this.readerMode;
|
||||||
|
this.readerMode = mode;
|
||||||
|
if (typeof localStorage !== 'undefined') localStorage.setItem(MODE_KEY, mode);
|
||||||
|
this.pushToServer({ reader_mode: mode }, () => {
|
||||||
|
this.readerMode = prev;
|
||||||
|
if (typeof localStorage !== 'undefined') localStorage.setItem(MODE_KEY, prev);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setGap(gap: ReaderPageGap): void {
|
||||||
|
const prev = this.readerPageGap;
|
||||||
|
this.readerPageGap = gap;
|
||||||
|
if (typeof localStorage !== 'undefined') localStorage.setItem(GAP_KEY, gap);
|
||||||
|
this.pushToServer({ reader_page_gap: gap }, () => {
|
||||||
|
this.readerPageGap = prev;
|
||||||
|
if (typeof localStorage !== 'undefined') localStorage.setItem(GAP_KEY, prev);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private apply(p: Preferences): void {
|
||||||
|
// Validate against the allowlist so a future server-side value
|
||||||
|
// we don't yet understand can't poison the UI (e.g. an unknown
|
||||||
|
// gap would render `style:gap="undefinedpx"` and break layout).
|
||||||
|
const mode = (MODES as string[]).includes(p.reader_mode)
|
||||||
|
? (p.reader_mode as ReaderMode)
|
||||||
|
: this.readerMode;
|
||||||
|
const gap = (GAPS as string[]).includes(p.reader_page_gap)
|
||||||
|
? (p.reader_page_gap as ReaderPageGap)
|
||||||
|
: this.readerPageGap;
|
||||||
|
this.readerMode = mode;
|
||||||
|
this.readerPageGap = gap;
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
localStorage.setItem(MODE_KEY, mode);
|
||||||
|
localStorage.setItem(GAP_KEY, gap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private pushToServer(
|
||||||
|
patch: Partial<Pick<Preferences, 'reader_mode' | 'reader_page_gap'>>,
|
||||||
|
revert: () => void
|
||||||
|
): void {
|
||||||
|
// Optimistic local update already happened. 401 (anonymous user)
|
||||||
|
// is the expected path for guests — the localStorage write keeps
|
||||||
|
// their choice. Any other failure (network, 5xx) means the
|
||||||
|
// server doesn't agree with our local state, so roll the
|
||||||
|
// optimistic change back.
|
||||||
|
updatePreferences(patch).catch((e) => {
|
||||||
|
const status = (e as { status?: number })?.status;
|
||||||
|
if (status === 401) return;
|
||||||
|
console.error('updatePreferences failed', e);
|
||||||
|
revert();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const preferences = new PreferencesStore();
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { logout } from '$lib/api/auth';
|
import { logout } from '$lib/api/auth';
|
||||||
|
import { preferences } from '$lib/preferences.svelte';
|
||||||
import { session } from '$lib/session.svelte';
|
import { session } from '$lib/session.svelte';
|
||||||
import { theme } from '$lib/theme.svelte';
|
import { theme } from '$lib/theme.svelte';
|
||||||
import Upload from '@lucide/svelte/icons/upload';
|
import Upload from '@lucide/svelte/icons/upload';
|
||||||
@@ -15,9 +16,17 @@
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
theme.init();
|
theme.init();
|
||||||
|
preferences.init();
|
||||||
if (!session.loaded) session.refresh();
|
if (!session.loaded) session.refresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Pull fresh server preferences whenever the user changes (login,
|
||||||
|
// logout, account switch). The store's seq guard keeps the most recent
|
||||||
|
// response authoritative.
|
||||||
|
$effect(() => {
|
||||||
|
if (session.user) preferences.refresh();
|
||||||
|
});
|
||||||
|
|
||||||
onDestroy(() => theme.destroy());
|
onDestroy(() => theme.destroy());
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
@@ -26,6 +35,10 @@
|
|||||||
await logout();
|
await logout();
|
||||||
} finally {
|
} finally {
|
||||||
session.setUser(null);
|
session.setUser(null);
|
||||||
|
// Don't let user A's reader preferences linger for the next
|
||||||
|
// person who uses this browser (or as guest state for the
|
||||||
|
// same user). Resets state + localStorage.
|
||||||
|
preferences.clearForLogout();
|
||||||
loggingOut = false;
|
loggingOut = false;
|
||||||
goto('/login');
|
goto('/login');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { fileUrl } from '$lib/api/client';
|
import { fileUrl } from '$lib/api/client';
|
||||||
|
import { GAP_PX, type ReaderPageGap } from '$lib/api/preferences';
|
||||||
|
import { preferences } from '$lib/preferences.svelte';
|
||||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||||
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
||||||
import BookImage from '@lucide/svelte/icons/book-image';
|
import BookImage from '@lucide/svelte/icons/book-image';
|
||||||
|
import FileText from '@lucide/svelte/icons/file-text';
|
||||||
|
import ScrollText from '@lucide/svelte/icons/scroll-text';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
const manga = $derived(data.manga);
|
const manga = $derived(data.manga);
|
||||||
const chapter = $derived(data.chapter);
|
const chapter = $derived(data.chapter);
|
||||||
const pages = $derived(data.pages);
|
const pages = $derived(data.pages);
|
||||||
|
|
||||||
|
const mode = $derived(preferences.readerMode);
|
||||||
|
const gapPx = $derived(GAP_PX[preferences.readerPageGap]);
|
||||||
|
|
||||||
const pageTitle = $derived(
|
const pageTitle = $derived(
|
||||||
chapter.title
|
chapter.title
|
||||||
? `${manga.title} — Ch. ${chapter.number}: ${chapter.title}`
|
? `${manga.title} — Ch. ${chapter.number}: ${chapter.title}`
|
||||||
@@ -18,6 +25,7 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
let index = $state(0);
|
let index = $state(0);
|
||||||
|
let continuousPageEls: HTMLImageElement[] = $state([]);
|
||||||
|
|
||||||
function next() {
|
function next() {
|
||||||
if (index < pages.length - 1) index += 1;
|
if (index < pages.length - 1) index += 1;
|
||||||
@@ -36,6 +44,21 @@
|
|||||||
// Don't hijack keys while the user is typing in an input.
|
// Don't hijack keys while the user is typing in an input.
|
||||||
const target = e.target as HTMLElement | null;
|
const target = e.target as HTMLElement | null;
|
||||||
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return;
|
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return;
|
||||||
|
// In continuous mode, native scrolling handles Space/PageDown/arrows;
|
||||||
|
// we still wire Home/End to scrollIntoView so jumping to the chapter
|
||||||
|
// bounds stays a one-keypress action.
|
||||||
|
if (mode === 'continuous') {
|
||||||
|
if (e.key === 'Home') {
|
||||||
|
e.preventDefault();
|
||||||
|
continuousPageEls[0]?.scrollIntoView({ block: 'start' });
|
||||||
|
} else if (e.key === 'End') {
|
||||||
|
e.preventDefault();
|
||||||
|
continuousPageEls[continuousPageEls.length - 1]?.scrollIntoView({
|
||||||
|
block: 'end'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Space is deliberately *not* bound — on viewports where a page
|
// Space is deliberately *not* bound — on viewports where a page
|
||||||
// image overflows (portrait phones, narrow desktop windows),
|
// image overflows (portrait phones, narrow desktop windows),
|
||||||
// users expect Space to scroll the page, and stealing it for
|
// users expect Space to scroll the page, and stealing it for
|
||||||
@@ -90,14 +113,65 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<span class="back-text">{manga.title}</span>
|
<span class="back-text">{manga.title}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<div class="controls" role="group" aria-label="reader options">
|
||||||
|
<div class="mode-toggle" role="radiogroup" aria-label="layout">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="seg"
|
||||||
|
class:active={mode === 'single'}
|
||||||
|
onclick={() => preferences.setMode('single')}
|
||||||
|
aria-pressed={mode === 'single'}
|
||||||
|
title="Single page"
|
||||||
|
data-testid="reader-mode-single"
|
||||||
|
>
|
||||||
|
<FileText size={16} aria-hidden="true" />
|
||||||
|
<span>Single</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="seg"
|
||||||
|
class:active={mode === 'continuous'}
|
||||||
|
onclick={() => preferences.setMode('continuous')}
|
||||||
|
aria-pressed={mode === 'continuous'}
|
||||||
|
title="Continuous (scroll)"
|
||||||
|
data-testid="reader-mode-continuous"
|
||||||
|
>
|
||||||
|
<ScrollText size={16} aria-hidden="true" />
|
||||||
|
<span>Continuous</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if mode === 'continuous'}
|
||||||
|
<label class="gap-field">
|
||||||
|
<span class="visually-hidden">Page gap</span>
|
||||||
|
<select
|
||||||
|
value={preferences.readerPageGap}
|
||||||
|
onchange={(e) =>
|
||||||
|
preferences.setGap((e.currentTarget as HTMLSelectElement).value as ReaderPageGap)}
|
||||||
|
data-testid="reader-gap"
|
||||||
|
>
|
||||||
|
<option value="none">No gap</option>
|
||||||
|
<option value="small">Small gap</option>
|
||||||
|
<option value="medium">Medium gap</option>
|
||||||
|
<option value="large">Large gap</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<span class="indicator" data-testid="page-indicator">
|
<span class="indicator" data-testid="page-indicator">
|
||||||
|
{#if mode === 'single'}
|
||||||
Page {index + 1} / {pages.length}
|
Page {index + 1} / {pages.length}
|
||||||
|
{:else}
|
||||||
|
{pages.length} {pages.length === 1 ? 'page' : 'pages'}
|
||||||
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{#if pages.length === 0}
|
{#if pages.length === 0}
|
||||||
<p class="empty" data-testid="reader-empty">This chapter has no pages yet.</p>
|
<p class="empty" data-testid="reader-empty">This chapter has no pages yet.</p>
|
||||||
{:else}
|
{:else if mode === 'single'}
|
||||||
<div class="page-wrap">
|
<div class="page-wrap">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -142,6 +216,19 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="continuous" style:gap="{gapPx}px" data-testid="reader-continuous">
|
||||||
|
{#each pages as p, i (p.id)}
|
||||||
|
<img
|
||||||
|
src={fileUrl(p.storage_key)}
|
||||||
|
alt={`${manga.title} chapter ${chapter.number} page ${i + 1}`}
|
||||||
|
class="page-image"
|
||||||
|
loading={i < 2 ? 'eager' : 'lazy'}
|
||||||
|
data-testid={`reader-page-${i + 1}`}
|
||||||
|
bind:this={continuousPageEls[i]}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -153,6 +240,7 @@
|
|||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
margin-bottom: var(--space-3);
|
margin-bottom: var(--space-3);
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.back {
|
.back {
|
||||||
@@ -191,6 +279,76 @@
|
|||||||
font-weight: var(--weight-medium);
|
font-weight: var(--weight-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: var(--space-1) var(--space-3);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
border: none;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background var(--transition),
|
||||||
|
color var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg + .seg {
|
||||||
|
border-left: 1px solid var(--border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg:hover:not(.active) {
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg.active {
|
||||||
|
background: var(--primary-soft-bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg:focus-visible {
|
||||||
|
outline: 2px solid var(--focus-ring);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap-field select {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.indicator {
|
.indicator {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -208,6 +366,12 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.continuous {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.page-image {
|
.page-image {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
@@ -216,6 +380,13 @@
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.continuous .page-image {
|
||||||
|
/* In continuous mode the user is scrolling — let each page take
|
||||||
|
its natural height instead of capping at viewport height, so
|
||||||
|
there are no scroll dead-zones inside a single page. */
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -255,5 +426,8 @@
|
|||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
justify-self: center;
|
justify-self: center;
|
||||||
}
|
}
|
||||||
|
.seg span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,11 +2,26 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { changePassword } from '$lib/api/auth';
|
import { changePassword } from '$lib/api/auth';
|
||||||
import { ApiError } from '$lib/api/client';
|
import { ApiError } from '$lib/api/client';
|
||||||
|
import type { ReaderMode, ReaderPageGap } from '$lib/api/preferences';
|
||||||
|
import { preferences } from '$lib/preferences.svelte';
|
||||||
import { session } from '$lib/session.svelte';
|
import { session } from '$lib/session.svelte';
|
||||||
import { theme, type Theme } from '$lib/theme.svelte';
|
import { theme, type Theme } from '$lib/theme.svelte';
|
||||||
import Monitor from '@lucide/svelte/icons/monitor';
|
import Monitor from '@lucide/svelte/icons/monitor';
|
||||||
import Sun from '@lucide/svelte/icons/sun';
|
import Sun from '@lucide/svelte/icons/sun';
|
||||||
import Moon from '@lucide/svelte/icons/moon';
|
import Moon from '@lucide/svelte/icons/moon';
|
||||||
|
import FileText from '@lucide/svelte/icons/file-text';
|
||||||
|
import ScrollText from '@lucide/svelte/icons/scroll-text';
|
||||||
|
|
||||||
|
const READER_MODES: { value: ReaderMode; label: string }[] = [
|
||||||
|
{ value: 'single', label: 'Single page' },
|
||||||
|
{ value: 'continuous', label: 'Continuous (scroll)' }
|
||||||
|
];
|
||||||
|
const READER_GAPS: { value: ReaderPageGap; label: string }[] = [
|
||||||
|
{ value: 'none', label: 'None' },
|
||||||
|
{ value: 'small', label: 'Small' },
|
||||||
|
{ value: 'medium', label: 'Medium' },
|
||||||
|
{ value: 'large', label: 'Large' }
|
||||||
|
];
|
||||||
|
|
||||||
function setTheme(next: Theme) {
|
function setTheme(next: Theme) {
|
||||||
theme.set(next);
|
theme.set(next);
|
||||||
@@ -108,6 +123,55 @@
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="card" aria-label="Reader">
|
||||||
|
<h2>Reader</h2>
|
||||||
|
{#if !session.user}
|
||||||
|
<p class="hint" data-testid="reader-prefs-guest-hint">
|
||||||
|
Sign in to sync these settings across devices. Until then they live in this browser.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<fieldset class="theme-picker">
|
||||||
|
<legend>Layout</legend>
|
||||||
|
{#each READER_MODES as opt (opt.value)}
|
||||||
|
<label class="theme-option" class:selected={preferences.readerMode === opt.value}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="reader-mode"
|
||||||
|
value={opt.value}
|
||||||
|
checked={preferences.readerMode === opt.value}
|
||||||
|
onchange={() => preferences.setMode(opt.value)}
|
||||||
|
data-testid={`reader-mode-radio-${opt.value}`}
|
||||||
|
/>
|
||||||
|
{#if opt.value === 'single'}
|
||||||
|
<FileText size={18} aria-hidden="true" />
|
||||||
|
{:else}
|
||||||
|
<ScrollText size={18} aria-hidden="true" />
|
||||||
|
{/if}
|
||||||
|
<span>{opt.label}</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{#if preferences.readerMode === 'continuous'}
|
||||||
|
<fieldset class="theme-picker gap-picker">
|
||||||
|
<legend>Page gap</legend>
|
||||||
|
{#each READER_GAPS as opt (opt.value)}
|
||||||
|
<label class="theme-option" class:selected={preferences.readerPageGap === opt.value}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="reader-gap"
|
||||||
|
value={opt.value}
|
||||||
|
checked={preferences.readerPageGap === opt.value}
|
||||||
|
onchange={() => preferences.setGap(opt.value)}
|
||||||
|
data-testid={`reader-gap-radio-${opt.value}`}
|
||||||
|
/>
|
||||||
|
<span>{opt.label}</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</fieldset>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
{#if !session.loaded}
|
{#if !session.loaded}
|
||||||
<p class="status" data-testid="settings-loading">Loading…</p>
|
<p class="status" data-testid="settings-loading">Loading…</p>
|
||||||
{:else if !session.user}
|
{:else if !session.user}
|
||||||
@@ -238,6 +302,10 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gap-picker {
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
.theme-picker legend {
|
.theme-picker legend {
|
||||||
font-size: var(--font-sm);
|
font-size: var(--font-sm);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
|||||||
Reference in New Issue
Block a user