The search loader now awaits only the chip cloud (its auth gate) and streams the view-specific results, so switching tag / view / sort / text shows a SearchResultsSkeleton in place of the frozen previous list until the query resolves. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
504 lines
16 KiB
Svelte
504 lines
16 KiB
Svelte
<script lang="ts">
|
||
import { page } from '$app/stores';
|
||
import { goto } from '$app/navigation';
|
||
import SegmentedControl from '$lib/components/SegmentedControl.svelte';
|
||
import TaggedPageRow from '$lib/components/TaggedPageRow.svelte';
|
||
import TaggedChapterRow from '$lib/components/TaggedChapterRow.svelte';
|
||
import TaggedMangaRow from '$lib/components/TaggedMangaRow.svelte';
|
||
import SearchResultsSkeleton from '$lib/components/SearchResultsSkeleton.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';
|
||
|
||
const VIEWS: { label: string; value: View }[] = [
|
||
{ label: 'Pages', value: 'pages' },
|
||
{ label: 'Chapters', value: 'chapters' },
|
||
{ label: 'Mangas', value: 'mangas' }
|
||
];
|
||
|
||
const ORDERS: { label: string; value: Order }[] = [
|
||
{ label: 'Most pages', value: 'desc' },
|
||
{ label: 'Fewest pages', value: 'asc' }
|
||
];
|
||
|
||
/**
|
||
* Update a single URL param and re-trigger the SvelteKit loader
|
||
* via goto with replaceState — same pattern as /library tab
|
||
* persistence. Passing `null` (or the param's default value)
|
||
* removes the param so the URL stays clean.
|
||
*/
|
||
function setParam(key: string, value: string | null) {
|
||
const url = new URL($page.url);
|
||
if (value == null || value === '') url.searchParams.delete(key);
|
||
else url.searchParams.set(key, value);
|
||
void goto(url.toString(), {
|
||
replaceState: true,
|
||
keepFocus: true,
|
||
noScroll: true
|
||
});
|
||
}
|
||
|
||
/** 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);
|
||
}
|
||
function setOrder(o: Order) {
|
||
setParam('order', o === 'desc' ? null : 'asc');
|
||
}
|
||
function setTag(t: string | null) {
|
||
// Reset view + order when the tag changes, so a user picking
|
||
// a fresh tag lands on the Pages tab with default sort.
|
||
const url = new URL($page.url);
|
||
url.searchParams.delete('view');
|
||
url.searchParams.delete('order');
|
||
if (t == null || t === '') url.searchParams.delete('tag');
|
||
else url.searchParams.set('tag', t);
|
||
void goto(url.toString(), {
|
||
replaceState: true,
|
||
keepFocus: true,
|
||
noScroll: true
|
||
});
|
||
}
|
||
|
||
// Tag input draft for the autocomplete chip cloud / dropdown.
|
||
let draft = $state('');
|
||
const filteredCloud = $derived.by(() => {
|
||
const q = draft.trim().toLowerCase();
|
||
if (!q) return data.distinct;
|
||
return data.distinct.filter((s) => s.tag.startsWith(q));
|
||
});
|
||
|
||
function onSubmitTag(e: SubmitEvent) {
|
||
e.preventDefault();
|
||
const q = draft.trim().toLowerCase();
|
||
if (!q) return;
|
||
// Pick the typed text directly — the backend normalizes and
|
||
// returns an empty result if the tag doesn't exist for this
|
||
// user, which the empty-state line handles cleanly.
|
||
setTag(q);
|
||
draft = '';
|
||
}
|
||
</script>
|
||
|
||
<svelte:head>
|
||
<title>Mangalord | Page search</title>
|
||
</svelte:head>
|
||
|
||
<h1 class="heading">Page search</h1>
|
||
|
||
{#if !data.authenticated}
|
||
<p class="hint" data-testid="search-signin">
|
||
<a href="/login?next=/search">Sign in</a> to search your tags.
|
||
</p>
|
||
{: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}
|
||
{#await data.results}
|
||
<SearchResultsSkeleton />
|
||
{:then r}
|
||
{#if r.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 r.results as p (p.page_id)}
|
||
<TaggedPageRow
|
||
item={{ ...p, tag: '', tagged_at: '' }}
|
||
showTagPill={false}
|
||
testid={`search-result-${p.page_id}`}
|
||
/>
|
||
{/each}
|
||
</ul>
|
||
{/if}
|
||
{/await}
|
||
{: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. -->
|
||
<section class="filter" aria-label="Tag filter">
|
||
{#if data.tag}
|
||
<div class="active-tag" data-testid="search-active-tag">
|
||
<span class="tag-pill">
|
||
{data.tag}
|
||
<button
|
||
type="button"
|
||
class="tag-clear"
|
||
aria-label="Clear tag"
|
||
onclick={() => setTag(null)}
|
||
data-testid="search-clear-tag"
|
||
>
|
||
<X size={12} aria-hidden="true" />
|
||
</button>
|
||
</span>
|
||
</div>
|
||
{:else}
|
||
<form class="tag-form" onsubmit={onSubmitTag} action="javascript:void(0)">
|
||
<input
|
||
type="text"
|
||
bind:value={draft}
|
||
placeholder="Type a tag and press Enter"
|
||
aria-label="Tag"
|
||
data-testid="search-tag-input"
|
||
/>
|
||
</form>
|
||
{/if}
|
||
</section>
|
||
|
||
{#if !data.tag}
|
||
<!-- Empty state: chip cloud doubles as autocomplete result. -->
|
||
{#if data.distinct.length === 0}
|
||
<p class="hint" data-testid="search-no-tags">
|
||
You haven't tagged any pages yet. Open a chapter and
|
||
right-click (or long-press on mobile) a page to add a
|
||
tag.
|
||
</p>
|
||
{:else if filteredCloud.length === 0}
|
||
<p class="hint" data-testid="search-no-matches">
|
||
No tags match "{draft}".
|
||
</p>
|
||
{:else}
|
||
<p class="cloud-hint">Browse your tags</p>
|
||
<div class="chip-cloud" data-testid="search-chip-cloud">
|
||
{#each filteredCloud as s (s.tag)}
|
||
<button
|
||
type="button"
|
||
class="chip"
|
||
onclick={() => setTag(s.tag)}
|
||
data-testid={`search-chip-${s.tag}`}
|
||
>
|
||
{s.tag}
|
||
<span class="count">{s.count}</span>
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
<p class="empty-hint" data-testid="search-no-tag-selected">
|
||
No tag selected. Pick one above to see matching pages,
|
||
chapters, and mangas.
|
||
</p>
|
||
{/if}
|
||
{:else}
|
||
<!-- Tag selected: tabs + sort + results. -->
|
||
<div class="tab-row">
|
||
<SegmentedControl
|
||
ariaLabel="Result type"
|
||
value={data.view}
|
||
options={VIEWS}
|
||
onchange={setView}
|
||
testid="search-tabs"
|
||
/>
|
||
</div>
|
||
|
||
{#if data.view !== 'pages'}
|
||
<div class="tab-row">
|
||
<SegmentedControl
|
||
ariaLabel="Sort"
|
||
value={data.order}
|
||
options={ORDERS}
|
||
onchange={setOrder}
|
||
testid="search-sort"
|
||
/>
|
||
</div>
|
||
{/if}
|
||
|
||
{#await data.results}
|
||
<SearchResultsSkeleton />
|
||
{:then r}
|
||
{#if data.view === 'pages'}
|
||
{#if r.pages.length === 0}
|
||
<p class="hint" data-testid="search-pages-empty">
|
||
No pages tagged with "{data.tag}".
|
||
</p>
|
||
{:else}
|
||
<ul class="list" data-testid="search-pages-list">
|
||
{#each r.pages as p (p.page_id)}
|
||
<TaggedPageRow
|
||
item={p}
|
||
showTagPill={false}
|
||
testid={`search-page-row-${p.page_id}`}
|
||
/>
|
||
{/each}
|
||
</ul>
|
||
{/if}
|
||
{:else if data.view === 'chapters'}
|
||
{#if r.chapters.length === 0}
|
||
<p class="hint" data-testid="search-chapters-empty">
|
||
No chapters contain pages tagged with "{data.tag}".
|
||
</p>
|
||
{:else}
|
||
<ul class="list" data-testid="search-chapters-list">
|
||
{#each r.chapters as c (c.chapter_id)}
|
||
<TaggedChapterRow
|
||
item={c}
|
||
testid={`search-chapter-row-${c.chapter_id}`}
|
||
/>
|
||
{/each}
|
||
</ul>
|
||
{/if}
|
||
{:else}
|
||
{#if r.mangas.length === 0}
|
||
<p class="hint" data-testid="search-mangas-empty">
|
||
No mangas contain pages tagged with "{data.tag}".
|
||
</p>
|
||
{:else}
|
||
<ul class="list" data-testid="search-mangas-list">
|
||
{#each r.mangas as m (m.manga_id)}
|
||
<TaggedMangaRow
|
||
item={m}
|
||
testid={`search-manga-row-${m.manga_id}`}
|
||
/>
|
||
{/each}
|
||
</ul>
|
||
{/if}
|
||
{/if}
|
||
{/await}
|
||
{/if}
|
||
{/if}
|
||
{/if}
|
||
|
||
<style>
|
||
.heading {
|
||
margin-bottom: var(--space-3);
|
||
}
|
||
|
||
.hint,
|
||
.empty-hint {
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.empty-hint {
|
||
margin-top: var(--space-4);
|
||
text-align: center;
|
||
}
|
||
|
||
.cloud-hint {
|
||
margin: var(--space-3) 0 var(--space-2);
|
||
color: var(--text-muted);
|
||
font-size: var(--font-sm);
|
||
}
|
||
|
||
.error {
|
||
color: var(--danger);
|
||
}
|
||
|
||
.filter {
|
||
margin-bottom: var(--space-3);
|
||
}
|
||
|
||
.tag-form input {
|
||
width: 100%;
|
||
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;
|
||
}
|
||
|
||
.tag-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: var(--space-1);
|
||
background: var(--primary-soft-bg);
|
||
color: var(--primary);
|
||
border-radius: var(--radius-pill);
|
||
padding: 2px var(--space-2);
|
||
font-size: var(--font-sm);
|
||
}
|
||
|
||
.tag-clear {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: transparent;
|
||
color: inherit;
|
||
border: 0;
|
||
padding: 0;
|
||
cursor: pointer;
|
||
line-height: 0;
|
||
}
|
||
|
||
.tag-clear:hover {
|
||
color: var(--text);
|
||
}
|
||
|
||
.chip-cloud {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: var(--space-1);
|
||
margin-bottom: var(--space-3);
|
||
}
|
||
|
||
.chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: var(--space-1);
|
||
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;
|
||
}
|
||
|
||
.chip:hover {
|
||
background: var(--surface-elevated);
|
||
}
|
||
|
||
.count {
|
||
color: var(--text-muted);
|
||
font-size: var(--font-xs);
|
||
}
|
||
|
||
.tab-row {
|
||
margin-bottom: var(--space-3);
|
||
display: flex;
|
||
}
|
||
|
||
.tab-row :global(.segmented) {
|
||
width: 100%;
|
||
}
|
||
|
||
.tab-row :global(.seg) {
|
||
flex: 1;
|
||
}
|
||
|
||
.list {
|
||
list-style: none;
|
||
padding: 0;
|
||
margin: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-3);
|
||
}
|
||
</style>
|