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

@@ -0,0 +1,315 @@
<script lang="ts">
import {
getMyTagsForPage,
addTagToPage,
removeTagFromPage,
listMyDistinctPageTags,
type PageTagSummary
} from '$lib/api/page_tags';
import X from '@lucide/svelte/icons/x';
/**
* Panel content for editing a user's tags on a single page. Slots
* into either a `Sheet` (mobile) or a `Modal` (desktop) — both
* provide the chrome (title bar, dismiss, focus trap), this
* component owns only the chip editor + autocomplete.
*/
let {
pageId,
onChange
}: {
pageId: string;
/** Fires after every successful add/remove so the host can
* refresh the context menu's "Tagged: …" line. */
onChange?: (tags: string[]) => void;
} = $props();
let tags = $state<string[]>([]);
let suggestions = $state<PageTagSummary[]>([]);
let draft = $state('');
let loading = $state(false);
let busy = $state(false);
let error: string | null = $state(null);
let inputEl: HTMLInputElement | undefined = $state();
$effect(() => {
// Re-load when the host swaps in a different page.
void pageId;
void load();
});
async function load() {
loading = true;
error = null;
try {
const [current, dist] = await Promise.all([
getMyTagsForPage(pageId),
listMyDistinctPageTags(undefined, 12)
]);
tags = current;
suggestions = dist;
} catch (e) {
error = (e as Error).message;
} finally {
loading = false;
}
}
async function refreshSuggestions() {
try {
const next = await listMyDistinctPageTags(
draft.trim() || undefined,
12
);
suggestions = next ?? [];
} catch {
// Soft-fail — autocomplete is non-critical.
}
}
async function add(rawTag: string) {
const tag = rawTag.trim();
if (!tag || busy) return;
busy = true;
error = null;
try {
await addTagToPage(pageId, tag);
// Re-read from the server so the chip shows the
// normalized form (lowercase, whitespace-collapsed) rather
// than whatever the user typed.
tags = await getMyTagsForPage(pageId);
draft = '';
onChange?.(tags);
void refreshSuggestions();
} catch (e) {
error = (e as Error).message;
} finally {
busy = false;
// The input is `disabled={busy}` during the request, which
// makes browsers drop focus. Restore it so the user can
// immediately type the next tag in a "tag, tag, tag" flow
// without clicking back into the field.
queueMicrotask(() => inputEl?.focus());
}
}
async function remove(tag: string) {
if (busy) return;
busy = true;
error = null;
// Optimistic — slice it out, revert on failure.
const previous = tags;
tags = tags.filter((t) => t !== tag);
try {
await removeTagFromPage(pageId, tag);
onChange?.(tags);
} catch (e) {
tags = previous;
error = (e as Error).message;
} finally {
busy = false;
}
}
function onSubmit(e: SubmitEvent) {
e.preventDefault();
void add(draft);
}
function onInput(e: Event) {
draft = (e.target as HTMLInputElement).value;
void refreshSuggestions();
}
function onKeydown(e: KeyboardEvent) {
// Enter is handled by the form submit, but support comma as a
// chip separator since users type tag lists.
if (e.key === ',') {
e.preventDefault();
void add(draft);
}
}
// Suggestions minus already-present tags, since adding a present
// tag is a no-op and clutters the row.
const filteredSuggestions = $derived(
suggestions.filter((s) => !tags.includes(s.tag))
);
</script>
<div class="panel" data-testid="add-tags-panel">
{#if loading}
<p class="status">Loading tags…</p>
{:else}
<div class="chips" data-testid="add-tags-chips">
{#each tags as t (t)}
<span class="chip">
<span>{t}</span>
<button
type="button"
class="chip-x"
aria-label={`Remove ${t}`}
disabled={busy}
onclick={() => remove(t)}
data-testid={`add-tags-remove-${t}`}
>
<X size={12} aria-hidden="true" />
</button>
</span>
{/each}
{#if tags.length === 0}
<span class="hint">No tags yet — add one below.</span>
{/if}
</div>
{/if}
<form class="input-row" onsubmit={onSubmit} action="javascript:void(0)">
<input
bind:this={inputEl}
type="text"
value={draft}
oninput={onInput}
onkeydown={onKeydown}
placeholder="Type a tag and press Enter"
aria-label="New tag"
maxlength="64"
disabled={busy}
data-testid="add-tags-input"
/>
<button
type="submit"
class="add-btn"
disabled={!draft.trim() || busy}
data-testid="add-tags-submit"
>
Add
</button>
</form>
{#if filteredSuggestions.length > 0}
<div class="suggestions" data-testid="add-tags-suggestions">
<p class="suggestions-label">Suggestions</p>
<div class="suggestion-chips">
{#each filteredSuggestions as s (s.tag)}
<button
type="button"
class="suggestion-chip"
disabled={busy}
onclick={() => add(s.tag)}
data-testid={`add-tags-suggest-${s.tag}`}
>
+ {s.tag}
</button>
{/each}
</div>
</div>
{/if}
{#if error}
<p class="error" role="alert" data-testid="add-tags-error">{error}</p>
{/if}
</div>
<style>
.panel {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.status,
.hint {
color: var(--text-muted);
margin: 0;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
min-height: 32px;
align-items: center;
}
.chip {
display: inline-flex;
align-items: center;
gap: var(--space-1);
background: var(--primary-soft-bg);
color: var(--text);
padding: 2px var(--space-2);
border-radius: var(--radius-pill);
font-size: var(--font-sm);
}
.chip-x {
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
color: var(--text-muted);
border: 0;
padding: 0;
cursor: pointer;
line-height: 0;
}
.chip-x:hover:not(:disabled) {
color: var(--text);
}
.input-row {
display: flex;
gap: var(--space-2);
align-items: center;
}
.input-row input {
flex: 1;
min-width: 0;
}
.add-btn {
background: var(--primary);
color: var(--primary-contrast);
border: 1px solid var(--primary);
padding: 0 var(--space-3);
height: 36px;
}
.add-btn:hover:not(:disabled) {
background: var(--primary-hover);
border-color: var(--primary-hover);
}
.suggestions-label {
margin: 0 0 var(--space-1);
color: var(--text-muted);
font-size: var(--font-xs);
}
.suggestion-chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
.suggestion-chip {
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
padding: 2px var(--space-2);
border-radius: var(--radius-pill);
font-size: var(--font-sm);
cursor: pointer;
}
.suggestion-chip:hover:not(:disabled) {
background: var(--surface-elevated);
}
.error {
color: var(--danger);
margin: 0;
}
</style>

View File

@@ -0,0 +1,113 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/svelte';
import { tick } from 'svelte';
import AddTagsSheet from './AddTagsSheet.svelte';
const pageTagsApi = vi.hoisted(() => ({
getMyTagsForPage: vi.fn(),
addTagToPage: vi.fn(),
removeTagFromPage: vi.fn(),
listMyDistinctPageTags: vi.fn()
}));
vi.mock('$lib/api/page_tags', () => pageTagsApi);
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
async function settle() {
await tick();
await Promise.resolve();
await tick();
}
describe('AddTagsSheet', () => {
it('renders current tags as chips after load', async () => {
pageTagsApi.getMyTagsForPage.mockResolvedValueOnce(['funny', 'fight']);
pageTagsApi.listMyDistinctPageTags.mockResolvedValueOnce([]);
render(AddTagsSheet, { props: { pageId: 'p1' } });
await settle();
expect(screen.getByTestId('add-tags-remove-funny')).toBeTruthy();
expect(screen.getByTestId('add-tags-remove-fight')).toBeTruthy();
});
it('renders suggestions excluding already-applied tags', async () => {
pageTagsApi.getMyTagsForPage.mockResolvedValueOnce(['funny']);
pageTagsApi.listMyDistinctPageTags.mockResolvedValueOnce([
{ tag: 'funny', count: 5 },
{ tag: 'fight', count: 3 }
]);
render(AddTagsSheet, { props: { pageId: 'p1' } });
await settle();
expect(screen.queryByTestId('add-tags-suggest-funny')).toBeNull();
expect(screen.getByTestId('add-tags-suggest-fight')).toBeTruthy();
});
it('add posts then refetches normalized tag list', async () => {
pageTagsApi.getMyTagsForPage.mockResolvedValueOnce([]);
// Two calls: initial load + post-add refreshSuggestions.
pageTagsApi.listMyDistinctPageTags.mockResolvedValue([]);
pageTagsApi.addTagToPage.mockResolvedValueOnce(undefined);
pageTagsApi.getMyTagsForPage.mockResolvedValueOnce(['funny']);
const onChange = vi.fn();
render(AddTagsSheet, { props: { pageId: 'p1', onChange } });
await settle();
const input = screen.getByTestId('add-tags-input') as HTMLInputElement;
await fireEvent.input(input, { target: { value: 'Funny' } });
await fireEvent.click(screen.getByTestId('add-tags-submit'));
await settle();
expect(pageTagsApi.addTagToPage).toHaveBeenCalledWith('p1', 'Funny');
expect(pageTagsApi.getMyTagsForPage).toHaveBeenCalledTimes(2);
expect(onChange).toHaveBeenCalledWith(['funny']);
// Stored as normalized — chip shows lowercase.
expect(screen.getByTestId('add-tags-remove-funny')).toBeTruthy();
});
it('restores focus to the input after a successful add', async () => {
pageTagsApi.getMyTagsForPage.mockResolvedValueOnce([]);
pageTagsApi.listMyDistinctPageTags.mockResolvedValue([]);
pageTagsApi.addTagToPage.mockResolvedValueOnce(undefined);
pageTagsApi.getMyTagsForPage.mockResolvedValueOnce(['funny']);
render(AddTagsSheet, { props: { pageId: 'p1' } });
await settle();
const input = screen.getByTestId('add-tags-input') as HTMLInputElement;
input.focus();
await fireEvent.input(input, { target: { value: 'funny' } });
await fireEvent.click(screen.getByTestId('add-tags-submit'));
await settle();
// Drain the queueMicrotask scheduled in `add()`'s finally so
// the focus-restore call has actually run.
await Promise.resolve();
await Promise.resolve();
expect(document.activeElement).toBe(input);
});
it('remove deletes and updates chip list', async () => {
pageTagsApi.getMyTagsForPage.mockResolvedValueOnce(['funny', 'fight']);
pageTagsApi.listMyDistinctPageTags.mockResolvedValueOnce([]);
pageTagsApi.removeTagFromPage.mockResolvedValueOnce(undefined);
const onChange = vi.fn();
render(AddTagsSheet, { props: { pageId: 'p1', onChange } });
await settle();
await fireEvent.click(screen.getByTestId('add-tags-remove-funny'));
await settle();
expect(pageTagsApi.removeTagFromPage).toHaveBeenCalledWith('p1', 'funny');
expect(screen.queryByTestId('add-tags-remove-funny')).toBeNull();
expect(onChange).toHaveBeenCalledWith(['fight']);
});
});

View File

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

View File

@@ -0,0 +1,100 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/svelte';
import { tick } from 'svelte';
import AddToCollectionModal from './AddToCollectionModal.svelte';
// Stub the API modules at the import-graph level so the modal exercises
// its dispatch (manga vs page) without hitting fetch.
const collectionsApi = vi.hoisted(() => ({
listMyCollections: vi.fn(),
getMyCollectionsContaining: vi.fn(),
addMangaToCollection: vi.fn(),
removeMangaFromCollection: vi.fn(),
createCollection: vi.fn()
}));
const pageCollectionsApi = vi.hoisted(() => ({
getMyCollectionsContainingPage: vi.fn(),
addPageToCollection: vi.fn(),
removePageFromCollection: vi.fn()
}));
vi.mock('$lib/api/collections', () => collectionsApi);
vi.mock('$lib/api/page_collections', () => pageCollectionsApi);
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
function summary(id: string, name: string, containing: string[] = []) {
return {
id,
user_id: 'u1',
name,
description: null,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
manga_count: 0,
sample_covers: [],
_containing: containing
};
}
describe('AddToCollectionModal', () => {
it('manga target loads via getMyCollectionsContaining', async () => {
collectionsApi.listMyCollections.mockResolvedValueOnce({
items: [summary('c1', 'Favorites')],
page: { limit: 200, offset: 0, total: 1 }
});
collectionsApi.getMyCollectionsContaining.mockResolvedValueOnce(['c1']);
render(AddToCollectionModal, {
props: {
open: true,
target: { kind: 'manga', id: 'm1' },
onClose: () => {}
}
});
// Wait for the $effect → load() → await chain to settle.
await tick();
await Promise.resolve();
await tick();
expect(collectionsApi.getMyCollectionsContaining).toHaveBeenCalledWith('m1');
expect(pageCollectionsApi.getMyCollectionsContainingPage).not.toHaveBeenCalled();
});
it('page target loads via getMyCollectionsContainingPage', async () => {
collectionsApi.listMyCollections.mockResolvedValueOnce({
items: [summary('c1', 'Favorite panels')],
page: { limit: 200, offset: 0, total: 1 }
});
pageCollectionsApi.getMyCollectionsContainingPage.mockResolvedValueOnce([]);
render(AddToCollectionModal, {
props: {
open: true,
target: { kind: 'page', id: 'p1' },
onClose: () => {}
}
});
await tick();
await Promise.resolve();
await tick();
expect(pageCollectionsApi.getMyCollectionsContainingPage).toHaveBeenCalledWith('p1');
expect(collectionsApi.getMyCollectionsContaining).not.toHaveBeenCalled();
expect(screen.getByText('Favorite panels')).toBeTruthy();
});
it('does not load when closed', () => {
render(AddToCollectionModal, {
props: {
open: false,
target: { kind: 'manga', id: 'm1' },
onClose: () => {}
}
});
expect(collectionsApi.listMyCollections).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,231 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
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';
/**
* Floating context menu anchored at `(anchor.x, anchor.y)` in
* viewport coordinates. After mount the menu position is clamped
* so it doesn't overflow the viewport on the right or bottom edge.
*
* Closing rules:
* - Esc.
* - pointerdown outside the menu (capture-phase so we beat the
* underlying right-click handler on the next page image).
* - Any scroll or resize, since the anchor is stale.
*/
let {
open,
anchor,
onClose,
onAddToCollection,
onAddTag,
onSaveImage,
onCopyLink,
collectionsCount,
tags
}: {
open: boolean;
anchor: { x: number; y: number };
onClose: () => void;
onAddToCollection: () => void;
onAddTag: () => void;
/** Open the page image in a new tab so the browser's "Save as"
* is one click away. */
onSaveImage: () => void;
/** Copy the canonical reader URL (with `?page=N`) to clipboard. */
onCopyLink: () => void;
/** Null while the count is still loading. */
collectionsCount: number | null;
/** May be empty. */
tags: string[];
} = $props();
let menuEl: HTMLDivElement | undefined = $state();
let pos = $state<{ left: number; top: number }>({ left: 0, top: 0 });
$effect(() => {
if (open) {
// Seed at the anchor, then post-mount nudge into the
// viewport once we know the menu's measured size.
pos = { left: anchor.x, top: anchor.y };
void clampAfterMount();
}
});
async function clampAfterMount() {
await tick();
if (!menuEl) return;
const rect = menuEl.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
let left = anchor.x;
let top = anchor.y;
if (left + rect.width + 8 > vw) left = Math.max(8, vw - rect.width - 8);
if (top + rect.height + 8 > vh) top = Math.max(8, vh - rect.height - 8);
pos = { left, top };
}
function onDocPointerDown(e: PointerEvent) {
if (!open) return;
if (menuEl && !menuEl.contains(e.target as Node)) {
onClose();
}
}
function onDocKeydown(e: KeyboardEvent) {
if (!open) return;
if (e.key === 'Escape') {
e.stopPropagation();
onClose();
}
}
function onScrollOrResize() {
if (open) onClose();
}
onMount(() => {
document.addEventListener('pointerdown', onDocPointerDown, true);
document.addEventListener('keydown', onDocKeydown, true);
window.addEventListener('scroll', onScrollOrResize, true);
window.addEventListener('resize', onScrollOrResize);
return () => {
document.removeEventListener('pointerdown', onDocPointerDown, true);
document.removeEventListener('keydown', onDocKeydown, true);
window.removeEventListener('scroll', onScrollOrResize, true);
window.removeEventListener('resize', onScrollOrResize);
};
});
const collectionsLine = $derived(
collectionsCount == null
? 'Loading…'
: collectionsCount === 0
? 'Not in any collection'
: `In ${collectionsCount} collection${collectionsCount === 1 ? '' : 's'}`
);
const tagsLine = $derived(
tags.length === 0 ? 'No tags yet' : `Tagged: ${tags.join(', ')}`
);
</script>
{#if open}
<!--
Rendered as a popover-shaped div + plain <button> children
rather than role="menu" + role="menuitem". The ARIA menu
pattern expects arrow-key navigation between menuitems, and
implementing that here would either re-invent a small focus
manager or violate the contract. `role="group"` is the
minimal role that gives `aria-label` a mooring — without it,
the label is ignored and a screen-reader user landing on the
first <button> would have no announced container.
-->
<div
bind:this={menuEl}
class="menu"
role="group"
aria-label="Page actions"
style:left="{pos.left}px"
style:top="{pos.top}px"
data-testid="page-context-menu"
>
<button
type="button"
class="item"
onclick={onAddToCollection}
data-testid="page-context-add-to-collection"
>
<FolderPlus size={14} aria-hidden="true" />
<span>Add to collection…</span>
</button>
<button
type="button"
class="item"
onclick={onAddTag}
data-testid="page-context-add-tag"
>
<Tag size={14} aria-hidden="true" />
<span>Add tag…</span>
</button>
<div class="divider" aria-hidden="true"></div>
<button
type="button"
class="item"
onclick={onSaveImage}
data-testid="page-context-save-image"
>
<Download size={14} aria-hidden="true" />
<span>Save image</span>
</button>
<button
type="button"
class="item"
onclick={onCopyLink}
data-testid="page-context-copy-link"
>
<Link2 size={14} aria-hidden="true" />
<span>Copy page link</span>
</button>
<div class="divider" aria-hidden="true"></div>
<p class="hint" data-testid="page-context-collections-line">
{collectionsLine}
</p>
<p class="hint" data-testid="page-context-tags-line">{tagsLine}</p>
</div>
{/if}
<style>
.menu {
position: fixed;
z-index: var(--z-modal);
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
padding: var(--space-1);
min-width: 14rem;
max-width: min(20rem, calc(100vw - 16px));
display: flex;
flex-direction: column;
gap: 2px;
}
.item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2);
background: transparent;
color: var(--text);
border: 1px solid transparent;
border-radius: var(--radius-sm);
text-align: left;
cursor: pointer;
}
.item:hover,
.item:focus-visible {
background: var(--surface-elevated);
}
.divider {
height: 1px;
background: var(--border);
margin: var(--space-1) 0;
}
.hint {
margin: 0;
padding: 0 var(--space-2);
color: var(--text-muted);
font-size: var(--font-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,109 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/svelte';
import PageContextMenu from './PageContextMenu.svelte';
afterEach(() => cleanup());
const baseProps = {
open: true,
anchor: { x: 100, y: 100 },
onClose: () => {},
onAddToCollection: () => {},
onAddTag: () => {},
onSaveImage: () => {},
onCopyLink: () => {},
collectionsCount: 0,
tags: [] as string[]
};
describe('PageContextMenu', () => {
it('renders all four action items', () => {
render(PageContextMenu, { props: baseProps });
expect(screen.getByTestId('page-context-add-to-collection')).toBeTruthy();
expect(screen.getByTestId('page-context-add-tag')).toBeTruthy();
expect(screen.getByTestId('page-context-save-image')).toBeTruthy();
expect(screen.getByTestId('page-context-copy-link')).toBeTruthy();
});
it('invokes onSaveImage and onCopyLink when their items are clicked', () => {
const onSaveImage = vi.fn();
const onCopyLink = vi.fn();
render(PageContextMenu, {
props: { ...baseProps, onSaveImage, onCopyLink }
});
screen.getByTestId('page-context-save-image').click();
screen.getByTestId('page-context-copy-link').click();
expect(onSaveImage).toHaveBeenCalledOnce();
expect(onCopyLink).toHaveBeenCalledOnce();
});
it('renders empty-state contextual lines when count=0 and tags=[]', () => {
render(PageContextMenu, { props: baseProps });
expect(screen.getByTestId('page-context-collections-line').textContent).toBe(
'Not in any collection'
);
expect(screen.getByTestId('page-context-tags-line').textContent).toBe(
'No tags yet'
);
});
it('renders loading line when collectionsCount is null', () => {
render(PageContextMenu, {
props: { ...baseProps, collectionsCount: null }
});
expect(screen.getByTestId('page-context-collections-line').textContent).toBe(
'Loading…'
);
});
it('renders pluralized line when in multiple collections', () => {
render(PageContextMenu, {
props: { ...baseProps, collectionsCount: 3 }
});
expect(screen.getByTestId('page-context-collections-line').textContent).toBe(
'In 3 collections'
);
});
it('renders singular line when in one collection', () => {
render(PageContextMenu, {
props: { ...baseProps, collectionsCount: 1 }
});
expect(screen.getByTestId('page-context-collections-line').textContent).toBe(
'In 1 collection'
);
});
it('joins tags with commas', () => {
render(PageContextMenu, {
props: { ...baseProps, tags: ['funny', 'fight', 'panel-of-the-day'] }
});
expect(screen.getByTestId('page-context-tags-line').textContent).toBe(
'Tagged: funny, fight, panel-of-the-day'
);
});
it('invokes callbacks when items are clicked', () => {
const onAddToCollection = vi.fn();
const onAddTag = vi.fn();
render(PageContextMenu, {
props: { ...baseProps, onAddToCollection, onAddTag }
});
screen.getByTestId('page-context-add-to-collection').click();
screen.getByTestId('page-context-add-tag').click();
expect(onAddToCollection).toHaveBeenCalledOnce();
expect(onAddTag).toHaveBeenCalledOnce();
});
it('closes on Escape', async () => {
const onClose = vi.fn();
render(PageContextMenu, { props: { ...baseProps, onClose } });
await fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalledOnce();
});
it('does not render when closed', () => {
render(PageContextMenu, { props: { ...baseProps, open: false } });
expect(screen.queryByTestId('page-context-menu')).toBeNull();
});
});

View File

@@ -0,0 +1,144 @@
<script lang="ts">
import {
listMyPageTags,
type TaggedPageItem,
type PageTagSummary
} from '$lib/api/page_tags';
import TaggedPageRow from './TaggedPageRow.svelte';
/**
* Library tab content for "Page tags". Renders the distinct-tags
* chip cloud (seeded from the loader) and a paged list of tagged
* pages, with breadcrumb + thumbnail. Clicking a chip filters the
* list to that tag; clicking a row jumps to the reader.
*/
let {
initialItems,
initialDistinct
}: {
initialItems: TaggedPageItem[];
initialDistinct: PageTagSummary[];
} = $props();
// svelte-ignore state_referenced_locally
let items = $state<TaggedPageItem[]>([...initialItems]);
let active: string | null = $state(null);
let loading = $state(false);
let error: string | null = $state(null);
async function setActive(tag: string | null) {
if (active === tag) {
// Click the active chip again to clear the filter.
tag = null;
}
active = tag;
loading = true;
error = null;
try {
const page = await listMyPageTags(
tag != null ? { tag, limit: 100 } : { limit: 100 }
);
items = page.items;
} catch (e) {
error = (e as Error).message;
} finally {
loading = false;
}
}
</script>
{#if initialDistinct.length === 0 && items.length === 0}
<p class="hint" data-testid="library-page-tags-empty">
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 initialDistinct.length > 0}
<div class="chip-cloud" data-testid="library-page-tags-chips">
{#each initialDistinct as s (s.tag)}
<button
type="button"
class="chip"
class:active={active === s.tag}
onclick={() => setActive(s.tag)}
data-testid={`library-page-tags-chip-${s.tag}`}
>
{s.tag}
<span class="count">{s.count}</span>
</button>
{/each}
</div>
{/if}
{#if error}
<p class="error" role="alert" data-testid="library-page-tags-error">{error}</p>
{/if}
{#if loading}
<p class="hint">Loading…</p>
{:else if items.length === 0}
<p class="hint" data-testid="library-page-tags-filtered-empty">
No pages tagged with "{active}".
</p>
{:else}
<ul class="list" data-testid="library-page-tags-list">
{#each items as p (`${p.tag}::${p.page_id}`)}
<TaggedPageRow item={p} />
{/each}
</ul>
{/if}
{/if}
<style>
.hint {
color: var(--text-muted);
}
.error {
color: var(--danger);
}
.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.active {
background: var(--primary-soft-bg);
border-color: var(--primary);
color: var(--primary);
}
.count {
color: var(--text-muted);
font-size: var(--font-xs);
}
.chip.active .count {
color: var(--primary);
}
.list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
</style>

View File

@@ -0,0 +1,85 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/svelte';
import { tick } from 'svelte';
import PageTagsList from './PageTagsList.svelte';
const pageTagsApi = vi.hoisted(() => ({
listMyPageTags: vi.fn()
}));
vi.mock('$lib/api/page_tags', async () => {
const actual =
await vi.importActual<typeof import('$lib/api/page_tags')>(
'$lib/api/page_tags'
);
return { ...actual, ...pageTagsApi };
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
function tagged(extra: Record<string, unknown> = {}) {
return {
tag: 'funny',
page_id: 'p1',
chapter_id: 'ch1',
manga_id: 'm1',
page_number: 1,
chapter_number: 1,
chapter_title: null,
manga_title: 'Berserk',
storage_key: 'mangas/m1/chapters/ch1/pages/0001.png',
tagged_at: '2026-01-01T00:00:00Z',
...extra
};
}
describe('PageTagsList', () => {
it('renders empty state when nothing is tagged', () => {
render(PageTagsList, {
props: { initialItems: [], initialDistinct: [] }
});
expect(screen.getByTestId('library-page-tags-empty')).toBeTruthy();
});
it('renders chip cloud and list from initial props', () => {
render(PageTagsList, {
props: {
initialItems: [tagged({ tag: 'funny' })],
initialDistinct: [
{ tag: 'funny', count: 5 },
{ tag: 'fight', count: 2 }
]
}
});
expect(screen.getByTestId('library-page-tags-chip-funny')).toBeTruthy();
expect(screen.getByTestId('library-page-tags-chip-fight')).toBeTruthy();
expect(screen.getByTestId('library-page-tags-list')).toBeTruthy();
});
it('clicking a chip filters via listMyPageTags', async () => {
pageTagsApi.listMyPageTags.mockResolvedValueOnce({
items: [tagged({ tag: 'fight' })],
page: { limit: 100, offset: 0, total: 1 }
});
render(PageTagsList, {
props: {
initialItems: [tagged({ tag: 'funny' })],
initialDistinct: [
{ tag: 'funny', count: 5 },
{ tag: 'fight', count: 2 }
]
}
});
await fireEvent.click(screen.getByTestId('library-page-tags-chip-fight'));
await tick();
await Promise.resolve();
await tick();
expect(pageTagsApi.listMyPageTags).toHaveBeenCalledWith({
tag: 'fight',
limit: 100
});
});
});

View File

@@ -0,0 +1,123 @@
<script lang="ts">
import { fileUrl } from '$lib/api/client';
import { chapterLabel } from '$lib/api/chapters';
import type { TaggedChapterAggregate } from '$lib/api/page_tags';
/**
* Single row in the /search Chapters tab. Renders the breadcrumb
* (manga title · chapter label), a match-count pill, and a
* thumbnail strip of up to 3 matching pages. Clicking the row
* lands on the reader at chapter page 1; the reader will respect
* the user's single/continuous mode preference.
*/
let {
item,
testid
}: {
item: TaggedChapterAggregate;
testid?: string;
} = $props();
const href = $derived(
`/manga/${item.manga_id}/chapter/${item.chapter_id}`
);
const primaryCover = $derived(item.sample_storage_keys[0] ?? null);
</script>
<li class="row" data-testid={testid}>
<a {href} class="cover-link" aria-hidden="true" tabindex="-1">
{#if primaryCover}
<img src={fileUrl(primaryCover)} alt="" class="cover" loading="lazy" />
{:else}
<div class="cover cover-placeholder"></div>
{/if}
</a>
<div class="meta">
<a class="title" {href}>
{item.manga_title} · {chapterLabel({
number: item.chapter_number,
title: item.chapter_title
})}
</a>
<span class="match-count">
{item.match_count}
{item.match_count === 1 ? 'page' : 'pages'}
</span>
{#if item.sample_storage_keys.length > 1}
<div class="samples">
{#each item.sample_storage_keys.slice(1) as key (key)}
<img src={fileUrl(key)} alt="" class="sample" loading="lazy" />
{/each}
</div>
{/if}
</div>
</li>
<style>
.row {
display: grid;
grid-template-columns: 56px 1fr;
gap: var(--space-3);
align-items: start;
}
.cover-link {
display: block;
line-height: 0;
}
.cover {
width: 56px;
aspect-ratio: 2 / 3;
object-fit: cover;
border-radius: var(--radius-sm);
background: var(--surface);
}
.cover-placeholder {
background: var(--surface-elevated);
}
.meta {
display: flex;
flex-direction: column;
gap: var(--space-1);
min-width: 0;
}
.title {
font-weight: var(--weight-semibold);
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.title:hover {
color: var(--primary);
text-decoration: none;
}
.match-count {
align-self: flex-start;
background: var(--primary-soft-bg);
color: var(--primary);
font-size: var(--font-xs);
padding: 2px var(--space-2);
border-radius: var(--radius-pill);
}
.samples {
display: flex;
gap: var(--space-1);
margin-top: var(--space-1);
}
.sample {
width: 40px;
aspect-ratio: 2 / 3;
object-fit: cover;
border-radius: var(--radius-sm);
background: var(--surface);
}
</style>

View File

@@ -0,0 +1,67 @@
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/svelte';
import TaggedChapterRow from './TaggedChapterRow.svelte';
afterEach(() => cleanup());
function fixture(overrides: Record<string, unknown> = {}) {
return {
chapter_id: 'ch1',
manga_id: 'm1',
manga_title: 'Berserk',
chapter_number: 3,
chapter_title: null as string | null,
match_count: 12,
sample_storage_keys: [
'mangas/m1/chapters/ch1/pages/0001.png',
'mangas/m1/chapters/ch1/pages/0002.png',
'mangas/m1/chapters/ch1/pages/0003.png'
],
...overrides
};
}
describe('TaggedChapterRow', () => {
it('links the row to the reader at the chapter root (no ?page=)', () => {
const { container } = render(TaggedChapterRow, {
props: { item: fixture() }
});
const link = container.querySelector('a.title') as HTMLAnchorElement;
expect(link.href).toContain('/manga/m1/chapter/ch1');
expect(link.href).not.toContain('?page=');
});
it('shows pluralized match-count when > 1', () => {
render(TaggedChapterRow, { props: { item: fixture({ match_count: 12 }) } });
expect(screen.getByText('12 pages')).toBeTruthy();
});
it('shows singular match-count when exactly 1', () => {
render(TaggedChapterRow, { props: { item: fixture({ match_count: 1 }) } });
expect(screen.getByText('1 page')).toBeTruthy();
});
it('renders breadcrumb with chapter title when present', () => {
render(TaggedChapterRow, {
props: { item: fixture({ chapter_title: 'The Brand' }) }
});
expect(screen.getByText(/Berserk · The Brand/)).toBeTruthy();
});
it('renders thumbnail strip for samples beyond the primary cover', () => {
const { container } = render(TaggedChapterRow, {
props: { item: fixture() }
});
// 3 samples: index 0 is the primary cover (rendered separately
// in the .cover slot). Indices 1-2 land in .samples.
const samples = container.querySelectorAll('.sample');
expect(samples.length).toBe(2);
});
it('falls back to a placeholder when no sample keys', () => {
const { container } = render(TaggedChapterRow, {
props: { item: fixture({ sample_storage_keys: [] }) }
});
expect(container.querySelector('.cover-placeholder')).toBeTruthy();
});
});

View File

@@ -0,0 +1,127 @@
<script lang="ts">
import { fileUrl } from '$lib/api/client';
import type { TaggedMangaAggregate } from '$lib/api/page_tags';
/**
* Single row in the /search Mangas tab. Renders the manga cover,
* title, the cross-chapter match-count pill, and a thumbnail
* strip of matching pages. Clicking the row lands on the manga
* detail page.
*
* Cover precedence: `manga_cover_image_path` if the manga has
* one, else the first sample page. Mangas without uploaded
* covers (rare during dev / bots) still get a recognisable thumb.
*/
let {
item,
testid
}: {
item: TaggedMangaAggregate;
testid?: string;
} = $props();
const href = $derived(`/manga/${item.manga_id}`);
const primaryCover = $derived(
item.manga_cover_image_path ?? item.sample_storage_keys[0] ?? null
);
/** Sample strip skips the slot already used as the primary cover
* to avoid showing the same thumbnail twice. */
const sampleStrip = $derived(
item.manga_cover_image_path != null
? item.sample_storage_keys
: item.sample_storage_keys.slice(1)
);
</script>
<li class="row" data-testid={testid}>
<a {href} class="cover-link" aria-hidden="true" tabindex="-1">
{#if primaryCover}
<img src={fileUrl(primaryCover)} alt="" class="cover" loading="lazy" />
{:else}
<div class="cover cover-placeholder"></div>
{/if}
</a>
<div class="meta">
<a class="title" {href}>{item.manga_title}</a>
<span class="match-count">
{item.match_count}
{item.match_count === 1 ? 'page' : 'pages'}
</span>
{#if sampleStrip.length > 0}
<div class="samples">
{#each sampleStrip as key (key)}
<img src={fileUrl(key)} alt="" class="sample" loading="lazy" />
{/each}
</div>
{/if}
</div>
</li>
<style>
.row {
display: grid;
grid-template-columns: 56px 1fr;
gap: var(--space-3);
align-items: start;
}
.cover-link {
display: block;
line-height: 0;
}
.cover {
width: 56px;
aspect-ratio: 2 / 3;
object-fit: cover;
border-radius: var(--radius-sm);
background: var(--surface);
}
.cover-placeholder {
background: var(--surface-elevated);
}
.meta {
display: flex;
flex-direction: column;
gap: var(--space-1);
min-width: 0;
}
.title {
font-weight: var(--weight-semibold);
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.title:hover {
color: var(--primary);
text-decoration: none;
}
.match-count {
align-self: flex-start;
background: var(--primary-soft-bg);
color: var(--primary);
font-size: var(--font-xs);
padding: 2px var(--space-2);
border-radius: var(--radius-pill);
}
.samples {
display: flex;
gap: var(--space-1);
margin-top: var(--space-1);
}
.sample {
width: 40px;
aspect-ratio: 2 / 3;
object-fit: cover;
border-radius: var(--radius-sm);
background: var(--surface);
}
</style>

View File

@@ -0,0 +1,72 @@
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/svelte';
import TaggedMangaRow from './TaggedMangaRow.svelte';
afterEach(() => cleanup());
function fixture(overrides: Record<string, unknown> = {}) {
return {
manga_id: 'm1',
manga_title: 'Berserk',
manga_cover_image_path: 'mangas/m1/cover.png' as string | null,
match_count: 28,
sample_storage_keys: [
'mangas/m1/chapters/ch1/pages/0005.png',
'mangas/m1/chapters/ch2/pages/0010.png',
'mangas/m1/chapters/ch3/pages/0015.png'
],
...overrides
};
}
describe('TaggedMangaRow', () => {
it('links to the manga detail page', () => {
const { container } = render(TaggedMangaRow, {
props: { item: fixture() }
});
const link = container.querySelector('a.title') as HTMLAnchorElement;
expect(link.href).toMatch(/\/manga\/m1$/);
});
it('prefers manga_cover_image_path as the primary cover', () => {
const { container } = render(TaggedMangaRow, {
props: { item: fixture() }
});
const cover = container.querySelector('img.cover') as HTMLImageElement;
expect(cover.src).toContain('mangas/m1/cover.png');
// All 3 samples land in the strip because the manga cover
// already filled the primary slot.
expect(container.querySelectorAll('.sample').length).toBe(3);
});
it('falls back to the first sample when no manga cover', () => {
const { container } = render(TaggedMangaRow, {
props: { item: fixture({ manga_cover_image_path: null }) }
});
const cover = container.querySelector('img.cover') as HTMLImageElement;
expect(cover.src).toContain('chapters/ch1/pages/0005.png');
// Strip shows the remaining 2 samples.
expect(container.querySelectorAll('.sample').length).toBe(2);
});
it('shows singular / plural match-count', () => {
const { rerender } = render(TaggedMangaRow, {
props: { item: fixture({ match_count: 1 }) }
});
expect(screen.getByText('1 page')).toBeTruthy();
rerender({ item: fixture({ match_count: 28 }) });
expect(screen.getByText('28 pages')).toBeTruthy();
});
it('renders placeholder when neither cover nor samples exist', () => {
const { container } = render(TaggedMangaRow, {
props: {
item: fixture({
manga_cover_image_path: null,
sample_storage_keys: []
})
}
});
expect(container.querySelector('.cover-placeholder')).toBeTruthy();
});
});

View File

@@ -0,0 +1,108 @@
<script lang="ts">
import { fileUrl } from '$lib/api/client';
import { chapterLabel } from '$lib/api/chapters';
import type { TaggedPageItem } from '$lib/api/page_tags';
/**
* Single row in any tagged-page list — used by both the library
* Page-tags tab and the /search Pages tab. Links the cover and
* the breadcrumb text to the reader at `?page=N` so the user
* lands on the exact page they're looking at.
*
* `showTagPill` defaults true (library wants it). The /search
* Pages tab can suppress it when a tag filter is already active,
* to remove the noise of every row reading "#funny".
*/
let {
item,
showTagPill = true,
testid
}: {
item: TaggedPageItem;
showTagPill?: boolean;
testid?: string;
} = $props();
const readerHref = $derived(
`/manga/${item.manga_id}/chapter/${item.chapter_id}?page=${item.page_number}`
);
</script>
<li class="row" data-testid={testid}>
<a href={readerHref} class="cover-link" aria-hidden="true" tabindex="-1">
<img
src={fileUrl(item.storage_key)}
alt=""
class="cover"
loading="lazy"
/>
</a>
<div class="meta">
<a class="title" href={`/manga/${item.manga_id}`}>{item.manga_title}</a>
<a class="target" href={readerHref}>
{chapterLabel({
number: item.chapter_number,
title: item.chapter_title
})} — page {item.page_number}
</a>
{#if showTagPill}
<span class="tag-pill">#{item.tag}</span>
{/if}
</div>
</li>
<style>
.row {
display: grid;
grid-template-columns: 56px 1fr;
gap: var(--space-3);
align-items: center;
}
.cover-link {
display: block;
line-height: 0;
}
.cover {
width: 56px;
aspect-ratio: 2 / 3;
object-fit: cover;
border-radius: var(--radius-sm);
background: var(--surface);
}
.meta {
display: flex;
flex-direction: column;
gap: var(--space-1);
min-width: 0;
}
.title {
font-weight: var(--weight-semibold);
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.title:hover {
color: var(--primary);
text-decoration: none;
}
.target {
font-size: var(--font-sm);
color: var(--primary);
}
.tag-pill {
align-self: flex-start;
background: var(--surface-elevated);
color: var(--text-muted);
font-size: var(--font-xs);
padding: 2px var(--space-2);
border-radius: var(--radius-pill);
}
</style>

View File

@@ -0,0 +1,58 @@
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/svelte';
import TaggedPageRow from './TaggedPageRow.svelte';
afterEach(() => cleanup());
function fixture(overrides: Record<string, unknown> = {}) {
return {
tag: 'funny',
page_id: 'p1',
chapter_id: 'ch1',
manga_id: 'm1',
page_number: 5,
chapter_number: 3,
chapter_title: null,
manga_title: 'Berserk',
storage_key: 'mangas/m1/chapters/ch1/pages/0005.png',
tagged_at: '2026-01-01T00:00:00Z',
...overrides
};
}
describe('TaggedPageRow', () => {
it('links the breadcrumb to the reader at ?page=N', () => {
const { container } = render(TaggedPageRow, { props: { item: fixture() } });
const anchors = Array.from(
container.querySelectorAll('a[href*="?page="]')
) as HTMLAnchorElement[];
// Both the (aria-hidden) cover link and the visible breadcrumb
// point to the reader at the same page.
expect(anchors.length).toBeGreaterThanOrEqual(2);
expect(anchors[0].href).toContain('/manga/m1/chapter/ch1?page=5');
});
it('shows the tag pill by default', () => {
render(TaggedPageRow, { props: { item: fixture({ tag: 'fight' }) } });
expect(screen.getByText('#fight')).toBeTruthy();
});
it('omits the tag pill when showTagPill=false', () => {
render(TaggedPageRow, {
props: { item: fixture(), showTagPill: false }
});
expect(screen.queryByText('#funny')).toBeNull();
});
it('renders chapter title when present', () => {
render(TaggedPageRow, {
props: { item: fixture({ chapter_title: 'The Brand' }) }
});
expect(screen.getByText(/The Brand/)).toBeTruthy();
});
it('falls back to "Chapter N" when title is null', () => {
render(TaggedPageRow, { props: { item: fixture({ chapter_title: null }) } });
expect(screen.getByText(/Chapter 3/)).toBeTruthy();
});
});

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>

View File

@@ -1,7 +1,48 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/svelte';
import TapZone from './TapZone.svelte';
// jsdom does not implement PointerEvent. Construct a MouseEvent of the
// correct type and decorate it with the `pointerType` + coords the
// component reads — Svelte forwards it to the onpointerdown handler
// unchanged, and the guard `e.pointerType === 'touch'` reads our
// property.
function pointerEvent(
type: string,
init: { pointerType?: string; clientX?: number; clientY?: number } = {}
): Event {
const ev = new MouseEvent(type, {
bubbles: true,
clientX: init.clientX,
clientY: init.clientY
});
if (init.pointerType != null) {
Object.defineProperty(ev, 'pointerType', {
value: init.pointerType,
configurable: true
});
}
return ev;
}
function pointerDown(
el: Element,
init: { pointerType: string; clientX: number; clientY: number }
) {
el.dispatchEvent(pointerEvent('pointerdown', init));
}
function pointerMove(
el: Element,
init: { clientX: number; clientY: number }
) {
el.dispatchEvent(pointerEvent('pointermove', init));
}
function pointerUp(el: Element) {
el.dispatchEvent(pointerEvent('pointerup'));
}
afterEach(() => cleanup());
describe('TapZone', () => {
@@ -49,4 +90,147 @@ describe('TapZone', () => {
expect(screen.getByTestId('reader-tap-center')).toBeTruthy();
expect(screen.getByTestId('reader-tap-right')).toBeTruthy();
});
describe('long-press', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('fires onLongPress with anchor coords after 450ms touch hold', () => {
const onLongPress = vi.fn();
const onNext = vi.fn();
render(TapZone, {
props: {
onPrev: () => {},
onNext,
onToggle: () => {},
onLongPress
}
});
const right = screen.getByTestId('tap-zone-right');
pointerDown(right, {
pointerType: 'touch',
clientX: 200,
clientY: 300
});
vi.advanceTimersByTime(500);
expect(onLongPress).toHaveBeenCalledWith({ x: 200, y: 300 });
// The synthesized click that follows the long-press is
// suppressed so the next-page handler doesn't fire.
right.click();
expect(onNext).not.toHaveBeenCalled();
});
it('does not fire onLongPress for mouse pointers', () => {
const onLongPress = vi.fn();
render(TapZone, {
props: {
onPrev: () => {},
onNext: () => {},
onToggle: () => {},
onLongPress
}
});
const center = screen.getByTestId('tap-zone-center');
pointerDown(center, {
pointerType: 'mouse',
clientX: 100,
clientY: 100
});
vi.advanceTimersByTime(1000);
expect(onLongPress).not.toHaveBeenCalled();
});
it('cancels long-press when pointer moves beyond tolerance', () => {
const onLongPress = vi.fn();
render(TapZone, {
props: {
onPrev: () => {},
onNext: () => {},
onToggle: () => {},
onLongPress
}
});
const right = screen.getByTestId('tap-zone-right');
pointerDown(right, {
pointerType: 'touch',
clientX: 100,
clientY: 100
});
pointerMove(right, { clientX: 200, clientY: 100 });
vi.advanceTimersByTime(500);
expect(onLongPress).not.toHaveBeenCalled();
});
it('cancels long-press on pointer up before threshold', () => {
const onLongPress = vi.fn();
render(TapZone, {
props: {
onPrev: () => {},
onNext: () => {},
onToggle: () => {},
onLongPress
}
});
const center = screen.getByTestId('tap-zone-center');
pointerDown(center, {
pointerType: 'touch',
clientX: 100,
clientY: 100
});
pointerUp(center);
vi.advanceTimersByTime(500);
expect(onLongPress).not.toHaveBeenCalled();
});
it('regular click still fires onNext when no long-press handler', () => {
const onNext = vi.fn();
render(TapZone, {
props: {
onPrev: () => {},
onNext,
onToggle: () => {}
}
});
screen.getByTestId('tap-zone-right').click();
expect(onNext).toHaveBeenCalledOnce();
});
it('suppression auto-expires so the next legitimate tap fires after a slide-off long-press', () => {
// Scenario: user long-presses, finger slides off the zone
// onto the opened sheet, releases there. No click is
// synthesized on the zone, so without the expiry the
// suppression would leak and eat the next tap. The 500ms
// expiry inside the long-press callback caps the
// suppression window.
const onLongPress = vi.fn();
const onNext = vi.fn();
render(TapZone, {
props: {
onPrev: () => {},
onNext,
onToggle: () => {},
onLongPress
}
});
const right = screen.getByTestId('tap-zone-right');
pointerDown(right, {
pointerType: 'touch',
clientX: 100,
clientY: 100
});
// Long-press fires.
vi.advanceTimersByTime(450);
expect(onLongPress).toHaveBeenCalledOnce();
// Drain the suppression-expiry window without a click —
// simulates the user sliding off and releasing elsewhere.
vi.advanceTimersByTime(600);
// Fresh tap on the zone — must NOT be eaten.
right.click();
expect(onNext).toHaveBeenCalledOnce();
});
});
});