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