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>
280 lines
8.2 KiB
Svelte
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>
|