diff --git a/backend/Cargo.lock b/backend/Cargo.lock index b4211b3..7fda42b 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -541,6 +541,17 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -561,6 +572,7 @@ checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -1021,7 +1033,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "mangalord" -version = "0.5.0" +version = "0.6.0" dependencies = [ "anyhow", "argon2", @@ -1029,8 +1041,11 @@ dependencies = [ "axum", "axum-extra", "base64", + "bytes", "chrono", "dotenvy", + "futures-core", + "futures-util", "http-body-util", "infer", "mime", @@ -1044,6 +1059,7 @@ dependencies = [ "thiserror 1.0.69", "time", "tokio", + "tokio-util", "tower", "tower-http", "tracing", @@ -2028,6 +2044,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "tower" version = "0.5.3" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index ef9cd2b..8539c8a 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.5.0" +version = "0.6.0" edition = "2021" [lib] @@ -34,9 +34,13 @@ base64 = "0.22" axum-extra = { version = "0.9", features = ["cookie", "typed-header"] } time = "0.3" infer = "0.16" +tokio-util = { version = "0.7", features = ["io"] } +futures-core = "0.3" +bytes = "1" [dev-dependencies] tempfile = "3" tower = { version = "0.5", features = ["util"] } http-body-util = "0.1" mime = "0.3" +futures-util = "0.3" diff --git a/backend/migrations/0003_pages.sql b/backend/migrations/0003_pages.sql new file mode 100644 index 0000000..718a388 --- /dev/null +++ b/backend/migrations/0003_pages.sql @@ -0,0 +1,15 @@ +-- Per-page row for each uploaded chapter. The reader needs to know each +-- page's storage key (extensions can vary across pages within a chapter +-- because uploads sniff and preserve per-image MIME), so we store one +-- row per page rather than re-deriving keys from a fixed pattern. + +CREATE TABLE pages ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + chapter_id uuid NOT NULL REFERENCES chapters(id) ON DELETE CASCADE, + page_number integer NOT NULL, + storage_key text NOT NULL, + content_type text NOT NULL, + UNIQUE (chapter_id, page_number) +); + +CREATE INDEX pages_chapter_idx ON pages (chapter_id, page_number); diff --git a/backend/src/api/chapters.rs b/backend/src/api/chapters.rs index 9bd5fdd..090dfb8 100644 --- a/backend/src/api/chapters.rs +++ b/backend/src/api/chapters.rs @@ -17,19 +17,20 @@ use crate::api::mangas::{next_field, read_field_bytes}; use crate::api::pagination::PagedResponse; use crate::app::AppState; use crate::auth::extractor::CurrentUser; -use crate::domain::Chapter; use crate::domain::chapter::NewChapter; +use crate::domain::{Chapter, Page}; use crate::error::{AppError, AppResult}; use crate::repo; use crate::upload::{parse_image, UploadedImage}; pub fn routes() -> Router { Router::new() - .route( - "/mangas/:manga_id/chapters", - get(list).post(create), - ) + .route("/mangas/:manga_id/chapters", get(list).post(create)) .route("/mangas/:manga_id/chapters/:number", get(get_one)) + .route( + "/mangas/:manga_id/chapters/:number/pages", + get(list_pages), + ) } #[derive(Debug, Deserialize)] @@ -120,12 +121,14 @@ async fn create( .await?; for (idx, page) in pages.iter().enumerate() { - let nnnn = format!("{:04}", idx + 1); + let page_number = (idx + 1) as i32; + let nnnn = format!("{:04}", page_number); let key = format!( "mangas/{}/chapters/{}/pages/{}.{}", manga_id, chapter.id, nnnn, page.ext ); state.storage.put(&key, &page.bytes).await?; + repo::page::create(&state.db, chapter.id, page_number, &key, page.mime).await?; } let page_count = pages.len() as i32; @@ -138,3 +141,20 @@ async fn create( Ok((StatusCode::CREATED, Json(chapter))) } + +#[derive(Debug, serde::Serialize)] +struct PagesResponse { + pages: Vec, +} + +async fn list_pages( + State(state): State, + Path((manga_id, number)): Path<(Uuid, i32)>, +) -> AppResult> { + repo::manga::get(&state.db, manga_id).await?; + let chapter = repo::chapter::find_by_manga_and_number(&state.db, manga_id, number) + .await? + .ok_or(AppError::NotFound)?; + let pages = repo::page::list_for_chapter(&state.db, chapter.id).await?; + Ok(Json(PagesResponse { pages })) +} diff --git a/backend/src/api/files.rs b/backend/src/api/files.rs index 1fc819a..019b3ce 100644 --- a/backend/src/api/files.rs +++ b/backend/src/api/files.rs @@ -1,7 +1,11 @@ -//! Serves blobs from the `Storage` trait. Same endpoint serves manga -//! covers and chapter pages; the key embedded in the URL is whatever -//! the writer stored. +//! Streams blobs from the `Storage` trait. Same endpoint serves manga +//! covers and chapter pages; the key embedded in the URL is whatever the +//! writer stored. +//! +//! The handler uses `Storage::get_stream` so a multi-MB page is piped to +//! the client a chunk at a time instead of buffered server-side. +use axum::body::Body; use axum::extract::{Path, State}; use axum::http::header; use axum::response::{IntoResponse, Response}; @@ -17,13 +21,17 @@ pub fn routes() -> Router { } async fn serve(State(state): State, Path(key): Path) -> AppResult { - let bytes = match state.storage.get(&key).await { - Ok(b) => b, + let file = match state.storage.get_stream(&key).await { + Ok(f) => f, Err(StorageError::NotFound) => return Err(crate::error::AppError::NotFound), Err(e) => return Err(e.into()), }; let ct = content_type_for(&key); - Ok(([(header::CONTENT_TYPE, ct)], bytes).into_response()) + let headers = [ + (header::CONTENT_TYPE, ct.to_string()), + (header::CONTENT_LENGTH, file.size_bytes.to_string()), + ]; + Ok((headers, Body::from_stream(file.stream)).into_response()) } fn content_type_for(key: &str) -> &'static str { diff --git a/backend/src/domain/mod.rs b/backend/src/domain/mod.rs index 02e983d..771380a 100644 --- a/backend/src/domain/mod.rs +++ b/backend/src/domain/mod.rs @@ -2,6 +2,7 @@ pub mod api_token; pub mod bookmark; pub mod chapter; pub mod manga; +pub mod page; pub mod session; pub mod user; @@ -9,5 +10,6 @@ pub use api_token::ApiToken; pub use bookmark::Bookmark; pub use chapter::Chapter; pub use manga::Manga; +pub use page::Page; pub use session::Session; pub use user::User; diff --git a/backend/src/domain/page.rs b/backend/src/domain/page.rs new file mode 100644 index 0000000..18937e3 --- /dev/null +++ b/backend/src/domain/page.rs @@ -0,0 +1,12 @@ +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct Page { + pub id: Uuid, + pub chapter_id: Uuid, + pub page_number: i32, + pub storage_key: String, + pub content_type: String, +} diff --git a/backend/src/repo/mod.rs b/backend/src/repo/mod.rs index 0e45bd6..26c91b4 100644 --- a/backend/src/repo/mod.rs +++ b/backend/src/repo/mod.rs @@ -1,5 +1,6 @@ pub mod api_token; pub mod chapter; pub mod manga; +pub mod page; pub mod session; pub mod user; diff --git a/backend/src/repo/page.rs b/backend/src/repo/page.rs new file mode 100644 index 0000000..6b2f905 --- /dev/null +++ b/backend/src/repo/page.rs @@ -0,0 +1,45 @@ +//! Per-page persistence. Mirrors the rows that `pages` holds. + +use sqlx::PgPool; +use uuid::Uuid; + +use crate::domain::Page; +use crate::error::AppResult; + +pub async fn create( + pool: &PgPool, + chapter_id: Uuid, + page_number: i32, + storage_key: &str, + content_type: &str, +) -> AppResult { + let row = sqlx::query_as::<_, Page>( + r#" + INSERT INTO pages (chapter_id, page_number, storage_key, content_type) + VALUES ($1, $2, $3, $4) + RETURNING id, chapter_id, page_number, storage_key, content_type + "#, + ) + .bind(chapter_id) + .bind(page_number) + .bind(storage_key) + .bind(content_type) + .fetch_one(pool) + .await?; + Ok(row) +} + +pub async fn list_for_chapter(pool: &PgPool, chapter_id: Uuid) -> AppResult> { + let rows = sqlx::query_as::<_, Page>( + r#" + SELECT id, chapter_id, page_number, storage_key, content_type + FROM pages + WHERE chapter_id = $1 + ORDER BY page_number ASC + "#, + ) + .bind(chapter_id) + .fetch_all(pool) + .await?; + Ok(rows) +} diff --git a/backend/src/storage/local.rs b/backend/src/storage/local.rs index 9fc5730..ddedfb1 100644 --- a/backend/src/storage/local.rs +++ b/backend/src/storage/local.rs @@ -2,8 +2,9 @@ use std::path::{Path, PathBuf}; use async_trait::async_trait; use tokio::fs; +use tokio_util::io::ReaderStream; -use super::{Storage, StorageError}; +use super::{Storage, StorageError, StreamingFile}; pub struct LocalStorage { root: PathBuf, @@ -46,6 +47,25 @@ impl Storage for LocalStorage { } } + async fn get_stream(&self, key: &str) -> Result { + let path = self.resolve(key)?; + let file = match fs::File::open(&path).await { + Ok(f) => f, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Err(StorageError::NotFound) + } + Err(e) => return Err(e.into()), + }; + let size_bytes = file.metadata().await?.len(); + // 64 KiB chunks: small enough that a few-MB page emits many frames + // (so streaming is observable), large enough to keep syscalls cheap. + let stream = ReaderStream::with_capacity(file, 64 * 1024); + Ok(StreamingFile { + stream: Box::pin(stream), + size_bytes, + }) + } + async fn delete(&self, key: &str) -> Result<(), StorageError> { let path = self.resolve(key)?; match fs::remove_file(&path).await { @@ -93,5 +113,33 @@ mod tests { let s = LocalStorage::new(dir.path()); assert!(matches!(s.get("nope").await, Err(StorageError::NotFound))); assert!(matches!(s.delete("nope").await, Err(StorageError::NotFound))); + assert!(matches!( + s.get_stream("nope").await.err(), + Some(StorageError::NotFound) + )); + } + + #[tokio::test] + async fn get_stream_emits_multiple_chunks_for_large_files() { + use futures_util::StreamExt as _; + + let dir = tempdir().unwrap(); + let s = LocalStorage::new(dir.path()); + // 256 KiB blob → at 64 KiB chunks should emit ~4 chunks. + let big = vec![7u8; 256 * 1024]; + s.put("big.bin", &big).await.unwrap(); + + let StreamingFile { mut stream, size_bytes } = s.get_stream("big.bin").await.unwrap(); + assert_eq!(size_bytes, big.len() as u64); + + let mut chunks = 0usize; + let mut total = 0usize; + while let Some(frame) = stream.next().await { + let bytes = frame.unwrap(); + chunks += 1; + total += bytes.len(); + } + assert_eq!(total, big.len()); + assert!(chunks > 1, "expected >1 chunk, got {chunks}"); } } diff --git a/backend/src/storage/mod.rs b/backend/src/storage/mod.rs index 898b218..c3c42a7 100644 --- a/backend/src/storage/mod.rs +++ b/backend/src/storage/mod.rs @@ -7,8 +7,11 @@ mod local; use std::io; +use std::pin::Pin; use async_trait::async_trait; +use bytes::Bytes; +use futures_core::Stream; pub use local::LocalStorage; @@ -22,10 +25,23 @@ pub enum StorageError { BadKey, } +/// Boxed byte stream returned by `Storage::get_stream` so the trait stays +/// object-safe regardless of the concrete reader behind it. +pub type ByteStream = Pin> + Send>>; + +pub struct StreamingFile { + pub stream: ByteStream, + pub size_bytes: u64, +} + #[async_trait] pub trait Storage: Send + Sync { async fn put(&self, key: &str, bytes: &[u8]) -> Result<(), StorageError>; + /// Reads the entire blob into memory. Convenient for small assets + /// (covers, thumbnails). For pages and other large blobs, use + /// `get_stream` so axum can pipe bytes straight to the client. async fn get(&self, key: &str) -> Result, StorageError>; + async fn get_stream(&self, key: &str) -> Result; async fn delete(&self, key: &str) -> Result<(), StorageError>; async fn exists(&self, key: &str) -> Result; } diff --git a/backend/tests/api_chapters.rs b/backend/tests/api_chapters.rs index ec4513d..044beef 100644 --- a/backend/tests/api_chapters.rs +++ b/backend/tests/api_chapters.rs @@ -128,3 +128,38 @@ async fn get_chapter_unknown_manga_is_404(pool: PgPool) { .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } + +#[sqlx::test(migrations = "./migrations")] +async fn list_pages_empty_for_chapter_without_upload(pool: PgPool) { + let h = common::harness(pool.clone()); + let (_, cookie) = common::register_user(&h.app).await; + let manga_id = seed_manga(&h, &cookie, "Berserk").await; + seed_chapter(&pool, manga_id, 1, None).await; + + let resp = h + .app + .oneshot(common::get(&format!( + "/api/v1/mangas/{manga_id}/chapters/1/pages" + ))) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = common::body_json(resp).await; + assert_eq!(body["pages"], json!([])); +} + +#[sqlx::test(migrations = "./migrations")] +async fn list_pages_returns_404_for_unknown_chapter(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let manga_id = seed_manga(&h, &cookie, "Berserk").await; + + let resp = h + .app + .oneshot(common::get(&format!( + "/api/v1/mangas/{manga_id}/chapters/99/pages" + ))) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} diff --git a/backend/tests/api_uploads.rs b/backend/tests/api_uploads.rs index 66d5f64..679f3d7 100644 --- a/backend/tests/api_uploads.rs +++ b/backend/tests/api_uploads.rs @@ -112,6 +112,74 @@ async fn create_manga_rejects_oversized_cover_with_413(pool: PgPool) { assert_eq!(body["error"]["code"], "payload_too_large"); } +#[sqlx::test(migrations = "./migrations")] +async fn files_endpoint_streams_in_multiple_frames(pool: PgPool) { + use axum::http::header; + use http_body_util::BodyExt; + + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Big Manga").await; + + // The test harness caps a single file at 256 KiB; build a ~200 KiB PNG + // so it fits but is large enough that the 64 KiB chunker emits >1 frame. + let mut big = common::fake_png_bytes(); + big.resize(200 * 1024, 7); + + let resp = h + .app + .clone() + .oneshot(common::post_multipart_with_cookie( + &format!("/api/v1/mangas/{manga_id}/chapters"), + common::MultipartBuilder::new() + .add_json("metadata", json!({ "number": 1 })) + .add_file("page", "1.png", "image/png", &big), + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::CREATED); + + // Fetch the page back via the streaming files endpoint. + let pages = h + .app + .clone() + .oneshot(common::get(&format!( + "/api/v1/mangas/{manga_id}/chapters/1/pages" + ))) + .await + .unwrap(); + let body = common::body_json(pages).await; + let key = body["pages"][0]["storage_key"].as_str().unwrap().to_string(); + + let resp = h + .app + .oneshot(common::get(&format!("/api/v1/files/{key}"))) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + resp.headers().get(header::CONTENT_LENGTH).unwrap(), + big.len().to_string().as_str() + ); + + let mut body = resp.into_body(); + let mut frames = 0usize; + let mut total = 0usize; + while let Some(frame) = body.frame().await { + let frame = frame.unwrap(); + if let Some(data) = frame.data_ref() { + frames += 1; + total += data.len(); + } + } + assert_eq!(total, big.len()); + assert!( + frames > 1, + "expected the file to stream in more than one frame (got {frames})" + ); +} + #[sqlx::test(migrations = "./migrations")] async fn create_chapter_with_pages_stores_each(pool: PgPool) { let h = common::harness(pool); diff --git a/frontend/e2e/reader.spec.ts b/frontend/e2e/reader.spec.ts new file mode 100644 index 0000000..78fcdd0 --- /dev/null +++ b/frontend/e2e/reader.spec.ts @@ -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 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(); +}); diff --git a/frontend/package.json b/frontend/package.json index 253fad1..20c3b4b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.5.0", + "version": "0.6.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/api/chapters.test.ts b/frontend/src/lib/api/chapters.test.ts index a8b6bb2..c1c8568 100644 --- a/frontend/src/lib/api/chapters.test.ts +++ b/frontend/src/lib/api/chapters.test.ts @@ -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$/); + }); }); diff --git a/frontend/src/lib/api/chapters.ts b/frontend/src/lib/api/chapters.ts index d4b7213..9a330c9 100644 --- a/frontend/src/lib/api/chapters.ts +++ b/frontend/src/lib/api/chapters.ts @@ -37,3 +37,21 @@ export async function getChapter(mangaId: string, number: number): Promise { + const r = await request<{ pages: ChapterPage[] }>( + `/v1/mangas/${encodeURIComponent(mangaId)}/chapters/${number}/pages` + ); + return r.pages; +} diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index de135e6..96b70e2 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -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 `` 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, diff --git a/frontend/src/routes/manga/[id]/+page.svelte b/frontend/src/routes/manga/[id]/+page.svelte new file mode 100644 index 0000000..e225ef2 --- /dev/null +++ b/frontend/src/routes/manga/[id]/+page.svelte @@ -0,0 +1,101 @@ + + + + {manga.title} — Mangalord + + +
+
+ {#if manga.cover_image_path} + {manga.title} cover + {/if} +
+

{manga.title}

+ {#if manga.author} +

by {manga.author}

+ {/if} + {#if manga.description} +

{manga.description}

+ {/if} + +
+
+ +
+

Chapters

+ {#if chapters.length === 0} +

No chapters yet.

+ {:else} +
    + {#each chapters as c (c.id)} +
  1. + + Chapter {c.number}{#if c.title}: {c.title}{/if} + + ({c.page_count} pages) +
  2. + {/each} +
+ {/if} +
+
+ + diff --git a/frontend/src/routes/manga/[id]/+page.ts b/frontend/src/routes/manga/[id]/+page.ts new file mode 100644 index 0000000..bb33555 --- /dev/null +++ b/frontend/src/routes/manga/[id]/+page.ts @@ -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 }; +}; diff --git a/frontend/src/routes/manga/[id]/chapter/[n]/+page.svelte b/frontend/src/routes/manga/[id]/chapter/[n]/+page.svelte new file mode 100644 index 0000000..33d643e --- /dev/null +++ b/frontend/src/routes/manga/[id]/chapter/[n]/+page.svelte @@ -0,0 +1,177 @@ + + + + {pageTitle} + + + + +{#if pages.length === 0} +

This chapter has no pages yet.

+{:else} +
+ + + {`${manga.title} + + + + + {#if index < pages.length - 1} + + {/if} +
+{/if} + + diff --git a/frontend/src/routes/manga/[id]/chapter/[n]/+page.ts b/frontend/src/routes/manga/[id]/chapter/[n]/+page.ts new file mode 100644 index 0000000..272e9a5 --- /dev/null +++ b/frontend/src/routes/manga/[id]/chapter/[n]/+page.ts @@ -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 }; +};