Both loaders now stream their manga grid instead of blocking on it: the cheap header renders immediately (and still handles 404/401 in the loader) while a MangaGridSkeleton stands in until the grid resolves, then swaps in without a layout shift. Collection detail seeds its optimistic-removal state from the stream via a guarded effect. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
116 lines
3.2 KiB
Svelte
116 lines
3.2 KiB
Svelte
<script lang="ts">
|
||
import MangaCard from '$lib/components/MangaCard.svelte';
|
||
import MangaGridSkeleton from '$lib/components/MangaGridSkeleton.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 currentPage = $derived(data.currentPage);
|
||
const pageSize = $derived(data.pageSize);
|
||
|
||
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>Mangalord | {author.name}</title>
|
||
</svelte:head>
|
||
|
||
<nav class="back">
|
||
<a href="/" class="back-link">
|
||
<ArrowLeft size={16} aria-hidden="true" />
|
||
<span>Back to search</span>
|
||
</a>
|
||
</nav>
|
||
|
||
<header class="overview">
|
||
<h1 data-testid="author-name">{author.name}</h1>
|
||
<p class="count" data-testid="author-manga-count">
|
||
{author.manga_count}
|
||
{author.manga_count === 1 ? 'work' : 'works'}
|
||
</p>
|
||
</header>
|
||
|
||
{#await data.mangas}
|
||
<MangaGridSkeleton />
|
||
{:then result}
|
||
{@const mangas = result.items}
|
||
{@const total = result.total}
|
||
{@const totalPages = total != null && total > 0 ? Math.ceil(total / pageSize) : 1}
|
||
{@const rangeStart = mangas.length === 0 ? 0 : (currentPage - 1) * pageSize + 1}
|
||
{@const rangeEnd = (currentPage - 1) * pageSize + mangas.length}
|
||
{#if mangas.length === 0}
|
||
<p class="status" data-testid="author-no-mangas">
|
||
No mangas attributed to this author.
|
||
</p>
|
||
{:else}
|
||
{#if total != null}
|
||
<p class="meta" data-testid="author-shown-of-total">
|
||
Showing {rangeStart}–{rangeEnd} of {total}
|
||
</p>
|
||
{/if}
|
||
<ul class="manga-grid" data-testid="author-manga-list">
|
||
{#each mangas as m (m.id)}
|
||
<MangaCard manga={m} testid={`author-manga-${m.id}`} />
|
||
{/each}
|
||
</ul>
|
||
<Pager page={currentPage} {totalPages} onChange={goToPage} testid="author-pager" />
|
||
{/if}
|
||
{:catch}
|
||
<p class="status" role="alert" data-testid="author-error">
|
||
Could not load this author's mangas.
|
||
</p>
|
||
{/await}
|
||
|
||
<style>
|
||
.back {
|
||
margin-bottom: var(--space-3);
|
||
}
|
||
|
||
.back-link {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: var(--space-1);
|
||
color: var(--text-muted);
|
||
font-size: var(--font-sm);
|
||
}
|
||
|
||
.back-link:hover {
|
||
color: var(--primary);
|
||
text-decoration: none;
|
||
}
|
||
|
||
.overview {
|
||
margin-bottom: var(--space-5);
|
||
}
|
||
|
||
.overview h1 {
|
||
margin: 0 0 var(--space-1);
|
||
}
|
||
|
||
.count {
|
||
color: var(--text-muted);
|
||
margin: 0 0 var(--space-2);
|
||
}
|
||
|
||
.meta {
|
||
color: var(--text-muted);
|
||
font-size: var(--font-sm);
|
||
margin: 0 0 var(--space-3);
|
||
}
|
||
|
||
.status {
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
/* `.manga-grid` is a shared global (see lib/styles/tokens.css). */
|
||
</style>
|