feat: streaming files endpoint + reader pages + chapter pages metadata
Backend:
- Migration 0003_pages.sql adds a `pages` table (id, chapter_id,
page_number, storage_key, content_type) with a unique (chapter_id,
page_number). New table because chapter pages can have different MIME
types per page; reconstructing keys from a single template would
break the moment a chapter mixes png and jpg pages.
- `domain::Page` + `repo::page` (create + list_for_chapter).
- The chapter upload handler now inserts one page row per part as it
writes the bytes to storage.
- GET /api/v1/mangas/{id}/chapters/{n}/pages returns `{pages: [...]}`
with the storage_key clients need to construct image URLs. 404 if
the manga or chapter doesn't exist; reads are public.
Storage trait grows `get_stream(&str) -> StreamingFile` returning a
`Pin<Box<dyn Stream<Item = io::Result<Bytes>> + Send>>` + size. The
local backend implements via `tokio::fs::File` + `tokio_util::io::
ReaderStream` with a 64 KiB chunk size. GET /api/v1/files/*key now
streams via `axum::body::Body::from_stream` instead of buffering — the
test asserts a 200 KiB file emits >1 frame end-to-end through the
router.
Frontend:
- lib/api/client.ts gains `fileUrl(key)` so components don't
reconstruct the `/api/v1/files/...` path manually.
- lib/api/chapters.ts gains `ChapterPage` type + `getChapterPages` (the
type is named ChapterPage to avoid colliding with `Page` from
client.ts, which is the pagination envelope).
- /manga/[id]/+page.svelte: overview with cover, title, author,
description, chapter list, and a disabled bookmark control (real
bookmarking lands in feat/bookmarks). Responsive at 640 px.
- /manga/[id]/chapter/[n]/+page.svelte: paginated reader. Current page
loads eagerly; next page is preloaded in a hidden img so navigation
feels instant. Keyboard handler maps ArrowRight/j/Space → next,
ArrowLeft/k → prev, Home/End → first/last; skips when the user is
typing in an input. Focus ring on the prev/next buttons.
- SSR is disabled on both routes via `export const ssr = false` so the
client-only fetch flow doesn't need to be replicated server-side; the
routes are interactive features, not SEO surfaces.
- E2E (e2e/reader.spec.ts): overview shows the title/cover/chapter
list; reader pages through three pages via ArrowRight, j, k, and
ArrowLeft, and the preload img holds the page-2 src on initial load.
Lockstep version bump to 0.6.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
153
frontend/e2e/reader.spec.ts
Normal file
153
frontend/e2e/reader.spec.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
const mangaId = '11111111-1111-1111-1111-111111111111';
|
||||
const mangaFixture = {
|
||||
id: mangaId,
|
||||
title: 'Berserk',
|
||||
author: 'Kentaro Miura',
|
||||
description: 'A dark fantasy.',
|
||||
cover_image_path: 'mangas/11111111-1111-1111-1111-111111111111/cover.png',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z'
|
||||
};
|
||||
const chaptersFixture = [
|
||||
{
|
||||
id: 'c1',
|
||||
manga_id: mangaId,
|
||||
number: 1,
|
||||
title: 'The Brand',
|
||||
page_count: 3,
|
||||
created_at: '2026-01-01T00:00:00Z'
|
||||
}
|
||||
];
|
||||
const pagesFixture = [
|
||||
{
|
||||
id: 'p1',
|
||||
chapter_id: 'c1',
|
||||
page_number: 1,
|
||||
storage_key: 'mangas/m1/chapters/c1/pages/0001.png',
|
||||
content_type: 'image/png'
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
chapter_id: 'c1',
|
||||
page_number: 2,
|
||||
storage_key: 'mangas/m1/chapters/c1/pages/0002.png',
|
||||
content_type: 'image/png'
|
||||
},
|
||||
{
|
||||
id: 'p3',
|
||||
chapter_id: 'c1',
|
||||
page_number: 3,
|
||||
storage_key: 'mangas/m1/chapters/c1/pages/0003.png',
|
||||
content_type: 'image/png'
|
||||
}
|
||||
];
|
||||
|
||||
async function mockReaderApis(page: Page) {
|
||||
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/mangas/${mangaId}`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mangaFixture)
|
||||
})
|
||||
);
|
||||
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: null }
|
||||
})
|
||||
})
|
||||
);
|
||||
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: null }
|
||||
})
|
||||
})
|
||||
);
|
||||
await page.route(`**/api/v1/mangas/${mangaId}/chapters/1`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(chaptersFixture[0])
|
||||
})
|
||||
);
|
||||
await page.route(`**/api/v1/mangas/${mangaId}/chapters/1/pages`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ pages: pagesFixture })
|
||||
})
|
||||
);
|
||||
// Stub image bytes so the <img> doesn't 404 (1x1 transparent PNG).
|
||||
const png = Buffer.from(
|
||||
'89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c63000100000005000158a3b62a0000000049454e44ae426082',
|
||||
'hex'
|
||||
);
|
||||
await page.route('**/api/v1/files/**', (route) =>
|
||||
route.fulfill({ status: 200, contentType: 'image/png', body: png })
|
||||
);
|
||||
}
|
||||
|
||||
test('manga overview shows title, cover, and a chapter list', async ({ page }) => {
|
||||
await mockReaderApis(page);
|
||||
await page.goto(`/manga/${mangaId}`);
|
||||
|
||||
await expect(page.getByTestId('manga-title')).toHaveText('Berserk');
|
||||
await expect(page.getByTestId('manga-author')).toContainText('Kentaro Miura');
|
||||
await expect(page.getByTestId('manga-cover')).toBeVisible();
|
||||
await expect(page.getByTestId('chapter-list')).toContainText('Chapter 1');
|
||||
await expect(page.getByTestId('bookmark-placeholder')).toBeDisabled();
|
||||
});
|
||||
|
||||
test('reader paginates with arrow keys and j/k, and preloads the next page', async ({ page }) => {
|
||||
await mockReaderApis(page);
|
||||
await page.goto(`/manga/${mangaId}/chapter/1`);
|
||||
|
||||
// Page 1 shown, preload for page 2 in the DOM.
|
||||
await expect(page.getByTestId('page-indicator')).toHaveText('Page 1 / 3');
|
||||
await expect(page.getByTestId('reader-page')).toHaveAttribute(
|
||||
'src',
|
||||
/0001\.png$/
|
||||
);
|
||||
await expect(page.getByTestId('reader-preload')).toHaveAttribute(
|
||||
'src',
|
||||
/0002\.png$/
|
||||
);
|
||||
|
||||
// ArrowRight → page 2.
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(page.getByTestId('page-indicator')).toHaveText('Page 2 / 3');
|
||||
await expect(page.getByTestId('reader-page')).toHaveAttribute(
|
||||
'src',
|
||||
/0002\.png$/
|
||||
);
|
||||
|
||||
// j → page 3 (last).
|
||||
await page.keyboard.press('j');
|
||||
await expect(page.getByTestId('page-indicator')).toHaveText('Page 3 / 3');
|
||||
await expect(page.getByTestId('reader-next')).toBeDisabled();
|
||||
|
||||
// k → page 2.
|
||||
await page.keyboard.press('k');
|
||||
await expect(page.getByTestId('page-indicator')).toHaveText('Page 2 / 3');
|
||||
|
||||
// ArrowLeft → page 1.
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await expect(page.getByTestId('page-indicator')).toHaveText('Page 1 / 3');
|
||||
await expect(page.getByTestId('reader-prev')).toBeDisabled();
|
||||
});
|
||||
Reference in New Issue
Block a user