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:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
57
frontend/e2e/bookmarks-skeleton.spec.ts
Normal file
57
frontend/e2e/bookmarks-skeleton.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
@@ -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": {
|
||||||
|
|||||||
49
frontend/src/lib/components/ListRowSkeleton.svelte
Normal file
49
frontend/src/lib/components/ListRowSkeleton.svelte
Normal 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>
|
||||||
25
frontend/src/lib/components/ListRowSkeleton.svelte.test.ts
Normal file
25
frontend/src/lib/components/ListRowSkeleton.svelte.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}
|
||||||
<p class="error" role="alert" data-testid="bookmarks-error">
|
<ListRowSkeleton />
|
||||||
Couldn't load bookmarks: {error}
|
{:then r}
|
||||||
</p>
|
{#if r.error}
|
||||||
{:else if !authenticated}
|
<p class="error" role="alert" data-testid="bookmarks-error">
|
||||||
<p class="hint" data-testid="bookmarks-signin">
|
Couldn't load bookmarks: {r.error}
|
||||||
<a href="/login">Sign in</a> to see your bookmarks.
|
</p>
|
||||||
</p>
|
{:else if !r.authenticated}
|
||||||
{:else if bookmarks.length === 0}
|
<p class="hint" data-testid="bookmarks-signin">
|
||||||
<p class="hint" data-testid="bookmarks-empty">No bookmarks yet.</p>
|
<a href="/login">Sign in</a> to see your bookmarks.
|
||||||
{:else}
|
</p>
|
||||||
<BookmarkList {bookmarks} />
|
{:else if r.bookmarks.length === 0}
|
||||||
{/if}
|
<p class="hint" data-testid="bookmarks-empty">No bookmarks yet.</p>
|
||||||
|
{:else}
|
||||||
|
<BookmarkList bookmarks={r.bookmarks} />
|
||||||
|
{/if}
|
||||||
|
{/await}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.error {
|
.error {
|
||||||
|
|||||||
@@ -5,21 +5,27 @@ import type { PageLoad } from './$types';
|
|||||||
export const ssr = false;
|
export const ssr = false;
|
||||||
|
|
||||||
export const load: PageLoad = async () => {
|
export const load: PageLoad = async () => {
|
||||||
try {
|
// Streamed (the load itself doesn't await) so the list shows a skeleton
|
||||||
const page = await listMyBookmarks();
|
// while the single fetch — which is also the auth gate — is in flight.
|
||||||
return { bookmarks: page.items, authenticated: true, error: null };
|
return {
|
||||||
} catch (e) {
|
result: (async () => {
|
||||||
if (e instanceof ApiError && e.status === 401) {
|
try {
|
||||||
return { bookmarks: [], authenticated: false, error: null };
|
const page = await listMyBookmarks();
|
||||||
}
|
return { bookmarks: page.items, authenticated: true, error: null as string | null };
|
||||||
// Anything else (502 upstream_unavailable from a backend
|
} catch (e) {
|
||||||
// restart, 500 internal_error) is rendered inline rather than
|
if (e instanceof ApiError && e.status === 401) {
|
||||||
// re-thrown — SvelteKit's generic error.html is not the right
|
return { bookmarks: [], authenticated: false, error: null as string | null };
|
||||||
// UX for a transient API blip and the user is already
|
}
|
||||||
// authenticated as far as we know.
|
// Anything else (502 upstream_unavailable from a backend
|
||||||
if (e instanceof ApiError) {
|
// restart, 500 internal_error) is rendered inline rather than
|
||||||
return { bookmarks: [], authenticated: true, error: e.message };
|
// re-thrown — SvelteKit's generic error.html is not the right
|
||||||
}
|
// UX for a transient API blip and the user is already
|
||||||
throw e;
|
// authenticated as far as we know.
|
||||||
}
|
if (e instanceof ApiError) {
|
||||||
|
return { bookmarks: [], authenticated: true, error: e.message };
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}
|
||||||
<p class="status">
|
<MangaGridSkeleton count={6} />
|
||||||
<a href="/login">Sign in</a> to see and manage your collections.
|
{:then r}
|
||||||
</p>
|
{#if !r.authenticated}
|
||||||
{:else if data.error}
|
<p class="status">
|
||||||
<p class="error" role="alert">{data.error}</p>
|
<a href="/login">Sign in</a> to see and manage your collections.
|
||||||
{:else if collections.length === 0}
|
</p>
|
||||||
<p class="status" data-testid="collections-empty">
|
{:else if r.error}
|
||||||
You don't have any collections yet. Open any manga and use
|
<p class="error" role="alert">{r.error}</p>
|
||||||
<strong>Add to collection</strong> to start one.
|
{:else if r.collections.length === 0}
|
||||||
</p>
|
<p class="status" data-testid="collections-empty">
|
||||||
{:else}
|
You don't have any collections yet. Open any manga and use
|
||||||
<CollectionsGrid {collections} />
|
<strong>Add to collection</strong> to start one.
|
||||||
{/if}
|
</p>
|
||||||
|
{:else}
|
||||||
|
<CollectionsGrid collections={r.collections} />
|
||||||
|
{/if}
|
||||||
|
{/await}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.status {
|
.status {
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
try {
|
// Streamed so the grid shows a skeleton while the single fetch (also the
|
||||||
const page = await listMyCollections({ limit: 200 });
|
// auth gate) is in flight.
|
||||||
return { collections: page.items, authenticated: true, error: null };
|
return {
|
||||||
} catch (e) {
|
result: (async () => {
|
||||||
if (e instanceof ApiError && e.status === 401) {
|
try {
|
||||||
return { collections: [], authenticated: false, error: null };
|
const page = await listMyCollections({ limit: 200 });
|
||||||
}
|
return { collections: page.items, authenticated: true, error: null as string | null };
|
||||||
if (e instanceof ApiError) {
|
} catch (e) {
|
||||||
return { collections: [], authenticated: true, error: e.message };
|
if (e instanceof ApiError && e.status === 401) {
|
||||||
}
|
return { collections: [], authenticated: false, error: null as string | null };
|
||||||
throw e;
|
}
|
||||||
}
|
if (e instanceof ApiError) {
|
||||||
|
return { collections: [], authenticated: true, error: e.message };
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user