diff --git a/backend/Cargo.lock b/backend/Cargo.lock index a3e789b..0c582aa 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1033,7 +1033,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "mangalord" -version = "0.15.0" +version = "0.16.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 55e6e24..a2704cc 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.15.0" +version = "0.16.0" edition = "2021" [lib] diff --git a/backend/src/api/authors.rs b/backend/src/api/authors.rs new file mode 100644 index 0000000..d8e33de --- /dev/null +++ b/backend/src/api/authors.rs @@ -0,0 +1,80 @@ +use axum::extract::{Path, Query, State}; +use axum::routing::get; +use axum::{Json, Router}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::api::pagination::PagedResponse; +use crate::app::AppState; +use crate::domain::author::{Author, AuthorWithCount}; +use crate::domain::manga::Manga; +use crate::error::AppResult; +use crate::repo; + +pub fn routes() -> Router { + Router::new() + .route("/authors", get(list)) + .route("/authors/:id", get(get_one)) + .route("/authors/:id/mangas", get(list_mangas)) +} + +#[derive(Debug, Deserialize)] +pub struct ListParams { + #[serde(default)] + pub search: Option, + #[serde(default = "default_limit")] + pub limit: i64, + #[serde(default)] + pub offset: i64, +} + +fn default_limit() -> i64 { + 10 +} + +#[derive(Debug, Deserialize)] +pub struct MangaListParams { + #[serde(default = "default_manga_limit")] + pub limit: i64, + #[serde(default)] + pub offset: i64, +} + +fn default_manga_limit() -> i64 { + 50 +} + +async fn list( + State(state): State, + Query(params): Query, +) -> AppResult>> { + let limit = params.limit.clamp(1, 50); + let offset = params.offset.max(0); + let search = params.search.as_deref().and_then(|s| { + let t = s.trim(); + if t.is_empty() { None } else { Some(t) } + }); + Ok(Json(repo::author::list(&state.db, search, limit, offset).await?)) +} + +async fn get_one( + State(state): State, + Path(id): Path, +) -> AppResult> { + Ok(Json(repo::author::find_with_count(&state.db, id).await?)) +} + +async fn list_mangas( + State(state): State, + Path(id): Path, + Query(params): Query, +) -> AppResult>> { + let limit = params.limit.clamp(1, 200); + let offset = params.offset.max(0); + // Intentionally does NOT 404 on unknown id — the join naturally + // returns zero rows, and the page envelope already conveys that. + // Saves a round-trip and matches the shape of GET /mangas. + let (items, total) = + repo::author::list_mangas_for_author(&state.db, id, limit, offset).await?; + Ok(Json(PagedResponse::with_total(items, limit, offset, total))) +} diff --git a/backend/src/api/mod.rs b/backend/src/api/mod.rs index 5474721..5933b3c 100644 --- a/backend/src/api/mod.rs +++ b/backend/src/api/mod.rs @@ -1,4 +1,5 @@ pub mod auth; +pub mod authors; pub mod bookmarks; pub mod chapters; pub mod files; @@ -22,4 +23,5 @@ pub fn routes() -> Router { .merge(bookmarks::routes()) .merge(genres::routes()) .merge(tags::routes()) + .merge(authors::routes()) } diff --git a/backend/tests/api_authors.rs b/backend/tests/api_authors.rs new file mode 100644 index 0000000..4df145b --- /dev/null +++ b/backend/tests/api_authors.rs @@ -0,0 +1,276 @@ +mod common; + +use axum::http::StatusCode; +use serde_json::{json, Value}; +use sqlx::PgPool; +use tower::ServiceExt; +use uuid::Uuid; + +use common::MultipartBuilder; + +async fn create_manga( + app: &axum::Router, + cookie: &str, + metadata: Value, +) -> Value { + let resp = app + .clone() + .oneshot(common::post_multipart_with_cookie( + "/api/v1/mangas", + MultipartBuilder::new().add_json("metadata", metadata), + cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::CREATED); + common::body_json(resp).await +} + +fn first_author_id(manga: &Value) -> String { + manga["authors"][0]["id"].as_str().unwrap().to_string() +} + +#[sqlx::test(migrations = "./migrations")] +async fn get_returns_name_and_manga_count(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + + let m1 = create_manga( + &h.app, + &cookie, + json!({ "title": "Berserk", "authors": ["Kentaro Miura"] }), + ) + .await; + // A second manga by the same author bumps the count to 2. + let _ = create_manga( + &h.app, + &cookie, + json!({ "title": "Berserk Prelude", "authors": ["Kentaro Miura"] }), + ) + .await; + let author_id = first_author_id(&m1); + + let resp = h + .app + .oneshot(common::get(&format!("/api/v1/authors/{author_id}"))) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = common::body_json(resp).await; + assert_eq!(body["id"], author_id); + assert_eq!(body["name"], "Kentaro Miura"); + assert_eq!(body["manga_count"], 2); +} + +#[sqlx::test(migrations = "./migrations")] +async fn get_unknown_id_is_404_with_envelope(pool: PgPool) { + let h = common::harness(pool); + let resp = h + .app + .oneshot(common::get(&format!("/api/v1/authors/{}", Uuid::new_v4()))) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + let body = common::body_json(resp).await; + assert_eq!(body["error"]["code"], "not_found"); +} + +#[sqlx::test(migrations = "./migrations")] +async fn list_mangas_returns_only_works_by_that_author(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + + let m_miura = create_manga( + &h.app, + &cookie, + json!({ "title": "Berserk", "authors": ["Kentaro Miura"] }), + ) + .await; + let _ = create_manga( + &h.app, + &cookie, + json!({ "title": "One Piece", "authors": ["Eiichiro Oda"] }), + ) + .await; + let _ = create_manga( + &h.app, + &cookie, + json!({ "title": "Berserk Prelude", "authors": ["Kentaro Miura"] }), + ) + .await; + let author_id = first_author_id(&m_miura); + + let resp = h + .app + .oneshot(common::get(&format!("/api/v1/authors/{author_id}/mangas"))) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = common::body_json(resp).await; + let titles: Vec<&str> = body["items"] + .as_array() + .unwrap() + .iter() + .map(|m| m["title"].as_str().unwrap()) + .collect(); + // Sorted by created_at DESC — Berserk Prelude was created last. + assert_eq!(titles, vec!["Berserk Prelude", "Berserk"]); + assert_eq!(body["page"]["total"], 2); +} + +#[sqlx::test(migrations = "./migrations")] +async fn list_mangas_paginates(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let mut author_id = String::new(); + for i in 0..5 { + let body = create_manga( + &h.app, + &cookie, + json!({ "title": format!("Vol {i}"), "authors": ["Solo"] }), + ) + .await; + if author_id.is_empty() { + author_id = first_author_id(&body); + } + } + + let resp = h + .app + .oneshot(common::get(&format!( + "/api/v1/authors/{author_id}/mangas?limit=2&offset=1" + ))) + .await + .unwrap(); + let body = common::body_json(resp).await; + assert_eq!(body["items"].as_array().unwrap().len(), 2); + assert_eq!(body["page"]["limit"], 2); + assert_eq!(body["page"]["offset"], 1); + // Total reflects unfiltered count, not page size. + assert_eq!(body["page"]["total"], 5); +} + +#[sqlx::test(migrations = "./migrations")] +async fn list_mangas_for_unknown_author_returns_empty(pool: PgPool) { + let h = common::harness(pool); + let resp = h + .app + .oneshot(common::get(&format!( + "/api/v1/authors/{}/mangas", + Uuid::new_v4() + ))) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = common::body_json(resp).await; + assert_eq!(body["items"], json!([])); + assert_eq!(body["page"]["total"], 0); +} + +#[sqlx::test(migrations = "./migrations")] +async fn search_authors_matches_substring_case_insensitively(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + + for name in ["Kentaro Miura", "Eiichiro Oda", "Makoto Yukimura"] { + let _ = create_manga( + &h.app, + &cookie, + json!({ "title": format!("Book of {name}"), "authors": [name] }), + ) + .await; + } + + // Substring is case-insensitive (ILIKE) — "miura" picks Kentaro + // Miura but NOT Makoto Yukimura (whose surname is Yukimura, not + // "Yumiura"). The trigram path is exercised in api_mangas.rs. + let resp = h + .app + .clone() + .oneshot(common::get("/api/v1/authors?search=miura")) + .await + .unwrap(); + let body = common::body_json(resp).await; + let names: Vec<&str> = body + .as_array() + .unwrap() + .iter() + .map(|a| a["name"].as_str().unwrap()) + .collect(); + assert_eq!(names, vec!["Kentaro Miura"]); + + // "yuki" matches only Makoto Yukimura. + let resp = h + .app + .oneshot(common::get("/api/v1/authors?search=yuki")) + .await + .unwrap(); + let body = common::body_json(resp).await; + let names: Vec<&str> = body + .as_array() + .unwrap() + .iter() + .map(|a| a["name"].as_str().unwrap()) + .collect(); + assert_eq!(names, vec!["Makoto Yukimura"]); +} + +#[sqlx::test(migrations = "./migrations")] +async fn search_authors_default_limit_caps_at_10(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + for i in 0..15 { + let _ = create_manga( + &h.app, + &cookie, + json!({ "title": format!("Book {i}"), "authors": [format!("Author {i}")] }), + ) + .await; + } + + let resp = h + .app + .oneshot(common::get("/api/v1/authors")) + .await + .unwrap(); + let body = common::body_json(resp).await; + // No search term: returns the default page (10), not all 15. + assert_eq!(body.as_array().unwrap().len(), 10); +} + +#[sqlx::test(migrations = "./migrations")] +async fn manga_count_drops_when_manga_deleted(pool: PgPool) { + let h = common::harness(pool.clone()); + let (_, cookie) = common::register_user(&h.app).await; + + let m1 = create_manga( + &h.app, + &cookie, + json!({ "title": "Vol 1", "authors": ["Solo"] }), + ) + .await; + let _ = create_manga( + &h.app, + &cookie, + json!({ "title": "Vol 2", "authors": ["Solo"] }), + ) + .await; + let author_id = first_author_id(&m1); + let manga_id = m1["id"].as_str().unwrap().to_string(); + + // No DELETE endpoint yet — delete via SQL to simulate cleanup. + sqlx::query("DELETE FROM mangas WHERE id = $1") + .bind(Uuid::parse_str(&manga_id).unwrap()) + .execute(&pool) + .await + .unwrap(); + + let resp = h + .app + .oneshot(common::get(&format!("/api/v1/authors/{author_id}"))) + .await + .unwrap(); + let body = common::body_json(resp).await; + // FK cascade on manga_authors keeps the count honest. + assert_eq!(body["manga_count"], 1); +} diff --git a/frontend/package.json b/frontend/package.json index 8616b45..1447949 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.15.0", + "version": "0.16.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/api/authors.test.ts b/frontend/src/lib/api/authors.test.ts new file mode 100644 index 0000000..0319a03 --- /dev/null +++ b/frontend/src/lib/api/authors.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest'; +import { listAuthors, getAuthor, listAuthorMangas } from './authors'; + +function ok(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' } + }); +} + +function envelope(status: number, code: string, message: string): Response { + return new Response(JSON.stringify({ error: { code, message } }), { + status, + headers: { 'content-type': 'application/json' } + }); +} + +describe('authors api client', () => { + let fetchSpy: MockInstance; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch'); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('listAuthors GETs /v1/authors with no params by default', async () => { + fetchSpy.mockResolvedValueOnce(ok([])); + await listAuthors(); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toMatch(/\/v1\/authors$/); + }); + + it('listAuthors encodes search, limit, offset', async () => { + fetchSpy.mockResolvedValueOnce(ok([])); + await listAuthors({ search: 'miura', limit: 5, offset: 0 }); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toContain('search=miura'); + expect(url).toContain('limit=5'); + expect(url).toContain('offset=0'); + }); + + it('getAuthor returns the AuthorWithCount shape', async () => { + fetchSpy.mockResolvedValueOnce( + ok({ + id: 'a1', + name: 'Kentaro Miura', + created_at: '2026-01-01T00:00:00Z', + manga_count: 3 + }) + ); + const a = await getAuthor('a1'); + expect(a.name).toBe('Kentaro Miura'); + expect(a.manga_count).toBe(3); + }); + + it('getAuthor surfaces 404 as ApiError', async () => { + fetchSpy.mockResolvedValueOnce(envelope(404, 'not_found', 'not found')); + await expect(getAuthor('missing')).rejects.toMatchObject({ + status: 404, + code: 'not_found' + }); + }); + + it('listAuthorMangas hits the nested route and forwards pagination', async () => { + fetchSpy.mockResolvedValueOnce( + ok({ + items: [ + { + id: 'm1', + title: 'Berserk', + status: 'ongoing', + alt_titles: [], + description: null, + cover_image_path: null, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z' + } + ], + page: { limit: 50, offset: 0, total: 1 } + }) + ); + const result = await listAuthorMangas('a1', { limit: 20, offset: 10 }); + expect(result.items[0].title).toBe('Berserk'); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toMatch(/\/v1\/authors\/a1\/mangas\?/); + expect(url).toContain('limit=20'); + expect(url).toContain('offset=10'); + }); +}); diff --git a/frontend/src/lib/api/authors.ts b/frontend/src/lib/api/authors.ts new file mode 100644 index 0000000..1add305 --- /dev/null +++ b/frontend/src/lib/api/authors.ts @@ -0,0 +1,56 @@ +import { request, type Page } from './client'; +import type { Manga } from './client'; + +export type Author = { + id: string; + name: string; + created_at: string; +}; + +/** Returned by `GET /v1/authors/:id` — adds the count of attached mangas. */ +export type AuthorWithCount = Author & { + manga_count: number; +}; + +export type AuthorMangasPage = { + items: Manga[]; + page: Page; +}; + +export type ListAuthorsOptions = { + search?: string; + limit?: number; + offset?: number; +}; + +export type ListAuthorMangasOptions = { + limit?: number; + offset?: number; +}; + +/** Autocomplete for author pickers. Server sorts by trigram similarity. */ +export async function listAuthors(opts: ListAuthorsOptions = {}): Promise { + 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(`/v1/authors${qs ? `?${qs}` : ''}`); +} + +export async function getAuthor(id: string): Promise { + return request(`/v1/authors/${encodeURIComponent(id)}`); +} + +export async function listAuthorMangas( + id: string, + opts: ListAuthorMangasOptions = {} +): Promise { + const params = new URLSearchParams(); + 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( + `/v1/authors/${encodeURIComponent(id)}/mangas${qs ? `?${qs}` : ''}` + ); +} diff --git a/frontend/src/lib/components/MangaCard.svelte b/frontend/src/lib/components/MangaCard.svelte new file mode 100644 index 0000000..7831c0c --- /dev/null +++ b/frontend/src/lib/components/MangaCard.svelte @@ -0,0 +1,107 @@ + + +
  • + +
    + {manga.title} + {#if authors.length > 0} + {authors.map((a) => a.name).join(', ')} + {/if} + {#if genres.length > 0} + {genres.map((g) => g.name).join(' · ')} + {/if} +
    +
  • + + diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 03605b3..fa2b5f3 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -5,20 +5,19 @@ import { page } from '$app/stores'; import { listMangas, - type MangaCard, + type MangaCard as MangaCardData, type MangaSort, type MangaStatus } from '$lib/api/mangas'; import { listGenres, type Genre } from '$lib/api/genres'; import { listTags, type Tag } from '$lib/api/tags'; - import { fileUrl } from '$lib/api/client'; import Chip from '$lib/components/Chip.svelte'; + import MangaCard from '$lib/components/MangaCard.svelte'; import Search from '@lucide/svelte/icons/search'; - import BookImage from '@lucide/svelte/icons/book-image'; import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal'; import Plus from '@lucide/svelte/icons/plus'; - let mangas: MangaCard[] = $state([]); + let mangas: MangaCardData[] = $state([]); let search = $state(''); let sort: MangaSort = $state('recent'); let statusFilter = $state<'' | MangaStatus>(''); @@ -389,35 +388,7 @@ {/if} {/if} @@ -648,64 +619,4 @@ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: var(--space-4); } - - .manga-card { - display: flex; - flex-direction: column; - gap: var(--space-2); - } - - .cover-link { - display: block; - line-height: 0; - } - - .cover { - width: 100%; - aspect-ratio: 2 / 3; - object-fit: cover; - border-radius: var(--radius-md); - background: var(--surface); - } - - .cover-placeholder { - display: flex; - align-items: center; - justify-content: center; - color: var(--text-muted); - user-select: none; - } - - .meta { - display: flex; - flex-direction: column; - min-width: 0; - gap: var(--space-1); - } - - .title { - font-weight: var(--weight-semibold); - font-size: var(--font-sm); - line-height: var(--leading-tight); - color: var(--text); - overflow: hidden; - display: -webkit-box; - -webkit-line-clamp: 2; - line-clamp: 2; - -webkit-box-orient: vertical; - } - - .title:hover { - color: var(--primary); - text-decoration: none; - } - - .author, - .genres { - color: var(--text-muted); - font-size: var(--font-xs); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } diff --git a/frontend/src/routes/authors/[id]/+page.svelte b/frontend/src/routes/authors/[id]/+page.svelte new file mode 100644 index 0000000..cf1524f --- /dev/null +++ b/frontend/src/routes/authors/[id]/+page.svelte @@ -0,0 +1,96 @@ + + + + {author.name} — Mangalord + + + + +
    +

    {author.name}

    +

    + {author.manga_count} + {author.manga_count === 1 ? 'work' : 'works'} +

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

    + No mangas attributed to this author. +

    +{:else} + {#if total != null} +

    + Showing {mangas.length} of {total} +

    + {/if} +
      + {#each mangas as m (m.id)} + + {/each} +
    +{/if} + + diff --git a/frontend/src/routes/authors/[id]/+page.ts b/frontend/src/routes/authors/[id]/+page.ts new file mode 100644 index 0000000..f94b8b5 --- /dev/null +++ b/frontend/src/routes/authors/[id]/+page.ts @@ -0,0 +1,24 @@ +import { error } from '@sveltejs/kit'; +import { ApiError } from '$lib/api/client'; +import { getAuthor, listAuthorMangas } from '$lib/api/authors'; +import type { PageLoad } from './$types'; + +export const ssr = false; + +export const load: PageLoad = async ({ params }) => { + try { + const [author, mangas] = await Promise.all([ + getAuthor(params.id), + listAuthorMangas(params.id, { limit: 50 }) + ]); + return { author, mangas: mangas.items, total: mangas.page.total }; + } catch (e) { + // 404 surfaces as a real SvelteKit error so the framework shell + // renders the standard not-found page instead of the route's + // happy-path markup with undefined data. + if (e instanceof ApiError && e.status === 404) { + error(404, 'Author not found'); + } + throw e; + } +};