chore: initial project scaffold

Set up Mangalord with a Rust/axum backend, SvelteKit frontend, Postgres,
and Docker Compose deployment. Establishes the architecture and TDD
patterns the project will extend:

- Hexagonal-ish backend layering (domain / repo / storage / api) with
  a pluggable Storage trait (LocalStorage today, S3 as a future impl).
- Initial migration: users, mangas, chapters, bookmarks.
- Vertical slice for mangas (list, search, create, get) with
  #[sqlx::test] integration coverage and storage unit tests.
- SvelteKit frontend using Svelte 5 runes, typed API client, Vitest
  unit tests and Playwright e2e with route mocking.
- CLAUDE.md documenting layering, TDD/git/SemVer workflow rules, and
  extension points (tags, fulltext search, OCR, S3, auth).
- Project-scoped .claude/settings.json with permission allowlist for
  the toolchain (git, cargo, npm/vite, docker, psql, gh, doc fetches).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-16 21:05:16 +02:00
commit 6c1d04aaf4
48 changed files with 1657 additions and 0 deletions

121
.claude/settings.json Normal file
View File

@@ -0,0 +1,121 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"allow": [
"Bash(ls:*)",
"Bash(pwd)",
"Bash(tree:*)",
"Bash(find:*)",
"Bash(grep:*)",
"Bash(rg:*)",
"Bash(cat:*)",
"Bash(head:*)",
"Bash(tail:*)",
"Bash(wc:*)",
"Bash(file:*)",
"Bash(stat:*)",
"Bash(du:*)",
"Bash(df:*)",
"Bash(diff:*)",
"Bash(which:*)",
"Bash(whereis:*)",
"Bash(env)",
"Bash(printenv:*)",
"Bash(date:*)",
"Bash(awk:*)",
"Bash(sed:*)",
"Bash(cut:*)",
"Bash(sort:*)",
"Bash(uniq:*)",
"Bash(tr:*)",
"Bash(jq:*)",
"Bash(yq:*)",
"Bash(xargs:*)",
"Bash(echo:*)",
"Bash(printf:*)",
"Bash(mkdir:*)",
"Bash(touch:*)",
"Bash(cp:*)",
"Bash(mv:*)",
"Bash(git status:*)",
"Bash(git diff:*)",
"Bash(git log:*)",
"Bash(git show:*)",
"Bash(git ls-files:*)",
"Bash(git rev-parse:*)",
"Bash(git branch:*)",
"Bash(git switch:*)",
"Bash(git checkout:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git restore:*)",
"Bash(git stash:*)",
"Bash(git tag:*)",
"Bash(git merge:*)",
"Bash(git rebase:*)",
"Bash(git cherry-pick:*)",
"Bash(git remote:*)",
"Bash(git fetch:*)",
"Bash(git pull:*)",
"Bash(git clean -n:*)",
"Bash(git config --get:*)",
"Bash(git config --list:*)",
"Bash(gh:*)",
"Bash(cargo:*)",
"Bash(rustc:*)",
"Bash(rustup:*)",
"Bash(rustfmt:*)",
"Bash(clippy-driver:*)",
"Bash(sqlx:*)",
"Bash(node:*)",
"Bash(npm:*)",
"Bash(npx:*)",
"Bash(pnpm:*)",
"Bash(yarn:*)",
"Bash(tsc:*)",
"Bash(vite:*)",
"Bash(vitest:*)",
"Bash(playwright:*)",
"Bash(svelte-kit:*)",
"Bash(svelte-check:*)",
"Bash(eslint:*)",
"Bash(prettier:*)",
"Bash(docker:*)",
"Bash(docker compose:*)",
"Bash(docker-compose:*)",
"Bash(psql:*)",
"Bash(pg_isready:*)",
"Bash(pg_dump:*)",
"Bash(pg_restore:*)",
"Bash(createdb:*)",
"Bash(dropdb:*)",
"Bash(curl http://localhost:*)",
"Bash(curl http://127.0.0.1:*)",
"Bash(curl -s http://localhost:*)",
"Bash(curl -s http://127.0.0.1:*)",
"Bash(wget http://localhost:*)",
"Bash(wget http://127.0.0.1:*)",
"WebFetch(domain:docs.rs)",
"WebFetch(domain:doc.rust-lang.org)",
"WebFetch(domain:rust-lang.org)",
"WebFetch(domain:crates.io)",
"WebFetch(domain:svelte.dev)",
"WebFetch(domain:kit.svelte.dev)",
"WebFetch(domain:vitejs.dev)",
"WebFetch(domain:vitest.dev)",
"WebFetch(domain:playwright.dev)",
"WebFetch(domain:docs.docker.com)",
"WebFetch(domain:www.postgresql.org)",
"WebFetch(domain:postgresql.org)"
]
}
}

15
.env.example Normal file
View File

@@ -0,0 +1,15 @@
# Copy to .env for `docker compose up`.
# Local dev (cargo run / npm run dev) reads backend/.env if present.
POSTGRES_USER=mangalord
POSTGRES_PASSWORD=mangalord
POSTGRES_DB=mangalord
DATABASE_URL=postgres://mangalord:mangalord@postgres:5432/mangalord
BIND_ADDRESS=0.0.0.0:8080
STORAGE_DIR=/var/lib/mangalord/storage
RUST_LOG=info,mangalord=debug
# Public base URL the frontend uses to reach the API from the browser.
# In docker compose this is exposed on the host.
PUBLIC_API_BASE=http://localhost:8080/api

39
.gitignore vendored Normal file
View File

@@ -0,0 +1,39 @@
# Rust
/backend/target
/backend/.sqlx
# Node / SvelteKit
/frontend/node_modules
/frontend/.svelte-kit
/frontend/build
/frontend/test-results
/frontend/playwright-report
# Local storage volume (manga files)
/data
# Env
.env
.env.local
.env.*.local
# Claude Code (personal overrides only; .claude/settings.json is committed)
.claude/settings.local.json
.claude/.session/
# IDEs / editors
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*

