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

@@ -4,6 +4,10 @@ import {
listMyPageTags,
listTaggedChapters,
listTaggedMangas,
searchPages,
CONTENT_WARNINGS,
type ContentWarning,
type PageSearchItem,
type PageTagSummary,
type TaggedChapterAggregate,
type TaggedMangaAggregate,
@@ -16,6 +20,17 @@ export const ssr = false;
type View = 'pages' | 'chapters' | 'mangas';
type Order = 'desc' | 'asc';
/** Parse a CSV of content warnings, keeping only valid vocabulary values. */
function parseWarnings(raw: string | null): ContentWarning[] {
if (!raw) return [];
return raw
.split(',')
.map((s) => s.trim().toLowerCase())
.filter((s): s is ContentWarning =>
(CONTENT_WARNINGS as string[]).includes(s)
);
}
/**
* Loader for the /search page. URL is the source of truth — refresh
* or share a link lands the user on the same view.
@@ -37,21 +52,46 @@ export const load: PageLoad = async ({ url }) => {
viewParam === 'chapters' || viewParam === 'mangas' ? viewParam : 'pages';
const order: Order = url.searchParams.get('order') === 'asc' ? 'asc' : 'desc';
// Content-search inputs (OCR/scene text + content-warning filters).
const text = (url.searchParams.get('text') ?? '').trim();
const cwInclude = parseWarnings(url.searchParams.get('cw_include'));
const cwExclude = parseWarnings(url.searchParams.get('cw_exclude'));
// A positive filter (text or an included warning) is required for the
// backend; cw_exclude alone can't drive a search.
const contentSearch = text !== '' || cwInclude.length > 0;
const empty = {
authenticated: true,
tag,
view,
order,
text,
cwInclude,
cwExclude,
contentSearch,
distinct: [] as PageTagSummary[],
pages: [] as TaggedPageItem[],
chapters: [] as TaggedChapterAggregate[],
mangas: [] as TaggedMangaAggregate[],
results: [] as PageSearchItem[],
total: 0,
error: null as string | null
};
try {
const distinct = await listMyDistinctPageTags(undefined, 100);
// Content search takes precedence over tag browsing.
if (contentSearch) {
const r = await searchPages({
text: text || undefined,
cwInclude,
cwExclude,
limit: 100
});
return { ...empty, distinct, results: r.items, total: r.page.total ?? 0 };
}
// No tag selected → just the chip cloud.
if (!tag) return { ...empty, distinct };