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

@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { logout } from '$lib/api/auth';
import { authConfig } from '$lib/auth-config.svelte';
import { preferences } from '$lib/preferences.svelte';
@@ -18,6 +19,32 @@
let loggingOut = $state(false);
let headerEl: HTMLElement | undefined = $state();
// Static-route title map. Dynamic pages (manga / author / collection /
// chapter) override this via their own <svelte:head><title>, since the
// title depends on data the layout doesn't have. Routes omitted here
// (notably the dynamic ones) fall through to the bare brand and rely
// on the page to set the descriptive form.
const STATIC_TITLES: Record<string, string> = {
'/': 'Mangalord',
'/login': 'Mangalord | Login',
'/register': 'Mangalord | Register',
'/upload': 'Mangalord | Upload',
'/bookmarks': 'Mangalord | Bookmarks',
'/collections': 'Mangalord | Collections',
'/profile': 'Mangalord | Profile',
'/profile/account': 'Mangalord | Account',
'/profile/bookmarks': 'Mangalord | Bookmarks',
'/profile/collections': 'Mangalord | Collections',
'/profile/history': 'Mangalord | Reading history',
'/profile/preferences': 'Mangalord | Preferences',
'/admin': 'Mangalord | Admin',
'/admin/mangas': 'Mangalord | Admin · Mangas',
'/admin/users': 'Mangalord | Admin · Users',
'/admin/system': 'Mangalord | Admin · System'
};
const layoutTitle = $derived(STATIC_TITLES[$page.route?.id ?? ''] ?? 'Mangalord');
// Seed authConfig from the universal layout load. $effect keeps
// the store in sync if `data` is replaced by a subsequent layout
// load (client-side nav). The first run also covers initial
@@ -78,6 +105,10 @@
}
</script>
<svelte:head>
<title>{layoutTitle}</title>
</svelte:head>
<header bind:this={headerEl}>
<nav aria-label="primary">
<a class="brand" href="/">Mangalord</a>

View File

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

View File

