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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user