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

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