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:
@@ -567,7 +567,7 @@
|
||||
{#if session.user}
|
||||
<AddToCollectionModal
|
||||
open={collectionModalOpen}
|
||||
mangaId={manga.id}
|
||||
target={{ kind: 'manga', id: manga.id }}
|
||||
onClose={() => (collectionModalOpen = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { goto, invalidateAll } from '$app/navigation';
|
||||
import { afterNavigate, goto, invalidateAll } from '$app/navigation';
|
||||
import { fileUrl, ApiError } from '$lib/api/client';
|
||||
import { GAP_PX, type ReaderPageGap } from '$lib/api/preferences';
|
||||
import { preferences } from '$lib/preferences.svelte';
|
||||
@@ -11,8 +11,18 @@
|
||||
import { readerFullscreen } from '$lib/reader-fullscreen.svelte';
|
||||
import { session } from '$lib/session.svelte';
|
||||
import Sheet from '$lib/components/Sheet.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import SegmentedControl from '$lib/components/SegmentedControl.svelte';
|
||||
import TapZone from '$lib/components/TapZone.svelte';
|
||||
import PageContextMenu from '$lib/components/PageContextMenu.svelte';
|
||||
import AddTagsSheet from '$lib/components/AddTagsSheet.svelte';
|
||||
import AddToCollectionModal from '$lib/components/AddToCollectionModal.svelte';
|
||||
import { getMyCollectionsContainingPage } from '$lib/api/page_collections';
|
||||
import { getMyTagsForPage } from '$lib/api/page_tags';
|
||||
import FolderPlus from '@lucide/svelte/icons/folder-plus';
|
||||
import Tag from '@lucide/svelte/icons/tag';
|
||||
import Download from '@lucide/svelte/icons/download';
|
||||
import Link2 from '@lucide/svelte/icons/link-2';
|
||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
||||
@@ -58,21 +68,26 @@
|
||||
: null
|
||||
);
|
||||
|
||||
// Seed the initial page index from `?page=`. Numeric values are
|
||||
// 1-indexed and clamped to the chapter's page count; the sentinel
|
||||
// `last` lands on the final page (used by the prev-chapter chevron
|
||||
// when going backwards through the series). Component remounts on
|
||||
// chapter navigation, so this only runs at the start of each
|
||||
// chapter — referencing `data` here is intentional.
|
||||
// svelte-ignore state_referenced_locally
|
||||
const initialIndex = (() => {
|
||||
// Initial page index from `?page=`. Numeric values are 1-indexed
|
||||
// and clamped to the chapter's page count; the sentinel `last`
|
||||
// lands on the final page (used by the prev-chapter chevron when
|
||||
// going backwards through the series).
|
||||
//
|
||||
// `$derived` so it tracks `data.requestedPage` / `data.pages`
|
||||
// across chapter navigation — SvelteKit reuses the component on
|
||||
// same-route nav (the chevrons at `jumpToPrevChapter` /
|
||||
// `jumpToNextChapter` below call `goto(...)` to navigate between
|
||||
// chapters), and a `const` would freeze initialIndex at the
|
||||
// first chapter's value.
|
||||
const initialIndex = $derived.by(() => {
|
||||
const req = data.requestedPage;
|
||||
if (req === 'last') return Math.max(0, data.pages.length - 1);
|
||||
if (typeof req === 'number') {
|
||||
return Math.min(Math.max(0, req - 1), Math.max(0, data.pages.length - 1));
|
||||
}
|
||||
return 0;
|
||||
})();
|
||||
});
|
||||
// svelte-ignore state_referenced_locally
|
||||
let index = $state(initialIndex);
|
||||
let continuousPageEls: HTMLImageElement[] = $state([]);
|
||||
let chapterBarEl: HTMLElement | undefined = $state();
|
||||
@@ -88,6 +103,217 @@
|
||||
let chapterJumpOpen = $state(false);
|
||||
let settingsOpen = $state(false);
|
||||
|
||||
// ---- Page context menu / tag + collection actions ----
|
||||
//
|
||||
// Desktop: right-click on a `.page-image` opens the floating
|
||||
// `PageContextMenu`. Mobile: long-press (via TapZone in single
|
||||
// mode, or per-image timer in continuous mode) opens an action
|
||||
// sheet that funnels into the same modals. Unauthenticated users
|
||||
// see neither — there's nothing for them to act on.
|
||||
let contextMenuOpen = $state(false);
|
||||
let contextMenuAnchor = $state<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
let activePageId = $state<string | null>(null);
|
||||
let activePageCollectionCount = $state<number | null>(null);
|
||||
let activePageTags = $state<string[]>([]);
|
||||
let actionSheetOpen = $state(false);
|
||||
let collectionsModalOpen = $state(false);
|
||||
let tagsModalOpen = $state(false);
|
||||
|
||||
// Monotonically-increasing token so a slow loadPageSummary for
|
||||
// page A can't clobber a fresh load for page B. The user right-
|
||||
// clicking one image then another on a slow network is the
|
||||
// scenario; without the token the older request's resolution
|
||||
// wins because it lands last.
|
||||
let loadPageSummaryToken = 0;
|
||||
|
||||
async function loadPageSummary(pageId: string) {
|
||||
const token = ++loadPageSummaryToken;
|
||||
activePageCollectionCount = null;
|
||||
activePageTags = [];
|
||||
try {
|
||||
const [ids, tags] = await Promise.all([
|
||||
getMyCollectionsContainingPage(pageId),
|
||||
getMyTagsForPage(pageId)
|
||||
]);
|
||||
if (token !== loadPageSummaryToken) return;
|
||||
activePageCollectionCount = ids.length;
|
||||
activePageTags = tags;
|
||||
} catch {
|
||||
// Soft-fail — context lines just stay empty / loading.
|
||||
}
|
||||
}
|
||||
|
||||
function openContextMenu(anchor: { x: number; y: number }, pageId: string) {
|
||||
if (!session.user) return;
|
||||
activePageId = pageId;
|
||||
contextMenuAnchor = anchor;
|
||||
contextMenuOpen = true;
|
||||
void loadPageSummary(pageId);
|
||||
}
|
||||
|
||||
function openActionSheet(pageId: string) {
|
||||
if (!session.user) return;
|
||||
activePageId = pageId;
|
||||
actionSheetOpen = true;
|
||||
void loadPageSummary(pageId);
|
||||
}
|
||||
|
||||
function onPageContextMenu(e: MouseEvent, pageId: string) {
|
||||
if (!session.user) return;
|
||||
// Power-user escape hatch — Shift + right-click drops to the
|
||||
// native browser context menu (image save, copy, inspect).
|
||||
if (e.shiftKey) return;
|
||||
e.preventDefault();
|
||||
openContextMenu({ x: e.clientX, y: e.clientY }, pageId);
|
||||
}
|
||||
|
||||
function handleAddToCollection() {
|
||||
contextMenuOpen = false;
|
||||
actionSheetOpen = false;
|
||||
collectionsModalOpen = true;
|
||||
}
|
||||
|
||||
function handleAddTag() {
|
||||
contextMenuOpen = false;
|
||||
actionSheetOpen = false;
|
||||
tagsModalOpen = true;
|
||||
}
|
||||
|
||||
function activePageNumber(): number | null {
|
||||
if (!activePageId) return null;
|
||||
const i = pages.findIndex((p) => p.id === activePageId);
|
||||
return i >= 0 ? i + 1 : null;
|
||||
}
|
||||
|
||||
function activeStorageKey(): string | null {
|
||||
if (!activePageId) return null;
|
||||
return pages.find((p) => p.id === activePageId)?.storage_key ?? null;
|
||||
}
|
||||
|
||||
function handleSaveImage() {
|
||||
const key = activeStorageKey();
|
||||
contextMenuOpen = false;
|
||||
actionSheetOpen = false;
|
||||
if (!key) return;
|
||||
// Open in a new tab — the browser surfaces its native "Save
|
||||
// image as" / long-press save from there. `download` on an
|
||||
// anchor would force the disk-save dialog, which is more
|
||||
// direct but loses the in-tab preview some users prefer.
|
||||
window.open(fileUrl(key), '_blank', 'noopener');
|
||||
}
|
||||
|
||||
// Two-state pill: either "Link copied" (clipboard write OK) or a
|
||||
// selectable URL pill (clipboard unavailable — insecure context,
|
||||
// permissions denied). Auto-clears via timer; the failure pill
|
||||
// sticks around for 10s (vs. 1.6s for success) so the user has
|
||||
// time to long-press → Copy on mobile without the pill vanishing
|
||||
// mid-gesture. The timer is torn down on chapter navigation so
|
||||
// a stale fire doesn't try to write post-unmount state.
|
||||
let linkCopiedState = $state<
|
||||
{ kind: 'hidden' } | { kind: 'ok' } | { kind: 'manual'; url: string }
|
||||
>({ kind: 'hidden' });
|
||||
let linkCopiedTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function clearLinkCopiedTimer() {
|
||||
if (linkCopiedTimer != null) {
|
||||
clearTimeout(linkCopiedTimer);
|
||||
linkCopiedTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopyLink() {
|
||||
const n = activePageNumber();
|
||||
contextMenuOpen = false;
|
||||
actionSheetOpen = false;
|
||||
if (n == null) return;
|
||||
const url = `${window.location.origin}/manga/${manga.id}/chapter/${chapter.id}?page=${n}`;
|
||||
clearLinkCopiedTimer();
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
linkCopiedState = { kind: 'ok' };
|
||||
linkCopiedTimer = setTimeout(() => {
|
||||
linkCopiedState = { kind: 'hidden' };
|
||||
linkCopiedTimer = null;
|
||||
}, 1600);
|
||||
} catch {
|
||||
// Insecure context (HTTP on a LAN demo), missing
|
||||
// permission, or some other API unavailability. Surface
|
||||
// the URL in the pill so the user can copy it manually
|
||||
// instead of silently swallowing — clipboard failures are
|
||||
// systemic, not transient, and the silent path leaves the
|
||||
// user pressing the button again with no feedback.
|
||||
linkCopiedState = { kind: 'manual', url };
|
||||
linkCopiedTimer = setTimeout(() => {
|
||||
linkCopiedState = { kind: 'hidden' };
|
||||
linkCopiedTimer = null;
|
||||
}, 10000);
|
||||
}
|
||||
}
|
||||
|
||||
// Continuous mode lacks TapZone, so long-press lives per-image.
|
||||
// Each in-flight press is keyed by `PointerEvent.pointerId` so a
|
||||
// two-finger multitouch (one finger on each of two images) tracks
|
||||
// both independently — a single shared timer would let the later
|
||||
// press clobber the earlier one and fire the wrong page's sheet.
|
||||
const LONG_PRESS_MS = 450;
|
||||
const MOVE_TOLERANCE = 8;
|
||||
type Press = {
|
||||
start: { x: number; y: number };
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
const presses = new Map<number, Press>();
|
||||
|
||||
function clearPress(pointerId: number) {
|
||||
const p = presses.get(pointerId);
|
||||
if (!p) return;
|
||||
clearTimeout(p.timer);
|
||||
presses.delete(pointerId);
|
||||
}
|
||||
|
||||
function clearAllPresses() {
|
||||
for (const p of presses.values()) clearTimeout(p.timer);
|
||||
presses.clear();
|
||||
}
|
||||
|
||||
function onPagePointerDown(e: PointerEvent, pageId: string) {
|
||||
if (e.pointerType !== 'touch') return;
|
||||
if (!session.user) return;
|
||||
clearPress(e.pointerId);
|
||||
const start = { x: e.clientX, y: e.clientY };
|
||||
const pointerId = e.pointerId;
|
||||
const timer = setTimeout(() => {
|
||||
presses.delete(pointerId);
|
||||
openActionSheet(pageId);
|
||||
}, LONG_PRESS_MS);
|
||||
presses.set(pointerId, { start, timer });
|
||||
}
|
||||
|
||||
function onPagePointerMove(e: PointerEvent) {
|
||||
const p = presses.get(e.pointerId);
|
||||
if (!p) return;
|
||||
const dx = e.clientX - p.start.x;
|
||||
const dy = e.clientY - p.start.y;
|
||||
if (Math.hypot(dx, dy) > MOVE_TOLERANCE) clearPress(e.pointerId);
|
||||
}
|
||||
|
||||
function onPagePointerUp(e: PointerEvent) {
|
||||
clearPress(e.pointerId);
|
||||
}
|
||||
|
||||
// Scroll-pan typically fires pointercancel on the underlying touch,
|
||||
// but not always — explicitly cancel every in-flight press on any
|
||||
// scroll event so a long hold during a vertical pan never fires
|
||||
// the action sheet for a page the user is already past. Matches
|
||||
// the TapZone-level cancel-on-scroll rule.
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
window.addEventListener('scroll', clearAllPresses, true);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', clearAllPresses, true);
|
||||
clearAllPresses();
|
||||
};
|
||||
});
|
||||
|
||||
// Brightness overlay — 1.0 is no dimming, 0.3 is maximum (70% black
|
||||
// overlay opacity). Stored in localStorage only; the Preferences
|
||||
// table doesn't carry this field and Phase 4 explicitly opted to
|
||||
@@ -125,6 +351,121 @@
|
||||
if (Number.isFinite(v) && v >= 0.3 && v <= 1) brightness = v;
|
||||
});
|
||||
|
||||
// Continuous mode previously ignored `?page=N` — the single-mode
|
||||
// `initialIndex` logic above wires it into `let index = $state(...)`
|
||||
// but continuous lets the user scroll naturally, so nothing
|
||||
// jumped to the requested page on load. Search-result click-
|
||||
// through (and any shared link) now relies on it.
|
||||
//
|
||||
// Two subtleties this guards against:
|
||||
// 1. Lazy-load layout shift: pages with `loading="lazy"` have
|
||||
// 0×0 placeholder height until they load. `scrollIntoView`
|
||||
// runs on whatever the current layout says — if prior pages
|
||||
// haven't loaded, the target appears far higher than its
|
||||
// final position, and as the images load the target gets
|
||||
// pushed past the viewport. The template eager-loads pages
|
||||
// `0..=initialIndex` so they have heights, and the effect
|
||||
// waits for each of them to fire `load` (or `error`) before
|
||||
// scrolling.
|
||||
// 2. Mode hydration race: `mode` derives from `preferences.readerMode`,
|
||||
// which initialises to 'single' and is hydrated async on
|
||||
// cold caches. An `onMount` would bail before the flip; an
|
||||
// `$effect` re-fires when mode changes. A one-shot sentinel
|
||||
// keeps us from re-scrolling on later toggles.
|
||||
// 3. In-reader chapter navigation: SvelteKit reuses this
|
||||
// component on same-route goto(...) (see chevron handlers
|
||||
// below). The sentinel + `index` get reset by the
|
||||
// chapter-change effect below so the scroll fires again for
|
||||
// the new chapter's `?page=N`.
|
||||
let initialScrollDone = $state(false);
|
||||
|
||||
// Re-seed chapter-scoped state when the chevrons (or any in-
|
||||
// reader link) navigate to a sibling chapter. SvelteKit reuses
|
||||
// the component on same-route nav, so without this the previous
|
||||
// chapter's mutable state bleeds into the new one:
|
||||
// - `index` would keep the old chapter's value (possibly out
|
||||
// of range for the new chapter's page count).
|
||||
// - `initialScrollDone` would stay true, so `?page=N` deep-
|
||||
// link scrolls would never re-fire.
|
||||
// - `progressPage` would keep the old chapter's high-water
|
||||
// mark, and a pending `progressTimer` would flush it against
|
||||
// the new `chapter.id` — poisoning the new chapter's stored
|
||||
// read progress.
|
||||
//
|
||||
// `lastChapterIdSeen` is a plain `let` rather than `$state` on
|
||||
// purpose — it's only read inside this effect's body and never
|
||||
// drives reactivity. Making it `$state` would not change behavior
|
||||
// but would suggest to a future reader that the value matters
|
||||
// elsewhere.
|
||||
// Two things are load-bearing here:
|
||||
//
|
||||
// 1. `$derived(data.chapter.id)` makes the dependency explicit.
|
||||
// Reading `data.chapter.id` directly inside an effect does
|
||||
// not reliably re-fire when SvelteKit hands the page a new
|
||||
// `data` prop with a swapped nested chapter object —
|
||||
// verified by an E2E that stayed stuck on the previous
|
||||
// chapter's index across the full polling window. The
|
||||
// `$derived` exists for tracking, not memoization.
|
||||
//
|
||||
// 2. `$effect.pre` (vs. `$effect`) lands the reset BEFORE the
|
||||
// DOM mutation rather than after. The page-indicator and
|
||||
// everything else that reads `index` / `pages.length` then
|
||||
// render once, with both values from the new chapter. With
|
||||
// a plain `$effect` the indicator rendered the previous
|
||||
// chapter's `index` against the new chapter's
|
||||
// `pages.length` ("Page 5 / 4") — the very symptom the
|
||||
// regression test pins.
|
||||
const currentChapterId = $derived(data.chapter.id);
|
||||
let lastChapterIdSeen: string | null = null;
|
||||
$effect.pre(() => {
|
||||
const cid = currentChapterId;
|
||||
if (lastChapterIdSeen !== null && lastChapterIdSeen !== cid) {
|
||||
index = initialIndex;
|
||||
initialScrollDone = false;
|
||||
progressPage = initialProgressPage;
|
||||
if (progressTimer) {
|
||||
clearTimeout(progressTimer);
|
||||
progressTimer = null;
|
||||
}
|
||||
}
|
||||
lastChapterIdSeen = cid;
|
||||
});
|
||||
$effect(() => {
|
||||
if (initialScrollDone) return;
|
||||
if (!browser) return;
|
||||
if (mode !== 'continuous') return;
|
||||
if (initialIndex === 0) {
|
||||
initialScrollDone = true;
|
||||
return;
|
||||
}
|
||||
const target = continuousPageEls[initialIndex];
|
||||
// continuousPageEls is populated as the {#each} mounts; the
|
||||
// effect re-runs once it's bound.
|
||||
if (!target) return;
|
||||
|
||||
initialScrollDone = true;
|
||||
|
||||
const above = continuousPageEls
|
||||
.slice(0, initialIndex + 1)
|
||||
.filter((el): el is HTMLImageElement => el != null);
|
||||
const pending = above.filter((el) => !el.complete);
|
||||
if (pending.length === 0) {
|
||||
target.scrollIntoView({ block: 'start' });
|
||||
return;
|
||||
}
|
||||
let remaining = pending.length;
|
||||
const onResolved = () => {
|
||||
remaining -= 1;
|
||||
if (remaining === 0) target.scrollIntoView({ block: 'start' });
|
||||
};
|
||||
for (const el of pending) {
|
||||
// `error` counts as resolved — a broken image's height is
|
||||
// settled at its alt-text height, and we shouldn't hang.
|
||||
el.addEventListener('load', onResolved, { once: true });
|
||||
el.addEventListener('error', onResolved, { once: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Publish the dim level as a CSS variable on <html>. (1 - brightness)
|
||||
// gives 0..0.7 alpha for the fixed overlay rendered at the bottom
|
||||
// of the template. Persisting on every change keeps the
|
||||
@@ -367,6 +708,10 @@
|
||||
onMount(() => window.addEventListener('keydown', onKeydown));
|
||||
onDestroy(() => {
|
||||
if (typeof window !== 'undefined') window.removeEventListener('keydown', onKeydown);
|
||||
// Tear down the copy-pill timer so a fire-after-unmount
|
||||
// doesn't try to write to a stale $state rune. Cheap on
|
||||
// SvelteKit chapter navigation, which remounts this page.
|
||||
clearLinkCopiedTimer();
|
||||
});
|
||||
|
||||
// ---- Admin force resync (current chapter) ----
|
||||
@@ -411,14 +756,18 @@
|
||||
// Writes are debounced and fire-and-forget — the reader never
|
||||
// blocks on the network, and a failed write just means the user's
|
||||
// history is slightly stale (acceptable).
|
||||
// Route param `[n]` is part of the URL, so SvelteKit remounts
|
||||
// this component on chapter navigation — capturing the initial
|
||||
// `data` value here is the desired behaviour.
|
||||
// svelte-ignore state_referenced_locally
|
||||
const initialProgressPage =
|
||||
//
|
||||
// `$derived` so the seed recomputes on in-reader chapter
|
||||
// navigation (SvelteKit reuses the component on same-route nav).
|
||||
// The chapter-change effect above re-seeds `progressPage` from
|
||||
// this — without it, the previous chapter's high-water mark
|
||||
// carries over and gets written against the new `chapter.id`.
|
||||
const initialProgressPage = $derived.by(() =>
|
||||
data.readProgress && data.readProgress.chapter_id === chapter.id
|
||||
? Math.max(1, data.readProgress.page)
|
||||
: 1;
|
||||
: 1
|
||||
);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let progressPage = $state(initialProgressPage);
|
||||
let progressTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let observer: IntersectionObserver | null = null;
|
||||
@@ -446,26 +795,62 @@
|
||||
progressTimer = setTimeout(flushProgress, 1500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reader back behavior — pop browser history instead of pushing a
|
||||
* fresh entry for `/manga/{id}`. Previously this was a naked
|
||||
* `<a href>` and every tap pushed, so browser-back ping-ponged
|
||||
* between detail and reader instead of walking out to home.
|
||||
*
|
||||
* Just check `history.length > 1` — `document.referrer` does NOT
|
||||
* update across SvelteKit SPA navigations, so referrer-based
|
||||
* gating silently fell back to the default href every time.
|
||||
* Middle-click / cmd-click / right-click are passed through so
|
||||
* "open in new tab" still works.
|
||||
*/
|
||||
function onBackClick(e: MouseEvent) {
|
||||
// Two-part reader back-control:
|
||||
// - The arrow is always a "go back" action: pops browser
|
||||
// history if there's something to pop, falls back to the
|
||||
// detail page on a cold tab.
|
||||
// - The cover+title is smart: if the user got to the reader
|
||||
// FROM this manga's detail page they go back (pop, no dup
|
||||
// history entry), otherwise they land on the detail page
|
||||
// (push). Stops the "click cover/title and end up in search
|
||||
// results" regression that the naked back-everywhere code
|
||||
// used to have.
|
||||
//
|
||||
// `lastInternalPath` is captured by `afterNavigate({ from })`,
|
||||
// which SvelteKit fires after every client-side navigation. It's
|
||||
// the only reliable signal — `document.referrer` doesn't update
|
||||
// across SPA navs (the chrome-resync at line 718 doesn't help
|
||||
// either since it doesn't write referrer).
|
||||
let lastInternalPath: string | null = null;
|
||||
afterNavigate(({ from }) => {
|
||||
lastInternalPath = from?.url?.pathname ?? null;
|
||||
});
|
||||
|
||||
const detailPath = $derived(`/manga/${manga.id}`);
|
||||
|
||||
function onArrowClick(e: MouseEvent) {
|
||||
if (!browser) return;
|
||||
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||
e.preventDefault();
|
||||
if (window.history.length > 1) {
|
||||
window.history.back();
|
||||
} else {
|
||||
// Cold tab / deep-link: nothing to pop. Push the detail
|
||||
// page as the sane "back" destination.
|
||||
void goto(detailPath);
|
||||
}
|
||||
}
|
||||
|
||||
function onCoverTitleClick(e: MouseEvent) {
|
||||
if (!browser) return;
|
||||
// Modifier / middle / non-left clicks fall through to the
|
||||
// native <a> so "open in new tab", "copy link", etc. all work.
|
||||
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||
if (lastInternalPath === detailPath) {
|
||||
// Came from THIS manga's detail page — pop so we don't
|
||||
// accumulate a duplicate /manga/:id entry. preventDefault
|
||||
// here stops SvelteKit's own click handler: its listener
|
||||
// is on the app container in the bubble phase and bails on
|
||||
// `event.defaultPrevented` (see @sveltejs/kit client.js),
|
||||
// and this element-level handler runs first at the target
|
||||
// phase, so the pop wins cleanly.
|
||||
e.preventDefault();
|
||||
window.history.back();
|
||||
}
|
||||
// else: let the href navigate (deep-link / fresh tab path)
|
||||
// Anywhere else (search, library, another manga's detail, cold
|
||||
// tab) — do nothing here and let the <a href> navigate
|
||||
// natively, which PUSHES the detail page so browser-back
|
||||
// returns to the reader.
|
||||
}
|
||||
|
||||
// Single-mode: every page change moves the high-water mark.
|
||||
@@ -574,27 +959,46 @@
|
||||
</svelte:head>
|
||||
|
||||
<nav class="reader-nav" aria-label="reader" bind:this={readerNavEl}>
|
||||
<a
|
||||
href="/manga/{manga.id}"
|
||||
class="back"
|
||||
onclick={onBackClick}
|
||||
data-testid="back-to-manga"
|
||||
>
|
||||
<ArrowLeft size={18} aria-hidden="true" />
|
||||
{#if manga.cover_image_path}
|
||||
<img
|
||||
src={fileUrl(manga.cover_image_path)}
|
||||
alt=""
|
||||
class="back-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<span class="back-cover back-cover-placeholder" aria-hidden="true">
|
||||
<BookImage size={14} aria-hidden="true" />
|
||||
</span>
|
||||
{/if}
|
||||
<span class="back-text">{manga.title}</span>
|
||||
</a>
|
||||
<div class="back-group">
|
||||
<button
|
||||
type="button"
|
||||
class="back-arrow"
|
||||
aria-label="Back"
|
||||
onclick={onArrowClick}
|
||||
data-testid="reader-back-arrow"
|
||||
>
|
||||
<ArrowLeft size={18} aria-hidden="true" />
|
||||
</button>
|
||||
<!--
|
||||
Cover + title is a real <a href> to the detail page so
|
||||
cmd/middle-click "open in new tab" and "copy link" work
|
||||
natively. The onclick only intercepts the one case where
|
||||
we want to POP instead of push (arrived from this manga's
|
||||
own detail page) — see onCoverTitleClick. Every other
|
||||
plain-left-click falls through to SvelteKit's default <a>
|
||||
navigation, which pushes the detail page.
|
||||
-->
|
||||
<a
|
||||
href={detailPath}
|
||||
class="back"
|
||||
onclick={onCoverTitleClick}
|
||||
data-testid="back-to-manga"
|
||||
>
|
||||
{#if manga.cover_image_path}
|
||||
<img
|
||||
src={fileUrl(manga.cover_image_path)}
|
||||
alt=""
|
||||
class="back-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<span class="back-cover back-cover-placeholder" aria-hidden="true">
|
||||
<BookImage size={14} aria-hidden="true" />
|
||||
</span>
|
||||
{/if}
|
||||
<span class="back-text">{manga.title}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="controls" role="group" aria-label="reader options">
|
||||
<label class="chapter-field desktop-control">
|
||||
@@ -777,6 +1181,7 @@
|
||||
alt={`${manga.title} chapter ${chapter.number} page ${index + 1}`}
|
||||
class="page-image"
|
||||
loading="eager"
|
||||
oncontextmenu={(e) => onPageContextMenu(e, pages[index].id)}
|
||||
data-testid="reader-page"
|
||||
/>
|
||||
|
||||
@@ -811,11 +1216,22 @@
|
||||
{:else}
|
||||
<div class="continuous" style:gap="{gapPx}px" data-testid="reader-continuous">
|
||||
{#each pages as p, i (p.id)}
|
||||
<!-- Pages 0..=initialIndex (or at least 0..1) are eager
|
||||
so their real heights are settled before the cold-
|
||||
load scroll-to-`?page=N` effect fires. Without this,
|
||||
`scrollIntoView(target)` lands while prior pages are
|
||||
still 0×0 placeholders and the target gets pushed
|
||||
past the viewport as they load. -->
|
||||
<img
|
||||
src={fileUrl(p.storage_key)}
|
||||
alt={`${manga.title} chapter ${chapter.number} page ${i + 1}`}
|
||||
class="page-image"
|
||||
loading={i < 2 ? 'eager' : 'lazy'}
|
||||
loading={i <= Math.max(1, initialIndex) ? 'eager' : 'lazy'}
|
||||
oncontextmenu={(e) => onPageContextMenu(e, p.id)}
|
||||
onpointerdown={(e) => onPagePointerDown(e, p.id)}
|
||||
onpointermove={onPagePointerMove}
|
||||
onpointerup={onPagePointerUp}
|
||||
onpointercancel={onPagePointerUp}
|
||||
data-testid={`reader-page-${i + 1}`}
|
||||
bind:this={continuousPageEls[i]}
|
||||
/>
|
||||
@@ -868,13 +1284,56 @@
|
||||
DOM is one zero-opacity div when brightness is at max. -->
|
||||
<div class="brightness-overlay" aria-hidden="true"></div>
|
||||
|
||||
{#if linkCopiedState.kind === 'ok'}
|
||||
<div
|
||||
class="link-copied"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="reader-link-copied"
|
||||
>
|
||||
Link copied
|
||||
</div>
|
||||
{:else if linkCopiedState.kind === 'manual'}
|
||||
<div
|
||||
class="link-copied link-copied-manual"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="reader-link-copied-manual"
|
||||
>
|
||||
<span class="link-copied-label">
|
||||
Couldn't copy automatically — press ⌘C / long-press to copy:
|
||||
</span>
|
||||
<!--
|
||||
`readonly` keeps the underlying page state untouchable.
|
||||
`inputmode="none"` suppresses the iOS / Android soft
|
||||
keyboard that would otherwise pop up under a tap, since
|
||||
the user is here to copy the URL, not edit it. Focus
|
||||
auto-selects so the desktop user can immediately ⌘C.
|
||||
-->
|
||||
<input
|
||||
type="text"
|
||||
readonly
|
||||
inputmode="none"
|
||||
value={linkCopiedState.url}
|
||||
onfocus={(e) => e.currentTarget.select()}
|
||||
data-testid="reader-link-copied-manual-input"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Tap zones — mobile + single mode only. Continuous mode owns native
|
||||
scroll so left/right would steal panning. Tap left/right advances
|
||||
within the chapter (falling through to adjacent chapters at the
|
||||
boundaries via the existing prev/next helpers); tap center toggles
|
||||
the focus-mode chrome and restarts the idle timer. -->
|
||||
{#if isMobileViewport && mode === 'single' && pages.length > 0}
|
||||
<TapZone onPrev={prev} onNext={next} onToggle={toggleChrome} testid="reader-tap" />
|
||||
<TapZone
|
||||
onPrev={prev}
|
||||
onNext={next}
|
||||
onToggle={toggleChrome}
|
||||
onLongPress={(_anchor) => openActionSheet(pages[index].id)}
|
||||
testid="reader-tap"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Bottom scrubber — mobile + single + multi-page only. Lives above
|
||||
@@ -981,6 +1440,119 @@
|
||||
</div>
|
||||
</Sheet>
|
||||
|
||||
<!-- Page context menu (desktop right-click) and the modals/sheet it
|
||||
funnels into. Rendered for authenticated users only — there's
|
||||
nothing for a guest to act on. -->
|
||||
{#if session.user}
|
||||
<PageContextMenu
|
||||
open={contextMenuOpen}
|
||||
anchor={contextMenuAnchor}
|
||||
onClose={() => (contextMenuOpen = false)}
|
||||
onAddToCollection={handleAddToCollection}
|
||||
onAddTag={handleAddTag}
|
||||
onSaveImage={handleSaveImage}
|
||||
onCopyLink={handleCopyLink}
|
||||
collectionsCount={activePageCollectionCount}
|
||||
tags={activePageTags}
|
||||
/>
|
||||
|
||||
<!-- Mobile action sheet: same two actions, larger touch targets. -->
|
||||
<Sheet
|
||||
open={actionSheetOpen}
|
||||
title="Page actions"
|
||||
onClose={() => (actionSheetOpen = false)}
|
||||
testid="page-action-sheet"
|
||||
>
|
||||
<ul class="action-list">
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="action-row"
|
||||
onclick={handleAddToCollection}
|
||||
data-testid="page-action-add-to-collection"
|
||||
>
|
||||
<FolderPlus size={18} aria-hidden="true" />
|
||||
<span>Add to collection</span>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="action-row"
|
||||
onclick={handleAddTag}
|
||||
data-testid="page-action-add-tag"
|
||||
>
|
||||
<Tag size={18} aria-hidden="true" />
|
||||
<span>Add tag</span>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="action-row"
|
||||
onclick={handleSaveImage}
|
||||
data-testid="page-action-save-image"
|
||||
>
|
||||
<Download size={18} aria-hidden="true" />
|
||||
<span>Save image</span>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="action-row"
|
||||
onclick={handleCopyLink}
|
||||
data-testid="page-action-copy-link"
|
||||
>
|
||||
<Link2 size={18} aria-hidden="true" />
|
||||
<span>Copy page link</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="action-hint" data-testid="page-action-collections-line">
|
||||
{#if activePageCollectionCount == null}
|
||||
Loading…
|
||||
{:else if activePageCollectionCount === 0}
|
||||
Not in any collection
|
||||
{:else}
|
||||
In {activePageCollectionCount} collection{activePageCollectionCount === 1
|
||||
? ''
|
||||
: 's'}
|
||||
{/if}
|
||||
</p>
|
||||
<p class="action-hint" data-testid="page-action-tags-line">
|
||||
{activePageTags.length === 0
|
||||
? 'No tags yet'
|
||||
: `Tagged: ${activePageTags.join(', ')}`}
|
||||
</p>
|
||||
</Sheet>
|
||||
|
||||
{#if activePageId}
|
||||
<AddToCollectionModal
|
||||
open={collectionsModalOpen}
|
||||
target={{ kind: 'page', id: activePageId }}
|
||||
onClose={() => {
|
||||
collectionsModalOpen = false;
|
||||
if (activePageId) void loadPageSummary(activePageId);
|
||||
}}
|
||||
/>
|
||||
<Modal
|
||||
open={tagsModalOpen}
|
||||
title="Tag this page"
|
||||
onClose={() => (tagsModalOpen = false)}
|
||||
size="md"
|
||||
testid="add-tags-modal"
|
||||
>
|
||||
<AddTagsSheet
|
||||
pageId={activePageId}
|
||||
onChange={(tags) => {
|
||||
activePageTags = tags;
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Pinned to the viewport directly below the (also fixed) layout
|
||||
header. `position: fixed` rather than `sticky` because the
|
||||
@@ -1015,6 +1587,31 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.back-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.back-arrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 0;
|
||||
padding: var(--space-1);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.back-arrow:hover {
|
||||
color: var(--primary);
|
||||
background: var(--surface-elevated);
|
||||
}
|
||||
|
||||
.back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1178,6 +1775,17 @@
|
||||
height: auto;
|
||||
margin: 0 auto;
|
||||
display: block;
|
||||
/* Suppress the native mobile Safari long-press image callout
|
||||
("Save Image", "Copy") and the long-press text selection so
|
||||
the in-app long-press → action sheet path is the only
|
||||
outcome. Cost: users lose the OS-level Save Image affordance
|
||||
in the reader. Save image stays available via the in-app
|
||||
action sheet.
|
||||
Desktop right-click is handled separately by oncontextmenu
|
||||
— holding Shift bypasses our handler for the native menu. */
|
||||
-webkit-touch-callout: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.continuous .page-image {
|
||||
@@ -1447,6 +2055,89 @@
|
||||
font-weight: var(--weight-regular);
|
||||
}
|
||||
|
||||
.action-list {
|
||||
list-style: none;
|
||||
margin: 0 0 var(--space-3);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
padding: var(--space-3);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: var(--font-base);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.action-row:hover {
|
||||
background: var(--surface-elevated);
|
||||
}
|
||||
|
||||
.action-hint {
|
||||
margin: var(--space-1) 0 0;
|
||||
padding: 0 var(--space-3);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-xs);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.link-copied {
|
||||
position: fixed;
|
||||
top: calc(var(--app-header-h) + var(--space-3));
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: var(--z-modal);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-pill);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
font-size: var(--font-sm);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.link-copied-manual {
|
||||
/* Manual fallback needs to be interactive so the user can
|
||||
select / copy the URL — override the success pill's
|
||||
pointer-events: none. */
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
max-width: min(90vw, 32rem);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
}
|
||||
|
||||
.link-copied-label {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.link-copied-manual input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: inherit;
|
||||
font-size: var(--font-xs);
|
||||
background: var(--surface-elevated);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px var(--space-2);
|
||||
}
|
||||
|
||||
.settings-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
Reference in New Issue
Block a user