Files
Mangalord/frontend/src/lib/api/preferences.ts
MechaCat02 60cc7712fa 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>
2026-05-17 13:15:03 +02:00

51 lines
1.4 KiB
TypeScript

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)
});
}