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

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