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 @@ + + + + + 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}