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>
314 lines
8.4 KiB
Svelte
314 lines
8.4 KiB
Svelte
<script lang="ts">
|
|
import { goto } from '$app/navigation';
|
|
import {
|
|
deleteCollection,
|
|
removeMangaFromCollection,
|
|
updateCollection
|
|
} from '$lib/api/collections';
|
|
import type { Manga } from '$lib/api/client';
|
|
import MangaCard from '$lib/components/MangaCard.svelte';
|
|
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
|
import Pencil from '@lucide/svelte/icons/pencil';
|
|
import Check from '@lucide/svelte/icons/check';
|
|
import Trash2 from '@lucide/svelte/icons/trash-2';
|
|
import X from '@lucide/svelte/icons/x';
|
|
|
|
let { data } = $props();
|
|
// svelte-ignore state_referenced_locally
|
|
let collection = $state({ ...data.collection });
|
|
// svelte-ignore state_referenced_locally
|
|
let mangas = $state<Manga[]>([...data.mangas]);
|
|
|
|
let editing = $state(false);
|
|
let editName = $state('');
|
|
let editDescription = $state('');
|
|
let editError: string | null = $state(null);
|
|
let editBusy = $state(false);
|
|
|
|
function startEdit() {
|
|
editName = collection.name;
|
|
editDescription = collection.description ?? '';
|
|
editError = null;
|
|
editing = true;
|
|
}
|
|
|
|
async function saveEdit() {
|
|
if (editBusy) return;
|
|
editBusy = true;
|
|
editError = null;
|
|
try {
|
|
const updated = await updateCollection(collection.id, {
|
|
name: editName.trim(),
|
|
description: editDescription.trim() || null
|
|
});
|
|
collection = updated;
|
|
editing = false;
|
|
} catch (e) {
|
|
editError = (e as Error).message;
|
|
} finally {
|
|
editBusy = false;
|
|
}
|
|
}
|
|
|
|
async function onDeleteCollection() {
|
|
if (!confirm(`Delete collection "${collection.name}"? This cannot be undone.`)) {
|
|
return;
|
|
}
|
|
try {
|
|
await deleteCollection(collection.id);
|
|
goto('/collections');
|
|
} catch (e) {
|
|
editError = (e as Error).message;
|
|
}
|
|
}
|
|
|
|
async function onRemoveManga(m: Manga) {
|
|
const snapshot = mangas;
|
|
mangas = mangas.filter((x) => x.id !== m.id);
|
|
try {
|
|
await removeMangaFromCollection(collection.id, m.id);
|
|
} catch (e) {
|
|
mangas = snapshot;
|
|
editError = (e as Error).message;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>{collection.name} — Mangalord</title>
|
|
</svelte:head>
|
|
|
|
<nav class="back">
|
|
<a href="/collections" class="back-link">
|
|
<ArrowLeft size={16} aria-hidden="true" />
|
|
<span>All collections</span>
|
|
</a>
|
|
</nav>
|
|
|
|
<header class="overview">
|
|
{#if editing}
|
|
<form
|
|
class="edit-form"
|
|
onsubmit={(e) => {
|
|
e.preventDefault();
|
|
void saveEdit();
|
|
}}
|
|
action="javascript:void(0)"
|
|
>
|
|
<input
|
|
type="text"
|
|
bind:value={editName}
|
|
maxlength="64"
|
|
required
|
|
aria-label="Collection name"
|
|
data-testid="collection-edit-name"
|
|
/>
|
|
<textarea
|
|
bind:value={editDescription}
|
|
rows="2"
|
|
maxlength="1024"
|
|
placeholder="Description (optional)"
|
|
aria-label="Collection description"
|
|
data-testid="collection-edit-description"
|
|
></textarea>
|
|
<div class="edit-actions">
|
|
<button
|
|
type="submit"
|
|
class="primary"
|
|
disabled={!editName.trim() || editBusy}
|
|
data-testid="collection-edit-save"
|
|
>
|
|
<Check size={14} aria-hidden="true" />
|
|
<span>Save</span>
|
|
</button>
|
|
<button type="button" onclick={() => (editing = false)} disabled={editBusy}>
|
|
<X size={14} aria-hidden="true" />
|
|
<span>Cancel</span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
{:else}
|
|
<div class="title-row">
|
|
<h1 data-testid="collection-name">{collection.name}</h1>
|
|
<button
|
|
type="button"
|
|
class="icon-btn"
|
|
onclick={startEdit}
|
|
aria-label="Edit collection"
|
|
title="Edit"
|
|
data-testid="collection-edit-open"
|
|
>
|
|
<Pencil size={16} aria-hidden="true" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="icon-btn danger"
|
|
onclick={onDeleteCollection}
|
|
aria-label="Delete collection"
|
|
title="Delete"
|
|
data-testid="collection-delete"
|
|
>
|
|
<Trash2 size={16} aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
{#if collection.description}
|
|
<p class="description" data-testid="collection-description">
|
|
{collection.description}
|
|
</p>
|
|
{/if}
|
|
{/if}
|
|
{#if editError}
|
|
<p class="error" role="alert">{editError}</p>
|
|
{/if}
|
|
</header>
|
|
|
|
{#if mangas.length === 0}
|
|
<p class="status" data-testid="collection-empty">
|
|
This collection is empty.
|
|
</p>
|
|
{:else}
|
|
<ul class="manga-grid" data-testid="collection-manga-list">
|
|
{#each mangas as m (m.id)}
|
|
<li class="card-with-remove">
|
|
<MangaCard manga={m} testid={`collection-manga-${m.id}`} />
|
|
<button
|
|
type="button"
|
|
class="remove"
|
|
onclick={() => onRemoveManga(m)}
|
|
aria-label={`Remove ${m.title} from collection`}
|
|
title="Remove from collection"
|
|
data-testid={`collection-remove-manga-${m.id}`}
|
|
>
|
|
<X size={14} aria-hidden="true" />
|
|
</button>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{/if}
|
|
|
|
<style>
|
|
.back {
|
|
margin-bottom: var(--space-3);
|
|
}
|
|
|
|
.back-link {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: var(--space-1);
|
|
color: var(--text-muted);
|
|
font-size: var(--font-sm);
|
|
}
|
|
|
|
.overview {
|
|
margin-bottom: var(--space-5);
|
|
}
|
|
|
|
.title-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-2);
|
|
}
|
|
|
|
.title-row h1 {
|
|
margin: 0;
|
|
flex: 1;
|
|
}
|
|
|
|
.description {
|
|
color: var(--text-muted);
|
|
margin: var(--space-2) 0 0;
|
|
white-space: pre-wrap;
|
|
}
|
|
|
|
.edit-form {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-2);
|
|
}
|
|
|
|
.edit-actions {
|
|
display: flex;
|
|
gap: var(--space-2);
|
|
}
|
|
|
|
.primary {
|
|
background: var(--primary);
|
|
color: var(--primary-contrast);
|
|
border-color: var(--primary);
|
|
}
|
|
|
|
.primary:hover:not(:disabled) {
|
|
background: var(--primary-hover);
|
|
border-color: var(--primary-hover);
|
|
}
|
|
|
|
.error {
|
|
color: var(--danger);
|
|
margin: var(--space-2) 0 0;
|
|
}
|
|
|
|
.status {
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.manga-grid {
|
|
list-style: none;
|
|
padding: 0;
|
|
margin: 0;
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
|
gap: var(--space-4);
|
|
}
|
|
|
|
.card-with-remove {
|
|
position: relative;
|
|
list-style: none;
|
|
}
|
|
|
|
.remove {
|
|
position: absolute;
|
|
top: var(--space-1);
|
|
right: var(--space-1);
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 24px;
|
|
height: 24px;
|
|
padding: 0;
|
|
background: rgba(0, 0, 0, 0.6);
|
|
color: white;
|
|
border: 0;
|
|
border-radius: 50%;
|
|
cursor: pointer;
|
|
opacity: 0;
|
|
transition: opacity var(--transition);
|
|
}
|
|
|
|
.card-with-remove:hover .remove,
|
|
.card-with-remove:focus-within .remove {
|
|
opacity: 1;
|
|
}
|
|
|
|
.icon-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 32px;
|
|
height: 32px;
|
|
padding: 0;
|
|
background: transparent;
|
|
color: var(--text-muted);
|
|
border: 1px solid transparent;
|
|
border-radius: var(--radius-sm);
|
|
}
|
|
|
|
.icon-btn:hover {
|
|
background: var(--surface-elevated);
|
|
color: var(--text);
|
|
}
|
|
|
|
.icon-btn.danger:hover {
|
|
color: var(--danger);
|
|
}
|
|
</style>
|