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

@@ -11,7 +11,9 @@
import { listTags, type Tag } from '$lib/api/tags';
import { session } from '$lib/session.svelte';
import Chip from '$lib/components/Chip.svelte';
import AddToCollectionModal from '$lib/components/AddToCollectionModal.svelte';
import Plus from '@lucide/svelte/icons/plus';
import FolderPlus from '@lucide/svelte/icons/folder-plus';
let { data } = $props();
const manga = $derived(data.manga);
@@ -148,6 +150,8 @@
}
const statusLabel = $derived(manga.status === 'completed' ? 'Completed' : 'Ongoing');
let collectionModalOpen = $state(false);
</script>
<svelte:head>
@@ -284,25 +288,44 @@
{/if}
{#if session.user}
<button
type="button"
class="bookmark"
class:active={mangaBookmark}
onclick={toggleBookmark}
disabled={busy}
aria-pressed={mangaBookmark ? 'true' : 'false'}
data-testid="bookmark-toggle"
>
{mangaBookmark ? '★ Bookmarked' : '☆ Bookmark'}
</button>
<div class="action-row">
<button
type="button"
class="action"
class:active={mangaBookmark}
onclick={toggleBookmark}
disabled={busy}
aria-pressed={mangaBookmark ? 'true' : 'false'}
data-testid="bookmark-toggle"
>
{mangaBookmark ? '★ Bookmarked' : '☆ Bookmark'}
</button>
<button
type="button"
class="action"
onclick={() => (collectionModalOpen = true)}
data-testid="add-to-collection-open"
>
<FolderPlus size={16} aria-hidden="true" />
<span>Add to collection</span>
</button>
</div>
{:else}
<a class="bookmark" href="/login" data-testid="bookmark-signin">
Sign in to bookmark
<a class="action" href="/login" data-testid="bookmark-signin">
Sign in to bookmark or collect
</a>
{/if}
</div>
</header>
{#if session.user}
<AddToCollectionModal
open={collectionModalOpen}
mangaId={manga.id}
onClose={() => (collectionModalOpen = false)}
/>
{/if}
<section aria-label="chapters">
<h2>Chapters</h2>
{#if chapters.length === 0}
@@ -475,11 +498,17 @@
font-size: var(--font-sm);
}
.bookmark {
.action-row {
display: flex;
gap: var(--space-2);
margin-top: var(--space-2);
flex-wrap: wrap;
}
.action {
display: inline-flex;
align-items: center;
gap: var(--space-2);
margin-top: var(--space-2);
padding: 0 var(--space-3);
height: 36px;
border: 1px solid var(--border-strong);
@@ -496,12 +525,12 @@
color var(--transition);
}
.bookmark:hover {
.action:hover {
background: var(--surface-elevated);
text-decoration: none;
}
.bookmark.active {
.action.active {
background: var(--warning-soft-bg);
border-color: var(--warning-border);
color: var(--text);