diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 8ee1d71..0d51ccb 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.118.1" +version = "0.119.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index b1a8515..0492bf5 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.118.1" +version = "0.119.0" edition = "2021" default-run = "mangalord" diff --git a/frontend/e2e/search-skeleton.spec.ts b/frontend/e2e/search-skeleton.spec.ts new file mode 100644 index 0000000..7c8bf68 --- /dev/null +++ b/frontend/e2e/search-skeleton.spec.ts @@ -0,0 +1,74 @@ +import { test, expect, type Page } from './fixtures'; + +// The search page streams its results: the chip cloud / controls stay put +// while a re-query shows a results skeleton, which then swaps for the list. + +const mangaId = 'a9999999-9999-9999-9999-999999999999'; +const chapterId = 'c9999999-9999-9999-9999-999999999999'; +const pageId = 'p11111111-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: 'tester', 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/me/page-tags/distinct*', (r) => + r.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ items: [{ tag: 'funny', count: 38 }] }) + }) + ); + await page.route('**/api/v1/files/**', (r) => r.fulfill({ status: 200, body: '' })); +} + +test('shows a results skeleton while the tagged pages 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/page-tags?**', async (route) => { + await gate; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + items: [ + { + tag: 'funny', page_id: pageId, chapter_id: chapterId, manga_id: mangaId, + page_number: 5, chapter_number: 1, chapter_title: null, manga_title: 'Berserk', + storage_key: `mangas/${mangaId}/chapters/${chapterId}/pages/0005.png`, + tagged_at: '2026-01-01T00:00:00Z' + } + ], + page: { limit: 100, offset: 0, total: 1 } + }) + }); + }); + + await page.goto('/search?tag=funny'); + // The chip cloud / active tag renders immediately (distinct resolved). + await expect(page.getByTestId('search-active-tag')).toBeVisible(); + // Results skeleton while the tagged-pages query is pending. + await expect(page.getByTestId('search-results-skeleton')).toBeVisible(); + await expect(page.getByTestId('search-pages-list')).toHaveCount(0); + + release(); + await expect(page.getByTestId('search-pages-list')).toContainText('Berserk'); + await expect(page.getByTestId('search-results-skeleton')).toHaveCount(0); +}); diff --git a/frontend/package.json b/frontend/package.json index 9e84fd5..c2a54d9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.118.1", + "version": "0.119.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/components/SearchResultsSkeleton.svelte b/frontend/src/lib/components/SearchResultsSkeleton.svelte new file mode 100644 index 0000000..c25b349 --- /dev/null +++ b/frontend/src/lib/components/SearchResultsSkeleton.svelte @@ -0,0 +1,48 @@ + + + + + diff --git a/frontend/src/lib/components/SearchResultsSkeleton.svelte.test.ts b/frontend/src/lib/components/SearchResultsSkeleton.svelte.test.ts new file mode 100644 index 0000000..3ae45a9 --- /dev/null +++ b/frontend/src/lib/components/SearchResultsSkeleton.svelte.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/svelte'; +import SearchResultsSkeleton from './SearchResultsSkeleton.svelte'; + +afterEach(() => cleanup()); + +describe('SearchResultsSkeleton', () => { + it('renders the requested number of row placeholders', () => { + render(SearchResultsSkeleton, { props: { count: 3, testid: 'sk' } }); + expect(screen.getByTestId('sk').querySelectorAll('.row').length).toBe(3); + }); + + it('defaults to 6 rows', () => { + render(SearchResultsSkeleton, { props: { testid: 'sk' } }); + expect(screen.getByTestId('sk').querySelectorAll('.row').length).toBe(6); + }); + + it('is decorative (aria-hidden)', () => { + render(SearchResultsSkeleton, { props: { testid: 'sk' } }); + expect(screen.getByTestId('sk').getAttribute('aria-hidden')).toBe('true'); + }); +}); diff --git a/frontend/src/routes/search/+page.svelte b/frontend/src/routes/search/+page.svelte index ce2b133..1c81311 100644 --- a/frontend/src/routes/search/+page.svelte +++ b/frontend/src/routes/search/+page.svelte @@ -5,6 +5,7 @@ import TaggedPageRow from '$lib/components/TaggedPageRow.svelte'; import TaggedChapterRow from '$lib/components/TaggedChapterRow.svelte'; import TaggedMangaRow from '$lib/components/TaggedMangaRow.svelte'; + import SearchResultsSkeleton from '$lib/components/SearchResultsSkeleton.svelte'; import { CONTENT_WARNINGS, type ContentWarning } from '$lib/api/page_tags'; import X from '@lucide/svelte/icons/x'; @@ -176,21 +177,25 @@ {#if data.contentSearch} - {#if data.results.length === 0} -

- No pages match this search. -

- {:else} - - {/if} + {#await data.results} + + {:then r} + {#if r.results.length === 0} +

+ No pages match this search. +

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