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

@@ -1,36 +1,153 @@
<script lang="ts">
import { onMount } from 'svelte';
let {
onPrev,
onNext,
onToggle,
onLongPress,
testid = 'tap-zone'
}: {
onPrev: () => void;
onNext: () => void;
onToggle: () => void;
/**
* Fires when a touch lingers > 450ms on any zone without
* moving. The viewport-coord `anchor` lets the reader open a
* sheet anchored to the press location. Desktop pointers
* (`pointerType !== 'touch'`) never trigger this — right-click
* is the desktop entry point and lives in the reader.
*/
onLongPress?: (anchor: { x: number; y: number }) => void;
testid?: string;
} = $props();
const LONG_PRESS_MS = 450;
const MOVE_TOLERANCE = 8;
let pressTimer: ReturnType<typeof setTimeout> | null = null;
let pressStart: { x: number; y: number } | null = null;
// Self-expiring suppression: cleared either by an actual click
// reaching `withSuppress` or by the SUPPRESS_EXPIRY_MS fallback
// timer below. The latter matters when the user lifts off the
// tap zone after a long-press (finger slid onto the sheet) — no
// click is synthesized, so without an expiry the flag would leak
// and eat the next legitimate tap.
const SUPPRESS_EXPIRY_MS = 500;
let suppressNextClick = false;
let suppressExpiryTimer: ReturnType<typeof setTimeout> | null = null;
function clearPress() {
if (pressTimer != null) {
clearTimeout(pressTimer);
pressTimer = null;
}
pressStart = null;
}
function clearSuppress() {
suppressNextClick = false;
if (suppressExpiryTimer != null) {
clearTimeout(suppressExpiryTimer);
suppressExpiryTimer = null;
}
}
function onPointerDown(e: PointerEvent) {
if (!onLongPress) return;
if (e.pointerType !== 'touch') return;
clearPress();
pressStart = { x: e.clientX, y: e.clientY };
const startX = e.clientX;
const startY = e.clientY;
pressTimer = setTimeout(() => {
pressTimer = null;
suppressNextClick = true;
if (suppressExpiryTimer != null) clearTimeout(suppressExpiryTimer);
suppressExpiryTimer = setTimeout(() => {
suppressNextClick = false;
suppressExpiryTimer = null;
}, SUPPRESS_EXPIRY_MS);
onLongPress?.({ x: startX, y: startY });
}, LONG_PRESS_MS);
}
function onPointerMove(e: PointerEvent) {
if (pressTimer == null || pressStart == null) return;
const dx = e.clientX - pressStart.x;
const dy = e.clientY - pressStart.y;
if (Math.hypot(dx, dy) > MOVE_TOLERANCE) clearPress();
}
function onPointerUp() {
clearPress();
}
function onPointerCancel() {
clearPress();
}
function onScroll() {
// Scrolling while pressed almost always means the user is
// panning, not deliberately holding. Cancel.
clearPress();
}
function withSuppress(handler: () => void) {
return () => {
if (suppressNextClick) {
clearSuppress();
return;
}
handler();
};
}
// onMount only runs on the client, so the window-scroll listener
// is automatically guarded — no SSR check needed. The teardown
// also clears any in-flight long-press timer.
onMount(() => {
if (!onLongPress) return;
window.addEventListener('scroll', onScroll, true);
return () => {
window.removeEventListener('scroll', onScroll, true);
clearPress();
clearSuppress();
};
});
</script>
<div class="tap-zones" data-testid={testid}>
<button
type="button"
class="zone left"
onclick={onPrev}
onclick={withSuppress(onPrev)}
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerCancel}
aria-label="Previous page"
data-testid="{testid}-left"
></button>
<button
type="button"
class="zone center"
onclick={onToggle}
onclick={withSuppress(onToggle)}
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerCancel}
aria-label="Toggle controls"
data-testid="{testid}-center"
></button>
<button
type="button"
class="zone right"
onclick={onNext}
onclick={withSuppress(onNext)}
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerCancel}
aria-label="Next page"
data-testid="{testid}-right"
></button>