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