Files
windwiderstand/src/lib/components/blocks/OrganisationsMapBlock.svelte
T
Peter Meier 03e84c1f28
Deploy / verify (push) Successful in 1m39s
Deploy / deploy (push) Successful in 2m9s
feat(design-system): Storybook + Button-Atom-Vereinheitlichung + SectionHeader
Storybook (SvelteKit-Framework, Tailwind v4, MSW, Translation-Decorator):
Stories für Atoms/Molecules/Organisms + alle CMS-Blocks.

Button-Atom: 5 .btn-*-Varianten (primary/secondary/tertiary/outline/warning),
class-Merge-Prop, href→<a>, Loading-Spinner neben Label, rounded-md/font-medium.
Alle rohen .btn-*- und Hand-Style-Buttons app-weit aufs Atom migriert.

SectionHeader-Molecule (kicker/number/title/subtitle/action) + Integration in
7 CMS-Blocks (post_overview, organisations, organisations_map, searchable_text,
image_gallery, youtube_video_gallery, files) via neuem section_header-Schema.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:35:57 +02:00

272 lines
8.6 KiB
Svelte

<script lang="ts">
import "leaflet/dist/leaflet.css";
import { onMount, onDestroy } from "svelte";
import { getBlockLayoutClasses } from "$lib/block-layout";
import SectionHeader from "$lib/components/SectionHeader.svelte";
import type { OrganisationsMapBlockData, OrganisationEntry, ContactEntry } from "$lib/block-types";
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
import { marked } from "$lib/markdown-safe";
import Button from "$lib/ui/Button.svelte";
let { block }: { block: OrganisationsMapBlockData } = $props();
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const actionRef = $derived(
block.action && typeof block.action === "object" ? block.action : null,
);
const orgs = $derived(
(block.organisations ?? []).filter(
(o): o is OrganisationEntry =>
typeof o === "object" &&
o !== null &&
typeof o.location?.lat === "number" &&
typeof o.location?.lng === "number",
),
);
let mapEl: HTMLDivElement;
let map: import("leaflet").Map | null = null;
let selectedOrg = $state<OrganisationEntry | null>(null);
let mapActive = $state(false);
// Keep reference to highlight active marker
let activeMarker: import("leaflet").CircleMarker | null = null;
function activateMap() {
if (mapActive || !map) return;
mapActive = true;
map.scrollWheelZoom.enable();
map.dragging.enable();
map.touchZoom.enable();
map.doubleClickZoom.enable();
}
const descriptionHtml = $derived(
selectedOrg?.description
? (marked.parse(selectedOrg.description) as string)
: "",
);
const contacts = $derived(
(selectedOrg?.contacts ?? []).filter(
(c): c is ContactEntry => typeof c === "object" && c !== null && !!c.name,
),
);
function getLink(org: OrganisationEntry): string | null {
if (!org.link || typeof org.link === "string") return null;
return org.link.url ?? null;
}
function getLinkLabel(org: OrganisationEntry): string {
if (!org.link || typeof org.link === "string") return "Website";
return org.link.linkName ?? "Website";
}
function closePanel() {
selectedOrg = null;
if (activeMarker) {
activeMarker.setStyle({ fillColor: "#3d6b4f", radius: 8 });
activeMarker = null;
}
}
onMount(async () => {
const L = (await import("leaflet")).default;
map = L.map(mapEl, {
zoomControl: true,
scrollWheelZoom: false,
dragging: false,
touchZoom: false,
doubleClickZoom: false,
});
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 18,
}).addTo(map);
for (const org of orgs) {
const { lat, lng } = org.location!;
const marker = L.circleMarker([lat!, lng!], {
radius: 8,
fillColor: "#3d6b4f",
color: "#fff",
weight: 2,
opacity: 1,
fillOpacity: 0.9,
})
.bindTooltip(org.name ?? "", {
permanent: false,
direction: "top",
className: "orgs-map-tip",
offset: [0, -6],
})
.addTo(map!)
.on("click", () => {
activateMap();
if (activeMarker && activeMarker !== marker) {
activeMarker.setStyle({ fillColor: "#3d6b4f", radius: 8 });
}
marker.setStyle({ fillColor: "#1a4030", radius: 11 });
activeMarker = marker;
selectedOrg = org;
});
}
if (orgs.length > 0) {
const bounds = L.latLngBounds(
orgs.map((o) => [o.location!.lat!, o.location!.lng!] as [number, number]),
);
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 11 });
}
});
onDestroy(() => {
map?.remove();
map = null;
});
</script>
<div class="not-prose {layoutClasses}" data-block-type="organisations_map">
{#if block.headline}
<div class="mb-3">
<SectionHeader
number={block.number}
kicker={block.kicker}
title={block.headline}
actionLabel={actionRef?.linkName}
actionHref={actionRef?.url}
/>
</div>
{/if}
<div
class="relative h-[320px] overflow-hidden rounded-sm border border-stein-200 sm:h-[clamp(400px,65vh,560px)]"
>
<!-- Leaflet map fills the container -->
<div bind:this={mapEl} class="absolute inset-0"></div>
{#if !mapActive}
<button
type="button"
onclick={activateMap}
class="absolute inset-0 z-[500] flex cursor-pointer items-center justify-center border-0 bg-black/15 p-0 transition-colors hover:bg-black/25"
aria-label="Karte aktivieren"
>
<span class="flex items-center gap-2 rounded-md bg-white/95 px-3 py-1.5 text-sm font-medium text-stein-800 shadow-md">
<Icon icon="lucide:mouse-pointer-click" class="size-4" />
Karte aktivieren
</span>
</button>
{/if}
<!-- Detail panel slides in from right (desktop) / bottom (mobile) -->
{#if selectedOrg}
<div
class="absolute bottom-0 left-0 right-0 z-[1000] max-h-[55%] overflow-y-auto border-t border-stein-200 bg-white shadow-lg
lg:bottom-0 lg:left-auto lg:right-0 lg:top-0 lg:w-72 lg:max-h-full lg:border-t-0 lg:border-l"
>
<div class="p-4">
<div class="mb-3 flex items-start justify-between gap-2">
<p class="text-sm font-semibold leading-snug text-stein-900">
{selectedOrg.name}
</p>
<button
type="button"
onclick={closePanel}
class="shrink-0 text-stein-400 transition-colors hover:text-stein-700"
aria-label="Schließen"
>
<Icon icon="lucide:x" class="size-4" />
</button>
</div>
{#if selectedOrg.location?.text}
<p class="mb-3 flex items-center gap-1 text-xs text-stein-500">
<Icon icon="lucide:map-pin" class="size-3.5 shrink-0" />
{selectedOrg.location.text}
</p>
{/if}
{#if selectedOrg.resolvedLogoSrc}
<img
src={selectedOrg.resolvedLogoSrc}
alt={selectedOrg.name ?? ""}
class="mb-3 max-h-10 object-contain"
/>
{/if}
{#if descriptionHtml}
<div class="markdown prose prose-sm mb-3 max-w-none text-xs text-stein-600">
{@html descriptionHtml}
</div>
{/if}
{#if contacts.length > 0}
<div class="mb-3 space-y-2 border-t border-stein-100 pt-3">
<p class="text-[0.65rem] font-semibold uppercase tracking-wider text-stein-500">Ansprechpartner</p>
{#each contacts as c}
<div class="text-xs">
<p class="font-semibold text-stein-800">{c.name}</p>
{#if c.role}
<p class="text-stein-500">{c.role}</p>
{/if}
{#if c.email}
<a href="mailto:{c.email}" class="mt-0.5 flex items-center gap-1 text-wald-700 hover:underline">
<Icon icon="lucide:mail" class="size-3 shrink-0" />
<span class="truncate">{c.email}</span>
</a>
{/if}
{#if c.phone}
<a href="tel:{c.phone.replace(/\s+/g, '')}" class="mt-0.5 flex items-center gap-1 text-wald-700 hover:underline">
<Icon icon="lucide:phone" class="size-3 shrink-0" />
{c.phone}
</a>
{/if}
{#if c.note}
<p class="mt-0.5 italic text-stein-500">{c.note}</p>
{/if}
</div>
{/each}
</div>
{/if}
{#if getLink(selectedOrg)}
<Button
href={getLink(selectedOrg) ?? undefined}
variant="outline"
size="sm"
target="_blank"
rel="noopener noreferrer"
class="w-full"
>
<Icon icon="lucide:external-link" class="size-3.5" />
{getLinkLabel(selectedOrg)}
</Button>
{/if}
</div>
</div>
{/if}
</div>
<p class="mt-1.5 text-xs text-stein-400">{orgs.length} Bürgerinitiativen · Marker anklicken für Details</p>
</div>
<style>
:global(.orgs-map-tip) {
background: #1c1917;
border: none;
border-radius: 3px;
color: #fafaf9;
font-size: 0.65rem;
padding: 2px 6px;
white-space: nowrap;
}
:global(.orgs-map-tip::before) {
border-top-color: #1c1917 !important;
}
</style>