@@ -71,16 +71,19 @@
>
<input
type="search"
placeholder="search by title"
placeholder="Search by title"
bind:value={search}
data-testid="admin-mangas-search"
/>
<select bind:value={syncFilter} aria-label="sync state">
<option value="">all states</option>
<option value="in_progress">in progress</option>
<option value="dropped">dropped</option>
<option value="synced">synced</option>
</select>
<label class="sync-label">
<span>Sync state</span>
<select bind:value={syncFilter} aria-label="sync state">
<option value="">All</option>
<option value="in_progress">In progress</option>
<option value="dropped">Dropped</option>
<option value="synced">Synced</option>
</select>
</label>
<button type="submit">Search</button>
</form>
@@ -173,17 +176,28 @@
}
form {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
input[type='search'] {
flex: 1;
min-width: 0;
max-width: 24rem;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
color: var(--text);
}
.sync-label {
display: inline-flex;
align-items: center;
gap: var(--space-2);
color: var(--text-muted);
font-size: var(--font-sm);
}
select {
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);

View File

@@ -1,15 +1,33 @@
<script lang="ts">
import MangaCard from '$lib/components/MangaCard.svelte';
import Pager from '$lib/components/Pager.svelte';
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
let { data } = $props();
const author = $derived(data.author);
const mangas = $derived(data.mangas);
const total = $derived(data.total);
const currentPage = $derived(data.currentPage);
const pageSize = $derived(data.pageSize);
const totalPages = $derived(
total != null && total > 0 ? Math.ceil(total / pageSize) : 1
);
const rangeStart = $derived(mangas.length === 0 ? 0 : (currentPage - 1) * pageSize + 1);
const rangeEnd = $derived((currentPage - 1) * pageSize + mangas.length);
function goToPage(p: number) {
if (p === currentPage) return;
const url = new URL($page.url);
if (p === 1) url.searchParams.delete('page');
else url.searchParams.set('page', String(p));
goto(url.pathname + url.search, { noScroll: false });
}
</script>
<svelte:head>
<title>{author.name} — Mangalord</title>
<title>Mangalord | {author.name}</title>
</svelte:head>
<nav class="back">
@@ -34,7 +52,7 @@
{:else}
{#if total != null}
<p class="meta" data-testid="author-shown-of-total">
Showing {mangas.length} of {total}
Showing {rangeStart}{rangeEnd} of {total}
</p>
{/if}
<ul class="manga-grid" data-testid="author-manga-list">
@@ -42,6 +60,12 @@
<MangaCard manga={m} testid={`author-manga-${m.id}`} />
{/each}
</ul>
<Pager
page={currentPage}
{totalPages}
onChange={goToPage}
testid="author-pager"
/>
{/if}
<style>

View File

@@ -5,13 +5,27 @@ import type { PageLoad } from './$types';
export const ssr = false;
export const load: PageLoad = async ({ params }) => {
const PAGE_SIZE = 50;
export const load: PageLoad = async ({ params, url }) => {
const pageParam = Number(url.searchParams.get('page') ?? '1');
const currentPage =
Number.isFinite(pageParam) && pageParam >= 1 ? Math.floor(pageParam) : 1;
try {
const [author, mangas] = await Promise.all([
getAuthor(params.id),
listAuthorMangas(params.id, { limit: 50 })
listAuthorMangas(params.id, {
limit: PAGE_SIZE,
offset: (currentPage - 1) * PAGE_SIZE
})
]);
return { author, mangas: mangas.items, total: mangas.page.total };
return {
author,
mangas: mangas.items,
total: mangas.page.total,
currentPage,
pageSize: PAGE_SIZE
};
} catch (e) {
// 404 surfaces as a real SvelteKit error so the framework shell
// renders the standard not-found page instead of the route's

View File

@@ -7,10 +7,6 @@
const error = $derived(data.error);
</script>
<svelte:head>
<title>Bookmarks — Mangalord</title>
</svelte:head>
<h1>Bookmarks</h1>
{#if error}

View File

@@ -5,10 +5,6 @@
const collections = $derived(data.collections);
</script>
<svelte:head>
<title>Collections — Mangalord</title>
</svelte:head>
<h1>Collections</h1>
{#if !data.authenticated}

View File

@@ -75,7 +75,7 @@
</script>
<svelte:head>
<title>{collection.name} — Mangalord</title>
<title>Mangalord | {collection.name}</title>
</svelte:head>
<nav class="back">

View File

@@ -174,7 +174,7 @@
</script>
<svelte:head>
<title>{manga.title} — Mangalord</title>
<title>Mangalord | {manga.title}</title>
</svelte:head>
<article>

View File

@@ -27,8 +27,8 @@
const pageTitle = $derived(
chapter.title
? `${manga.title} Ch. ${chapter.number}: ${chapter.title}`
: `${manga.title} Ch. ${chapter.number}`
? `Mangalord | ${manga.title} · Ch. ${chapter.number}: ${chapter.title}`
: `Mangalord | ${manga.title} · Ch. ${chapter.number}`
);
// Prev/next chapter computed from the chapter list. listChapters

View File

@@ -135,7 +135,7 @@
</script>
<svelte:head>
<title>Edit {manga.title} — Mangalord</title>
<title>Mangalord | Edit · {manga.title}</title>
</svelte:head>
<h1>Edit manga</h1>

View File

@@ -57,7 +57,7 @@
</script>
<svelte:head>
<title>Upload chapter {manga.title} — Mangalord</title>
<title>Mangalord | Upload chapter · {manga.title}</title>
</svelte:head>
<nav class="back">

View File

@@ -35,10 +35,6 @@
);
</script>
<svelte:head>
<title>Profile — Mangalord</title>
</svelte:head>
<header class="profile-header">
<h1>Profile</h1>
{#if !session.loaded}

View File

@@ -184,10 +184,6 @@
}
</script>
<svelte:head>
<title>Upload — Mangalord</title>
</svelte:head>
<h1>Create manga</h1>
{#if !session.loaded}