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:
Admin
2026-03-05 14:00:59 +05:00
parent a54d8d43aa
commit 1d00fd4e2e
4 changed files with 189 additions and 7 deletions

View File

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

View File

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