feat: manga collections (0.17.0)

User-owned named lists of mangas with an add-to-collection modal on
the manga page and dedicated /collections and /collections/:id pages.

- Schema (0010): `collections` (per-user case-insensitive name
  uniqueness) + `collection_mangas` join with cascade FKs.
- Endpoints: full CRUD on `/v1/collections`, idempotent add/remove
  for `/v1/collections/:id/mangas`, and `/v1/mangas/:id/my-collections`
  for the modal's pre-checked state. Owner-mismatch surfaces as 404
  (not 403) so the API doesn't disclose collection existence to
  non-owners; the frontend funnels 401 to /login. Three-state PATCH
  via a new shared `domain::patch::Patch<T>` lets clients distinguish
  "leave alone", "clear", and "set" for description.
- Frontend: reusable `Modal` component (focus trap, opt-in
  backdrop close, ESC) and `AddToCollectionModal` with optimistic
  toggling that's race-safe under fast clicks. /collections page
  renders cover-collage cards; /collections/:id is editable with
  per-card remove. Top nav gets a Collections link.

155 backend tests (incl. 21 collection tests covering ownership,
idempotence, sample-cover enrichment, three-state PATCH, FK race);
88 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 17:43:06 +02:00
parent 5e92a2c450
commit 274cc819ca
24 changed files with 2689 additions and 100 deletions

View File

@@ -0,0 +1,50 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
use super::patch::Patch;
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Collection {
pub id: Uuid,
pub user_id: Uuid,
pub name: String,
pub description: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Shape returned by `GET /me/collections`. Enriched with the manga
/// count and up to three sample cover paths so a collection card can
/// render without extra round-trips.
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct CollectionSummary {
pub id: Uuid,
pub user_id: Uuid,
pub name: String,
pub description: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub manga_count: i64,
/// Cover image keys of up to three sample mangas (newest-added
/// first). `Vec<String>` rather than `Option<...>` so an empty
/// collection renders as `[]` rather than `null`.
pub sample_covers: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct NewCollection {
pub name: String,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct CollectionPatch {
pub name: Option<String>,
/// Three-state: missing key leaves description alone; explicit
/// `null` clears it; a string sets it. See `Patch`.
#[serde(default)]
pub description: Patch<String>,
}

View File

@@ -5,6 +5,7 @@ use uuid::Uuid;
use super::author::AuthorRef;
use super::genre::GenreRef;
use super::patch::Patch;
use super::tag::TagRef;
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
@@ -73,82 +74,6 @@ pub struct MangaPatch {
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()));
}
}
// `Patch<T>` lives in `super::patch` so other resources (collections,
// future PATCH endpoints) can reuse the same three-state semantics
// without re-importing through `manga::`.

View File

@@ -2,9 +2,11 @@ pub mod api_token;
pub mod author;
pub mod bookmark;
pub mod chapter;
pub mod collection;
pub mod genre;
pub mod manga;
pub mod page;
pub mod patch;
pub mod session;
pub mod tag;
pub mod user;
@@ -14,9 +16,11 @@ pub use api_token::ApiToken;
pub use author::{Author, AuthorRef, AuthorWithCount};
pub use bookmark::{Bookmark, BookmarkSummary};
pub use chapter::Chapter;
pub use collection::{Collection, CollectionSummary};
pub use genre::{Genre, GenreRef};
pub use manga::{Manga, MangaCard, MangaDetail};
pub use page::Page;
pub use patch::Patch;
pub use session::Session;
pub use tag::{Tag, TagRef};
pub use user::User;

View File

@@ -0,0 +1,81 @@
//! Three-state container for 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::Deserialize;
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()));
}
}