feat: loading skeletons on the bookmarks and collections pages

Both loaders now stream the whole result (the single fetch is also the auth
gate) so the page shows a skeleton while it loads instead of the global nav
bar only: a ListRowSkeleton on bookmarks and a MangaGridSkeleton on the
collections grid, resolving to the sign-in / error / empty / list branches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-07 07:18:43 +02:00
parent 5b51bcf056
commit 16cdec051a
10 changed files with 212 additions and 63 deletions

2
backend/Cargo.lock generated
View File

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

View File

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

View File

@@ -0,0 +1,57 @@
import { test, expect, type Page } from './fixtures';
// The bookmarks page streams its list (the single fetch is also the auth
// gate), so a row skeleton shows while it loads, then resolves to the list.
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/files/**', (r) => r.fulfill({ status: 200, body: '' }));
}
test('shows a row skeleton while bookmarks stream, then the list', async ({ page }) => {
await mockCommon(page);
let release: () => void = () => {};
const gate = new Promise<void>((resolve) => {
release = resolve;
});
await page.route('**/api/v1/me/bookmarks*', async (route) => {
await gate;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [
{
id: 'bk1', user_id: 'u1', manga_id: 'm1', chapter_id: null, page: null,
created_at: '2026-01-01T00:00:00Z', manga_title: 'Berserk', manga_cover_image_path: null
}
],
page: { limit: 50, offset: 0, total: 1 }
})
});
});
await page.goto('/bookmarks');
await expect(page.getByTestId('list-row-skeleton')).toBeVisible();
release();
await expect(page.getByText('Berserk')).toBeVisible();
await expect(page.getByTestId('list-row-skeleton')).toHaveCount(0);
});

View File

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

View File

