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