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:
31
backend/Cargo.lock
generated
31
backend/Cargo.lock
generated
@@ -541,6 +541,17 @@ version = "0.3.32"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
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]]
|
[[package]]
|
||||||
name = "futures-sink"
|
name = "futures-sink"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
@@ -561,6 +572,7 @@ checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-io",
|
"futures-io",
|
||||||
|
"futures-macro",
|
||||||
"futures-sink",
|
"futures-sink",
|
||||||
"futures-task",
|
"futures-task",
|
||||||
"memchr",
|
"memchr",
|
||||||
@@ -1021,7 +1033,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.5.0"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
@@ -1029,8 +1041,11 @@ dependencies = [
|
|||||||
"axum",
|
"axum",
|
||||||
"axum-extra",
|
"axum-extra",
|
||||||
"base64",
|
"base64",
|
||||||
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"infer",
|
"infer",
|
||||||
"mime",
|
"mime",
|
||||||
@@ -1044,6 +1059,7 @@ dependencies = [
|
|||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -2028,6 +2044,19 @@ dependencies = [
|
|||||||
"tokio",
|
"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]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.5.0"
|
version = "0.6.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
@@ -34,9 +34,13 @@ base64 = "0.22"
|
|||||||
axum-extra = { version = "0.9", features = ["cookie", "typed-header"] }
|
axum-extra = { version = "0.9", features = ["cookie", "typed-header"] }
|
||||||
time = "0.3"
|
time = "0.3"
|
||||||
infer = "0.16"
|
infer = "0.16"
|
||||||
|
tokio-util = { version = "0.7", features = ["io"] }
|
||||||
|
futures-core = "0.3"
|
||||||
|
bytes = "1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
tower = { version = "0.5", features = ["util"] }
|
tower = { version = "0.5", features = ["util"] }
|
||||||
http-body-util = "0.1"
|
http-body-util = "0.1"
|
||||||
mime = "0.3"
|
mime = "0.3"
|
||||||
|
futures-util = "0.3"
|
||||||
|
|||||||
15
backend/migrations/0003_pages.sql
Normal file
15
backend/migrations/0003_pages.sql
Normal file
@@ -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);
|
||||||
@@ -17,19 +17,20 @@ use crate::api::mangas::{next_field, read_field_bytes};
|
|||||||
use crate::api::pagination::PagedResponse;
|
use crate::api::pagination::PagedResponse;
|
||||||
use crate::app::AppState;
|
use crate::app::AppState;
|
||||||
use crate::auth::extractor::CurrentUser;
|
use crate::auth::extractor::CurrentUser;
|
||||||
use crate::domain::Chapter;
|
|
||||||
use crate::domain::chapter::NewChapter;
|
use crate::domain::chapter::NewChapter;
|
||||||
|
use crate::domain::{Chapter, Page};
|
||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::repo;
|
use crate::repo;
|
||||||
use crate::upload::{parse_image, UploadedImage};
|
use crate::upload::{parse_image, UploadedImage};
|
||||||
|
|
||||||
pub fn routes() -> Router<AppState> {
|
pub fn routes() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route(
|
.route("/mangas/:manga_id/chapters", get(list).post(create))
|
||||||
"/mangas/:manga_id/chapters",
|
|
||||||
get(list).post(create),
|
|
||||||
)
|
|
||||||
.route("/mangas/:manga_id/chapters/:number", get(get_one))
|
.route("/mangas/:manga_id/chapters/:number", get(get_one))
|
||||||
|
.route(
|
||||||
|
"/mangas/:manga_id/chapters/:number/pages",
|
||||||
|
get(list_pages),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -120,12 +121,14 @@ async fn create(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
for (idx, page) in pages.iter().enumerate() {
|
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!(
|
let key = format!(
|
||||||
"mangas/{}/chapters/{}/pages/{}.{}",
|
"mangas/{}/chapters/{}/pages/{}.{}",
|
||||||
manga_id, chapter.id, nnnn, page.ext
|
manga_id, chapter.id, nnnn, page.ext
|
||||||
);
|
);
|
||||||
state.storage.put(&key, &page.bytes).await?;
|
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;
|
let page_count = pages.len() as i32;
|
||||||
@@ -138,3 +141,20 @@ async fn create(
|
|||||||
|
|
||||||
Ok((StatusCode::CREATED, Json(chapter)))
|
Ok((StatusCode::CREATED, Json(chapter)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Serialize)]
|
||||||
|
struct PagesResponse {
|
||||||
|
pages: Vec<Page>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_pages(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path((manga_id, number)): Path<(Uuid, i32)>,
|
||||||
|
) -> AppResult<Json<PagesResponse>> {
|
||||||
|
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 }))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
//! Serves blobs from the `Storage` trait. Same endpoint serves manga
|
//! Streams blobs from the `Storage` trait. Same endpoint serves manga
|
||||||
//! covers and chapter pages; the key embedded in the URL is whatever
|
//! covers and chapter pages; the key embedded in the URL is whatever the
|
||||||
//! the writer stored.
|
//! 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::extract::{Path, State};
|
||||||
use axum::http::header;
|
use axum::http::header;
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
@@ -17,13 +21,17 @@ pub fn routes() -> Router<AppState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn serve(State(state): State<AppState>, Path(key): Path<String>) -> AppResult<Response> {
|
async fn serve(State(state): State<AppState>, Path(key): Path<String>) -> AppResult<Response> {
|
||||||
let bytes = match state.storage.get(&key).await {
|
let file = match state.storage.get_stream(&key).await {
|
||||||
Ok(b) => b,
|
Ok(f) => f,
|
||||||
Err(StorageError::NotFound) => return Err(crate::error::AppError::NotFound),
|
Err(StorageError::NotFound) => return Err(crate::error::AppError::NotFound),
|
||||||
Err(e) => return Err(e.into()),
|
Err(e) => return Err(e.into()),
|
||||||
};
|
};
|
||||||
let ct = content_type_for(&key);
|
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 {
|
fn content_type_for(key: &str) -> &'static str {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ pub mod api_token;
|
|||||||
pub mod bookmark;
|
pub mod bookmark;
|
||||||
pub mod chapter;
|
pub mod chapter;
|
||||||
pub mod manga;
|
pub mod manga;
|
||||||
|
pub mod page;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
|
||||||
@@ -9,5 +10,6 @@ pub use api_token::ApiToken;
|
|||||||
pub use bookmark::Bookmark;
|
pub use bookmark::Bookmark;
|
||||||
pub use chapter::Chapter;
|
pub use chapter::Chapter;
|
||||||
pub use manga::Manga;
|
pub use manga::Manga;
|
||||||
|
pub use page::Page;
|
||||||
pub use session::Session;
|
pub use session::Session;
|
||||||
pub use user::User;
|
pub use user::User;
|
||||||
|
|||||||
12
backend/src/domain/page.rs
Normal file
12
backend/src/domain/page.rs
Normal file
@@ -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,
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod api_token;
|
pub mod api_token;
|
||||||
pub mod chapter;
|
pub mod chapter;
|
||||||
pub mod manga;
|
pub mod manga;
|
||||||
|
pub mod page;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
|||||||
45
backend/src/repo/page.rs
Normal file
45
backend/src/repo/page.rs
Normal file
@@ -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<Page> {
|
||||||
|
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<Vec<Page>> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -2,8 +2,9 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
|
use tokio_util::io::ReaderStream;
|
||||||
|
|
||||||
use super::{Storage, StorageError};
|
use super::{Storage, StorageError, StreamingFile};
|
||||||
|
|
||||||
pub struct LocalStorage {
|
pub struct LocalStorage {
|
||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
@@ -46,6 +47,25 @@ impl Storage for LocalStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_stream(&self, key: &str) -> Result<StreamingFile, StorageError> {
|
||||||
|
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> {
|
async fn delete(&self, key: &str) -> Result<(), StorageError> {
|
||||||
let path = self.resolve(key)?;
|
let path = self.resolve(key)?;
|
||||||
match fs::remove_file(&path).await {
|
match fs::remove_file(&path).await {
|
||||||
@@ -93,5 +113,33 @@ mod tests {
|
|||||||
let s = LocalStorage::new(dir.path());
|
let s = LocalStorage::new(dir.path());
|
||||||
assert!(matches!(s.get("nope").await, Err(StorageError::NotFound)));
|
assert!(matches!(s.get("nope").await, Err(StorageError::NotFound)));
|
||||||
assert!(matches!(s.delete("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}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,11 @@
|
|||||||
mod local;
|
mod local;
|
||||||
|
|
||||||
use std::io;
|
use std::io;
|
||||||
|
use std::pin::Pin;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use bytes::Bytes;
|
||||||
|
use futures_core::Stream;
|
||||||
|
|
||||||
pub use local::LocalStorage;
|
pub use local::LocalStorage;
|
||||||
|
|
||||||
@@ -22,10 +25,23 @@ pub enum StorageError {
|
|||||||
BadKey,
|
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<Box<dyn Stream<Item = io::Result<Bytes>> + Send>>;
|
||||||
|
|
||||||
|
pub struct StreamingFile {
|
||||||
|
pub stream: ByteStream,
|
||||||
|
pub size_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait Storage: Send + Sync {
|
pub trait Storage: Send + Sync {
|
||||||
async fn put(&self, key: &str, bytes: &[u8]) -> Result<(), StorageError>;
|
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<Vec<u8>, StorageError>;
|
async fn get(&self, key: &str) -> Result<Vec<u8>, StorageError>;
|
||||||
|
async fn get_stream(&self, key: &str) -> Result<StreamingFile, StorageError>;
|
||||||
async fn delete(&self, key: &str) -> Result<(), StorageError>;
|
async fn delete(&self, key: &str) -> Result<(), StorageError>;
|
||||||
async fn exists(&self, key: &str) -> Result<bool, StorageError>;
|
async fn exists(&self, key: &str) -> Result<bool, StorageError>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,3 +128,38 @@ async fn get_chapter_unknown_manga_is_404(pool: PgPool) {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -112,6 +112,74 @@ async fn create_manga_rejects_oversized_cover_with_413(pool: PgPool) {
|
|||||||
assert_eq!(body["error"]["code"], "payload_too_large");
|
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")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn create_chapter_with_pages_stores_each(pool: PgPool) {
|
async fn create_chapter_with_pages_stores_each(pool: PgPool) {
|
||||||
let h = common::harness(pool);
|
let h = common::harness(pool);
|
||||||
|
|||||||
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();
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
afterEach,
|
afterEach,
|
||||||
type MockInstance
|
type MockInstance
|
||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
import { listChapters, getChapter } from './chapters';
|
import { listChapters, getChapter, getChapterPages } from './chapters';
|
||||||
|
|
||||||
function ok(body: unknown): Response {
|
function ok(body: unknown): Response {
|
||||||
return new Response(JSON.stringify(body), {
|
return new Response(JSON.stringify(body), {
|
||||||
@@ -86,4 +86,25 @@ describe('chapters api client', () => {
|
|||||||
code: 'not_found'
|
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}`
|
`/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';
|
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 {
|
export class ApiError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly status: number,
|
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