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