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}