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:
MechaCat02
2026-06-13 19:12:34 +02:00
parent bd39476ac7
commit d36f24e9af
11 changed files with 394 additions and 4 deletions

View File

@@ -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({

View File

@@ -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);

View File

@@ -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=');
});
});

View File

@@ -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