feat: paginate list views, fix stale page titles, tidy admin filter bar
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 `<svelte:head><title>` 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>
This commit is contained in:
@@ -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');
|
||||
});
|
||||
|
||||
67
frontend/e2e/page-title.spec.ts
Normal file
67
frontend/e2e/page-title.spec.ts
Normal file
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user