feat: manga metadata with status, authors, genres, tags, and search filters (0.15.0)
Adds first-class manga metadata across the stack: - **Status** (ongoing / completed), **alternative titles**, normalized **multi-author** support, **curated genres** (13 seeded), and **free-form user tags** (case-insensitive, globally shared). Each is modelled as its own table joined to mangas; `mangas.author` is backfilled into `authors` + `manga_authors` and dropped. - New endpoints: `PATCH /v1/mangas/:id` (three-state `description`), `POST/DELETE /v1/mangas/:id/tags[/:tag_id]`, `GET /v1/genres`, `GET /v1/tags?search=`. - `GET /v1/mangas` now returns `MangaCard` (with authors + genres batched in) and supports `?status=`, `?author_id=`, `?genre_id=`, `?tag_id=` filters — AND across facets, with empty-array no-op semantics for the unnest primitive. - `GET /v1/mangas/:id` returns the enriched `MangaDetail` with tags. - Frontend: reusable `Chip` component; manga detail page renders authors as chips linking to `/authors/:id` (Phase 2), a status badge, alt titles, genres, and tags with inline add/remove (only the attacher sees remove); upload form supports multi-author / multi-genre / alt titles / status; search page gets a collapsible URL-synced filter panel with keyboard-navigable tag autocomplete. - 126 backend tests (incl. AND-across-facets primitive, case-insens author/tag de-dup, transactional create rollback, PATCH semantics for missing / null / set on description); 72 frontend tests + svelte-check clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -65,13 +65,16 @@ export async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
export type Manga = {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string | null;
|
||||
status: MangaStatus;
|
||||
alt_titles: string[];
|
||||
description: string | null;
|
||||
cover_image_path: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type MangaStatus = 'ongoing' | 'completed';
|
||||
|
||||
export type Page = {
|
||||
limit: number;
|
||||
offset: number;
|
||||
|
||||
33
frontend/src/lib/api/genres.test.ts
Normal file
33
frontend/src/lib/api/genres.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest';
|
||||
import { listGenres } from './genres';
|
||||
|
||||
function ok(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
describe('genres api client', () => {
|
||||
let fetchSpy: MockInstance<typeof globalThis.fetch>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('listGenres GETs /v1/genres and returns a flat array', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok([
|
||||
{ id: 'g1', name: 'Action' },
|
||||
{ id: 'g2', name: 'Comedy' }
|
||||
])
|
||||
);
|
||||
const genres = await listGenres();
|
||||
expect(genres.map((g) => g.name)).toEqual(['Action', 'Comedy']);
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/genres$/);
|
||||
});
|
||||
});
|
||||
9
frontend/src/lib/api/genres.ts
Normal file
9
frontend/src/lib/api/genres.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { request } from './client';
|
||||
import type { GenreRef } from './mangas';
|
||||
|
||||
export type Genre = GenreRef;
|
||||
|
||||
/** Returns the full curated genre vocabulary. The list is short, so no pagination. */
|
||||
export async function listGenres(): Promise<Genre[]> {
|
||||
return request<Genre[]>('/v1/genres');
|
||||
}
|
||||
@@ -1,13 +1,24 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest';
|
||||
import { listMangas, createManga, getManga } from './mangas';
|
||||
import {
|
||||
listMangas,
|
||||
createManga,
|
||||
getManga,
|
||||
updateManga,
|
||||
attachTag,
|
||||
detachTag
|
||||
} from './mangas';
|
||||
|
||||
function ok(body: unknown): Response {
|
||||
function ok(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
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,
|
||||
@@ -19,6 +30,30 @@ function emptyPage() {
|
||||
return { items: [], page: { limit: 50, offset: 0, total: null } };
|
||||
}
|
||||
|
||||
function cardFixture(extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'b1',
|
||||
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',
|
||||
authors: [{ id: 'a1', name: 'Kentaro Miura' }],
|
||||
genres: [],
|
||||
...extra
|
||||
};
|
||||
}
|
||||
|
||||
function detailFixture(extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
...cardFixture(),
|
||||
tags: [],
|
||||
...extra
|
||||
};
|
||||
}
|
||||
|
||||
describe('mangas api client', () => {
|
||||
let fetchSpy: MockInstance<typeof globalThis.fetch>;
|
||||
|
||||
@@ -37,54 +72,65 @@ describe('mangas api client', () => {
|
||||
expect(url).toMatch(/\/v1\/mangas$/);
|
||||
});
|
||||
|
||||
it('listMangas returns the paged envelope', async () => {
|
||||
it('listMangas returns the paged envelope with cards', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({
|
||||
items: [
|
||||
{
|
||||
id: 'b1',
|
||||
title: 'Berserk',
|
||||
author: 'Miura',
|
||||
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: null }
|
||||
items: [cardFixture()],
|
||||
page: { limit: 50, offset: 0, total: 1 }
|
||||
})
|
||||
);
|
||||
const result = await listMangas();
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0].title).toBe('Berserk');
|
||||
expect(result.page).toEqual({ limit: 50, offset: 0, total: null });
|
||||
expect(result.items[0].authors[0].name).toBe('Kentaro Miura');
|
||||
expect(result.page).toEqual({ limit: 50, offset: 0, total: 1 });
|
||||
});
|
||||
|
||||
it('listMangas encodes search, limit, offset, sort', async () => {
|
||||
it('listMangas encodes search, status, ids (csv), limit, offset, sort', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(ok(emptyPage()));
|
||||
await listMangas({ search: 'one piece', limit: 10, offset: 20, sort: 'title' });
|
||||
await listMangas({
|
||||
search: 'one piece',
|
||||
status: 'completed',
|
||||
authorIds: ['a1', 'a2'],
|
||||
genreIds: ['g1'],
|
||||
tagIds: ['t1', 't2'],
|
||||
limit: 10,
|
||||
offset: 20,
|
||||
sort: 'title'
|
||||
});
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/mangas\?/);
|
||||
expect(url).toContain('search=one+piece');
|
||||
expect(url).toContain('status=completed');
|
||||
// Multi-value facets land as a single comma-separated param so the
|
||||
// backend can apply pure AND semantics across the list.
|
||||
expect(url).toContain('author_id=a1%2Ca2');
|
||||
expect(url).toContain('genre_id=g1');
|
||||
expect(url).toContain('tag_id=t1%2Ct2');
|
||||
expect(url).toContain('limit=10');
|
||||
expect(url).toContain('offset=20');
|
||||
expect(url).toContain('sort=title');
|
||||
});
|
||||
|
||||
it('createManga POSTs multipart with metadata to /v1/mangas', async () => {
|
||||
it('getManga returns the enriched detail shape', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({
|
||||
id: 'abc',
|
||||
title: 'Berserk',
|
||||
author: 'Miura',
|
||||
description: null,
|
||||
cover_image_path: null,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z'
|
||||
})
|
||||
ok(detailFixture({ tags: [{ id: 't1', name: 'Seinen', added_by: 'u1' }] }))
|
||||
);
|
||||
const m = await createManga({ title: 'Berserk', author: 'Miura' });
|
||||
expect(m.title).toBe('Berserk');
|
||||
const m = await getManga('b1');
|
||||
expect(m.tags).toHaveLength(1);
|
||||
expect(m.tags[0].name).toBe('Seinen');
|
||||
expect(m.authors[0].name).toBe('Kentaro Miura');
|
||||
});
|
||||
|
||||
it('createManga POSTs multipart with the new metadata shape', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(ok(detailFixture()));
|
||||
await createManga({
|
||||
title: 'Berserk',
|
||||
authors: ['Kentaro Miura'],
|
||||
status: 'completed',
|
||||
alt_titles: ['ベルセルク'],
|
||||
genre_ids: ['g1']
|
||||
});
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/mangas$/);
|
||||
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||
@@ -94,13 +140,17 @@ describe('mangas api client', () => {
|
||||
const metadata = form.get('metadata') as Blob;
|
||||
expect(metadata).toBeInstanceOf(Blob);
|
||||
expect(metadata.type).toBe('application/json');
|
||||
// jsdom doesn't implement Blob.text(); read the bytes via FileReader.
|
||||
const text = await new Promise<string>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.readAsText(metadata);
|
||||
});
|
||||
expect(text).toBe(JSON.stringify({ title: 'Berserk', author: 'Miura' }));
|
||||
const parsed = JSON.parse(text);
|
||||
expect(parsed.title).toBe('Berserk');
|
||||
expect(parsed.authors).toEqual(['Kentaro Miura']);
|
||||
expect(parsed.status).toBe('completed');
|
||||
expect(parsed.alt_titles).toEqual(['ベルセルク']);
|
||||
expect(parsed.genre_ids).toEqual(['g1']);
|
||||
expect(form.get('cover')).toBeNull();
|
||||
// The browser sets Content-Type with boundary automatically when body
|
||||
// is a FormData — we must NOT set it ourselves.
|
||||
@@ -108,17 +158,7 @@ describe('mangas api client', () => {
|
||||
});
|
||||
|
||||
it('createManga attaches the cover Blob when supplied', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({
|
||||
id: 'abc',
|
||||
title: 'Berserk',
|
||||
author: null,
|
||||
description: null,
|
||||
cover_image_path: 'mangas/abc/cover.png',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z'
|
||||
})
|
||||
);
|
||||
fetchSpy.mockResolvedValueOnce(ok(detailFixture()));
|
||||
const cover = new Blob([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], { type: 'image/png' });
|
||||
await createManga({ title: 'Berserk' }, cover);
|
||||
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||
@@ -127,6 +167,45 @@ describe('mangas api client', () => {
|
||||
expect(got).toBeInstanceOf(Blob);
|
||||
});
|
||||
|
||||
it('updateManga PATCHes with the provided patch body', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(ok(detailFixture({ status: 'completed' })));
|
||||
const updated = await updateManga('b1', {
|
||||
status: 'completed',
|
||||
authors: ['Kentaro Miura', 'Studio Gaga']
|
||||
});
|
||||
expect(updated.status).toBe('completed');
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/mangas\/b1$/);
|
||||
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||
expect(init.method).toBe('PATCH');
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
status: 'completed',
|
||||
authors: ['Kentaro Miura', 'Studio Gaga']
|
||||
});
|
||||
});
|
||||
|
||||
it('attachTag POSTs the name and returns the TagRef', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({ id: 't9', name: 'Dark Fantasy', added_by: 'u1' }, 201)
|
||||
);
|
||||
const tag = await attachTag('b1', 'Dark Fantasy');
|
||||
expect(tag.name).toBe('Dark Fantasy');
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/mangas\/b1\/tags$/);
|
||||
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||
expect(init.method).toBe('POST');
|
||||
expect(JSON.parse(init.body as string)).toEqual({ name: 'Dark Fantasy' });
|
||||
});
|
||||
|
||||
it('detachTag DELETEs the (manga, tag) pair', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(noContent());
|
||||
await detachTag('b1', 't9');
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/mangas\/b1\/tags\/t9$/);
|
||||
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||
expect(init.method).toBe('DELETE');
|
||||
});
|
||||
|
||||
it('getManga throws ApiError carrying the envelope code on non-2xx', async () => {
|
||||
fetchSpy.mockResolvedValue(envelope(404, 'not_found', 'manga not found'));
|
||||
await expect(getManga('missing')).rejects.toMatchObject({
|
||||
|
||||
@@ -1,22 +1,54 @@
|
||||
import { request, type Manga, type Page } from './client';
|
||||
import { request, type Manga, type MangaStatus, type Page } from './client';
|
||||
|
||||
export type MangaSort = 'recent' | 'title';
|
||||
|
||||
export type AuthorRef = { id: string; name: string };
|
||||
export type GenreRef = { id: string; name: string };
|
||||
export type TagRef = { id: string; name: string; added_by: string | null };
|
||||
|
||||
/** Card shape returned by `GET /v1/mangas` — authors + genres, no tags. */
|
||||
export type MangaCard = Manga & {
|
||||
authors: AuthorRef[];
|
||||
genres: GenreRef[];
|
||||
};
|
||||
|
||||
/** Detail shape returned by `GET /v1/mangas/:id`. Includes user tags. */
|
||||
export type MangaDetail = Manga & {
|
||||
authors: AuthorRef[];
|
||||
genres: GenreRef[];
|
||||
tags: TagRef[];
|
||||
};
|
||||
|
||||
export type ListOptions = {
|
||||
search?: string;
|
||||
status?: MangaStatus;
|
||||
/** AND across the list — every id must be attached to the manga. */
|
||||
authorIds?: string[];
|
||||
genreIds?: string[];
|
||||
tagIds?: string[];
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sort?: MangaSort;
|
||||
};
|
||||
|
||||
export type MangasPage = {
|
||||
items: Manga[];
|
||||
items: MangaCard[];
|
||||
page: Page;
|
||||
};
|
||||
|
||||
export async function listMangas(opts: ListOptions = {}): Promise<MangasPage> {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.search) params.set('search', opts.search);
|
||||
if (opts.status) params.set('status', opts.status);
|
||||
if (opts.authorIds && opts.authorIds.length) {
|
||||
params.set('author_id', opts.authorIds.join(','));
|
||||
}
|
||||
if (opts.genreIds && opts.genreIds.length) {
|
||||
params.set('genre_id', opts.genreIds.join(','));
|
||||
}
|
||||
if (opts.tagIds && opts.tagIds.length) {
|
||||
params.set('tag_id', opts.tagIds.join(','));
|
||||
}
|
||||
if (opts.limit != null) params.set('limit', String(opts.limit));
|
||||
if (opts.offset != null) params.set('offset', String(opts.offset));
|
||||
if (opts.sort) params.set('sort', opts.sort);
|
||||
@@ -24,14 +56,18 @@ export async function listMangas(opts: ListOptions = {}): Promise<MangasPage> {
|
||||
return request<MangasPage>(`/v1/mangas${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
|
||||
export async function getManga(id: string): Promise<Manga> {
|
||||
return request<Manga>(`/v1/mangas/${encodeURIComponent(id)}`);
|
||||
export async function getManga(id: string): Promise<MangaDetail> {
|
||||
return request<MangaDetail>(`/v1/mangas/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
export type NewManga = {
|
||||
title: string;
|
||||
author?: string | null;
|
||||
status?: MangaStatus;
|
||||
/** Author display names; resolved server-side, case-insensitive. */
|
||||
authors?: string[];
|
||||
description?: string | null;
|
||||
alt_titles?: string[];
|
||||
genre_ids?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -40,14 +76,55 @@ export type NewManga = {
|
||||
* automatically when `body` is a FormData, so we deliberately do not set
|
||||
* Content-Type ourselves.
|
||||
*/
|
||||
export async function createManga(input: NewManga, cover?: Blob): Promise<Manga> {
|
||||
export async function createManga(
|
||||
input: NewManga,
|
||||
cover?: Blob
|
||||
): Promise<MangaDetail> {
|
||||
const form = new FormData();
|
||||
form.append(
|
||||
'metadata',
|
||||
new Blob([JSON.stringify(input)], { type: 'application/json' })
|
||||
);
|
||||
if (cover) form.append('cover', cover);
|
||||
return request<Manga>('/v1/mangas', { method: 'POST', body: form });
|
||||
return request<MangaDetail>('/v1/mangas', { method: 'POST', body: form });
|
||||
}
|
||||
|
||||
export type { Manga, Page };
|
||||
export type MangaPatch = {
|
||||
title?: string;
|
||||
status?: MangaStatus;
|
||||
description?: string | null;
|
||||
alt_titles?: string[];
|
||||
authors?: string[];
|
||||
genre_ids?: string[];
|
||||
};
|
||||
|
||||
export async function updateManga(
|
||||
id: string,
|
||||
patch: MangaPatch
|
||||
): Promise<MangaDetail> {
|
||||
return request<MangaDetail>(`/v1/mangas/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(patch)
|
||||
});
|
||||
}
|
||||
|
||||
export async function attachTag(
|
||||
mangaId: string,
|
||||
name: string
|
||||
): Promise<TagRef> {
|
||||
return request<TagRef>(`/v1/mangas/${encodeURIComponent(mangaId)}/tags`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ name })
|
||||
});
|
||||
}
|
||||
|
||||
export async function detachTag(mangaId: string, tagId: string): Promise<void> {
|
||||
await request<void>(
|
||||
`/v1/mangas/${encodeURIComponent(mangaId)}/tags/${encodeURIComponent(tagId)}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
}
|
||||
|
||||
export type { Manga, MangaStatus, Page };
|
||||
|
||||
38
frontend/src/lib/api/tags.test.ts
Normal file
38
frontend/src/lib/api/tags.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest';
|
||||
import { listTags } from './tags';
|
||||
|
||||
function ok(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
describe('tags api client', () => {
|
||||
let fetchSpy: MockInstance<typeof globalThis.fetch>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('listTags GETs /v1/tags with no query when no opts', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(ok([]));
|
||||
await listTags();
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/tags$/);
|
||||
});
|
||||
|
||||
it('listTags encodes search + limit', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok([{ id: 't1', name: 'Mystery', created_at: '2026-01-01T00:00:00Z' }])
|
||||
);
|
||||
const tags = await listTags({ search: 'myst', limit: 5 });
|
||||
expect(tags[0].name).toBe('Mystery');
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toContain('search=myst');
|
||||
expect(url).toContain('limit=5');
|
||||
});
|
||||
});
|
||||
21
frontend/src/lib/api/tags.ts
Normal file
21
frontend/src/lib/api/tags.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { request } from './client';
|
||||
|
||||
export type Tag = {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type ListTagsOptions = {
|
||||
search?: string;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
/** Autocomplete for the tag input. Server sorts by trigram similarity. */
|
||||
export async function listTags(opts: ListTagsOptions = {}): Promise<Tag[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.search) params.set('search', opts.search);
|
||||
if (opts.limit != null) params.set('limit', String(opts.limit));
|
||||
const qs = params.toString();
|
||||
return request<Tag[]>(`/v1/tags${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
Reference in New Issue
Block a user