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:
MechaCat02
2026-06-01 21:18:53 +02:00
parent e50fc093c3
commit 5c22dfdb41
22 changed files with 501 additions and 53 deletions

View File

@@ -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>

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