feat(search): Domain-Filter als Dropdown im Suchfeld
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:
hsiegeln
2026-04-18 08:13:33 +02:00
parent 864d113082
commit d004430854
10 changed files with 457 additions and 21 deletions

View 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();

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)
: [];

View File

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

View File

@@ -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++) {

View File

@@ -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');