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>
This commit is contained in:
158
frontend/src/lib/api/collections.test.ts
Normal file
158
frontend/src/lib/api/collections.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest';
|
||||
import {
|
||||
listMyCollections,
|
||||
listMyCollectionsOrEmpty,
|
||||
createCollection,
|
||||
getCollection,
|
||||
updateCollection,
|
||||
deleteCollection,
|
||||
listCollectionMangas,
|
||||
addMangaToCollection,
|
||||
removeMangaFromCollection,
|
||||
getMyCollectionsContaining
|
||||
} from './collections';
|
||||
|
||||
function ok(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
function noContent(): Response {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
function envelope(status: number, code: string, message: string): Response {
|
||||
return new Response(JSON.stringify({ error: { code, message } }), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
function collectionFixture(extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'c1',
|
||||
user_id: 'u1',
|
||||
name: 'Favorites',
|
||||
description: null,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
manga_count: 0,
|
||||
sample_covers: [],
|
||||
...extra
|
||||
};
|
||||
}
|
||||
|
||||
describe('collections api client', () => {
|
||||
let fetchSpy: MockInstance<typeof globalThis.fetch>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('listMyCollections returns the paged envelope', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({
|
||||
items: [collectionFixture()],
|
||||
page: { limit: 50, offset: 0, total: 1 }
|
||||
})
|
||||
);
|
||||
const result = await listMyCollections();
|
||||
expect(result.items[0].name).toBe('Favorites');
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/me\/collections$/);
|
||||
});
|
||||
|
||||
it('listMyCollectionsOrEmpty returns empty page on 401', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(envelope(401, 'unauthenticated', 'login required'));
|
||||
const result = await listMyCollectionsOrEmpty();
|
||||
expect(result.items).toEqual([]);
|
||||
expect(result.page.total).toBeNull();
|
||||
});
|
||||
|
||||
it('listMyCollectionsOrEmpty re-throws non-401 errors', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(envelope(500, 'internal_error', 'oops'));
|
||||
await expect(listMyCollectionsOrEmpty()).rejects.toMatchObject({ status: 500 });
|
||||
});
|
||||
|
||||
it('createCollection POSTs JSON to /v1/collections', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(ok(collectionFixture(), 201));
|
||||
const c = await createCollection({ name: 'Favorites' });
|
||||
expect(c.name).toBe('Favorites');
|
||||
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||
expect(init.method).toBe('POST');
|
||||
expect(JSON.parse(init.body as string)).toEqual({ name: 'Favorites' });
|
||||
});
|
||||
|
||||
it('getCollection encodes the id', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(ok(collectionFixture()));
|
||||
await getCollection('id with space');
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toContain('/v1/collections/id%20with%20space');
|
||||
});
|
||||
|
||||
it('updateCollection PATCHes with the patch body', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(ok(collectionFixture({ name: 'Read later' })));
|
||||
const updated = await updateCollection('c1', { name: 'Read later' });
|
||||
expect(updated.name).toBe('Read later');
|
||||
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||
expect(init.method).toBe('PATCH');
|
||||
expect(JSON.parse(init.body as string)).toEqual({ name: 'Read later' });
|
||||
});
|
||||
|
||||
it('deleteCollection issues DELETE', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(noContent());
|
||||
await deleteCollection('c1');
|
||||
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||
expect(init.method).toBe('DELETE');
|
||||
});
|
||||
|
||||
it('listCollectionMangas returns the paged envelope of mangas', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({
|
||||
items: [
|
||||
{
|
||||
id: 'm1',
|
||||
title: 'Berserk',
|
||||
status: 'ongoing',
|
||||
alt_titles: [],
|
||||
description: null,
|
||||
cover_image_path: null,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z'
|
||||
}
|
||||
],
|
||||
page: { limit: 50, offset: 0, total: 1 }
|
||||
})
|
||||
);
|
||||
const r = await listCollectionMangas('c1');
|
||||
expect(r.items[0].title).toBe('Berserk');
|
||||
});
|
||||
|
||||
it('addMangaToCollection POSTs the manga_id', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(ok({}, 201));
|
||||
await addMangaToCollection('c1', 'm9');
|
||||
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||
expect(init.method).toBe('POST');
|
||||
expect(JSON.parse(init.body as string)).toEqual({ manga_id: 'm9' });
|
||||
});
|
||||
|
||||
it('removeMangaFromCollection DELETEs the nested resource', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(noContent());
|
||||
await removeMangaFromCollection('c1', 'm9');
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/collections\/c1\/mangas\/m9$/);
|
||||
});
|
||||
|
||||
it('getMyCollectionsContaining returns the id list', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(ok({ collection_ids: ['c1', 'c3'] }));
|
||||
const ids = await getMyCollectionsContaining('m1');
|
||||
expect(ids).toEqual(['c1', 'c3']);
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/mangas\/m1\/my-collections$/);
|
||||
});
|
||||
});
|
||||
139
frontend/src/lib/api/collections.ts
Normal file
139
frontend/src/lib/api/collections.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user