feat: Adressbuch-Block mit Suche, Org- und Buchstabenfilter
- 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:
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user