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:
80
backend/src/api/authors.rs
Normal file
80
backend/src/api/authors.rs
Normal 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)))
|
||||
}
|
||||
Reference in New Issue
Block a user