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

@@ -7,6 +7,7 @@
import { theme } from '$lib/theme.svelte';
import Upload from '@lucide/svelte/icons/upload';
import Bookmark from '@lucide/svelte/icons/bookmark';
import FolderOpen from '@lucide/svelte/icons/folder-open';
import Settings from '@lucide/svelte/icons/settings';
import LogOut from '@lucide/svelte/icons/log-out';
import '$lib/styles/tokens.css';
@@ -56,6 +57,10 @@
<Bookmark size={18} aria-hidden="true" />
<span>Bookmarks</span>
</a>
<a class="nav-link" href="/collections">
<FolderOpen size={18} aria-hidden="true" />
<span>Collections</span>
</a>
</nav>
<div class="session" data-testid="session-area">
{#if !session.loaded}