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:
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,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}
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user