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:
MechaCat02
2026-07-07 07:14:43 +02:00
parent f1e66141f4
commit 5b51bcf056
8 changed files with 262 additions and 107 deletions

View 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);
});

View File

@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
"version": "0.118.1",
"version": "0.119.0",
"private": true,
"type": "module",
"scripts": {

View 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>

View File

@@ -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');
});
});

View File

@@ -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 @@
</section>
{#if data.contentSearch}
{#if data.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)}
<TaggedPageRow
item={{ ...p, tag: '', tagged_at: '' }}
showTagPill={false}
testid={`search-result-${p.page_id}`}
/>
{/each}
</ul>
{/if}
{#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 r.results as p (p.page_id)}
<TaggedPageRow
item={{ ...p, tag: '', tagged_at: '' }}
showTagPill={false}
testid={`search-result-${p.page_id}`}
/>
{/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,53 +285,57 @@
</div>
{/if}
{#if data.view === 'pages'}
{#if data.pages.length === 0}
<p class="hint" data-testid="search-pages-empty">
No pages tagged with "{data.tag}".
</p>
{#await data.results}
<SearchResultsSkeleton />
{:then r}
{#if data.view === 'pages'}
{#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 r.pages as p (p.page_id)}
<TaggedPageRow
item={p}
showTagPill={false}
testid={`search-page-row-${p.page_id}`}
/>
{/each}
</ul>
{/if}
{:else if data.view === 'chapters'}
{#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 r.chapters as c (c.chapter_id)}
<TaggedChapterRow
item={c}
testid={`search-chapter-row-${c.chapter_id}`}
/>
{/each}
</ul>
{/if}
{:else}
<ul class="list" data-testid="search-pages-list">
{#each data.pages as p (p.page_id)}
<TaggedPageRow
item={p}
showTagPill={false}
testid={`search-page-row-${p.page_id}`}
/>
{/each}
</ul>
{#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 r.mangas as m (m.manga_id)}
<TaggedMangaRow
item={m}
testid={`search-manga-row-${m.manga_id}`}
/>
{/each}
</ul>
{/if}
{/if}
{:else if data.view === 'chapters'}
{#if data.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)}
<TaggedChapterRow
item={c}
testid={`search-chapter-row-${c.chapter_id}`}
/>
{/each}
</ul>
{/if}
{:else}
{#if data.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)}
<TaggedMangaRow
item={m}
testid={`search-manga-row-${m.manga_id}`}
/>
{/each}
</ul>
{/if}
{/if}
{/await}
{/if}
{/if}
{/if}

View File

@@ -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,55 +82,53 @@ 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);
// Content search takes precedence over tag browsing.
if (contentSearch) {
const r = await searchPages({
text: text || undefined,
cwInclude,
cwExclude,
limit: 100
});
return { ...empty, distinct, results: r.items, total: r.page.total ?? 0 };
}
// No tag selected → just the chip cloud.
if (!tag) return { ...empty, distinct };
if (view === 'chapters') {
const r = await listTaggedChapters({ tag, order, limit: 100 });
return {
...empty,
distinct,
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
};
}
const r = await listMyPageTags({ tag, limit: 100 });
return {
...empty,
distinct,
pages: r.items,
total: r.page.total ?? 0
};
distinct = await listMyDistinctPageTags(undefined, 100);
} catch (e) {
if (e instanceof ApiError && e.status === 401) {
return { ...empty, authenticated: false };
return { ...base, authenticated: false, results: Promise.resolve(emptyResults) };
}
if (e instanceof ApiError) {
return { ...empty, error: e.message };
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({
text: text || undefined,
cwInclude,
cwExclude,
limit: 100
});
return { ...emptyResults, results: r.items, total: r.page.total ?? 0 };
}
// 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 { ...emptyResults, chapters: r.items, total: r.page.total ?? 0 };
}
if (view === 'mangas') {
const r = await listTaggedMangas({ tag, order, limit: 100 });
return { ...emptyResults, mangas: r.items, total: r.page.total ?? 0 };
}
const r = await listMyPageTags({ tag, limit: 100 });
return { ...emptyResults, pages: r.items, total: r.page.total ?? 0 };
} catch (e) {
if (e instanceof ApiError) return { ...emptyResults, error: e.message };
throw e;
}
})();
return { ...base, distinct, results };
};