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:
@@ -13,10 +13,13 @@
|
||||
import { listTags, type Tag } from '$lib/api/tags';
|
||||
import Chip from '$lib/components/Chip.svelte';
|
||||
import MangaCard from '$lib/components/MangaCard.svelte';
|
||||
import Pager from '$lib/components/Pager.svelte';
|
||||
import Search from '@lucide/svelte/icons/search';
|
||||
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
let mangas: MangaCardData[] = $state([]);
|
||||
let search = $state('');
|
||||
let sort: MangaSort = $state('recent');
|
||||
@@ -36,11 +39,21 @@
|
||||
let total: number | null = $state(null);
|
||||
let loading = $state(true);
|
||||
let error: string | null = $state(null);
|
||||
let currentPage = $state(1);
|
||||
|
||||
const activeFilterCount = $derived(
|
||||
(statusFilter ? 1 : 0) + selectedGenres.length + selectedTags.length
|
||||
);
|
||||
|
||||
const totalPages = $derived(
|
||||
total != null && total > 0 ? Math.ceil(total / PAGE_SIZE) : 1
|
||||
);
|
||||
|
||||
// 1-indexed range like "51–100 of 237", clamped to the actual loaded set
|
||||
// in case the last page is short.
|
||||
const rangeStart = $derived(mangas.length === 0 ? 0 : (currentPage - 1) * PAGE_SIZE + 1);
|
||||
const rangeEnd = $derived((currentPage - 1) * PAGE_SIZE + mangas.length);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
@@ -50,7 +63,9 @@
|
||||
status: statusFilter || undefined,
|
||||
genreIds: selectedGenres.map((g) => g.id),
|
||||
tagIds: selectedTags.map((t) => t.id),
|
||||
sort
|
||||
sort,
|
||||
limit: PAGE_SIZE,
|
||||
offset: (currentPage - 1) * PAGE_SIZE
|
||||
});
|
||||
mangas = result.items;
|
||||
total = result.page.total;
|
||||
@@ -71,11 +86,29 @@
|
||||
params.set('genres', selectedGenres.map((g) => g.id).join(','));
|
||||
if (selectedTags.length)
|
||||
params.set('tags', selectedTags.map((t) => t.id).join(','));
|
||||
if (currentPage > 1) params.set('page', String(currentPage));
|
||||
const qs = params.toString();
|
||||
const url = qs ? `/?${qs}` : '/';
|
||||
goto(url, { replaceState: true, keepFocus: true, noScroll: true });
|
||||
}
|
||||
|
||||
// Filter / search / sort changes invalidate the current page — drop back
|
||||
// to page 1 so the user isn't stranded on an out-of-range page when the
|
||||
// result set shrinks. Direct page navigation calls `goToPage()` instead.
|
||||
function resetAndReload() {
|
||||
currentPage = 1;
|
||||
syncUrl();
|
||||
load();
|
||||
}
|
||||
|
||||
function goToPage(p: number) {
|
||||
if (p === currentPage) return;
|
||||
currentPage = p;
|
||||
syncUrl();
|
||||
load();
|
||||
if (browser) window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
async function hydrateFromUrl() {
|
||||
// Parse the query and resolve the supplied ids back to full Tag /
|
||||
// Genre objects so the chip rows render real labels.
|
||||
@@ -100,6 +133,8 @@
|
||||
const tags = await listTags({ limit: 50 });
|
||||
selectedTags = tags.filter((t) => tagIds.includes(t.id));
|
||||
}
|
||||
const pageParam = Number(url.searchParams.get('page') ?? '1');
|
||||
currentPage = Number.isFinite(pageParam) && pageParam >= 1 ? Math.floor(pageParam) : 1;
|
||||
// Open the filters panel if anything is active so the user can see why.
|
||||
if (statusFilter || selectedGenres.length || selectedTags.length) {
|
||||
filtersOpen = true;
|
||||
@@ -108,32 +143,27 @@
|
||||
|
||||
async function onSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
syncUrl();
|
||||
await load();
|
||||
resetAndReload();
|
||||
}
|
||||
|
||||
function onSortChange() {
|
||||
syncUrl();
|
||||
load();
|
||||
resetAndReload();
|
||||
}
|
||||
|
||||
function onStatusChange() {
|
||||
syncUrl();
|
||||
load();
|
||||
resetAndReload();
|
||||
}
|
||||
|
||||
function toggleGenre(g: Genre) {
|
||||
selectedGenres = selectedGenres.some((x) => x.id === g.id)
|
||||
? selectedGenres.filter((x) => x.id !== g.id)
|
||||
: [...selectedGenres, g];
|
||||
syncUrl();
|
||||
load();
|
||||
resetAndReload();
|
||||
}
|
||||
|
||||
function removeTag(t: Tag) {
|
||||
selectedTags = selectedTags.filter((x) => x.id !== t.id);
|
||||
syncUrl();
|
||||
load();
|
||||
resetAndReload();
|
||||
}
|
||||
|
||||
function pickTag(t: Tag) {
|
||||
@@ -143,8 +173,7 @@
|
||||
tagDraft = '';
|
||||
tagSuggestions = [];
|
||||
tagSuggestHighlight = -1;
|
||||
syncUrl();
|
||||
load();
|
||||
resetAndReload();
|
||||
}
|
||||
|
||||
function onTagDraftInput() {
|
||||
@@ -192,8 +221,7 @@
|
||||
statusFilter = '';
|
||||
selectedGenres = [];
|
||||
selectedTags = [];
|
||||
syncUrl();
|
||||
load();
|
||||
resetAndReload();
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
@@ -383,7 +411,7 @@
|
||||
{:else}
|
||||
{#if total !== null}
|
||||
<p class="count" data-testid="manga-total">
|
||||
Showing {mangas.length} of {total}
|
||||
Showing {rangeStart}–{rangeEnd} of {total}
|
||||
</p>
|
||||
{/if}
|
||||
<ul class="manga-grid" data-testid="manga-list">
|
||||
@@ -391,6 +419,12 @@
|
||||
<MangaCard manga={m} authors={m.authors} genres={m.genres} />
|
||||
{/each}
|
||||
</ul>
|
||||
<Pager
|
||||
page={currentPage}
|
||||
{totalPages}
|
||||
onChange={goToPage}
|
||||
testid="manga-pager"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
|
||||
Reference in New Issue
Block a user