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

2
backend/Cargo.lock generated
View File

@@ -1517,7 +1517,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mangalord"
version = "0.70.0"
version = "0.71.0"
dependencies = [
"anyhow",
"argon2",

View File

@@ -1,6 +1,6 @@
[package]
name = "mangalord"
version = "0.70.0"
version = "0.71.0"
edition = "2021"
default-run = "mangalord"

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();
});
});

View File

@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
"version": "0.70.0",
"version": "0.71.0",
"private": true,
"type": "module",
"scripts": {

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

View File

@@ -14,6 +14,7 @@
import { resyncManga } from '$lib/api/admin';
import { chapterLabel } from '$lib/api/chapters';
import { listTags, type Tag } from '$lib/api/tags';
import type { ContentWarning } from '$lib/api/page_tags';
import { session } from '$lib/session.svelte';
import Chip from '$lib/components/Chip.svelte';
import MangaCard from '$lib/components/MangaCard.svelte';
@@ -62,6 +63,8 @@
const authors = $derived<AuthorRef[]>(manga.authors);
const genres = $derived<GenreRef[]>(manga.genres);
/** Deduped content warnings across the manga's pages (analysis worker). */
const contentWarnings = $derived<ContentWarning[]>(manga.content_warnings ?? []);
/** Up to 5 mangas with the most similar tags; empty hides the section. */
const similar = $derived(data.similar);
@@ -395,6 +398,15 @@
</details>
{/if}
{#if contentWarnings.length > 0}
<div class="content-warnings" data-testid="manga-content-warnings" role="note">
<span class="cw-label">⚠ Content warning</span>
{#each contentWarnings as w (w)}
<span class="cw-chip">{w}</span>
{/each}
</div>
{/if}
{#if genres.length > 0}
<div class="chip-row" data-testid="manga-genres">
<span class="chip-row-label">Genres</span>
@@ -785,6 +797,33 @@
position: relative;
}
.content-warnings {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
margin: var(--space-3) 0;
padding: var(--space-2) var(--space-3);
border: 1px solid color-mix(in srgb, #d4762a 50%, transparent);
border-radius: var(--radius-md, 8px);
background: color-mix(in srgb, #d4762a 12%, transparent);
}
.cw-label {
font-size: var(--font-sm);
font-weight: 600;
color: #b85e1a;
}
.cw-chip {
font-size: var(--font-sm);
padding: 2px var(--space-2);
border-radius: var(--radius-sm, 4px);
background: color-mix(in srgb, #d4762a 22%, transparent);
color: var(--text);
text-transform: capitalize;
}
.tag-form {
display: inline-flex;
align-items: center;

View File

@@ -5,10 +5,16 @@
import TaggedPageRow from '$lib/components/TaggedPageRow.svelte';
import TaggedChapterRow from '$lib/components/TaggedChapterRow.svelte';
import TaggedMangaRow from '$lib/components/TaggedMangaRow.svelte';
import { CONTENT_WARNINGS, type ContentWarning } from '$lib/api/page_tags';
import X from '@lucide/svelte/icons/x';
let { data } = $props();
// Free-text content-search draft, seeded from the URL. The input is
// user-edited from here; the URL stays the source of truth on submit.
// svelte-ignore state_referenced_locally
let textDraft = $state(data.text);
type View = 'pages' | 'chapters' | 'mangas';
type Order = 'desc' | 'asc';
@@ -40,6 +46,43 @@
});
}
/** Update several URL params in one navigation. */
function setParams(updates: Record<string, string | null>) {
const url = new URL($page.url);
for (const [k, v] of Object.entries(updates)) {
if (v == null || v === '') url.searchParams.delete(k);
else url.searchParams.set(k, v);
}
void goto(url.toString(), {
replaceState: true,
keepFocus: true,
noScroll: true
});
}
function submitContentSearch(e: SubmitEvent) {
e.preventDefault();
setParam('text', textDraft.trim() || null);
}
/** Tri-state per warning: none → include → exclude → none. */
function cycleWarning(w: ContentWarning) {
const inc = new Set(data.cwInclude);
const exc = new Set(data.cwExclude);
if (inc.has(w)) {
inc.delete(w);
exc.add(w);
} else if (exc.has(w)) {
exc.delete(w);
} else {
inc.add(w);
}
setParams({
cw_include: [...inc].join(',') || null,
cw_exclude: [...exc].join(',') || null
});
}
function setView(v: View) {
setParam('view', v === 'pages' ? null : v);
}
@@ -94,6 +137,61 @@
{:else if data.error}
<p class="error" role="alert" data-testid="search-error">{data.error}</p>
{:else}
<!-- Content search: free-text over OCR + scene text, plus content
warning include/exclude toggles. Takes precedence over tag
browsing when a text query or an included warning is active. -->
<section class="filter content-search" aria-label="Content search">
<form
class="tag-form"
onsubmit={submitContentSearch}
action="javascript:void(0)"
>
<input
type="text"
bind:value={textDraft}
placeholder="Search page text (OCR + scene)…"
aria-label="Text search"
data-testid="search-text-input"
/>
</form>
<div class="cw-filters" data-testid="search-cw-filters">
<span class="cw-group-label">Warnings</span>
{#each CONTENT_WARNINGS as w (w)}
<button
type="button"
class="cw-toggle"
class:include={data.cwInclude.includes(w)}
class:exclude={data.cwExclude.includes(w)}
onclick={() => cycleWarning(w)}
aria-pressed={data.cwInclude.includes(w) ||
data.cwExclude.includes(w)}
title="Click to include, again to exclude, again to clear"
data-testid={`cw-toggle-${w}`}
>
{#if data.cwInclude.includes(w)}{:else if data.cwExclude.includes(w)}{/if}
{w}
</button>
{/each}
</div>
</section>
{#if data.contentSearch}
{#if data.results.length === 0}
<p class="hint" data-testid="search-content-empty">
No pages match this search.
</p>
{:else}
<ul class="list" data-testid="search-content-list">
{#each data.results as p (p.page_id)}
<TaggedPageRow
item={{ ...p, tag: '', tagged_at: '' }}
showTagPill={false}
testid={`search-result-${p.page_id}`}
/>
{/each}
</ul>
{/if}
{:else}
<!-- Tag filter. When a tag is selected it's shown as a chip with
an x to clear; when none is, the input doubles as the entry
point + autocomplete. -->
@@ -230,6 +328,7 @@
{/if}
{/if}
{/if}
{/if}
{/if}
<style>
@@ -266,6 +365,50 @@
max-width: 24rem;
}
.content-search {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--border);
}
.cw-filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-1);
}
.cw-group-label {
color: var(--text-muted);
font-size: var(--font-sm);
margin-right: var(--space-1);
}
.cw-toggle {
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius-pill);
padding: 2px var(--space-2);
font-size: var(--font-sm);
cursor: pointer;
text-transform: capitalize;
}
.cw-toggle.include {
background: var(--primary-soft-bg);
color: var(--primary);
border-color: var(--primary);
}
.cw-toggle.exclude {
background: color-mix(in srgb, var(--danger) 14%, transparent);
color: var(--danger);
border-color: var(--danger);
}
.active-tag {
display: flex;
align-items: center;

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 };