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:
98
frontend/src/lib/api/preferences.test.ts
Normal file
98
frontend/src/lib/api/preferences.test.ts
Normal file
@@ -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<typeof globalThis.fetch>;
|
||||
|
||||
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' });
|
||||
});
|
||||
});
|
||||
50
frontend/src/lib/api/preferences.ts
Normal file
50
frontend/src/lib/api/preferences.ts
Normal file
@@ -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<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)
|
||||
});
|
||||
}
|
||||
151
frontend/src/lib/preferences.svelte.test.ts
Normal file
151
frontend/src/lib/preferences.svelte.test.ts
Normal file
@@ -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<typeof globalThis.fetch>;
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
136
frontend/src/lib/preferences.svelte.ts
Normal file
136
frontend/src/lib/preferences.svelte.ts
Normal file
@@ -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<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();
|
||||
Reference in New Issue
Block a user