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;
|
||||
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 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}
|
||||
|
||||
@@ -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 {
|
||||
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>
|
||||
Reference in New Issue
Block a user