feat(search): tag-based page search surface + per-page tags & collections

Add the /search surface (Pages / Chapters / Mangas tabs) backed by
per-user page tags and per-page collections: schema (migration 0023),
backend endpoints for page tags/collections and tagged-page aggregations
(with the OCR text-search param reserved at 501), plus the frontend API
clients, library Page-tags tab, collection page sections, page context
menu / AddTagsSheet, and reader long-press wiring. Includes the
continuous-reader navigation fixes (?page=N handling, chapter-reset
timing, back-button pops history) and tag-normalization hardening
accumulated on the branch.

Bump version 0.60.2 -> 0.62.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-13 15:51:38 +02:00
parent 9910a0a995
commit 6c901e64c9
50 changed files with 6971 additions and 132 deletions

View File

@@ -0,0 +1,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$/);
});
});

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

View 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$/);
});
});

View 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)}`
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,36 +1,153 @@
<script lang="ts">
import { onMount } from 'svelte';
let {
onPrev,
onNext,
onToggle,
onLongPress,
testid = 'tap-zone'
}: {
onPrev: () => void;
onNext: () => void;
onToggle: () => void;
/**
* Fires when a touch lingers > 450ms on any zone without
* moving. The viewport-coord `anchor` lets the reader open a
* sheet anchored to the press location. Desktop pointers
* (`pointerType !== 'touch'`) never trigger this — right-click
* is the desktop entry point and lives in the reader.
*/
onLongPress?: (anchor: { x: number; y: number }) => void;
testid?: string;
} = $props();
const LONG_PRESS_MS = 450;
const MOVE_TOLERANCE = 8;
let pressTimer: ReturnType<typeof setTimeout> | null = null;
let pressStart: { x: number; y: number } | null = null;
// Self-expiring suppression: cleared either by an actual click
// reaching `withSuppress` or by the SUPPRESS_EXPIRY_MS fallback
// timer below. The latter matters when the user lifts off the
// tap zone after a long-press (finger slid onto the sheet) — no
// click is synthesized, so without an expiry the flag would leak
// and eat the next legitimate tap.
const SUPPRESS_EXPIRY_MS = 500;
let suppressNextClick = false;
let suppressExpiryTimer: ReturnType<typeof setTimeout> | null = null;
function clearPress() {
if (pressTimer != null) {
clearTimeout(pressTimer);
pressTimer = null;
}
pressStart = null;
}
function clearSuppress() {
suppressNextClick = false;
if (suppressExpiryTimer != null) {
clearTimeout(suppressExpiryTimer);
suppressExpiryTimer = null;
}
}
function onPointerDown(e: PointerEvent) {
if (!onLongPress) return;
if (e.pointerType !== 'touch') return;
clearPress();
pressStart = { x: e.clientX, y: e.clientY };
const startX = e.clientX;
const startY = e.clientY;
pressTimer = setTimeout(() => {
pressTimer = null;
suppressNextClick = true;
if (suppressExpiryTimer != null) clearTimeout(suppressExpiryTimer);
suppressExpiryTimer = setTimeout(() => {
suppressNextClick = false;
suppressExpiryTimer = null;
}, SUPPRESS_EXPIRY_MS);
onLongPress?.({ x: startX, y: startY });
}, LONG_PRESS_MS);
}
function onPointerMove(e: PointerEvent) {
if (pressTimer == null || pressStart == null) return;
const dx = e.clientX - pressStart.x;
const dy = e.clientY - pressStart.y;
if (Math.hypot(dx, dy) > MOVE_TOLERANCE) clearPress();
}
function onPointerUp() {
clearPress();
}
function onPointerCancel() {
clearPress();
}
function onScroll() {
// Scrolling while pressed almost always means the user is
// panning, not deliberately holding. Cancel.
clearPress();
}
function withSuppress(handler: () => void) {
return () => {
if (suppressNextClick) {
clearSuppress();
return;
}
handler();
};
}
// onMount only runs on the client, so the window-scroll listener
// is automatically guarded — no SSR check needed. The teardown
// also clears any in-flight long-press timer.
onMount(() => {
if (!onLongPress) return;
window.addEventListener('scroll', onScroll, true);
return () => {
window.removeEventListener('scroll', onScroll, true);
clearPress();
clearSuppress();
};
});
</script>
<div class="tap-zones" data-testid={testid}>
<button
type="button"
class="zone left"
onclick={onPrev}
onclick={withSuppress(onPrev)}
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerCancel}
aria-label="Previous page"
data-testid="{testid}-left"
></button>
<button
type="button"
class="zone center"
onclick={onToggle}
onclick={withSuppress(onToggle)}
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerCancel}
aria-label="Toggle controls"
data-testid="{testid}-center"
></button>
<button
type="button"
class="zone right"
onclick={onNext}
onclick={withSuppress(onNext)}
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerCancel}
aria-label="Next page"
data-testid="{testid}-right"
></button>

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,14 +6,16 @@
import SegmentedControl from '$lib/components/SegmentedControl.svelte';
import BookmarkList from '$lib/components/BookmarkList.svelte';
import CollectionsGrid from '$lib/components/CollectionsGrid.svelte';
import PageTagsList from '$lib/components/PageTagsList.svelte';
import BookImage from '@lucide/svelte/icons/book-image';
let { data } = $props();
type Tab = 'bookmarks' | 'collections' | 'history';
type Tab = 'bookmarks' | 'collections' | 'page-tags' | 'history';
const TABS: { label: string; value: Tab }[] = [
{ label: 'Bookmarks', value: 'bookmarks' },
{ label: 'Collections', value: 'collections' },
{ label: 'Page tags', value: 'page-tags' },
{ label: 'History', value: 'history' }
];
@@ -22,7 +24,8 @@
// default so visiting /library bare doesn't add noise to the URL.
const activeTab: Tab = $derived.by(() => {
const t = $page.url.searchParams.get('tab');
return t === 'collections' || t === 'history' ? t : 'bookmarks';
if (t === 'collections' || t === 'history' || t === 'page-tags') return t;
return 'bookmarks';
});
function setTab(t: Tab) {
@@ -34,6 +37,8 @@
const url = new URL($page.url);
if (t === 'bookmarks') url.searchParams.delete('tab');
else url.searchParams.set('tab', t);
// Cast away the literal union — the SegmentedControl is
// generic so its onchange receives `string`.
void goto(url.toString(), {
replaceState: true,
keepFocus: true,
@@ -81,6 +86,11 @@
{:else}
<CollectionsGrid collections={data.collections} />
{/if}
{:else if activeTab === 'page-tags'}
<PageTagsList
initialItems={data.pageTags}
initialDistinct={data.distinctPageTags}
/>
{:else if data.history.length === 0}
<p class="hint" data-testid="library-history-empty">
Nothing here yet — open any manga and a row will land here once you turn

View File

@@ -2,15 +2,19 @@ import { ApiError } from '$lib/api/client';
import { listMyBookmarks } from '$lib/api/bookmarks';
import { listMyCollections } from '$lib/api/collections';
import { listMyReadProgress } from '$lib/api/read_progress';
import {
listMyPageTags,
listMyDistinctPageTags
} from '$lib/api/page_tags';
import type { PageLoad } from './$types';
export const ssr = false;
/**
* Loads bookmarks + collections + reading-history in one shot so the
* Library segmented control can swap between sub-tabs without firing a
* second round trip per tap. 401 → unauthenticated path; the page
* surfaces a sign-in prompt and renders empty lists.
* Loads bookmarks + collections + history + page-tags in one shot so
* the Library segmented control can swap between sub-tabs without
* firing a second round trip per tap. 401 → unauthenticated path; the
* page surfaces a sign-in prompt and renders empty lists.
*/
export const load: PageLoad = async () => {
const empty = {
@@ -18,19 +22,26 @@ export const load: PageLoad = async () => {
bookmarks: [] as Awaited<ReturnType<typeof listMyBookmarks>>['items'],
collections: [] as Awaited<ReturnType<typeof listMyCollections>>['items'],
history: [] as Awaited<ReturnType<typeof listMyReadProgress>>['items'],
pageTags: [] as Awaited<ReturnType<typeof listMyPageTags>>['items'],
distinctPageTags: [] as Awaited<ReturnType<typeof listMyDistinctPageTags>>,
error: null as string | null
};
try {
const [bookmarks, collections, history] = await Promise.all([
listMyBookmarks(),
listMyCollections({ limit: 200 }),
listMyReadProgress({ limit: 100 })
]);
const [bookmarks, collections, history, pageTags, distinctPageTags] =
await Promise.all([
listMyBookmarks(),
listMyCollections({ limit: 200 }),
listMyReadProgress({ limit: 100 }),
listMyPageTags({ limit: 100 }),
listMyDistinctPageTags(undefined, 100)
]);
return {
...empty,
bookmarks: bookmarks.items,
collections: collections.items,
history: history.items
history: history.items,
pageTags: pageTags.items,
distinctPageTags
};
} catch (e) {
if (e instanceof ApiError && e.status === 401) {

View File

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

View File

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

View File

@@ -0,0 +1,351 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import SegmentedControl from '$lib/components/SegmentedControl.svelte';
import TaggedPageRow from '$lib/components/TaggedPageRow.svelte';
import TaggedChapterRow from '$lib/components/TaggedChapterRow.svelte';
import TaggedMangaRow from '$lib/components/TaggedMangaRow.svelte';
import X from '@lucide/svelte/icons/x';
let { data } = $props();
type View = 'pages' | 'chapters' | 'mangas';
type Order = 'desc' | 'asc';
const VIEWS: { label: string; value: View }[] = [
{ label: 'Pages', value: 'pages' },
{ label: 'Chapters', value: 'chapters' },
{ label: 'Mangas', value: 'mangas' }
];
const ORDERS: { label: string; value: Order }[] = [
{ label: 'Most pages', value: 'desc' },
{ label: 'Fewest pages', value: 'asc' }
];
/**
* Update a single URL param and re-trigger the SvelteKit loader
* via goto with replaceState — same pattern as /library tab
* persistence. Passing `null` (or the param's default value)
* removes the param so the URL stays clean.
*/
function setParam(key: string, value: string | null) {
const url = new URL($page.url);
if (value == null || value === '') url.searchParams.delete(key);
else url.searchParams.set(key, value);
void goto(url.toString(), {
replaceState: true,
keepFocus: true,
noScroll: true
});
}
function setView(v: View) {
setParam('view', v === 'pages' ? null : v);
}
function setOrder(o: Order) {
setParam('order', o === 'desc' ? null : 'asc');
}
function setTag(t: string | null) {
// Reset view + order when the tag changes, so a user picking
// a fresh tag lands on the Pages tab with default sort.
const url = new URL($page.url);
url.searchParams.delete('view');
url.searchParams.delete('order');
if (t == null || t === '') url.searchParams.delete('tag');
else url.searchParams.set('tag', t);
void goto(url.toString(), {
replaceState: true,
keepFocus: true,
noScroll: true
});
}
// Tag input draft for the autocomplete chip cloud / dropdown.
let draft = $state('');
const filteredCloud = $derived.by(() => {
const q = draft.trim().toLowerCase();
if (!q) return data.distinct;
return data.distinct.filter((s) => s.tag.startsWith(q));
});
function onSubmitTag(e: SubmitEvent) {
e.preventDefault();
const q = draft.trim().toLowerCase();
if (!q) return;
// Pick the typed text directly — the backend normalizes and
// returns an empty result if the tag doesn't exist for this
// user, which the empty-state line handles cleanly.
setTag(q);
draft = '';
}
</script>
<svelte:head>
<title>Mangalord | Page search</title>
</svelte:head>
<h1 class="heading">Page search</h1>
{#if !data.authenticated}
<p class="hint" data-testid="search-signin">
<a href="/login?next=/search">Sign in</a> to search your tags.
</p>
{:else if data.error}
<p class="error" role="alert" data-testid="search-error">{data.error}</p>
{:else}
<!-- Tag filter. When a tag is selected it's shown as a chip with
an x to clear; when none is, the input doubles as the entry
point + autocomplete. -->
<section class="filter" aria-label="Tag filter">
{#if data.tag}
<div class="active-tag" data-testid="search-active-tag">
<span class="tag-pill">
{data.tag}
<button
type="button"
class="tag-clear"
aria-label="Clear tag"
onclick={() => setTag(null)}
data-testid="search-clear-tag"
>
<X size={12} aria-hidden="true" />
</button>
</span>
</div>
{:else}
<form class="tag-form" onsubmit={onSubmitTag} action="javascript:void(0)">
<input
type="text"
bind:value={draft}
placeholder="Type a tag and press Enter"
aria-label="Tag"
data-testid="search-tag-input"
/>
</form>
{/if}
</section>
{#if !data.tag}
<!-- Empty state: chip cloud doubles as autocomplete result. -->
{#if data.distinct.length === 0}
<p class="hint" data-testid="search-no-tags">
You haven't tagged any pages yet. Open a chapter and
right-click (or long-press on mobile) a page to add a
tag.
</p>
{:else if filteredCloud.length === 0}
<p class="hint" data-testid="search-no-matches">
No tags match "{draft}".
</p>
{:else}
<p class="cloud-hint">Browse your tags</p>
<div class="chip-cloud" data-testid="search-chip-cloud">
{#each filteredCloud as s (s.tag)}
<button
type="button"
class="chip"
onclick={() => setTag(s.tag)}
data-testid={`search-chip-${s.tag}`}
>
{s.tag}
<span class="count">{s.count}</span>
</button>
{/each}
</div>
<p class="empty-hint" data-testid="search-no-tag-selected">
No tag selected. Pick one above to see matching pages,
chapters, and mangas.
</p>
{/if}
{:else}
<!-- Tag selected: tabs + sort + results. -->
<div class="tab-row">
<SegmentedControl
ariaLabel="Result type"
value={data.view}
options={VIEWS}
onchange={setView}
testid="search-tabs"
/>
</div>
{#if data.view !== 'pages'}
<div class="tab-row">
<SegmentedControl
ariaLabel="Sort"
value={data.order}
options={ORDERS}
onchange={setOrder}
testid="search-sort"
/>
</div>
{/if}
{#if data.view === 'pages'}
{#if data.pages.length === 0}
<p class="hint" data-testid="search-pages-empty">
No pages tagged with "{data.tag}".
</p>
{:else}
<ul class="list" data-testid="search-pages-list">
{#each data.pages as p (p.page_id)}
<TaggedPageRow
item={p}
showTagPill={false}
testid={`search-page-row-${p.page_id}`}
/>
{/each}
</ul>
{/if}
{:else if data.view === 'chapters'}
{#if data.chapters.length === 0}
<p class="hint" data-testid="search-chapters-empty">
No chapters contain pages tagged with "{data.tag}".
</p>
{:else}
<ul class="list" data-testid="search-chapters-list">
{#each data.chapters as c (c.chapter_id)}
<TaggedChapterRow
item={c}
testid={`search-chapter-row-${c.chapter_id}`}
/>
{/each}
</ul>
{/if}
{:else}
{#if data.mangas.length === 0}
<p class="hint" data-testid="search-mangas-empty">
No mangas contain pages tagged with "{data.tag}".
</p>
{:else}
<ul class="list" data-testid="search-mangas-list">
{#each data.mangas as m (m.manga_id)}
<TaggedMangaRow
item={m}
testid={`search-manga-row-${m.manga_id}`}
/>
{/each}
</ul>
{/if}
{/if}
{/if}
{/if}
<style>
.heading {
margin-bottom: var(--space-3);
}
.hint,
.empty-hint {
color: var(--text-muted);
}
.empty-hint {
margin-top: var(--space-4);
text-align: center;
}
.cloud-hint {
margin: var(--space-3) 0 var(--space-2);
color: var(--text-muted);
font-size: var(--font-sm);
}
.error {
color: var(--danger);
}
.filter {
margin-bottom: var(--space-3);
}
.tag-form input {
width: 100%;
max-width: 24rem;
}
.active-tag {
display: flex;
align-items: center;
}
.tag-pill {
display: inline-flex;
align-items: center;
gap: var(--space-1);
background: var(--primary-soft-bg);
color: var(--primary);
border-radius: var(--radius-pill);
padding: 2px var(--space-2);
font-size: var(--font-sm);
}
.tag-clear {
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
color: inherit;
border: 0;
padding: 0;
cursor: pointer;
line-height: 0;
}
.tag-clear:hover {
color: var(--text);
}
.chip-cloud {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
margin-bottom: var(--space-3);
}
.chip {
display: inline-flex;
align-items: center;
gap: var(--space-1);
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius-pill);
padding: 2px var(--space-2);
font-size: var(--font-sm);
cursor: pointer;
}
.chip:hover {
background: var(--surface-elevated);
}
.count {
color: var(--text-muted);
font-size: var(--font-xs);
}
.tab-row {
margin-bottom: var(--space-3);
display: flex;
}
.tab-row :global(.segmented) {
width: 100%;
}
.tab-row :global(.seg) {
flex: 1;
}
.list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
</style>

View File

@@ -0,0 +1,92 @@
import { ApiError } from '$lib/api/client';
import {
listMyDistinctPageTags,
listMyPageTags,
listTaggedChapters,
listTaggedMangas,
type PageTagSummary,
type TaggedChapterAggregate,
type TaggedMangaAggregate,
type TaggedPageItem
} from '$lib/api/page_tags';
import type { PageLoad } from './$types';
export const ssr = false;
type View = 'pages' | 'chapters' | 'mangas';
type Order = 'desc' | 'asc';
/**
* Loader for the /search page. URL is the source of truth — refresh
* or share a link lands the user on the same view.
*
* - `?tag=` exact-match tag filter. Empty → no results, just the
* chip cloud for browsing.
* - `?view=pages|chapters|mangas` — defaults to `pages` when omitted.
* - `?order=desc|asc` — only meaningful for chapters/mangas tabs.
* `desc` (most matches first) is the default.
*
* `?text=` is reserved for the planned OCR text-search input. The
* backend rejects it with 501 + stable code
* `text_search_not_yet_supported` today; the frontend never sets it.
*/
export const load: PageLoad = async ({ url }) => {
const tag = url.searchParams.get('tag');
const viewParam = url.searchParams.get('view');
const view: View =
viewParam === 'chapters' || viewParam === 'mangas' ? viewParam : 'pages';
const order: Order = url.searchParams.get('order') === 'asc' ? 'asc' : 'desc';
const empty = {
authenticated: true,
tag,
view,
order,
distinct: [] as PageTagSummary[],
pages: [] as TaggedPageItem[],
chapters: [] as TaggedChapterAggregate[],
mangas: [] as TaggedMangaAggregate[],
total: 0,
error: null as string | null
};
try {
const distinct = await listMyDistinctPageTags(undefined, 100);
// No tag selected → just the chip cloud.
if (!tag) return { ...empty, distinct };
if (view === 'chapters') {
const r = await listTaggedChapters({ tag, order, limit: 100 });
return {
...empty,
distinct,
chapters: r.items,
total: r.page.total ?? 0
};
}
if (view === 'mangas') {
const r = await listTaggedMangas({ tag, order, limit: 100 });
return {
...empty,
distinct,
mangas: r.items,
total: r.page.total ?? 0
};
}
const r = await listMyPageTags({ tag, limit: 100 });
return {
...empty,
distinct,
pages: r.items,
total: r.page.total ?? 0
};
} catch (e) {
if (e instanceof ApiError && e.status === 401) {
return { ...empty, authenticated: false };
}
if (e instanceof ApiError) {
return { ...empty, error: e.message };
}
throw e;
}
};