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

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>