Files
Mangalord/frontend/src/lib/api/mangas.ts
MechaCat02 fa0a7da311 feat: edit existing manga metadata (0.31.0)
Adds PUT /mangas/:id/cover (multipart) and DELETE /mangas/:id/cover so
covers can be replaced or cleared after creation, and wires a dedicated
/manga/[id]/edit SvelteKit route that combines the existing PATCH with
the new cover endpoints. Cover PUT cleans up the old blob when the
extension changes, swallowing StorageError::NotFound so a manually-gone
file doesn't surface as a 404 to the client. Edit link on the manga
detail page is gated on session.user, matching the auth posture of the
underlying handlers.

Also pins the local-dev port story via loadEnv() in vite.config.ts so
VITE_PORT / BACKEND_URL from a (gitignored) .env keep the dev URL
stable across runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:26:23 +02:00

156 lines
4.7 KiB
TypeScript

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: 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);
const qs = params.toString();
return request<MangasPage>(`/v1/mangas${qs ? `?${qs}` : ''}`);
}
export async function getManga(id: string): Promise<MangaDetail> {
return request<MangaDetail>(`/v1/mangas/${encodeURIComponent(id)}`);
}
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<MangaDetail> {
const form = new FormData();
form.append(
'metadata',
new Blob([JSON.stringify(input)], { type: 'application/json' })
);
if (cover) form.append('cover', cover);
return request<MangaDetail>('/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<MangaDetail> {
return request<MangaDetail>(`/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<MangaDetail> {
const form = new FormData();
form.append('cover', cover);
return request<MangaDetail>(
`/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<MangaDetail> {
return request<MangaDetail>(
`/v1/mangas/${encodeURIComponent(id)}/cover`,
{ method: 'DELETE' }
);
}
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 };