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:
MechaCat02
2026-05-16 22:32:08 +02:00
parent a92f6f70e2
commit 9af070608b
22 changed files with 827 additions and 17 deletions

153
frontend/e2e/reader.spec.ts Normal file
View 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();
});

View File

@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
"version": "0.5.0",
"version": "0.6.0",
"private": true,
"type": "module",
"scripts": {

View File

@@ -7,7 +7,7 @@ import {
afterEach,
type MockInstance
} from 'vitest';
import { listChapters, getChapter } from './chapters';
import { listChapters, getChapter, getChapterPages } from './chapters';
function ok(body: unknown): Response {
return new Response(JSON.stringify(body), {
@@ -86,4 +86,25 @@ describe('chapters api client', () => {
code: 'not_found'
});
});
it('getChapterPages unwraps the {pages} envelope into the array', async () => {
fetchSpy.mockResolvedValueOnce(
ok({
pages: [
{
id: 'p1',
chapter_id: 'c1',
page_number: 1,
storage_key: 'mangas/m1/chapters/c1/pages/0001.png',
content_type: 'image/png'
}
]
})
);
const pages = await getChapterPages('m1', 1);
expect(pages).toHaveLength(1);
expect(pages[0].storage_key).toContain('0001.png');
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toMatch(/\/v1\/mangas\/m1\/chapters\/1\/pages$/);
});
});

View File

@@ -37,3 +37,21 @@ export async function getChapter(mangaId: string, number: number): Promise<Chapt
`/v1/mangas/${encodeURIComponent(mangaId)}/chapters/${number}`
);
}
export type ChapterPage = {
id: string;
chapter_id: string;
page_number: number;
storage_key: string;
content_type: string;
};
export async function getChapterPages(
mangaId: string,
number: number
): Promise<ChapterPage[]> {
const r = await request<{ pages: ChapterPage[] }>(
`/v1/mangas/${encodeURIComponent(mangaId)}/chapters/${number}/pages`
);
return r.pages;
}

View File

