Files
windwiderstand/src/lib/components/blocks/OrganisationsBlock.svelte
T
Peter Meier 5a86ff701e feat(blocks): data-block PascalCase identifier on every CMS block
Adds data-block="PascalName" alongside existing data-block-type
snake_case so blocks can be located via [data-block="Calendar"] etc.
in DevTools. Snake-case attribute kept for backward compat.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 15:44:40 +02:00

255 lines
9.8 KiB
Svelte

<script lang="ts">
import { marked } from "$lib/markdown-safe";
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
import { getBlockLayoutClasses } from "$lib/block-layout";
import type { OrganisationsBlockData, OrganisationEntry } from "$lib/block-types";
import { extractCmsImageField } from "$lib/rusty-image";
import Card from "$lib/components/Card.svelte";
import Badge from "$lib/components/Badge.svelte";
import RustyImage from "$lib/components/RustyImage.svelte";
import Button from "$lib/ui/Button.svelte";
function organisationLogoField(o: OrganisationEntry) {
return extractCmsImageField(o.logo);
}
function organisationLogoAlt(o: OrganisationEntry): string {
if (o.logo && typeof o.logo === "object" && o.logo.description) {
return o.logo.description;
}
return o.name ?? "";
}
let { block }: { block: OrganisationsBlockData } = $props();
const layoutClasses = $derived(
block.layout ? getBlockLayoutClasses(block.layout) : "col-span-12 mb-4",
);
const compact = $derived(block.presentation === "compact");
const carousel = $derived(block.presentation === "carousel");
const orgs = $derived(block.organisations ?? []);
const ctaHtml = $derived(
block.addOrganisationMarkdown
? (marked.parse(block.addOrganisationMarkdown) as string)
: ""
);
const addOrganisationCtaLink = $derived.by(() => {
const raw = block.addOrganisationLink;
if (!raw || typeof raw !== "object") return undefined;
const url = typeof raw.url === "string" ? raw.url.trim() : "";
if (!url) return undefined;
const linkName =
typeof raw.linkName === "string" && raw.linkName.trim() !== ""
? raw.linkName.trim()
: url;
return { url, linkName, newTab: Boolean(raw.newTab) };
});
const showAddOrganisationCard = $derived(
Boolean(
block.addOrganisationTitle?.trim() ||
block.addOrganisationMarkdown?.trim() ||
addOrganisationCtaLink
)
);
let scroller: HTMLDivElement | undefined = $state();
function scrollByPage(dir: 1 | -1) {
if (!scroller) return;
scroller.scrollBy({ left: scroller.clientWidth * dir * 0.9, behavior: "smooth" });
}
</script>
{#snippet defaultCard(o: OrganisationEntry, linkUrl: string | undefined, logoField: ReturnType<typeof organisationLogoField>, orgIndex: number)}
<Card href={linkUrl} class="h-full">
<div class="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden bg-stein-100">
{#if logoField}
<RustyImage
src={logoField.url}
focal={logoField.focal ?? null}
width={88}
aspect="1/1"
fit="contain"
quality={85}
alt={organisationLogoAlt(o)}
class="h-full w-full object-contain p-0.5"
loading="lazy"
/>
{:else}
{@const fishMaskId = `org-logo-fallback-mask-${orgIndex}`}
<svg class="h-8 w-8 text-stein-300" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="currentColor" aria-hidden="true">
<defs>
<mask id={fishMaskId}>
<g fill="none">
<path stroke="#fff" stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="M24 30v14" />
<path fill="#555555" stroke="#fff" stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="M29 23c11 5 16 14 16 14s-12 0-21-8c-9 8-21 8-21 8s5-10 16-14c0-13 5-19 5-19s5 6 5 19" />
<circle cx="24" cy="24" r="2" fill="#fff" />
</g>
</mask>
</defs>
<path fill="currentColor" d="M0 0h48v48H0z" mask={`url(#${fishMaskId})`} />
</svg>
{/if}
</div>
{#if o.badge && typeof o.badge === "object" && o.badge.label}
<div class="absolute top-2 right-2">
<Badge color={o.badge.color}>{o.badge.label}</Badge>
</div>
{/if}
<h5 class="mb-1 min-w-0 truncate text-sm font-medium leading-tight text-stein-900">{o.name ?? ""}</h5>
{#if o.description}
<p class="line-clamp-2 text-xs font-light text-stein-600">{o.description}</p>
{/if}
</Card>
{/snippet}
{#snippet ctaCard()}
<Card
variant="callout"
href={addOrganisationCtaLink?.url}
target={addOrganisationCtaLink?.newTab ? "_blank" : undefined}
rel={addOrganisationCtaLink?.newTab ? "noopener noreferrer" : undefined}
class="h-full w-full max-w-full md:max-w-md"
>
{#if block.addOrganisationTitle}
<h5 class="text-xl font-semibold text-wald-800 leading-snug">{block.addOrganisationTitle}</h5>
{/if}
{#if ctaHtml}
<div class="markdown text-xs text-wald-700">{@html ctaHtml}</div>
{/if}
{#if addOrganisationCtaLink}
<div class="self-start">
<Button variant="primary" size="sm">
{addOrganisationCtaLink.linkName}
</Button>
</div>
{/if}
</Card>
{/snippet}
<div class={layoutClasses} data-block="Organisations" data-block-type="organisations" data-block-slug={block._slug}>
{#if block.headline}
<h3
class={compact ? "mb-2 text-lg font-semibold text-stein-900" : "mb-4"}
>{block.headline}</h3>
{/if}
{#if carousel}
<div class="relative">
<div
bind:this={scroller}
class="flex gap-4 overflow-x-auto scroll-smooth snap-x snap-mandatory pb-2 [scrollbar-width:thin]"
>
{#each orgs as org, orgIndex}
{#if typeof org === "object" && org !== null}
{@const o = org as OrganisationEntry}
{@const linkUrl =
typeof o.link === "object" &&
o.link !== null &&
typeof o.link.url === "string" &&
o.link.url.trim() !== ""
? o.link.url.trim()
: undefined}
{@const logoField = organisationLogoField(o)}
<div
class="shrink-0 snap-start w-[calc((100%-1rem)/2)] lg:w-[calc((100%-2rem)/3)]"
>
{@render defaultCard(o, linkUrl, logoField, orgIndex)}
</div>
{/if}
{/each}
</div>
{#if orgs.length > 2}
<button
type="button"
onclick={() => scrollByPage(-1)}
aria-label="Vorherige Organisationen"
class="hidden md:flex absolute -left-3 top-1/2 -translate-y-1/2 -translate-x-1/2 size-7 items-center justify-center rounded-full bg-white border border-stein-200 shadow-md hover:bg-stein-50 transition"
>
<Icon icon="mdi:chevron-left" class="size-4" aria-hidden="true" />
</button>
<button
type="button"
onclick={() => scrollByPage(1)}
aria-label="Weitere Organisationen"
class="hidden md:flex absolute -right-3 top-1/2 -translate-y-1/2 translate-x-1/2 size-7 items-center justify-center rounded-full bg-white border border-stein-200 shadow-md hover:bg-stein-50 transition"
>
<Icon icon="mdi:chevron-right" class="size-4" aria-hidden="true" />
</button>
{/if}
</div>
{#if showAddOrganisationCard}
<div class="mt-2 min-w-0">
{@render ctaCard()}
</div>
{/if}
{:else}
<div
class={compact
? "grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-3"
: "grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"}
>
{#each orgs as org, orgIndex}
{#if typeof org === "object" && org !== null}
{@const o = org as OrganisationEntry}
{@const linkUrl =
typeof o.link === "object" &&
o.link !== null &&
typeof o.link.url === "string" &&
o.link.url.trim() !== ""
? o.link.url.trim()
: undefined}
{@const logoField = organisationLogoField(o)}
{#if compact}
<Card href={linkUrl} layout="inline" class="h-full">
<div class="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden bg-stein-100">
{#if logoField}
<RustyImage
src={logoField.url}
focal={logoField.focal ?? null}
width={80}
aspect="1/1"
fit="contain"
quality={85}
alt={organisationLogoAlt(o)}
class="h-full w-full object-contain p-0.5"
loading="lazy"
/>
{:else}
{@const fishMaskId = `org-logo-fallback-mask-c-${orgIndex}`}
<svg class="h-7 w-7 text-stein-300" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="currentColor" aria-hidden="true">
<defs>
<mask id={fishMaskId}>
<g fill="none">
<path stroke="#fff" stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="M24 30v14" />
<path fill="#555555" stroke="#fff" stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="M29 23c11 5 16 14 16 14s-12 0-21-8c-9 8-21 8-21 8s5-10 16-14c0-13 5-19 5-19s5 6 5 19" />
<circle cx="24" cy="24" r="2" fill="#fff" />
</g>
</mask>
</defs>
<path fill="currentColor" d="M0 0h48v48H0z" mask={`url(#${fishMaskId})`} />
</svg>
{/if}
</div>
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<h5 class="text-sm font-medium leading-tight text-stein-900">{o.name ?? ""}</h5>
{#if o.description}
<p class="line-clamp-2 text-xs font-light text-stein-600">{o.description}</p>
{/if}
</div>
</Card>
{:else}
{@render defaultCard(o, linkUrl, logoField, orgIndex)}
{/if}
{/if}
{/each}
{#if showAddOrganisationCard}
{@render ctaCard()}
{/if}
</div>
{/if}
</div>