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:
@@ -2,11 +2,26 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { changePassword } from '$lib/api/auth';
|
||||
import { ApiError } from '$lib/api/client';
|
||||
import type { ReaderMode, ReaderPageGap } from '$lib/api/preferences';
|
||||
import { preferences } from '$lib/preferences.svelte';
|
||||
import { session } from '$lib/session.svelte';
|
||||
import { theme, type Theme } from '$lib/theme.svelte';
|
||||
import Monitor from '@lucide/svelte/icons/monitor';
|
||||
import Sun from '@lucide/svelte/icons/sun';
|
||||
import Moon from '@lucide/svelte/icons/moon';
|
||||
import FileText from '@lucide/svelte/icons/file-text';
|
||||
import ScrollText from '@lucide/svelte/icons/scroll-text';
|
||||
|
||||
const READER_MODES: { value: ReaderMode; label: string }[] = [
|
||||
{ value: 'single', label: 'Single page' },
|
||||
{ value: 'continuous', label: 'Continuous (scroll)' }
|
||||
];
|
||||
const READER_GAPS: { value: ReaderPageGap; label: string }[] = [
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: 'small', label: 'Small' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'large', label: 'Large' }
|
||||
];
|
||||
|
||||
function setTheme(next: Theme) {
|
||||
theme.set(next);
|
||||
@@ -108,6 +123,55 @@
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
<section class="card" aria-label="Reader">
|
||||
<h2>Reader</h2>
|
||||
{#if !session.user}
|
||||
<p class="hint" data-testid="reader-prefs-guest-hint">
|
||||
Sign in to sync these settings across devices. Until then they live in this browser.
|
||||
</p>
|
||||
{/if}
|
||||
<fieldset class="theme-picker">
|
||||
<legend>Layout</legend>
|
||||
{#each READER_MODES as opt (opt.value)}
|
||||
<label class="theme-option" class:selected={preferences.readerMode === opt.value}>
|
||||
<input
|
||||
type="radio"
|
||||
name="reader-mode"
|
||||
value={opt.value}
|
||||
checked={preferences.readerMode === opt.value}
|
||||
onchange={() => preferences.setMode(opt.value)}
|
||||
data-testid={`reader-mode-radio-${opt.value}`}
|
||||
/>
|
||||
{#if opt.value === 'single'}
|
||||
<FileText size={18} aria-hidden="true" />
|
||||
{:else}
|
||||
<ScrollText size={18} aria-hidden="true" />
|
||||
{/if}
|
||||
<span>{opt.label}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</fieldset>
|
||||
|
||||
{#if preferences.readerMode === 'continuous'}
|
||||
<fieldset class="theme-picker gap-picker">
|
||||
<legend>Page gap</legend>
|
||||
{#each READER_GAPS as opt (opt.value)}
|
||||
<label class="theme-option" class:selected={preferences.readerPageGap === opt.value}>
|
||||
<input
|
||||
type="radio"
|
||||
name="reader-gap"
|
||||
value={opt.value}
|
||||
checked={preferences.readerPageGap === opt.value}
|
||||
onchange={() => preferences.setGap(opt.value)}
|
||||
data-testid={`reader-gap-radio-${opt.value}`}
|
||||
/>
|
||||
<span>{opt.label}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</fieldset>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if !session.loaded}
|
||||
<p class="status" data-testid="settings-loading">Loading…</p>
|
||||
{:else if !session.user}
|
||||
@@ -238,6 +302,10 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.gap-picker {
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.theme-picker legend {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text);
|
||||
|
||||
Reference in New Issue
Block a user