feat(section-grid): InfoCard + SectionGrid als CMS-Block
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:
@@ -576,3 +576,34 @@ export interface StellingnahmeGeneratorBlockData {
|
|||||||
aiRules?: string;
|
aiRules?: string;
|
||||||
layout?: BlockLayout;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
import WindkarteBlock from "./blocks/WindkarteBlock.svelte";
|
import WindkarteBlock from "./blocks/WindkarteBlock.svelte";
|
||||||
import AdressbuchBlock from "./blocks/AdressbuchBlock.svelte";
|
import AdressbuchBlock from "./blocks/AdressbuchBlock.svelte";
|
||||||
import OrganisationsMapBlock from "./blocks/OrganisationsMapBlock.svelte";
|
import OrganisationsMapBlock from "./blocks/OrganisationsMapBlock.svelte";
|
||||||
|
import SectionGridBlock from "./blocks/SectionGridBlock.svelte";
|
||||||
import type { Translations } from "$lib/translations";
|
import type { Translations } from "$lib/translations";
|
||||||
import type {
|
import type {
|
||||||
ResolvedBlock,
|
ResolvedBlock,
|
||||||
@@ -51,6 +52,7 @@
|
|||||||
WindMapBlockData,
|
WindMapBlockData,
|
||||||
StellingnahmeGeneratorBlockData,
|
StellingnahmeGeneratorBlockData,
|
||||||
OrganisationsMapBlockData,
|
OrganisationsMapBlockData,
|
||||||
|
SectionGridBlockData,
|
||||||
} from "$lib/block-types";
|
} from "$lib/block-types";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -117,6 +119,8 @@
|
|||||||
<AdressbuchBlock block={block as AdressbuchBlockData} />
|
<AdressbuchBlock block={block as AdressbuchBlockData} />
|
||||||
{:else if type === "organisations_map"}
|
{:else if type === "organisations_map"}
|
||||||
<OrganisationsMapBlock block={block as OrganisationsMapBlockData} />
|
<OrganisationsMapBlock block={block as OrganisationsMapBlockData} />
|
||||||
|
{:else if type === "section_grid"}
|
||||||
|
<SectionGridBlock block={block as SectionGridBlockData} />
|
||||||
{:else if type === "stellungnahme_generator"}
|
{:else if type === "stellungnahme_generator"}
|
||||||
{#await import("./blocks/StellingnahmeGeneratorBlock.svelte") then m}
|
{#await import("./blocks/StellingnahmeGeneratorBlock.svelte") then m}
|
||||||
{@const Comp = m.default}
|
{@const Comp = m.default}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
let {
|
let {
|
||||||
number = undefined,
|
number = undefined,
|
||||||
kicker = undefined,
|
kicker = undefined,
|
||||||
title,
|
title = undefined,
|
||||||
subtitle = undefined,
|
subtitle = undefined,
|
||||||
actionLabel = undefined,
|
actionLabel = undefined,
|
||||||
actionHref = undefined,
|
actionHref = undefined,
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
}: {
|
}: {
|
||||||
number?: string; // z. B. "03" — tabular-nums, wald-500
|
number?: string; // z. B. "03" — tabular-nums, wald-500
|
||||||
kicker?: string; // Label, uppercase — optional (CMS-Blocks liefern oft nur title)
|
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;
|
subtitle?: string;
|
||||||
actionLabel?: string;
|
actionLabel?: string;
|
||||||
actionHref?: string;
|
actionHref?: string;
|
||||||
@@ -51,10 +51,12 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Titel (\n → harter Umbruch, wie HeadlineBlock) -->
|
<!-- 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]">
|
<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}
|
{#each title.split('\n') as line, i}{#if i > 0}<br />{/if}{line}{/each}
|
||||||
</svelte:element>
|
</svelte:element>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if subtitle}
|
{#if subtitle}
|
||||||
<p class="mt-2 text-base text-stein-500">{subtitle}</p>
|
<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>
|
||||||
Reference in New Issue
Block a user