From 60cc7712fa4322ce877c284f2843587d584df80e Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sun, 17 May 2026 13:15:03 +0200 Subject: [PATCH] feat: continuous reader mode with persisted preference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/Cargo.lock | 2 +- backend/Cargo.toml | 2 +- backend/migrations/0008_user_preferences.sql | 8 + backend/src/api/auth.rs | 65 ++++- backend/src/domain/mod.rs | 2 + backend/src/domain/user_preferences.rs | 30 +++ backend/src/repo/mod.rs | 1 + backend/src/repo/user_preferences.rs | 48 ++++ backend/tests/api_preferences.rs | 224 ++++++++++++++++++ frontend/e2e/reader-mode.spec.ts | 215 +++++++++++++++++ frontend/package.json | 2 +- frontend/src/lib/api/preferences.test.ts | 98 ++++++++ frontend/src/lib/api/preferences.ts | 50 ++++ frontend/src/lib/preferences.svelte.test.ts | 151 ++++++++++++ frontend/src/lib/preferences.svelte.ts | 136 +++++++++++ frontend/src/routes/+layout.svelte | 13 + .../manga/[id]/chapter/[n]/+page.svelte | 178 +++++++++++++- frontend/src/routes/settings/+page.svelte | 68 ++++++ 18 files changed, 1287 insertions(+), 6 deletions(-) create mode 100644 backend/migrations/0008_user_preferences.sql create mode 100644 backend/src/domain/user_preferences.rs create mode 100644 backend/src/repo/user_preferences.rs create mode 100644 backend/tests/api_preferences.rs create mode 100644 frontend/e2e/reader-mode.spec.ts create mode 100644 frontend/src/lib/api/preferences.test.ts create mode 100644 frontend/src/lib/api/preferences.ts create mode 100644 frontend/src/lib/preferences.svelte.test.ts create mode 100644 frontend/src/lib/preferences.svelte.ts diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 6f0b008..8ee8ee8 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1033,7 +1033,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "mangalord" -version = "0.13.0" +version = "0.14.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index c9d7962..96b0aa7 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.13.0" +version = "0.14.0" edition = "2021" [lib] diff --git a/backend/migrations/0008_user_preferences.sql b/backend/migrations/0008_user_preferences.sql new file mode 100644 index 0000000..a2fa5cc --- /dev/null +++ b/backend/migrations/0008_user_preferences.sql @@ -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() +); diff --git a/backend/src/api/auth.rs b/backend/src/api/auth.rs index bdf531c..3dd5cc2 100644 --- a/backend/src/api/auth.rs +++ b/backend/src/api/auth.rs @@ -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 { .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, + pub reader_page_gap: Option, +} + #[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, + CurrentUser(user): CurrentUser, +) -> AppResult> { + 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, + CurrentUser(user): CurrentUser, + Json(input): Json, +) -> AppResult> { + 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, CurrentUser(user): CurrentUser, diff --git a/backend/src/domain/mod.rs b/backend/src/domain/mod.rs index 1b501f3..cb53180 100644 --- a/backend/src/domain/mod.rs +++ b/backend/src/domain/mod.rs @@ -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; diff --git a/backend/src/domain/user_preferences.rs b/backend/src/domain/user_preferences.rs new file mode 100644 index 0000000..5cc9752 --- /dev/null +++ b/backend/src/domain/user_preferences.rs @@ -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, +} + +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(), + } + } +} diff --git a/backend/src/repo/mod.rs b/backend/src/repo/mod.rs index 5a7dbe0..95cad9d 100644 --- a/backend/src/repo/mod.rs +++ b/backend/src/repo/mod.rs @@ -5,3 +5,4 @@ pub mod manga; pub mod page; pub mod session; pub mod user; +pub mod user_preferences; diff --git a/backend/src/repo/user_preferences.rs b/backend/src/repo/user_preferences.rs new file mode 100644 index 0000000..b8c3b5e --- /dev/null +++ b/backend/src/repo/user_preferences.rs @@ -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> { + 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 { + 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) +} diff --git a/backend/tests/api_preferences.rs b/backend/tests/api_preferences.rs new file mode 100644 index 0000000..28e435c --- /dev/null +++ b/backend/tests/api_preferences.rs @@ -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()); +} diff --git a/frontend/e2e/reader-mode.spec.ts b/frontend/e2e/reader-mode.spec.ts new file mode 100644 index 0000000..7ca85ef --- /dev/null +++ b/frontend/e2e/reader-mode.spec.ts @@ -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); +}); diff --git a/frontend/package.json b/frontend/package.json index 656e8b4..b7d9ec6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.13.0", + "version": "0.14.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/api/preferences.test.ts b/frontend/src/lib/api/preferences.test.ts new file mode 100644 index 0000000..b8b8936 --- /dev/null +++ b/frontend/src/lib/api/preferences.test.ts @@ -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; + + 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' }); + }); +}); diff --git a/frontend/src/lib/api/preferences.ts b/frontend/src/lib/api/preferences.ts new file mode 100644 index 0000000..646bc31 --- /dev/null +++ b/frontend/src/lib/api/preferences.ts @@ -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 = { + 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 { + try { + return await request('/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> +): Promise { + return request('/v1/auth/me/preferences', { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(patch) + }); +} diff --git a/frontend/src/lib/preferences.svelte.test.ts b/frontend/src/lib/preferences.svelte.test.ts new file mode 100644 index 0000000..d276cec --- /dev/null +++ b/frontend/src/lib/preferences.svelte.test.ts @@ -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; + + 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'); + }); +}); diff --git a/frontend/src/lib/preferences.svelte.ts b/frontend/src/lib/preferences.svelte.ts new file mode 100644 index 0000000..a998f77 --- /dev/null +++ b/frontend/src/lib/preferences.svelte.ts @@ -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 { + this.readerMode = readStoredMode(); + this.readerPageGap = readStoredGap(); + await this.refresh(); + } + + async refresh(): Promise { + 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>, + 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(); diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 0d15c87..4970be3 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -2,6 +2,7 @@ import { onMount, onDestroy } from 'svelte'; import { goto } from '$app/navigation'; import { logout } from '$lib/api/auth'; + import { preferences } from '$lib/preferences.svelte'; import { session } from '$lib/session.svelte'; import { theme } from '$lib/theme.svelte'; import Upload from '@lucide/svelte/icons/upload'; @@ -15,9 +16,17 @@ onMount(() => { theme.init(); + preferences.init(); 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()); async function handleLogout() { @@ -26,6 +35,10 @@ await logout(); } finally { 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; goto('/login'); } diff --git a/frontend/src/routes/manga/[id]/chapter/[n]/+page.svelte b/frontend/src/routes/manga/[id]/chapter/[n]/+page.svelte index 966990c..56931e3 100644 --- a/frontend/src/routes/manga/[id]/chapter/[n]/+page.svelte +++ b/frontend/src/routes/manga/[id]/chapter/[n]/+page.svelte @@ -1,16 +1,23 @@