diff --git a/backend/Cargo.lock b/backend/Cargo.lock index aeac6db..83252c0 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.57.0" +version = "0.58.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index cac1ade..24a0273 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.57.0" +version = "0.58.0" edition = "2021" default-run = "mangalord" diff --git a/frontend/e2e/mobile-manga-detail.spec.ts b/frontend/e2e/mobile-manga-detail.spec.ts new file mode 100644 index 0000000..67e4953 --- /dev/null +++ b/frontend/e2e/mobile-manga-detail.spec.ts @@ -0,0 +1,244 @@ +import { test, expect, type Page } from '@playwright/test'; + +// Phase 3: the manga detail page gains a mobile hero (blurred backdrop +// + transparent app bar), a sticky bottom CTA whose wording reflects +// read progress, a 3-line description clamp with Read more, and an +// overflow Sheet that hosts secondary actions (Edit / Upload chapter / +// Add to collection / Force resync). Desktop layout is preserved. + +const MOBILE = { width: 390, height: 844 } as const; +const DESKTOP = { width: 1280, height: 720 } as const; + +const mangaId = 'a1111111-1111-1111-1111-111111111111'; +const firstChapterId = 'c1111111-1111-1111-1111-111111111111'; +const latestChapterId = 'c2222222-2222-2222-2222-222222222222'; + +// Chapters arrive newest-first from the API (source_index DESC) — the +// detail page's "Read first chapter" CTA targets the last element. +const chaptersFixture = [ + { + id: latestChapterId, + manga_id: mangaId, + number: 2, + title: 'Second', + page_count: 10, + created_at: '2026-02-01T00:00:00Z' + }, + { + id: firstChapterId, + manga_id: mangaId, + number: 1, + title: 'The Brand', + page_count: 8, + created_at: '2026-01-01T00:00:00Z' + } +]; + +const shortDescription = 'A brief synopsis.'; +const longDescription = + 'This is an extended description that runs over multiple lines so that ' + + 'the clamp + Read more behavior on mobile has actual content to truncate. ' + + 'Long-form descriptions are common for manga that have been crawled from ' + + 'public sources with editorial blurbs, so the catalog has to handle them ' + + 'gracefully without pushing the chapter list below the fold on phones.'; + +function mangaFixture(description: string = shortDescription) { + return { + id: mangaId, + title: 'Berserk', + status: 'ongoing', + alt_titles: [], + description, + 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: [] + }; +} + +async function mockDetail( + page: Page, + opts: { + description?: string; + readProgress?: { + chapter_id: string; + chapter_number: number; + page: number; + } | null; + authed?: boolean; + } = {} +) { + const authed = opts.authed ?? false; + + 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: authed ? 200 : 401, + contentType: 'application/json', + body: authed + ? JSON.stringify({ + user: { + id: 'u1', + username: 'reader', + created_at: '2026-01-01T00:00:00Z', + is_admin: false + } + }) + : JSON.stringify({ + error: { code: 'unauthenticated', message: 'unauthenticated' } + }) + }) + ); + await page.route('**/api/v1/auth/me/preferences', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({}) + }) + ); + 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(opts.description)) + }) + ); + 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/me/read-progress/${mangaId}`, (route) => + route.fulfill({ + status: opts.readProgress ? 200 : 404, + contentType: 'application/json', + body: opts.readProgress + ? JSON.stringify(opts.readProgress) + : JSON.stringify({ error: { code: 'not_found', message: 'not found' } }) + }) + ); + // 1x1 transparent PNG so cover image requests don't 404 noisily. + 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 manga detail', () => { + test('phone viewport: with no progress, CTA reads "Read first chapter" and links to the oldest chapter', async ({ + page + }) => { + await mockDetail(page); + await page.setViewportSize(MOBILE); + await page.goto(`/manga/${mangaId}`); + + await expect(page.getByTestId('mobile-hero')).toBeVisible(); + + const cta = page.getByTestId('continue-cta'); + await expect(cta).toHaveText('Read first chapter'); + await expect(cta).toHaveAttribute( + 'href', + `/manga/${mangaId}/chapter/${firstChapterId}` + ); + }); + + test('phone viewport: with progress, CTA reads "Continue {chapterLabel}" and links to the in-progress chapter', async ({ + page + }) => { + await mockDetail(page, { + authed: true, + readProgress: { chapter_id: latestChapterId, chapter_number: 2, page: 5 } + }); + await page.setViewportSize(MOBILE); + await page.goto(`/manga/${mangaId}`); + + const cta = page.getByTestId('continue-cta'); + // chapterLabel returns the title when present, falling back to + // "Chapter N" only when empty — the fixture has title "Second". + await expect(cta).toHaveText(/Continue\s+Second/); + await expect(cta).toHaveAttribute( + 'href', + `/manga/${mangaId}/chapter/${latestChapterId}` + ); + }); + + test('phone viewport: short description shows no Read more toggle', async ({ page }) => { + await mockDetail(page, { description: shortDescription }); + await page.setViewportSize(MOBILE); + await page.goto(`/manga/${mangaId}`); + + await expect(page.getByTestId('manga-description')).toBeVisible(); + await expect(page.getByTestId('read-more-toggle')).toHaveCount(0); + }); + + test('phone viewport: long description shows Read more, expands on tap, collapses on second tap', async ({ + page + }) => { + await mockDetail(page, { description: longDescription }); + await page.setViewportSize(MOBILE); + await page.goto(`/manga/${mangaId}`); + + const toggle = page.getByTestId('read-more-toggle'); + await expect(toggle).toHaveText('Read more'); + + await toggle.click(); + await expect(toggle).toHaveText('Read less'); + + await toggle.click(); + await expect(toggle).toHaveText('Read more'); + }); + + test('phone viewport: overflow ⋯ opens the actions sheet with Edit / Upload / Add-to-collection rows', async ({ + page + }) => { + await mockDetail(page, { authed: true }); + await page.setViewportSize(MOBILE); + await page.goto(`/manga/${mangaId}`); + + await expect(page.getByTestId('detail-overflow-sheet')).toBeHidden(); + await page.getByTestId('detail-overflow').click(); + + const sheet = page.getByTestId('detail-overflow-sheet'); + await expect(sheet).toBeVisible(); + await expect(sheet.getByTestId('overflow-edit')).toBeVisible(); + await expect(sheet.getByTestId('overflow-upload-chapter')).toBeVisible(); + await expect(sheet.getByTestId('overflow-add-to-collection')).toBeVisible(); + }); + + test('desktop viewport: mobile hero is hidden, existing layout + action-row stays', async ({ + page + }) => { + await mockDetail(page, { authed: true }); + await page.setViewportSize(DESKTOP); + await page.goto(`/manga/${mangaId}`); + + // Hero block exists in DOM but is hidden by CSS at >640px. + await expect(page.getByTestId('mobile-hero')).toBeHidden(); + // The desktop title/cover/action surfaces remain visible. + await expect(page.getByTestId('manga-title')).toBeVisible(); + await expect(page.getByTestId('bookmark-toggle')).toBeVisible(); + }); +}); diff --git a/frontend/package.json b/frontend/package.json index aa4ed38..32b9388 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.57.0", + "version": "0.58.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 4508652..57de468 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -87,9 +87,13 @@ // is unaffected; it continues to follow the reader fullscreen slide // transform. const HIDE_MOBILE_CHROME_PATHS = new Set(['/login', '/register']); + const HIDE_MOBILE_CHROME_ROUTES = new Set([ + '/manga/[id]', + '/manga/[id]/chapter/[chapter_id]' + ]); const showMobileChrome = $derived.by(() => { if (HIDE_MOBILE_CHROME_PATHS.has($page.url.pathname)) return false; - if ($page.route?.id === '/manga/[id]/chapter/[chapter_id]') return false; + if (HIDE_MOBILE_CHROME_ROUTES.has($page.route?.id ?? '')) return false; return true; }); @@ -381,6 +385,18 @@ padding-top: 0; } + @media (max-width: 640px) { + :global(html[data-mobile-full-bleed='true']) main { + /* Mobile detail page surrenders the layout's top reservation + so its hero can sit at the viewport top under a transparent + page-specific app bar. Horizontal and bottom padding are + preserved so the body content (description, chapter list) + keeps its gutters; the hero negates the horizontal padding + itself via negative margins. */ + padding-top: 0; + } + } + /* Mobile chrome (AppBar + BottomNav) is rendered always when the route opts in, but CSS-hides above the breakpoint so the desktop header is the only visible top-chrome. The ResizeObserver in the diff --git a/frontend/src/routes/manga/[id]/+page.svelte b/frontend/src/routes/manga/[id]/+page.svelte index 94b6bff..a444ec1 100644 --- a/frontend/src/routes/manga/[id]/+page.svelte +++ b/frontend/src/routes/manga/[id]/+page.svelte @@ -1,4 +1,6 @@