feat(section-grid): InfoCard + SectionGrid als CMS-Block
Deploy / verify (push) Successful in 2m27s
Deploy / deploy (push) Successful in 1m49s

Neuer section_grid-Block (SectionHeader + Card-Grid aus info_card-Refs) +
info_card-Collection. SectionGrid/InfoCard-Komponenten, BlockRenderer-Case,
content_layout erlaubt section_grid in Rows. SectionHeader.title jetzt optional
(Kicker-only-Header).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-07-12 09:37:05 +02:00
parent 7c7538a22c
commit 3878a2ed26
8 changed files with 352 additions and 6 deletions
+31
View File
@@ -576,3 +576,34 @@ export interface StellingnahmeGeneratorBlockData {
aiRules?: string;
layout?: BlockLayout;
}
/** Einzelne Kachel (info_card): Icon/Bild + Titel + Beschreibung + optionaler Link. */
export interface InfoCardData {
_type?: "info_card";
_slug?: string;
/** Iconify-String ("mdi:email") oder Bild-URL — Komponente erkennt automatisch. */
icon?: string;
title?: string;
description?: string;
linkLabel?: string;
linkHref?: string;
external?: boolean;
}
/** Sektions-Grid (_type: "section_grid"): SectionHeader + info_card-Kacheln. */
export interface SectionGridBlockData {
_type?: "section_grid";
_slug?: string;
headline?: string;
subtitle?: string;
/** SectionHeader: Label über dem Titel (uppercase). */
kicker?: string;
/** SectionHeader: Index vor dem Kicker, z.B. "01". */
number?: string;
/** SectionHeader: CTA-Link (aufgelöst: url, linkName). */
action?: string | { url?: string; linkName?: string; newTab?: boolean };
columns?: "2" | "3" | "4";
/** Slugs oder aufgelöste info_card-Einträge. */
cards?: (string | InfoCardData)[];
layout?: BlockLayout;
}
+4
View File
@@ -23,6 +23,7 @@
import WindkarteBlock from "./blocks/WindkarteBlock.svelte";
import AdressbuchBlock from "./blocks/AdressbuchBlock.svelte";
import OrganisationsMapBlock from "./blocks/OrganisationsMapBlock.svelte";
import SectionGridBlock from "./blocks/SectionGridBlock.svelte";
import type { Translations } from "$lib/translations";
import type {
ResolvedBlock,
@@ -51,6 +52,7 @@
WindMapBlockData,
StellingnahmeGeneratorBlockData,
OrganisationsMapBlockData,
SectionGridBlockData,
} from "$lib/block-types";
let {
@@ -117,6 +119,8 @@
<AdressbuchBlock block={block as AdressbuchBlockData} />
{:else if type === "organisations_map"}
<OrganisationsMapBlock block={block as OrganisationsMapBlockData} />
{:else if type === "section_grid"}
<SectionGridBlock block={block as SectionGridBlockData} />
{:else if type === "stellungnahme_generator"}
{#await import("./blocks/StellingnahmeGeneratorBlock.svelte") then m}
{@const Comp = m.default}
+64
View File
@@ -0,0 +1,64 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import InfoCard from './InfoCard.svelte';
const CMS_ASSET =
'https://cms.pm86.de/api/assets/pages/img_2814-ar4x2.webp?_environment=windwiderstand';
const meta = {
title: 'Molecules/InfoCard',
component: InfoCard,
tags: ['autodocs'],
parameters: { layout: 'centered' },
argTypes: {
icon: { control: 'text' },
title: { control: 'text' },
description: { control: 'text' },
linkLabel: { control: 'text' },
linkHref: { control: 'text' },
external: { control: 'boolean' },
},
} satisfies Meta<typeof InfoCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const MitIcon: Story = {
args: {
icon: 'mdi:email-outline',
title: 'E-Mail',
description: 'Für alle Fragen und Anregungen',
linkLabel: 'mail@windwiderstand.de',
linkHref: 'mailto:mail@windwiderstand.de',
},
};
export const ExternerLink: Story = {
args: {
icon: 'mdi:whatsapp',
title: 'WhatsApp-Community',
description: 'Aktuelles und Austausch vor Ort',
linkLabel: 'Beitreten',
linkHref: 'https://chat.whatsapp.com/xyz',
external: true,
},
};
// icon = Bild-URL statt Iconify-String → wird als Bild gerendert.
export const MitBild: Story = {
args: {
icon: CMS_ASSET,
title: 'Unsere Cloud',
description: 'Dokumente und Materialien zum Mitnehmen',
linkLabel: 'cloud.windwiderstand.de',
linkHref: 'https://cloud.windwiderstand.de',
external: true,
},
};
export const OhneLink: Story = {
args: {
icon: 'mdi:youtube',
title: 'YouTube-Kanal',
description: 'Aufzeichnungen und Visualisierungen',
},
};
+61
View File
@@ -0,0 +1,61 @@
<script lang="ts">
/**
* InfoCard — Icon/Bild + Titel + Beschreibung + optionaler Link.
* Wiederverwendbare Kachel für Sektions-Grids (siehe SectionGrid).
*
* `icon` ist entweder ein Iconify-String ("mdi:email") ODER eine Bild-URL
* (https://… oder /…) — wird automatisch erkannt.
*/
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
import RustyImage from "$lib/components/RustyImage.svelte";
let {
icon = undefined,
title,
description = undefined,
linkLabel = undefined,
linkHref = undefined,
external = false,
}: {
icon?: string;
title: string;
description?: string;
linkLabel?: string;
linkHref?: string;
external?: boolean;
} = $props();
// Bild-URL vs Iconify-String unterscheiden.
const isImage = $derived(!!icon && (/^https?:\/\//.test(icon) || icon.startsWith("/")));
const hasLink = $derived(!!linkLabel && !!linkHref);
</script>
<div class="flex h-full flex-col rounded-lg border border-stein-200 bg-white p-6">
{#if icon}
<div class="flex size-11 items-center justify-center overflow-hidden rounded-full bg-wald-50 text-wald-700">
{#if isImage}
<RustyImage src={icon} width={44} aspect="1/1" alt="" class="size-full object-cover" />
{:else}
<Icon icon={icon} class="size-5" aria-hidden="true" />
{/if}
</div>
{/if}
<h3 class="mt-4 text-lg font-semibold text-stein-900">{title}</h3>
{#if description}
<p class="mt-1 text-sm text-stein-500">{description}</p>
{/if}
{#if hasLink}
<a
href={linkHref}
target={external ? "_blank" : undefined}
rel={external ? "noopener noreferrer" : undefined}
class="mt-4 inline-flex items-center gap-1.5 text-[15px] font-semibold text-himmel-600 no-underline hover:text-himmel-700"
>
{linkLabel}
<svg class="size-4" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</a>
{/if}
</div>
@@ -0,0 +1,70 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import SectionGrid from './SectionGrid.svelte';
import InfoCard from './InfoCard.svelte';
const { Story } = defineMeta({
title: 'Organisms/SectionGrid',
component: SectionGrid,
tags: ['autodocs'],
parameters: { layout: 'fullscreen' },
});
</script>
<!-- Reproduktion des Screenshots: Kicker-only-Header + 4 InfoCards. -->
<Story name="Direkter Draht" asChild>
<div class="p-8">
<SectionGrid number="01" kicker="Direkter Draht" columns={4}>
<InfoCard
icon="mdi:email-outline"
title="E-Mail"
description="Für alle Fragen und Anregungen"
linkLabel="mail@windwiderstand.de"
linkHref="mailto:mail@windwiderstand.de"
/>
<InfoCard
icon="mdi:whatsapp"
title="WhatsApp-Community"
description="Aktuelles und Austausch vor Ort"
linkLabel="Beitreten"
linkHref="#"
external
/>
<InfoCard
icon="mdi:youtube"
title="YouTube-Kanal"
description="Aufzeichnungen und Visualisierungen"
linkLabel="Ansehen"
linkHref="#"
external
/>
<InfoCard
icon="mdi:cloud-outline"
title="Unsere Cloud"
description="Dokumente und Materialien zum Mitnehmen"
linkLabel="cloud.windwiderstand.de"
linkHref="#"
external
/>
</SectionGrid>
</div>
</Story>
<!-- Mit vollem Header (Titel + CTA) und 3 Spalten. -->
<Story name="Mit Titel + CTA" asChild>
<div class="p-8">
<SectionGrid
number="03"
kicker="Vor Ort"
title="Beteiligte Initiativen"
subtitle="13 Bürgerinitiativen im Bündnis."
actionLabel="Zum Adressbuch"
actionHref="/adressbuch"
columns={3}
>
<InfoCard icon="mdi:account-group" title="BI Steigerwald" description="Schleusingen" linkLabel="Mehr" linkHref="#" />
<InfoCard icon="mdi:pine-tree" title="Waldfreunde e.V." description="Ebrach" linkLabel="Mehr" linkHref="#" />
<InfoCard icon="mdi:leaf" title="Naturschutz Nord" description="Bamberg" linkLabel="Mehr" linkHref="#" />
</SectionGrid>
</div>
</Story>
+65
View File
@@ -0,0 +1,65 @@
<script lang="ts">
/**
* SectionGrid — SectionHeader (Kicker/Nummer/Titel/CTA) oben + responsives
* Card-Grid darunter. Die Cards kommen als children-Slot rein (z.B. InfoCard,
* Organisations-Karten …). Generisch → auch für Organisationen nutzbar.
*
* <SectionGrid number="01" kicker="Direkter Draht" columns={4}>
* <InfoCard … /> <InfoCard … /> …
* </SectionGrid>
*/
import type { Snippet } from "svelte";
import SectionHeader from "$lib/components/SectionHeader.svelte";
let {
number = undefined,
kicker = undefined,
title = undefined,
subtitle = undefined,
actionLabel = undefined,
actionHref = undefined,
tag = "h2",
columns = 4,
children,
}: {
number?: string;
kicker?: string;
title?: string;
subtitle?: string;
actionLabel?: string;
actionHref?: string;
tag?: "h1" | "h2" | "h3";
/** Spalten auf Desktop (Mobil immer 1, ab sm 2). */
columns?: 2 | 3 | 4;
children: Snippet;
} = $props();
const hasHeader = $derived(!!number || !!kicker || !!title || (!!actionLabel && !!actionHref));
// Statische Grid-Klassen (Tailwind kann keine dynamischen Spaltenzahlen scannen).
const gridCols: Record<2 | 3 | 4, string> = {
2: "sm:grid-cols-2",
3: "sm:grid-cols-2 lg:grid-cols-3",
4: "sm:grid-cols-2 lg:grid-cols-4",
};
</script>
<section>
{#if hasHeader}
<div class="mb-6">
<SectionHeader
{number}
{kicker}
{title}
{subtitle}
{tag}
{actionLabel}
{actionHref}
/>
</div>
{/if}
<div class="grid grid-cols-1 gap-4 {gridCols[columns]}">
{@render children()}
</div>
</section>
+8 -6
View File
@@ -13,7 +13,7 @@
let {
number = undefined,
kicker = undefined,
title,
title = undefined,
subtitle = undefined,
actionLabel = undefined,
actionHref = undefined,
@@ -22,7 +22,7 @@
}: {
number?: string; // z. B. "03" — tabular-nums, wald-500
kicker?: string; // Label, uppercase — optional (CMS-Blocks liefern oft nur title)
title: string;
title?: string; // optional — z.B. Kicker-only-Header (nur "01 · DIREKTER DRAHT")
subtitle?: string;
actionLabel?: string;
actionHref?: string;
@@ -51,10 +51,12 @@
</div>
{/if}
<!-- Titel (\n → harter Umbruch, wie HeadlineBlock) -->
<svelte:element this={tag} class="text-3xl font-bold tracking-[-0.02em] text-stein-800 lg:text-[34px]">
{#each title.split('\n') as line, i}{#if i > 0}<br />{/if}{line}{/each}
</svelte:element>
<!-- Titel (\n → harter Umbruch, wie HeadlineBlock). Optional — Kicker-only-Header. -->
{#if title}
<svelte:element this={tag} class="text-3xl font-bold tracking-[-0.02em] text-stein-800 lg:text-[34px]">
{#each title.split('\n') as line, i}{#if i > 0}<br />{/if}{line}{/each}
</svelte:element>
{/if}
{#if subtitle}
<p class="mt-2 text-base text-stein-500">{subtitle}</p>
@@ -0,0 +1,49 @@
<script lang="ts">
import { getBlockLayoutClasses } from "$lib/block-layout";
import SectionGrid from "$lib/components/SectionGrid.svelte";
import InfoCard from "$lib/components/InfoCard.svelte";
import type { SectionGridBlockData, InfoCardData } from "$lib/block-types";
let { block }: { block: SectionGridBlockData } = $props();
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const actionRef = $derived(
block.action && typeof block.action === "object" ? block.action : null,
);
const cards = $derived(
(block.cards ?? []).filter(
(c): c is InfoCardData => typeof c === "object" && c !== null,
),
);
const cols = $derived(
(block.columns === "2" ? 2 : block.columns === "3" ? 3 : 4) as 2 | 3 | 4,
);
</script>
<div
class={layoutClasses}
data-block="SectionGrid"
data-block-type="section_grid"
data-block-slug={block._slug}
>
<SectionGrid
number={block.number}
kicker={block.kicker}
title={block.headline}
subtitle={block.subtitle}
actionLabel={actionRef?.linkName}
actionHref={actionRef?.url}
columns={cols}
>
{#each cards as card}
<InfoCard
icon={card.icon}
title={card.title ?? ""}
description={card.description}
linkLabel={card.linkLabel}
linkHref={card.linkHref}
external={card.external}
/>
{/each}
</SectionGrid>
</div>