diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 83252c0..09b01fa 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.58.0" +version = "0.59.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 24a0273..83607b3 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.58.0" +version = "0.59.0" edition = "2021" default-run = "mangalord" diff --git a/frontend/e2e/mobile-reader.spec.ts b/frontend/e2e/mobile-reader.spec.ts new file mode 100644 index 0000000..a76e734 --- /dev/null +++ b/frontend/e2e/mobile-reader.spec.ts @@ -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(); + }); +}); diff --git a/frontend/package.json b/frontend/package.json index 32b9388..432228f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.58.0", + "version": "0.59.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/components/TapZone.svelte b/frontend/src/lib/components/TapZone.svelte new file mode 100644 index 0000000..f7e34a0 --- /dev/null +++ b/frontend/src/lib/components/TapZone.svelte @@ -0,0 +1,70 @@ + + +