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) <noreply@anthropic.com>
This commit is contained in:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mangalord"
|
||||
version = "0.118.1"
|
||||
version = "0.119.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mangalord"
|
||||
version = "0.118.1"
|
||||
version = "0.119.0"
|
||||
edition = "2021"
|
||||
default-run = "mangalord"
|
||||
|
||||
|
||||
74
frontend/e2e/search-skeleton.spec.ts
Normal file
74
frontend/e2e/search-skeleton.spec.ts
Normal file
@@ -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<void>((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);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.118.1",
|
||||
"version": "0.119.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
48
frontend/src/lib/components/SearchResultsSkeleton.svelte
Normal file
48
frontend/src/lib/components/SearchResultsSkeleton.svelte
Normal file
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import Skeleton from './Skeleton.svelte';
|
||||
|
||||
/**
|
||||
* Placeholder for a tagged-page / chapter / manga results list while a
|
||||
* search re-queries. Mirrors the TaggedPageRow shape (56px 2/3 cover +
|
||||
* two text lines) so the list keeps its height as results swap in.
|
||||
*/
|
||||
let { count = 6, testid = 'search-results-skeleton' }: { count?: number; testid?: string } =
|
||||
$props();
|
||||
</script>
|
||||
|
||||
<ul class="list" data-testid={testid} aria-hidden="true">
|
||||
{#each Array(count) as _}
|
||||
<li class="row">
|
||||
<Skeleton width="56px" aspectRatio="2 / 3" radius="sm" />
|
||||
<div class="meta">
|
||||
<Skeleton variant="text" width="60%" radius="sm" />
|
||||
<Skeleton variant="text" width="40%" radius="sm" />
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<style>
|
||||
.list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 56px 1fr;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,13 +177,16 @@
|
||||
</section>
|
||||
|
||||
{#if data.contentSearch}
|
||||
{#if data.results.length === 0}
|
||||
{#await data.results}
|
||||
<SearchResultsSkeleton />
|
||||
{:then r}
|
||||
{#if r.results.length === 0}
|
||||
<p class="hint" data-testid="search-content-empty">
|
||||
No pages match this search.
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="list" data-testid="search-content-list">
|
||||
{#each data.results as p (p.page_id)}
|
||||
{#each r.results as p (p.page_id)}
|
||||
<TaggedPageRow
|
||||
item={{ ...p, tag: '', tagged_at: '' }}
|
||||
showTagPill={false}
|
||||
@@ -191,6 +195,7 @@
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/await}
|
||||
{:else}
|
||||
<!-- Tag filter. When a tag is selected it's shown as a chip with
|
||||
an x to clear; when none is, the input doubles as the entry
|
||||
@@ -280,14 +285,17 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#await data.results}
|
||||
<SearchResultsSkeleton />
|
||||
{:then r}
|
||||
{#if data.view === 'pages'}
|
||||
{#if data.pages.length === 0}
|
||||
{#if r.pages.length === 0}
|
||||
<p class="hint" data-testid="search-pages-empty">
|
||||
No pages tagged with "{data.tag}".
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="list" data-testid="search-pages-list">
|
||||
{#each data.pages as p (p.page_id)}
|
||||
{#each r.pages as p (p.page_id)}
|
||||
<TaggedPageRow
|
||||
item={p}
|
||||
showTagPill={false}
|
||||
@@ -297,13 +305,13 @@
|
||||
</ul>
|
||||
{/if}
|
||||
{:else if data.view === 'chapters'}
|
||||
{#if data.chapters.length === 0}
|
||||
{#if r.chapters.length === 0}
|
||||
<p class="hint" data-testid="search-chapters-empty">
|
||||
No chapters contain pages tagged with "{data.tag}".
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="list" data-testid="search-chapters-list">
|
||||
{#each data.chapters as c (c.chapter_id)}
|
||||
{#each r.chapters as c (c.chapter_id)}
|
||||
<TaggedChapterRow
|
||||
item={c}
|
||||
testid={`search-chapter-row-${c.chapter_id}`}
|
||||
@@ -312,13 +320,13 @@
|
||||
</ul>
|
||||
{/if}
|
||||
{:else}
|
||||
{#if data.mangas.length === 0}
|
||||
{#if r.mangas.length === 0}
|
||||
<p class="hint" data-testid="search-mangas-empty">
|
||||
No mangas contain pages tagged with "{data.tag}".
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="list" data-testid="search-mangas-list">
|
||||
{#each data.mangas as m (m.manga_id)}
|
||||
{#each r.mangas as m (m.manga_id)}
|
||||
<TaggedMangaRow
|
||||
item={m}
|
||||
testid={`search-manga-row-${m.manga_id}`}
|
||||
@@ -327,6 +335,7 @@
|
||||
</ul>
|
||||
{/if}
|
||||
{/if}
|
||||
{/await}
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -60,7 +60,7 @@ export const load: PageLoad = async ({ url }) => {
|
||||
// backend; cw_exclude alone can't drive a search.
|
||||
const contentSearch = text !== '' || cwInclude.length > 0;
|
||||
|
||||
const empty = {
|
||||
const base = {
|
||||
authenticated: true,
|
||||
tag,
|
||||
view,
|
||||
@@ -70,6 +70,10 @@ export const load: PageLoad = async ({ url }) => {
|
||||
cwExclude,
|
||||
contentSearch,
|
||||
distinct: [] as PageTagSummary[],
|
||||
error: null as string | null
|
||||
};
|
||||
|
||||
const emptyResults = {
|
||||
pages: [] as TaggedPageItem[],
|
||||
chapters: [] as TaggedChapterAggregate[],
|
||||
mangas: [] as TaggedMangaAggregate[],
|
||||
@@ -78,9 +82,26 @@ export const load: PageLoad = async ({ url }) => {
|
||||
error: null as string | null
|
||||
};
|
||||
|
||||
// The chip cloud + auth gate come from `distinct`, so it's awaited. A 401
|
||||
// here means "not signed in"; any other error is surfaced inline. The
|
||||
// view-specific results are streamed (see below).
|
||||
let distinct: PageTagSummary[];
|
||||
try {
|
||||
const distinct = await listMyDistinctPageTags(undefined, 100);
|
||||
distinct = await listMyDistinctPageTags(undefined, 100);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 401) {
|
||||
return { ...base, authenticated: false, results: Promise.resolve(emptyResults) };
|
||||
}
|
||||
if (e instanceof ApiError) {
|
||||
return { ...base, error: e.message, results: Promise.resolve(emptyResults) };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Streamed so the results list shows a skeleton while it (re-)queries on
|
||||
// every tag / view / sort / text change instead of freezing the old list.
|
||||
const results = (async () => {
|
||||
try {
|
||||
// Content search takes precedence over tag browsing.
|
||||
if (contentSearch) {
|
||||
const r = await searchPages({
|
||||
@@ -89,44 +110,25 @@ export const load: PageLoad = async ({ url }) => {
|
||||
cwExclude,
|
||||
limit: 100
|
||||
});
|
||||
return { ...empty, distinct, results: r.items, total: r.page.total ?? 0 };
|
||||
return { ...emptyResults, results: r.items, total: r.page.total ?? 0 };
|
||||
}
|
||||
|
||||
// No tag selected → just the chip cloud.
|
||||
if (!tag) return { ...empty, distinct };
|
||||
|
||||
// No tag selected → just the chip cloud, no results.
|
||||
if (!tag) return emptyResults;
|
||||
if (view === 'chapters') {
|
||||
const r = await listTaggedChapters({ tag, order, limit: 100 });
|
||||
return {
|
||||
...empty,
|
||||
distinct,
|
||||
chapters: r.items,
|
||||
total: r.page.total ?? 0
|
||||
};
|
||||
return { ...emptyResults, chapters: r.items, total: r.page.total ?? 0 };
|
||||
}
|
||||
if (view === 'mangas') {
|
||||
const r = await listTaggedMangas({ tag, order, limit: 100 });
|
||||
return {
|
||||
...empty,
|
||||
distinct,
|
||||
mangas: r.items,
|
||||
total: r.page.total ?? 0
|
||||
};
|
||||
return { ...emptyResults, mangas: r.items, total: r.page.total ?? 0 };
|
||||
}
|
||||
const r = await listMyPageTags({ tag, limit: 100 });
|
||||
return {
|
||||
...empty,
|
||||
distinct,
|
||||
pages: r.items,
|
||||
total: r.page.total ?? 0
|
||||
};
|
||||
return { ...emptyResults, pages: r.items, total: r.page.total ?? 0 };
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 401) {
|
||||
return { ...empty, authenticated: false };
|
||||
}
|
||||
if (e instanceof ApiError) {
|
||||
return { ...empty, error: e.message };
|
||||
}
|
||||
if (e instanceof ApiError) return { ...emptyResults, error: e.message };
|
||||
throw e;
|
||||
}
|
||||
})();
|
||||
|
||||
return { ...base, distinct, results };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user