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>
129 lines
3.3 KiB
Svelte
129 lines
3.3 KiB
Svelte
<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>
|