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

2
backend/Cargo.lock generated
View File

@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]] [[package]]
name = "mangalord" name = "mangalord"
version = "0.117.0" version = "0.118.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "mangalord" name = "mangalord"
version = "0.117.0" version = "0.118.0"
edition = "2021" edition = "2021"
default-run = "mangalord" default-run = "mangalord"

View File

@@ -0,0 +1,66 @@
import { test, expect, type Page } from './fixtures';
// The author page streams its manga grid: the cheap author header renders
// immediately while the (potentially large) grid shows a skeleton, then swaps
// in without a layout shift.
const authorId = 'a1111111-1111-1111-1111-111111111111';
async function mockCommon(page: Page) {
await page.route('**/api/v1/auth/config', (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
})
);
await page.route('**/api/v1/auth/me', (r) =>
r.fulfill({ status: 401, contentType: 'application/json', body: '{"error":{"code":"x","message":"x"}}' })
);
await page.route('**/api/v1/auth/me/preferences', (r) =>
r.fulfill({ status: 401, contentType: 'application/json', body: '{"error":{"code":"x","message":"x"}}' })
);
await page.route(`**/api/v1/authors/${authorId}`, (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ id: authorId, name: 'Kentaro Miura', manga_count: 1 })
})
);
}
test('shows a grid skeleton while the author mangas stream, then the grid', async ({ page }) => {
await mockCommon(page);
let release: () => void = () => {};
const gate = new Promise<void>((resolve) => {
release = resolve;
});
await page.route(`**/api/v1/authors/${authorId}/mangas*`, async (route) => {
await gate;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [
{
id: 'm1', title: 'Berserk', status: 'ongoing', alt_titles: [], description: null,
cover_image_path: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z'
}
],
page: { limit: 50, offset: 0, total: 1 }
})
});
});
await page.goto(`/authors/${authorId}`);
// Header renders immediately (not blocked on the grid).
await expect(page.getByTestId('author-name')).toHaveText('Kentaro Miura');
// Grid skeleton stands in while the stream is pending.
await expect(page.getByTestId('manga-grid-skeleton')).toBeVisible();
await expect(page.getByTestId('author-manga-list')).toHaveCount(0);
release();
await expect(page.getByTestId('author-manga-list')).toContainText('Berserk');
await expect(page.getByTestId('manga-grid-skeleton')).toHaveCount(0);
});

View File

@@ -0,0 +1,67 @@
import { test, expect, type Page } from './fixtures';
// The collection detail page streams its manga/page grids: the header renders
// immediately while a grid skeleton stands in, then the real grid swaps in.
const collectionId = 'd1111111-1111-1111-1111-111111111111';
async function mockCommon(page: Page) {
await page.route('**/api/v1/auth/config', (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
})
);
await page.route('**/api/v1/auth/me', (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ user: { id: 'u1', username: 'reader', created_at: '2026-01-01T00:00:00Z', is_admin: false } })
})
);
await page.route('**/api/v1/auth/me/preferences', (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
);
await page.route(`**/api/v1/collections/${collectionId}`, (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ id: collectionId, name: 'Favourites', description: null, manga_count: 1 })
})
);
await page.route(`**/api/v1/collections/${collectionId}/pages*`, (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 200, offset: 0, total: 0 } }) })
);
}
test('streams a grid skeleton on the collection detail then the manga grid', async ({ page }) => {
await mockCommon(page);
let release: () => void = () => {};
const gate = new Promise<void>((resolve) => {
release = resolve;
});
await page.route(`**/api/v1/collections/${collectionId}/mangas*`, async (route) => {
await gate;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [
{ id: 'm1', title: 'Berserk', status: 'ongoing', alt_titles: [], description: null, cover_image_path: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z' }
],
page: { limit: 200, offset: 0, total: 1 }
})
});
});
await page.goto(`/collections/${collectionId}`);
await expect(page.getByRole('heading', { name: 'Favourites' })).toBeVisible();
await expect(page.getByTestId('manga-grid-skeleton')).toBeVisible();
await expect(page.getByTestId('collection-manga-list')).toHaveCount(0);
release();
await expect(page.getByTestId('collection-manga-list')).toContainText('Berserk');
await expect(page.getByTestId('manga-grid-skeleton')).toHaveCount(0);
});

View File

