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]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.118.1"
|
version = "0.119.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.118.1"
|
version = "0.119.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
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",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.118.1",
|
"version": "0.119.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"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 TaggedPageRow from '$lib/components/TaggedPageRow.svelte';
|
||||||
import TaggedChapterRow from '$lib/components/TaggedChapterRow.svelte';
|
import TaggedChapterRow from '$lib/components/TaggedChapterRow.svelte';
|
||||||
import TaggedMangaRow from '$lib/components/TaggedMangaRow.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 { CONTENT_WARNINGS, type ContentWarning } from '$lib/api/page_tags';
|
||||||
import X from '@lucide/svelte/icons/x';
|
import X from '@lucide/svelte/icons/x';
|
||||||
|
|
||||||
@@ -176,21 +177,25 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#if data.contentSearch}
|
{#if data.contentSearch}
|
||||||
{#if data.results.length === 0}
|
{#await data.results}
|
||||||
<p class="hint" data-testid="search-content-empty">
|
<SearchResultsSkeleton />
|
||||||
No pages match this search.
|
{:then r}
|
||||||
</p>
|
{#if r.results.length === 0}
|
||||||
{:else}
|
<p class="hint" data-testid="search-content-empty">
|
||||||
<ul class="list" data-testid="search-content-list">
|
No pages match this search.
|
||||||
{#each data.results as p (p.page_id)}
|
</p>
|
||||||
<TaggedPageRow
|
{:else}
|
||||||
item={{ ...p, tag: '', tagged_at: '' }}
|
<ul class="list" data-testid="search-content-list">
|
||||||
showTagPill={false}
|
{#each r.results as p (p.page_id)}
|
||||||
testid={`search-result-${p.page_id}`}
|
<TaggedPageRow
|
||||||
/>
|
item={{ ...p, tag: '', tagged_at: '' }}
|
||||||
{/each}
|
showTagPill={false}
|
||||||
</ul>
|
testid={`search-result-${p.page_id}`}
|
||||||
{/if}
|
/>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
{/await}
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Tag filter. When a tag is selected it's shown as a chip with
|
<!-- 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
|
an x to clear; when none is, the input doubles as the entry
|
||||||
@@ -280,53 +285,57 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if data.view === 'pages'}
|
{#await data.results}
|
||||||
{#if data.pages.length === 0}
|
<SearchResultsSkeleton />
|
||||||
<p class="hint" data-testid="search-pages-empty">
|
{:then r}
|
||||||
No pages tagged with "{data.tag}".
|
{#if data.view === 'pages'}
|
||||||
</p>
|
{#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}
|
{:else}
|
||||||
<ul class="list" data-testid="search-pages-list">
|
{#if r.mangas.length === 0}
|
||||||
{#each data.pages as p (p.page_id)}
|
<p class="hint" data-testid="search-mangas-empty">
|
||||||
<TaggedPageRow
|
No mangas contain pages tagged with "{data.tag}".
|
||||||
item={p}
|
</p>
|
||||||
showTagPill={false}
|
{:else}
|
||||||
testid={`search-page-row-${p.page_id}`}
|
<ul class="list" data-testid="search-mangas-list">
|
||||||
/>
|
{#each r.mangas as m (m.manga_id)}
|
||||||
{/each}
|
<TaggedMangaRow
|
||||||
</ul>
|
item={m}
|
||||||
|
testid={`search-manga-row-${m.manga_id}`}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{:else if data.view === 'chapters'}
|
{/await}
|
||||||
{#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}
|
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export const load: PageLoad = async ({ url }) => {
|
|||||||
// backend; cw_exclude alone can't drive a search.
|
// backend; cw_exclude alone can't drive a search.
|
||||||
const contentSearch = text !== '' || cwInclude.length > 0;
|
const contentSearch = text !== '' || cwInclude.length > 0;
|
||||||
|
|
||||||
const empty = {
|
const base = {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
tag,
|
tag,
|
||||||
view,
|
view,
|
||||||
@@ -70,6 +70,10 @@ export const load: PageLoad = async ({ url }) => {
|
|||||||
cwExclude,
|
cwExclude,
|
||||||
contentSearch,
|
contentSearch,
|
||||||
distinct: [] as PageTagSummary[],
|
distinct: [] as PageTagSummary[],
|
||||||
|
error: null as string | null
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyResults = {
|
||||||
pages: [] as TaggedPageItem[],
|
pages: [] as TaggedPageItem[],
|
||||||
chapters: [] as TaggedChapterAggregate[],
|
chapters: [] as TaggedChapterAggregate[],
|
||||||
mangas: [] as TaggedMangaAggregate[],
|
mangas: [] as TaggedMangaAggregate[],
|
||||||
@@ -78,55 +82,53 @@ export const load: PageLoad = async ({ url }) => {
|
|||||||
error: null as string | null
|
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 {
|
try {
|
||||||
const distinct = await listMyDistinctPageTags(undefined, 100);
|
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
|
|
||||||
};
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ApiError && e.status === 401) {
|
if (e instanceof ApiError && e.status === 401) {
|
||||||
return { ...empty, authenticated: false };
|
return { ...base, authenticated: false, results: Promise.resolve(emptyResults) };
|
||||||
}
|
}
|
||||||
if (e instanceof ApiError) {
|
if (e instanceof ApiError) {
|
||||||
return { ...empty, error: e.message };
|
return { ...base, error: e.message, results: Promise.resolve(emptyResults) };
|
||||||
}
|
}
|
||||||
throw e;
|
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