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