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
+10
View File
@@ -336,6 +336,7 @@ export interface ContactEntry {
_slug?: string;
name?: string;
role?: string;
organisations?: (string | { _slug?: string; name?: string })[];
email?: string;
phone?: string;
note?: string;
@@ -349,6 +350,15 @@ export interface ContactsBlockData {
layout?: BlockLayout;
}
export interface AdressbuchBlockData {
_type?: "adressbuch";
_slug?: string;
excludeContacts?: (string | ContactEntry)[];
layout?: BlockLayout;
/** Server-side injected — not in CMS schema */
resolvedContacts?: ContactEntry[];
}
/** Live-Strommix-Widget (_type: "live_strommix"). Editorial-Konfig + Platzierungs-Instanz.
* Fetched live data from /api/strommix (aggregator) and renders status bucket
* derived from `(wind_onshore + wind_offshore + solar) / load * 100`. */
+27
View File
@@ -8,6 +8,7 @@ import {
getEntryBySlug,
getCalendarBySlug,
getCalendarItemBySlug,
getContacts,
getCalendarItems,
} from "./cms";
import type { RowContentLayout } from "./block-types";
@@ -833,3 +834,29 @@ export async function resolveStellingnahmeGeneratorBlocks(
}
}
}
export async function resolveAdressbuchBlocks(
layout: RowContentLayout | null | undefined,
): Promise<void> {
if (!layout) return;
const rows = [layout.row1Content ?? [], layout.row2Content ?? [], layout.row3Content ?? []];
let contacts: unknown[] | null = null;
for (const content of rows) {
if (!Array.isArray(content)) continue;
for (const item of content) {
if (typeof item !== "object" || item === null) continue;
if ((item as { _type?: string })._type !== "adressbuch") continue;
if (contacts === null) contacts = await getContacts();
const block = item as Record<string, unknown>;
const exclude = (block.excludeContacts as unknown[]) ?? [];
const excludeSlugs = new Set(
exclude.map((e) =>
typeof e === "string" ? e : (e as { _slug?: string })?._slug ?? ""
).filter(Boolean)
);
block.resolvedContacts = excludeSlugs.size
? contacts.filter((c) => !excludeSlugs.has((c as { _slug?: string })._slug ?? ""))
: contacts;
}
}
}
+17
View File
@@ -1067,3 +1067,20 @@ export async function getCommentCounts(
return {};
}
}
/** Alle Kontakte (GET /api/content/contact), alphabetisch nach Name. */
export async function getContacts(): Promise<Record<string, unknown>[]> {
return cached("contact", "contact:list:all", async () => {
const base = getBaseUrl();
const url = new URL(`${base}/api/content/contact`);
url.searchParams.set("_per_page", "1000");
url.searchParams.set("_resolve", "all");
url.searchParams.set("_depth", "2");
url.searchParams.set("_sort", "name");
url.searchParams.set("_order", "asc");
const res = await cmsFetch(url.toString());
if (!res.ok) return [];
const data = (await res.json()) as { items?: Record<string, unknown>[] };
return data.items ?? [];
});
}
+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}
+9 -3
View File
@@ -48,6 +48,12 @@
"shield-check-outline": {
"body": "<path fill=\"currentColor\" d=\"M21 11c0 5.55-3.84 10.74-9 12c-5.16-1.26-9-6.45-9-12V5l9-4l9 4zm-9 10c3.75-1 7-5.46 7-9.78V6.3l-7-3.12L5 6.3v4.92C5 15.54 8.25 20 12 21m-2-4l-4-4l1.41-1.41L10 14.17l6.59-6.59L18 9\"/>"
},
"phone-outline": {
"body": "<path fill=\"currentColor\" d=\"M20 15.5c-1.2 0-2.5-.2-3.6-.6h-.3c-.3 0-.5.1-.7.3l-2.2 2.2c-2.8-1.5-5.2-3.8-6.6-6.6l2.2-2.2c.3-.3.4-.7.2-1c-.3-1.1-.5-2.4-.5-3.6c0-.5-.5-1-1-1H4c-.5 0-1 .5-1 1c0 9.4 7.6 17 17 17c.5 0 1-.5 1-1v-3.5c0-.5-.5-1-1-1M5 5h1.5c.1.9.3 1.8.5 2.6L5.8 8.8C5.4 7.6 5.1 6.3 5 5m14 14c-1.3-.1-2.6-.4-3.8-.8l1.2-1.2c.8.2 1.7.4 2.6.4z\"/>"
},
"email-outline": {
"body": "<path fill=\"currentColor\" d=\"M22 6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2zm-2 0l-8 5l-8-5zm0 12H4V8l8 5l8-5z\"/>"
},
"map-marker": {
"body": "<path fill=\"currentColor\" d=\"M12 11.5A2.5 2.5 0 0 1 9.5 9A2.5 2.5 0 0 1 12 6.5A2.5 2.5 0 0 1 14.5 9a2.5 2.5 0 0 1-2.5 2.5M12 2a7 7 0 0 0-7 7c0 5.25 7 13 7 13s7-7.75 7-13a7 7 0 0 0-7-7\"/>"
},
@@ -144,6 +150,9 @@
"flag-outline": {
"body": "<path fill=\"currentColor\" d=\"m12.36 6l.4 2H18v6h-3.36l-.4-2H7V6zM14 4H5v17h2v-7h5.6l.4 2h7V6h-5.6\"/>"
},
"drag-vertical": {
"body": "<path fill=\"currentColor\" d=\"M9 3h2v2H9zm4 0h2v2h-2zM9 7h2v2H9zm4 0h2v2h-2zm-4 4h2v2H9zm4 0h2v2h-2zm-4 4h2v2H9zm4 0h2v2h-2zm-4 4h2v2H9zm4 0h2v2h-2z\"/>"
},
"transmission-tower": {
"body": "<path fill=\"currentColor\" d=\"m8.28 5.45l-1.78-.9L7.76 2h8.47l1.27 2.55l-1.78.89L15 4H9zM18.62 8h-4.53l-.79-3h-2.6l-.79 3H5.38L4.1 10.55l1.79.89l.73-1.44h10.76l.72 1.45l1.79-.89zm-.85 14H15.7l-.24-.9L12 15.9l-3.47 5.2l-.23.9H6.23l2.89-11h2.07l-.36 1.35L12 14.1l1.16-1.75l-.35-1.35h2.07zm-6.37-7l-.9-1.35l-1.18 4.48zm3.28 3.12l-1.18-4.48l-.9 1.36z\"/>"
},
@@ -204,9 +213,6 @@
"email": {
"body": "<path fill=\"currentColor\" d=\"m20 8l-8 5l-8-5V6l8 5l8-5m0-2H4c-1.11 0-2 .89-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2\"/>"
},
"email-outline": {
"body": "<path fill=\"currentColor\" d=\"M22 6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2zm-2 0l-8 5l-8-5zm0 12H4V8l8 5l8-5z\"/>"
},
"phone": {
"body": "<path fill=\"currentColor\" d=\"M6.62 10.79c1.44 2.83 3.76 5.15 6.59 6.59l2.2-2.2c.28-.28.67-.36 1.02-.25c1.12.37 2.32.57 3.57.57a1 1 0 0 1 1 1V20a1 1 0 0 1-1 1A17 17 0 0 1 3 4a1 1 0 0 1 1-1h3.5a1 1 0 0 1 1 1c0 1.25.2 2.45.57 3.57c.11.35.03.74-.25 1.02z\"/>"
},
+2
View File
@@ -13,6 +13,7 @@ import {
resolveCalendarBlocks,
resolveDeadlineBannerBlocks,
resolveStellingnahmeGeneratorBlocks,
resolveAdressbuchBlocks,
} from '$lib/blog-utils';
import { PAGE_RESOLVE, POST_SOCIAL_IMAGE_TRANSFORM } from '$lib/constants';
import { env } from '$env/dynamic/public';
@@ -74,6 +75,7 @@ export const load: PageServerLoad = async ({ params, locals, fetch, url, setHead
resolveCalendarBlocks(page),
resolveDeadlineBannerBlocks(page),
resolveStellingnahmeGeneratorBlocks(page),
resolveAdressbuchBlocks(page),
]);
const tagsMap = await postOverviewPromise;
await Promise.all([