140
CLAUDE.md Normal file
View File

@@ -0,0 +1,140 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project
Mangalord is a manga / comics reader. Users browse, search, read, bookmark, and upload manga. The same HTTP API is consumed by both the SvelteKit frontend and external bots/scripts — there is no separate "bot API" or "internal API." Deployment is a single server via Docker Compose.
## Stack
- **Backend**: Rust, axum 0.7, sqlx 0.8 (Postgres), tokio
- **Database**: Postgres 16
- **Frontend**: SvelteKit 2 with Svelte 5 runes, TypeScript, Vite
- **Storage**: `Storage` trait + `LocalStorage` impl. S3 and friends are planned implementations, not refactors.
- **Tests**: `cargo test` (unit + integration via `#[sqlx::test]`), Vitest (frontend unit), Playwright (E2E)
## Development workflow rules
These rules apply to every change. They are not negotiable per-task — if a change can't be made under them, raise the conflict before writing code.
### TDD
Tests describing the expected behaviour are written **before** the implementation, in the same commit or one immediately preceding. A commit that adds production code without a corresponding test (or that updates a test only after the fact to match what was built) does not meet this bar — split it.
See *TDD workflow* below for picking the right test level.
### Git
- One branch per logical change, prefixed by intent: `feat/<slug>`, `bugfix/<slug>`, `chore/<slug>`, `docs/<slug>`, `refactor/<slug>`, `test/<slug>`. Never commit directly to `main`.
- Commit messages follow Conventional Commits (`feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `test:`). One concern per commit.
- Each branch lands via a reviewed PR (self-review counts when working solo; the diff must still be read end-to-end before merge). Squash-merge into `main` to keep history linear.
- Do not push `main` directly, do not force-push shared branches, and do not bypass hooks.
### Semantic versioning
The project version is tracked in [backend/Cargo.toml](backend/Cargo.toml) and [frontend/package.json](frontend/package.json) and bumped on every merge to `main`:
- `feat:` → bump **minor**
- `fix:` / `bugfix:` → bump **patch**
- breaking change (only valid before 1.0, otherwise needs a major bump after 1.0) → bump **minor** while under 1.0
- `chore:` / `docs:` / `test:` / `refactor:` with no behaviour change → no bump
While the project is pre-release the version stays **below `1.0.0`**. The `1.0.0` release happens **only at the user's explicit instruction** — do not bump to or past it autonomously.
Both manifests must stay in lockstep (same version string).
## TDD workflow
The project is developed test-first. For each change:
1. Write a failing test at the right level.
2. Implement the minimum to make it pass.
3. Refactor with tests green.
Pick the level deliberately:
- **Pure logic / mappers / validation** → unit test next to the code. In Rust: `#[cfg(test)] mod tests` in the same file. In the frontend: a sibling `*.test.ts`.
- **Anything that touches the DB** → integration test in [backend/tests/](backend/tests/), using `#[sqlx::test(migrations = "./migrations")]`. Each test gets a fresh, migrated database.
- **Cross-component user journeys** → Playwright in [frontend/e2e/](frontend/e2e/). Mock the network at the Playwright route level when the journey doesn't require a real backend.
Run these before claiming a change works:
```bash
(cd backend && cargo test)
(cd frontend && npm test)
(cd frontend && npm run test:e2e) # needs dev server, see playwright.config.ts
```
Backend integration tests require `DATABASE_URL` to point at a Postgres where the test user can `CREATEDB` (the `#[sqlx::test]` macro provisions a fresh database per test).
## Backend layout
Hexagonal-ish layering. Handlers depend on `repo` and `storage`, never the other way around. This is the seam new extensions plug into.
- [backend/src/domain/](backend/src/domain/) — pure data types, no I/O. Add new types here first.
- [backend/src/repo/](backend/src/repo/) — DB access functions taking `&PgPool`. Plain async fns rather than a repository struct; tests target them directly.
- [backend/src/storage/](backend/src/storage/) — `Storage` trait + `LocalStorage`. **Add `S3Storage` here when needed**, do not branch on backend type in handlers.
- [backend/src/api/](backend/src/api/) — axum handlers and route wiring. One module per resource.
- [backend/src/app.rs](backend/src/app.rs) — `AppState` and router assembly. Integration tests use the same `router(state)` function with a test `AppState`.
- [backend/src/error.rs](backend/src/error.rs) — `AppError` is the single error type returned by handlers. New variants map to status codes in `IntoResponse`.
When adding a new resource:
1. Add a migration `backend/migrations/NNNN_<name>.sql`.
2. Add the domain type in [backend/src/domain/](backend/src/domain/).
3. Add repo functions in [backend/src/repo/](backend/src/repo/).
4. Add handlers in `backend/src/api/<resource>.rs` and merge in [backend/src/api/mod.rs](backend/src/api/mod.rs).
5. Add integration tests in [backend/tests/](backend/tests/), reusing [backend/tests/common/mod.rs](backend/tests/common/mod.rs).
### Database access
Queries use `sqlx::query_as::<_, T>(...)` (runtime-checked) with `#[derive(FromRow)]` on the domain types, so the project builds without a live DB. If you want compile-time SQL checking later, switch to `sqlx::query!`/`query_as!` macros and commit a `.sqlx` directory via `cargo sqlx prepare`.
Migrations live in [backend/migrations/](backend/migrations/) and run automatically at startup via `sqlx::migrate!`.
### Storage trait
Do not call `tokio::fs` or any filesystem API from handlers. Go through `state.storage` (the `Storage` trait). Keys are `/`-separated paths; the local backend rejects `..` and empty segments. Suggested key layout:
- `mangas/{manga_id}/cover.{ext}`
- `mangas/{manga_id}/chapters/{chapter_id}/pages/{nnnn}.{ext}`
## Frontend layout
- [frontend/src/lib/api/](frontend/src/lib/api/) — typed API client. **All backend calls go through here**, not raw `fetch` in components. The base URL is `import.meta.env.VITE_API_BASE`, defaulting to `/api` (which Vite dev-proxies to the backend).
- [frontend/src/routes/](frontend/src/routes/) — SvelteKit routes.
- Use Svelte 5 runes (`$state`, `$derived`, `$effect`, `$props`). Do not use the legacy `let`-reactive syntax or `on:event=` directive form — prefer `onevent={...}`.
- The Node adapter is configured for production; `npm run build && node build` is what the Dockerfile runs.
## Common commands
```bash
# Full stack (compose), production-like
docker compose up --build
# Local dev: Postgres in compose, backend + frontend native
docker compose -f docker-compose.dev.yml up -d
(cd backend && cargo run)
(cd frontend && npm run dev)
# Backend tests
(cd backend && cargo test)
(cd backend && cargo test --test api_mangas) # single integration test file
(cd backend && cargo test list_is_empty_initially) # single test by name
# Frontend tests
(cd frontend && npm test) # vitest, all
(cd frontend && npx vitest run src/lib/api/mangas.test.ts) # single file
(cd frontend && npm run test:e2e) # playwright (auto-starts vite)
```
## Extension points
These are first-class slots in the architecture. When adding any of them, plug into the existing seam rather than building parallel infrastructure.
- **Tags / lists**: new tables joined to `mangas`. New `domain`, `repo`, and `api` modules; the existing manga endpoints do not need to change.
- **Full-text / fuzzy search**: enable `pg_trgm` in a migration and add a GIN index on `mangas.title`; swap the `WHERE` in `repo::manga::list` to use `%` operator or `tsvector`. The API shape (`?search=...`) does not change.
- **OCR / autotagging**: a background worker (a separate binary or a tokio task spawned in `app::build`) that reads pages from `storage::Storage` and writes tag rows. Do not couple OCR to upload handlers — it runs asynchronously.
- **S3 storage**: add `storage::S3Storage` implementing `Storage`. Branch in `app::build` based on a config field (e.g., `STORAGE_BACKEND=s3`). Handlers do not change.
- **Auth**: an axum middleware producing a `CurrentUser` extractor. Bots use an API token header, browser users a session cookie — both should populate the same extractor so handlers stay backend-agnostic. Until this lands, treat all endpoints as unauthenticated (this is acknowledged technical debt, not a final design).

69
README.md Normal file
View File

@@ -0,0 +1,69 @@
# Mangalord
A self-hosted manga and comics reader. Browse, search, read, bookmark, and upload manga and chapters. The HTTP API is consumed by both the SvelteKit web UI and external bots/scripts that perform the same actions programmatically.
## Stack
- **Backend**: Rust, axum, sqlx
- **Database**: Postgres 16
- **Frontend**: SvelteKit 2 (Svelte 5 runes), TypeScript, Vite
- **File storage**: pluggable `Storage` trait — local FS today, S3 (and friends) as future impls
- **Deploy**: Docker Compose on a single server
## Quick start
```bash
cp .env.example .env
docker compose up --build
```
- Frontend: http://localhost:3000
- API: http://localhost:8080/api
- API health: http://localhost:8080/api/health
## Local development
Run only Postgres in Docker; run backend and frontend natively for fast iteration:
```bash
docker compose -f docker-compose.dev.yml up -d
# backend
cd backend
export DATABASE_URL=postgres://mangalord:mangalord@localhost:5432/mangalord
cargo run
# frontend (separate shell)
cd frontend
npm install
npm run dev
```
The Vite dev server proxies `/api` to `http://localhost:8080`.
## Tests
This project is developed test-first. Tests live at three levels:
```bash
# Backend: unit (in-module) + integration (tests/, per-test DB via #[sqlx::test])
cd backend && cargo test
# Frontend: unit / module tests (Vitest)
cd frontend && npm test
# Frontend: end-to-end (Playwright; spins up dev server, mocks API by default)
cd frontend && npm run test:e2e
```
## API surface
| Method | Path | Purpose |
| ------ | ------------------- | -------------------------------------- |
| GET | `/api/health` | Liveness |
| GET | `/api/mangas` | List / search mangas |
| POST | `/api/mangas` | Create a manga |
| GET | `/api/mangas/{id}` | Get a manga |
| GET | `/api/files/{key}` | Stream a blob (cover, chapter page) |
Chapters, uploads, and bookmarks are next — the patterns to extend are documented in [`CLAUDE.md`](./CLAUDE.md).

3
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/target
/.sqlx
.env

34
backend/Cargo.toml Normal file
View File

@@ -0,0 +1,34 @@
[package]
name = "mangalord"
version = "0.1.0"
edition = "2021"
[lib]
path = "src/lib.rs"
[[bin]]
name = "mangalord"
path = "src/main.rs"
[dependencies]
axum = { version = "0.7", features = ["macros"] }
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono", "macros", "migrate"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tower = { version = "0.5", features = ["util"] }
tower-http = { version = "0.6", features = ["trace", "cors"] }
thiserror = "1"
anyhow = "1"
async-trait = "0.1"
dotenvy = "0.15"
[dev-dependencies]
tempfile = "3"
tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1"
mime = "0.3"

27
backend/Dockerfile Normal file
View File

@@ -0,0 +1,27 @@
# Multi-stage build for the Rust backend.
FROM rust:1-slim AS builder
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends pkg-config libssl-dev ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Cache deps separately from sources.
COPY Cargo.toml ./
RUN mkdir src && echo "fn main() {}" > src/main.rs && echo "" > src/lib.rs \
&& cargo build --release \
&& rm -rf src
COPY src ./src
COPY migrations ./migrations
RUN touch src/main.rs src/lib.rs && cargo build --release
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /app/target/release/mangalord /usr/local/bin/mangalord
COPY --from=builder /app/migrations /app/migrations
ENV STORAGE_DIR=/var/lib/mangalord/storage
EXPOSE 8080
CMD ["mangalord"]

View File

@@ -0,0 +1,49 @@
-- Initial schema for Mangalord.
-- Designed for future extensions: tags, lists, fulltext/fuzzy search,
-- OCR-derived metadata. New concepts get new tables joined here; do
-- not jam them onto existing rows.
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
username text NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE mangas (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
title text NOT NULL,
author text,
description text,
cover_image_path text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX mangas_created_at_idx ON mangas (created_at DESC);
CREATE INDEX mangas_title_lower_idx ON mangas (lower(title));
CREATE TABLE chapters (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
manga_id uuid NOT NULL REFERENCES mangas(id) ON DELETE CASCADE,
number integer NOT NULL,
title text,
page_count integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (manga_id, number)
);
CREATE INDEX chapters_manga_idx ON chapters (manga_id, number);
CREATE TABLE bookmarks (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
manga_id uuid NOT NULL REFERENCES mangas(id) ON DELETE CASCADE,
chapter_id uuid REFERENCES chapters(id) ON DELETE SET NULL,
page integer,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, manga_id, chapter_id)
);
CREATE INDEX bookmarks_user_idx ON bookmarks (user_id, created_at DESC);

39
backend/src/api/files.rs Normal file
View File

@@ -0,0 +1,39 @@
//! 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.
use axum::extract::{Path, State};
use axum::http::header;
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::Router;
use crate::app::AppState;
use crate::error::AppResult;
use crate::storage::StorageError;
pub fn routes() -> Router<AppState> {
Router::new().route("/files/*key", get(serve))
}
async fn serve(State(state): State<AppState>, Path(key): Path<String>) -> AppResult<Response> {
let bytes = match state.storage.get(&key).await {
Ok(b) => b,
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())
}
fn content_type_for(key: &str) -> &'static str {
let ext = key.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
match ext.as_str() {
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"webp" => "image/webp",
"gif" => "image/gif",
"avif" => "image/avif",
_ => "application/octet-stream",
}
}

