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>
51 lines
1.4 KiB
TypeScript
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)
|
|
});
|
|
}
|