From 5b51bcf056fa54b093bc06bf45050f9727c181b7 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Tue, 7 Jul 2026 07:14:43 +0200 Subject: [PATCH] feat: results skeleton on the search page The search loader now awaits only the chip cloud (its auth gate) and streams the view-specific results, so switching tag / view / sort / text shows a SearchResultsSkeleton in place of the frozen previous list until the query resolves. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/Cargo.lock | 2 +- backend/Cargo.toml | 2 +- frontend/e2e/search-skeleton.spec.ts | 74 ++++++++++ frontend/package.json | 2 +- .../components/SearchResultsSkeleton.svelte | 48 +++++++ .../SearchResultsSkeleton.svelte.test.ts | 22 +++ frontend/src/routes/search/+page.svelte | 129 ++++++++++-------- frontend/src/routes/search/+page.ts | 90 ++++++------ 8 files changed, 262 insertions(+), 107 deletions(-) create mode 100644 frontend/e2e/search-skeleton.spec.ts create mode 100644 frontend/src/lib/components/SearchResultsSkeleton.svelte create mode 100644 frontend/src/lib/components/SearchResultsSkeleton.svelte.test.ts 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} -
    - {#each data.results as p (p.page_id)} - - {/each} -
- {/if} + {#await data.results} + + {:then r} + {#if r.results.length === 0} +

+ No pages match this search. +

+ {:else} +
    + {#each r.results as p (p.page_id)} + + {/each} +
+ {/if} + {/await} {:else}