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

@@ -89,6 +89,25 @@ const mangasResponse = {
page: { limit: 100, offset: 0, total: 1 }
};
const pageSearchResponse = {
items: [
{
page_id: pageId,
chapter_id: chapterId,
manga_id: mangaId,
page_number: 5,
chapter_number: 1,
chapter_title: null,
manga_title: 'Berserk',
storage_key: `mangas/${mangaId}/chapters/${chapterId}/pages/0005.png`,
is_nsfw: true,
content_warnings: ['gore'],
rank: 0.9
}
],
page: { limit: 100, offset: 0, total: 1 }
};
async function mockSearch(page: Page) {
await page.route('**/api/v1/auth/config', (route) =>
route.fulfill({
@@ -125,6 +144,13 @@ async function mockSearch(page: Page) {
body: JSON.stringify({ items: distinct })
})
);
await page.route('**/api/v1/me/page-search*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(pageSearchResponse)
})
);
await page.route('**/api/v1/me/page-tags?**', (route) =>
route.fulfill({
status: 200,
@@ -248,4 +274,32 @@ test.describe('/search', () => {
`/manga/${mangaId}`
);
});
test('text search sets ?text= and renders page results', async ({ page }) => {
await mockSearch(page);
await page.setViewportSize(DESKTOP);
await page.goto('/search');
await page.getByTestId('search-text-input').fill('dragon');
await page.getByTestId('search-text-input').press('Enter');
await expect(page).toHaveURL(/text=dragon/);
await expect(page.getByTestId('search-content-list')).toBeVisible();
await expect(
page.getByTestId(`search-result-${pageId}`)
).toBeVisible();
});
test('content-warning toggle sets ?cw_include= and searches', async ({
page
}) => {
await mockSearch(page);
await page.setViewportSize(DESKTOP);
await page.goto('/search');
await page.getByTestId('cw-toggle-gore').click();
await expect(page).toHaveURL(/cw_include=gore/);
await expect(page.getByTestId('search-content-list')).toBeVisible();
});
});