feat(search): tag-based page search surface + per-page tags & collections
Add the /search surface (Pages / Chapters / Mangas tabs) backed by per-user page tags and per-page collections: schema (migration 0023), backend endpoints for page tags/collections and tagged-page aggregations (with the OCR text-search param reserved at 501), plus the frontend API clients, library Page-tags tab, collection page sections, page context menu / AddTagsSheet, and reader long-press wiring. Includes the continuous-reader navigation fixes (?page=N handling, chapter-reset timing, back-button pops history) and tag-normalization hardening accumulated on the branch. Bump version 0.60.2 -> 0.62.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
351
frontend/src/routes/search/+page.svelte
Normal file
351
frontend/src/routes/search/+page.svelte
Normal file
@@ -0,0 +1,351 @@
|
||||
<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 X from '@lucide/svelte/icons/x';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
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}
|
||||
<!-- 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}
|
||||
|
||||
{#if data.view === 'pages'}
|
||||
{#if data.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 data.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 data.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 data.chapters as c (c.chapter_id)}
|
||||
<TaggedChapterRow
|
||||
item={c}
|
||||
testid={`search-chapter-row-${c.chapter_id}`}
|
||||
/>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{:else}
|
||||
{#if data.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 data.mangas as m (m.manga_id)}
|
||||
<TaggedMangaRow
|
||||
item={m}
|
||||
testid={`search-manga-row-${m.manga_id}`}
|
||||
/>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/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;
|
||||
}
|
||||
|
||||
.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>
|
||||
92
frontend/src/routes/search/+page.ts
Normal file
92
frontend/src/routes/search/+page.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { ApiError } from '$lib/api/client';
|
||||
import {
|
||||
listMyDistinctPageTags,
|
||||
listMyPageTags,
|
||||
listTaggedChapters,
|
||||
listTaggedMangas,
|
||||
type PageTagSummary,
|
||||
type TaggedChapterAggregate,
|
||||
type TaggedMangaAggregate,
|
||||
type TaggedPageItem
|
||||
} from '$lib/api/page_tags';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const ssr = false;
|
||||
|
||||
type View = 'pages' | 'chapters' | 'mangas';
|
||||
type Order = 'desc' | 'asc';
|
||||
|
||||
/**
|
||||
* Loader for the /search page. URL is the source of truth — refresh
|
||||
* or share a link lands the user on the same view.
|
||||
*
|
||||
* - `?tag=` exact-match tag filter. Empty → no results, just the
|
||||
* chip cloud for browsing.
|
||||
* - `?view=pages|chapters|mangas` — defaults to `pages` when omitted.
|
||||
* - `?order=desc|asc` — only meaningful for chapters/mangas tabs.
|
||||
* `desc` (most matches first) is the default.
|
||||
*
|
||||
* `?text=` is reserved for the planned OCR text-search input. The
|
||||
* backend rejects it with 501 + stable code
|
||||
* `text_search_not_yet_supported` today; the frontend never sets it.
|
||||
*/
|
||||
export const load: PageLoad = async ({ url }) => {
|
||||
const tag = url.searchParams.get('tag');
|
||||
const viewParam = url.searchParams.get('view');
|
||||
const view: View =
|
||||
viewParam === 'chapters' || viewParam === 'mangas' ? viewParam : 'pages';
|
||||
const order: Order = url.searchParams.get('order') === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
const empty = {
|
||||
authenticated: true,
|
||||
tag,
|
||||
view,
|
||||
order,
|
||||
distinct: [] as PageTagSummary[],
|
||||
pages: [] as TaggedPageItem[],
|
||||
chapters: [] as TaggedChapterAggregate[],
|
||||
mangas: [] as TaggedMangaAggregate[],
|
||||
total: 0,
|
||||
error: null as string | null
|
||||
};
|
||||
|
||||
try {
|
||||
const distinct = await listMyDistinctPageTags(undefined, 100);
|
||||
// No tag selected → just the chip cloud.
|
||||
if (!tag) return { ...empty, distinct };
|
||||
|
||||
if (view === 'chapters') {
|
||||
const r = await listTaggedChapters({ tag, order, limit: 100 });
|
||||
return {
|
||||
...empty,
|
||||
distinct,
|
||||
chapters: r.items,
|
||||
total: r.page.total ?? 0
|
||||
};
|
||||
}
|
||||
if (view === 'mangas') {
|
||||
const r = await listTaggedMangas({ tag, order, limit: 100 });
|
||||
return {
|
||||
...empty,
|
||||
distinct,
|
||||
mangas: r.items,
|
||||
total: r.page.total ?? 0
|
||||
};
|
||||
}
|
||||
const r = await listMyPageTags({ tag, limit: 100 });
|
||||
return {
|
||||
...empty,
|
||||
distinct,
|
||||
pages: r.items,
|
||||
total: r.page.total ?? 0
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 401) {
|
||||
return { ...empty, authenticated: false };
|
||||
}
|
||||
if (e instanceof ApiError) {
|
||||
return { ...empty, error: e.message };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user