@@ -1,6 +1,6 @@
{ {
"name": "mangalord-frontend", "name": "mangalord-frontend",
"version": "0.117.0", "version": "0.118.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import MangaCard from '$lib/components/MangaCard.svelte'; import MangaCard from '$lib/components/MangaCard.svelte';
import MangaGridSkeleton from '$lib/components/MangaGridSkeleton.svelte';
import Pager from '$lib/components/Pager.svelte'; import Pager from '$lib/components/Pager.svelte';
import ArrowLeft from '@lucide/svelte/icons/arrow-left'; import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
@@ -7,15 +8,8 @@
let { data } = $props(); let { data } = $props();
const author = $derived(data.author); const author = $derived(data.author);
const mangas = $derived(data.mangas);
const total = $derived(data.total);
const currentPage = $derived(data.currentPage); const currentPage = $derived(data.currentPage);
const pageSize = $derived(data.pageSize); 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) { function goToPage(p: number) {
if (p === currentPage) return; if (p === currentPage) return;
@@ -45,28 +39,36 @@
</p> </p>
</header> </header>
{#if mangas.length === 0} {#await data.mangas}
<p class="status" data-testid="author-no-mangas"> <MangaGridSkeleton />
No mangas attributed to this author. {:then result}
</p> {@const mangas = result.items}
{:else} {@const total = result.total}
{#if total != null} {@const totalPages = total != null && total > 0 ? Math.ceil(total / pageSize) : 1}
<p class="meta" data-testid="author-shown-of-total"> {@const rangeStart = mangas.length === 0 ? 0 : (currentPage - 1) * pageSize + 1}
Showing {rangeStart}{rangeEnd} of {total} {@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> </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} {/if}
<ul class="manga-grid" data-testid="author-manga-list"> {:catch}
{#each mangas as m (m.id)} <p class="status" role="alert" data-testid="author-error">
<MangaCard manga={m} testid={`author-manga-${m.id}`} /> Could not load this author's mangas.
{/each} </p>
</ul> {/await}
<Pager
page={currentPage}
{totalPages}
onChange={goToPage}
testid="author-pager"
/>
{/if}
<style> <style>
.back { .back {

View File

@@ -11,28 +11,27 @@ export const load: PageLoad = async ({ params, url }) => {
const pageParam = Number(url.searchParams.get('page') ?? '1'); const pageParam = Number(url.searchParams.get('page') ?? '1');
const currentPage = const currentPage =
Number.isFinite(pageParam) && pageParam >= 1 ? Math.floor(pageParam) : 1; Number.isFinite(pageParam) && pageParam >= 1 ? Math.floor(pageParam) : 1;
let author;
try { try {
const [author, mangas] = await Promise.all([ // Awaited so a 404 surfaces as a real SvelteKit error (the framework
getAuthor(params.id), // not-found page) rather than the happy-path markup with undefined
listAuthorMangas(params.id, { // data. The author header is cheap, so blocking on it is fine.
limit: PAGE_SIZE, author = await getAuthor(params.id);
offset: (currentPage - 1) * PAGE_SIZE
})
]);
return {
author,
mangas: mangas.items,
total: mangas.page.total,
currentPage,
pageSize: PAGE_SIZE
};
} catch (e) { } 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) { if (e instanceof ApiError && e.status === 404) {
error(404, 'Author not found'); error(404, 'Author not found');
} }
throw e; 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
};
}; };

View File

@@ -12,6 +12,7 @@
import type { Manga } from '$lib/api/client'; import type { Manga } from '$lib/api/client';
import { fileUrl } from '$lib/api/client'; import { fileUrl } from '$lib/api/client';
import MangaCard from '$lib/components/MangaCard.svelte'; import MangaCard from '$lib/components/MangaCard.svelte';
import MangaGridSkeleton from '$lib/components/MangaGridSkeleton.svelte';
import IconButton from '$lib/components/IconButton.svelte'; import IconButton from '$lib/components/IconButton.svelte';
import ArrowLeft from '@lucide/svelte/icons/arrow-left'; import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import Pencil from '@lucide/svelte/icons/pencil'; import Pencil from '@lucide/svelte/icons/pencil';
@@ -22,10 +23,32 @@
let { data } = $props(); let { data } = $props();
// svelte-ignore state_referenced_locally // svelte-ignore state_referenced_locally
let collection = $state({ ...data.collection }); let collection = $state({ ...data.collection });
// svelte-ignore state_referenced_locally // The manga/page lists are streamed and held as local mutable state so
let mangas = $state<Manga[]>([...data.mangas]); // removals can be optimistic. They're seeded once the stream resolves; a
// svelte-ignore state_referenced_locally // skeleton shows until then.
let pages = $state<CollectionPageItem[]>([...data.pages]); 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 editing = $state(false);
let editName = $state(''); let editName = $state('');
@@ -178,13 +201,15 @@
{/if} {/if}
</header> </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"> <p class="status" data-testid="collection-empty">
This collection is empty. This collection is empty.
</p> </p>
{/if} {/if}
{#if mangas.length > 0} {#if !contentLoading && mangas.length > 0}
<section aria-labelledby="mangas-heading"> <section aria-labelledby="mangas-heading">
<h2 id="mangas-heading" class="section-heading">Mangas</h2> <h2 id="mangas-heading" class="section-heading">Mangas</h2>
<ul class="manga-grid" data-testid="collection-manga-list"> <ul class="manga-grid" data-testid="collection-manga-list">
@@ -207,7 +232,7 @@
</section> </section>
{/if} {/if}
{#if pages.length > 0} {#if !contentLoading && pages.length > 0}
<section aria-labelledby="pages-heading"> <section aria-labelledby="pages-heading">
<h2 id="pages-heading" class="section-heading">Pages</h2> <h2 id="pages-heading" class="section-heading">Pages</h2>
<ul class="page-grid" data-testid="collection-page-list"> <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 ssr = false;
export const load: PageLoad = async ({ params, url }) => { export const load: PageLoad = async ({ params, url }) => {
let collection;
try { try {
const [collection, mangas, pages] = await Promise.all([ // Awaited so the header renders immediately and 401/404 are handled by
getCollection(params.id), // the framework (redirect / not-found) rather than the happy path.
listCollectionMangas(params.id, { limit: 200 }), collection = await getCollection(params.id);
listCollectionPages(params.id, { limit: 200 })
]);
return {
collection,
mangas: mangas.items,
total: mangas.page.total,
pages: pages.items
};
} catch (e) { } catch (e) {
if (e instanceof ApiError) { if (e instanceof ApiError) {
if (e.status === 401) { if (e.status === 401) {
@@ -34,4 +27,13 @@ export const load: PageLoad = async ({ params, url }) => {
} }
throw e; 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 }))
};
}; };