import { request, type Manga, type MangaStatus, type Page } from './client'; import type { ContentWarning } from './page_tags'; 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[]; /** Deduped union of content warnings across the manga's pages. */ content_warnings: ContentWarning[]; /** Total bytes of all this manga's stored chapter pages (cover * excluded). `null` when any page is unmeasured (show an em-dash); * `0` when there are no pages. */ chapter_storage_bytes: number | null; }; 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[]; /** Content warnings the manga must carry (all) / must not carry (any). */ cwInclude?: ContentWarning[]; cwExclude?: ContentWarning[]; limit?: number; offset?: number; sort?: MangaSort; }; export type MangasPage = { items: MangaCard[]; page: Page; }; export async function listMangas(opts: ListOptions = {}): Promise { 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.cwInclude && opts.cwInclude.length) { params.set('cw_include', opts.cwInclude.join(',')); } if (opts.cwExclude && opts.cwExclude.length) { params.set('cw_exclude', opts.cwExclude.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); const qs = params.toString(); return request(`/v1/mangas${qs ? `?${qs}` : ''}`); } export async function getManga(id: string): Promise { return request(`/v1/mangas/${encodeURIComponent(id)}`); } /** * GET /v1/mangas/:id/similar — up to 5 mangas ranked by tag overlap with * `id`. Returns a plain `{ items }` object (a fixed top-N, not a paginated * collection), so we unwrap to the card array the page wants. */ export async function getSimilarMangas(id: string): Promise { const res = await request<{ items: MangaCard[] }>( `/v1/mangas/${encodeURIComponent(id)}/similar` ); // Defensive: a malformed 200 body (items omitted) must still yield an // array so the page's `similar.length` guard can't throw. return res.items ?? []; } export type NewManga = { title: string; status?: MangaStatus; /** Author display names; resolved server-side, case-insensitive. */ authors?: string[]; description?: string | null; alt_titles?: string[]; genre_ids?: string[]; }; /** * POST /api/v1/mangas is multipart. The metadata part is JSON; the cover * part is the raw image bytes. The browser fills in the multipart boundary * automatically when `body` is a FormData, so we deliberately do not set * Content-Type ourselves. */ export async function createManga( input: NewManga, cover?: Blob ): Promise { const form = new FormData(); form.append( 'metadata', new Blob([JSON.stringify(input)], { type: 'application/json' }) ); if (cover) form.append('cover', cover); return request('/v1/mangas', { method: 'POST', body: form }); } 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 { return request(`/v1/mangas/${encodeURIComponent(id)}`, { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify(patch) }); } /** * PUT /api/v1/mangas/:id/cover (multipart). Replaces the cover image and * returns the refreshed detail. As with createManga the browser fills in * the multipart boundary automatically, so we must NOT set Content-Type. */ export async function updateMangaCover( id: string, cover: Blob ): Promise { const form = new FormData(); form.append('cover', cover); return request( `/v1/mangas/${encodeURIComponent(id)}/cover`, { method: 'PUT', body: form } ); } /** DELETE /api/v1/mangas/:id/cover. Returns the refreshed detail. */ export async function deleteMangaCover(id: string): Promise { return request( `/v1/mangas/${encodeURIComponent(id)}/cover`, { method: 'DELETE' } ); } export async function attachTag( mangaId: string, name: string ): Promise { return request(`/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 { await request( `/v1/mangas/${encodeURIComponent(mangaId)}/tags/${encodeURIComponent(tagId)}`, { method: 'DELETE' } ); } export type { Manga, MangaStatus, Page };