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

@@ -18,6 +18,7 @@
import Search from '@lucide/svelte/icons/search';
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
import ArrowUpDown from '@lucide/svelte/icons/arrow-up-down';
import ArrowRight from '@lucide/svelte/icons/arrow-right';
import Plus from '@lucide/svelte/icons/plus';
const PAGE_SIZE = 50;
@@ -402,7 +403,22 @@
{/if}
{/snippet}
<h1>Mangas</h1>
<div class="heading-row">
<h1>Mangas</h1>
<!--
Secondary entry point into the per-page search at /search,
which is headed "Page search". Label matches the destination's
title so the two read as one feature. It filters the user's
tagged pages and pivots between Pages / Chapters / Mangas;
title-search above stays the primary action. The name is also
forward-compatible with the planned OCR text search (which
searches page content, not just tags).
-->
<a class="alt-search" href="/search" data-testid="nav-page-search">
<span>Page search</span>
<ArrowRight size={14} aria-hidden="true" />
</a>
</div>
<form
onsubmit={onSubmit}
@@ -562,11 +578,38 @@
{/if}
<style>
.heading-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-3);
flex-wrap: wrap;
}
.heading-row h1 {
margin-bottom: 0;
}
.alt-search {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--text-muted);
font-size: var(--font-sm);
text-decoration: none;
white-space: nowrap;
}
.alt-search:hover {
color: var(--primary);
}
.controls {
display: flex;
flex-direction: column;
gap: var(--space-3);
margin-bottom: var(--space-4);
margin-top: var(--space-3);
}
.search-row {

View File

@@ -5,7 +5,12 @@
removeMangaFromCollection,
updateCollection
} from '$lib/api/collections';
import {
removePageFromCollection,
type CollectionPageItem
} from '$lib/api/page_collections';
import type { Manga } from '$lib/api/client';
import { fileUrl } from '$lib/api/client';
import MangaCard from '$lib/components/MangaCard.svelte';
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import Pencil from '@lucide/svelte/icons/pencil';
@@ -18,6 +23,8 @@
let collection = $state({ ...data.collection });
// svelte-ignore state_referenced_locally
let mangas = $state<Manga[]>([...data.mangas]);
// svelte-ignore state_referenced_locally
let pages = $state<CollectionPageItem[]>([...data.pages]);
let editing = $state(false);
let editName = $state('');
@@ -72,6 +79,17 @@
editError = (e as Error).message;
}
}
async function onRemovePage(p: CollectionPageItem) {
const snapshot = pages;
pages = pages.filter((x) => x.page_id !== p.page_id);
try {
await removePageFromCollection(collection.id, p.page_id);
} catch (e) {
pages = snapshot;
editError = (e as Error).message;
}
}
</script>
<svelte:head>
@@ -162,28 +180,73 @@
{/if}
</header>
{#if mangas.length === 0}
{#if mangas.length === 0 && pages.length === 0}
<p class="status" data-testid="collection-empty">
This collection is empty.
</p>
{:else}
<ul class="manga-grid" data-testid="collection-manga-list">
{#each mangas as m (m.id)}
<li class="card-with-remove">
<MangaCard manga={m} testid={`collection-manga-${m.id}`} />
<button
type="button"
class="remove"
onclick={() => onRemoveManga(m)}
aria-label={`Remove ${m.title} from collection`}
title="Remove from collection"
data-testid={`collection-remove-manga-${m.id}`}
>
<X size={14} aria-hidden="true" />
</button>
</li>
{/each}
</ul>
{/if}
{#if mangas.length > 0}
<section aria-labelledby="mangas-heading">
<h2 id="mangas-heading" class="section-heading">Mangas</h2>
<ul class="manga-grid" data-testid="collection-manga-list">
{#each mangas as m (m.id)}
<li class="card-with-remove">
<MangaCard manga={m} testid={`collection-manga-${m.id}`} />
<button
type="button"
class="remove"
onclick={() => onRemoveManga(m)}
aria-label={`Remove ${m.title} from collection`}
title="Remove from collection"
data-testid={`collection-remove-manga-${m.id}`}
>
<X size={14} aria-hidden="true" />
</button>
</li>
{/each}
</ul>
</section>
{/if}
{#if pages.length > 0}
<section aria-labelledby="pages-heading">
<h2 id="pages-heading" class="section-heading">Pages</h2>
<ul class="page-grid" data-testid="collection-page-list">
{#each pages as p (p.page_id)}
<li class="card-with-remove">
<a
class="page-card"
href={`/manga/${p.manga_id}/chapter/${p.chapter_id}?page=${p.page_number}`}
data-testid={`collection-page-${p.page_id}`}
>
<img
src={fileUrl(p.storage_key)}
alt={`${p.manga_title} chapter ${p.chapter_number} page ${p.page_number}`}
class="page-thumb"
loading="lazy"
/>
<span class="page-meta">
<span class="page-title">{p.manga_title}</span>
<span class="page-breadcrumb">
Ch. {p.chapter_number} · page {p.page_number}
</span>
</span>
</a>
<button
type="button"
class="remove"
onclick={() => onRemovePage(p)}
aria-label={`Remove ${p.manga_title} page ${p.page_number} from collection`}
title="Remove from collection"
data-testid={`collection-remove-page-${p.page_id}`}
>
<X size={14} aria-hidden="true" />
</button>
</li>
{/each}
</ul>
</section>
{/if}
<style>
@@ -260,6 +323,67 @@
gap: var(--space-4);
}
.section-heading {
margin: var(--space-5) 0 var(--space-3);
font-size: var(--font-lg);
}
.section-heading:first-of-type {
margin-top: 0;
}
.page-grid {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: var(--space-3);
}
.page-card {
display: flex;
flex-direction: column;
gap: var(--space-1);
color: var(--text);
text-decoration: none;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow: hidden;
}
.page-card:hover {
border-color: var(--primary);
text-decoration: none;
}
.page-thumb {
width: 100%;
aspect-ratio: 2 / 3;
object-fit: cover;
background: var(--surface-elevated);
}
.page-meta {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--space-2);
}
.page-title {
font-weight: var(--weight-medium);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.page-breadcrumb {
color: var(--text-muted);
font-size: var(--font-xs);
}
.card-with-remove {
position: relative;
list-style: none;

View File

@@ -4,33 +4,30 @@ import {
getCollection,
listCollectionMangas
} from '$lib/api/collections';
import { listCollectionPages } from '$lib/api/page_collections';
import type { PageLoad } from './$types';
export const ssr = false;
export const load: PageLoad = async ({ params, url }) => {
try {
const [collection, mangas] = await Promise.all([
const [collection, mangas, pages] = await Promise.all([
getCollection(params.id),
listCollectionMangas(params.id, { limit: 200 })
listCollectionMangas(params.id, { limit: 200 }),
listCollectionPages(params.id, { limit: 200 })
]);
return {
collection,
mangas: mangas.items,
total: mangas.page.total
total: mangas.page.total,
pages: pages.items
};
} catch (e) {
if (e instanceof ApiError) {
// 401 means the user's session is gone — bounce to login
// and preserve where they wanted to go.
if (e.status === 401) {
const next = encodeURIComponent(url.pathname);
redirect(302, `/login?next=${next}`);
}
// 403 (post-Phase-3-polish the backend collapses this to
// 404 already, but keep the branch for defense-in-depth)
// and 404 both render the standard not-found page so the
// URL doesn't disclose collection existence to non-owners.
if (e.status === 404 || e.status === 403) {
error(404, 'Collection not found');
}

View File

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

View File

@@ -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) {

View File

@@ -567,7 +567,7 @@
{#if session.user}
<AddToCollectionModal
open={collectionModalOpen}
mangaId={manga.id}
target={{ kind: 'manga', id: manga.id }}
onClose={() => (collectionModalOpen = false)}
/>
{/if}

View File

@@ -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;

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