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:
@@ -6,14 +6,16 @@
|
||||
import SegmentedControl from '$lib/components/SegmentedControl.svelte';
|
||||
import BookmarkList from '$lib/components/BookmarkList.svelte';
|
||||
import CollectionsGrid from '$lib/components/CollectionsGrid.svelte';
|
||||
import PageTagsList from '$lib/components/PageTagsList.svelte';
|
||||
import BookImage from '@lucide/svelte/icons/book-image';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
type Tab = 'bookmarks' | 'collections' | 'history';
|
||||
type Tab = 'bookmarks' | 'collections' | 'page-tags' | 'history';
|
||||
const TABS: { label: string; value: Tab }[] = [
|
||||
{ label: 'Bookmarks', value: 'bookmarks' },
|
||||
{ label: 'Collections', value: 'collections' },
|
||||
{ label: 'Page tags', value: 'page-tags' },
|
||||
{ label: 'History', value: 'history' }
|
||||
];
|
||||
|
||||
@@ -22,7 +24,8 @@
|
||||
// default so visiting /library bare doesn't add noise to the URL.
|
||||
const activeTab: Tab = $derived.by(() => {
|
||||
const t = $page.url.searchParams.get('tab');
|
||||
return t === 'collections' || t === 'history' ? t : 'bookmarks';
|
||||
if (t === 'collections' || t === 'history' || t === 'page-tags') return t;
|
||||
return 'bookmarks';
|
||||
});
|
||||
|
||||
function setTab(t: Tab) {
|
||||
@@ -34,6 +37,8 @@
|
||||
const url = new URL($page.url);
|
||||
if (t === 'bookmarks') url.searchParams.delete('tab');
|
||||
else url.searchParams.set('tab', t);
|
||||
// Cast away the literal union — the SegmentedControl is
|
||||
// generic so its onchange receives `string`.
|
||||
void goto(url.toString(), {
|
||||
replaceState: true,
|
||||
keepFocus: true,
|
||||
@@ -81,6 +86,11 @@
|
||||
{:else}
|
||||
<CollectionsGrid collections={data.collections} />
|
||||
{/if}
|
||||
{:else if activeTab === 'page-tags'}
|
||||
<PageTagsList
|
||||
initialItems={data.pageTags}
|
||||
initialDistinct={data.distinctPageTags}
|
||||
/>
|
||||
{:else if data.history.length === 0}
|
||||
<p class="hint" data-testid="library-history-empty">
|
||||
Nothing here yet — open any manga and a row will land here once you turn
|
||||
|
||||
@@ -2,15 +2,19 @@ import { ApiError } from '$lib/api/client';
|
||||
import { listMyBookmarks } from '$lib/api/bookmarks';
|
||||
import { listMyCollections } from '$lib/api/collections';
|
||||
import { listMyReadProgress } from '$lib/api/read_progress';
|
||||
import {
|
||||
listMyPageTags,
|
||||
listMyDistinctPageTags
|
||||
} from '$lib/api/page_tags';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const ssr = false;
|
||||
|
||||
/**
|
||||
* Loads bookmarks + collections + reading-history in one shot so the
|
||||
* Library segmented control can swap between sub-tabs without firing a
|
||||
* second round trip per tap. 401 → unauthenticated path; the page
|
||||
* surfaces a sign-in prompt and renders empty lists.
|
||||
* Loads bookmarks + collections + history + page-tags in one shot so
|
||||
* the Library segmented control can swap between sub-tabs without
|
||||
* firing a second round trip per tap. 401 → unauthenticated path; the
|
||||
* page surfaces a sign-in prompt and renders empty lists.
|
||||
*/
|
||||
export const load: PageLoad = async () => {
|
||||
const empty = {
|
||||
@@ -18,19 +22,26 @@ export const load: PageLoad = async () => {
|
||||
bookmarks: [] as Awaited<ReturnType<typeof listMyBookmarks>>['items'],
|
||||
collections: [] as Awaited<ReturnType<typeof listMyCollections>>['items'],
|
||||
history: [] as Awaited<ReturnType<typeof listMyReadProgress>>['items'],
|
||||
pageTags: [] as Awaited<ReturnType<typeof listMyPageTags>>['items'],
|
||||
distinctPageTags: [] as Awaited<ReturnType<typeof listMyDistinctPageTags>>,
|
||||
error: null as string | null
|
||||
};
|
||||
try {
|
||||
const [bookmarks, collections, history] = await Promise.all([
|
||||
listMyBookmarks(),
|
||||
listMyCollections({ limit: 200 }),
|
||||
listMyReadProgress({ limit: 100 })
|
||||
]);
|
||||
const [bookmarks, collections, history, pageTags, distinctPageTags] =
|
||||
await Promise.all([
|
||||
listMyBookmarks(),
|
||||
listMyCollections({ limit: 200 }),
|
||||
listMyReadProgress({ limit: 100 }),
|
||||
listMyPageTags({ limit: 100 }),
|
||||
listMyDistinctPageTags(undefined, 100)
|
||||
]);
|
||||
return {
|
||||
...empty,
|
||||
bookmarks: bookmarks.items,
|
||||
collections: collections.items,
|
||||
history: history.items
|
||||
history: history.items,
|
||||
pageTags: pageTags.items,
|
||||
distinctPageTags
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 401) {
|
||||
|
||||
Reference in New Issue
Block a user