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

@@ -12,6 +12,7 @@
import type { Manga } from '$lib/api/client';
import { fileUrl } from '$lib/api/client';
import MangaCard from '$lib/components/MangaCard.svelte';
import MangaGridSkeleton from '$lib/components/MangaGridSkeleton.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import Pencil from '@lucide/svelte/icons/pencil';
@@ -22,10 +23,32 @@
let { data } = $props();
// svelte-ignore state_referenced_locally
let collection = $state({ ...data.collection });
// svelte-ignore state_referenced_locally
let mangas = $state<Manga[]>([...data.mangas]);
// svelte-ignore state_referenced_locally
let pages = $state<CollectionPageItem[]>([...data.pages]);
// The manga/page lists are streamed and held as local mutable state so
// removals can be optimistic. They're seeded once the stream resolves; a
// skeleton shows until then.
let mangas = $state<Manga[]>([]);
let pages = $state<CollectionPageItem[]>([]);
let contentLoading = $state(true);
// Seed (and re-seed on navigation) from the streamed content. A monotonic
// guard drops a stale resolution that lands after a newer navigation.
let contentSeq = 0;
$effect(() => {
const content = data.content;
const seq = ++contentSeq;
contentLoading = true;
content
.then((r) => {
if (seq !== contentSeq) return;
mangas = r.mangas;
pages = r.pages;
contentLoading = false;
})
.catch(() => {
if (seq !== contentSeq) return;
contentLoading = false;
});
});
let editing = $state(false);
let editName = $state('');
@@ -178,13 +201,15 @@
{/if}
</header>
{#if mangas.length === 0 && pages.length === 0}
{#if contentLoading}
<MangaGridSkeleton count={6} />
{:else if mangas.length === 0 && pages.length === 0}
<p class="status" data-testid="collection-empty">
This collection is empty.
</p>
{/if}
{#if mangas.length > 0}
{#if !contentLoading && mangas.length > 0}
<section aria-labelledby="mangas-heading">
<h2 id="mangas-heading" class="section-heading">Mangas</h2>
<ul class="manga-grid" data-testid="collection-manga-list">
@@ -207,7 +232,7 @@
</section>
{/if}
{#if pages.length > 0}
{#if !contentLoading && pages.length > 0}
<section aria-labelledby="pages-heading">
<h2 id="pages-heading" class="section-heading">Pages</h2>
<ul class="page-grid" data-testid="collection-page-list">

View File

@@ -10,18 +10,11 @@ import type { PageLoad } from './$types';
export const ssr = false;
export const load: PageLoad = async ({ params, url }) => {
let collection;
try {
const [collection, mangas, pages] = await Promise.all([
getCollection(params.id),
listCollectionMangas(params.id, { limit: 200 }),
listCollectionPages(params.id, { limit: 200 })
]);
return {
collection,
mangas: mangas.items,
total: mangas.page.total,
pages: pages.items
};
// Awaited so the header renders immediately and 401/404 are handled by
// the framework (redirect / not-found) rather than the happy path.
collection = await getCollection(params.id);
} catch (e) {
if (e instanceof ApiError) {
if (e.status === 401) {
@@ -34,4 +27,13 @@ export const load: PageLoad = async ({ params, url }) => {
}
throw e;
}
return {
collection,
// Streamed so the manga/page grids show a skeleton while they load
// instead of the whole page blocking on them.
content: Promise.all([
listCollectionMangas(params.id, { limit: 200 }),
listCollectionPages(params.id, { limit: 200 })
]).then(([mangas, pages]) => ({ mangas: mangas.items, pages: pages.items }))
};
};