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>
140 lines
3.9 KiB
TypeScript
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;
|
|
}
|