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:
@@ -5,3 +5,4 @@ pub mod manga;
|
||||
pub mod page;
|
||||
pub mod session;
|
||||
pub mod user;
|
||||
pub mod user_preferences;
|
||||
|
||||
48
backend/src/repo/user_preferences.rs
Normal file
48
backend/src/repo/user_preferences.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
//! Per-user reader preferences. Rows are lazily inserted on the first
|
||||
//! upsert — a user that has never customised their settings has no row,
|
||||
//! and reads fall back to the in-memory defaults in `UserPreferences::defaults_for`.
|
||||
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::domain::UserPreferences;
|
||||
use crate::error::AppResult;
|
||||
|
||||
pub async fn find(pool: &PgPool, user_id: Uuid) -> AppResult<Option<UserPreferences>> {
|
||||
let row = sqlx::query_as::<_, UserPreferences>(
|
||||
r#"
|
||||
SELECT user_id, reader_mode, reader_page_gap, updated_at
|
||||
FROM user_preferences
|
||||
WHERE user_id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
pub async fn upsert(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
reader_mode: &str,
|
||||
reader_page_gap: &str,
|
||||
) -> AppResult<UserPreferences> {
|
||||
let row = sqlx::query_as::<_, UserPreferences>(
|
||||
r#"
|
||||
INSERT INTO user_preferences (user_id, reader_mode, reader_page_gap)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (user_id) DO UPDATE
|
||||
SET reader_mode = EXCLUDED.reader_mode,
|
||||
reader_page_gap = EXCLUDED.reader_page_gap,
|
||||
updated_at = now()
|
||||
RETURNING user_id, reader_mode, reader_page_gap, updated_at
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(reader_mode)
|
||||
.bind(reader_page_gap)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
Reference in New Issue
Block a user