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:
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user