diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 167b8d7..ec54b59 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.117.0" +version = "0.118.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 9a01ffa..a25f41a 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.117.0" +version = "0.118.0" edition = "2021" default-run = "mangalord" diff --git a/frontend/e2e/author-page.spec.ts b/frontend/e2e/author-page.spec.ts new file mode 100644 index 0000000..58a8fe6 --- /dev/null +++ b/frontend/e2e/author-page.spec.ts @@ -0,0 +1,66 @@ +import { test, expect, type Page } from './fixtures'; + +// The author page streams its manga grid: the cheap author header renders +// immediately while the (potentially large) grid shows a skeleton, then swaps +// in without a layout shift. + +const authorId = 'a1111111-1111-1111-1111-111111111111'; + +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: 401, contentType: 'application/json', body: '{"error":{"code":"x","message":"x"}}' }) + ); + await page.route('**/api/v1/auth/me/preferences', (r) => + r.fulfill({ status: 401, contentType: 'application/json', body: '{"error":{"code":"x","message":"x"}}' }) + ); + await page.route(`**/api/v1/authors/${authorId}`, (r) => + r.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: authorId, name: 'Kentaro Miura', manga_count: 1 }) + }) + ); +} + +test('shows a grid skeleton while the author mangas stream, then the grid', async ({ page }) => { + await mockCommon(page); + + let release: () => void = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + await page.route(`**/api/v1/authors/${authorId}/mangas*`, async (route) => { + await gate; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + items: [ + { + id: 'm1', title: 'Berserk', status: 'ongoing', alt_titles: [], description: null, + cover_image_path: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z' + } + ], + page: { limit: 50, offset: 0, total: 1 } + }) + }); + }); + + await page.goto(`/authors/${authorId}`); + // Header renders immediately (not blocked on the grid). + await expect(page.getByTestId('author-name')).toHaveText('Kentaro Miura'); + // Grid skeleton stands in while the stream is pending. + await expect(page.getByTestId('manga-grid-skeleton')).toBeVisible(); + await expect(page.getByTestId('author-manga-list')).toHaveCount(0); + + release(); + await expect(page.getByTestId('author-manga-list')).toContainText('Berserk'); + await expect(page.getByTestId('manga-grid-skeleton')).toHaveCount(0); +}); diff --git a/frontend/e2e/collection-detail-skeleton.spec.ts b/frontend/e2e/collection-detail-skeleton.spec.ts new file mode 100644 index 0000000..cef54de --- /dev/null +++ b/frontend/e2e/collection-detail-skeleton.spec.ts @@ -0,0 +1,67 @@ +import { test, expect, type Page } from './fixtures'; + +// The collection detail page streams its manga/page grids: the header renders +// immediately while a grid skeleton stands in, then the real grid swaps in. + +const collectionId = 'd1111111-1111-1111-1111-111111111111'; + +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/collections/${collectionId}`, (r) => + r.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: collectionId, name: 'Favourites', description: null, manga_count: 1 }) + }) + ); + await page.route(`**/api/v1/collections/${collectionId}/pages*`, (r) => + r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 200, offset: 0, total: 0 } }) }) + ); +} + +test('streams a grid skeleton on the collection detail then the manga grid', async ({ page }) => { + await mockCommon(page); + + let release: () => void = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + await page.route(`**/api/v1/collections/${collectionId}/mangas*`, async (route) => { + await gate; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + items: [ + { id: 'm1', title: 'Berserk', status: 'ongoing', alt_titles: [], description: null, cover_image_path: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z' } + ], + page: { limit: 200, offset: 0, total: 1 } + }) + }); + }); + + await page.goto(`/collections/${collectionId}`); + await expect(page.getByRole('heading', { name: 'Favourites' })).toBeVisible(); + await expect(page.getByTestId('manga-grid-skeleton')).toBeVisible(); + await expect(page.getByTestId('collection-manga-list')).toHaveCount(0); + + release(); + await expect(page.getByTestId('collection-manga-list')).toContainText('Berserk'); + await expect(page.getByTestId('manga-grid-skeleton')).toHaveCount(0); +}); diff --git a/frontend/package.json b/frontend/package.json index 4d21d4d..1668ffd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.117.0", + "version": "0.118.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/routes/authors/[id]/+page.svelte b/frontend/src/routes/authors/[id]/+page.svelte index 1da8cf4..e534a75 100644 --- a/frontend/src/routes/authors/[id]/+page.svelte +++ b/frontend/src/routes/authors/[id]/+page.svelte @@ -1,5 +1,6 @@