12
backend/src/api/health.rs Normal file
View File

@@ -0,0 +1,12 @@
use axum::{routing::get, Json, Router};
use serde_json::{json, Value};
use crate::app::AppState;
pub fn routes() -> Router<AppState> {
Router::new().route("/health", get(health))
}
async fn health() -> Json<Value> {
Json(json!({ "status": "ok" }))
}

59
backend/src/api/mangas.rs Normal file
View File

@@ -0,0 +1,59 @@
use axum::extract::{Path, Query, State};
use axum::routing::get;
use axum::{Json, Router};
use serde::Deserialize;
use uuid::Uuid;
use crate::app::AppState;
use crate::domain::manga::{Manga, NewManga};
use crate::error::{AppError, AppResult};
use crate::repo;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/mangas", get(list).post(create))
.route("/mangas/:id", get(get_one))
}
#[derive(Debug, Deserialize)]
pub struct ListParams {
#[serde(default)]
pub search: Option<String>,
#[serde(default = "default_limit")]
pub limit: i64,
#[serde(default)]
pub offset: i64,
}
fn default_limit() -> i64 {
50
}
async fn list(
State(state): State<AppState>,
Query(params): Query<ListParams>,
) -> AppResult<Json<Vec<Manga>>> {
let q = repo::manga::ListQuery {
search: params.search.filter(|s| !s.trim().is_empty()),
limit: params.limit.clamp(1, 200),
offset: params.offset.max(0),
};
Ok(Json(repo::manga::list(&state.db, &q).await?))
}
async fn get_one(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> AppResult<Json<Manga>> {
Ok(Json(repo::manga::get(&state.db, id).await?))
}
async fn create(
State(state): State<AppState>,
Json(input): Json<NewManga>,
) -> AppResult<Json<Manga>> {
if input.title.trim().is_empty() {
return Err(AppError::InvalidInput("title is required".into()));
}
Ok(Json(repo::manga::create(&state.db, input).await?))
}

14
backend/src/api/mod.rs Normal file
View File

@@ -0,0 +1,14 @@
pub mod files;
pub mod health;
pub mod mangas;
use axum::Router;
use crate::app::AppState;
pub fn routes() -> Router<AppState> {
Router::new()
.merge(health::routes())
.merge(mangas::routes())
.merge(files::routes())
}

36
backend/src/app.rs Normal file
View File

@@ -0,0 +1,36 @@
use std::sync::Arc;
use axum::Router;
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use tower_http::trace::TraceLayer;
use crate::config::Config;
use crate::storage::{LocalStorage, Storage};
#[derive(Clone)]
pub struct AppState {
pub db: PgPool,
pub storage: Arc<dyn Storage>,
}
pub async fn build(config: Config) -> anyhow::Result<Router> {
let db = PgPoolOptions::new()
.max_connections(10)
.connect(&config.database_url)
.await?;
sqlx::migrate!("./migrations").run(&db).await?;
let storage: Arc<dyn Storage> = Arc::new(LocalStorage::new(config.storage_dir.clone()));
Ok(router(AppState { db, storage }))
}
/// Build a router from a pre-assembled state. Used by integration tests
/// so they can swap in a test DB pool and a `tempfile`-backed storage.
pub fn router(state: AppState) -> Router {
Router::new()
.nest("/api", crate::api::routes())
.with_state(state)
.layer(TraceLayer::new_for_http())
}

22
backend/src/config.rs Normal file
View File

@@ -0,0 +1,22 @@
use std::path::PathBuf;
#[derive(Clone, Debug)]
pub struct Config {
pub database_url: String,
pub bind_address: String,
pub storage_dir: PathBuf,
}
impl Config {
pub fn from_env() -> anyhow::Result<Self> {
Ok(Self {
database_url: std::env::var("DATABASE_URL")
.map_err(|_| anyhow::anyhow!("DATABASE_URL must be set"))?,
bind_address: std::env::var("BIND_ADDRESS")
.unwrap_or_else(|_| "0.0.0.0:8080".to_string()),
storage_dir: std::env::var("STORAGE_DIR")
.unwrap_or_else(|_| "./data/storage".to_string())
.into(),
})
}
}

View File

@@ -0,0 +1,14 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Bookmark {
pub id: Uuid,
pub user_id: Uuid,
pub manga_id: Uuid,
pub chapter_id: Option<Uuid>,
pub page: Option<i32>,
pub created_at: DateTime<Utc>,
}

View File

@@ -0,0 +1,20 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Chapter {
pub id: Uuid,
pub manga_id: Uuid,
pub number: i32,
pub title: Option<String>,
pub page_count: i32,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct NewChapter {
pub number: i32,
pub title: Option<String>,
}

View File

@@ -0,0 +1,22 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Manga {
pub id: Uuid,
pub title: String,
pub author: Option<String>,
pub description: Option<String>,
pub cover_image_path: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct NewManga {
pub title: String,
pub author: Option<String>,
pub description: Option<String>,
}

View File

@@ -0,0 +1,9 @@
pub mod bookmark;
pub mod chapter;
pub mod manga;
pub mod user;
pub use bookmark::Bookmark;
pub use chapter::Chapter;
pub use manga::Manga;
pub use user::User;

View File

@@ -0,0 +1,11 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct User {
pub id: Uuid,
pub username: String,
pub created_at: DateTime<Utc>,
}

45
backend/src/error.rs Normal file
View File

@@ -0,0 +1,45 @@
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;
use crate::storage::StorageError;
#[derive(thiserror::Error, Debug)]
pub enum AppError {
#[error("not found")]
NotFound,
#[error("invalid input: {0}")]
InvalidInput(String),
#[error(transparent)]
Database(#[from] sqlx::Error),
#[error(transparent)]
Storage(#[from] StorageError),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
pub type AppResult<T> = Result<T, AppError>;
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match &self {
AppError::NotFound => (StatusCode::NOT_FOUND, self.to_string()),
AppError::InvalidInput(_) => (StatusCode::BAD_REQUEST, self.to_string()),
AppError::Database(sqlx::Error::RowNotFound) => {
(StatusCode::NOT_FOUND, "not found".to_string())
}
AppError::Storage(StorageError::NotFound) => {
(StatusCode::NOT_FOUND, "not found".to_string())
}
AppError::Storage(StorageError::BadKey) => {
(StatusCode::BAD_REQUEST, "invalid file key".to_string())
}
AppError::Database(_) | AppError::Storage(_) | AppError::Other(_) => {
tracing::error!(error = ?self, "internal error");
(StatusCode::INTERNAL_SERVER_ERROR, "internal error".to_string())
}
};
(status, Json(json!({ "error": message }))).into_response()
}
}

7
backend/src/lib.rs Normal file
View File

@@ -0,0 +1,7 @@
pub mod api;
pub mod app;
pub mod config;
pub mod domain;
pub mod error;
pub mod repo;
pub mod storage;

21
backend/src/main.rs Normal file
View File

@@ -0,0 +1,21 @@
use std::net::SocketAddr;
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| "info,mangalord=debug".into()),
)
.init();
let config = mangalord::config::Config::from_env()?;
let addr: SocketAddr = config.bind_address.parse()?;
let app = mangalord::app::build(config).await?;
tracing::info!(%addr, "mangalord listening");
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
Ok(())
}

76
backend/src/repo/manga.rs Normal file
View File

@@ -0,0 +1,76 @@
//! Manga persistence.
//!
//! Plain async functions over `&PgPool` rather than a repository struct —
//! each function is easy to test in isolation with `#[sqlx::test]`, and
//! handlers depend only on `sqlx::PgPool`, not on a trait object. Swap to
//! a trait + impl if a second backend ever becomes necessary.
use sqlx::PgPool;
use uuid::Uuid;
use crate::domain::manga::{Manga, NewManga};
use crate::error::{AppError, AppResult};
#[derive(Debug, Clone)]
pub struct ListQuery {
pub search: Option<String>,
pub limit: i64,
pub offset: i64,
}
impl Default for ListQuery {
fn default() -> Self {
Self { search: None, limit: 50, offset: 0 }
}
}
pub async fn list(pool: &PgPool, query: &ListQuery) -> AppResult<Vec<Manga>> {
let pattern = query.search.as_deref().map(|s| format!("%{}%", s));
let rows = sqlx::query_as::<_, Manga>(
r#"
SELECT id, title, author, description, cover_image_path, created_at, updated_at
FROM mangas
WHERE $1::text IS NULL
OR title ILIKE $1
OR COALESCE(author, '') ILIKE $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
"#,
)
.bind(pattern)
.bind(query.limit)
.bind(query.offset)
.fetch_all(pool)
.await?;
Ok(rows)
}
pub async fn get(pool: &PgPool, id: Uuid) -> AppResult<Manga> {
sqlx::query_as::<_, Manga>(
r#"
SELECT id, title, author, description, cover_image_path, created_at, updated_at
FROM mangas
WHERE id = $1
"#,
)
.bind(id)
.fetch_optional(pool)
.await?
.ok_or(AppError::NotFound)
}
pub async fn create(pool: &PgPool, input: NewManga) -> AppResult<Manga> {
let row = sqlx::query_as::<_, Manga>(
r#"
INSERT INTO mangas (title, author, description)
VALUES ($1, $2, $3)
RETURNING id, title, author, description, cover_image_path, created_at, updated_at
"#,
)
.bind(&input.title)
.bind(&input.author)
.bind(&input.description)
.fetch_one(pool)
.await?;
Ok(row)
}

1
backend/src/repo/mod.rs Normal file
View File

@@ -0,0 +1 @@
pub mod manga;

View File

@@ -0,0 +1,97 @@
use std::path::{Path, PathBuf};
use async_trait::async_trait;
use tokio::fs;
use super::{Storage, StorageError};
pub struct LocalStorage {
root: PathBuf,
}
impl LocalStorage {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
fn resolve(&self, key: &str) -> Result<PathBuf, StorageError> {
let key = key.trim_start_matches('/');
if key.is_empty() {
return Err(StorageError::BadKey);
}
if key.split('/').any(|seg| seg.is_empty() || seg == "." || seg == "..") {
return Err(StorageError::BadKey);
}
Ok(self.root.join(key))
}
}
#[async_trait]
impl Storage for LocalStorage {
async fn put(&self, key: &str, bytes: &[u8]) -> Result<(), StorageError> {
let path = self.resolve(key)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).await?;
}
fs::write(path, bytes).await?;
Ok(())
}
async fn get(&self, key: &str) -> Result<Vec<u8>, StorageError> {
let path = self.resolve(key)?;
match fs::read(&path).await {
Ok(b) => Ok(b),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(StorageError::NotFound),
Err(e) => Err(e.into()),
}
}
async fn delete(&self, key: &str) -> Result<(), StorageError> {
let path = self.resolve(key)?;
match fs::remove_file(&path).await {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(StorageError::NotFound),
Err(e) => Err(e.into()),
}
}
async fn exists(&self, key: &str) -> Result<bool, StorageError> {
let path: &Path = &self.resolve(key)?;
Ok(fs::try_exists(path).await?)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[tokio::test]
async fn put_get_delete_roundtrip() {
let dir = tempdir().unwrap();
let s = LocalStorage::new(dir.path());
s.put("mangas/abc/cover.jpg", b"hello").await.unwrap();
assert!(s.exists("mangas/abc/cover.jpg").await.unwrap());
assert_eq!(s.get("mangas/abc/cover.jpg").await.unwrap(), b"hello");
s.delete("mangas/abc/cover.jpg").await.unwrap();
assert!(!s.exists("mangas/abc/cover.jpg").await.unwrap());
}
#[tokio::test]
async fn rejects_path_traversal() {
let dir = tempdir().unwrap();
let s = LocalStorage::new(dir.path());
assert!(matches!(s.put("../escape", b"x").await, Err(StorageError::BadKey)));
assert!(matches!(s.get("a/../../b").await, Err(StorageError::BadKey)));
assert!(matches!(s.exists("").await, Err(StorageError::BadKey)));
}
#[tokio::test]
async fn missing_key_is_not_found() {
let dir = tempdir().unwrap();
let s = LocalStorage::new(dir.path());
assert!(matches!(s.get("nope").await, Err(StorageError::NotFound)));
assert!(matches!(s.delete("nope").await, Err(StorageError::NotFound)));
}
}

