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:
77
frontend/src/lib/components/Pager.svelte.test.ts
Normal file
77
frontend/src/lib/components/Pager.svelte.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user