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:
@@ -8,15 +8,32 @@
|
||||
removeMangaFromCollection,
|
||||
type CollectionSummary
|
||||
} from '$lib/api/collections';
|
||||
import {
|
||||
addPageToCollection,
|
||||
getMyCollectionsContainingPage,
|
||||
removePageFromCollection
|
||||
} from '$lib/api/page_collections';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
|
||||
/**
|
||||
* Discriminated-union target so the same modal serves both the
|
||||
* manga-detail "Add to collection" flow and the reader's page
|
||||
* context menu. The list / pre-check / toggle / create-and-add
|
||||
* scaffolding is identical for both — only the leaf API call
|
||||
* differs, dispatched through `loadContaining` / `addTo` /
|
||||
* `removeFrom` below.
|
||||
*/
|
||||
export type Target =
|
||||
| { kind: 'manga'; id: string }
|
||||
| { kind: 'page'; id: string };
|
||||
|
||||
let {
|
||||
open,
|
||||
mangaId,
|
||||
target,
|
||||
onClose
|
||||
}: {
|
||||
open: boolean;
|
||||
mangaId: string;
|
||||
target: Target;
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
|
||||
@@ -28,23 +45,42 @@
|
||||
let loading = $state(false);
|
||||
let error: string | null = $state(null);
|
||||
|
||||
// Refetch every time the modal opens (and when the manga id changes
|
||||
// mid-session — unlikely but cheap). The data is per-user and per-
|
||||
// manga, so re-fetching is the simplest way to stay in sync with
|
||||
// changes made elsewhere (e.g., a collection deleted on another page).
|
||||
// Refetch on open and whenever the target changes (e.g., the user
|
||||
// right-clicks a different page while the modal is in flight). The
|
||||
// dependency on `target.id` is explicit so the $effect re-runs.
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
// Touching target.id wires the reactive dependency.
|
||||
void target.id;
|
||||
void load();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadContaining(t: Target): Promise<string[]> {
|
||||
return t.kind === 'manga'
|
||||
? getMyCollectionsContaining(t.id)
|
||||
: getMyCollectionsContainingPage(t.id);
|
||||
}
|
||||
|
||||
async function addTo(collectionId: string, t: Target): Promise<void> {
|
||||
return t.kind === 'manga'
|
||||
? addMangaToCollection(collectionId, t.id)
|
||||
: addPageToCollection(collectionId, t.id);
|
||||
}
|
||||
|
||||
async function removeFrom(collectionId: string, t: Target): Promise<void> {
|
||||
return t.kind === 'manga'
|
||||
? removeMangaFromCollection(collectionId, t.id)
|
||||
: removePageFromCollection(collectionId, t.id);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const [page, ids] = await Promise.all([
|
||||
listMyCollections({ limit: 200 }),
|
||||
getMyCollectionsContaining(mangaId)
|
||||
loadContaining(target)
|
||||
]);
|
||||
collections = page.items;
|
||||
containingIds = new Set(ids);
|
||||
@@ -55,9 +91,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Functional set updates that read the latest state at mutation
|
||||
// time, so concurrent toggles on different rows don't clobber
|
||||
// each other by building from a stale snapshot.
|
||||
function withAdd<T>(s: Set<T>, v: T): Set<T> {
|
||||
const n = new Set(s);
|
||||
n.add(v);
|
||||
@@ -72,21 +105,27 @@
|
||||
async function toggle(collection: CollectionSummary) {
|
||||
if (busyIds.has(collection.id)) return;
|
||||
const wasIn = containingIds.has(collection.id);
|
||||
// Optimistic toggle — local set first; revert on failure.
|
||||
containingIds = wasIn
|
||||
? withDelete(containingIds, collection.id)
|
||||
: withAdd(containingIds, collection.id);
|
||||
busyIds = withAdd(busyIds, collection.id);
|
||||
try {
|
||||
if (wasIn) {
|
||||
await removeMangaFromCollection(collection.id, mangaId);
|
||||
collection.manga_count = Math.max(0, collection.manga_count - 1);
|
||||
await removeFrom(collection.id, target);
|
||||
// manga_count drifts when pages are toggled — the
|
||||
// backend doesn't track page_count yet, so we only
|
||||
// adjust the count for the manga path. Page targets
|
||||
// leave manga_count as-is.
|
||||
if (target.kind === 'manga') {
|
||||
collection.manga_count = Math.max(0, collection.manga_count - 1);
|
||||
}
|
||||
} else {
|
||||
await addMangaToCollection(collection.id, mangaId);
|
||||
collection.manga_count += 1;
|
||||
await addTo(collection.id, target);
|
||||
if (target.kind === 'manga') {
|
||||
collection.manga_count += 1;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Revert (read latest containingIds, not the pre-toggle snapshot).
|
||||
containingIds = wasIn
|
||||
? withAdd(containingIds, collection.id)
|
||||
: withDelete(containingIds, collection.id);
|
||||
@@ -103,15 +142,11 @@
|
||||
error = null;
|
||||
try {
|
||||
const created = await createCollection({ name });
|
||||
// The list endpoint sorts by updated_at DESC; adding the
|
||||
// manga immediately also bumps it. Append a synthetic
|
||||
// summary so the new collection appears checked-on right
|
||||
// away rather than waiting for a refetch.
|
||||
await addMangaToCollection(created.id, mangaId);
|
||||
await addTo(created.id, target);
|
||||
collections = [
|
||||
{
|
||||
...created,
|
||||
manga_count: 1,
|
||||
manga_count: target.kind === 'manga' ? 1 : 0,
|
||||
sample_covers: []
|
||||
},
|
||||
...collections
|
||||
@@ -156,10 +191,20 @@
|
||||
/>
|
||||
<span class="row-label">
|
||||
<span class="row-name">{c.name}</span>
|
||||
<span class="row-count">
|
||||
{c.manga_count}
|
||||
{c.manga_count === 1 ? 'manga' : 'mangas'}
|
||||
</span>
|
||||
{#if target.kind === 'manga'}
|
||||
<!-- The count is the manga count only.
|
||||
Suppress it on page targets to
|
||||
avoid the rot of showing "0
|
||||
mangas" beside a collection that
|
||||
has 12 saved pages — the user
|
||||
would read the modal as broken.
|
||||
A backend page_count is a
|
||||
follow-up. -->
|
||||
<span class="row-count">
|
||||
{c.manga_count}
|
||||
{c.manga_count === 1 ? 'manga' : 'mangas'}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
|
||||
Reference in New Issue
Block a user