Files
libnovel/ui/src/routes/browse/+page.server.ts
Admin 1d00fd4e2e 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.
2026-03-05 14:00:59 +05:00

171 lines
4.9 KiB
TypeScript

import { error } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { env } from '$env/dynamic/private';
import { log } from '$lib/server/logger';
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
export interface NovelListing {
slug: string;
title: string;
cover: string;
rank: string;
rating: string;
chapters: string;
url: string;
// enriched fields (only set when sort=rank)
author?: string;
status?: string;
genres?: string[];
source_url?: string;
}
export const load: PageServerLoad = async ({ url, locals }) => {
const page = url.searchParams.get('page') ?? '1';
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.
// Pagination and filters (genre/status) don't apply here — the ranking
// is a single pre-computed list from the last catalogue scrape.
const apiURL = `${SCRAPER_URL}/api/ranking`;
try {
const res = await fetch(apiURL);
if (!res.ok) {
log.error('browse', 'scraper ranking returned error', { status: res.status });
throw error(502, `Ranking fetch failed: ${res.status}`);
}
const items: Array<{
rank: number;
slug: string;
title: string;
author: string;
cover: string;
status: string;
genres: string[];
source_url: string;
}> = await res.json();
novels = (items ?? []).map((item) => ({
slug: item.slug,
title: item.title,
cover: item.cover,
rank: item.rank != null ? `#${item.rank}` : '',
rating: '',
chapters: '',
url: item.source_url ?? '',
author: item.author,
status: item.status,
genres: item.genres ?? [],
source_url: item.source_url
}));
pageNum = 1;
hasNext = false;
} catch (e) {
if (e instanceof Error && 'status' in e) throw e;
log.error('browse', 'scraper ranking network error', { err: String(e) });
throw error(502, 'Could not load ranking');
}
} else {
// Browse view: paginated catalogue from /api/browse.
const params = new URLSearchParams({ page, genre, sort, status });
const apiURL = `${SCRAPER_URL}/api/browse?${params.toString()}`;
try {
const res = await fetch(apiURL);
if (!res.ok) {
log.error('browse', 'scraper browse returned error', { status: res.status, url: apiURL });
throw error(502, `Browse fetch failed: ${res.status}`);
}
const data: { novels: NovelListing[]; page: number; hasNext: boolean } = await res.json();
novels = data.novels ?? [];
pageNum = data.page ?? 1;
hasNext = data.hasNext ?? false;
} catch (e) {
if (e instanceof Error && 'status' in e) throw e;
log.error('browse', 'scraper browse network error', { url: apiURL, err: String(e) });
throw error(502, 'Could not load browse page');
}
}
return {
novels,
page: pageNum,
hasNext,
genre,
sort,
status,
isAdmin: locals.user?.role === 'admin',
searchQuery: '',
searchLocalCount: 0,
searchRemoteCount: 0
};
};
// Admin action: trigger a full catalogue scrape (refreshes ranking + library).
export const actions: Actions = {
refresh: async ({ locals, fetch }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
try {
const res = await fetch('/api/scrape', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
if (res.status === 409) return { status: 'busy' };
if (!res.ok) return { status: 'error' };
return { status: 'queued' };
} catch {
return { status: 'error' };
}
}
};