View File

@@ -0,0 +1,31 @@
//! Pluggable blob storage.
//!
//! Handlers depend on the `Storage` trait, never on a concrete backend.
//! Add new backends (S3, GCS, …) as new impls in this module and wire
//! them up in `app::build` based on config.
mod local;
use std::io;
use async_trait::async_trait;
pub use local::LocalStorage;
#[derive(thiserror::Error, Debug)]
pub enum StorageError {
#[error(transparent)]
Io(#[from] io::Error),
#[error("not found")]
NotFound,
#[error("invalid storage key")]
BadKey,
}
#[async_trait]
pub trait Storage: Send + Sync {
async fn put(&self, key: &str, bytes: &[u8]) -> Result<(), StorageError>;
async fn get(&self, key: &str) -> Result<Vec<u8>, StorageError>;
async fn delete(&self, key: &str) -> Result<(), StorageError>;
async fn exists(&self, key: &str) -> Result<bool, StorageError>;
}

View File

@@ -0,0 +1,77 @@
mod common;
use axum::http::StatusCode;
use serde_json::json;
use sqlx::PgPool;
use tower::ServiceExt;
#[sqlx::test(migrations = "./migrations")]
async fn list_is_empty_initially(pool: PgPool) {
let h = common::harness(pool);
let resp = h.app.oneshot(common::get("/api/mangas")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(common::body_json(resp).await, json!([]));
}
#[sqlx::test(migrations = "./migrations")]
async fn create_then_list_roundtrip(pool: PgPool) {
let h = common::harness(pool);
let created = h.app.clone().oneshot(common::post_json(
"/api/mangas",
json!({ "title": "Berserk", "author": "Kentaro Miura", "description": null }),
)).await.unwrap();
assert_eq!(created.status(), StatusCode::OK);
let body = common::body_json(created).await;
assert_eq!(body["title"], "Berserk");
assert_eq!(body["author"], "Kentaro Miura");
assert!(body["id"].as_str().is_some());
let listed = h.app.oneshot(common::get("/api/mangas")).await.unwrap();
let listed_body = common::body_json(listed).await;
assert_eq!(listed_body.as_array().unwrap().len(), 1);
assert_eq!(listed_body[0]["title"], "Berserk");
}
#[sqlx::test(migrations = "./migrations")]
async fn search_filters_by_title_and_author(pool: PgPool) {
let h = common::harness(pool);
for (title, author) in [
("One Piece", "Eiichiro Oda"),
("Berserk", "Kentaro Miura"),
("Vinland Saga", "Makoto Yukimura"),
] {
let _ = h.app.clone().oneshot(common::post_json(
"/api/mangas",
json!({ "title": title, "author": author }),
)).await.unwrap();
}
let resp = h.app.clone().oneshot(common::get("/api/mangas?search=miura")).await.unwrap();
let body = common::body_json(resp).await;
let titles: Vec<&str> = body.as_array().unwrap().iter().map(|m| m["title"].as_str().unwrap()).collect();
assert_eq!(titles, vec!["Berserk"]);
let resp = h.app.oneshot(common::get("/api/mangas?search=saga")).await.unwrap();
let body = common::body_json(resp).await;
let titles: Vec<&str> = body.as_array().unwrap().iter().map(|m| m["title"].as_str().unwrap()).collect();
assert_eq!(titles, vec!["Vinland Saga"]);
}
#[sqlx::test(migrations = "./migrations")]
async fn create_rejects_empty_title(pool: PgPool) {
let h = common::harness(pool);
let resp = h.app.oneshot(common::post_json(
"/api/mangas",
json!({ "title": " ", "author": null }),
)).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[sqlx::test(migrations = "./migrations")]
async fn get_unknown_id_is_404(pool: PgPool) {
let h = common::harness(pool);
let resp = h.app.oneshot(common::get("/api/mangas/00000000-0000-0000-0000-000000000000")).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

View File

@@ -0,0 +1,44 @@
use std::sync::Arc;
use axum::body::Body;
use axum::http::Request;
use axum::Router;
use http_body_util::BodyExt;
use sqlx::PgPool;
use tempfile::TempDir;
use mangalord::app::{router, AppState};
use mangalord::storage::LocalStorage;
pub struct Harness {
pub app: Router,
// Kept alive for the lifetime of the test so the temp dir is not dropped.
pub _storage_dir: TempDir,
}
pub fn harness(pool: PgPool) -> Harness {
let storage_dir = tempfile::tempdir().expect("tempdir");
let state = AppState {
db: pool,
storage: Arc::new(LocalStorage::new(storage_dir.path())),
};
Harness { app: router(state), _storage_dir: storage_dir }
}
pub async fn body_json(response: axum::response::Response) -> serde_json::Value {
let bytes = response.into_body().collect().await.unwrap().to_bytes();
serde_json::from_slice(&bytes).expect("body is JSON")
}
pub fn get(uri: &str) -> Request<Body> {
Request::builder().uri(uri).body(Body::empty()).unwrap()
}
pub fn post_json(uri: &str, body: serde_json::Value) -> Request<Body> {
Request::builder()
.method("POST")
.uri(uri)
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap()
}

14
backend/tests/health.rs Normal file
View File

@@ -0,0 +1,14 @@
mod common;
use axum::http::StatusCode;
use sqlx::PgPool;
use tower::ServiceExt;
#[sqlx::test(migrations = "./migrations")]
async fn health_returns_ok(pool: PgPool) {
let h = common::harness(pool);
let resp = h.app.oneshot(common::get("/api/health")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = common::body_json(resp).await;
assert_eq!(body["status"], "ok");
}

21
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,21 @@
# Spin up just Postgres for local dev. Run backend & frontend natively
# against it (cargo run / npm run dev) for fast iteration.
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: mangalord
POSTGRES_PASSWORD: mangalord
POSTGRES_DB: mangalord
ports:
- "5432:5432"
volumes:
- mangalord-postgres-dev:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U mangalord"]
interval: 5s
timeout: 5s
retries: 10
volumes:
mangalord-postgres-dev:

42
docker-compose.yml Normal file
View File

@@ -0,0 +1,42 @@
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER:-mangalord}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-mangalord}
POSTGRES_DB: ${POSTGRES_DB:-mangalord}
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-mangalord}"]
interval: 5s
timeout: 5s
retries: 10
backend:
build: ./backend
depends_on:
postgres:
condition: service_healthy
environment:
DATABASE_URL: postgres://${POSTGRES_USER:-mangalord}:${POSTGRES_PASSWORD:-mangalord}@postgres:5432/${POSTGRES_DB:-mangalord}
BIND_ADDRESS: 0.0.0.0:8080
STORAGE_DIR: /var/lib/mangalord/storage
RUST_LOG: ${RUST_LOG:-info,mangalord=debug}
volumes:
- storage-data:/var/lib/mangalord/storage
ports:
- "8080:8080"
frontend:
build: ./frontend
depends_on:
- backend
environment:
PUBLIC_API_BASE: ${PUBLIC_API_BASE:-http://localhost:8080/api}
ports:
- "3000:3000"
volumes:
postgres-data:
storage-data:

7
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules
.svelte-kit
build
test-results
playwright-report
.env
.env.local

17
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3000
COPY --from=builder /app/build ./build
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
EXPOSE 3000
CMD ["node", "build"]

View File

@@ -0,0 +1,57 @@
import { test, expect } from '@playwright/test';
// These E2E tests run against the dev server (vite on :5173) which proxies
// /api to the backend. Set E2E_BASE_URL to point at a different deployment.
//
// A live backend (and Postgres) must be reachable. Routes mock the network
// where possible to keep journeys deterministic.
test('home page renders the Mangalord heading and search input', async ({ page }) => {
// Mock the list endpoint so the test doesn't depend on DB state.
await page.route('**/api/mangas*', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([])
});
});
await page.goto('/');
await expect(page.getByRole('link', { name: 'Mangalord' })).toBeVisible();
await expect(page.getByTestId('search-input')).toBeVisible();
await expect(page.getByTestId('empty')).toContainText('No mangas yet');
});
test('search updates the manga list', async ({ page }) => {
let lastSearch: string | null = null;
await page.route('**/api/mangas*', async (route) => {
const url = new URL(route.request().url());
lastSearch = url.searchParams.get('search');
const body =
lastSearch === 'berserk'
? [
{
id: 'b1',
title: 'Berserk',
author: 'Kentaro Miura',
description: null,
cover_image_path: null,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z'
}
]
: [];
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body)
});
});
await page.goto('/');
await page.getByTestId('search-input').fill('berserk');
await page.getByRole('button', { name: 'Search' }).click();
await expect(page.getByTestId('manga-list')).toContainText('Berserk');
expect(lastSearch).toBe('berserk');
});

