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:
@@ -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$/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
101
frontend/src/routes/manga/[id]/+page.svelte
Normal file
101
frontend/src/routes/manga/[id]/+page.svelte
Normal 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>
|
||||
13
frontend/src/routes/manga/[id]/+page.ts
Normal file
13
frontend/src/routes/manga/[id]/+page.ts
Normal 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 };
|
||||
};
|
||||
177
frontend/src/routes/manga/[id]/chapter/[n]/+page.svelte
Normal file
177
frontend/src/routes/manga/[id]/chapter/[n]/+page.svelte
Normal 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>
|
||||
15
frontend/src/routes/manga/[id]/chapter/[n]/+page.ts
Normal file
15
frontend/src/routes/manga/[id]/chapter/[n]/+page.ts
Normal 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 };
|
||||
};
|
||||
Reference in New Issue
Block a user