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();
|
||||
Reference in New Issue
Block a user