31
frontend/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "mangalord-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test"
},
"devDependencies": {
"@playwright/test": "^1.48.0",
"@sveltejs/adapter-node": "^5.2.0",
"@sveltejs/kit": "^2.7.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/svelte": "^5.2.0",
"@types/node": "^22.7.0",
"jsdom": "^25.0.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tslib": "^2.7.0",
"typescript": "^5.6.0",
"vite": "^5.4.0",
"vitest": "^2.1.0"
}
}

View File

@@ -0,0 +1,18 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: 'e2e',
timeout: 30_000,
use: {
baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:5173',
trace: 'retain-on-failure'
},
webServer: process.env.E2E_BASE_URL
? undefined
: {
command: 'npm run dev',
port: 5173,
reuseExistingServer: !process.env.CI,
timeout: 120_000
}
});

11
frontend/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

13
frontend/src/app.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Mangalord</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1,33 @@
// All backend calls go through this module. Components and routes import
// the typed helpers below — they do not call fetch directly.
const BASE = (typeof import.meta !== 'undefined' && import.meta.env?.VITE_API_BASE) || '/api';
export class ApiError extends Error {
constructor(
public readonly status: number,
message: string
) {
super(message);
this.name = 'ApiError';
}
}
export async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, init);
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new ApiError(res.status, text || `${res.status} ${res.statusText}`);
}
return (await res.json()) as T;
}
export type Manga = {
id: string;
title: string;
author: string | null;
description: string | null;
cover_image_path: string | null;
created_at: string;
updated_at: string;
};

