Files
Mangalord/frontend/src/routes/settings/+page.svelte
MechaCat02 60cc7712fa 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>
2026-05-17 13:15:03 +02:00

355 lines
11 KiB
Svelte

<script lang="ts">
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);
}
let currentPassword = $state('');
let newPassword = $state('');
let confirmPassword = $state('');
let submitting = $state(false);
let success = $state<string | null>(null);
let error: string | null = $state(null);
const passwordsMatch = $derived(
newPassword.length > 0 && newPassword === confirmPassword
);
const canSubmit = $derived(
Boolean(session.user) &&
currentPassword.length > 0 &&
newPassword.length >= 8 &&
passwordsMatch &&
!submitting
);
async function submit(e: SubmitEvent) {
e.preventDefault();
if (!canSubmit) return;
submitting = true;
success = null;
error = null;
try {
await changePassword({
current_password: currentPassword,
new_password: newPassword
});
success = 'Password updated. Other devices have been signed out.';
currentPassword = '';
newPassword = '';
confirmPassword = '';
} catch (e) {
if (e instanceof ApiError && e.status === 401 && !session.user) {
// The CurrentUser extractor rejected us — session must
// have been wiped externally. Bounce to login.
await goto('/login');
return;
}
error = e instanceof Error ? e.message : String(e);
} finally {
submitting = false;
}
}
</script>
<svelte:head>
<title>Settings — Mangalord</title>
</svelte:head>
<h1>Settings</h1>
<section class="card" aria-label="Appearance">
<h2>Appearance</h2>
<fieldset class="theme-picker">
<legend>Theme</legend>
<label class="theme-option" class:selected={theme.value === 'system'}>
<input
type="radio"
name="theme"
value="system"
checked={theme.value === 'system'}
onchange={() => setTheme('system')}
data-testid="theme-radio-system"
/>
<Monitor size={18} aria-hidden="true" />
<span>System</span>
</label>
<label class="theme-option" class:selected={theme.value === 'light'}>
<input
type="radio"
name="theme"
value="light"
checked={theme.value === 'light'}
onchange={() => setTheme('light')}
data-testid="theme-radio-light"
/>
<Sun size={18} aria-hidden="true" />
<span>Light</span>
</label>
<label class="theme-option" class:selected={theme.value === 'dark'}>
<input
type="radio"
name="theme"
value="dark"
checked={theme.value === 'dark'}
onchange={() => setTheme('dark')}
data-testid="theme-radio-dark"
/>
<Moon size={18} aria-hidden="true" />
<span>Dark</span>
</label>
</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}
<p class="status" data-testid="settings-signin">
<a href="/login">Sign in</a> to change your password.
</p>
{:else}
<section class="card">
<h2>Change password</h2>
<p class="hint">
Changing your password signs out every other device using this account.
Bot API tokens keep working — revoke them individually from the bot-token
list if you want to invalidate them too.
</p>
<form onsubmit={submit} action="javascript:void(0)" data-testid="password-form">
<label class="form-field">
<span>Current password</span>
<input
type="password"
bind:value={currentPassword}
autocomplete="current-password"
required
data-testid="current-password"
/>
</label>
<label class="form-field">
<span>New password</span>
<input
type="password"
bind:value={newPassword}
autocomplete="new-password"
minlength="8"
required
data-testid="new-password"
/>
</label>
<label class="form-field">
<span>Confirm new password</span>
<input
type="password"
bind:value={confirmPassword}
autocomplete="new-password"
minlength="8"
required
data-testid="confirm-password"
/>
{#if confirmPassword.length > 0 && !passwordsMatch}
<span class="field-error" role="alert" data-testid="mismatch">
Passwords don't match.
</span>
{/if}
</label>
<button
class="primary"
type="submit"
disabled={!canSubmit}
data-testid="password-submit"
>
{submitting ? 'Updating…' : 'Update password'}
</button>
{#if success}
<p class="success" data-testid="password-success">{success}</p>
{/if}
{#if error}
<p role="alert" class="form-error" data-testid="password-error">{error}</p>
{/if}
</form>
</section>
{/if}
<style>
.status {
color: var(--text-muted);
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-4);
max-width: 32rem;
margin-bottom: var(--space-4);
}
.hint {
color: var(--text-muted);
font-size: var(--font-sm);
}
form {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.primary {
background: var(--primary);
color: var(--primary-contrast);
border-color: var(--primary);
margin-top: var(--space-1);
align-self: flex-start;
}
.primary:hover:not(:disabled) {
background: var(--primary-hover);
border-color: var(--primary-hover);
}
.field-error {
color: var(--danger);
font-size: var(--font-sm);
}
.form-error {
color: var(--danger);
}
.success {
color: var(--success);
}
.theme-picker {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
border: none;
padding: 0;
margin: 0;
}
.gap-picker {
margin-top: var(--space-3);
}
.theme-picker legend {
font-size: var(--font-sm);
color: var(--text);
padding: 0;
margin-bottom: var(--space-2);
}
.theme-option {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
background: var(--surface);
color: var(--text);
font-size: var(--font-sm);
cursor: pointer;
transition:
background var(--transition),
border-color var(--transition);
}
.theme-option:hover {
background: var(--surface-elevated);
}
.theme-option input[type='radio'] {
position: absolute;
opacity: 0;
pointer-events: none;
width: 0;
height: 0;
}
.theme-option.selected {
background: var(--primary-soft-bg);
border-color: var(--primary);
color: var(--text);
}
.theme-option:has(input:focus-visible) {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
</style>