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:
101
frontend/src/lib/api/page_collections.test.ts
Normal file
101
frontend/src/lib/api/page_collections.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type MockInstance
|
||||
} from 'vitest';
|
||||
import {
|
||||
addPageToCollection,
|
||||
removePageFromCollection,
|
||||
listCollectionPages,
|
||||
getMyCollectionsContainingPage
|
||||
} from './page_collections';
|
||||
|
||||
function ok(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
function noContent(): Response {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
function pageItemFixture(extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
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',
|
||||
added_at: '2026-01-01T00:00:00Z',
|
||||
...extra
|
||||
};
|
||||
}
|
||||
|
||||
describe('page_collections api client', () => {
|
||||
let fetchSpy: MockInstance<typeof globalThis.fetch>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('listCollectionPages hits the breadcrumb endpoint', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({
|
||||
items: [pageItemFixture()],
|
||||
page: { limit: 50, offset: 0, total: 1 }
|
||||
})
|
||||
);
|
||||
const r = await listCollectionPages('c1');
|
||||
expect(r.items[0].manga_title).toBe('Berserk');
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/collections\/c1\/pages$/);
|
||||
});
|
||||
|
||||
it('listCollectionPages forwards pagination params', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({ items: [], page: { limit: 10, offset: 20, total: 0 } })
|
||||
);
|
||||
await listCollectionPages('c1', { limit: 10, offset: 20 });
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toContain('limit=10');
|
||||
expect(url).toContain('offset=20');
|
||||
});
|
||||
|
||||
it('addPageToCollection POSTs { page_id }', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(ok({}, 201));
|
||||
await addPageToCollection('c1', 'p1');
|
||||
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||
expect(init.method).toBe('POST');
|
||||
expect(JSON.parse(init.body as string)).toEqual({ page_id: 'p1' });
|
||||
});
|
||||
|
||||
it('removePageFromCollection DELETEs with both ids encoded', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(noContent());
|
||||
await removePageFromCollection('c with space', 'p1');
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
// Path includes encoded "c with space" + page id.
|
||||
expect(url).toContain('/v1/collections/c%20with%20space/pages/p1');
|
||||
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||
expect(init.method).toBe('DELETE');
|
||||
});
|
||||
|
||||
it('getMyCollectionsContainingPage unwraps `collection_ids`', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(ok({ collection_ids: ['c1', 'c2'] }));
|
||||
const ids = await getMyCollectionsContainingPage('p1');
|
||||
expect(ids).toEqual(['c1', 'c2']);
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/pages\/p1\/my-collections$/);
|
||||
});
|
||||
});
|
||||
68
frontend/src/lib/api/page_collections.ts
Normal file
68
frontend/src/lib/api/page_collections.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { request, type Page } from './client';
|
||||
|
||||
/** Row returned by `GET /v1/collections/:id/pages`. */
|
||||
export type CollectionPageItem = {
|
||||
page_id: string;
|
||||
chapter_id: string;
|
||||
manga_id: string;
|
||||
page_number: number;
|
||||
chapter_number: number;
|
||||
chapter_title: string | null;
|
||||
manga_title: string;
|
||||
storage_key: string;
|
||||
added_at: string;
|
||||
};
|
||||
|
||||
export type CollectionPagesPage = {
|
||||
items: CollectionPageItem[];
|
||||
page: Page;
|
||||
};
|
||||
|
||||
export type ListOptions = { limit?: number; offset?: number };
|
||||
|
||||
export async function listCollectionPages(
|
||||
collectionId: string,
|
||||
opts: ListOptions = {}
|
||||
): Promise<CollectionPagesPage> {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.limit != null) params.set('limit', String(opts.limit));
|
||||
if (opts.offset != null) params.set('offset', String(opts.offset));
|
||||
const qs = params.toString();
|
||||
return request<CollectionPagesPage>(
|
||||
`/v1/collections/${encodeURIComponent(collectionId)}/pages${qs ? `?${qs}` : ''}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function addPageToCollection(
|
||||
collectionId: string,
|
||||
pageId: string
|
||||
): Promise<void> {
|
||||
await request<void>(
|
||||
`/v1/collections/${encodeURIComponent(collectionId)}/pages`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ page_id: pageId })
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function removePageFromCollection(
|
||||
collectionId: string,
|
||||
pageId: string
|
||||
): Promise<void> {
|
||||
await request<void>(
|
||||
`/v1/collections/${encodeURIComponent(collectionId)}/pages/${encodeURIComponent(pageId)}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
}
|
||||
|
||||
/** Which of the user's collections currently contain this page. */
|
||||
export async function getMyCollectionsContainingPage(
|
||||
pageId: string
|
||||
): Promise<string[]> {
|
||||
const r = await request<{ collection_ids: string[] }>(
|
||||
`/v1/pages/${encodeURIComponent(pageId)}/my-collections`
|
||||
);
|
||||
return r.collection_ids;
|
||||
}
|
||||
166
frontend/src/lib/api/page_tags.test.ts
Normal file
166
frontend/src/lib/api/page_tags.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type MockInstance
|
||||
} from 'vitest';
|
||||
import {
|
||||
getMyTagsForPage,
|
||||
addTagToPage,
|
||||
removeTagFromPage,
|
||||
listMyPageTags,
|
||||
listMyDistinctPageTags,
|
||||
listTaggedChapters,
|
||||
listTaggedMangas
|
||||
} from './page_tags';
|
||||
|
||||
function ok(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
function noContent(): Response {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
function taggedItemFixture(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('page_tags api client', () => {
|
||||
let fetchSpy: MockInstance<typeof globalThis.fetch>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('getMyTagsForPage unwraps `tags`', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(ok({ tags: ['funny', 'fight'] }));
|
||||
const tags = await getMyTagsForPage('p1');
|
||||
expect(tags).toEqual(['funny', 'fight']);
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/pages\/p1\/my-tags$/);
|
||||
});
|
||||
|
||||
it('addTagToPage POSTs { tag }', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(ok({}, 201));
|
||||
await addTagToPage('p1', 'funny');
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/pages\/p1\/tags$/);
|
||||
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||
expect(init.method).toBe('POST');
|
||||
expect(JSON.parse(init.body as string)).toEqual({ tag: 'funny' });
|
||||
});
|
||||
|
||||
it('removeTagFromPage URL-encodes the tag', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(noContent());
|
||||
await removeTagFromPage('p1', 'character:askeladd');
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
// ':' becomes %3A under encodeURIComponent.
|
||||
expect(url).toContain('/v1/pages/p1/tags/character%3Aaskeladd');
|
||||
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||
expect(init.method).toBe('DELETE');
|
||||
});
|
||||
|
||||
it('listMyPageTags forwards tag / q / pagination params', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({
|
||||
items: [taggedItemFixture()],
|
||||
page: { limit: 50, offset: 0, total: 1 }
|
||||
})
|
||||
);
|
||||
await listMyPageTags({ tag: 'funny', q: 'fu', limit: 10, offset: 20 });
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toContain('tag=funny');
|
||||
expect(url).toContain('q=fu');
|
||||
expect(url).toContain('limit=10');
|
||||
expect(url).toContain('offset=20');
|
||||
});
|
||||
|
||||
it('listMyPageTags omits query string when no params', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({ items: [], page: { limit: 50, offset: 0, total: 0 } })
|
||||
);
|
||||
await listMyPageTags();
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/me\/page-tags$/);
|
||||
});
|
||||
|
||||
it('listMyDistinctPageTags unwraps `items`', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({
|
||||
items: [
|
||||
{ tag: 'funny', count: 5 },
|
||||
{ tag: 'fight', count: 2 }
|
||||
]
|
||||
})
|
||||
);
|
||||
const summaries = await listMyDistinctPageTags();
|
||||
expect(summaries.map((s) => s.tag)).toEqual(['funny', 'fight']);
|
||||
});
|
||||
|
||||
it('listMyDistinctPageTags sends only `q` when prefix supplied', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(ok({ items: [] }));
|
||||
await listMyDistinctPageTags('fu');
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toContain('q=fu');
|
||||
});
|
||||
|
||||
it('listTaggedChapters always sends tag, order/limit/offset are optional', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({
|
||||
items: [],
|
||||
page: { limit: 50, offset: 0, total: 0 }
|
||||
})
|
||||
);
|
||||
await listTaggedChapters({ tag: 'funny' });
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/me\/page-tags\/chapters\?tag=funny$/);
|
||||
});
|
||||
|
||||
it('listTaggedChapters forwards order + pagination', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({ items: [], page: { limit: 25, offset: 10, total: 0 } })
|
||||
);
|
||||
await listTaggedChapters({
|
||||
tag: 'funny',
|
||||
order: 'asc',
|
||||
limit: 25,
|
||||
offset: 10
|
||||
});
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toContain('tag=funny');
|
||||
expect(url).toContain('order=asc');
|
||||
expect(url).toContain('limit=25');
|
||||
expect(url).toContain('offset=10');
|
||||
});
|
||||
|
||||
it('listTaggedMangas hits the mangas endpoint', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({ items: [], page: { limit: 50, offset: 0, total: 0 } })
|
||||
);
|
||||
await listTaggedMangas({ tag: 'funny' });
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/me\/page-tags\/mangas\?tag=funny$/);
|
||||
});
|
||||
});
|
||||
145
frontend/src/lib/api/page_tags.ts
Normal file
145
frontend/src/lib/api/page_tags.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { request, type Page } from './client';
|
||||
|
||||
/** Row returned by `GET /v1/me/page-tags`. */
|
||||
export type TaggedPageItem = {
|
||||
tag: string;
|
||||
page_id: string;
|
||||
chapter_id: string;
|
||||
manga_id: string;
|
||||
page_number: number;
|
||||
chapter_number: number;
|
||||
chapter_title: string | null;
|
||||
manga_title: string;
|
||||
storage_key: string;
|
||||
tagged_at: string;
|
||||
};
|
||||
|
||||
export type MyPageTagsPage = {
|
||||
items: TaggedPageItem[];
|
||||
page: Page;
|
||||
};
|
||||
|
||||
export type PageTagSummary = {
|
||||
tag: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type ListMineOptions = {
|
||||
/** Exact-match filter (chip filter in the library tab). */
|
||||
tag?: string;
|
||||
/** Prefix filter (autocomplete-style). */
|
||||
q?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export async function getMyTagsForPage(pageId: string): Promise<string[]> {
|
||||
const r = await request<{ tags: string[] }>(
|
||||
`/v1/pages/${encodeURIComponent(pageId)}/my-tags`
|
||||
);
|
||||
return r.tags;
|
||||
}
|
||||
|
||||
export async function addTagToPage(pageId: string, tag: string): Promise<void> {
|
||||
await request<void>(`/v1/pages/${encodeURIComponent(pageId)}/tags`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ tag })
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeTagFromPage(pageId: string, tag: string): Promise<void> {
|
||||
await request<void>(
|
||||
`/v1/pages/${encodeURIComponent(pageId)}/tags/${encodeURIComponent(tag)}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
}
|
||||
|
||||
export async function listMyPageTags(
|
||||
opts: ListMineOptions = {}
|
||||
): Promise<MyPageTagsPage> {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.tag != null) params.set('tag', opts.tag);
|
||||
if (opts.q != null) params.set('q', opts.q);
|
||||
if (opts.limit != null) params.set('limit', String(opts.limit));
|
||||
if (opts.offset != null) params.set('offset', String(opts.offset));
|
||||
const qs = params.toString();
|
||||
return request<MyPageTagsPage>(`/v1/me/page-tags${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
|
||||
export async function listMyDistinctPageTags(
|
||||
prefix?: string,
|
||||
limit?: number
|
||||
): Promise<PageTagSummary[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (prefix != null && prefix !== '') params.set('q', prefix);
|
||||
if (limit != null) params.set('limit', String(limit));
|
||||
const qs = params.toString();
|
||||
const r = await request<{ items: PageTagSummary[] }>(
|
||||
`/v1/me/page-tags/distinct${qs ? `?${qs}` : ''}`
|
||||
);
|
||||
return r.items;
|
||||
}
|
||||
|
||||
/** Row returned by `GET /v1/me/page-tags/chapters`. */
|
||||
export type TaggedChapterAggregate = {
|
||||
chapter_id: string;
|
||||
manga_id: string;
|
||||
manga_title: string;
|
||||
chapter_number: number;
|
||||
chapter_title: string | null;
|
||||
match_count: number;
|
||||
/** Up to 3 storage keys of matching pages, page-number ascending. */
|
||||
sample_storage_keys: string[];
|
||||
};
|
||||
|
||||
/** Row returned by `GET /v1/me/page-tags/mangas`. */
|
||||
export type TaggedMangaAggregate = {
|
||||
manga_id: string;
|
||||
manga_title: string;
|
||||
manga_cover_image_path: string | null;
|
||||
match_count: number;
|
||||
sample_storage_keys: string[];
|
||||
};
|
||||
|
||||
export type TaggedChaptersPage = {
|
||||
items: TaggedChapterAggregate[];
|
||||
page: Page;
|
||||
};
|
||||
|
||||
export type TaggedMangasPage = {
|
||||
items: TaggedMangaAggregate[];
|
||||
page: Page;
|
||||
};
|
||||
|
||||
export type AggregateOptions = {
|
||||
tag: string;
|
||||
order?: 'desc' | 'asc';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
function aggregateQs(opts: AggregateOptions): string {
|
||||
const params = new URLSearchParams();
|
||||
params.set('tag', opts.tag);
|
||||
if (opts.order != null) params.set('order', opts.order);
|
||||
if (opts.limit != null) params.set('limit', String(opts.limit));
|
||||
if (opts.offset != null) params.set('offset', String(opts.offset));
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
export async function listTaggedChapters(
|
||||
opts: AggregateOptions
|
||||
): Promise<TaggedChaptersPage> {
|
||||
return request<TaggedChaptersPage>(
|
||||
`/v1/me/page-tags/chapters?${aggregateQs(opts)}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function listTaggedMangas(
|
||||
opts: AggregateOptions
|
||||
): Promise<TaggedMangasPage> {
|
||||
return request<TaggedMangasPage>(
|
||||
`/v1/me/page-tags/mangas?${aggregateQs(opts)}`
|
||||
);
|
||||
}
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import Search from '@lucide/svelte/icons/search';
|
||||
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
|
||||
import ArrowUpDown from '@lucide/svelte/icons/arrow-up-down';
|
||||
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
@@ -402,7 +403,22 @@
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<h1>Mangas</h1>
|
||||
<div class="heading-row">
|
||||
<h1>Mangas</h1>
|
||||
<!--
|
||||
Secondary entry point into the per-page search at /search,
|
||||
which is headed "Page search". Label matches the destination's
|
||||
title so the two read as one feature. It filters the user's
|
||||
tagged pages and pivots between Pages / Chapters / Mangas;
|
||||
title-search above stays the primary action. The name is also
|
||||
forward-compatible with the planned OCR text search (which
|
||||
searches page content, not just tags).
|
||||
-->
|
||||
<a class="alt-search" href="/search" data-testid="nav-page-search">
|
||||
<span>Page search</span>
|
||||
<ArrowRight size={14} aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onsubmit={onSubmit}
|
||||
@@ -562,11 +578,38 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.heading-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.heading-row h1 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.alt-search {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-sm);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.alt-search:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-4);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.search-row {
|
||||
|
||||
@@ -5,7 +5,12 @@
|
||||
removeMangaFromCollection,
|
||||
updateCollection
|
||||
} from '$lib/api/collections';
|
||||
import {
|
||||
removePageFromCollection,
|
||||
type CollectionPageItem
|
||||
} from '$lib/api/page_collections';
|
||||
import type { Manga } from '$lib/api/client';
|
||||
import { fileUrl } from '$lib/api/client';
|
||||
import MangaCard from '$lib/components/MangaCard.svelte';
|
||||
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
||||
import Pencil from '@lucide/svelte/icons/pencil';
|
||||
@@ -18,6 +23,8 @@
|
||||
let collection = $state({ ...data.collection });
|
||||
// svelte-ignore state_referenced_locally
|
||||
let mangas = $state<Manga[]>([...data.mangas]);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let pages = $state<CollectionPageItem[]>([...data.pages]);
|
||||
|
||||
let editing = $state(false);
|
||||
let editName = $state('');
|
||||
@@ -72,6 +79,17 @@
|
||||
editError = (e as Error).message;
|
||||
}
|
||||
}
|
||||
|
||||
async function onRemovePage(p: CollectionPageItem) {
|
||||
const snapshot = pages;
|
||||
pages = pages.filter((x) => x.page_id !== p.page_id);
|
||||
try {
|
||||
await removePageFromCollection(collection.id, p.page_id);
|
||||
} catch (e) {
|
||||
pages = snapshot;
|
||||
editError = (e as Error).message;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -162,28 +180,73 @@
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if mangas.length === 0}
|
||||
{#if mangas.length === 0 && pages.length === 0}
|
||||
<p class="status" data-testid="collection-empty">
|
||||
This collection is empty.
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="manga-grid" data-testid="collection-manga-list">
|
||||
{#each mangas as m (m.id)}
|
||||
<li class="card-with-remove">
|
||||
<MangaCard manga={m} testid={`collection-manga-${m.id}`} />
|
||||
<button
|
||||
type="button"
|
||||
class="remove"
|
||||
onclick={() => onRemoveManga(m)}
|
||||
aria-label={`Remove ${m.title} from collection`}
|
||||
title="Remove from collection"
|
||||
data-testid={`collection-remove-manga-${m.id}`}
|
||||
>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if mangas.length > 0}
|
||||
<section aria-labelledby="mangas-heading">
|
||||
<h2 id="mangas-heading" class="section-heading">Mangas</h2>
|
||||
<ul class="manga-grid" data-testid="collection-manga-list">
|
||||
{#each mangas as m (m.id)}
|
||||
<li class="card-with-remove">
|
||||
<MangaCard manga={m} testid={`collection-manga-${m.id}`} />
|
||||
<button
|
||||
type="button"
|
||||
class="remove"
|
||||
onclick={() => onRemoveManga(m)}
|
||||
aria-label={`Remove ${m.title} from collection`}
|
||||
title="Remove from collection"
|
||||
data-testid={`collection-remove-manga-${m.id}`}
|
||||
>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if pages.length > 0}
|
||||
<section aria-labelledby="pages-heading">
|
||||
<h2 id="pages-heading" class="section-heading">Pages</h2>
|
||||
<ul class="page-grid" data-testid="collection-page-list">
|
||||
{#each pages as p (p.page_id)}
|
||||
<li class="card-with-remove">
|
||||
<a
|
||||
class="page-card"
|
||||
href={`/manga/${p.manga_id}/chapter/${p.chapter_id}?page=${p.page_number}`}
|
||||
data-testid={`collection-page-${p.page_id}`}
|
||||
>
|
||||
<img
|
||||
src={fileUrl(p.storage_key)}
|
||||
alt={`${p.manga_title} chapter ${p.chapter_number} page ${p.page_number}`}
|
||||
class="page-thumb"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span class="page-meta">
|
||||
<span class="page-title">{p.manga_title}</span>
|
||||
<span class="page-breadcrumb">
|
||||
Ch. {p.chapter_number} · page {p.page_number}
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="remove"
|
||||
onclick={() => onRemovePage(p)}
|
||||
aria-label={`Remove ${p.manga_title} page ${p.page_number} from collection`}
|
||||
title="Remove from collection"
|
||||
data-testid={`collection-remove-page-${p.page_id}`}
|
||||
>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@@ -260,6 +323,67 @@
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
margin: var(--space-5) 0 var(--space-3);
|
||||
font-size: var(--font-lg);
|
||||
}
|
||||
|
||||
.section-heading:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.page-grid {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.page-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-card:hover {
|
||||
border-color: var(--primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.page-thumb {
|
||||
width: 100%;
|
||||
aspect-ratio: 2 / 3;
|
||||
object-fit: cover;
|
||||
background: var(--surface-elevated);
|
||||
}
|
||||
|
||||
.page-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-weight: var(--weight-medium);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.page-breadcrumb {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
|
||||
.card-with-remove {
|
||||
position: relative;
|
||||
list-style: none;
|
||||
|
||||
@@ -4,33 +4,30 @@ import {
|
||||
getCollection,
|
||||
listCollectionMangas
|
||||
} from '$lib/api/collections';
|
||||
import { listCollectionPages } from '$lib/api/page_collections';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const ssr = false;
|
||||
|
||||
export const load: PageLoad = async ({ params, url }) => {
|
||||
try {
|
||||
const [collection, mangas] = await Promise.all([
|
||||
const [collection, mangas, pages] = await Promise.all([
|
||||
getCollection(params.id),
|
||||
listCollectionMangas(params.id, { limit: 200 })
|
||||
listCollectionMangas(params.id, { limit: 200 }),
|
||||
listCollectionPages(params.id, { limit: 200 })
|
||||
]);
|
||||
return {
|
||||
collection,
|
||||
mangas: mangas.items,
|
||||
total: mangas.page.total
|
||||
total: mangas.page.total,
|
||||
pages: pages.items
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
// 401 means the user's session is gone — bounce to login
|
||||
// and preserve where they wanted to go.
|
||||
if (e.status === 401) {
|
||||
const next = encodeURIComponent(url.pathname);
|
||||
redirect(302, `/login?next=${next}`);
|
||||
}
|
||||
// 403 (post-Phase-3-polish the backend collapses this to
|
||||
// 404 already, but keep the branch for defense-in-depth)
|
||||
// and 404 both render the standard not-found page so the
|
||||
// URL doesn't disclose collection existence to non-owners.
|
||||
if (e.status === 404 || e.status === 403) {
|
||||
error(404, 'Collection not found');
|
||||
}
|
||||
|
||||
@@ -6,14 +6,16 @@
|
||||
import SegmentedControl from '$lib/components/SegmentedControl.svelte';
|
||||
import BookmarkList from '$lib/components/BookmarkList.svelte';
|
||||
import CollectionsGrid from '$lib/components/CollectionsGrid.svelte';
|
||||
import PageTagsList from '$lib/components/PageTagsList.svelte';
|
||||
import BookImage from '@lucide/svelte/icons/book-image';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
type Tab = 'bookmarks' | 'collections' | 'history';
|
||||
type Tab = 'bookmarks' | 'collections' | 'page-tags' | 'history';
|
||||
const TABS: { label: string; value: Tab }[] = [
|
||||
{ label: 'Bookmarks', value: 'bookmarks' },
|
||||
{ label: 'Collections', value: 'collections' },
|
||||
{ label: 'Page tags', value: 'page-tags' },
|
||||
{ label: 'History', value: 'history' }
|
||||
];
|
||||
|
||||
@@ -22,7 +24,8 @@
|
||||
// default so visiting /library bare doesn't add noise to the URL.
|
||||
const activeTab: Tab = $derived.by(() => {
|
||||
const t = $page.url.searchParams.get('tab');
|
||||
return t === 'collections' || t === 'history' ? t : 'bookmarks';
|
||||
if (t === 'collections' || t === 'history' || t === 'page-tags') return t;
|
||||
return 'bookmarks';
|
||||
});
|
||||
|
||||
function setTab(t: Tab) {
|
||||
@@ -34,6 +37,8 @@
|
||||
const url = new URL($page.url);
|
||||
if (t === 'bookmarks') url.searchParams.delete('tab');
|
||||
else url.searchParams.set('tab', t);
|
||||
// Cast away the literal union — the SegmentedControl is
|
||||
// generic so its onchange receives `string`.
|
||||
void goto(url.toString(), {
|
||||
replaceState: true,
|
||||
keepFocus: true,
|
||||
@@ -81,6 +86,11 @@
|
||||
{:else}
|
||||
<CollectionsGrid collections={data.collections} />
|
||||
{/if}
|
||||
{:else if activeTab === 'page-tags'}
|
||||
<PageTagsList
|
||||
initialItems={data.pageTags}
|
||||
initialDistinct={data.distinctPageTags}
|
||||
/>
|
||||
{:else if data.history.length === 0}
|
||||
<p class="hint" data-testid="library-history-empty">
|
||||
Nothing here yet — open any manga and a row will land here once you turn
|
||||
|
||||
@@ -2,15 +2,19 @@ import { ApiError } from '$lib/api/client';
|
||||
import { listMyBookmarks } from '$lib/api/bookmarks';
|
||||
import { listMyCollections } from '$lib/api/collections';
|
||||
import { listMyReadProgress } from '$lib/api/read_progress';
|
||||
import {
|
||||
listMyPageTags,
|
||||
listMyDistinctPageTags
|
||||
} from '$lib/api/page_tags';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const ssr = false;
|
||||
|
||||
/**
|
||||
* Loads bookmarks + collections + reading-history in one shot so the
|
||||
* Library segmented control can swap between sub-tabs without firing a
|
||||
* second round trip per tap. 401 → unauthenticated path; the page
|
||||
* surfaces a sign-in prompt and renders empty lists.
|
||||
* Loads bookmarks + collections + history + page-tags in one shot so
|
||||
* the Library segmented control can swap between sub-tabs without
|
||||
* firing a second round trip per tap. 401 → unauthenticated path; the
|
||||
* page surfaces a sign-in prompt and renders empty lists.
|
||||
*/
|
||||
export const load: PageLoad = async () => {
|
||||
const empty = {
|
||||
@@ -18,19 +22,26 @@ export const load: PageLoad = async () => {
|
||||
bookmarks: [] as Awaited<ReturnType<typeof listMyBookmarks>>['items'],
|
||||
collections: [] as Awaited<ReturnType<typeof listMyCollections>>['items'],
|
||||
history: [] as Awaited<ReturnType<typeof listMyReadProgress>>['items'],
|
||||
pageTags: [] as Awaited<ReturnType<typeof listMyPageTags>>['items'],
|
||||
distinctPageTags: [] as Awaited<ReturnType<typeof listMyDistinctPageTags>>,
|
||||
error: null as string | null
|
||||
};
|
||||
try {
|
||||
const [bookmarks, collections, history] = await Promise.all([
|
||||
listMyBookmarks(),
|
||||
listMyCollections({ limit: 200 }),
|
||||
listMyReadProgress({ limit: 100 })
|
||||
]);
|
||||
const [bookmarks, collections, history, pageTags, distinctPageTags] =
|
||||
await Promise.all([
|
||||
listMyBookmarks(),
|
||||
listMyCollections({ limit: 200 }),
|
||||
listMyReadProgress({ limit: 100 }),
|
||||
listMyPageTags({ limit: 100 }),
|
||||
listMyDistinctPageTags(undefined, 100)
|
||||
]);
|
||||
return {
|
||||
...empty,
|
||||
bookmarks: bookmarks.items,
|
||||
collections: collections.items,
|
||||
history: history.items
|
||||
history: history.items,
|
||||
pageTags: pageTags.items,
|
||||
distinctPageTags
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 401) {
|
||||
|
||||
@@ -567,7 +567,7 @@
|
||||
{#if session.user}
|
||||
<AddToCollectionModal
|
||||
open={collectionModalOpen}
|
||||
mangaId={manga.id}
|
||||
target={{ kind: 'manga', id: manga.id }}
|
||||
onClose={() => (collectionModalOpen = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { goto, invalidateAll } from '$app/navigation';
|
||||
import { afterNavigate, goto, invalidateAll } from '$app/navigation';
|
||||
import { fileUrl, ApiError } from '$lib/api/client';
|
||||
import { GAP_PX, type ReaderPageGap } from '$lib/api/preferences';
|
||||
import { preferences } from '$lib/preferences.svelte';
|
||||
@@ -11,8 +11,18 @@
|
||||
import { readerFullscreen } from '$lib/reader-fullscreen.svelte';
|
||||
import { session } from '$lib/session.svelte';
|
||||
import Sheet from '$lib/components/Sheet.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import SegmentedControl from '$lib/components/SegmentedControl.svelte';
|
||||
import TapZone from '$lib/components/TapZone.svelte';
|
||||
import PageContextMenu from '$lib/components/PageContextMenu.svelte';
|
||||
import AddTagsSheet from '$lib/components/AddTagsSheet.svelte';
|
||||
import AddToCollectionModal from '$lib/components/AddToCollectionModal.svelte';
|
||||
import { getMyCollectionsContainingPage } from '$lib/api/page_collections';
|
||||
import { getMyTagsForPage } from '$lib/api/page_tags';
|
||||
import FolderPlus from '@lucide/svelte/icons/folder-plus';
|
||||
import Tag from '@lucide/svelte/icons/tag';
|
||||
import Download from '@lucide/svelte/icons/download';
|
||||
import Link2 from '@lucide/svelte/icons/link-2';
|
||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
||||
@@ -58,21 +68,26 @@
|
||||
: null
|
||||
);
|
||||
|
||||
// Seed the initial page index from `?page=`. Numeric values are
|
||||
// 1-indexed and clamped to the chapter's page count; the sentinel
|
||||
// `last` lands on the final page (used by the prev-chapter chevron
|
||||
// when going backwards through the series). Component remounts on
|
||||
// chapter navigation, so this only runs at the start of each
|
||||
// chapter — referencing `data` here is intentional.
|
||||
// svelte-ignore state_referenced_locally
|
||||
const initialIndex = (() => {
|
||||
// Initial page index from `?page=`. Numeric values are 1-indexed
|
||||
// and clamped to the chapter's page count; the sentinel `last`
|
||||
// lands on the final page (used by the prev-chapter chevron when
|
||||
// going backwards through the series).
|
||||
//
|
||||
// `$derived` so it tracks `data.requestedPage` / `data.pages`
|
||||
// across chapter navigation — SvelteKit reuses the component on
|
||||
// same-route nav (the chevrons at `jumpToPrevChapter` /
|
||||
// `jumpToNextChapter` below call `goto(...)` to navigate between
|
||||
// chapters), and a `const` would freeze initialIndex at the
|
||||
// first chapter's value.
|
||||
const initialIndex = $derived.by(() => {
|
||||
const req = data.requestedPage;
|
||||
if (req === 'last') return Math.max(0, data.pages.length - 1);
|
||||
if (typeof req === 'number') {
|
||||
return Math.min(Math.max(0, req - 1), Math.max(0, data.pages.length - 1));
|
||||
}
|
||||
return 0;
|
||||
})();
|
||||
});
|
||||
// svelte-ignore state_referenced_locally
|
||||
let index = $state(initialIndex);
|
||||
let continuousPageEls: HTMLImageElement[] = $state([]);
|
||||
let chapterBarEl: HTMLElement | undefined = $state();
|
||||
@@ -88,6 +103,217 @@
|
||||
let chapterJumpOpen = $state(false);
|
||||
let settingsOpen = $state(false);
|
||||
|
||||
// ---- Page context menu / tag + collection actions ----
|
||||
//
|
||||
// Desktop: right-click on a `.page-image` opens the floating
|
||||
// `PageContextMenu`. Mobile: long-press (via TapZone in single
|
||||
// mode, or per-image timer in continuous mode) opens an action
|
||||
// sheet that funnels into the same modals. Unauthenticated users
|
||||
// see neither — there's nothing for them to act on.
|
||||
let contextMenuOpen = $state(false);
|
||||
let contextMenuAnchor = $state<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
let activePageId = $state<string | null>(null);
|
||||
let activePageCollectionCount = $state<number | null>(null);
|
||||
let activePageTags = $state<string[]>([]);
|
||||
let actionSheetOpen = $state(false);
|
||||
let collectionsModalOpen = $state(false);
|
||||
let tagsModalOpen = $state(false);
|
||||
|
||||
// Monotonically-increasing token so a slow loadPageSummary for
|
||||
// page A can't clobber a fresh load for page B. The user right-
|
||||
// clicking one image then another on a slow network is the
|
||||
// scenario; without the token the older request's resolution
|
||||
// wins because it lands last.
|
||||
let loadPageSummaryToken = 0;
|
||||
|
||||
async function loadPageSummary(pageId: string) {
|
||||
const token = ++loadPageSummaryToken;
|
||||
activePageCollectionCount = null;
|
||||
activePageTags = [];
|
||||
try {
|
||||
const [ids, tags] = await Promise.all([
|
||||
getMyCollectionsContainingPage(pageId),
|
||||
getMyTagsForPage(pageId)
|
||||
]);
|
||||
if (token !== loadPageSummaryToken) return;
|
||||
activePageCollectionCount = ids.length;
|
||||
activePageTags = tags;
|
||||
} catch {
|
||||
// Soft-fail — context lines just stay empty / loading.
|
||||
}
|
||||
}
|
||||
|
||||
function openContextMenu(anchor: { x: number; y: number }, pageId: string) {
|
||||
if (!session.user) return;
|
||||
activePageId = pageId;
|
||||
contextMenuAnchor = anchor;
|
||||
contextMenuOpen = true;
|
||||
void loadPageSummary(pageId);
|
||||
}
|
||||
|
||||
function openActionSheet(pageId: string) {
|
||||
if (!session.user) return;
|
||||
activePageId = pageId;
|
||||
actionSheetOpen = true;
|
||||
void loadPageSummary(pageId);
|
||||
}
|
||||
|
||||
function onPageContextMenu(e: MouseEvent, pageId: string) {
|
||||
if (!session.user) return;
|
||||
// Power-user escape hatch — Shift + right-click drops to the
|
||||
// native browser context menu (image save, copy, inspect).
|
||||
if (e.shiftKey) return;
|
||||
e.preventDefault();
|
||||
openContextMenu({ x: e.clientX, y: e.clientY }, pageId);
|
||||
}
|
||||
|
||||
function handleAddToCollection() {
|
||||
contextMenuOpen = false;
|
||||
actionSheetOpen = false;
|
||||
collectionsModalOpen = true;
|
||||
}
|
||||
|
||||
function handleAddTag() {
|
||||
contextMenuOpen = false;
|
||||
actionSheetOpen = false;
|
||||
tagsModalOpen = true;
|
||||
}
|
||||
|
||||
function activePageNumber(): number | null {
|
||||
if (!activePageId) return null;
|
||||
const i = pages.findIndex((p) => p.id === activePageId);
|
||||
return i >= 0 ? i + 1 : null;
|
||||
}
|
||||
|
||||
function activeStorageKey(): string | null {
|
||||
if (!activePageId) return null;
|
||||
return pages.find((p) => p.id === activePageId)?.storage_key ?? null;
|
||||
}
|
||||
|
||||
function handleSaveImage() {
|
||||
const key = activeStorageKey();
|
||||
contextMenuOpen = false;
|
||||
actionSheetOpen = false;
|
||||
if (!key) return;
|
||||
// Open in a new tab — the browser surfaces its native "Save
|
||||
// image as" / long-press save from there. `download` on an
|
||||
// anchor would force the disk-save dialog, which is more
|
||||
// direct but loses the in-tab preview some users prefer.
|
||||
window.open(fileUrl(key), '_blank', 'noopener');
|
||||
}
|
||||
|
||||
// Two-state pill: either "Link copied" (clipboard write OK) or a
|
||||
// selectable URL pill (clipboard unavailable — insecure context,
|
||||
// permissions denied). Auto-clears via timer; the failure pill
|
||||
// sticks around for 10s (vs. 1.6s for success) so the user has
|
||||
// time to long-press → Copy on mobile without the pill vanishing
|
||||
// mid-gesture. The timer is torn down on chapter navigation so
|
||||
// a stale fire doesn't try to write post-unmount state.
|
||||
let linkCopiedState = $state<
|
||||
{ kind: 'hidden' } | { kind: 'ok' } | { kind: 'manual'; url: string }
|
||||
>({ kind: 'hidden' });
|
||||
let linkCopiedTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function clearLinkCopiedTimer() {
|
||||
if (linkCopiedTimer != null) {
|
||||
clearTimeout(linkCopiedTimer);
|
||||
linkCopiedTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopyLink() {
|
||||
const n = activePageNumber();
|
||||
contextMenuOpen = false;
|
||||
actionSheetOpen = false;
|
||||
if (n == null) return;
|
||||
const url = `${window.location.origin}/manga/${manga.id}/chapter/${chapter.id}?page=${n}`;
|
||||
clearLinkCopiedTimer();
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
linkCopiedState = { kind: 'ok' };
|
||||
linkCopiedTimer = setTimeout(() => {
|
||||
linkCopiedState = { kind: 'hidden' };
|
||||
linkCopiedTimer = null;
|
||||
}, 1600);
|
||||
} catch {
|
||||
// Insecure context (HTTP on a LAN demo), missing
|
||||
// permission, or some other API unavailability. Surface
|
||||
// the URL in the pill so the user can copy it manually
|
||||
// instead of silently swallowing — clipboard failures are
|
||||
// systemic, not transient, and the silent path leaves the
|
||||
// user pressing the button again with no feedback.
|
||||
linkCopiedState = { kind: 'manual', url };
|
||||
linkCopiedTimer = setTimeout(() => {
|
||||
linkCopiedState = { kind: 'hidden' };
|
||||
linkCopiedTimer = null;
|
||||
}, 10000);
|
||||
}
|
||||
}
|
||||
|
||||
// Continuous mode lacks TapZone, so long-press lives per-image.
|
||||
// Each in-flight press is keyed by `PointerEvent.pointerId` so a
|
||||
// two-finger multitouch (one finger on each of two images) tracks
|
||||
// both independently — a single shared timer would let the later
|
||||
// press clobber the earlier one and fire the wrong page's sheet.
|
||||
const LONG_PRESS_MS = 450;
|
||||
const MOVE_TOLERANCE = 8;
|
||||
type Press = {
|
||||
start: { x: number; y: number };
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
const presses = new Map<number, Press>();
|
||||
|
||||
function clearPress(pointerId: number) {
|
||||
const p = presses.get(pointerId);
|
||||
if (!p) return;
|
||||
clearTimeout(p.timer);
|
||||
presses.delete(pointerId);
|
||||
}
|
||||
|
||||
function clearAllPresses() {
|
||||
for (const p of presses.values()) clearTimeout(p.timer);
|
||||
presses.clear();
|
||||
}
|
||||
|
||||
function onPagePointerDown(e: PointerEvent, pageId: string) {
|
||||
if (e.pointerType !== 'touch') return;
|
||||
if (!session.user) return;
|
||||
clearPress(e.pointerId);
|
||||
const start = { x: e.clientX, y: e.clientY };
|
||||
const pointerId = e.pointerId;
|
||||
const timer = setTimeout(() => {
|
||||
presses.delete(pointerId);
|
||||
openActionSheet(pageId);
|
||||
}, LONG_PRESS_MS);
|
||||
presses.set(pointerId, { start, timer });
|
||||
}
|
||||
|
||||
function onPagePointerMove(e: PointerEvent) {
|
||||
const p = presses.get(e.pointerId);
|
||||
if (!p) return;
|
||||
const dx = e.clientX - p.start.x;
|
||||
const dy = e.clientY - p.start.y;
|
||||
if (Math.hypot(dx, dy) > MOVE_TOLERANCE) clearPress(e.pointerId);
|
||||
}
|
||||
|
||||
function onPagePointerUp(e: PointerEvent) {
|
||||
clearPress(e.pointerId);
|
||||
}
|
||||
|
||||
// Scroll-pan typically fires pointercancel on the underlying touch,
|
||||
// but not always — explicitly cancel every in-flight press on any
|
||||
// scroll event so a long hold during a vertical pan never fires
|
||||
// the action sheet for a page the user is already past. Matches
|
||||
// the TapZone-level cancel-on-scroll rule.
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
window.addEventListener('scroll', clearAllPresses, true);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', clearAllPresses, true);
|
||||
clearAllPresses();
|
||||
};
|
||||
});
|
||||
|
||||
// Brightness overlay — 1.0 is no dimming, 0.3 is maximum (70% black
|
||||
// overlay opacity). Stored in localStorage only; the Preferences
|
||||
// table doesn't carry this field and Phase 4 explicitly opted to
|
||||
@@ -125,6 +351,121 @@
|
||||
if (Number.isFinite(v) && v >= 0.3 && v <= 1) brightness = v;
|
||||
});
|
||||
|
||||
// Continuous mode previously ignored `?page=N` — the single-mode
|
||||
// `initialIndex` logic above wires it into `let index = $state(...)`
|
||||
// but continuous lets the user scroll naturally, so nothing
|
||||
// jumped to the requested page on load. Search-result click-
|
||||
// through (and any shared link) now relies on it.
|
||||
//
|
||||
// Two subtleties this guards against:
|
||||
// 1. Lazy-load layout shift: pages with `loading="lazy"` have
|
||||
// 0×0 placeholder height until they load. `scrollIntoView`
|
||||
// runs on whatever the current layout says — if prior pages
|
||||
// haven't loaded, the target appears far higher than its
|
||||
// final position, and as the images load the target gets
|
||||
// pushed past the viewport. The template eager-loads pages
|
||||
// `0..=initialIndex` so they have heights, and the effect
|
||||
// waits for each of them to fire `load` (or `error`) before
|
||||
// scrolling.
|
||||
// 2. Mode hydration race: `mode` derives from `preferences.readerMode`,
|
||||
// which initialises to 'single' and is hydrated async on
|
||||
// cold caches. An `onMount` would bail before the flip; an
|
||||
// `$effect` re-fires when mode changes. A one-shot sentinel
|
||||
// keeps us from re-scrolling on later toggles.
|
||||
// 3. In-reader chapter navigation: SvelteKit reuses this
|
||||
// component on same-route goto(...) (see chevron handlers
|
||||
// below). The sentinel + `index` get reset by the
|
||||
// chapter-change effect below so the scroll fires again for
|
||||
// the new chapter's `?page=N`.
|
||||
let initialScrollDone = $state(false);
|
||||
|
||||
// Re-seed chapter-scoped state when the chevrons (or any in-
|
||||
// reader link) navigate to a sibling chapter. SvelteKit reuses
|
||||
// the component on same-route nav, so without this the previous
|
||||
// chapter's mutable state bleeds into the new one:
|
||||
// - `index` would keep the old chapter's value (possibly out
|
||||
// of range for the new chapter's page count).
|
||||
// - `initialScrollDone` would stay true, so `?page=N` deep-
|
||||
// link scrolls would never re-fire.
|
||||
// - `progressPage` would keep the old chapter's high-water
|
||||
// mark, and a pending `progressTimer` would flush it against
|
||||
// the new `chapter.id` — poisoning the new chapter's stored
|
||||
// read progress.
|
||||
//
|
||||
// `lastChapterIdSeen` is a plain `let` rather than `$state` on
|
||||
// purpose — it's only read inside this effect's body and never
|
||||
// drives reactivity. Making it `$state` would not change behavior
|
||||
// but would suggest to a future reader that the value matters
|
||||
// elsewhere.
|
||||
// Two things are load-bearing here:
|
||||
//
|
||||
// 1. `$derived(data.chapter.id)` makes the dependency explicit.
|
||||
// Reading `data.chapter.id` directly inside an effect does
|
||||
// not reliably re-fire when SvelteKit hands the page a new
|
||||
// `data` prop with a swapped nested chapter object —
|
||||
// verified by an E2E that stayed stuck on the previous
|
||||
// chapter's index across the full polling window. The
|
||||
// `$derived` exists for tracking, not memoization.
|
||||
//
|
||||
// 2. `$effect.pre` (vs. `$effect`) lands the reset BEFORE the
|
||||
// DOM mutation rather than after. The page-indicator and
|
||||
// everything else that reads `index` / `pages.length` then
|
||||
// render once, with both values from the new chapter. With
|
||||
// a plain `$effect` the indicator rendered the previous
|
||||
// chapter's `index` against the new chapter's
|
||||
// `pages.length` ("Page 5 / 4") — the very symptom the
|
||||
// regression test pins.
|
||||
const currentChapterId = $derived(data.chapter.id);
|
||||
let lastChapterIdSeen: string | null = null;
|
||||
$effect.pre(() => {
|
||||
const cid = currentChapterId;
|
||||
if (lastChapterIdSeen !== null && lastChapterIdSeen !== cid) {
|
||||
index = initialIndex;
|
||||
initialScrollDone = false;
|
||||
progressPage = initialProgressPage;
|
||||
if (progressTimer) {
|
||||
clearTimeout(progressTimer);
|
||||
progressTimer = null;
|
||||
}
|
||||
}
|
||||
lastChapterIdSeen = cid;
|
||||
});
|
||||
$effect(() => {
|
||||
if (initialScrollDone) return;
|
||||
if (!browser) return;
|
||||
if (mode !== 'continuous') return;
|
||||
if (initialIndex === 0) {
|
||||
initialScrollDone = true;
|
||||
return;
|
||||
}
|
||||
const target = continuousPageEls[initialIndex];
|
||||
// continuousPageEls is populated as the {#each} mounts; the
|
||||
// effect re-runs once it's bound.
|
||||
if (!target) return;
|
||||
|
||||
initialScrollDone = true;
|
||||
|
||||
const above = continuousPageEls
|
||||
.slice(0, initialIndex + 1)
|
||||
.filter((el): el is HTMLImageElement => el != null);
|
||||
const pending = above.filter((el) => !el.complete);
|
||||
if (pending.length === 0) {
|
||||
target.scrollIntoView({ block: 'start' });
|
||||
return;
|
||||
}
|
||||
let remaining = pending.length;
|
||||
const onResolved = () => {
|
||||
remaining -= 1;
|
||||
if (remaining === 0) target.scrollIntoView({ block: 'start' });
|
||||
};
|
||||
for (const el of pending) {
|
||||
// `error` counts as resolved — a broken image's height is
|
||||
// settled at its alt-text height, and we shouldn't hang.
|
||||
el.addEventListener('load', onResolved, { once: true });
|
||||
el.addEventListener('error', onResolved, { once: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Publish the dim level as a CSS variable on <html>. (1 - brightness)
|
||||
// gives 0..0.7 alpha for the fixed overlay rendered at the bottom
|
||||
// of the template. Persisting on every change keeps the
|
||||
@@ -367,6 +708,10 @@
|
||||
onMount(() => window.addEventListener('keydown', onKeydown));
|
||||
onDestroy(() => {
|
||||
if (typeof window !== 'undefined') window.removeEventListener('keydown', onKeydown);
|
||||
// Tear down the copy-pill timer so a fire-after-unmount
|
||||
// doesn't try to write to a stale $state rune. Cheap on
|
||||
// SvelteKit chapter navigation, which remounts this page.
|
||||
clearLinkCopiedTimer();
|
||||
});
|
||||
|
||||
// ---- Admin force resync (current chapter) ----
|
||||
@@ -411,14 +756,18 @@
|
||||
// Writes are debounced and fire-and-forget — the reader never
|
||||
// blocks on the network, and a failed write just means the user's
|
||||
// history is slightly stale (acceptable).
|
||||
// Route param `[n]` is part of the URL, so SvelteKit remounts
|
||||
// this component on chapter navigation — capturing the initial
|
||||
// `data` value here is the desired behaviour.
|
||||
// svelte-ignore state_referenced_locally
|
||||
const initialProgressPage =
|
||||
//
|
||||
// `$derived` so the seed recomputes on in-reader chapter
|
||||
// navigation (SvelteKit reuses the component on same-route nav).
|
||||
// The chapter-change effect above re-seeds `progressPage` from
|
||||
// this — without it, the previous chapter's high-water mark
|
||||
// carries over and gets written against the new `chapter.id`.
|
||||
const initialProgressPage = $derived.by(() =>
|
||||
data.readProgress && data.readProgress.chapter_id === chapter.id
|
||||
? Math.max(1, data.readProgress.page)
|
||||
: 1;
|
||||
: 1
|
||||
);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let progressPage = $state(initialProgressPage);
|
||||
let progressTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let observer: IntersectionObserver | null = null;
|
||||
@@ -446,26 +795,62 @@
|
||||
progressTimer = setTimeout(flushProgress, 1500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reader back behavior — pop browser history instead of pushing a
|
||||
* fresh entry for `/manga/{id}`. Previously this was a naked
|
||||
* `<a href>` and every tap pushed, so browser-back ping-ponged
|
||||
* between detail and reader instead of walking out to home.
|
||||
*
|
||||
* Just check `history.length > 1` — `document.referrer` does NOT
|
||||
* update across SvelteKit SPA navigations, so referrer-based
|
||||
* gating silently fell back to the default href every time.
|
||||
* Middle-click / cmd-click / right-click are passed through so
|
||||
* "open in new tab" still works.
|
||||
*/
|
||||
function onBackClick(e: MouseEvent) {
|
||||
// Two-part reader back-control:
|
||||
// - The arrow is always a "go back" action: pops browser
|
||||
// history if there's something to pop, falls back to the
|
||||
// detail page on a cold tab.
|
||||
// - The cover+title is smart: if the user got to the reader
|
||||
// FROM this manga's detail page they go back (pop, no dup
|
||||
// history entry), otherwise they land on the detail page
|
||||
// (push). Stops the "click cover/title and end up in search
|
||||
// results" regression that the naked back-everywhere code
|
||||
// used to have.
|
||||
//
|
||||
// `lastInternalPath` is captured by `afterNavigate({ from })`,
|
||||
// which SvelteKit fires after every client-side navigation. It's
|
||||
// the only reliable signal — `document.referrer` doesn't update
|
||||
// across SPA navs (the chrome-resync at line 718 doesn't help
|
||||
// either since it doesn't write referrer).
|
||||
let lastInternalPath: string | null = null;
|
||||
afterNavigate(({ from }) => {
|
||||
lastInternalPath = from?.url?.pathname ?? null;
|
||||
});
|
||||
|
||||
const detailPath = $derived(`/manga/${manga.id}`);
|
||||
|
||||
function onArrowClick(e: MouseEvent) {
|
||||
if (!browser) return;
|
||||
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||
e.preventDefault();
|
||||
if (window.history.length > 1) {
|
||||
window.history.back();
|
||||
} else {
|
||||
// Cold tab / deep-link: nothing to pop. Push the detail
|
||||
// page as the sane "back" destination.
|
||||
void goto(detailPath);
|
||||
}
|
||||
}
|
||||
|
||||
function onCoverTitleClick(e: MouseEvent) {
|
||||
if (!browser) return;
|
||||
// Modifier / middle / non-left clicks fall through to the
|
||||
// native <a> so "open in new tab", "copy link", etc. all work.
|
||||
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||
if (lastInternalPath === detailPath) {
|
||||
// Came from THIS manga's detail page — pop so we don't
|
||||
// accumulate a duplicate /manga/:id entry. preventDefault
|
||||
// here stops SvelteKit's own click handler: its listener
|
||||
// is on the app container in the bubble phase and bails on
|
||||
// `event.defaultPrevented` (see @sveltejs/kit client.js),
|
||||
// and this element-level handler runs first at the target
|
||||
// phase, so the pop wins cleanly.
|
||||
e.preventDefault();
|
||||
window.history.back();
|
||||
}
|
||||
// else: let the href navigate (deep-link / fresh tab path)
|
||||
// Anywhere else (search, library, another manga's detail, cold
|
||||
// tab) — do nothing here and let the <a href> navigate
|
||||
// natively, which PUSHES the detail page so browser-back
|
||||
// returns to the reader.
|
||||
}
|
||||
|
||||
// Single-mode: every page change moves the high-water mark.
|
||||
@@ -574,27 +959,46 @@
|
||||
</svelte:head>
|
||||
|
||||
<nav class="reader-nav" aria-label="reader" bind:this={readerNavEl}>
|
||||
<a
|
||||
href="/manga/{manga.id}"
|
||||
class="back"
|
||||
onclick={onBackClick}
|
||||
data-testid="back-to-manga"
|
||||
>
|
||||
<ArrowLeft size={18} aria-hidden="true" />
|
||||
{#if manga.cover_image_path}
|
||||
<img
|
||||
src={fileUrl(manga.cover_image_path)}
|
||||
alt=""
|
||||
class="back-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<span class="back-cover back-cover-placeholder" aria-hidden="true">
|
||||
<BookImage size={14} aria-hidden="true" />
|
||||
</span>
|
||||
{/if}
|
||||
<span class="back-text">{manga.title}</span>
|
||||
</a>
|
||||
<div class="back-group">
|
||||
<button
|
||||
type="button"
|
||||
class="back-arrow"
|
||||
aria-label="Back"
|
||||
onclick={onArrowClick}
|
||||
data-testid="reader-back-arrow"
|
||||
>
|
||||
<ArrowLeft size={18} aria-hidden="true" />
|
||||
</button>
|
||||
<!--
|
||||
Cover + title is a real <a href> to the detail page so
|
||||
cmd/middle-click "open in new tab" and "copy link" work
|
||||
natively. The onclick only intercepts the one case where
|
||||
we want to POP instead of push (arrived from this manga's
|
||||
own detail page) — see onCoverTitleClick. Every other
|
||||
plain-left-click falls through to SvelteKit's default <a>
|
||||
navigation, which pushes the detail page.
|
||||
-->
|
||||
<a
|
||||
href={detailPath}
|
||||
class="back"
|
||||
onclick={onCoverTitleClick}
|
||||
data-testid="back-to-manga"
|
||||
>
|
||||
{#if manga.cover_image_path}
|
||||
<img
|
||||
src={fileUrl(manga.cover_image_path)}
|
||||
alt=""
|
||||
class="back-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<span class="back-cover back-cover-placeholder" aria-hidden="true">
|
||||
<BookImage size={14} aria-hidden="true" />
|
||||
</span>
|
||||
{/if}
|
||||
<span class="back-text">{manga.title}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="controls" role="group" aria-label="reader options">
|
||||
<label class="chapter-field desktop-control">
|
||||
@@ -777,6 +1181,7 @@
|
||||
alt={`${manga.title} chapter ${chapter.number} page ${index + 1}`}
|
||||
class="page-image"
|
||||
loading="eager"
|
||||
oncontextmenu={(e) => onPageContextMenu(e, pages[index].id)}
|
||||
data-testid="reader-page"
|
||||
/>
|
||||
|
||||
@@ -811,11 +1216,22 @@
|
||||
{:else}
|
||||
<div class="continuous" style:gap="{gapPx}px" data-testid="reader-continuous">
|
||||
{#each pages as p, i (p.id)}
|
||||
<!-- Pages 0..=initialIndex (or at least 0..1) are eager
|
||||
so their real heights are settled before the cold-
|
||||
load scroll-to-`?page=N` effect fires. Without this,
|
||||
`scrollIntoView(target)` lands while prior pages are
|
||||
still 0×0 placeholders and the target gets pushed
|
||||
past the viewport as they load. -->
|
||||
<img
|
||||
src={fileUrl(p.storage_key)}
|
||||
alt={`${manga.title} chapter ${chapter.number} page ${i + 1}`}
|
||||
class="page-image"
|
||||
loading={i < 2 ? 'eager' : 'lazy'}
|
||||
loading={i <= Math.max(1, initialIndex) ? 'eager' : 'lazy'}
|
||||
oncontextmenu={(e) => onPageContextMenu(e, p.id)}
|
||||
onpointerdown={(e) => onPagePointerDown(e, p.id)}
|
||||
onpointermove={onPagePointerMove}
|
||||
onpointerup={onPagePointerUp}
|
||||
onpointercancel={onPagePointerUp}
|
||||
data-testid={`reader-page-${i + 1}`}
|
||||
bind:this={continuousPageEls[i]}
|
||||
/>
|
||||
@@ -868,13 +1284,56 @@
|
||||
DOM is one zero-opacity div when brightness is at max. -->
|
||||
<div class="brightness-overlay" aria-hidden="true"></div>
|
||||
|
||||
{#if linkCopiedState.kind === 'ok'}
|
||||
<div
|
||||
class="link-copied"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="reader-link-copied"
|
||||
>
|
||||
Link copied
|
||||
</div>
|
||||
{:else if linkCopiedState.kind === 'manual'}
|
||||
<div
|
||||
class="link-copied link-copied-manual"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="reader-link-copied-manual"
|
||||
>
|
||||
<span class="link-copied-label">
|
||||
Couldn't copy automatically — press ⌘C / long-press to copy:
|
||||
</span>
|
||||
<!--
|
||||
`readonly` keeps the underlying page state untouchable.
|
||||
`inputmode="none"` suppresses the iOS / Android soft
|
||||
keyboard that would otherwise pop up under a tap, since
|
||||
the user is here to copy the URL, not edit it. Focus
|
||||
auto-selects so the desktop user can immediately ⌘C.
|
||||
-->
|
||||
<input
|
||||
type="text"
|
||||
readonly
|
||||
inputmode="none"
|
||||
value={linkCopiedState.url}
|
||||
onfocus={(e) => e.currentTarget.select()}
|
||||
data-testid="reader-link-copied-manual-input"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Tap zones — mobile + single mode only. Continuous mode owns native
|
||||
scroll so left/right would steal panning. Tap left/right advances
|
||||
within the chapter (falling through to adjacent chapters at the
|
||||
boundaries via the existing prev/next helpers); tap center toggles
|
||||
the focus-mode chrome and restarts the idle timer. -->
|
||||
{#if isMobileViewport && mode === 'single' && pages.length > 0}
|
||||
<TapZone onPrev={prev} onNext={next} onToggle={toggleChrome} testid="reader-tap" />
|
||||
<TapZone
|
||||
onPrev={prev}
|
||||
onNext={next}
|
||||
onToggle={toggleChrome}
|
||||
onLongPress={(_anchor) => openActionSheet(pages[index].id)}
|
||||
testid="reader-tap"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Bottom scrubber — mobile + single + multi-page only. Lives above
|
||||
@@ -981,6 +1440,119 @@
|
||||
</div>
|
||||
</Sheet>
|
||||
|
||||
<!-- Page context menu (desktop right-click) and the modals/sheet it
|
||||
funnels into. Rendered for authenticated users only — there's
|
||||
nothing for a guest to act on. -->
|
||||
{#if session.user}
|
||||
<PageContextMenu
|
||||
open={contextMenuOpen}
|
||||
anchor={contextMenuAnchor}
|
||||
onClose={() => (contextMenuOpen = false)}
|
||||
onAddToCollection={handleAddToCollection}
|
||||
onAddTag={handleAddTag}
|
||||
onSaveImage={handleSaveImage}
|
||||
onCopyLink={handleCopyLink}
|
||||
collectionsCount={activePageCollectionCount}
|
||||
tags={activePageTags}
|
||||
/>
|
||||
|
||||
<!-- Mobile action sheet: same two actions, larger touch targets. -->
|
||||
<Sheet
|
||||
open={actionSheetOpen}
|
||||
title="Page actions"
|
||||
onClose={() => (actionSheetOpen = false)}
|
||||
testid="page-action-sheet"
|
||||
>
|
||||
<ul class="action-list">
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="action-row"
|
||||
onclick={handleAddToCollection}
|
||||
data-testid="page-action-add-to-collection"
|
||||
>
|
||||
<FolderPlus size={18} aria-hidden="true" />
|
||||
<span>Add to collection</span>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="action-row"
|
||||
onclick={handleAddTag}
|
||||
data-testid="page-action-add-tag"
|
||||
>
|
||||
<Tag size={18} aria-hidden="true" />
|
||||
<span>Add tag</span>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="action-row"
|
||||
onclick={handleSaveImage}
|
||||
data-testid="page-action-save-image"
|
||||
>
|
||||
<Download size={18} aria-hidden="true" />
|
||||
<span>Save image</span>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="action-row"
|
||||
onclick={handleCopyLink}
|
||||
data-testid="page-action-copy-link"
|
||||
>
|
||||
<Link2 size={18} aria-hidden="true" />
|
||||
<span>Copy page link</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="action-hint" data-testid="page-action-collections-line">
|
||||
{#if activePageCollectionCount == null}
|
||||
Loading…
|
||||
{:else if activePageCollectionCount === 0}
|
||||
Not in any collection
|
||||
{:else}
|
||||
In {activePageCollectionCount} collection{activePageCollectionCount === 1
|
||||
? ''
|
||||
: 's'}
|
||||
{/if}
|
||||
</p>
|
||||
<p class="action-hint" data-testid="page-action-tags-line">
|
||||
{activePageTags.length === 0
|
||||
? 'No tags yet'
|
||||
: `Tagged: ${activePageTags.join(', ')}`}
|
||||
</p>
|
||||
</Sheet>
|
||||
|
||||
{#if activePageId}
|
||||
<AddToCollectionModal
|
||||
open={collectionsModalOpen}
|
||||
target={{ kind: 'page', id: activePageId }}
|
||||
onClose={() => {
|
||||
collectionsModalOpen = false;
|
||||
if (activePageId) void loadPageSummary(activePageId);
|
||||
}}
|
||||
/>
|
||||
<Modal
|
||||
open={tagsModalOpen}
|
||||
title="Tag this page"
|
||||
onClose={() => (tagsModalOpen = false)}
|
||||
size="md"
|
||||
testid="add-tags-modal"
|
||||
>
|
||||
<AddTagsSheet
|
||||
pageId={activePageId}
|
||||
onChange={(tags) => {
|
||||
activePageTags = tags;
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Pinned to the viewport directly below the (also fixed) layout
|
||||
header. `position: fixed` rather than `sticky` because the
|
||||
@@ -1015,6 +1587,31 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.back-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.back-arrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 0;
|
||||
padding: var(--space-1);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.back-arrow:hover {
|
||||
color: var(--primary);
|
||||
background: var(--surface-elevated);
|
||||
}
|
||||
|
||||
.back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1178,6 +1775,17 @@
|
||||
height: auto;
|
||||
margin: 0 auto;
|
||||
display: block;
|
||||
/* Suppress the native mobile Safari long-press image callout
|
||||
("Save Image", "Copy") and the long-press text selection so
|
||||
the in-app long-press → action sheet path is the only
|
||||
outcome. Cost: users lose the OS-level Save Image affordance
|
||||
in the reader. Save image stays available via the in-app
|
||||
action sheet.
|
||||
Desktop right-click is handled separately by oncontextmenu
|
||||
— holding Shift bypasses our handler for the native menu. */
|
||||
-webkit-touch-callout: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.continuous .page-image {
|
||||
@@ -1447,6 +2055,89 @@
|
||||
font-weight: var(--weight-regular);
|
||||
}
|
||||
|
||||
.action-list {
|
||||
list-style: none;
|
||||
margin: 0 0 var(--space-3);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
padding: var(--space-3);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: var(--font-base);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.action-row:hover {
|
||||
background: var(--surface-elevated);
|
||||
}
|
||||
|
||||
.action-hint {
|
||||
margin: var(--space-1) 0 0;
|
||||
padding: 0 var(--space-3);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-xs);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.link-copied {
|
||||
position: fixed;
|
||||
top: calc(var(--app-header-h) + var(--space-3));
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: var(--z-modal);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-pill);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
font-size: var(--font-sm);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.link-copied-manual {
|
||||
/* Manual fallback needs to be interactive so the user can
|
||||
select / copy the URL — override the success pill's
|
||||
pointer-events: none. */
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
max-width: min(90vw, 32rem);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
}
|
||||
|
||||
.link-copied-label {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.link-copied-manual input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: inherit;
|
||||
font-size: var(--font-xs);
|
||||
background: var(--surface-elevated);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px var(--space-2);
|
||||
}
|
||||
|
||||
.settings-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
351
frontend/src/routes/search/+page.svelte
Normal file
351
frontend/src/routes/search/+page.svelte
Normal file
@@ -0,0 +1,351 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import SegmentedControl from '$lib/components/SegmentedControl.svelte';
|
||||
import TaggedPageRow from '$lib/components/TaggedPageRow.svelte';
|
||||
import TaggedChapterRow from '$lib/components/TaggedChapterRow.svelte';
|
||||
import TaggedMangaRow from '$lib/components/TaggedMangaRow.svelte';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
type View = 'pages' | 'chapters' | 'mangas';
|
||||
type Order = 'desc' | 'asc';
|
||||
|
||||
const VIEWS: { label: string; value: View }[] = [
|
||||
{ label: 'Pages', value: 'pages' },
|
||||
{ label: 'Chapters', value: 'chapters' },
|
||||
{ label: 'Mangas', value: 'mangas' }
|
||||
];
|
||||
|
||||
const ORDERS: { label: string; value: Order }[] = [
|
||||
{ label: 'Most pages', value: 'desc' },
|
||||
{ label: 'Fewest pages', value: 'asc' }
|
||||
];
|
||||
|
||||
/**
|
||||
* Update a single URL param and re-trigger the SvelteKit loader
|
||||
* via goto with replaceState — same pattern as /library tab
|
||||
* persistence. Passing `null` (or the param's default value)
|
||||
* removes the param so the URL stays clean.
|
||||
*/
|
||||
function setParam(key: string, value: string | null) {
|
||||
const url = new URL($page.url);
|
||||
if (value == null || value === '') url.searchParams.delete(key);
|
||||
else url.searchParams.set(key, value);
|
||||
void goto(url.toString(), {
|
||||
replaceState: true,
|
||||
keepFocus: true,
|
||||
noScroll: true
|
||||
});
|
||||
}
|
||||
|
||||
function setView(v: View) {
|
||||
setParam('view', v === 'pages' ? null : v);
|
||||
}
|
||||
function setOrder(o: Order) {
|
||||
setParam('order', o === 'desc' ? null : 'asc');
|
||||
}
|
||||
function setTag(t: string | null) {
|
||||
// Reset view + order when the tag changes, so a user picking
|
||||
// a fresh tag lands on the Pages tab with default sort.
|
||||
const url = new URL($page.url);
|
||||
url.searchParams.delete('view');
|
||||
url.searchParams.delete('order');
|
||||
if (t == null || t === '') url.searchParams.delete('tag');
|
||||
else url.searchParams.set('tag', t);
|
||||
void goto(url.toString(), {
|
||||
replaceState: true,
|
||||
keepFocus: true,
|
||||
noScroll: true
|
||||
});
|
||||
}
|
||||
|
||||
// Tag input draft for the autocomplete chip cloud / dropdown.
|
||||
let draft = $state('');
|
||||
const filteredCloud = $derived.by(() => {
|
||||
const q = draft.trim().toLowerCase();
|
||||
if (!q) return data.distinct;
|
||||
return data.distinct.filter((s) => s.tag.startsWith(q));
|
||||
});
|
||||
|
||||
function onSubmitTag(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
const q = draft.trim().toLowerCase();
|
||||
if (!q) return;
|
||||
// Pick the typed text directly — the backend normalizes and
|
||||
// returns an empty result if the tag doesn't exist for this
|
||||
// user, which the empty-state line handles cleanly.
|
||||
setTag(q);
|
||||
draft = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Mangalord | Page search</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1 class="heading">Page search</h1>
|
||||
|
||||
{#if !data.authenticated}
|
||||
<p class="hint" data-testid="search-signin">
|
||||
<a href="/login?next=/search">Sign in</a> to search your tags.
|
||||
</p>
|
||||
{:else if data.error}
|
||||
<p class="error" role="alert" data-testid="search-error">{data.error}</p>
|
||||
{:else}
|
||||
<!-- Tag filter. When a tag is selected it's shown as a chip with
|
||||
an x to clear; when none is, the input doubles as the entry
|
||||
point + autocomplete. -->
|
||||
<section class="filter" aria-label="Tag filter">
|
||||
{#if data.tag}
|
||||
<div class="active-tag" data-testid="search-active-tag">
|
||||
<span class="tag-pill">
|
||||
{data.tag}
|
||||
<button
|
||||
type="button"
|
||||
class="tag-clear"
|
||||
aria-label="Clear tag"
|
||||
onclick={() => setTag(null)}
|
||||
data-testid="search-clear-tag"
|
||||
>
|
||||
<X size={12} aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
<form class="tag-form" onsubmit={onSubmitTag} action="javascript:void(0)">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={draft}
|
||||
placeholder="Type a tag and press Enter"
|
||||
aria-label="Tag"
|
||||
data-testid="search-tag-input"
|
||||
/>
|
||||
</form>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if !data.tag}
|
||||
<!-- Empty state: chip cloud doubles as autocomplete result. -->
|
||||
{#if data.distinct.length === 0}
|
||||
<p class="hint" data-testid="search-no-tags">
|
||||
You haven't tagged any pages yet. Open a chapter and
|
||||
right-click (or long-press on mobile) a page to add a
|
||||
tag.
|
||||
</p>
|
||||
{:else if filteredCloud.length === 0}
|
||||
<p class="hint" data-testid="search-no-matches">
|
||||
No tags match "{draft}".
|
||||
</p>
|
||||
{:else}
|
||||
<p class="cloud-hint">Browse your tags</p>
|
||||
<div class="chip-cloud" data-testid="search-chip-cloud">
|
||||
{#each filteredCloud as s (s.tag)}
|
||||
<button
|
||||
type="button"
|
||||
class="chip"
|
||||
onclick={() => setTag(s.tag)}
|
||||
data-testid={`search-chip-${s.tag}`}
|
||||
>
|
||||
{s.tag}
|
||||
<span class="count">{s.count}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="empty-hint" data-testid="search-no-tag-selected">
|
||||
No tag selected. Pick one above to see matching pages,
|
||||
chapters, and mangas.
|
||||
</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Tag selected: tabs + sort + results. -->
|
||||
<div class="tab-row">
|
||||
<SegmentedControl
|
||||
ariaLabel="Result type"
|
||||
value={data.view}
|
||||
options={VIEWS}
|
||||
onchange={setView}
|
||||
testid="search-tabs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if data.view !== 'pages'}
|
||||
<div class="tab-row">
|
||||
<SegmentedControl
|
||||
ariaLabel="Sort"
|
||||
value={data.order}
|
||||
options={ORDERS}
|
||||
onchange={setOrder}
|
||||
testid="search-sort"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if data.view === 'pages'}
|
||||
{#if data.pages.length === 0}
|
||||
<p class="hint" data-testid="search-pages-empty">
|
||||
No pages tagged with "{data.tag}".
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="list" data-testid="search-pages-list">
|
||||
{#each data.pages as p (p.page_id)}
|
||||
<TaggedPageRow
|
||||
item={p}
|
||||
showTagPill={false}
|
||||
testid={`search-page-row-${p.page_id}`}
|
||||
/>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{:else if data.view === 'chapters'}
|
||||
{#if data.chapters.length === 0}
|
||||
<p class="hint" data-testid="search-chapters-empty">
|
||||
No chapters contain pages tagged with "{data.tag}".
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="list" data-testid="search-chapters-list">
|
||||
{#each data.chapters as c (c.chapter_id)}
|
||||
<TaggedChapterRow
|
||||
item={c}
|
||||
testid={`search-chapter-row-${c.chapter_id}`}
|
||||
/>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{:else}
|
||||
{#if data.mangas.length === 0}
|
||||
<p class="hint" data-testid="search-mangas-empty">
|
||||
No mangas contain pages tagged with "{data.tag}".
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="list" data-testid="search-mangas-list">
|
||||
{#each data.mangas as m (m.manga_id)}
|
||||
<TaggedMangaRow
|
||||
item={m}
|
||||
testid={`search-manga-row-${m.manga_id}`}
|
||||
/>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.heading {
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.hint,
|
||||
.empty-hint {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
margin-top: var(--space-4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cloud-hint {
|
||||
margin: var(--space-3) 0 var(--space-2);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.filter {
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.tag-form input {
|
||||
width: 100%;
|
||||
max-width: 24rem;
|
||||
}
|
||||
|
||||
.active-tag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tag-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
background: var(--primary-soft-bg);
|
||||
color: var(--primary);
|
||||
border-radius: var(--radius-pill);
|
||||
padding: 2px var(--space-2);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.tag-clear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.tag-clear:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.chip-cloud {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-pill);
|
||||
padding: 2px var(--space-2);
|
||||
font-size: var(--font-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chip:hover {
|
||||
background: var(--surface-elevated);
|
||||
}
|
||||
|
||||
.count {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
|
||||
.tab-row {
|
||||
margin-bottom: var(--space-3);
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.tab-row :global(.segmented) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tab-row :global(.seg) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
</style>
|
||||
92
frontend/src/routes/search/+page.ts
Normal file
92
frontend/src/routes/search/+page.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { ApiError } from '$lib/api/client';
|
||||
import {
|
||||
listMyDistinctPageTags,
|
||||
listMyPageTags,
|
||||
listTaggedChapters,
|
||||
listTaggedMangas,
|
||||
type PageTagSummary,
|
||||
type TaggedChapterAggregate,
|
||||
type TaggedMangaAggregate,
|
||||
type TaggedPageItem
|
||||
} from '$lib/api/page_tags';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const ssr = false;
|
||||
|
||||
type View = 'pages' | 'chapters' | 'mangas';
|
||||
type Order = 'desc' | 'asc';
|
||||
|
||||
/**
|
||||
* Loader for the /search page. URL is the source of truth — refresh
|
||||
* or share a link lands the user on the same view.
|
||||
*
|
||||
* - `?tag=` exact-match tag filter. Empty → no results, just the
|
||||
* chip cloud for browsing.
|
||||
* - `?view=pages|chapters|mangas` — defaults to `pages` when omitted.
|
||||
* - `?order=desc|asc` — only meaningful for chapters/mangas tabs.
|
||||
* `desc` (most matches first) is the default.
|
||||
*
|
||||
* `?text=` is reserved for the planned OCR text-search input. The
|
||||
* backend rejects it with 501 + stable code
|
||||
* `text_search_not_yet_supported` today; the frontend never sets it.
|
||||
*/
|
||||
export const load: PageLoad = async ({ url }) => {
|
||||
const tag = url.searchParams.get('tag');
|
||||
const viewParam = url.searchParams.get('view');
|
||||
const view: View =
|
||||
viewParam === 'chapters' || viewParam === 'mangas' ? viewParam : 'pages';
|
||||
const order: Order = url.searchParams.get('order') === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
const empty = {
|
||||
authenticated: true,
|
||||
tag,
|
||||
view,
|
||||
order,
|
||||
distinct: [] as PageTagSummary[],
|
||||
pages: [] as TaggedPageItem[],
|
||||
chapters: [] as TaggedChapterAggregate[],
|
||||
mangas: [] as TaggedMangaAggregate[],
|
||||
total: 0,
|
||||
error: null as string | null
|
||||
};
|
||||
|
||||
try {
|
||||
const distinct = await listMyDistinctPageTags(undefined, 100);
|
||||
// No tag selected → just the chip cloud.
|
||||
if (!tag) return { ...empty, distinct };
|
||||
|
||||
if (view === 'chapters') {
|
||||
const r = await listTaggedChapters({ tag, order, limit: 100 });
|
||||
return {
|
||||
...empty,
|
||||
distinct,
|
||||
chapters: r.items,
|
||||
total: r.page.total ?? 0
|
||||
};
|
||||
}
|
||||
if (view === 'mangas') {
|
||||
const r = await listTaggedMangas({ tag, order, limit: 100 });
|
||||
return {
|
||||
...empty,
|
||||
distinct,
|
||||
mangas: r.items,
|
||||
total: r.page.total ?? 0
|
||||
};
|
||||
}
|
||||
const r = await listMyPageTags({ tag, limit: 100 });
|
||||
return {
|
||||
...empty,
|
||||
distinct,
|
||||
pages: r.items,
|
||||
total: r.page.total ?? 0
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 401) {
|
||||
return { ...empty, authenticated: false };
|
||||
}
|
||||
if (e instanceof ApiError) {
|
||||
return { ...empty, error: e.message };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user