View File

@@ -0,0 +1,69 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { listMangas, createManga, getManga } from './mangas';
function ok(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json' }
});
}
function fail(status: number, body = ''): Response {
return new Response(body, { status });
}
describe('mangas api client', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(() => {
vi.restoreAllMocks();
});
it('listMangas hits /mangas with no params by default', async () => {
fetchSpy.mockResolvedValueOnce(ok([]));
await listMangas();
expect(fetchSpy).toHaveBeenCalledTimes(1);
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toMatch(/\/mangas$/);
});
it('listMangas encodes search, limit, offset', async () => {
fetchSpy.mockResolvedValueOnce(ok([]));
await listMangas({ search: 'one piece', limit: 10, offset: 20 });
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toContain('search=one+piece');
expect(url).toContain('limit=10');
expect(url).toContain('offset=20');
});
it('createManga POSTs JSON', async () => {
fetchSpy.mockResolvedValueOnce(
ok({
id: 'abc',
title: 'Berserk',
author: 'Miura',
description: null,
cover_image_path: null,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z'
})
);
const m = await createManga({ title: 'Berserk', author: 'Miura' });
expect(m.title).toBe('Berserk');
const init = fetchSpy.mock.calls[0][1] as RequestInit;
expect(init.method).toBe('POST');
expect(init.headers).toMatchObject({ 'content-type': 'application/json' });
expect(JSON.parse(init.body as string)).toEqual({ title: 'Berserk', author: 'Miura' });
});
it('getManga throws ApiError on non-2xx', async () => {
fetchSpy.mockResolvedValue(fail(404, 'not found'));
await expect(getManga('missing')).rejects.toMatchObject({
name: 'ApiError',
status: 404
});
});
});

