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:
315
frontend/src/lib/components/AddTagsSheet.svelte
Normal file
315
frontend/src/lib/components/AddTagsSheet.svelte
Normal 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>
|
||||
113
frontend/src/lib/components/AddTagsSheet.svelte.test.ts
Normal file
113
frontend/src/lib/components/AddTagsSheet.svelte.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
100
frontend/src/lib/components/AddToCollectionModal.svelte.test.ts
Normal file
100
frontend/src/lib/components/AddToCollectionModal.svelte.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
231
frontend/src/lib/components/PageContextMenu.svelte
Normal file
231
frontend/src/lib/components/PageContextMenu.svelte
Normal 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>
|
||||
109
frontend/src/lib/components/PageContextMenu.svelte.test.ts
Normal file
109
frontend/src/lib/components/PageContextMenu.svelte.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
144
frontend/src/lib/components/PageTagsList.svelte
Normal file
144
frontend/src/lib/components/PageTagsList.svelte
Normal 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>
|
||||
85
frontend/src/lib/components/PageTagsList.svelte.test.ts
Normal file
85
frontend/src/lib/components/PageTagsList.svelte.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
123
frontend/src/lib/components/TaggedChapterRow.svelte
Normal file
123
frontend/src/lib/components/TaggedChapterRow.svelte
Normal 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>
|
||||
67
frontend/src/lib/components/TaggedChapterRow.svelte.test.ts
Normal file
67
frontend/src/lib/components/TaggedChapterRow.svelte.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
127
frontend/src/lib/components/TaggedMangaRow.svelte
Normal file
127
frontend/src/lib/components/TaggedMangaRow.svelte
Normal 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>
|
||||
72
frontend/src/lib/components/TaggedMangaRow.svelte.test.ts
Normal file
72
frontend/src/lib/components/TaggedMangaRow.svelte.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
108
frontend/src/lib/components/TaggedPageRow.svelte
Normal file
108
frontend/src/lib/components/TaggedPageRow.svelte
Normal 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>
|
||||
58
frontend/src/lib/components/TaggedPageRow.svelte.test.ts
Normal file
58
frontend/src/lib/components/TaggedPageRow.svelte.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user