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:
121
.claude/settings.json
Normal file
121
.claude/settings.json
Normal 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
15
.env.example
Normal 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
39
.gitignore
vendored
Normal 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
140
CLAUDE.md
Normal 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
69
README.md
Normal 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
3
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/target
|
||||||
|
/.sqlx
|
||||||
|
.env
|
||||||
34
backend/Cargo.toml
Normal file
34
backend/Cargo.toml
Normal 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
27
backend/Dockerfile
Normal 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"]
|
||||||
49
backend/migrations/0001_init.sql
Normal file
49
backend/migrations/0001_init.sql
Normal 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
39
backend/src/api/files.rs
Normal 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
12
backend/src/api/health.rs
Normal 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
59
backend/src/api/mangas.rs
Normal 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
14
backend/src/api/mod.rs
Normal 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
36
backend/src/app.rs
Normal 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
22
backend/src/config.rs
Normal 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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
14
backend/src/domain/bookmark.rs
Normal file
14
backend/src/domain/bookmark.rs
Normal 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>,
|
||||||
|
}
|
||||||
20
backend/src/domain/chapter.rs
Normal file
20
backend/src/domain/chapter.rs
Normal 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>,
|
||||||
|
}
|
||||||
22
backend/src/domain/manga.rs
Normal file
22
backend/src/domain/manga.rs
Normal 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>,
|
||||||
|
}
|
||||||
9
backend/src/domain/mod.rs
Normal file
9
backend/src/domain/mod.rs
Normal 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;
|
||||||
11
backend/src/domain/user.rs
Normal file
11
backend/src/domain/user.rs
Normal 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
45
backend/src/error.rs
Normal 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
7
backend/src/lib.rs
Normal 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
21
backend/src/main.rs
Normal 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
76
backend/src/repo/manga.rs
Normal 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
1
backend/src/repo/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod manga;
|
||||||
97
backend/src/storage/local.rs
Normal file
97
backend/src/storage/local.rs
Normal 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
31
backend/src/storage/mod.rs
Normal file
31
backend/src/storage/mod.rs
Normal 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>;
|
||||||
|
}
|
||||||
77
backend/tests/api_mangas.rs
Normal file
77
backend/tests/api_mangas.rs
Normal 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);
|
||||||
|
}
|
||||||
44
backend/tests/common/mod.rs
Normal file
44
backend/tests/common/mod.rs
Normal 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
14
backend/tests/health.rs
Normal 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
21
docker-compose.dev.yml
Normal 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
42
docker-compose.yml
Normal 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
7
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
.svelte-kit
|
||||||
|
build
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
17
frontend/Dockerfile
Normal file
17
frontend/Dockerfile
Normal 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"]
|
||||||
57
frontend/e2e/manga-list.spec.ts
Normal file
57
frontend/e2e/manga-list.spec.ts
Normal 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
31
frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
18
frontend/playwright.config.ts
Normal file
18
frontend/playwright.config.ts
Normal 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
11
frontend/src/app.d.ts
vendored
Normal 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
13
frontend/src/app.html
Normal 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>
|
||||||
33
frontend/src/lib/api/client.ts
Normal file
33
frontend/src/lib/api/client.ts
Normal 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;
|
||||||
|
};
|
||||||
69
frontend/src/lib/api/mangas.test.ts
Normal file
69
frontend/src/lib/api/mangas.test.ts
Normal 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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
36
frontend/src/lib/api/mangas.ts
Normal file
36
frontend/src/lib/api/mangas.ts
Normal 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 };
|
||||||
30
frontend/src/routes/+layout.svelte
Normal file
30
frontend/src/routes/+layout.svelte
Normal 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>
|
||||||
57
frontend/src/routes/+page.svelte
Normal file
57
frontend/src/routes/+page.svelte
Normal 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
0
frontend/static/.gitkeep
Normal file
12
frontend/svelte.config.js
Normal file
12
frontend/svelte.config.js
Normal 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
15
frontend/tsconfig.json
Normal 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
20
frontend/vite.config.ts
Normal 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
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user