View File

@@ -0,0 +1,36 @@
import { request, type Manga } from './client';
export type ListOptions = {
search?: string;
limit?: number;
offset?: number;
};
export async function listMangas(opts: ListOptions = {}): Promise<Manga[]> {
const params = new URLSearchParams();
if (opts.search) params.set('search', opts.search);
if (opts.limit != null) params.set('limit', String(opts.limit));
if (opts.offset != null) params.set('offset', String(opts.offset));
const qs = params.toString();
return request<Manga[]>(`/mangas${qs ? `?${qs}` : ''}`);
}
export async function getManga(id: string): Promise<Manga> {
return request<Manga>(`/mangas/${encodeURIComponent(id)}`);
}
export type NewManga = {
title: string;
author?: string | null;
description?: string | null;
};
export async function createManga(input: NewManga): Promise<Manga> {
return request<Manga>('/mangas', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(input)
});
}
export type { Manga };

View File

@@ -0,0 +1,30 @@
<script lang="ts">
let { children } = $props();
</script>
<header>
<nav>
<a href="/">Mangalord</a>
<a href="/upload">Upload</a>
<a href="/bookmarks">Bookmarks</a>
</nav>
</header>
<main>
{@render children()}
</main>
<style>
header {
padding: 1rem;
border-bottom: 1px solid #ddd;
}
nav a {
margin-right: 1rem;
}
main {
padding: 1rem;
max-width: 64rem;
margin: 0 auto;
}
</style>