@@ -3,6 +3,15 @@
const BASE = import.meta.env?.VITE_API_BASE ?? '/api';
/**
* Builds an absolute URL to the streaming `/files/{key}` endpoint so
* components can use it directly in `<img src>` etc., without
* reconstructing the API base in each call site.
*/
export function fileUrl(key: string): string {
return `${BASE}/v1/files/${key}`;
}
export class ApiError extends Error {
constructor(
public readonly status: number,

View File

@@ -0,0 +1,101 @@
<script lang="ts">
import { fileUrl } from '$lib/api/client';
let { data } = $props();
const manga = $derived(data.manga);
const chapters = $derived(data.chapters);
</script>
<svelte:head>
<title>{manga.title} — Mangalord</title>
</svelte:head>
<article>
<header class="overview">
{#if manga.cover_image_path}
<img
src={fileUrl(manga.cover_image_path)}
alt="{manga.title} cover"
class="cover"
loading="eager"
data-testid="manga-cover"
/>
{/if}
<div class="meta">
<h1 data-testid="manga-title">{manga.title}</h1>
{#if manga.author}
<p class="author" data-testid="manga-author">by {manga.author}</p>
{/if}
{#if manga.description}
<p class="description" data-testid="manga-description">{manga.description}</p>
{/if}
<button
type="button"
class="bookmark"
disabled
aria-disabled="true"
title="Bookmarking lands in feat/bookmarks"
data-testid="bookmark-placeholder"
>
☆ Bookmark
</button>
</div>
</header>
<section aria-label="chapters">
<h2>Chapters</h2>
{#if chapters.length === 0}
<p data-testid="chapters-empty">No chapters yet.</p>
{:else}
<ol class="chapter-list" data-testid="chapter-list">
{#each chapters as c (c.id)}
<li>
<a href="/manga/{manga.id}/chapter/{c.number}">
Chapter {c.number}{#if c.title}: {c.title}{/if}
</a>
<span class="pages">({c.page_count} pages)</span>
</li>
{/each}
</ol>
{/if}
</section>
</article>
<style>
.overview {
display: grid;
grid-template-columns: minmax(0, 200px) 1fr;
gap: 1rem;
align-items: start;
}
@media (max-width: 640px) {
.overview {
grid-template-columns: 1fr;
}
}
.cover {
width: 100%;
height: auto;
border-radius: 4px;
}
.meta h1 {
margin: 0 0 0.5rem;
}
.author {
color: #555;
margin: 0 0 0.75rem;
}
.description {
white-space: pre-wrap;
}
.bookmark {
margin-top: 0.5rem;
}
.chapter-list {
padding-left: 1.5rem;
}
.pages {
color: #777;
margin-left: 0.5rem;
}
</style>

View File

@@ -0,0 +1,13 @@
import { getManga } from '$lib/api/mangas';
import { listChapters } from '$lib/api/chapters';
import type { PageLoad } from './$types';
export const ssr = false;
export const load: PageLoad = async ({ params }) => {
const [manga, chapters] = await Promise.all([
getManga(params.id),
listChapters(params.id)
]);
return { manga, chapters: chapters.items };
};

View File

@@ -0,0 +1,177 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { fileUrl } from '$lib/api/client';
let { data } = $props();
const manga = $derived(data.manga);
const chapter = $derived(data.chapter);
const pages = $derived(data.pages);
const pageTitle = $derived(
chapter.title
? `${manga.title} — Ch. ${chapter.number}: ${chapter.title}`
: `${manga.title} — Ch. ${chapter.number}`
);
let index = $state(0);
function next() {
if (index < pages.length - 1) index += 1;
}
function prev() {
if (index > 0) index -= 1;
}
function first() {
index = 0;
}
function last() {
index = pages.length - 1;
}
function onKeydown(e: KeyboardEvent) {
// Don't hijack keys while the user is typing in an input.
const target = e.target as HTMLElement | null;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return;
switch (e.key) {
case 'ArrowRight':
case 'j':
case ' ':
e.preventDefault();
next();
break;
case 'ArrowLeft':
case 'k':
e.preventDefault();
prev();
break;
case 'Home':
e.preventDefault();
first();
break;
case 'End':
e.preventDefault();
last();
break;
}
}
onMount(() => window.addEventListener('keydown', onKeydown));
onDestroy(() => {
if (typeof window !== 'undefined') window.removeEventListener('keydown', onKeydown);
});
</script>
<svelte:head>
<title>{pageTitle}</title>
</svelte:head>
<nav class="reader-nav" aria-label="reader">
<a href="/manga/{manga.id}" data-testid="back-to-manga">{manga.title}</a>
<span class="indicator" data-testid="page-indicator">
Page {index + 1} / {pages.length}
</span>
</nav>
{#if pages.length === 0}
<p data-testid="reader-empty">This chapter has no pages yet.</p>
{:else}
<div class="page-wrap">
<button
type="button"
class="nav prev"
onclick={prev}
disabled={index === 0}
aria-label="Previous page"
data-testid="reader-prev"
>
</button>
<img
src={fileUrl(pages[index].storage_key)}
alt={`${manga.title} chapter ${chapter.number} page ${index + 1}`}
class="page-image"
loading="eager"
data-testid="reader-page"
/>
<button
type="button"
class="nav next"
onclick={next}
disabled={index === pages.length - 1}
aria-label="Next page"
data-testid="reader-next"
>
</button>
<!-- Preload the next page in a hidden img so it's already cached
when the user advances. -->
{#if index < pages.length - 1}
<img
src={fileUrl(pages[index + 1].storage_key)}
alt=""
aria-hidden="true"
class="preload"
loading="lazy"
data-testid="reader-preload"
/>
{/if}
</div>
{/if}
<style>
.reader-nav {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 0.5rem;
border-bottom: 1px solid #eee;
margin-bottom: 0.75rem;
}
.indicator {
color: #555;
}
.page-wrap {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 0.5rem;
align-items: center;
}
.page-image {
max-width: 100%;
max-height: 90vh;
height: auto;
margin: 0 auto;
display: block;
}
.nav {
font-size: 2rem;
padding: 0.5rem 1rem;
background: #f5f5f5;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
}
.nav:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.nav:focus-visible {
outline: 2px solid #06f;
outline-offset: 2px;
}
.preload {
display: none;
}
@media (max-width: 640px) {
.page-wrap {
grid-template-columns: 1fr;
}
.nav {
grid-column: 1;
justify-self: center;
}
}
</style>

View File

@@ -0,0 +1,15 @@
import { getManga } from '$lib/api/mangas';
import { getChapter, getChapterPages } from '$lib/api/chapters';
import type { PageLoad } from './$types';
export const ssr = false;
export const load: PageLoad = async ({ params }) => {
const number = Number(params.n);
const [manga, chapter, pages] = await Promise.all([
getManga(params.id),
getChapter(params.id, number),
getChapterPages(params.id, number)
]);
return { manga, chapter, pages };
};