Files
Mangalord/frontend/src/lib/components/AddToCollectionModal.svelte
MechaCat02 274cc819ca 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>
2026-05-17 17:43:06 +02:00

280 lines
8.2 KiB
Svelte

<script lang="ts">
import Modal from './Modal.svelte';
import {
addMangaToCollection,
createCollection,
listMyCollections,
getMyCollectionsContaining,
removeMangaFromCollection,
type CollectionSummary
} from '$lib/api/collections';
import Plus from '@lucide/svelte/icons/plus';
let {
open,
mangaId,
onClose
}: {
open: boolean;
mangaId: string;
onClose: () => void;
} = $props();
let collections = $state<CollectionSummary[]>([]);
let containingIds = $state<Set<string>>(new Set());
let busyIds = $state<Set<string>>(new Set());
let newName = $state('');
let creating = $state(false);
let loading = $state(false);
let error: string | null = $state(null);
// Refetch every time the modal opens (and when the manga id changes
// mid-session — unlikely but cheap). The data is per-user and per-
// manga, so re-fetching is the simplest way to stay in sync with
// changes made elsewhere (e.g., a collection deleted on another page).
$effect(() => {
if (open) {
void load();
}
});
async function load() {
loading = true;
error = null;
try {
const [page, ids] = await Promise.all([
listMyCollections({ limit: 200 }),
getMyCollectionsContaining(mangaId)
]);
collections = page.items;
containingIds = new Set(ids);
} catch (e) {
error = (e as Error).message;
} finally {
loading = false;
}
}
// Functional set updates that read the latest state at mutation
// time, so concurrent toggles on different rows don't clobber
// each other by building from a stale snapshot.
function withAdd<T>(s: Set<T>, v: T): Set<T> {
const n = new Set(s);
n.add(v);
return n;
}
function withDelete<T>(s: Set<T>, v: T): Set<T> {
const n = new Set(s);
n.delete(v);
return n;
}
async function toggle(collection: CollectionSummary) {
if (busyIds.has(collection.id)) return;
const wasIn = containingIds.has(collection.id);
// Optimistic toggle — local set first; revert on failure.
containingIds = wasIn
? withDelete(containingIds, collection.id)
: withAdd(containingIds, collection.id);
busyIds = withAdd(busyIds, collection.id);
try {
if (wasIn) {
await removeMangaFromCollection(collection.id, mangaId);
collection.manga_count = Math.max(0, collection.manga_count - 1);
} else {
await addMangaToCollection(collection.id, mangaId);
collection.manga_count += 1;
}
} catch (e) {
// Revert (read latest containingIds, not the pre-toggle snapshot).
containingIds = wasIn
? withAdd(containingIds, collection.id)
: withDelete(containingIds, collection.id);
error = (e as Error).message;
} finally {
busyIds = withDelete(busyIds, collection.id);
}
}
async function createAndAdd() {
const name = newName.trim();
if (!name || creating) return;
creating = true;
error = null;
try {
const created = await createCollection({ name });
// The list endpoint sorts by updated_at DESC; adding the
// manga immediately also bumps it. Append a synthetic
// summary so the new collection appears checked-on right
// away rather than waiting for a refetch.
await addMangaToCollection(created.id, mangaId);
collections = [
{
...created,
manga_count: 1,
sample_covers: []
},
...collections
];
containingIds = new Set([...containingIds, created.id]);
newName = '';
} catch (e) {
error = (e as Error).message;
} finally {
creating = false;
}
}
function onCreateSubmit(e: SubmitEvent) {
e.preventDefault();
void createAndAdd();
}
</script>
<Modal {open} {onClose} title="Add to collection" size="md" testid="add-to-collection-modal">
{#if loading}
<p class="status">Loading your collections…</p>
{:else if error}
<p class="error" role="alert" data-testid="add-to-collection-error">{error}</p>
{:else if collections.length === 0}
<p class="status" data-testid="no-collections">
You don't have any collections yet. Create one below to get started.
</p>
{:else}
<ul class="collection-list">
{#each collections as c (c.id)}
{@const checked = containingIds.has(c.id)}
{@const busy = busyIds.has(c.id)}
<li>
<label class="row" class:checked>
<input
type="checkbox"
{checked}
disabled={busy}
onchange={() => toggle(c)}
data-testid={`collection-toggle-${c.id}`}
/>
<span class="row-label">
<span class="row-name">{c.name}</span>
<span class="row-count">
{c.manga_count}
{c.manga_count === 1 ? 'manga' : 'mangas'}
</span>
</span>
</label>
</li>
{/each}
</ul>
{/if}
<form
class="create-form"
onsubmit={onCreateSubmit}
action="javascript:void(0)"
>
<input
type="text"
bind:value={newName}
maxlength="64"
placeholder="Create new collection"
aria-label="New collection name"
data-testid="new-collection-name"
/>
<button
type="submit"
class="create-btn"
disabled={!newName.trim() || creating}
data-testid="create-collection-btn"
>
<Plus size={14} aria-hidden="true" />
<span>{creating ? 'Creating…' : 'Create + add'}</span>
</button>
</form>
</Modal>
<style>
.status {
color: var(--text-muted);
}
.error {
color: var(--danger);
margin: 0 0 var(--space-2);
}
.collection-list {
list-style: none;
padding: 0;
margin: 0 0 var(--space-3);
display: flex;
flex-direction: column;
gap: var(--space-1);
max-height: 16rem;
overflow-y: auto;
}
.row {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2);
border-radius: var(--radius-md);
cursor: pointer;
transition: background var(--transition);
}
.row:hover {
background: var(--surface-elevated);
}
.row.checked {
background: var(--primary-soft-bg);
}
.row-label {
display: flex;
flex-direction: column;
min-width: 0;
}
.row-name {
color: var(--text);
font-weight: var(--weight-medium);
}
.row-count {
color: var(--text-muted);
font-size: var(--font-xs);
}
.create-form {
display: flex;
gap: var(--space-2);
align-items: center;
padding-top: var(--space-3);
border-top: 1px solid var(--border);
}
.create-form input {
flex: 1;
min-width: 0;
}
.create-btn {
display: inline-flex;
align-items: center;
gap: var(--space-1);
background: var(--primary);
color: var(--primary-contrast);
border: 1px solid var(--primary);
padding: 0 var(--space-3);
height: 36px;
white-space: nowrap;
}
.create-btn:hover:not(:disabled) {
background: var(--primary-hover);
border-color: var(--primary-hover);
}
</style>