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:
MechaCat02
2026-06-13 15:51:38 +02:00
parent 9910a0a995
commit 6c901e64c9
50 changed files with 6971 additions and 132 deletions

View 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>

View 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;
}
};