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:
@@ -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 };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user