feat(search): add browse text search across local library and novelfire.net
Adds GET /api/search Go endpoint that queries PocketBase books by title/author substring and fetches novelfire.net /search results via parseBrowsePage(), merging results with local-first de-duplication. The browse page gains a search input that navigates to ?q=; results show local vs remote counts and hide pagination/filter controls while in search mode.
This commit is contained in:
@@ -25,10 +25,52 @@ export const load: PageServerLoad = async ({ url, locals }) => {
|
||||
const genre = url.searchParams.get('genre') ?? 'all';
|
||||
const sort = url.searchParams.get('sort') ?? 'popular';
|
||||
const status = url.searchParams.get('status') ?? 'all';
|
||||
const q = url.searchParams.get('q') ?? '';
|
||||
|
||||
let novels: NovelListing[] = [];
|
||||
let pageNum = parseInt(page, 10) || 1;
|
||||
let hasNext = false;
|
||||
let searchQuery = '';
|
||||
let searchLocalCount = 0;
|
||||
let searchRemoteCount = 0;
|
||||
|
||||
// ── Search mode: ?q= overrides browse/ranking ─────────────────────────
|
||||
if (q.trim().length >= 2) {
|
||||
searchQuery = q.trim();
|
||||
const apiURL = `${SCRAPER_URL}/api/search?q=${encodeURIComponent(searchQuery)}`;
|
||||
try {
|
||||
const res = await fetch(apiURL);
|
||||
if (!res.ok) {
|
||||
log.error('browse', 'search returned error', { status: res.status });
|
||||
throw error(502, `Search failed: ${res.status}`);
|
||||
}
|
||||
const data: {
|
||||
results: NovelListing[];
|
||||
local_count: number;
|
||||
remote_count: number;
|
||||
} = await res.json();
|
||||
novels = data.results ?? [];
|
||||
searchLocalCount = data.local_count ?? 0;
|
||||
searchRemoteCount = data.remote_count ?? 0;
|
||||
} catch (e) {
|
||||
if (e instanceof Error && 'status' in e) throw e;
|
||||
log.error('browse', 'search network error', { q: searchQuery, err: String(e) });
|
||||
throw error(502, 'Could not reach search service');
|
||||
}
|
||||
|
||||
return {
|
||||
novels,
|
||||
page: 1,
|
||||
hasNext: false,
|
||||
genre,
|
||||
sort,
|
||||
status,
|
||||
isAdmin: locals.user?.role === 'admin',
|
||||
searchQuery,
|
||||
searchLocalCount,
|
||||
searchRemoteCount
|
||||
};
|
||||
}
|
||||
|
||||
if (sort === 'rank') {
|
||||
// Ranking view: fetch from /api/ranking which returns richer metadata.
|
||||
@@ -99,7 +141,10 @@ export const load: PageServerLoad = async ({ url, locals }) => {
|
||||
genre,
|
||||
sort,
|
||||
status,
|
||||
isAdmin: locals.user?.role === 'admin'
|
||||
isAdmin: locals.user?.role === 'admin',
|
||||
searchQuery: '',
|
||||
searchLocalCount: 0,
|
||||
searchRemoteCount: 0
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
// When sort=rank the ranking API is used — pagination + genre/status filters
|
||||
// don't apply to that endpoint.
|
||||
const isRankView = $derived(data.sort === 'rank');
|
||||
const isSearchView = $derived(!!data.searchQuery);
|
||||
|
||||
function buildURL(overrides: Record<string, string | number>) {
|
||||
const params = new URLSearchParams({
|
||||
@@ -96,7 +97,12 @@
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-zinc-100">Discover</h1>
|
||||
<p class="text-zinc-400 text-sm mt-1">
|
||||
{#if isRankView}
|
||||
{#if isSearchView}
|
||||
{data.novels.length} result{data.novels.length !== 1 ? 's' : ''} for "<span class="text-zinc-200">{data.searchQuery}</span>"
|
||||
{#if data.searchLocalCount > 0 || data.searchRemoteCount > 0}
|
||||
<span class="text-zinc-500 text-xs ml-1">({data.searchLocalCount} local, {data.searchRemoteCount} from novelfire)</span>
|
||||
{/if}
|
||||
{:else if isRankView}
|
||||
{#if data.novels.length > 0}
|
||||
{data.novels.length} novels ranked from last catalogue scrape
|
||||
{:else}
|
||||
@@ -105,8 +111,7 @@
|
||||
{:else}
|
||||
Browse novels from novelfire.net
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</p> </div>
|
||||
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<!-- View toggle -->
|
||||
@@ -181,6 +186,31 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Search -->
|
||||
<form method="GET" action="/browse" class="flex gap-2 mb-4">
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
value={data.searchQuery}
|
||||
placeholder="Search novels by title or author…"
|
||||
class="flex-1 bg-zinc-800 border border-zinc-700 text-zinc-200 text-sm rounded px-3 py-1.5 focus:outline-none focus:border-amber-400 placeholder-zinc-500"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-1.5 rounded bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors whitespace-nowrap"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
{#if data.searchQuery}
|
||||
<a
|
||||
href="/browse"
|
||||
class="px-3 py-1.5 rounded bg-zinc-700 text-zinc-300 text-sm hover:bg-zinc-600 transition-colors whitespace-nowrap"
|
||||
>
|
||||
Clear
|
||||
</a>
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
<!-- Filters -->
|
||||
<form method="GET" action="/browse" class="flex flex-wrap gap-3 mb-6">
|
||||
<input type="hidden" name="page" value="1" />
|
||||
@@ -232,9 +262,11 @@
|
||||
<!-- Content -->
|
||||
{#if data.novels.length === 0}
|
||||
<div class="text-center py-20 text-zinc-500">
|
||||
<p class="text-lg">{isRankView ? 'No ranking data.' : 'No novels found.'}</p>
|
||||
<p class="text-lg">{isSearchView ? 'No results found.' : isRankView ? 'No ranking data.' : 'No novels found.'}</p>
|
||||
<p class="text-sm mt-2">
|
||||
{#if isRankView}
|
||||
{#if isSearchView}
|
||||
Try a different search term.
|
||||
{:else if isRankView}
|
||||
{#if data.isAdmin}
|
||||
Click <span class="text-amber-400">Refresh catalogue</span> above to trigger a full catalogue scrape.
|
||||
{:else}
|
||||
@@ -417,7 +449,7 @@
|
||||
{/if}
|
||||
|
||||
<!-- Pagination (browse mode only) -->
|
||||
{#if !isRankView && data.novels.length > 0}
|
||||
{#if !isRankView && !isSearchView && data.novels.length > 0}
|
||||
<div class="flex items-center justify-center gap-3 mt-8">
|
||||
{#if data.page > 1}
|
||||
<a
|
||||
|
||||
Reference in New Issue
Block a user