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:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1517,7 +1517,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.70.0"
|
version = "0.71.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.70.0"
|
version = "0.71.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,25 @@ const mangasResponse = {
|
|||||||
page: { limit: 100, offset: 0, total: 1 }
|
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) {
|
async function mockSearch(page: Page) {
|
||||||
await page.route('**/api/v1/auth/config', (route) =>
|
await page.route('**/api/v1/auth/config', (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
@@ -125,6 +144,13 @@ async function mockSearch(page: Page) {
|
|||||||
body: JSON.stringify({ items: distinct })
|
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) =>
|
await page.route('**/api/v1/me/page-tags?**', (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -248,4 +274,32 @@ test.describe('/search', () => {
|
|||||||
`/manga/${mangaId}`
|
`/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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.70.0",
|
"version": "0.71.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -267,6 +267,14 @@ describe('mangas api client', () => {
|
|||||||
expect(await getSimilarMangas('b1')).toEqual([]);
|
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 () => {
|
it('getManga throws ApiError carrying the envelope code on non-2xx', async () => {
|
||||||
fetchSpy.mockResolvedValue(envelope(404, 'not_found', 'manga not found'));
|
fetchSpy.mockResolvedValue(envelope(404, 'not_found', 'manga not found'));
|
||||||
await expect(getManga('missing')).rejects.toMatchObject({
|
await expect(getManga('missing')).rejects.toMatchObject({
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { request, type Manga, type MangaStatus, type Page } from './client';
|
import { request, type Manga, type MangaStatus, type Page } from './client';
|
||||||
|
import type { ContentWarning } from './page_tags';
|
||||||
|
|
||||||
export type MangaSort = 'recent' | 'title';
|
export type MangaSort = 'recent' | 'title';
|
||||||
|
|
||||||
@@ -17,6 +18,8 @@ export type MangaDetail = Manga & {
|
|||||||
authors: AuthorRef[];
|
authors: AuthorRef[];
|
||||||
genres: GenreRef[];
|
genres: GenreRef[];
|
||||||
tags: TagRef[];
|
tags: TagRef[];
|
||||||
|
/** Deduped union of content warnings across the manga's pages. */
|
||||||
|
content_warnings: ContentWarning[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ListOptions = {
|
export type ListOptions = {
|
||||||
@@ -26,6 +29,9 @@ export type ListOptions = {
|
|||||||
authorIds?: string[];
|
authorIds?: string[];
|
||||||
genreIds?: string[];
|
genreIds?: string[];
|
||||||
tagIds?: string[];
|
tagIds?: string[];
|
||||||
|
/** Content warnings the manga must carry (all) / must not carry (any). */
|
||||||
|
cwInclude?: ContentWarning[];
|
||||||
|
cwExclude?: ContentWarning[];
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
sort?: MangaSort;
|
sort?: MangaSort;
|
||||||
@@ -49,6 +55,12 @@ export async function listMangas(opts: ListOptions = {}): Promise<MangasPage> {
|
|||||||
if (opts.tagIds && opts.tagIds.length) {
|
if (opts.tagIds && opts.tagIds.length) {
|
||||||
params.set('tag_id', opts.tagIds.join(','));
|
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.limit != null) params.set('limit', String(opts.limit));
|
||||||
if (opts.offset != null) params.set('offset', String(opts.offset));
|
if (opts.offset != null) params.set('offset', String(opts.offset));
|
||||||
if (opts.sort) params.set('sort', opts.sort);
|
if (opts.sort) params.set('sort', opts.sort);
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import {
|
|||||||
listMyPageTags,
|
listMyPageTags,
|
||||||
listMyDistinctPageTags,
|
listMyDistinctPageTags,
|
||||||
listTaggedChapters,
|
listTaggedChapters,
|
||||||
listTaggedMangas
|
listTaggedMangas,
|
||||||
|
searchPages
|
||||||
} from './page_tags';
|
} from './page_tags';
|
||||||
|
|
||||||
function ok(body: unknown, status = 200): Response {
|
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;
|
const url = fetchSpy.mock.calls[0][0] as string;
|
||||||
expect(url).toMatch(/\/v1\/me\/page-tags\/mangas\?tag=funny$/);
|
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=');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -67,6 +67,68 @@ export async function listMyPageTags(
|
|||||||
return request<MyPageTagsPage>(`/v1/me/page-tags${qs ? `?${qs}` : ''}`);
|
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(
|
export async function listMyDistinctPageTags(
|
||||||
prefix?: string,
|
prefix?: string,
|
||||||
limit?: number
|
limit?: number
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
import { resyncManga } from '$lib/api/admin';
|
import { resyncManga } from '$lib/api/admin';
|
||||||
import { chapterLabel } from '$lib/api/chapters';
|
import { chapterLabel } from '$lib/api/chapters';
|
||||||
import { listTags, type Tag } from '$lib/api/tags';
|
import { listTags, type Tag } from '$lib/api/tags';
|
||||||
|
import type { ContentWarning } from '$lib/api/page_tags';
|
||||||
import { session } from '$lib/session.svelte';
|
import { session } from '$lib/session.svelte';
|
||||||
import Chip from '$lib/components/Chip.svelte';
|
import Chip from '$lib/components/Chip.svelte';
|
||||||
import MangaCard from '$lib/components/MangaCard.svelte';
|
import MangaCard from '$lib/components/MangaCard.svelte';
|
||||||
@@ -62,6 +63,8 @@
|
|||||||
|
|
||||||
const authors = $derived<AuthorRef[]>(manga.authors);
|
const authors = $derived<AuthorRef[]>(manga.authors);
|
||||||
const genres = $derived<GenreRef[]>(manga.genres);
|
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. */
|
/** Up to 5 mangas with the most similar tags; empty hides the section. */
|
||||||
const similar = $derived(data.similar);
|
const similar = $derived(data.similar);
|
||||||
@@ -395,6 +398,15 @@
|
|||||||
</details>
|
</details>
|
||||||
{/if}
|
{/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}
|
{#if genres.length > 0}
|
||||||
<div class="chip-row" data-testid="manga-genres">
|
<div class="chip-row" data-testid="manga-genres">
|
||||||
<span class="chip-row-label">Genres</span>
|
<span class="chip-row-label">Genres</span>
|
||||||
@@ -785,6 +797,33 @@
|
|||||||
position: relative;
|
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 {
|
.tag-form {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -5,10 +5,16 @@
|
|||||||
import TaggedPageRow from '$lib/components/TaggedPageRow.svelte';
|
import TaggedPageRow from '$lib/components/TaggedPageRow.svelte';
|
||||||
import TaggedChapterRow from '$lib/components/TaggedChapterRow.svelte';
|
import TaggedChapterRow from '$lib/components/TaggedChapterRow.svelte';
|
||||||
import TaggedMangaRow from '$lib/components/TaggedMangaRow.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';
|
import X from '@lucide/svelte/icons/x';
|
||||||
|
|
||||||
let { data } = $props();
|
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 View = 'pages' | 'chapters' | 'mangas';
|
||||||
type Order = 'desc' | 'asc';
|
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) {
|
function setView(v: View) {
|
||||||
setParam('view', v === 'pages' ? null : v);
|
setParam('view', v === 'pages' ? null : v);
|
||||||
}
|
}
|
||||||
@@ -94,6 +137,61 @@
|
|||||||
{:else if data.error}
|
{:else if data.error}
|
||||||
<p class="error" role="alert" data-testid="search-error">{data.error}</p>
|
<p class="error" role="alert" data-testid="search-error">{data.error}</p>
|
||||||
{:else}
|
{: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
|
<!-- 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
|
an x to clear; when none is, the input doubles as the entry
|
||||||
point + autocomplete. -->
|
point + autocomplete. -->
|
||||||
@@ -230,6 +328,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -266,6 +365,50 @@
|
|||||||
max-width: 24rem;
|
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 {
|
.active-tag {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import {
|
|||||||
listMyPageTags,
|
listMyPageTags,
|
||||||
listTaggedChapters,
|
listTaggedChapters,
|
||||||
listTaggedMangas,
|
listTaggedMangas,
|
||||||
|
searchPages,
|
||||||
|
CONTENT_WARNINGS,
|
||||||
|
type ContentWarning,
|
||||||
|
type PageSearchItem,
|
||||||
type PageTagSummary,
|
type PageTagSummary,
|
||||||
type TaggedChapterAggregate,
|
type TaggedChapterAggregate,
|
||||||
type TaggedMangaAggregate,
|
type TaggedMangaAggregate,
|
||||||
@@ -16,6 +20,17 @@ export const ssr = false;
|
|||||||
type View = 'pages' | 'chapters' | 'mangas';
|
type View = 'pages' | 'chapters' | 'mangas';
|
||||||
type Order = 'desc' | 'asc';
|
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
|
* Loader for the /search page. URL is the source of truth — refresh
|
||||||
* or share a link lands the user on the same view.
|
* 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';
|
viewParam === 'chapters' || viewParam === 'mangas' ? viewParam : 'pages';
|
||||||
const order: Order = url.searchParams.get('order') === 'asc' ? 'asc' : 'desc';
|
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 = {
|
const empty = {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
tag,
|
tag,
|
||||||
view,
|
view,
|
||||||
order,
|
order,
|
||||||
|
text,
|
||||||
|
cwInclude,
|
||||||
|
cwExclude,
|
||||||
|
contentSearch,
|
||||||
distinct: [] as PageTagSummary[],
|
distinct: [] as PageTagSummary[],
|
||||||
pages: [] as TaggedPageItem[],
|
pages: [] as TaggedPageItem[],
|
||||||
chapters: [] as TaggedChapterAggregate[],
|
chapters: [] as TaggedChapterAggregate[],
|
||||||
mangas: [] as TaggedMangaAggregate[],
|
mangas: [] as TaggedMangaAggregate[],
|
||||||
|
results: [] as PageSearchItem[],
|
||||||
total: 0,
|
total: 0,
|
||||||
error: null as string | null
|
error: null as string | null
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const distinct = await listMyDistinctPageTags(undefined, 100);
|
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.
|
// No tag selected → just the chip cloud.
|
||||||
if (!tag) return { ...empty, distinct };
|
if (!tag) return { ...empty, distinct };
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user