Files
Mangalord/frontend/src/lib/api/collections.ts
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

140 lines
3.9 KiB
TypeScript

import { ApiError, request, type Manga, type Page } from './client';
export type Collection = {
id: string;
user_id: string;
name: string;
description: string | null;
created_at: string;
updated_at: string;
};
/** Returned by `GET /v1/me/collections` — enriched for card rendering. */
export type CollectionSummary = Collection & {
manga_count: number;
/** Up to 3 cover image keys, newest-added first. */
sample_covers: string[];
};
export type CollectionsPage = {
items: CollectionSummary[];
page: Page;
};
export type CollectionMangasPage = {
items: Manga[];
page: Page;
};
export type NewCollection = {
name: string;
description?: string | null;
};
export type CollectionPatch = {
name?: string;
description?: string | null;
};
export type ListMyOptions = { limit?: number; offset?: number };
export async function listMyCollections(
opts: ListMyOptions = {}
): Promise<CollectionsPage> {
const params = new URLSearchParams();
if (opts.limit != null) params.set('limit', String(opts.limit));
if (opts.offset != null) params.set('offset', String(opts.offset));
const qs = params.toString();
return request<CollectionsPage>(`/v1/me/collections${qs ? `?${qs}` : ''}`);
}
/** Empty page on 401 so guest-rendering pages don't have to special-case. */
export async function listMyCollectionsOrEmpty(): Promise<CollectionsPage> {
try {
return await listMyCollections();
} catch (e) {
if (e instanceof ApiError && e.status === 401) {
return { items: [], page: { limit: 50, offset: 0, total: null } };
}
throw e;
}
}
export async function createCollection(
input: NewCollection
): Promise<Collection> {
return request<Collection>('/v1/collections', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(input)
});
}
export async function getCollection(id: string): Promise<Collection> {
return request<Collection>(`/v1/collections/${encodeURIComponent(id)}`);
}
export async function updateCollection(
id: string,
patch: CollectionPatch
): Promise<Collection> {
return request<Collection>(`/v1/collections/${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(patch)
});
}
export async function deleteCollection(id: string): Promise<void> {
await request<void>(`/v1/collections/${encodeURIComponent(id)}`, {
method: 'DELETE'
});
}
export async function listCollectionMangas(
id: string,
opts: ListMyOptions = {}
): Promise<CollectionMangasPage> {
const params = new URLSearchParams();
if (opts.limit != null) params.set('limit', String(opts.limit));
if (opts.offset != null) params.set('offset', String(opts.offset));
const qs = params.toString();
return request<CollectionMangasPage>(
`/v1/collections/${encodeURIComponent(id)}/mangas${qs ? `?${qs}` : ''}`
);
}
export async function addMangaToCollection(
collectionId: string,
mangaId: string
): Promise<void> {
await request<void>(
`/v1/collections/${encodeURIComponent(collectionId)}/mangas`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ manga_id: mangaId })
}
);
}
export async function removeMangaFromCollection(
collectionId: string,
mangaId: string
): Promise<void> {
await request<void>(
`/v1/collections/${encodeURIComponent(collectionId)}/mangas/${encodeURIComponent(mangaId)}`,
{ method: 'DELETE' }
);
}
/** Which of the user's collections currently contain this manga. */
export async function getMyCollectionsContaining(
mangaId: string
): Promise<string[]> {
const r = await request<{ collection_ids: string[] }>(
`/v1/mangas/${encodeURIComponent(mangaId)}/my-collections`
);
return r.collection_ids;
}