feat(search): frontend content search (text + warnings) + manga warning banner
Surfaces the analysis pipeline in the UI: - api clients: searchPages() + PageSearchItem/ContentWarning types; listMangas cwInclude/cwExclude; MangaDetail.content_warnings. - /search gains a content-search mode: free-text over OCR+scene and tri-state content-warning toggles (include/exclude), driven by URL params (text, cw_include, cw_exclude) and the new /me/page-search endpoint. Tag browsing is preserved when no content filter is active. - manga detail renders a deduped content-warning banner. Tests: vitest param serialization (searchPages CSV/text/cw, listMangas cw); Playwright text-search + cw-toggle flows (route-mocked). svelte-check + build clean; full vitest (258) + search e2e (7) green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -267,6 +267,14 @@ describe('mangas api client', () => {
|
||||
expect(await getSimilarMangas('b1')).toEqual([]);
|
||||
});
|
||||
|
||||
it('listMangas serializes content-warning include/exclude filters', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(ok(emptyPage()));
|
||||
await listMangas({ cwInclude: ['gore'], cwExclude: ['sexual', 'nudity'] });
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toContain('cw_include=gore');
|
||||
expect(url).toContain('cw_exclude=sexual%2Cnudity');
|
||||
});
|
||||
|
||||
it('getManga throws ApiError carrying the envelope code on non-2xx', async () => {
|
||||
fetchSpy.mockResolvedValue(envelope(404, 'not_found', 'manga not found'));
|
||||
await expect(getManga('missing')).rejects.toMatchObject({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { request, type Manga, type MangaStatus, type Page } from './client';
|
||||
import type { ContentWarning } from './page_tags';
|
||||
|
||||
export type MangaSort = 'recent' | 'title';
|
||||
|
||||
@@ -17,6 +18,8 @@ export type MangaDetail = Manga & {
|
||||
authors: AuthorRef[];
|
||||
genres: GenreRef[];
|
||||
tags: TagRef[];
|
||||
/** Deduped union of content warnings across the manga's pages. */
|
||||
content_warnings: ContentWarning[];
|
||||
};
|
||||
|
||||
export type ListOptions = {
|
||||
@@ -26,6 +29,9 @@ export type ListOptions = {
|
||||
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;
|
||||
@@ -49,6 +55,12 @@ export async function listMangas(opts: ListOptions = {}): Promise<MangasPage> {
|
||||
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);
|
||||
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
listMyPageTags,
|
||||
listMyDistinctPageTags,
|
||||
listTaggedChapters,
|
||||
listTaggedMangas
|
||||
listTaggedMangas,
|
||||
searchPages
|
||||
} from './page_tags';
|
||||
|
||||
function ok(body: unknown, status = 200): Response {
|
||||
@@ -163,4 +164,35 @@ describe('page_tags api client', () => {
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/me\/page-tags\/mangas\?tag=funny$/);
|
||||
});
|
||||
|
||||
it('searchPages serializes tags (CSV), text and content-warning filters', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({ items: [], page: { limit: 50, offset: 0, total: 0 } })
|
||||
);
|
||||
await searchPages({
|
||||
tags: ['fight', 'city'],
|
||||
text: 'dragon',
|
||||
cwInclude: ['gore'],
|
||||
cwExclude: ['sexual'],
|
||||
limit: 20
|
||||
});
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toContain('/v1/me/page-search?');
|
||||
expect(url).toContain('tags=fight%2Ccity');
|
||||
expect(url).toContain('text=dragon');
|
||||
expect(url).toContain('cw_include=gore');
|
||||
expect(url).toContain('cw_exclude=sexual');
|
||||
expect(url).toContain('limit=20');
|
||||
});
|
||||
|
||||
it('searchPages omits empty filters', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({ items: [], page: { limit: 50, offset: 0, total: 0 } })
|
||||
);
|
||||
await searchPages({ text: 'x', tags: [], cwInclude: [] });
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toContain('text=x');
|
||||
expect(url).not.toContain('tags=');
|
||||
expect(url).not.toContain('cw_include=');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,6 +67,68 @@ export async function listMyPageTags(
|
||||
return request<MyPageTagsPage>(`/v1/me/page-tags${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
|
||||
/** Closed content-warning vocabulary the analysis worker can flag. */
|
||||
export type ContentWarning = 'sexual' | 'nudity' | 'gore' | 'violence' | 'disturbing';
|
||||
|
||||
export const CONTENT_WARNINGS: ContentWarning[] = [
|
||||
'sexual',
|
||||
'nudity',
|
||||
'gore',
|
||||
'violence',
|
||||
'disturbing'
|
||||
];
|
||||
|
||||
/** Row returned by `GET /v1/me/page-search` (one per matching page). */
|
||||
export type PageSearchItem = {
|
||||
page_id: string;
|
||||
chapter_id: string;
|
||||
manga_id: string;
|
||||
page_number: number;
|
||||
chapter_number: number;
|
||||
chapter_title: string | null;
|
||||
manga_title: string;
|
||||
storage_key: string;
|
||||
is_nsfw: boolean;
|
||||
content_warnings: ContentWarning[];
|
||||
rank: number;
|
||||
};
|
||||
|
||||
export type PageSearchPage = {
|
||||
items: PageSearchItem[];
|
||||
page: Page;
|
||||
};
|
||||
|
||||
export type PageSearchOptions = {
|
||||
/** AND-ed tags (own page tags ∪ global auto-tags). */
|
||||
tags?: string[];
|
||||
/** Free-text query over OCR + scene description. */
|
||||
text?: string;
|
||||
/** Content warnings the page must carry (all). */
|
||||
cwInclude?: ContentWarning[];
|
||||
/** Content warnings the page must not carry (any). */
|
||||
cwExclude?: ContentWarning[];
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Content search over the caller's pages. The backend requires at least
|
||||
* one positive filter (tags, text, or cwInclude) — calling with none is a
|
||||
* 422.
|
||||
*/
|
||||
export async function searchPages(opts: PageSearchOptions = {}): Promise<PageSearchPage> {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.tags && opts.tags.length) params.set('tags', opts.tags.join(','));
|
||||
if (opts.text != null && opts.text !== '') params.set('text', opts.text);
|
||||
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));
|
||||
return request<PageSearchPage>(`/v1/me/page-search?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function listMyDistinctPageTags(
|
||||
prefix?: string,
|
||||
limit?: number
|
||||
|
||||
Reference in New Issue
Block a user