feat: Adressbuch-Block mit Suche, Org- und Buchstabenfilter
Deploy / verify (push) Successful in 1m14s
Deploy / deploy (push) Successful in 1m18s

- AdressbuchBlock: listet alle Kontakte alphabetisch mit Suche,
  Org-Filter-Chips und A–Z-Buchstabenfilter; /adressbuch-Seite
- ContactCard: neues Design passend zu OrganisationsCard (Badges, Links)
- ContactsBlock: Karussell mit snap-scroll, volle Breite
- block-types, blog-utils, cms: AdressbuchBlockData, resolveAdressbuchBlocks,
  getContacts; BlockRenderer registriert adressbuch
- Kontakt-Seite: Link auf /adressbuch statt eingebettetem Block

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-06-04 10:08:54 +02:00
parent 7bd62b75b0
commit 363d4edb76
9 changed files with 298 additions and 23 deletions
+4
View File
@@ -21,9 +21,11 @@
import InternalComponentBlock from "./blocks/InternalComponentBlock.svelte";
import DeadlineBannerBlock from "./blocks/DeadlineBannerBlock.svelte";
import WindkarteBlock from "./blocks/WindkarteBlock.svelte";
import AdressbuchBlock from "./blocks/AdressbuchBlock.svelte";
import type { Translations } from "$lib/translations";
import type {
ResolvedBlock,
AdressbuchBlockData,
MarkdownBlockData,
HeadlineBlockData,
HtmlBlockData,
@@ -109,6 +111,8 @@
{/await}
{:else if type === "wind_map"}
<WindkarteBlock block={block as WindMapBlockData} />
{:else if type === "adressbuch"}
<AdressbuchBlock block={block as AdressbuchBlockData} />
{:else if type === "stellungnahme_generator"}
{#await import("./blocks/StellingnahmeGeneratorBlock.svelte") then m}
{@const Comp = m.default}
+44 -17
View File
@@ -4,27 +4,54 @@
import type { ContactEntry } from "$lib/block-types";
let { contact }: { contact: ContactEntry } = $props();
const orgNames = $derived(
(contact.organisations ?? [])
.filter((o): o is { _slug?: string; name?: string } => typeof o === "object" && o !== null)
.map((o) => o.name)
.filter(Boolean) as string[]
);
</script>
<div class="flex flex-col gap-1.5 rounded-lg border border-stein-200 bg-white px-4 py-3">
<div>
<span class="text-sm font-semibold text-stein-900">{contact.name ?? ""}</span>
{#if contact.role}
<span class="ml-1.5 text-xs text-stein-500">{contact.role}</span>
{/if}
<div
class="not-prose card-surface relative flex h-full min-w-0 flex-col gap-2 overflow-hidden rounded-sm p-4 transition-[transform,box-shadow,border-color] duration-200 hover:-translate-y-0.5 hover:border-wald-300 hover:shadow-md"
>
<div class="flex flex-col gap-1.5">
<p class="truncate text-sm font-medium leading-tight text-stein-900">{contact.name ?? ""}</p>
{#if contact.role || orgNames.length > 0}
<div class="flex flex-wrap gap-1">
{#if contact.role}
<span class="shrink-0 rounded-full border border-stein-200 bg-stein-100 px-1.5 py-0 text-[0.65rem] font-medium text-stein-600">
{contact.role}
</span>
{/if}
{#each orgNames as org}
<span class="shrink-0 rounded-full border border-wald-200 bg-wald-50 px-1.5 py-0 text-[0.65rem] font-medium text-wald-700">
{org}
</span>
{/each}
</div>
{/if}
</div>
{#if contact.phone}
<a href="tel:{contact.phone.replace(/\s/g, '')}" class="inline-flex items-center gap-1.5 text-xs text-stein-700 no-underline hover:text-wald-700">
<Icon icon="mdi:phone-outline" class="size-3.5 shrink-0 text-stein-400" />
{contact.phone}
</a>
{/if}
{#if contact.email}
<a href="mailto:{contact.email}" class="inline-flex items-center gap-1.5 text-xs text-stein-700 no-underline hover:text-wald-700">
<Icon icon="mdi:email-outline" class="size-3.5 shrink-0 text-stein-400" />
{contact.email}
</a>
{#if contact.phone || contact.email}
<div class="flex flex-col gap-1">
{#if contact.phone}
<a href="tel:{contact.phone.replace(/\s/g, '')}" class="inline-flex items-center gap-1.5 text-xs text-stein-600 no-underline hover:text-wald-700">
<Icon icon="mdi:phone-outline" class="size-3.5 shrink-0 text-stein-400" />
<span class="truncate">{contact.phone}</span>
</a>
{/if}
{#if contact.email}
<a href="mailto:{contact.email}" class="inline-flex items-center gap-1.5 text-xs text-stein-600 no-underline hover:text-wald-700">
<Icon icon="mdi:email-outline" class="size-3.5 shrink-0 text-stein-400" />
<span class="truncate">{contact.email}</span>
</a>
{/if}
</div>
{/if}
{#if contact.note}
<p class="text-[11px] text-stein-400">{contact.note}</p>
{/if}
@@ -0,0 +1,177 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
import { getBlockLayoutClasses } from "$lib/block-layout";
import type { AdressbuchBlockData, ContactEntry } from "$lib/block-types";
let { block }: { block: AdressbuchBlockData } = $props();
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const allContacts = $derived(
(block.resolvedContacts ?? []) as ContactEntry[]
);
// Unique orgs from all contacts (resolved objects only)
const allOrgs = $derived(() => {
const map = new Map<string, string>();
for (const c of allContacts) {
for (const o of c.organisations ?? []) {
if (typeof o === "object" && o !== null && o._slug && o.name) {
map.set(o._slug, o.name);
}
}
}
return [...map.entries()].sort((a, b) => a[1].localeCompare(b[1], "de"));
});
let search = $state("");
let activeOrg = $state<string | null>(null);
let activeLetter = $state<string | null>(null);
const afterOrgFilter = $derived(() =>
allContacts.filter((c) => {
if (!activeOrg) return true;
return (c.organisations ?? []).some(
(o) => (typeof o === "object" ? o._slug : o) === activeOrg
);
})
);
const availableLetters = $derived(() => {
const set = new Set<string>();
for (const c of afterOrgFilter()) {
const first = (c.name ?? "").trim()[0];
if (first) set.add(first.toUpperCase());
}
return set;
});
const filtered = $derived(() => {
const q = search.toLowerCase().trim();
return afterOrgFilter().filter((c) => {
if (activeLetter) {
const first = (c.name ?? "").trim()[0]?.toUpperCase();
if (first !== activeLetter) return false;
}
if (!q) return true;
return (
(c.name ?? "").toLowerCase().includes(q) ||
(c.role ?? "").toLowerCase().includes(q) ||
(c.email ?? "").toLowerCase().includes(q) ||
(c.organisations ?? []).some((o) =>
typeof o === "object" ? (o.name ?? "").toLowerCase().includes(q) : false
)
);
});
});
</script>
<div class="{layoutClasses} not-prose" data-block-type="adressbuch">
<!-- Search + Filter bar -->
<div class="mb-4 flex flex-col gap-2">
<div class="relative max-w-sm">
<Icon icon="mdi:magnify" class="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-stein-400" />
<input
type="search"
placeholder="Suchen…"
bind:value={search}
class="w-full rounded-sm border border-stein-200 bg-white py-1.5 pl-8 pr-3 text-sm text-stein-900 placeholder:text-stein-400 focus:border-wald-400 focus:outline-none focus:ring-1 focus:ring-wald-400"
/>
</div>
<div class="flex flex-wrap gap-1">
<button
onclick={() => (activeOrg = null)}
class="rounded-full border px-2 py-px text-[0.65rem] font-medium transition-colors {activeOrg === null
? 'border-wald-400 bg-wald-500 text-white'
: 'border-stein-200 bg-stein-100 text-stein-600 hover:border-wald-300 hover:text-wald-700'}"
>
Alle
</button>
{#each allOrgs() as [slug, name]}
<button
onclick={() => (activeOrg = activeOrg === slug ? null : slug)}
class="rounded-full border px-2 py-px text-[0.65rem] font-medium transition-colors {activeOrg === slug
? 'border-wald-400 bg-wald-500 text-white'
: 'border-stein-200 bg-stein-100 text-stein-600 hover:border-wald-300 hover:text-wald-700'}"
>
{name}
</button>
{/each}
</div>
<!-- Letter filter -->
<div class="flex flex-wrap gap-0.5">
{#each "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("") as letter}
{@const has = availableLetters().has(letter)}
<button
onclick={() => (activeLetter = activeLetter === letter ? null : letter)}
disabled={!has}
class="w-6 rounded py-px text-center text-[0.65rem] font-medium transition-colors {activeLetter === letter
? 'bg-wald-500 text-white'
: has
? 'text-stein-600 hover:bg-stein-100 hover:text-wald-700'
: 'cursor-default text-stein-300'}"
>
{letter}
</button>
{/each}
</div>
</div>
<!-- Contact list -->
{#if filtered().length === 0}
<p class="text-sm text-stein-400">Keine Kontakte gefunden.</p>
{:else}
<div class="divide-y divide-stein-100 rounded-sm border border-stein-200 bg-white">
{#each filtered() as contact (contact._slug)}
<div class="flex flex-col gap-2 px-4 py-3">
<!-- Name + badges -->
<div class="min-w-0">
<p class="text-sm font-medium text-stein-900">{contact.name ?? ""}</p>
{#if contact.role || (contact.organisations ?? []).some((o) => typeof o === "object" && o.name)}
<div class="mt-0.5 flex flex-nowrap gap-1 overflow-hidden">
{#if contact.role}
<span class="shrink-0 whitespace-nowrap rounded-full border border-stein-200 bg-stein-100 px-1.5 py-0 text-[0.65rem] font-medium text-stein-600">
{contact.role}
</span>
{/if}
{#each (contact.organisations ?? []).filter((o): o is { _slug?: string; name?: string } => typeof o === "object" && !!o.name) as org}
<span class="shrink-0 whitespace-nowrap rounded-full border border-wald-200 bg-wald-50 px-1.5 py-0 text-[0.65rem] font-medium text-wald-700">
{org.name}
</span>
{/each}
</div>
{/if}
</div>
<!-- Contact actions -->
{#if contact.phone || contact.email}
<div class="flex justify-end gap-2">
{#if contact.phone}
<a
href="tel:{contact.phone.replace(/\s/g, '')}"
title={contact.phone}
class="inline-flex items-center gap-1 rounded-sm border border-stein-200 bg-stein-50 px-2 py-0.5 text-[0.6rem] text-stein-600 no-underline transition-colors hover:border-wald-300 hover:bg-wald-50 hover:text-wald-700"
>
<Icon icon="mdi:phone-outline" class="size-3.5 shrink-0" />
<span class="truncate">{contact.phone}</span>
</a>
{/if}
{#if contact.email}
<a
href="mailto:{contact.email}"
title={contact.email}
class="inline-flex items-center gap-1 rounded-sm border border-stein-200 bg-stein-50 px-2 py-0.5 text-[0.6rem] text-stein-600 no-underline transition-colors hover:border-wald-300 hover:bg-wald-50 hover:text-wald-700"
>
<Icon icon="mdi:email-outline" class="size-3.5 shrink-0" />
<span class="truncate">{contact.email}</span>
</a>
{/if}
</div>
{/if}
</div>
{/each}
</div>
<p class="mt-2 text-right text-xs text-stein-400">{filtered().length} Kontakt{filtered().length !== 1 ? "e" : ""}</p>
{/if}
</div>
@@ -1,22 +1,27 @@
<script lang="ts">
import ContactCard from "$lib/components/ContactCard.svelte";
import { getBlockLayoutClasses } from "$lib/block-layout";
import type { ContactsBlockData, ContactEntry } from "$lib/block-types";
let { block }: { block: ContactsBlockData } = $props();
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const contacts = $derived(
(block.contacts ?? []).filter((c): c is ContactEntry => typeof c === "object" && c !== null)
);
</script>
{#if contacts.length > 0}
<div>
<div class="{layoutClasses}" data-block="ContactsBlock" data-block-type="contacts_block">
{#if block.headline}
<h3 class="mb-3 text-sm font-semibold text-stein-700">{block.headline}</h3>
{/if}
<div class="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<div
class="flex snap-x snap-mandatory gap-3 overflow-x-auto px-px pt-1 pb-2 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{#each contacts as contact}
<div class="sm:min-w-[220px] sm:max-w-xs">
<div class="w-[calc(100%-2rem)] min-w-[220px] max-w-[320px] shrink-0 snap-start sm:w-60 lg:w-72">
<ContactCard {contact} />
</div>
{/each}