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:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
66
frontend/e2e/author-page.spec.ts
Normal file
66
frontend/e2e/author-page.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
67
frontend/e2e/collection-detail-skeleton.spec.ts
Normal file
67
frontend/e2e/collection-detail-skeleton.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 }))
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user