feat(frontend): mobile reader with tap zones + settings sheet (0.59.0)
Phase 4 of the mobile redesign: the reader gets a mobile-native interaction model — invisible tap zones for prev / next / toggle, a chapter-jump bottom sheet, a reader settings sheet (mode / gap / brightness), a fixed bottom page scrubber, a brightness overlay driven by a CSS variable, and an idle-timer auto-hide for the chrome after 3s of inactivity. Desktop keyboard shortcuts and chevrons are preserved above 640px. - New TapZone primitive: invisible 3-column grid that splits the viewport into prev / toggle / next thirds (Mihon convention). Wired to the existing `prev()` / `next()` / new `toggleChrome()` so chapter-edge behavior, page preload, and read-progress tracking all keep working. Has its own vitest coverage. - matchMedia gates every mobile addition so the same DOM never carries two copies of the chapter selector or two sets of nav controls — the desktop <select>, mode toggle, and gap field stay inline above 640px; below 640px they swap to a chapter-jump button and a settings ⋯ button hosting the Sheets. - Chapter jump Sheet lists every chapter with the current one highlighted; tapping a row navigates and dismisses. - Reader settings Sheet uses the SegmentedControl primitive for mode and (continuous-only) page gap. Brightness lives next to them as a range slider 0.3..1; its value publishes `--reader-dim` as a CSS variable that drives the always-rendered fixed dim overlay (pointer-events: none so taps fall through). Brightness is client-side only in localStorage — the Preferences table doesn't carry it and Phase 4 deferred a backend migration. - Idle auto-hide: 3s timer scoped to mobile + single mode, reuses the existing focus-mode CSS for the actual slide-off. The timer resets on every index/mode/viewport/fullscreen change via a tracked $effect. - Bottom page scrubber: a styled <input type="range"> at the viewport foot, single + multi-page only, sliding off in focus mode alongside the chrome. Honors env(safe-area-inset-bottom). - Reader joins the layout's `data-mobile-full-bleed` attribute so the hero/top reader-nav can sit at viewport top under main's cleared padding-top on mobile. - Playwright spec covers tap left/right/center, the 3s auto-hide, the chapter-jump sheet, the settings sheet swapping to continuous, the brightness slider driving `--reader-dim`, and a desktop regression for the unchanged inline controls. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mangalord"
|
||||
version = "0.58.0"
|
||||
version = "0.59.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mangalord"
|
||||
version = "0.58.0"
|
||||
version = "0.59.0"
|
||||
edition = "2021"
|
||||
default-run = "mangalord"
|
||||
|
||||
|
||||
295
frontend/e2e/mobile-reader.spec.ts
Normal file
295
frontend/e2e/mobile-reader.spec.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
// Phase 4: the reader gains a mobile chrome — invisible tap zones for
|
||||
// prev/next/toggle, a chapter-jump bottom sheet, a reader-settings
|
||||
// sheet (mode + gap + brightness), a fixed bottom page scrubber, a
|
||||
// brightness overlay driven by a CSS variable, and an idle-timer
|
||||
// auto-hide for the chrome after 3s of inactivity. Desktop chrome is
|
||||
// preserved above 640px.
|
||||
|
||||
const MOBILE = { width: 390, height: 844 } as const;
|
||||
const DESKTOP = { width: 1280, height: 720 } as const;
|
||||
|
||||
const mangaId = 'a9999999-9999-9999-9999-999999999999';
|
||||
const chapterAId = 'c9999999-9999-9999-9999-999999999999';
|
||||
const chapterBId = 'c8888888-8888-8888-8888-888888888888';
|
||||
|
||||
const mangaFixture = {
|
||||
id: mangaId,
|
||||
title: 'Berserk',
|
||||
status: 'ongoing',
|
||||
alt_titles: [],
|
||||
description: null,
|
||||
cover_image_path: `mangas/${mangaId}/cover.png`,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
authors: [{ id: 'au1', name: 'Kentaro Miura' }],
|
||||
genres: [],
|
||||
tags: []
|
||||
};
|
||||
|
||||
const chaptersFixture = [
|
||||
{
|
||||
id: chapterAId,
|
||||
manga_id: mangaId,
|
||||
number: 1,
|
||||
title: 'The Brand',
|
||||
page_count: 3,
|
||||
created_at: '2026-01-01T00:00:00Z'
|
||||
},
|
||||
{
|
||||
id: chapterBId,
|
||||
manga_id: mangaId,
|
||||
number: 2,
|
||||
title: 'Second',
|
||||
page_count: 3,
|
||||
created_at: '2026-02-01T00:00:00Z'
|
||||
}
|
||||
];
|
||||
|
||||
const pagesFixture = [
|
||||
{
|
||||
id: 'p11111111-1111-1111-1111-111111111111',
|
||||
chapter_id: chapterAId,
|
||||
page_number: 1,
|
||||
storage_key: `mangas/${mangaId}/chapters/${chapterAId}/pages/0001.png`,
|
||||
content_type: 'image/png'
|
||||
},
|
||||
{
|
||||
id: 'p22222222-1111-1111-1111-111111111111',
|
||||
chapter_id: chapterAId,
|
||||
page_number: 2,
|
||||
storage_key: `mangas/${mangaId}/chapters/${chapterAId}/pages/0002.png`,
|
||||
content_type: 'image/png'
|
||||
},
|
||||
{
|
||||
id: 'p33333333-1111-1111-1111-111111111111',
|
||||
chapter_id: chapterAId,
|
||||
page_number: 3,
|
||||
storage_key: `mangas/${mangaId}/chapters/${chapterAId}/pages/0003.png`,
|
||||
content_type: 'image/png'
|
||||
}
|
||||
];
|
||||
|
||||
async function mockReader(page: Page) {
|
||||
await page.route('**/api/v1/auth/config', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/auth/me', (route) =>
|
||||
route.fulfill({
|
||||
status: 401,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
error: { code: 'unauthenticated', message: 'unauthenticated' }
|
||||
})
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/auth/me/preferences', (route) =>
|
||||
route.fulfill({ status: 401, contentType: 'application/json', body: '{}' })
|
||||
);
|
||||
await page.route('**/api/v1/me/bookmarks*', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } })
|
||||
})
|
||||
);
|
||||
await page.route(`**/api/v1/mangas/${mangaId}`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mangaFixture)
|
||||
})
|
||||
);
|
||||
// Pages endpoint must come before the single-chapter handler
|
||||
// because Playwright matches more recently-registered routes
|
||||
// first — registering pages last keeps it on top.
|
||||
await page.route(`**/api/v1/mangas/${mangaId}/chapters`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
items: chaptersFixture,
|
||||
page: { limit: 50, offset: 0, total: chaptersFixture.length }
|
||||
})
|
||||
})
|
||||
);
|
||||
await page.route(`**/api/v1/mangas/${mangaId}/chapters\\?*`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
items: chaptersFixture,
|
||||
page: { limit: 50, offset: 0, total: chaptersFixture.length }
|
||||
})
|
||||
})
|
||||
);
|
||||
await page.route(`**/api/v1/mangas/${mangaId}/chapters/${chapterAId}`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(chaptersFixture[0])
|
||||
})
|
||||
);
|
||||
await page.route(`**/api/v1/mangas/${mangaId}/chapters/${chapterBId}`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(chaptersFixture[1])
|
||||
})
|
||||
);
|
||||
await page.route(
|
||||
`**/api/v1/mangas/${mangaId}/chapters/${chapterAId}/pages`,
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ pages: pagesFixture })
|
||||
})
|
||||
);
|
||||
await page.route(
|
||||
`**/api/v1/mangas/${mangaId}/chapters/${chapterBId}/pages`,
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ pages: pagesFixture })
|
||||
})
|
||||
);
|
||||
await page.route(`**/api/v1/me/read-progress/${mangaId}`, (route) =>
|
||||
route.fulfill({
|
||||
status: 404,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: { code: 'not_found', message: 'not found' } })
|
||||
})
|
||||
);
|
||||
const png = Buffer.from(
|
||||
'89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c63000100000005000158a3b62a0000000049454e44ae426082',
|
||||
'hex'
|
||||
);
|
||||
await page.route('**/api/v1/files/**', (route) =>
|
||||
route.fulfill({ status: 200, contentType: 'image/png', body: png })
|
||||
);
|
||||
}
|
||||
|
||||
test.describe('mobile reader', () => {
|
||||
test('phone viewport: tap right advances a page, tap left goes back', async ({
|
||||
page
|
||||
}) => {
|
||||
await mockReader(page);
|
||||
await page.setViewportSize(MOBILE);
|
||||
await page.goto(`/manga/${mangaId}/chapter/${chapterAId}`);
|
||||
|
||||
await expect(page.getByTestId('page-indicator')).toHaveText('Page 1 / 3');
|
||||
|
||||
await page.getByTestId('reader-tap-right').click();
|
||||
await expect(page.getByTestId('page-indicator')).toHaveText('Page 2 / 3');
|
||||
|
||||
await page.getByTestId('reader-tap-left').click();
|
||||
await expect(page.getByTestId('page-indicator')).toHaveText('Page 1 / 3');
|
||||
});
|
||||
|
||||
test('phone viewport: tap center toggles focus mode (chrome slides off)', async ({
|
||||
page
|
||||
}) => {
|
||||
await mockReader(page);
|
||||
await page.setViewportSize(MOBILE);
|
||||
await page.goto(`/manga/${mangaId}/chapter/${chapterAId}`);
|
||||
|
||||
const html = page.locator('html');
|
||||
await expect(html).not.toHaveAttribute('data-reader-fullscreen', 'true');
|
||||
|
||||
await page.getByTestId('reader-tap-center').click();
|
||||
await expect(html).toHaveAttribute('data-reader-fullscreen', 'true');
|
||||
|
||||
await page.getByTestId('reader-tap-center').click();
|
||||
await expect(html).not.toHaveAttribute('data-reader-fullscreen', 'true');
|
||||
});
|
||||
|
||||
test('phone viewport: chrome auto-hides after 3s of inactivity in single mode', async ({
|
||||
page
|
||||
}) => {
|
||||
await mockReader(page);
|
||||
await page.setViewportSize(MOBILE);
|
||||
await page.goto(`/manga/${mangaId}/chapter/${chapterAId}`);
|
||||
|
||||
const html = page.locator('html');
|
||||
// Default state: chrome visible.
|
||||
await expect(html).not.toHaveAttribute('data-reader-fullscreen', 'true');
|
||||
// After 3s of idle the timer flips the fullscreen flag (the
|
||||
// existing focus-mode CSS does the slide-off).
|
||||
await expect(html).toHaveAttribute('data-reader-fullscreen', 'true', {
|
||||
timeout: 6000
|
||||
});
|
||||
});
|
||||
|
||||
test('phone viewport: chapter-jump button opens the sheet and lists all chapters', async ({
|
||||
page
|
||||
}) => {
|
||||
await mockReader(page);
|
||||
await page.setViewportSize(MOBILE);
|
||||
await page.goto(`/manga/${mangaId}/chapter/${chapterAId}`);
|
||||
|
||||
await expect(page.getByTestId('chapter-jump-sheet')).toBeHidden();
|
||||
await page.getByTestId('reader-chapter-jump').click();
|
||||
|
||||
const sheet = page.getByTestId('chapter-jump-sheet');
|
||||
await expect(sheet).toBeVisible();
|
||||
await expect(sheet.getByTestId(`chapter-jump-${chapterAId}`)).toBeVisible();
|
||||
await expect(sheet.getByTestId(`chapter-jump-${chapterBId}`)).toBeVisible();
|
||||
});
|
||||
|
||||
test('phone viewport: settings sheet swaps mode to Continuous and the continuous container appears', async ({
|
||||
page
|
||||
}) => {
|
||||
await mockReader(page);
|
||||
await page.setViewportSize(MOBILE);
|
||||
await page.goto(`/manga/${mangaId}/chapter/${chapterAId}`);
|
||||
|
||||
await page.getByTestId('reader-settings-btn').click();
|
||||
await expect(page.getByTestId('reader-settings-sheet')).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByTestId('reader-settings-sheet')
|
||||
.getByRole('radio', { name: 'Continuous' })
|
||||
.click();
|
||||
|
||||
await expect(page.getByTestId('reader-continuous')).toBeVisible();
|
||||
});
|
||||
|
||||
test('phone viewport: brightness slider drives the --reader-dim CSS variable', async ({
|
||||
page
|
||||
}) => {
|
||||
await mockReader(page);
|
||||
await page.setViewportSize(MOBILE);
|
||||
await page.goto(`/manga/${mangaId}/chapter/${chapterAId}`);
|
||||
|
||||
await page.getByTestId('reader-settings-btn').click();
|
||||
|
||||
// Drive the slider via fill — Playwright's fill on a range
|
||||
// input sets value and dispatches input events.
|
||||
await page.getByTestId('settings-brightness').fill('0.5');
|
||||
|
||||
const dim = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--reader-dim').trim()
|
||||
);
|
||||
// brightness 0.5 → dim = (1 - 0.5) * 0.7 = 0.35
|
||||
expect(Number.parseFloat(dim)).toBeCloseTo(0.35, 2);
|
||||
});
|
||||
|
||||
test('desktop viewport: tap zones hidden, chapter select visible, settings button hidden', async ({
|
||||
page
|
||||
}) => {
|
||||
await mockReader(page);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await page.goto(`/manga/${mangaId}/chapter/${chapterAId}`);
|
||||
|
||||
await expect(page.getByTestId('reader-tap')).toHaveCount(0);
|
||||
await expect(page.getByTestId('reader-chapter-select')).toBeVisible();
|
||||
await expect(page.getByTestId('reader-settings-btn')).toBeHidden();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.58.0",
|
||||
"version": "0.59.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
70
frontend/src/lib/components/TapZone.svelte
Normal file
70
frontend/src/lib/components/TapZone.svelte
Normal file
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
onPrev,
|
||||
onNext,
|
||||
onToggle,
|
||||
testid = 'tap-zone'
|
||||
}: {
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
onToggle: () => void;
|
||||
testid?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="tap-zones" data-testid={testid}>
|
||||
<button
|
||||
type="button"
|
||||
class="zone left"
|
||||
onclick={onPrev}
|
||||
aria-label="Previous page"
|
||||
data-testid="{testid}-left"
|
||||
></button>
|
||||
<button
|
||||
type="button"
|
||||
class="zone center"
|
||||
onclick={onToggle}
|
||||
aria-label="Toggle controls"
|
||||
data-testid="{testid}-center"
|
||||
></button>
|
||||
<button
|
||||
type="button"
|
||||
class="zone right"
|
||||
onclick={onNext}
|
||||
aria-label="Next page"
|
||||
data-testid="{testid}-right"
|
||||
></button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Invisible viewport overlay split into thirds. Mirrors the Mihon
|
||||
/ Tachiyomi convention: left-third = prev, center-third = toggle
|
||||
chrome, right-third = next. `pointer-events: none` on the wrap
|
||||
so unrelated clicks (chrome buttons, sheets) aren't trapped;
|
||||
each zone re-enables pointer events locally. */
|
||||
.tap-zones {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 5;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.zone {
|
||||
pointer-events: auto;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
/* No visible style — these are interaction surfaces, not
|
||||
controls. Focus-visible is honored so keyboard users get
|
||||
a ring if they tab into one. */
|
||||
}
|
||||
|
||||
.zone:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: -4px;
|
||||
}
|
||||
</style>
|
||||
52
frontend/src/lib/components/TapZone.svelte.test.ts
Normal file
52
frontend/src/lib/components/TapZone.svelte.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, screen, cleanup } from '@testing-library/svelte';
|
||||
import TapZone from './TapZone.svelte';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('TapZone', () => {
|
||||
it('renders three labelled buttons (Previous / Toggle / Next)', () => {
|
||||
render(TapZone, {
|
||||
props: { onPrev: () => {}, onNext: () => {}, onToggle: () => {} }
|
||||
});
|
||||
expect(screen.getByRole('button', { name: 'Previous page' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: 'Toggle controls' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: 'Next page' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('fires onPrev when the left zone is tapped', () => {
|
||||
const onPrev = vi.fn();
|
||||
render(TapZone, { props: { onPrev, onNext: () => {}, onToggle: () => {} } });
|
||||
screen.getByRole('button', { name: 'Previous page' }).click();
|
||||
expect(onPrev).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('fires onNext when the right zone is tapped', () => {
|
||||
const onNext = vi.fn();
|
||||
render(TapZone, { props: { onPrev: () => {}, onNext, onToggle: () => {} } });
|
||||
screen.getByRole('button', { name: 'Next page' }).click();
|
||||
expect(onNext).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('fires onToggle when the center zone is tapped', () => {
|
||||
const onToggle = vi.fn();
|
||||
render(TapZone, { props: { onPrev: () => {}, onNext: () => {}, onToggle } });
|
||||
screen.getByRole('button', { name: 'Toggle controls' }).click();
|
||||
expect(onToggle).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('exposes zone-level testids derived from the wrapper testid', () => {
|
||||
render(TapZone, {
|
||||
props: {
|
||||
onPrev: () => {},
|
||||
onNext: () => {},
|
||||
onToggle: () => {},
|
||||
testid: 'reader-tap'
|
||||
}
|
||||
});
|
||||
expect(screen.getByTestId('reader-tap')).toBeTruthy();
|
||||
expect(screen.getByTestId('reader-tap-left')).toBeTruthy();
|
||||
expect(screen.getByTestId('reader-tap-center')).toBeTruthy();
|
||||
expect(screen.getByTestId('reader-tap-right')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { goto, invalidateAll } from '$app/navigation';
|
||||
import { fileUrl, ApiError } from '$lib/api/client';
|
||||
import { GAP_PX, type ReaderPageGap } from '$lib/api/preferences';
|
||||
@@ -9,6 +10,9 @@
|
||||
import { resyncChapter } from '$lib/api/admin';
|
||||
import { readerFullscreen } from '$lib/reader-fullscreen.svelte';
|
||||
import { session } from '$lib/session.svelte';
|
||||
import Sheet from '$lib/components/Sheet.svelte';
|
||||
import SegmentedControl from '$lib/components/SegmentedControl.svelte';
|
||||
import TapZone from '$lib/components/TapZone.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';
|
||||
@@ -18,6 +22,9 @@
|
||||
import Maximize2 from '@lucide/svelte/icons/maximize-2';
|
||||
import Minimize2 from '@lucide/svelte/icons/minimize-2';
|
||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||
import Settings from '@lucide/svelte/icons/settings';
|
||||
import BookOpen from '@lucide/svelte/icons/book-open';
|
||||
import Sun from '@lucide/svelte/icons/sun';
|
||||
|
||||
let { data } = $props();
|
||||
const manga = $derived(data.manga);
|
||||
@@ -71,6 +78,69 @@
|
||||
let chapterBarEl: HTMLElement | undefined = $state();
|
||||
let readerNavEl: HTMLElement | undefined = $state();
|
||||
|
||||
// ---- Mobile reader state ----
|
||||
//
|
||||
// matchMedia drives every mobile-specific behavior (tap zones, idle
|
||||
// auto-hide, sheets vs. inline controls). Without it we'd render
|
||||
// the desktop nav AND the mobile bottom sheet hosts at the same
|
||||
// time and double up testids / focus traps. SSR default is false.
|
||||
let isMobileViewport = $state(false);
|
||||
let chapterJumpOpen = $state(false);
|
||||
let settingsOpen = $state(false);
|
||||
|
||||
// Brightness overlay — 1.0 is no dimming, 0.3 is maximum (70% black
|
||||
// overlay opacity). Stored in localStorage only; the Preferences
|
||||
// table doesn't carry this field and Phase 4 explicitly opted to
|
||||
// keep it client-side rather than ship a backend migration. Init
|
||||
// is deferred to a $effect so SSR doesn't touch localStorage.
|
||||
const BRIGHTNESS_KEY = 'mangalord-reader-brightness';
|
||||
let brightness = $state(1);
|
||||
|
||||
// Opt into the layout's edge-to-edge mode so the reader chrome
|
||||
// (which is `position: fixed` and pins itself to the viewport top)
|
||||
// doesn't sit under main's mobile padding reservation. Cleaned up
|
||||
// on navigation away.
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
document.documentElement.dataset.mobileFullBleed = 'true';
|
||||
return () => {
|
||||
delete document.documentElement.dataset.mobileFullBleed;
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
const mql = window.matchMedia('(max-width: 640px)');
|
||||
isMobileViewport = mql.matches;
|
||||
const onChange = (e: MediaQueryListEvent) => {
|
||||
isMobileViewport = e.matches;
|
||||
};
|
||||
mql.addEventListener('change', onChange);
|
||||
return () => mql.removeEventListener('change', onChange);
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const raw = localStorage.getItem(BRIGHTNESS_KEY);
|
||||
const v = raw == null ? NaN : Number.parseFloat(raw);
|
||||
if (Number.isFinite(v) && v >= 0.3 && v <= 1) brightness = v;
|
||||
});
|
||||
|
||||
// Publish the dim level as a CSS variable on <html>. (1 - brightness)
|
||||
// gives 0..0.7 alpha for the fixed overlay rendered at the bottom
|
||||
// of the template. Persisting on every change keeps the
|
||||
// localStorage shadow in sync with what the user sees.
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
const dim = (1 - brightness) * 0.7;
|
||||
document.documentElement.style.setProperty('--reader-dim', String(dim));
|
||||
localStorage.setItem(BRIGHTNESS_KEY, String(brightness));
|
||||
return () => {
|
||||
// On unmount (leaving the reader), clear the dim so other
|
||||
// routes don't inherit a darkened overlay.
|
||||
document.documentElement.style.removeProperty('--reader-dim');
|
||||
};
|
||||
});
|
||||
|
||||
// Publish the reader nav's actual measured height. Sticky
|
||||
// positioning had a "settle on scroll" effect: the bar's natural
|
||||
// position sat 16px below the app header (main's space-4 padding),
|
||||
@@ -121,6 +191,49 @@
|
||||
};
|
||||
});
|
||||
|
||||
// ---- Mobile chrome auto-hide ----
|
||||
//
|
||||
// After 3s of inactivity on mobile + single mode, slide the reader
|
||||
// chrome off (existing focus-mode CSS does the actual transform).
|
||||
// Every index change, mode change, viewport change, or fullscreen
|
||||
// toggle restarts the countdown via the $effect below.
|
||||
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function clearIdleTimer() {
|
||||
if (idleTimer) {
|
||||
clearTimeout(idleTimer);
|
||||
idleTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startIdleTimer() {
|
||||
clearIdleTimer();
|
||||
if (!isMobileViewport) return;
|
||||
if (mode !== 'single') return;
|
||||
if (readerFullscreen.value) return; // already hidden
|
||||
idleTimer = setTimeout(() => {
|
||||
readerFullscreen.value = true;
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function toggleChrome() {
|
||||
readerFullscreen.value = !readerFullscreen.value;
|
||||
// If we just revealed the chrome, restart the countdown — the
|
||||
// user just tapped, but next 3s of stillness re-hides.
|
||||
if (!readerFullscreen.value) startIdleTimer();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
// Track the inputs that should restart the idle countdown.
|
||||
// Reading them here creates the reactive dependencies.
|
||||
void index;
|
||||
void mode;
|
||||
void isMobileViewport;
|
||||
void readerFullscreen.value;
|
||||
startIdleTimer();
|
||||
return clearIdleTimer;
|
||||
});
|
||||
|
||||
// ---- Navigation ----
|
||||
//
|
||||
// In single mode: page-by-page within the chapter, falling through
|
||||
@@ -457,7 +570,7 @@
|
||||
</a>
|
||||
|
||||
<div class="controls" role="group" aria-label="reader options">
|
||||
<label class="chapter-field">
|
||||
<label class="chapter-field desktop-control">
|
||||
<span class="visually-hidden">Jump to chapter</span>
|
||||
<select
|
||||
class="chapter-select"
|
||||
@@ -478,7 +591,18 @@
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="mode-toggle" role="radiogroup" aria-label="layout">
|
||||
<button
|
||||
type="button"
|
||||
class="chapter-jump-btn mobile-control"
|
||||
onclick={() => (chapterJumpOpen = true)}
|
||||
aria-label="Jump to chapter"
|
||||
data-testid="reader-chapter-jump"
|
||||
>
|
||||
<BookOpen size={16} aria-hidden="true" />
|
||||
<span class="chapter-jump-label">{chapterLabel(chapter)}</span>
|
||||
</button>
|
||||
|
||||
<div class="mode-toggle desktop-control" role="radiogroup" aria-label="layout">
|
||||
<button
|
||||
type="button"
|
||||
class="seg"
|
||||
@@ -506,7 +630,7 @@
|
||||
</div>
|
||||
|
||||
{#if mode === 'continuous'}
|
||||
<label class="gap-field">
|
||||
<label class="gap-field desktop-control">
|
||||
<span class="visually-hidden">Page gap</span>
|
||||
<select
|
||||
value={preferences.readerPageGap}
|
||||
@@ -522,6 +646,16 @@
|
||||
</label>
|
||||
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="reader-settings-btn mobile-control"
|
||||
onclick={() => (settingsOpen = true)}
|
||||
aria-label="Reader settings"
|
||||
data-testid="reader-settings-btn"
|
||||
>
|
||||
<Settings size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span class="indicator" data-testid="page-indicator">
|
||||
@@ -702,6 +836,124 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Brightness dim overlay. Always rendered — opacity is driven by the
|
||||
CSS var the script publishes, so the cost of leaving it in the
|
||||
DOM is one zero-opacity div when brightness is at max. -->
|
||||
<div class="brightness-overlay" aria-hidden="true"></div>
|
||||
|
||||
<!-- Tap zones — mobile + single mode only. Continuous mode owns native
|
||||
scroll so left/right would steal panning. Tap left/right advances
|
||||
within the chapter (falling through to adjacent chapters at the
|
||||
boundaries via the existing prev/next helpers); tap center toggles
|
||||
the focus-mode chrome and restarts the idle timer. -->
|
||||
{#if isMobileViewport && mode === 'single' && pages.length > 0}
|
||||
<TapZone onPrev={prev} onNext={next} onToggle={toggleChrome} testid="reader-tap" />
|
||||
{/if}
|
||||
|
||||
<!-- Bottom scrubber — mobile + single + multi-page only. Lives above
|
||||
the home-indicator inset and slides off with the rest of the
|
||||
chrome when focus mode hides everything. -->
|
||||
{#if isMobileViewport && mode === 'single' && pages.length > 1}
|
||||
<div class="mobile-scrubber" data-testid="reader-scrubber-bar">
|
||||
<input
|
||||
type="range"
|
||||
class="scrubber"
|
||||
min="0"
|
||||
max={pages.length - 1}
|
||||
value={index}
|
||||
oninput={(e) => {
|
||||
index = Number((e.currentTarget as HTMLInputElement).value);
|
||||
}}
|
||||
aria-label="Page"
|
||||
data-testid="reader-scrubber"
|
||||
/>
|
||||
<span class="scrubber-count">{index + 1} / {pages.length}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Sheet
|
||||
open={chapterJumpOpen}
|
||||
title="Jump to chapter"
|
||||
onClose={() => (chapterJumpOpen = false)}
|
||||
testid="chapter-jump-sheet"
|
||||
>
|
||||
<ul class="chapter-jump-list">
|
||||
{#each chapters as c (c.id)}
|
||||
<li>
|
||||
<a
|
||||
href="/manga/{manga.id}/chapter/{c.id}"
|
||||
class="chapter-jump-item"
|
||||
class:current={c.id === chapter.id}
|
||||
onclick={() => (chapterJumpOpen = false)}
|
||||
data-testid="chapter-jump-{c.id}"
|
||||
>
|
||||
{chapterLabel(c)}
|
||||
{#if c.id === chapter.id}<span class="chapter-jump-current-mark">· current</span>{/if}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</Sheet>
|
||||
|
||||
<Sheet
|
||||
open={settingsOpen}
|
||||
title="Reader settings"
|
||||
onClose={() => (settingsOpen = false)}
|
||||
testid="reader-settings-sheet"
|
||||
>
|
||||
<div class="settings-group">
|
||||
<span class="settings-label">Mode</span>
|
||||
<SegmentedControl
|
||||
ariaLabel="Reader mode"
|
||||
value={mode}
|
||||
options={[
|
||||
{ label: 'Single', value: 'single' as const },
|
||||
{ label: 'Continuous', value: 'continuous' as const }
|
||||
]}
|
||||
onchange={(v) => preferences.setMode(v)}
|
||||
testid="settings-mode"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if mode === 'continuous'}
|
||||
<div class="settings-group">
|
||||
<span class="settings-label">Page gap</span>
|
||||
<SegmentedControl
|
||||
ariaLabel="Page gap"
|
||||
value={preferences.readerPageGap}
|
||||
options={[
|
||||
{ label: 'None', value: 'none' as const },
|
||||
{ label: 'Small', value: 'small' as const },
|
||||
{ label: 'Medium', value: 'medium' as const },
|
||||
{ label: 'Large', value: 'large' as const }
|
||||
]}
|
||||
onchange={(v) => preferences.setGap(v)}
|
||||
testid="settings-gap"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="settings-group">
|
||||
<span class="settings-label">
|
||||
<Sun size={16} aria-hidden="true" />
|
||||
Brightness
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
class="brightness-slider"
|
||||
min="0.3"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={brightness}
|
||||
oninput={(e) => {
|
||||
brightness = Number((e.currentTarget as HTMLInputElement).value);
|
||||
}}
|
||||
aria-label="Brightness"
|
||||
data-testid="settings-brightness"
|
||||
/>
|
||||
</div>
|
||||
</Sheet>
|
||||
|
||||
<style>
|
||||
/* Pinned to the viewport directly below the (also fixed) layout
|
||||
header. `position: fixed` rather than `sticky` because the
|
||||
@@ -1106,15 +1358,205 @@
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.page-wrap {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.nav {
|
||||
grid-column: 1;
|
||||
justify-self: center;
|
||||
}
|
||||
.seg span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Phase 4 — Mobile reader chrome ===== */
|
||||
|
||||
.mobile-control {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.brightness-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: #000;
|
||||
opacity: var(--reader-dim, 0);
|
||||
pointer-events: none;
|
||||
z-index: 4;
|
||||
transition: opacity var(--transition);
|
||||
}
|
||||
|
||||
.chapter-jump-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.chapter-jump-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-3) var(--space-1);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
min-height: 48px;
|
||||
font-size: var(--font-base);
|
||||
}
|
||||
|
||||
.chapter-jump-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.chapter-jump-item:hover {
|
||||
background: var(--surface-elevated);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.chapter-jump-item.current {
|
||||
color: var(--primary);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
|
||||
.chapter-jump-current-mark {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-regular);
|
||||
}
|
||||
|
||||
.settings-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.settings-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.settings-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
|
||||
.brightness-slider {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
/* Reader-nav sits at the viewport top — the global mobile
|
||||
chrome is hidden on this route so there's no app bar above
|
||||
it. Top reservation comes from main being padding-top: 0
|
||||
via data-mobile-full-bleed. */
|
||||
.reader-nav {
|
||||
top: 0;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
gap: var(--space-2);
|
||||
padding-top: calc(var(--space-2) + var(--safe-top));
|
||||
}
|
||||
|
||||
/* The desktop chapter <select>, mode toggle, and gap selector
|
||||
move into the chapter-jump and reader-settings sheets — the
|
||||
native controls would be cramped at this width. */
|
||||
.desktop-control {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-control {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.chapter-jump-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
max-width: 60vw;
|
||||
height: 36px;
|
||||
padding: 0 var(--space-3);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chapter-jump-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.reader-settings-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Single-mode chevrons step aside on mobile — the TapZone
|
||||
overlay carries prev/next/toggle. Keeping the buttons would
|
||||
double up the click surface and clutter the screen. */
|
||||
.page-wrap .nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.page-wrap {
|
||||
display: block;
|
||||
padding: 0;
|
||||
padding-top: var(--reader-nav-h);
|
||||
}
|
||||
|
||||
.page-image {
|
||||
/* Drop the desktop 90vh cap on mobile so each page fills
|
||||
the available height naturally; the scrubber + chrome
|
||||
eat their own room. */
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
/* Scrubber bar: fixed bottom, mirrors the desktop chapter-bar's
|
||||
slide-off behavior in focus mode. */
|
||||
.mobile-scrubber {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: var(--z-sticky);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
padding-bottom: calc(var(--space-2) + var(--safe-bottom));
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--border);
|
||||
transform: translateY(0);
|
||||
transition: transform 220ms ease-out;
|
||||
}
|
||||
|
||||
:global(html[data-reader-fullscreen='true']) .mobile-scrubber {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
.scrubber {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.scrubber-count {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-sm);
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user