Sort the manga catalog by created / updated / title / author with an independent asc/desc direction control. An omitted `order` defaults per field (dates newest-first, text A→Z), matching the UI, so a bare `?sort=<field>` means the same thing in the browser and over the API; `sort=recent` remains a back-compat alias for `created`. - Backend: SortField/SortOrder parsed with validation (structured 422 on bad input), per-field default_order, NULLS LAST only on the nullable author key, and migration 0033 indexing mangas(updated_at DESC, id) to back the default sort and its id tie-break. - Frontend: catalog sort field + direction (SegmentedControl) on desktop and a mobile bottom sheet; pure helpers in $lib/mangaSort; keyboard-accessible direction control; visible Direction labels and a sheet "Done" button. - Tests: backend integration coverage (defaults, alias, invalid input, ascending-id tie-break), frontend unit + e2e. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
189 lines
6.1 KiB
TypeScript
189 lines
6.1 KiB
TypeScript
import { request, type Manga, type MangaStatus, type Page } from './client';
|
|
import type { ContentWarning } from './page_tags';
|
|
|
|
export type MangaSort = 'created' | 'updated' | 'title' | 'author';
|
|
export type SortOrder = 'asc' | 'desc';
|
|
|
|
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;
|
|
order?: SortOrder;
|
|
};
|
|
|
|
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.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);
|
|
if (opts.order) params.set('order', opts.order);
|
|
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)}`);
|
|
}
|
|
|
|
/**
|
|
* 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<MangaCard[]> {
|
|
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<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 };
|