From 5c22dfdb41c8e9994743dcc1586089f6047c3f75 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Mon, 1 Jun 2026 21:18:53 +0200 Subject: [PATCH] feat: paginate list views, fix stale page titles, tidy admin filter bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundle of small UI/UX fixes plus a build hygiene tweak. * List pagination — Home (`/`) and `/authors/[id]` silently capped at the backend default of 50 with no UI to advance. New reusable `Pager.svelte` (Prev/Next + numbered with ellipsis), URL-synced `?page=N`, and filter/search/sort reset to page 1 so users aren't stranded on an out-of-range page. Count label now shows a range ("Showing 51–100 of 237"). * Stale page title — Pages without a `` left the document title at whatever the last manga / author / collection page set it to. Move static-route titles into a route-id → title map in the root layout and invert every dynamic title to brand-first (`Mangalord | {X}`) for consistency. * Admin filter bar — `/admin/mangas` search input had `flex: 1` and ballooned across the row, shoving the sync-state select + Search button to the far right. Cap at 24rem, vertical-align the row, and promote the previously aria-only "Sync state" label to visible text. * Build hygiene — `backend/target` had grown to 68 GiB. Cleaned and added `[profile.dev] debug = "line-tables-only"` (and `[profile.test]` too) to cut future dev builds by ~50–70% while keeping line numbers in backtraces. Also: configure vitest to resolve Svelte's browser entry so `@testing-library/svelte` can mount components in jsdom — needed for the new `Pager.svelte.test.ts`. Bump 0.48.0 -> 0.49.1. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- backend/Cargo.lock | 2 +- backend/Cargo.toml | 12 +- frontend/e2e/manga-list.spec.ts | 59 ++++++++ frontend/e2e/page-title.spec.ts | 67 +++++++++ frontend/package.json | 2 +- frontend/src/lib/components/Pager.svelte | 128 ++++++++++++++++++ .../src/lib/components/Pager.svelte.test.ts | 77 +++++++++++ frontend/src/routes/+layout.svelte | 31 +++++ frontend/src/routes/+page.svelte | 66 ++++++--- frontend/src/routes/admin/mangas/+page.svelte | 28 +++- frontend/src/routes/authors/[id]/+page.svelte | 28 +++- frontend/src/routes/authors/[id]/+page.ts | 20 ++- frontend/src/routes/bookmarks/+page.svelte | 4 - frontend/src/routes/collections/+page.svelte | 4 - .../src/routes/collections/[id]/+page.svelte | 2 +- frontend/src/routes/manga/[id]/+page.svelte | 2 +- .../[id]/chapter/[chapter_id]/+page.svelte | 4 +- .../src/routes/manga/[id]/edit/+page.svelte | 2 +- .../manga/[id]/upload-chapter/+page.svelte | 2 +- frontend/src/routes/profile/+layout.svelte | 4 - frontend/src/routes/upload/+page.svelte | 4 - frontend/vite.config.ts | 6 + 22 files changed, 501 insertions(+), 53 deletions(-) create mode 100644 frontend/e2e/page-title.spec.ts create mode 100644 frontend/src/lib/components/Pager.svelte create mode 100644 frontend/src/lib/components/Pager.svelte.test.ts diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 7b44851..98a18c1 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.48.0" +version = "0.49.1" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 323f95c..028582e 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.48.0" +version = "0.49.1" edition = "2021" default-run = "mangalord" @@ -57,3 +57,13 @@ http-body-util = "0.1" mime = "0.3" futures-util = "0.3" tokio = { version = "1", features = ["test-util"] } + +# Trim debug builds: keep line numbers in panics / backtraces but drop the +# full DWARF info (variable-level inspection in gdb/lldb). With a sqlx + +# axum + tokio dep tree the default ("full") leaves backend/target on the +# order of tens of GiB; this typically cuts ~50–70% off that. +[profile.dev] +debug = "line-tables-only" + +[profile.test] +debug = "line-tables-only" diff --git a/frontend/e2e/manga-list.spec.ts b/frontend/e2e/manga-list.spec.ts index 19e00e5..04bd298 100644 --- a/frontend/e2e/manga-list.spec.ts +++ b/frontend/e2e/manga-list.spec.ts @@ -10,6 +10,15 @@ import { test, expect, type Page } from '@playwright/test'; const emptyPage = { items: [], page: { limit: 50, offset: 0, total: null } }; async function mockAnonymous(page: Page) { + // Force public mode so the root +layout.ts doesn't bounce us to /login + // (a dev backend with PRIVATE_MODE=true must not leak into E2E runs). + await page.route('**/api/v1/auth/config', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ self_register_enabled: true, private_mode: false }) + }); + }); await page.route('**/api/v1/auth/me', async (route) => { await route.fulfill({ status: 401, @@ -69,3 +78,53 @@ test('search updates the manga list', async ({ page }) => { await expect(page.getByTestId('manga-list')).toContainText('Berserk'); expect(lastSearch).toBe('berserk'); }); + +test('clicking Next paginates to page 2 and updates the URL', async ({ page }) => { + await mockAnonymous(page); + + // Fake a catalogue of 75 mangas; page 1 is ids 1..50, page 2 is ids 51..75. + const TOTAL = 75; + function mangaAt(i: number) { + return { + id: `m${i}`, + title: `Manga ${i}`, + author: 'Test', + description: null, + cover_image_path: null, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + authors: [], + genres: [] + }; + } + + await page.route('**/api/v1/mangas*', async (route) => { + const url = new URL(route.request().url()); + const limit = Number(url.searchParams.get('limit') ?? '50'); + const offset = Number(url.searchParams.get('offset') ?? '0'); + const items: ReturnType<typeof mangaAt>[] = []; + for (let i = offset + 1; i <= Math.min(offset + limit, TOTAL); i++) { + items.push(mangaAt(i)); + } + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + items, + page: { limit, offset, total: TOTAL } + }) + }); + }); + + await page.goto('/'); + await expect(page.getByTestId('manga-total')).toContainText('Showing 1–50 of 75'); + await expect(page.getByTestId('manga-list')).toContainText('Manga 1'); + await expect(page.getByTestId('manga-list')).not.toContainText('Manga 75'); + + await page.getByTestId('manga-pager').getByRole('button', { name: /next/i }).click(); + + await expect(page).toHaveURL(/[?&]page=2(&|$)/); + await expect(page.getByTestId('manga-total')).toContainText('Showing 51–75 of 75'); + await expect(page.getByTestId('manga-list')).toContainText('Manga 75'); + await expect(page.getByTestId('manga-list')).not.toContainText('Manga 1'); +}); diff --git a/frontend/e2e/page-title.spec.ts b/frontend/e2e/page-title.spec.ts new file mode 100644 index 0000000..ccf16c2 --- /dev/null +++ b/frontend/e2e/page-title.spec.ts @@ -0,0 +1,67 @@ +import { test, expect, type Page } from '@playwright/test'; + +// Guards the title-on-nav behavior: without this, a stale title from +// the last manga / author page lingers when the user navigates to a +// generic page like /upload. + +async function mockAnonymous(page: Page) { + await page.route('**/api/v1/auth/config', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ self_register_enabled: true, private_mode: false }) + }); + }); + await page.route('**/api/v1/auth/me', async (route) => { + await route.fulfill({ + status: 401, + contentType: 'application/json', + body: JSON.stringify({ error: { code: 'unauthenticated', message: 'unauthenticated' } }) + }); + }); + await page.route('**/api/v1/mangas*', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } }) + }); + }); +} + +test('static route titles use the brand-first layout map', async ({ page }) => { + await mockAnonymous(page); + + await page.goto('/'); + await expect(page).toHaveTitle('Mangalord'); + + await page.goto('/upload'); + await expect(page).toHaveTitle('Mangalord | Upload'); + + await page.goto('/login'); + await expect(page).toHaveTitle('Mangalord | Login'); + + await page.goto('/bookmarks'); + await expect(page).toHaveTitle('Mangalord | Bookmarks'); + + await page.goto('/collections'); + await expect(page).toHaveTitle('Mangalord | Collections'); +}); + +test('title updates when navigating away from a content page', async ({ page }) => { + await mockAnonymous(page); + + // Pretend we just left a manga detail page — the document title + // would have been overridden to "Mangalord | Berserk". Use evaluate + // to set it synthetically so we can assert the regression cleanly + // even though the dynamic page itself isn't mocked here. + await page.goto('/'); + await page.evaluate(() => { + document.title = 'Mangalord | Berserk'; + }); + expect(await page.title()).toBe('Mangalord | Berserk'); + + // Client-side nav to /upload — the root layout must reassert its + // mapped title or the stale "Berserk" lingers. + await page.goto('/upload'); + await expect(page).toHaveTitle('Mangalord | Upload'); +}); diff --git a/frontend/package.json b/frontend/package.json index 7aa908a..679b33e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.48.0", + "version": "0.49.1", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/components/Pager.svelte b/frontend/src/lib/components/Pager.svelte new file mode 100644 index 0000000..f3b5dfb --- /dev/null +++ b/frontend/src/lib/components/Pager.svelte @@ -0,0 +1,128 @@ +<script lang="ts"> + type Props = { + page: number; + totalPages: number; + onChange: (page: number) => void; + testid?: string; + }; + + let { page, totalPages, onChange, testid }: Props = $props(); + + type Slot = number | 'ellipsis'; + + // Compact layout: always show first + last, surround the current page with + // its direct neighbours, and use "…" to elide the rest. Keeps the bar to + // at most 7 buttons regardless of totalPages. + function buildSlots(p: number, total: number): Slot[] { + if (total <= 7) { + return Array.from({ length: total }, (_, i) => i + 1); + } + const out: Slot[] = [1]; + if (p <= 4) { + for (let i = 2; i <= 5; i++) out.push(i); + out.push('ellipsis'); + out.push(total); + } else if (p >= total - 3) { + out.push('ellipsis'); + for (let i = total - 4; i <= total; i++) out.push(i); + } else { + out.push('ellipsis'); + out.push(p - 1); + out.push(p); + out.push(p + 1); + out.push('ellipsis'); + out.push(total); + } + return out; + } + + const slots = $derived(buildSlots(page, totalPages)); +</script> + +{#if totalPages > 1} + <nav class="pager" aria-label="Pagination" data-testid={testid}> + <button + type="button" + class="step" + disabled={page <= 1} + onclick={() => onChange(page - 1)} + aria-label="Previous page" + > + ‹ Prev + </button> + + {#each slots as slot, i (i)} + {#if slot === 'ellipsis'} + <span class="ellipsis" aria-hidden="true">…</span> + {:else} + <button + type="button" + class="num" + class:active={slot === page} + aria-current={slot === page ? 'page' : undefined} + aria-label={`Go to page ${slot}`} + onclick={() => onChange(slot)} + > + {slot} + </button> + {/if} + {/each} + + <button + type="button" + class="step" + disabled={page >= totalPages} + onclick={() => onChange(page + 1)} + aria-label="Next page" + > + Next › + </button> + </nav> +{/if} + +<style> + .pager { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-1); + margin: var(--space-4) 0; + justify-content: center; + } + + .step, + .num { + min-width: 36px; + height: 36px; + padding: 0 var(--space-2); + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-md); + color: var(--text); + cursor: pointer; + font-size: var(--font-sm); + } + + .step:hover:not(:disabled), + .num:hover:not(.active) { + border-color: var(--primary); + } + + .step:disabled { + opacity: 0.4; + cursor: not-allowed; + } + + .num.active { + background: var(--primary); + color: var(--primary-contrast); + border-color: var(--primary); + cursor: default; + } + + .ellipsis { + padding: 0 var(--space-1); + color: var(--text-muted); + user-select: none; + } +</style> diff --git a/frontend/src/lib/components/Pager.svelte.test.ts b/frontend/src/lib/components/Pager.svelte.test.ts new file mode 100644 index 0000000..a587349 --- /dev/null +++ b/frontend/src/lib/components/Pager.svelte.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/svelte'; +import Pager from './Pager.svelte'; + +afterEach(() => cleanup()); + +describe('Pager', () => { + it('renders nothing when totalPages <= 1', () => { + const { container } = render(Pager, { props: { page: 1, totalPages: 1, onChange: () => {} } }); + expect(container.querySelector('nav')).toBeNull(); + }); + + it('disables Prev on the first page and Next on the last', () => { + const { rerender } = render(Pager, { + props: { page: 1, totalPages: 5, onChange: () => {} } + }); + expect((screen.getByRole('button', { name: /prev/i }) as HTMLButtonElement).disabled).toBe(true); + expect((screen.getByRole('button', { name: /next/i }) as HTMLButtonElement).disabled).toBe(false); + + rerender({ page: 5, totalPages: 5, onChange: () => {} }); + expect((screen.getByRole('button', { name: /prev/i }) as HTMLButtonElement).disabled).toBe(false); + expect((screen.getByRole('button', { name: /next/i }) as HTMLButtonElement).disabled).toBe(true); + }); + + it('marks the current page button as aria-current', () => { + render(Pager, { props: { page: 3, totalPages: 5, onChange: () => {} } }); + const current = screen.getByRole('button', { name: /go to page 3/i }); + expect(current.getAttribute('aria-current')).toBe('page'); + }); + + it('fires onChange with the clicked page number', async () => { + const onChange = vi.fn(); + render(Pager, { props: { page: 1, totalPages: 5, onChange } }); + screen.getByRole('button', { name: /go to page 3/i }).click(); + expect(onChange).toHaveBeenCalledWith(3); + }); + + it('Prev decrements and Next increments via onChange', () => { + const onChange = vi.fn(); + render(Pager, { props: { page: 3, totalPages: 5, onChange } }); + screen.getByRole('button', { name: /prev/i }).click(); + screen.getByRole('button', { name: /next/i }).click(); + expect(onChange).toHaveBeenNthCalledWith(1, 2); + expect(onChange).toHaveBeenNthCalledWith(2, 4); + }); + + it('shows every page button when totalPages <= 7', () => { + render(Pager, { props: { page: 4, totalPages: 7, onChange: () => {} } }); + for (let n = 1; n <= 7; n++) { + expect(screen.getByRole('button', { name: new RegExp(`go to page ${n}$`, 'i') })).toBeTruthy(); + } + }); + + it('collapses middle pages with ellipsis when totalPages > 7 and current is in the middle', () => { + render(Pager, { props: { page: 10, totalPages: 24, onChange: () => {} } }); + // First and last are always shown + expect(screen.getByRole('button', { name: /go to page 1$/i })).toBeTruthy(); + expect(screen.getByRole('button', { name: /go to page 24$/i })).toBeTruthy(); + // Current and direct neighbours are shown + expect(screen.getByRole('button', { name: /go to page 9$/i })).toBeTruthy(); + expect(screen.getByRole('button', { name: /go to page 10$/i })).toBeTruthy(); + expect(screen.getByRole('button', { name: /go to page 11$/i })).toBeTruthy(); + // Distant pages are NOT rendered as buttons + expect(screen.queryByRole('button', { name: /go to page 2$/i })).toBeNull(); + expect(screen.queryByRole('button', { name: /go to page 23$/i })).toBeNull(); + // Ellipsis appears on both sides + const ellipses = screen.getAllByText('…'); + expect(ellipses.length).toBeGreaterThanOrEqual(2); + }); + + it('does not duplicate boundary buttons when current is near the edge', () => { + render(Pager, { props: { page: 2, totalPages: 20, onChange: () => {} } }); + // Each page button rendered should be unique — no duplicate "go to page 1" + const first = screen.getAllByRole('button', { name: /go to page 1$/i }); + expect(first.length).toBe(1); + }); +}); diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index c934a44..1f530af 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -1,6 +1,7 @@ <script lang="ts"> import { onMount, onDestroy } from 'svelte'; import { goto } from '$app/navigation'; + import { page } from '$app/stores'; import { logout } from '$lib/api/auth'; import { authConfig } from '$lib/auth-config.svelte'; import { preferences } from '$lib/preferences.svelte'; @@ -18,6 +19,32 @@ let loggingOut = $state(false); let headerEl: HTMLElement | undefined = $state(); + // Static-route title map. Dynamic pages (manga / author / collection / + // chapter) override this via their own <svelte:head><title>, since the + // title depends on data the layout doesn't have. Routes omitted here + // (notably the dynamic ones) fall through to the bare brand and rely + // on the page to set the descriptive form. + const STATIC_TITLES: Record<string, string> = { + '/': 'Mangalord', + '/login': 'Mangalord | Login', + '/register': 'Mangalord | Register', + '/upload': 'Mangalord | Upload', + '/bookmarks': 'Mangalord | Bookmarks', + '/collections': 'Mangalord | Collections', + '/profile': 'Mangalord | Profile', + '/profile/account': 'Mangalord | Account', + '/profile/bookmarks': 'Mangalord | Bookmarks', + '/profile/collections': 'Mangalord | Collections', + '/profile/history': 'Mangalord | Reading history', + '/profile/preferences': 'Mangalord | Preferences', + '/admin': 'Mangalord | Admin', + '/admin/mangas': 'Mangalord | Admin · Mangas', + '/admin/users': 'Mangalord | Admin · Users', + '/admin/system': 'Mangalord | Admin · System' + }; + + const layoutTitle = $derived(STATIC_TITLES[$page.route?.id ?? ''] ?? 'Mangalord'); + // Seed authConfig from the universal layout load. $effect keeps // the store in sync if `data` is replaced by a subsequent layout // load (client-side nav). The first run also covers initial @@ -78,6 +105,10 @@ } </script> +<svelte:head> + <title>{layoutTitle} + +