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:
@@ -1,16 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { fileUrl } from '$lib/api/client';
|
||||
import { GAP_PX, type ReaderPageGap } from '$lib/api/preferences';
|
||||
import { preferences } from '$lib/preferences.svelte';
|
||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
||||
import BookImage from '@lucide/svelte/icons/book-image';
|
||||
import FileText from '@lucide/svelte/icons/file-text';
|
||||
import ScrollText from '@lucide/svelte/icons/scroll-text';
|
||||
|
||||
let { data } = $props();
|
||||
const manga = $derived(data.manga);
|
||||
const chapter = $derived(data.chapter);
|
||||
const pages = $derived(data.pages);
|
||||
|
||||
const mode = $derived(preferences.readerMode);
|
||||
const gapPx = $derived(GAP_PX[preferences.readerPageGap]);
|
||||
|
||||
const pageTitle = $derived(
|
||||
chapter.title
|
||||
? `${manga.title} — Ch. ${chapter.number}: ${chapter.title}`
|
||||
@@ -18,6 +25,7 @@
|
||||
);
|
||||
|
||||
let index = $state(0);
|
||||
let continuousPageEls: HTMLImageElement[] = $state([]);
|
||||
|
||||
function next() {
|
||||
if (index < pages.length - 1) index += 1;
|
||||
@@ -36,6 +44,21 @@
|
||||
// Don't hijack keys while the user is typing in an input.
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return;
|
||||
// In continuous mode, native scrolling handles Space/PageDown/arrows;
|
||||
// we still wire Home/End to scrollIntoView so jumping to the chapter
|
||||
// bounds stays a one-keypress action.
|
||||
if (mode === 'continuous') {
|
||||
if (e.key === 'Home') {
|
||||
e.preventDefault();
|
||||
continuousPageEls[0]?.scrollIntoView({ block: 'start' });
|
||||
} else if (e.key === 'End') {
|
||||
e.preventDefault();
|
||||
continuousPageEls[continuousPageEls.length - 1]?.scrollIntoView({
|
||||
block: 'end'
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Space is deliberately *not* bound — on viewports where a page
|
||||
// image overflows (portrait phones, narrow desktop windows),
|
||||
// users expect Space to scroll the page, and stealing it for
|
||||
@@ -90,14 +113,65 @@
|
||||
{/if}
|
||||
<span class="back-text">{manga.title}</span>
|
||||
</a>
|
||||
|
||||
<div class="controls" role="group" aria-label="reader options">
|
||||
<div class="mode-toggle" role="radiogroup" aria-label="layout">
|
||||
<button
|
||||
type="button"
|
||||
class="seg"
|
||||
class:active={mode === 'single'}
|
||||
onclick={() => preferences.setMode('single')}
|
||||
aria-pressed={mode === 'single'}
|
||||
title="Single page"
|
||||
data-testid="reader-mode-single"
|
||||
>
|
||||
<FileText size={16} aria-hidden="true" />
|
||||
<span>Single</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="seg"
|
||||
class:active={mode === 'continuous'}
|
||||
onclick={() => preferences.setMode('continuous')}
|
||||
aria-pressed={mode === 'continuous'}
|
||||
title="Continuous (scroll)"
|
||||
data-testid="reader-mode-continuous"
|
||||
>
|
||||
<ScrollText size={16} aria-hidden="true" />
|
||||
<span>Continuous</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if mode === 'continuous'}
|
||||
<label class="gap-field">
|
||||
<span class="visually-hidden">Page gap</span>
|
||||
<select
|
||||
value={preferences.readerPageGap}
|
||||
onchange={(e) =>
|
||||
preferences.setGap((e.currentTarget as HTMLSelectElement).value as ReaderPageGap)}
|
||||
data-testid="reader-gap"
|
||||
>
|
||||
<option value="none">No gap</option>
|
||||
<option value="small">Small gap</option>
|
||||
<option value="medium">Medium gap</option>
|
||||
<option value="large">Large gap</option>
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<span class="indicator" data-testid="page-indicator">
|
||||
Page {index + 1} / {pages.length}
|
||||
{#if mode === 'single'}
|
||||
Page {index + 1} / {pages.length}
|
||||
{:else}
|
||||
{pages.length} {pages.length === 1 ? 'page' : 'pages'}
|
||||
{/if}
|
||||
</span>
|
||||
</nav>
|
||||
|
||||
{#if pages.length === 0}
|
||||
<p class="empty" data-testid="reader-empty">This chapter has no pages yet.</p>
|
||||
{:else}
|
||||
{:else if mode === 'single'}
|
||||
<div class="page-wrap">
|
||||
<button
|
||||
type="button"
|
||||
@@ -142,6 +216,19 @@
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="continuous" style:gap="{gapPx}px" data-testid="reader-continuous">
|
||||
{#each pages as p, i (p.id)}
|
||||
<img
|
||||
src={fileUrl(p.storage_key)}
|
||||
alt={`${manga.title} chapter ${chapter.number} page ${i + 1}`}
|
||||
class="page-image"
|
||||
loading={i < 2 ? 'eager' : 'lazy'}
|
||||
data-testid={`reader-page-${i + 1}`}
|
||||
bind:this={continuousPageEls[i]}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@@ -153,6 +240,7 @@
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: var(--space-3);
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.back {
|
||||
@@ -191,6 +279,76 @@
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mode-toggle {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.seg {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: none;
|
||||
font-size: var(--font-sm);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--transition),
|
||||
color var(--transition);
|
||||
}
|
||||
|
||||
.seg + .seg {
|
||||
border-left: 1px solid var(--border-strong);
|
||||
}
|
||||
|
||||
.seg:hover:not(.active) {
|
||||
background: var(--surface-elevated);
|
||||
}
|
||||
|
||||
.seg.active {
|
||||
background: var(--primary-soft-bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.seg:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.gap-field select {
|
||||
height: 32px;
|
||||
padding: 0 var(--space-2);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.indicator {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
@@ -208,6 +366,12 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.continuous {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-image {
|
||||
max-width: 100%;
|
||||
max-height: 90vh;
|
||||
@@ -216,6 +380,13 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
.continuous .page-image {
|
||||
/* In continuous mode the user is scrolling — let each page take
|
||||
its natural height instead of capping at viewport height, so
|
||||
there are no scroll dead-zones inside a single page. */
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -255,5 +426,8 @@
|
||||
grid-column: 1;
|
||||
justify-self: center;
|
||||
}
|
||||
.seg span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user