From 363d4edb76116c87ded7fc9ab602ec1920da91d2 Mon Sep 17 00:00:00 2001 From: Peter Meier Date: Thu, 4 Jun 2026 10:08:54 +0200 Subject: [PATCH] feat: Adressbuch-Block mit Suche, Org- und Buchstabenfilter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/lib/block-types.ts | 10 + src/lib/blog-utils.ts | 27 +++ src/lib/cms.ts | 17 ++ src/lib/components/BlockRenderer.svelte | 4 + src/lib/components/ContactCard.svelte | 61 ++++-- .../components/blocks/AdressbuchBlock.svelte | 177 ++++++++++++++++++ .../components/blocks/ContactsBlock.svelte | 11 +- src/lib/iconify-mdi-subset.generated.json | 12 +- src/routes/[...slug]/+page.server.ts | 2 + 9 files changed, 298 insertions(+), 23 deletions(-) create mode 100644 src/lib/components/blocks/AdressbuchBlock.svelte diff --git a/src/lib/block-types.ts b/src/lib/block-types.ts index f60c7ae..ef79814 100644 --- a/src/lib/block-types.ts +++ b/src/lib/block-types.ts @@ -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`. */ diff --git a/src/lib/blog-utils.ts b/src/lib/blog-utils.ts index d2b7762..c9efcd6 100644 --- a/src/lib/blog-utils.ts +++ b/src/lib/blog-utils.ts @@ -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 { + 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; + 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; + } + } +} diff --git a/src/lib/cms.ts b/src/lib/cms.ts index 5bb46d3..9e30baa 100644 --- a/src/lib/cms.ts +++ b/src/lib/cms.ts @@ -1067,3 +1067,20 @@ export async function getCommentCounts( return {}; } } + +/** Alle Kontakte (GET /api/content/contact), alphabetisch nach Name. */ +export async function getContacts(): Promise[]> { + 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[] }; + return data.items ?? []; + }); +} diff --git a/src/lib/components/BlockRenderer.svelte b/src/lib/components/BlockRenderer.svelte index efbe294..14ba194 100644 --- a/src/lib/components/BlockRenderer.svelte +++ b/src/lib/components/BlockRenderer.svelte @@ -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"} +{:else if type === "adressbuch"} + {:else if type === "stellungnahme_generator"} {#await import("./blocks/StellingnahmeGeneratorBlock.svelte") then m} {@const Comp = m.default} diff --git a/src/lib/components/ContactCard.svelte b/src/lib/components/ContactCard.svelte index 64b05dd..5d16127 100644 --- a/src/lib/components/ContactCard.svelte +++ b/src/lib/components/ContactCard.svelte @@ -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[] + ); -
-
- {contact.name ?? ""} - {#if contact.role} - {contact.role} - {/if} +
+
+

{contact.name ?? ""}

+ + {#if contact.role || orgNames.length > 0} +
+ {#if contact.role} + + {contact.role} + + {/if} + {#each orgNames as org} + + {org} + + {/each} +
+ {/if}
- {#if contact.phone} - - - {contact.phone} - - {/if} - {#if contact.email} - - - {contact.email} - + + {#if contact.phone || contact.email} +
+ {#if contact.phone} + + + {contact.phone} + + {/if} + {#if contact.email} + + + {contact.email} + + {/if} +
{/if} + {#if contact.note}

{contact.note}

{/if} diff --git a/src/lib/components/blocks/AdressbuchBlock.svelte b/src/lib/components/blocks/AdressbuchBlock.svelte new file mode 100644 index 0000000..ce2f27a --- /dev/null +++ b/src/lib/components/blocks/AdressbuchBlock.svelte @@ -0,0 +1,177 @@ + + +
+ +
+
+ + +
+
+ + {#each allOrgs() as [slug, name]} + + {/each} +
+ +
+ {#each "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("") as letter} + {@const has = availableLetters().has(letter)} + + {/each} +
+
+ + + {#if filtered().length === 0} +

Keine Kontakte gefunden.

+ {:else} +
+ {#each filtered() as contact (contact._slug)} +
+ +
+

{contact.name ?? ""}

+ {#if contact.role || (contact.organisations ?? []).some((o) => typeof o === "object" && o.name)} +
+ {#if contact.role} + + {contact.role} + + {/if} + {#each (contact.organisations ?? []).filter((o): o is { _slug?: string; name?: string } => typeof o === "object" && !!o.name) as org} + + {org.name} + + {/each} +
+ {/if} +
+ + + {#if contact.phone || contact.email} +
+ {#if contact.phone} + + + {contact.phone} + + {/if} + {#if contact.email} + + + {contact.email} + + {/if} +
+ {/if} +
+ {/each} +
+

{filtered().length} Kontakt{filtered().length !== 1 ? "e" : ""}

+ {/if} +
diff --git a/src/lib/components/blocks/ContactsBlock.svelte b/src/lib/components/blocks/ContactsBlock.svelte index 52ac719..8b89d94 100644 --- a/src/lib/components/blocks/ContactsBlock.svelte +++ b/src/lib/components/blocks/ContactsBlock.svelte @@ -1,22 +1,27 @@ {#if contacts.length > 0} -
+
{#if block.headline}

{block.headline}

{/if} -
+
{#each contacts as contact} -
+
{/each} diff --git a/src/lib/iconify-mdi-subset.generated.json b/src/lib/iconify-mdi-subset.generated.json index 53e503d..8205429 100644 --- a/src/lib/iconify-mdi-subset.generated.json +++ b/src/lib/iconify-mdi-subset.generated.json @@ -48,6 +48,12 @@ "shield-check-outline": { "body": "" }, + "phone-outline": { + "body": "" + }, + "email-outline": { + "body": "" + }, "map-marker": { "body": "" }, @@ -144,6 +150,9 @@ "flag-outline": { "body": "" }, + "drag-vertical": { + "body": "" + }, "transmission-tower": { "body": "" }, @@ -204,9 +213,6 @@ "email": { "body": "" }, - "email-outline": { - "body": "" - }, "phone": { "body": "" }, diff --git a/src/routes/[...slug]/+page.server.ts b/src/routes/[...slug]/+page.server.ts index 3751f76..d219af4 100644 --- a/src/routes/[...slug]/+page.server.ts +++ b/src/routes/[...slug]/+page.server.ts @@ -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([