feat: manga metadata with status, authors, genres, tags, and search filters (0.15.0)
Adds first-class manga metadata across the stack: - **Status** (ongoing / completed), **alternative titles**, normalized **multi-author** support, **curated genres** (13 seeded), and **free-form user tags** (case-insensitive, globally shared). Each is modelled as its own table joined to mangas; `mangas.author` is backfilled into `authors` + `manga_authors` and dropped. - New endpoints: `PATCH /v1/mangas/:id` (three-state `description`), `POST/DELETE /v1/mangas/:id/tags[/:tag_id]`, `GET /v1/genres`, `GET /v1/tags?search=`. - `GET /v1/mangas` now returns `MangaCard` (with authors + genres batched in) and supports `?status=`, `?author_id=`, `?genre_id=`, `?tag_id=` filters — AND across facets, with empty-array no-op semantics for the unnest primitive. - `GET /v1/mangas/:id` returns the enriched `MangaDetail` with tags. - Frontend: reusable `Chip` component; manga detail page renders authors as chips linking to `/authors/:id` (Phase 2), a status badge, alt titles, genres, and tags with inline add/remove (only the attacher sees remove); upload form supports multi-author / multi-genre / alt titles / status; search page gets a collapsible URL-synced filter panel with keyboard-navigable tag autocomplete. - 126 backend tests (incl. AND-across-facets primitive, case-insens author/tag de-dup, transactional create rollback, PATCH semantics for missing / null / set on description); 72 frontend tests + svelte-check clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
30
backend/src/domain/author.rs
Normal file
30
backend/src/domain/author.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct Author {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Slimmer shape embedded in manga responses — id + name is all a chip
|
||||
/// needs to render and link to the author page.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct AuthorRef {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Returned by `GET /authors/:id`. `manga_count` is denormalized from
|
||||
/// `manga_authors` so the author page can show it without an extra
|
||||
/// round-trip.
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct AuthorWithCount {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub manga_count: i64,
|
||||
}
|
||||
14
backend/src/domain/genre.rs
Normal file
14
backend/src/domain/genre.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct Genre {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Same shape as `Genre` today, but kept distinct from the canonical
|
||||
/// type so future fields on `Genre` (description, slug…) don't bloat
|
||||
/// every manga payload.
|
||||
pub type GenreRef = Genre;
|
||||
@@ -3,20 +3,152 @@ use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::author::AuthorRef;
|
||||
use super::genre::GenreRef;
|
||||
use super::tag::TagRef;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct Manga {
|
||||
pub id: Uuid,
|
||||
pub title: String,
|
||||
pub author: Option<String>,
|
||||
pub status: String,
|
||||
pub alt_titles: Vec<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)]
|
||||
/// Shape returned by list endpoints. Cards show the title, authors and
|
||||
/// genres; tags are intentionally elided here to keep the response
|
||||
/// proportional to what a card actually renders.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct MangaCard {
|
||||
#[serde(flatten)]
|
||||
pub manga: Manga,
|
||||
pub authors: Vec<AuthorRef>,
|
||||
pub genres: Vec<GenreRef>,
|
||||
}
|
||||
|
||||
/// Shape returned by `GET /mangas/:id`. Adds user-added tags on top of
|
||||
/// the card fields.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct MangaDetail {
|
||||
#[serde(flatten)]
|
||||
pub manga: Manga,
|
||||
pub authors: Vec<AuthorRef>,
|
||||
pub genres: Vec<GenreRef>,
|
||||
pub tags: Vec<TagRef>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
pub struct NewManga {
|
||||
pub title: String,
|
||||
pub author: Option<String>,
|
||||
#[serde(default)]
|
||||
pub status: Option<String>,
|
||||
/// Author display names. Looked up case-insensitively; created on
|
||||
/// the fly when unseen.
|
||||
#[serde(default)]
|
||||
pub authors: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub alt_titles: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub genre_ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
pub struct MangaPatch {
|
||||
pub title: Option<String>,
|
||||
pub status: Option<String>,
|
||||
/// Three-state: missing key leaves the description alone; explicit
|
||||
/// `null` clears it; a string sets it. See `Patch` for details.
|
||||
#[serde(default)]
|
||||
pub description: Patch<String>,
|
||||
pub alt_titles: Option<Vec<String>>,
|
||||
/// When provided, replaces the manga's authors atomically.
|
||||
pub authors: Option<Vec<String>>,
|
||||
/// When provided, replaces the manga's genres atomically.
|
||||
pub genre_ids: Option<Vec<Uuid>>,
|
||||
}
|
||||
|
||||
/// Three-state container for nullable PATCH fields.
|
||||
///
|
||||
/// `serde`'s default behaviour collapses both "field missing" and
|
||||
/// "field is `null`" to `Option::None`, which means an `Option<T>`
|
||||
/// patch field can't distinguish "leave alone" from "set to NULL".
|
||||
/// `Patch<T>` carries that distinction by deserializing JSON `null`
|
||||
/// into `Clear` and any value into `Set`; with `#[serde(default)]` on
|
||||
/// the field, a missing key falls through to `Unchanged`.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub enum Patch<T> {
|
||||
/// Field absent from the request — leave the column untouched.
|
||||
#[default]
|
||||
Unchanged,
|
||||
/// Field present and explicitly `null` — set the column to NULL.
|
||||
Clear,
|
||||
/// Field present with a value — set the column to that value.
|
||||
Set(T),
|
||||
}
|
||||
|
||||
impl<T> Patch<T> {
|
||||
/// Whether the request indicated this field should be written
|
||||
/// (either to a new value or to NULL).
|
||||
pub fn is_provided(&self) -> bool {
|
||||
!matches!(self, Patch::Unchanged)
|
||||
}
|
||||
|
||||
/// The value to bind when writing, or `None` for `Unchanged`/`Clear`.
|
||||
pub fn set_value(&self) -> Option<&T> {
|
||||
match self {
|
||||
Patch::Set(v) => Some(v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de, T> serde::Deserialize<'de> for Patch<T>
|
||||
where
|
||||
T: serde::Deserialize<'de>,
|
||||
{
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
Option::<T>::deserialize(deserializer).map(|opt| match opt {
|
||||
Some(v) => Patch::Set(v),
|
||||
None => Patch::Clear,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Holder {
|
||||
#[serde(default)]
|
||||
desc: Patch<String>,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_key_is_unchanged() {
|
||||
let h: Holder = serde_json::from_value(json!({})).unwrap();
|
||||
assert_eq!(h.desc, Patch::Unchanged);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_null_is_clear() {
|
||||
let h: Holder = serde_json::from_value(json!({ "desc": null })).unwrap();
|
||||
assert_eq!(h.desc, Patch::Clear);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_is_set() {
|
||||
let h: Holder = serde_json::from_value(json!({ "desc": "x" })).unwrap();
|
||||
assert_eq!(h.desc, Patch::Set("x".into()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
pub mod api_token;
|
||||
pub mod author;
|
||||
pub mod bookmark;
|
||||
pub mod chapter;
|
||||
pub mod genre;
|
||||
pub mod manga;
|
||||
pub mod page;
|
||||
pub mod session;
|
||||
pub mod tag;
|
||||
pub mod user;
|
||||
pub mod user_preferences;
|
||||
|
||||
pub use api_token::ApiToken;
|
||||
pub use author::{Author, AuthorRef, AuthorWithCount};
|
||||
pub use bookmark::{Bookmark, BookmarkSummary};
|
||||
pub use chapter::Chapter;
|
||||
pub use manga::Manga;
|
||||
pub use genre::{Genre, GenreRef};
|
||||
pub use manga::{Manga, MangaCard, MangaDetail};
|
||||
pub use page::Page;
|
||||
pub use session::Session;
|
||||
pub use tag::{Tag, TagRef};
|
||||
pub use user::User;
|
||||
pub use user_preferences::UserPreferences;
|
||||
|
||||
20
backend/src/domain/tag.rs
Normal file
20
backend/src/domain/tag.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 Tag {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Tag embedded on a manga payload, including the user who attached it
|
||||
/// so the UI can render a remove button only to them.
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct TagRef {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub added_by: Option<Uuid>,
|
||||
}
|
||||
Reference in New Issue
Block a user