feat: grid skeletons on the author and collection detail pages

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>
This commit is contained in:
MechaCat02
2026-07-06 22:01:53 +02:00
parent ec73c6e001
commit cee1e73f98
9 changed files with 226 additions and 65 deletions

View File

@@ -1,5 +1,6 @@
<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';
@@ -7,15 +8,8 @@
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;
@@ -45,28 +39,36 @@
</p>
</header>
{#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}
{#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}
<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 {

View File

@@ -11,28 +11,27 @@ 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;
let author;
try {
const [author, mangas] = await Promise.all([
getAuthor(params.id),
listAuthorMangas(params.id, {
limit: PAGE_SIZE,
offset: (currentPage - 1) * PAGE_SIZE
})
]);
return {
author,
mangas: mangas.items,
total: mangas.page.total,
currentPage,
pageSize: PAGE_SIZE
};
// Awaited so a 404 surfaces as a real SvelteKit error (the framework
// not-found page) rather than the happy-path markup with undefined
// data. The author header is cheap, so blocking on it is fine.
author = await getAuthor(params.id);
} catch (e) {
// 404 surfaces as a real SvelteKit error so the framework shell
// renders the standard not-found page instead of the route's
// happy-path markup with undefined data.
if (e instanceof ApiError && e.status === 404) {
error(404, 'Author not found');
}
throw e;
}
return {
author,
// Streamed (not awaited) so the grid shows a MangaGridSkeleton while it
// loads instead of the whole page blocking on it.
mangas: listAuthorMangas(params.id, {
limit: PAGE_SIZE,
offset: (currentPage - 1) * PAGE_SIZE
}).then((m) => ({ items: m.items, total: m.page.total })),
currentPage,
pageSize: PAGE_SIZE
};
};