@@ -0,0 +1,49 @@
<script lang="ts">
import Skeleton from './Skeleton.svelte';
/**
* Placeholder for a cover + two-line row list (bookmarks, history, tagged
* pages). Reserves the list height so rows swap in without a shift.
*/
let {
count = 6,
coverWidth = '64px',
testid = 'list-row-skeleton'
}: { count?: number; coverWidth?: string; testid?: string } = $props();
</script>
<ul class="list" data-testid={testid} aria-hidden="true">
{#each Array(count) as _}
<li class="row" style="grid-template-columns: {coverWidth} 1fr;">
<Skeleton width={coverWidth} aspectRatio="2 / 3" radius="sm" />
<div class="meta">
<Skeleton variant="text" width="55%" radius="sm" />
<Skeleton variant="text" width="35%" radius="sm" />
</div>
</li>
{/each}
</ul>
<style>
.list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.row {
display: grid;
gap: var(--space-3);
align-items: center;
}
.meta {
display: flex;
flex-direction: column;
gap: var(--space-2);
min-width: 0;
}
</style>

View File

@@ -0,0 +1,25 @@
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/svelte';
import ListRowSkeleton from './ListRowSkeleton.svelte';
afterEach(() => cleanup());
describe('ListRowSkeleton', () => {
it('renders the requested number of rows', () => {
render(ListRowSkeleton, { props: { count: 5, testid: 'sk' } });
expect(screen.getByTestId('sk').querySelectorAll('.row').length).toBe(5);
});
it('defaults to 6 rows and is decorative', () => {
render(ListRowSkeleton, { props: { testid: 'sk' } });
const el = screen.getByTestId('sk');
expect(el.querySelectorAll('.row').length).toBe(6);
expect(el.getAttribute('aria-hidden')).toBe('true');
});
it('applies the cover width to the row template', () => {
render(ListRowSkeleton, { props: { coverWidth: '80px', testid: 'sk' } });
const row = screen.getByTestId('sk').querySelector('.row') as HTMLElement;
expect(row.style.gridTemplateColumns).toBe('80px 1fr');
});
});

View File

@@ -1,27 +1,29 @@
<script lang="ts"> <script lang="ts">
import BookmarkList from '$lib/components/BookmarkList.svelte'; import BookmarkList from '$lib/components/BookmarkList.svelte';
import ListRowSkeleton from '$lib/components/ListRowSkeleton.svelte';
let { data } = $props(); let { data } = $props();
const authenticated = $derived(data.authenticated);
const bookmarks = $derived(data.bookmarks);
const error = $derived(data.error);
</script> </script>
<h1>Bookmarks</h1> <h1>Bookmarks</h1>
{#if error} {#await data.result}
<ListRowSkeleton />
{:then r}
{#if r.error}
<p class="error" role="alert" data-testid="bookmarks-error"> <p class="error" role="alert" data-testid="bookmarks-error">
Couldn't load bookmarks: {error} Couldn't load bookmarks: {r.error}
</p> </p>
{:else if !authenticated} {:else if !r.authenticated}
<p class="hint" data-testid="bookmarks-signin"> <p class="hint" data-testid="bookmarks-signin">
<a href="/login">Sign in</a> to see your bookmarks. <a href="/login">Sign in</a> to see your bookmarks.
</p> </p>
{:else if bookmarks.length === 0} {:else if r.bookmarks.length === 0}
<p class="hint" data-testid="bookmarks-empty">No bookmarks yet.</p> <p class="hint" data-testid="bookmarks-empty">No bookmarks yet.</p>
{:else} {:else}
<BookmarkList {bookmarks} /> <BookmarkList bookmarks={r.bookmarks} />
{/if} {/if}
{/await}
<style> <style>
.error { .error {

View File

@@ -5,12 +5,16 @@ import type { PageLoad } from './$types';
export const ssr = false; export const ssr = false;
export const load: PageLoad = async () => { export const load: PageLoad = async () => {
// Streamed (the load itself doesn't await) so the list shows a skeleton
// while the single fetch — which is also the auth gate — is in flight.
return {
result: (async () => {
try { try {
const page = await listMyBookmarks(); const page = await listMyBookmarks();
return { bookmarks: page.items, authenticated: true, error: null }; return { bookmarks: page.items, authenticated: true, error: null as string | null };
} catch (e) { } catch (e) {
if (e instanceof ApiError && e.status === 401) { if (e instanceof ApiError && e.status === 401) {
return { bookmarks: [], authenticated: false, error: null }; return { bookmarks: [], authenticated: false, error: null as string | null };
} }
// Anything else (502 upstream_unavailable from a backend // Anything else (502 upstream_unavailable from a backend
// restart, 500 internal_error) is rendered inline rather than // restart, 500 internal_error) is rendered inline rather than
@@ -22,4 +26,6 @@ export const load: PageLoad = async () => {
} }
throw e; throw e;
} }
})()
};
}; };

View File

@@ -1,26 +1,30 @@
<script lang="ts"> <script lang="ts">
import CollectionsGrid from '$lib/components/CollectionsGrid.svelte'; import CollectionsGrid from '$lib/components/CollectionsGrid.svelte';
import MangaGridSkeleton from '$lib/components/MangaGridSkeleton.svelte';
let { data } = $props(); let { data } = $props();
const collections = $derived(data.collections);
</script> </script>
<h1>Collections</h1> <h1>Collections</h1>
{#if !data.authenticated} {#await data.result}
<MangaGridSkeleton count={6} />
{:then r}
{#if !r.authenticated}
<p class="status"> <p class="status">
<a href="/login">Sign in</a> to see and manage your collections. <a href="/login">Sign in</a> to see and manage your collections.
</p> </p>
{:else if data.error} {:else if r.error}
<p class="error" role="alert">{data.error}</p> <p class="error" role="alert">{r.error}</p>
{:else if collections.length === 0} {:else if r.collections.length === 0}
<p class="status" data-testid="collections-empty"> <p class="status" data-testid="collections-empty">
You don't have any collections yet. Open any manga and use You don't have any collections yet. Open any manga and use
<strong>Add to collection</strong> to start one. <strong>Add to collection</strong> to start one.
</p> </p>
{:else} {:else}
<CollectionsGrid {collections} /> <CollectionsGrid collections={r.collections} />
{/if} {/if}
{/await}
<style> <style>
.status { .status {

View File

@@ -5,16 +5,22 @@ import type { PageLoad } from './$types';
export const ssr = false; export const ssr = false;
export const load: PageLoad = async () => { export const load: PageLoad = async () => {
// Streamed so the grid shows a skeleton while the single fetch (also the
// auth gate) is in flight.
return {
result: (async () => {
try { try {
const page = await listMyCollections({ limit: 200 }); const page = await listMyCollections({ limit: 200 });
return { collections: page.items, authenticated: true, error: null }; return { collections: page.items, authenticated: true, error: null as string | null };
} catch (e) { } catch (e) {
if (e instanceof ApiError && e.status === 401) { if (e instanceof ApiError && e.status === 401) {
return { collections: [], authenticated: false, error: null }; return { collections: [], authenticated: false, error: null as string | null };
} }
if (e instanceof ApiError) { if (e instanceof ApiError) {
return { collections: [], authenticated: true, error: e.message }; return { collections: [], authenticated: true, error: e.message };
} }
throw e; throw e;
} }
})()
};
}; };