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:
MechaCat02
2026-05-17 14:32:03 +02:00
parent 60cc7712fa
commit 59d380b6d7
34 changed files with 3614 additions and 174 deletions

View 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,
}

View 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;

View File

@@ -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()));
}
}

View File

@@ -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
View 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>,
}