03e84c1f28
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>
272 lines
8.6 KiB
Svelte
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>
|