feat: author pages with /authors/:id route (0.16.0)

- `GET /v1/authors/:id` returns `AuthorWithCount` (id, name, manga_count).
- `GET /v1/authors/:id/mangas` paged works by that author.
- `GET /v1/authors?search=` autocomplete (already used by Phase 1 forms;
  now formally exposed).
- New `/authors/:id` page on the frontend; author chips on the manga
  detail page (added in Phase 1) now link to a real page.
- Extracts `lib/components/MangaCard.svelte` — already used by the home
  page, ready for the collection page in Phase 3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-17 14:39:11 +02:00
parent 59d380b6d7
commit 5e92a2c450
12 changed files with 739 additions and 96 deletions

View File

@@ -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<AppState> {
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<String>,
#[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<AppState>,
Query(params): Query<ListParams>,
) -> AppResult<Json<Vec<Author>>> {
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<AppState>,
Path(id): Path<Uuid>,
) -> AppResult<Json<AuthorWithCount>> {
Ok(Json(repo::author::find_with_count(&state.db, id).await?))
}
async fn list_mangas(
State(state): State<AppState>,
Path(id): Path<Uuid>,
Query(params): Query<MangaListParams>,
) -> AppResult<Json<PagedResponse<Manga>>> {
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)))
}

View File

@@ -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<AppState> {
.merge(bookmarks::routes())
.merge(genres::routes())
.merge(tags::routes())
.merge(authors::routes())
}