Files
Mangalord/frontend/src/routes/+page.svelte
MechaCat02 5c22dfdb41 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>
2026-06-01 21:18:53 +02:00

657 lines
21 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import {
listMangas,
type MangaCard as MangaCardData,
type MangaSort,
type MangaStatus
} from '$lib/api/mangas';
import { listGenres, type Genre } from '$lib/api/genres';
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');
let statusFilter = $state<'' | MangaStatus>('');
let selectedGenres = $state<Genre[]>([]);
let selectedTags = $state<Tag[]>([]);
let allGenres = $state<Genre[]>([]);
let tagDraft = $state('');
let tagSuggestions = $state<Tag[]>([]);
let tagSuggestHighlight = $state(-1);
let suggestTimer: ReturnType<typeof setTimeout> | null = null;
// Monotonic counter — discards stale fetch results so a fast typist
// can't see an earlier query's results overwrite the current one.
let suggestSeq = 0;
const tagSuggestListId = 'tag-filter-suggest-list';
let filtersOpen = $state(false);
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;
try {
const result = await listMangas({
search: search.trim() || undefined,
status: statusFilter || undefined,
genreIds: selectedGenres.map((g) => g.id),
tagIds: selectedTags.map((t) => t.id),
sort,
limit: PAGE_SIZE,
offset: (currentPage - 1) * PAGE_SIZE
});
mangas = result.items;
total = result.page.total;
} catch (e) {
error = (e as Error).message;
} finally {
loading = false;
}
}
function syncUrl() {
if (!browser) return;
const params = new URLSearchParams();
if (search.trim()) params.set('q', search.trim());
if (sort !== 'recent') params.set('sort', sort);
if (statusFilter) params.set('status', statusFilter);
if (selectedGenres.length)
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.
const url = new URL($page.url);
search = url.searchParams.get('q') ?? '';
const s = url.searchParams.get('sort');
if (s === 'title' || s === 'recent') sort = s;
const st = url.searchParams.get('status');
statusFilter = st === 'ongoing' || st === 'completed' ? st : '';
const genreIds = (url.searchParams.get('genres') ?? '')
.split(',')
.filter(Boolean);
if (genreIds.length) {
selectedGenres = allGenres.filter((g) => genreIds.includes(g.id));
}
const tagIds = (url.searchParams.get('tags') ?? '')
.split(',')
.filter(Boolean);
if (tagIds.length) {
// listTags doesn't take ids; fetch a generous page and filter.
// Tag count is small in the near term, so this is fine.
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;
}
}
async function onSubmit(e: SubmitEvent) {
e.preventDefault();
resetAndReload();
}
function onSortChange() {
resetAndReload();
}
function onStatusChange() {
resetAndReload();
}
function toggleGenre(g: Genre) {
selectedGenres = selectedGenres.some((x) => x.id === g.id)
? selectedGenres.filter((x) => x.id !== g.id)
: [...selectedGenres, g];
resetAndReload();
}
function removeTag(t: Tag) {
selectedTags = selectedTags.filter((x) => x.id !== t.id);
resetAndReload();
}
function pickTag(t: Tag) {
if (!selectedTags.some((x) => x.id === t.id)) {
selectedTags = [...selectedTags, t];
}
tagDraft = '';
tagSuggestions = [];
tagSuggestHighlight = -1;
resetAndReload();
}
function onTagDraftInput() {
tagSuggestHighlight = -1;
if (suggestTimer) clearTimeout(suggestTimer);
const q = tagDraft.trim();
if (q.length === 0) {
tagSuggestions = [];
suggestSeq++;
return;
}
const seq = ++suggestSeq;
suggestTimer = setTimeout(async () => {
try {
const matched = await listTags({ search: q, limit: 6 });
if (seq !== suggestSeq) return;
const chosen = new Set(selectedTags.map((t) => t.id));
tagSuggestions = matched.filter((m) => !chosen.has(m.id));
} catch {
if (seq === suggestSeq) tagSuggestions = [];
}
}, 180);
}
function onTagFilterKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowDown' && tagSuggestions.length > 0) {
e.preventDefault();
tagSuggestHighlight = (tagSuggestHighlight + 1) % tagSuggestions.length;
} else if (e.key === 'ArrowUp' && tagSuggestions.length > 0) {
e.preventDefault();
tagSuggestHighlight =
tagSuggestHighlight <= 0
? tagSuggestions.length - 1
: tagSuggestHighlight - 1;
} else if (e.key === 'Enter' && tagSuggestHighlight >= 0) {
e.preventDefault();
pickTag(tagSuggestions[tagSuggestHighlight]);
} else if (e.key === 'Escape') {
tagSuggestions = [];
tagSuggestHighlight = -1;
}
}
function clearFilters() {
statusFilter = '';
selectedGenres = [];
selectedTags = [];
resetAndReload();
}
onMount(async () => {
try {
allGenres = await listGenres();
} catch {
// Filter UI still loads with an empty genre list rather than blocking.
}
await hydrateFromUrl();
await load();
});
</script>
<h1>Mangas</h1>
<form
onsubmit={onSubmit}
action="javascript:void(0)"
class="controls"
>
<div class="search-row">
<input
class="search"
type="search"
bind:value={search}
placeholder="Search by title or author"
data-testid="search-input"
/>
<button class="icon-btn primary" type="submit" aria-label="Search" title="Search">
<Search size={18} aria-hidden="true" />
</button>
<button
type="button"
class="filters-toggle"
class:active={filtersOpen}
onclick={() => (filtersOpen = !filtersOpen)}
aria-expanded={filtersOpen}
aria-controls="filters-panel"
data-testid="filters-toggle"
>
<SlidersHorizontal size={16} aria-hidden="true" />
<span>Filters</span>
{#if activeFilterCount > 0}
<span class="filter-count">{activeFilterCount}</span>
{/if}
</button>
</div>
{#if filtersOpen}
<div class="filters-panel" id="filters-panel" data-testid="filters-panel">
<div class="filter-group">
<span class="filter-label">Status</span>
<div class="status-row">
<label>
<input
type="radio"
name="status"
value=""
bind:group={statusFilter}
onchange={onStatusChange}
/>
<span>Any</span>
</label>
<label>
<input
type="radio"
name="status"
value="ongoing"
bind:group={statusFilter}
onchange={onStatusChange}
/>
<span>Ongoing</span>
</label>
<label>
<input
type="radio"
name="status"
value="completed"
bind:group={statusFilter}
onchange={onStatusChange}
/>
<span>Completed</span>
</label>
</div>
</div>
<div class="filter-group">
<span class="filter-label">Genres (all must match)</span>
<div class="filter-chip-row">
{#each allGenres as g (g.id)}
{@const on = selectedGenres.some((x) => x.id === g.id)}
<button
type="button"
class="genre-pill"
class:active={on}
onclick={() => toggleGenre(g)}
data-testid={`genre-filter-${g.name}`}
>
{g.name}
</button>
{/each}
</div>
</div>
<div class="filter-group">
<span class="filter-label">Tags (all must match)</span>
{#if selectedTags.length > 0}
<div class="filter-chip-row">
{#each selectedTags as t (t.id)}
<Chip
label={t.name}
variant="primary"
onRemove={() => removeTag(t)}
removeLabel={`Remove tag ${t.name}`}
testid={`tag-filter-chip-${t.name}`}
/>
{/each}
</div>
{/if}
<div class="tag-search">
<input
type="text"
role="combobox"
bind:value={tagDraft}
oninput={onTagDraftInput}
onkeydown={onTagFilterKeydown}
placeholder="Type to find a tag"
maxlength="64"
aria-label="Find a tag"
aria-controls={tagSuggestListId}
aria-expanded={tagSuggestions.length > 0}
aria-autocomplete="list"
aria-activedescendant={tagSuggestHighlight >= 0
? `${tagSuggestListId}-opt-${tagSuggestHighlight}`
: undefined}
data-testid="tag-filter-input"
/>
{#if tagSuggestions.length > 0}
<ul class="tag-suggestions" role="listbox" id={tagSuggestListId}>
{#each tagSuggestions as s, i (s.id)}
<li
id={`${tagSuggestListId}-opt-${i}`}
role="option"
aria-selected={i === tagSuggestHighlight}
class:active={i === tagSuggestHighlight}
>
<button
type="button"
tabindex="-1"
onmouseenter={() => (tagSuggestHighlight = i)}
onclick={() => pickTag(s)}
data-testid={`tag-filter-suggestion-${s.name}`}
>
<Plus size={12} aria-hidden="true" />
{s.name}
</button>
</li>
{/each}
</ul>
{/if}
</div>
</div>
{#if activeFilterCount > 0}
<button type="button" class="clear" onclick={clearFilters}>
Clear filters
</button>
{/if}
</div>
{/if}
<div class="config-row">
<label class="sort">
<span>Sort</span>
<select bind:value={sort} onchange={onSortChange} data-testid="sort-select">
<option value="recent">Recent</option>
<option value="title">Title (A→Z)</option>
</select>
</label>
</div>
</form>
{#if loading}
<p class="status" data-testid="loading">Loading…</p>
{:else if error}
<p class="error" data-testid="error" role="alert">{error}</p>
{:else if mangas.length === 0}
<p class="status" data-testid="empty">No mangas yet. <a href="/upload">Upload one</a>.</p>
{:else}
{#if total !== null}
<p class="count" data-testid="manga-total">
Showing {rangeStart}{rangeEnd} of {total}
</p>
{/if}
<ul class="manga-grid" data-testid="manga-list">
{#each mangas as m (m.id)}
<MangaCard manga={m} authors={m.authors} genres={m.genres} />
{/each}
</ul>
<Pager
page={currentPage}
{totalPages}
onChange={goToPage}
testid="manga-pager"
/>
{/if}
<style>
.controls {
display: flex;
flex-direction: column;
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.search-row {
display: flex;
gap: var(--space-2);
align-items: center;
flex-wrap: wrap;
}
.search {
flex: 1;
min-width: 0;
max-width: 28rem;
}
.filters-toggle {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: 0 var(--space-3);
height: 36px;
background: var(--surface);
border: 1px solid var(--border-strong);
color: var(--text);
cursor: pointer;
}
.filters-toggle:hover,
.filters-toggle.active {
background: var(--surface-elevated);
border-color: var(--primary);
}
.filter-count {
background: var(--primary);
color: var(--primary-contrast);
border-radius: var(--radius-pill);
font-size: var(--font-xs);
padding: 0 6px;
min-width: 18px;
text-align: center;
}
.filters-panel {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-3);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
}
.filter-group {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.filter-label {
color: var(--text-muted);
font-size: var(--font-sm);
font-weight: var(--weight-medium);
}
.status-row {
display: flex;
gap: var(--space-3);
flex-wrap: wrap;
}
.status-row label {
display: inline-flex;
align-items: center;
gap: var(--space-1);
color: var(--text);
font-size: var(--font-sm);
cursor: pointer;
}
.filter-chip-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.genre-pill {
display: inline-flex;
align-items: center;
padding: 4px var(--space-2);
background: var(--surface-elevated);
border: 1px solid var(--border);
color: var(--text);
border-radius: var(--radius-pill);
font-size: var(--font-xs);
cursor: pointer;
}
.genre-pill:hover {
border-color: var(--primary);
}
.genre-pill.active {
background: var(--primary-soft-bg);
border-color: var(--primary);
color: var(--primary);
}
.tag-search {
position: relative;
max-width: 20rem;
}
.tag-search input {
width: 100%;
}
.tag-suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin: var(--space-1) 0 0;
padding: var(--space-1) 0;
list-style: none;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
z-index: var(--z-dropdown);
}
.tag-suggestions button {
width: 100%;
background: transparent;
border: 0;
text-align: left;
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) var(--space-3);
color: var(--text);
cursor: pointer;
font-size: var(--font-sm);
}
.tag-suggestions li.active button,
.tag-suggestions button:hover {
background: var(--primary-soft-bg);
}
.clear {
align-self: flex-start;
background: transparent;
border: 1px solid var(--border);
color: var(--text-muted);
font-size: var(--font-sm);
}
.config-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
align-items: center;
}
.sort {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--text-muted);
font-size: var(--font-sm);
}
.sort select {
width: auto;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
}
.icon-btn.primary {
background: var(--primary);
color: var(--primary-contrast);
border: 1px solid var(--primary);
}
.icon-btn.primary:hover:not(:disabled) {
background: var(--primary-hover);
border-color: var(--primary-hover);
}
.status {
color: var(--text-muted);
}
.error {
color: var(--danger);
}
.count {
color: var(--text-muted);
font-size: var(--font-sm);
margin: var(--space-2) 0;
}
.manga-grid {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: var(--space-4);
}
</style>