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>
156 lines
4.7 KiB
TypeScript
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 };
|