View File

@@ -0,0 +1,57 @@
<script lang="ts">
import { onMount } from 'svelte';
import { listMangas, type Manga } from '$lib/api/mangas';
let mangas: Manga[] = $state([]);
let search = $state('');
let loading = $state(true);
let error: string | null = $state(null);
async function load() {
loading = true;
error = null;
try {
mangas = await listMangas({ search: search.trim() || undefined });
} catch (e) {
error = (e as Error).message;
} finally {
loading = false;
}
}
onMount(load);
</script>
<h1>Mangas</h1>
<form
onsubmit={(e) => {
e.preventDefault();
load();
}}
>
<input
type="search"
bind:value={search}
placeholder="Search by title or author"
data-testid="search-input"
/>
<button type="submit">Search</button>
</form>
{#if loading}
<p data-testid="loading">Loading…</p>
{:else if error}
<p data-testid="error" role="alert">{error}</p>
{:else if mangas.length === 0}
<p data-testid="empty">No mangas yet. <a href="/upload">Upload one</a>.</p>
{:else}
<ul data-testid="manga-list">
{#each mangas as m (m.id)}
<li>
<a href="/manga/{m.id}">{m.title}</a>
{#if m.author}<span>{m.author}</span>{/if}
</li>
{/each}
</ul>
{/if}

0
frontend/static/.gitkeep Normal file
View File

12
frontend/svelte.config.js Normal file
View File

@@ -0,0 +1,12 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({ out: 'build' })
}
};
export default config;

15
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler",
"types": ["@testing-library/jest-dom", "vitest/globals"]
}
}

20
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,20 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
server: {
port: 5173,
proxy: {
'/api': {
target: process.env.BACKEND_URL ?? 'http://localhost:8080',
changeOrigin: true
}
}
},
test: {
environment: 'jsdom',
include: ['src/**/*.test.ts'],
globals: false
}
});