From 16cdec051ac1bbc8ab76f4353053e7d4b5a4d4a1 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Tue, 7 Jul 2026 07:18:43 +0200 Subject: [PATCH] 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) --- backend/Cargo.lock | 2 +- backend/Cargo.toml | 2 +- frontend/e2e/bookmarks-skeleton.spec.ts | 57 +++++++++++++++++++ frontend/package.json | 2 +- .../src/lib/components/ListRowSkeleton.svelte | 49 ++++++++++++++++ .../components/ListRowSkeleton.svelte.test.ts | 25 ++++++++ frontend/src/routes/bookmarks/+page.svelte | 34 +++++------ frontend/src/routes/bookmarks/+page.ts | 40 +++++++------ frontend/src/routes/collections/+page.svelte | 34 ++++++----- frontend/src/routes/collections/+page.ts | 30 ++++++---- 10 files changed, 212 insertions(+), 63 deletions(-) create mode 100644 frontend/e2e/bookmarks-skeleton.spec.ts create mode 100644 frontend/src/lib/components/ListRowSkeleton.svelte create mode 100644 frontend/src/lib/components/ListRowSkeleton.svelte.test.ts diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 0d51ccb..a56bb91 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.119.0" +version = "0.120.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 0492bf5..ef2f1f7 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.119.0" +version = "0.120.0" edition = "2021" default-run = "mangalord" diff --git a/frontend/e2e/bookmarks-skeleton.spec.ts b/frontend/e2e/bookmarks-skeleton.spec.ts new file mode 100644 index 0000000..d652e1c --- /dev/null +++ b/frontend/e2e/bookmarks-skeleton.spec.ts @@ -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((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); +}); diff --git a/frontend/package.json b/frontend/package.json index c2a54d9..246822a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.119.0", + "version": "0.120.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/components/ListRowSkeleton.svelte b/frontend/src/lib/components/ListRowSkeleton.svelte new file mode 100644 index 0000000..a8444dd --- /dev/null +++ b/frontend/src/lib/components/ListRowSkeleton.svelte @@ -0,0 +1,49 @@ + + + + + diff --git a/frontend/src/lib/components/ListRowSkeleton.svelte.test.ts b/frontend/src/lib/components/ListRowSkeleton.svelte.test.ts new file mode 100644 index 0000000..792ff22 --- /dev/null +++ b/frontend/src/lib/components/ListRowSkeleton.svelte.test.ts @@ -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'); + }); +}); diff --git a/frontend/src/routes/bookmarks/+page.svelte b/frontend/src/routes/bookmarks/+page.svelte index 571d615..2bf3a91 100644 --- a/frontend/src/routes/bookmarks/+page.svelte +++ b/frontend/src/routes/bookmarks/+page.svelte @@ -1,27 +1,29 @@

Bookmarks

-{#if error} - -{:else if !authenticated} -

- Sign in to see your bookmarks. -

-{:else if bookmarks.length === 0} -

No bookmarks yet.

-{:else} - -{/if} +{#await data.result} + +{:then r} + {#if r.error} + + {:else if !r.authenticated} +

+ Sign in to see your bookmarks. +

+ {:else if r.bookmarks.length === 0} +

No bookmarks yet.

+ {:else} + + {/if} +{/await}