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>
137 lines
5.0 KiB
TypeScript
137 lines
5.0 KiB
TypeScript
// 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();
|