Files
Mangalord/frontend/src/routes/search/+page.svelte
MechaCat02 5b51bcf056 feat: results skeleton on the search page
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>
2026-07-07 07:14:43 +02:00

504 lines
16 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>