diff --git a/backend/Cargo.lock b/backend/Cargo.lock
index a2bcb53..167b8d7 100644
--- a/backend/Cargo.lock
+++ b/backend/Cargo.lock
@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mangalord"
-version = "0.116.0"
+version = "0.117.0"
dependencies = [
"anyhow",
"argon2",
diff --git a/backend/Cargo.toml b/backend/Cargo.toml
index 90e7ffb..9a01ffa 100644
--- a/backend/Cargo.toml
+++ b/backend/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "mangalord"
-version = "0.116.0"
+version = "0.117.0"
edition = "2021"
default-run = "mangalord"
diff --git a/frontend/package.json b/frontend/package.json
index 093b2f0..4d21d4d 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
- "version": "0.116.0",
+ "version": "0.117.0",
"private": true,
"type": "module",
"scripts": {
diff --git a/frontend/src/lib/components/ShelfSkeleton.svelte b/frontend/src/lib/components/ShelfSkeleton.svelte
new file mode 100644
index 0000000..21dae3c
--- /dev/null
+++ b/frontend/src/lib/components/ShelfSkeleton.svelte
@@ -0,0 +1,49 @@
+
+
+
+
+
+ {#each Array(count) as _}
+ -
+
+
+
+ {/each}
+
+
+
+
diff --git a/frontend/src/lib/components/ShelfSkeleton.svelte.test.ts b/frontend/src/lib/components/ShelfSkeleton.svelte.test.ts
new file mode 100644
index 0000000..ad72074
--- /dev/null
+++ b/frontend/src/lib/components/ShelfSkeleton.svelte.test.ts
@@ -0,0 +1,22 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { render, screen, cleanup } from '@testing-library/svelte';
+import ShelfSkeleton from './ShelfSkeleton.svelte';
+
+afterEach(() => cleanup());
+
+describe('ShelfSkeleton', () => {
+ it('renders the requested number of card placeholders', () => {
+ render(ShelfSkeleton, { props: { count: 4, testid: 'sk' } });
+ expect(screen.getByTestId('sk').querySelectorAll('.card').length).toBe(4);
+ });
+
+ it('defaults to 6 cards', () => {
+ render(ShelfSkeleton, { props: { testid: 'sk' } });
+ expect(screen.getByTestId('sk').querySelectorAll('.card').length).toBe(6);
+ });
+
+ it('is decorative (aria-hidden)', () => {
+ render(ShelfSkeleton, { props: { testid: 'sk' } });
+ expect(screen.getByTestId('sk').getAttribute('aria-hidden')).toBe('true');
+ });
+});
diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte
index 29bf43d..8cfe6c6 100644
--- a/frontend/src/routes/+page.svelte
+++ b/frontend/src/routes/+page.svelte
@@ -31,6 +31,8 @@
import Chip from '$lib/components/Chip.svelte';
import ContinueReadingShelf from '$lib/components/ContinueReadingShelf.svelte';
import RecommendationShelf from '$lib/components/RecommendationShelf.svelte';
+ import ShelfSkeleton from '$lib/components/ShelfSkeleton.svelte';
+ import { session } from '$lib/session.svelte';
import MangaCard from '$lib/components/MangaCard.svelte';
import MangaGridSkeleton from '$lib/components/MangaGridSkeleton.svelte';
import Pager from '$lib/components/Pager.svelte';
@@ -48,6 +50,10 @@
let mangas: MangaCardData[] = $state([]);
let continueEntries = $state([]);
let recommendations = $state([]);
+ // True while the personal shelves are being fetched. Drives a reserved
+ // ShelfSkeleton (for logged-in users) so the catalog below isn't shoved
+ // down when the shelves resolve.
+ let shelvesLoading = $state(false);
let search = $state('');
let sort = $state(DEFAULT_SORT);
let order = $state(defaultOrderFor(DEFAULT_SORT));
@@ -311,26 +317,31 @@
// Filter UI still loads with an empty genre list rather than blocking.
}
await hydrateFromUrl();
+ // Fetch the personal shelves concurrently with the catalogue (rather
+ // than after it) so their reserved skeleton and the grid resolve
+ // together instead of the shelves popping in late and shoving the grid
+ // down. The `/me/*` calls are isolated: a failure (or 401 for guests)
+ // hides its own shelf via the *OrEmpty wrappers without touching the
+ // catalogue or the other shelf.
+ shelvesLoading = true;
+ const shelves = Promise.allSettled([listMyReadProgressOrEmpty(), listMyRecommendations()])
+ .then(([progressResult, recsResult]) => {
+ if (progressResult.status === 'fulfilled') {
+ // Drop finished series (read to the end, nothing new) — a
+ // "Continue reading" shelf is for what's still in progress.
+ continueEntries = progressResult.value.items.filter((e) => !isCaughtUp(e));
+ }
+ if (recsResult.status === 'fulfilled') {
+ // Personal "Recommended for you" feed (empty for guests / no
+ // taste signals yet → shelf hidden).
+ recommendations = recsResult.value;
+ }
+ })
+ .finally(() => {
+ shelvesLoading = false;
+ });
await load();
- // Fetch the personal shelves after the catalogue so the public browse
- // path stays unauthenticated and unblocked. Both are independent
- // `/me/*` calls, so fire them concurrently rather than in series.
- // Each is isolated: a failure (or 401 for guests) hides its own shelf
- // without touching the catalogue or the other shelf.
- const [progressResult, recsResult] = await Promise.allSettled([
- listMyReadProgressOrEmpty(),
- listMyRecommendations()
- ]);
- if (progressResult.status === 'fulfilled') {
- // Drop finished series (read to the end, nothing new) — a
- // "Continue reading" shelf is for what's still in progress.
- continueEntries = progressResult.value.items.filter((e) => !isCaughtUp(e));
- }
- if (recsResult.status === 'fulfilled') {
- // Personal "Recommended for you" feed (empty for guests / no
- // taste signals yet → shelf hidden).
- recommendations = recsResult.value;
- }
+ await shelves;
});
// Track viewport in a separate $effect so the listener cleans up on
@@ -485,12 +496,18 @@
-{#if continueEntries.length > 0}
-
-{/if}
+{#if shelvesLoading && session.user}
+
+
+{:else}
+ {#if continueEntries.length > 0}
+
+ {/if}
-{#if recommendations.length > 0}
-
+ {#if recommendations.length > 0}
+
+ {/if}
{/if}