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]]
|
||||
name = "mangalord"
|
||||
version = "0.70.0"
|
||||
version = "0.71.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mangalord"
|
||||
version = "0.70.0"
|
||||
version = "0.71.0"
|
||||
edition = "2021"
|
||||
default-run = "mangalord"
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.70.0",
|
||||
"version": "0.71.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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=');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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