diff --git a/src/lib/server/recipes/search-local.ts b/src/lib/server/recipes/search-local.ts index 6c7df08..b006103 100644 --- a/src/lib/server/recipes/search-local.ts +++ b/src/lib/server/recipes/search-local.ts @@ -29,7 +29,8 @@ function buildFtsQuery(q: string): string | null { export function searchLocal( db: Database.Database, query: string, - limit = 30 + limit = 30, + offset = 0 ): SearchHit[] { const fts = buildFtsQuery(query); if (!fts) return []; @@ -48,9 +49,9 @@ export function searchLocal( JOIN recipe_fts f ON f.rowid = r.id WHERE recipe_fts MATCH ? ORDER BY bm25(recipe_fts, 10.0, 0.5, 2.0, 5.0) - LIMIT ?` + LIMIT ? OFFSET ?` ) - .all(fts, limit) as SearchHit[]; + .all(fts, limit, offset) as SearchHit[]; } export function listRecentRecipes( diff --git a/src/lib/server/search/searxng.ts b/src/lib/server/search/searxng.ts index f171e28..09b2062 100644 --- a/src/lib/server/search/searxng.ts +++ b/src/lib/server/search/searxng.ts @@ -251,7 +251,12 @@ async function enrichAllThumbnails( export async function searchWeb( db: Database.Database, query: string, - opts: { searxngUrl?: string; limit?: number; enrichThumbnails?: boolean } = {} + opts: { + searxngUrl?: string; + limit?: number; + enrichThumbnails?: boolean; + pageno?: number; + } = {} ): Promise { const trimmed = query.trim(); if (!trimmed) return []; @@ -260,12 +265,14 @@ export async function searchWeb( const searxngUrl = opts.searxngUrl ?? process.env.SEARXNG_URL ?? 'http://localhost:8888'; const limit = opts.limit ?? 20; + const pageno = Math.max(1, opts.pageno ?? 1); const siteFilter = domains.map((d) => `site:${d}`).join(' OR '); const q = `${trimmed} (${siteFilter})`; const endpoint = new URL('/search', searxngUrl); endpoint.searchParams.set('q', q); endpoint.searchParams.set('format', 'json'); endpoint.searchParams.set('language', 'de'); + if (pageno > 1) endpoint.searchParams.set('pageno', String(pageno)); const body = await fetchText(endpoint.toString(), { timeoutMs: 15_000, diff --git a/src/routes/api/recipes/search/+server.ts b/src/routes/api/recipes/search/+server.ts index 83d554b..ffdea32 100644 --- a/src/routes/api/recipes/search/+server.ts +++ b/src/routes/api/recipes/search/+server.ts @@ -6,6 +6,12 @@ import { listRecentRecipes, searchLocal } from '$lib/server/recipes/search-local export const GET: RequestHandler = async ({ url }) => { const q = url.searchParams.get('q')?.trim() ?? ''; const limit = Math.min(Number(url.searchParams.get('limit') ?? 30), 100); - const hits = q.length >= 1 ? searchLocal(getDb(), q, limit) : listRecentRecipes(getDb(), limit); + const offset = Math.max(0, Number(url.searchParams.get('offset') ?? 0)); + const hits = + q.length >= 1 + ? searchLocal(getDb(), q, limit, offset) + : offset === 0 + ? listRecentRecipes(getDb(), limit) + : []; return json({ query: q, hits }); }; diff --git a/src/routes/api/recipes/search/web/+server.ts b/src/routes/api/recipes/search/web/+server.ts index f0ba7b7..813553e 100644 --- a/src/routes/api/recipes/search/web/+server.ts +++ b/src/routes/api/recipes/search/web/+server.ts @@ -6,9 +6,10 @@ import { searchWeb } from '$lib/server/search/searxng'; export const GET: RequestHandler = async ({ url }) => { const q = url.searchParams.get('q')?.trim() ?? ''; if (!q) error(400, { message: 'Missing ?q=' }); + const pageno = Math.max(1, Math.min(10, Number(url.searchParams.get('pageno') ?? 1))); try { - const hits = await searchWeb(getDb(), q); - return json({ query: q, hits }); + const hits = await searchWeb(getDb(), q, { pageno }); + return json({ query: q, pageno, hits }); } catch (e) { error(502, { message: `Web search unavailable: ${(e as Error).message}` }); } diff --git a/src/routes/search/+page.svelte b/src/routes/search/+page.svelte index 2e414a0..47c4979 100644 --- a/src/routes/search/+page.svelte +++ b/src/routes/search/+page.svelte @@ -3,9 +3,13 @@ import { goto } from '$app/navigation'; import type { SearchHit } from '$lib/server/recipes/search-local'; + const PAGE_SIZE = 30; + let query = $state(($page.url.searchParams.get('q') ?? '').trim()); let hits = $state([]); let loading = $state(false); + let loadingMore = $state(false); + let exhausted = $state(false); let searched = $state(false); let canWebSearch = $state(false); @@ -13,9 +17,13 @@ loading = true; searched = true; canWebSearch = true; - const res = await fetch(`/api/recipes/search?q=${encodeURIComponent(q)}`); + exhausted = false; + const res = await fetch( + `/api/recipes/search?q=${encodeURIComponent(q)}&limit=${PAGE_SIZE}` + ); const body = await res.json(); hits = body.hits; + exhausted = hits.length < PAGE_SIZE; loading = false; if (hits.length === 0) { // Kein lokaler Treffer → automatisch im Internet weitersuchen. @@ -24,6 +32,25 @@ } } + async function loadMore() { + if (loadingMore || exhausted || !query) return; + loadingMore = true; + try { + const res = await fetch( + `/api/recipes/search?q=${encodeURIComponent(query)}&limit=${PAGE_SIZE}&offset=${hits.length}` + ); + const body = await res.json(); + const more = body.hits as SearchHit[]; + // Gegen Doppel-Treffer absichern (z.B. Race oder identisches bm25-Scoring). + const seen = new Set(hits.map((h) => h.id)); + const deduped = more.filter((h) => !seen.has(h.id)); + hits = [...hits, ...deduped]; + if (more.length < PAGE_SIZE) exhausted = true; + } finally { + loadingMore = false; + } + } + $effect(() => { const q = ($page.url.searchParams.get('q') ?? '').trim(); query = q; @@ -80,6 +107,13 @@ {/each} + {#if !exhausted} +
+ +
+ {/if} {#if canWebSearch}
@@ -173,6 +207,27 @@ font-size: 0.8rem; flex-wrap: wrap; } + .more-cta { + margin-top: 1rem; + text-align: center; + } + .more-btn { + padding: 0.7rem 1.25rem; + background: white; + color: #2b6a3d; + border: 1px solid #cfd9d1; + border-radius: 10px; + font-size: 0.95rem; + min-height: 44px; + cursor: pointer; + } + .more-btn:hover:not(:disabled) { + background: #f4f8f5; + } + .more-btn:disabled { + opacity: 0.6; + cursor: progress; + } .web-cta { margin-top: 1.25rem; text-align: center; diff --git a/src/routes/search/web/+page.svelte b/src/routes/search/web/+page.svelte index ac83494..daca831 100644 --- a/src/routes/search/web/+page.svelte +++ b/src/routes/search/web/+page.svelte @@ -6,6 +6,9 @@ let query = $state(($page.url.searchParams.get('q') ?? '').trim()); let hits = $state([]); let loading = $state(false); + let loadingMore = $state(false); + let exhausted = $state(false); + let pageno = $state(1); let errored = $state(null); let searched = $state(false); @@ -13,23 +16,56 @@ loading = true; searched = true; errored = null; + pageno = 1; + exhausted = false; try { const res = await fetch(`/api/recipes/search/web?q=${encodeURIComponent(q)}`); if (!res.ok) { const body = await res.json().catch(() => ({})); errored = body.message ?? `HTTP ${res.status}`; hits = []; + exhausted = true; } else { const body = await res.json(); hits = body.hits; + if (hits.length === 0) exhausted = true; } } catch (e) { errored = (e as Error).message; + exhausted = true; } finally { loading = false; } } + async function loadMore() { + if (loadingMore || exhausted || !query) return; + loadingMore = true; + try { + const next = pageno + 1; + const res = await fetch( + `/api/recipes/search/web?q=${encodeURIComponent(query)}&pageno=${next}` + ); + if (!res.ok) { + exhausted = true; + return; + } + const body = await res.json(); + const more = body.hits as WebHit[]; + // Dedup über URL — SearXNG kann dasselbe Ergebnis auf zwei Seiten liefern. + const seen = new Set(hits.map((h) => h.url)); + const deduped = more.filter((h) => !seen.has(h.url)); + if (deduped.length === 0) { + exhausted = true; + } else { + hits = [...hits, ...deduped]; + pageno = next; + } + } finally { + loadingMore = false; + } + } + $effect(() => { const q = ($page.url.searchParams.get('q') ?? '').trim(); query = q; @@ -93,6 +129,13 @@ {/each} + {#if !exhausted} +
+ +
+ {/if} {/if} diff --git a/tests/integration/search-local.test.ts b/tests/integration/search-local.test.ts index d0e07da..65f2c51 100644 --- a/tests/integration/search-local.test.ts +++ b/tests/integration/search-local.test.ts @@ -68,6 +68,20 @@ describe('searchLocal', () => { expect(searchLocal(db, ' ')).toEqual([]); }); + it('paginates via limit + offset', () => { + const db = openInMemoryForTest(); + for (let i = 0; i < 5; i++) { + insertRecipe(db, recipe({ title: `Pizza ${i}` })); + } + const first = searchLocal(db, 'pizza', 2, 0); + const second = searchLocal(db, 'pizza', 2, 2); + expect(first.length).toBe(2); + expect(second.length).toBe(2); + // No overlap between pages + const firstIds = new Set(first.map((h) => h.id)); + for (const h of second) expect(firstIds.has(h.id)).toBe(false); + }); + it('aggregates avg_stars across profiles', () => { const db = openInMemoryForTest(); const id = insertRecipe(db, recipe({ title: 'Rated' })); diff --git a/tests/integration/searxng.test.ts b/tests/integration/searxng.test.ts index b7d3016..476c1bd 100644 --- a/tests/integration/searxng.test.ts +++ b/tests/integration/searxng.test.ts @@ -72,6 +72,34 @@ describe('searchWeb', () => { expect(hits).toEqual([]); }); + it('passes pageno to SearXNG when > 1', async () => { + const db = openInMemoryForTest(); + addDomain(db, 'chefkoch.de'); + let receivedPageno: string | null = 'not set'; + server.on('request', (req, res) => { + const u = new URL(req.url ?? '/', 'http://localhost'); + receivedPageno = u.searchParams.get('pageno'); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ results: [] })); + }); + await searchWeb(db, 'x', { searxngUrl: baseUrl, enrichThumbnails: false, pageno: 3 }); + expect(receivedPageno).toBe('3'); + }); + + it('omits pageno param when 1', async () => { + const db = openInMemoryForTest(); + addDomain(db, 'chefkoch.de'); + let receivedPageno: string | null = 'not set'; + server.on('request', (req, res) => { + const u = new URL(req.url ?? '/', 'http://localhost'); + receivedPageno = u.searchParams.get('pageno'); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ results: [] })); + }); + await searchWeb(db, 'x', { searxngUrl: baseUrl, enrichThumbnails: false }); + expect(receivedPageno).toBe(null); + }); + it('enriches missing thumbnails from og:image', async () => { const pageServer = createServer((_req, res) => { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });