diff --git a/backend/Cargo.lock b/backend/Cargo.lock index edb5ca0..3efd98e 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1517,7 +1517,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.70.0" +version = "0.71.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 3330664..632d0e3 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.70.0" +version = "0.71.0" edition = "2021" default-run = "mangalord" diff --git a/frontend/e2e/search.spec.ts b/frontend/e2e/search.spec.ts index d14f12b..460feab 100644 --- a/frontend/e2e/search.spec.ts +++ b/frontend/e2e/search.spec.ts @@ -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(); + }); }); diff --git a/frontend/package.json b/frontend/package.json index 185a94c..e7e2078 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.70.0", + "version": "0.71.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/api/mangas.test.ts b/frontend/src/lib/api/mangas.test.ts index b0d1e2a..ea7f21f 100644 --- a/frontend/src/lib/api/mangas.test.ts +++ b/frontend/src/lib/api/mangas.test.ts @@ -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({ diff --git a/frontend/src/lib/api/mangas.ts b/frontend/src/lib/api/mangas.ts index 199be90..c35ba56 100644 --- a/frontend/src/lib/api/mangas.ts +++ b/frontend/src/lib/api/mangas.ts @@ -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 { 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); diff --git a/frontend/src/lib/api/page_tags.test.ts b/frontend/src/lib/api/page_tags.test.ts index 46e3366..8c15911 100644 --- a/frontend/src/lib/api/page_tags.test.ts +++ b/frontend/src/lib/api/page_tags.test.ts @@ -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='); + }); }); diff --git a/frontend/src/lib/api/page_tags.ts b/frontend/src/lib/api/page_tags.ts index 0392777..512aec3 100644 --- a/frontend/src/lib/api/page_tags.ts +++ b/frontend/src/lib/api/page_tags.ts @@ -67,6 +67,68 @@ export async function listMyPageTags( return request(`/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 { + 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(`/v1/me/page-search?${params.toString()}`); +} + export async function listMyDistinctPageTags( prefix?: string, limit?: number diff --git a/frontend/src/routes/manga/[id]/+page.svelte b/frontend/src/routes/manga/[id]/+page.svelte index 625d7b0..ed493d5 100644 --- a/frontend/src/routes/manga/[id]/+page.svelte +++ b/frontend/src/routes/manga/[id]/+page.svelte @@ -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(manga.authors); const genres = $derived(manga.genres); + /** Deduped content warnings across the manga's pages (analysis worker). */ + const contentWarnings = $derived(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 @@ {/if} + {#if contentWarnings.length > 0} +
+ ⚠ Content warning + {#each contentWarnings as w (w)} + {w} + {/each} +
+ {/if} + {#if genres.length > 0}
Genres @@ -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; diff --git a/frontend/src/routes/search/+page.svelte b/frontend/src/routes/search/+page.svelte index b60db8c..ce2b133 100644 --- a/frontend/src/routes/search/+page.svelte +++ b/frontend/src/routes/search/+page.svelte @@ -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) { + 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} {:else} + + + + {#if data.contentSearch} + {#if data.results.length === 0} +

+ No pages match this search. +

+ {:else} +
    + {#each data.results as p (p.page_id)} + + {/each} +
+ {/if} + {:else} @@ -230,6 +328,7 @@ {/if} {/if} {/if} + {/if} {/if}