feat(search): Domain-Filter als Dropdown im Suchfeld
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m18s
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m18s
Links im großen Suchfeld ein Slider-Icon mit Badge („Alle" oder „2/5"), das ein Dropdown-Menü mit allen Whitelist-Domains als Checkboxen öffnet. Auswahl wird per localStorage persistiert und gilt global — Header-Such- Dropdown konsumiert den gleichen Store und sendet den domains-Parameter bei jedem Fetch mit. Leere Menge heißt „alle aktiv", damit neu vom Admin freigeschaltete Domains automatisch dabei sind. Aktive Auswahl landet als explizite Intersection mit der Whitelist serverseitig. - searchLocal nimmt jetzt optional string[] domains → `source_domain IN (…)`. - searchWeb nimmt jetzt opts.domains → site:-Filter auf die Auswahl eingeschränkt. Nicht-Whitelist-Einträge werden ignoriert. - API-Endpoints: `?domains=a.de,b.de`. - Neuer Client-Store $lib/client/search-filter.svelte.ts. - Neue Komponente $lib/components/SearchFilter.svelte (mobile-tauglich, 44px Touch-Targets, Badge auf engen Screens versteckt). Home-Seite re-runt die Suche bei Filter-Änderung automatisch (150ms debounce), ohne dass der User neu tippen muss. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
83
src/lib/client/search-filter.svelte.ts
Normal file
83
src/lib/client/search-filter.svelte.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { AllowedDomain } from '$lib/types';
|
||||
|
||||
const STORAGE_KEY = 'kochwas.filter.domains';
|
||||
|
||||
// Leere Menge = kein Filter aktiv (alle Domains werden gesucht). Damit fügt sich
|
||||
// eine neu vom Admin freigeschaltete Domain automatisch ein, ohne dass der User
|
||||
// sie extra aktivieren muss. Wenn der User aktiv auswählt, speichern wir die
|
||||
// Auswahl als explizite Menge — und genau die wird dann gesucht.
|
||||
class SearchFilterStore {
|
||||
domains = $state<AllowedDomain[]>([]);
|
||||
active = $state<Set<string>>(new Set());
|
||||
loaded = $state(false);
|
||||
|
||||
async load(): Promise<void> {
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const arr = JSON.parse(raw) as string[];
|
||||
if (Array.isArray(arr)) this.active = new Set(arr);
|
||||
}
|
||||
} catch {
|
||||
// ignore corrupted state
|
||||
}
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/domains');
|
||||
if (res.ok) {
|
||||
this.domains = (await res.json()) as AllowedDomain[];
|
||||
}
|
||||
} catch {
|
||||
// offline / server error — leave domains empty, UI falls back to "no filter"
|
||||
}
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
persist(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify([...this.active]));
|
||||
} catch {
|
||||
// ignore quota / disabled storage
|
||||
}
|
||||
}
|
||||
|
||||
toggle(domain: string): void {
|
||||
const next = new Set(this.active);
|
||||
if (next.has(domain)) next.delete(domain);
|
||||
else next.add(domain);
|
||||
this.active = next;
|
||||
this.persist();
|
||||
}
|
||||
|
||||
selectAll(): void {
|
||||
// "Alle" == leere Menge, damit neue Domains automatisch dabei sind.
|
||||
this.active = new Set();
|
||||
this.persist();
|
||||
}
|
||||
|
||||
selectOnly(domain: string): void {
|
||||
this.active = new Set([domain]);
|
||||
this.persist();
|
||||
}
|
||||
|
||||
// True wenn der User die Suche eingeschränkt hat (mindestens eine aber nicht alle).
|
||||
get isFiltered(): boolean {
|
||||
return this.active.size > 0 && this.active.size < this.domains.length;
|
||||
}
|
||||
|
||||
// Als Query-Param-String. Leer = kein Filter.
|
||||
get queryParam(): string {
|
||||
if (this.active.size === 0) return '';
|
||||
return [...this.active].join(',');
|
||||
}
|
||||
|
||||
// Badge-Text: "2/5" wenn gefiltert, sonst "Alle".
|
||||
get label(): string {
|
||||
if (!this.isFiltered) return 'Alle';
|
||||
return `${this.active.size}/${this.domains.length}`;
|
||||
}
|
||||
}
|
||||
|
||||
export const searchFilterStore = new SearchFilterStore();
|
||||
246
src/lib/components/SearchFilter.svelte
Normal file
246
src/lib/components/SearchFilter.svelte
Normal file
@@ -0,0 +1,246 @@
|
||||
<script lang="ts">
|
||||
import { SlidersHorizontal, Check, ChevronDown } from 'lucide-svelte';
|
||||
import { searchFilterStore } from '$lib/client/search-filter.svelte';
|
||||
|
||||
let open = $state(false);
|
||||
let container: HTMLElement | undefined = $state();
|
||||
|
||||
function toggle() {
|
||||
open = !open;
|
||||
}
|
||||
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (container && !container.contains(e.target as Node)) open = false;
|
||||
}
|
||||
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && open) open = false;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
document.addEventListener('keydown', handleKey);
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleKey);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
function checked(domain: string): boolean {
|
||||
// Leere Menge (default "alle") heißt: jeder Eintrag ist implizit aktiv.
|
||||
if (searchFilterStore.active.size === 0) return true;
|
||||
return searchFilterStore.active.has(domain);
|
||||
}
|
||||
|
||||
function onToggleDomain(domain: string) {
|
||||
if (searchFilterStore.active.size === 0) {
|
||||
// Aus "Alle" wird "alle außer dem abgewählten": explizite Liste mit
|
||||
// allen Domains minus der einen.
|
||||
const rest = searchFilterStore.domains
|
||||
.map((d) => d.domain)
|
||||
.filter((d) => d !== domain);
|
||||
searchFilterStore.active = new Set(rest);
|
||||
searchFilterStore.persist();
|
||||
return;
|
||||
}
|
||||
searchFilterStore.toggle(domain);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="wrap" bind:this={container}>
|
||||
<button
|
||||
class="trigger"
|
||||
class:filtered={searchFilterStore.isFiltered}
|
||||
type="button"
|
||||
aria-label="Suchfilter"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onclick={toggle}
|
||||
>
|
||||
<SlidersHorizontal size={16} strokeWidth={2} />
|
||||
<span class="badge">{searchFilterStore.label}</span>
|
||||
<ChevronDown size={14} strokeWidth={2} />
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div class="menu" role="menu">
|
||||
<div class="menu-head">
|
||||
<span class="head-title">Gefunden auf</span>
|
||||
<button
|
||||
class="quick"
|
||||
type="button"
|
||||
onclick={() => {
|
||||
searchFilterStore.selectAll();
|
||||
}}
|
||||
>
|
||||
Alle
|
||||
</button>
|
||||
</div>
|
||||
{#if searchFilterStore.domains.length === 0}
|
||||
<p class="empty">Keine Domains in der Whitelist.</p>
|
||||
{:else}
|
||||
<ul class="list">
|
||||
{#each searchFilterStore.domains as d (d.id)}
|
||||
{@const isOn = checked(d.domain)}
|
||||
<li>
|
||||
<button
|
||||
class="row"
|
||||
type="button"
|
||||
role="menuitemcheckbox"
|
||||
aria-checked={isOn}
|
||||
onclick={() => onToggleDomain(d.domain)}
|
||||
>
|
||||
<span class="box" class:on={isOn}>
|
||||
{#if isOn}<Check size={14} strokeWidth={3} />{/if}
|
||||
</span>
|
||||
<span class="dom">{d.display_name ?? d.domain}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.wrap {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: white;
|
||||
border: 1px solid #cfd9d1;
|
||||
border-radius: 10px;
|
||||
color: #2b6a3d;
|
||||
cursor: pointer;
|
||||
font-size: 0.88rem;
|
||||
min-height: 44px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.trigger:hover {
|
||||
background: #f4f8f5;
|
||||
}
|
||||
.trigger.filtered {
|
||||
background: #eaf4ed;
|
||||
border-color: #2b6a3d;
|
||||
}
|
||||
.badge {
|
||||
font-weight: 600;
|
||||
}
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.4rem);
|
||||
left: 0;
|
||||
min-width: 260px;
|
||||
max-width: calc(100vw - 2rem);
|
||||
background: white;
|
||||
border: 1px solid #e4eae7;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.18);
|
||||
z-index: 80;
|
||||
padding: 0.35rem;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.menu-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.45rem 0.75rem;
|
||||
border-bottom: 1px solid #f0f3f1;
|
||||
}
|
||||
.head-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.quick {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: #2b6a3d;
|
||||
font-size: 0.88rem;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.4rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.quick:hover {
|
||||
background: #eaf4ed;
|
||||
}
|
||||
.list {
|
||||
list-style: none;
|
||||
padding: 0.2rem 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
color: #1a1a1a;
|
||||
text-align: left;
|
||||
min-height: 44px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.row:hover {
|
||||
background: #f4f8f5;
|
||||
}
|
||||
.box {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 1.5px solid #cfd9d1;
|
||||
border-radius: 5px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
background: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.box.on {
|
||||
background: #2b6a3d;
|
||||
border-color: #2b6a3d;
|
||||
}
|
||||
.dom {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.empty {
|
||||
padding: 0.8rem 0.75rem;
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.trigger {
|
||||
padding: 0.5rem 0.55rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.badge {
|
||||
display: none;
|
||||
}
|
||||
.menu {
|
||||
left: -0.25rem;
|
||||
min-width: calc(100vw - 2rem);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -30,15 +30,16 @@ export function searchLocal(
|
||||
db: Database.Database,
|
||||
query: string,
|
||||
limit = 30,
|
||||
offset = 0
|
||||
offset = 0,
|
||||
domains: string[] = []
|
||||
): SearchHit[] {
|
||||
const fts = buildFtsQuery(query);
|
||||
if (!fts) return [];
|
||||
|
||||
// bm25: lower is better. Use weights: title > tags > ingredients > description
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT r.id,
|
||||
const hasFilter = domains.length > 0;
|
||||
const placeholders = hasFilter ? domains.map(() => '?').join(',') : '';
|
||||
const sql = `SELECT r.id,
|
||||
r.title,
|
||||
r.description,
|
||||
r.image_path,
|
||||
@@ -48,10 +49,13 @@ export function searchLocal(
|
||||
FROM recipe r
|
||||
JOIN recipe_fts f ON f.rowid = r.id
|
||||
WHERE recipe_fts MATCH ?
|
||||
${hasFilter ? `AND r.source_domain IN (${placeholders})` : ''}
|
||||
ORDER BY bm25(recipe_fts, 10.0, 0.5, 2.0, 5.0)
|
||||
LIMIT ? OFFSET ?`
|
||||
)
|
||||
.all(fts, limit, offset) as SearchHit[];
|
||||
LIMIT ? OFFSET ?`;
|
||||
const params = hasFilter
|
||||
? [fts, ...domains, limit, offset]
|
||||
: [fts, limit, offset];
|
||||
return db.prepare(sql).all(...params) as SearchHit[];
|
||||
}
|
||||
|
||||
export function listRecentRecipes(
|
||||
|
||||
@@ -287,12 +287,18 @@ export async function searchWeb(
|
||||
limit?: number;
|
||||
enrichThumbnails?: boolean;
|
||||
pageno?: number;
|
||||
domains?: string[];
|
||||
} = {}
|
||||
): Promise<WebHit[]> {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) return [];
|
||||
const domains = listDomains(db).map((d) => d.domain);
|
||||
if (domains.length === 0) return [];
|
||||
const allDomains = listDomains(db).map((d) => d.domain);
|
||||
if (allDomains.length === 0) return [];
|
||||
// Optionaler Domain-Filter: Intersection mit der Whitelist, damit der
|
||||
// Filter nie außerhalb der erlaubten Domains sucht.
|
||||
const whitelist = new Set(allDomains);
|
||||
const filtered = opts.domains?.filter((d) => whitelist.has(d)) ?? [];
|
||||
const domains = filtered.length > 0 ? filtered : allDomains;
|
||||
|
||||
const searxngUrl = opts.searxngUrl ?? process.env.SEARXNG_URL ?? 'http://localhost:8888';
|
||||
const limit = opts.limit ?? 20;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { profileStore } from '$lib/client/profile.svelte';
|
||||
import { wishlistStore } from '$lib/client/wishlist.svelte';
|
||||
import { pwaStore } from '$lib/client/pwa.svelte';
|
||||
import { searchFilterStore } from '$lib/client/search-filter.svelte';
|
||||
import ProfileSwitcher from '$lib/components/ProfileSwitcher.svelte';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
import SearchLoader from '$lib/components/SearchLoader.svelte';
|
||||
@@ -37,6 +38,11 @@
|
||||
$page.url.pathname.startsWith('/recipes/') || $page.url.pathname === '/preview'
|
||||
);
|
||||
|
||||
function filterParam(): string {
|
||||
const p = searchFilterStore.queryParam;
|
||||
return p ? `&domains=${encodeURIComponent(p)}` : '';
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const q = navQuery.trim();
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
@@ -63,7 +69,7 @@
|
||||
debounceTimer = setTimeout(async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/recipes/search?q=${encodeURIComponent(q)}&limit=${NAV_PAGE_SIZE}`
|
||||
`/api/recipes/search?q=${encodeURIComponent(q)}&limit=${NAV_PAGE_SIZE}${filterParam()}`
|
||||
);
|
||||
const body = await res.json();
|
||||
if (navQuery.trim() !== q) return;
|
||||
@@ -73,7 +79,7 @@
|
||||
navWebSearching = true;
|
||||
try {
|
||||
const wres = await fetch(
|
||||
`/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=1`
|
||||
`/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=1${filterParam()}`
|
||||
);
|
||||
if (navQuery.trim() !== q) return;
|
||||
if (!wres.ok) {
|
||||
@@ -104,7 +110,7 @@
|
||||
try {
|
||||
if (!navLocalExhausted) {
|
||||
const res = await fetch(
|
||||
`/api/recipes/search?q=${encodeURIComponent(q)}&limit=${NAV_PAGE_SIZE}&offset=${navHits.length}`
|
||||
`/api/recipes/search?q=${encodeURIComponent(q)}&limit=${NAV_PAGE_SIZE}&offset=${navHits.length}${filterParam()}`
|
||||
);
|
||||
const body = await res.json();
|
||||
if (navQuery.trim() !== q) return;
|
||||
@@ -118,7 +124,7 @@
|
||||
navWebSearching = navWebHits.length === 0;
|
||||
try {
|
||||
const wres = await fetch(
|
||||
`/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${nextPage}`
|
||||
`/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${nextPage}${filterParam()}`
|
||||
);
|
||||
if (navQuery.trim() !== q) return;
|
||||
if (!wres.ok) {
|
||||
@@ -193,6 +199,7 @@
|
||||
onMount(() => {
|
||||
profileStore.load();
|
||||
void wishlistStore.refresh();
|
||||
void searchFilterStore.load();
|
||||
void pwaStore.init();
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
document.addEventListener('keydown', handleKey);
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
import type { WebHit } from '$lib/server/search/searxng';
|
||||
import { randomQuote } from '$lib/quotes';
|
||||
import SearchLoader from '$lib/components/SearchLoader.svelte';
|
||||
import SearchFilter from '$lib/components/SearchFilter.svelte';
|
||||
import { profileStore } from '$lib/client/profile.svelte';
|
||||
import { searchFilterStore } from '$lib/client/search-filter.svelte';
|
||||
|
||||
const LOCAL_PAGE = 30;
|
||||
|
||||
@@ -26,6 +28,7 @@
|
||||
let webExhausted = $state(false);
|
||||
let loadingMore = $state(false);
|
||||
let skipNextSearch = false;
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
type SearchSnapshot = {
|
||||
query: string;
|
||||
@@ -85,6 +88,22 @@
|
||||
const urlQ = ($page.url.searchParams.get('q') ?? '').trim();
|
||||
if (urlQ) query = urlQ;
|
||||
void loadRecent();
|
||||
void searchFilterStore.load();
|
||||
});
|
||||
|
||||
// Bei Änderung der Domain-Auswahl: laufende Suche neu ausführen,
|
||||
// damit der User nicht manuell re-tippen muss.
|
||||
$effect(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
searchFilterStore.active;
|
||||
const q = query.trim();
|
||||
if (!q || q.length <= 3) return;
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
searching = true;
|
||||
webHits = [];
|
||||
webSearching = false;
|
||||
webError = null;
|
||||
debounceTimer = setTimeout(() => void runSearch(q), 150);
|
||||
});
|
||||
|
||||
// Sync current query back into the URL as ?q=... via replaceState,
|
||||
@@ -110,7 +129,10 @@
|
||||
void loadFavorites(active.id);
|
||||
});
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
function filterParam(): string {
|
||||
const p = searchFilterStore.queryParam;
|
||||
return p ? `&domains=${encodeURIComponent(p)}` : '';
|
||||
}
|
||||
|
||||
async function runSearch(q: string) {
|
||||
localExhausted = false;
|
||||
@@ -118,7 +140,7 @@
|
||||
webExhausted = false;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/recipes/search?q=${encodeURIComponent(q)}&limit=${LOCAL_PAGE}`
|
||||
`/api/recipes/search?q=${encodeURIComponent(q)}&limit=${LOCAL_PAGE}${filterParam()}`
|
||||
);
|
||||
const body = await res.json();
|
||||
if (query.trim() !== q) return;
|
||||
@@ -130,7 +152,9 @@
|
||||
// damit der User nicht extra auf „+ weitere" klicken muss.
|
||||
webSearching = true;
|
||||
try {
|
||||
const wres = await fetch(`/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=1`);
|
||||
const wres = await fetch(
|
||||
`/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=1${filterParam()}`
|
||||
);
|
||||
if (query.trim() !== q) return;
|
||||
if (!wres.ok) {
|
||||
const err = await wres.json().catch(() => ({}));
|
||||
@@ -160,7 +184,7 @@
|
||||
if (!localExhausted) {
|
||||
// Noch mehr lokale Treffer holen.
|
||||
const res = await fetch(
|
||||
`/api/recipes/search?q=${encodeURIComponent(q)}&limit=${LOCAL_PAGE}&offset=${hits.length}`
|
||||
`/api/recipes/search?q=${encodeURIComponent(q)}&limit=${LOCAL_PAGE}&offset=${hits.length}${filterParam()}`
|
||||
);
|
||||
const body = await res.json();
|
||||
if (query.trim() !== q) return;
|
||||
@@ -175,7 +199,7 @@
|
||||
webSearching = webHits.length === 0;
|
||||
try {
|
||||
const wres = await fetch(
|
||||
`/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${nextPage}`
|
||||
`/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${nextPage}${filterParam()}`
|
||||
);
|
||||
if (query.trim() !== q) return;
|
||||
if (!wres.ok) {
|
||||
@@ -256,7 +280,8 @@
|
||||
<section class="hero">
|
||||
<h1>Kochwas</h1>
|
||||
<p class="tagline" aria-live="polite">{quote || '\u00a0'}</p>
|
||||
<form onsubmit={submit}>
|
||||
<form class="search-form" onsubmit={submit}>
|
||||
<SearchFilter />
|
||||
<input
|
||||
type="search"
|
||||
bind:value={query}
|
||||
|
||||
@@ -7,9 +7,13 @@ 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 offset = Math.max(0, Number(url.searchParams.get('offset') ?? 0));
|
||||
const domains = (url.searchParams.get('domains') ?? '')
|
||||
.split(',')
|
||||
.map((d) => d.trim())
|
||||
.filter(Boolean);
|
||||
const hits =
|
||||
q.length >= 1
|
||||
? searchLocal(getDb(), q, limit, offset)
|
||||
? searchLocal(getDb(), q, limit, offset, domains)
|
||||
: offset === 0
|
||||
? listRecentRecipes(getDb(), limit)
|
||||
: [];
|
||||
|
||||
@@ -7,8 +7,12 @@ 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)));
|
||||
const domains = (url.searchParams.get('domains') ?? '')
|
||||
.split(',')
|
||||
.map((d) => d.trim())
|
||||
.filter(Boolean);
|
||||
try {
|
||||
const hits = await searchWeb(getDb(), q, { pageno });
|
||||
const hits = await searchWeb(getDb(), q, { pageno, domains });
|
||||
return json({ query: q, pageno, hits });
|
||||
} catch (e) {
|
||||
error(502, { message: `Web search unavailable: ${(e as Error).message}` });
|
||||
|
||||
@@ -68,6 +68,23 @@ describe('searchLocal', () => {
|
||||
expect(searchLocal(db, ' ')).toEqual([]);
|
||||
});
|
||||
|
||||
it('filters by domain when supplied', () => {
|
||||
const db = openInMemoryForTest();
|
||||
insertRecipe(db, recipe({ title: 'Apfelstrudel', source_domain: 'chefkoch.de' }));
|
||||
insertRecipe(db, recipe({ title: 'Apfeltraum', source_domain: 'rezeptwelt.de' }));
|
||||
const hits = searchLocal(db, 'apfel', 10, 0, ['chefkoch.de']);
|
||||
expect(hits.length).toBe(1);
|
||||
expect(hits[0].source_domain).toBe('chefkoch.de');
|
||||
});
|
||||
|
||||
it('no domain filter when array is empty', () => {
|
||||
const db = openInMemoryForTest();
|
||||
insertRecipe(db, recipe({ title: 'Apfelstrudel', source_domain: 'chefkoch.de' }));
|
||||
insertRecipe(db, recipe({ title: 'Apfeltraum', source_domain: 'rezeptwelt.de' }));
|
||||
const hits = searchLocal(db, 'apfel', 10, 0, []);
|
||||
expect(hits.length).toBe(2);
|
||||
});
|
||||
|
||||
it('paginates via limit + offset', () => {
|
||||
const db = openInMemoryForTest();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
|
||||
@@ -72,6 +72,46 @@ describe('searchWeb', () => {
|
||||
expect(hits).toEqual([]);
|
||||
});
|
||||
|
||||
it('domain filter restricts site:-query to supplied subset', async () => {
|
||||
const db = openInMemoryForTest();
|
||||
addDomain(db, 'chefkoch.de');
|
||||
addDomain(db, 'rezeptwelt.de');
|
||||
let receivedQ: string | null = null;
|
||||
server.on('request', (req, res) => {
|
||||
const u = new URL(req.url ?? '/', 'http://localhost');
|
||||
receivedQ = u.searchParams.get('q');
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ results: [] }));
|
||||
});
|
||||
await searchWeb(db, 'apfel', {
|
||||
searxngUrl: baseUrl,
|
||||
enrichThumbnails: false,
|
||||
domains: ['rezeptwelt.de']
|
||||
});
|
||||
expect(receivedQ).toMatch(/site:rezeptwelt\.de/);
|
||||
expect(receivedQ).not.toMatch(/site:chefkoch\.de/);
|
||||
});
|
||||
|
||||
it('ignores domain filter entries that are not in whitelist', async () => {
|
||||
const db = openInMemoryForTest();
|
||||
addDomain(db, 'chefkoch.de');
|
||||
let receivedQ: string | null = null;
|
||||
server.on('request', (req, res) => {
|
||||
const u = new URL(req.url ?? '/', 'http://localhost');
|
||||
receivedQ = u.searchParams.get('q');
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ results: [] }));
|
||||
});
|
||||
// Only "evil.com" requested — not in whitelist → fall back to full whitelist.
|
||||
await searchWeb(db, 'x', {
|
||||
searxngUrl: baseUrl,
|
||||
enrichThumbnails: false,
|
||||
domains: ['evil.com']
|
||||
});
|
||||
expect(receivedQ).toMatch(/site:chefkoch\.de/);
|
||||
expect(receivedQ).not.toMatch(/site:evil\.com/);
|
||||
});
|
||||
|
||||
it('passes pageno to SearXNG when > 1', async () => {
|
||||
const db = openInMemoryForTest();
|
||||
addDomain(db, 'chefkoch.de');
|
||||
|
||||
Reference in New Issue
Block a user