feat(design-system): Storybook + Button-Atom-Vereinheitlichung + SectionHeader
Deploy / verify (push) Successful in 1m39s
Deploy / deploy (push) Successful in 2m9s

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>
This commit is contained in:
Peter Meier
2026-07-11 23:35:57 +02:00
parent 289cdb3951
commit 03e84c1f28
93 changed files with 5473 additions and 173 deletions
+3
View File
@@ -19,3 +19,6 @@ dist/
# Logs
*.log
npm-debug.log*
# Storybook
storybook-static/
@@ -0,0 +1,14 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import TranslationProvider from '$lib/components/TranslationProvider.svelte';
import strings from '$lib/components/blocks/_fixtures/translations.json';
// Storybook reicht die Story als `children`-Snippet rein. Wir setzen den
// Translation-Context (echte deutsche Strings aus dem CMS-Bundle), damit
// Blocks lesbare Texte statt roher Keys zeigen.
let { children }: { children: Snippet } = $props();
</script>
<TranslationProvider translations={strings}>
{@render children()}
</TranslationProvider>
+40
View File
@@ -0,0 +1,40 @@
import type { StorybookConfig } from '@storybook/sveltekit';
import tailwindcss from '@tailwindcss/vite';
const config: StorybookConfig = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|ts|svelte)'],
addons: [
'@storybook/addon-svelte-csf',
'@storybook/addon-a11y',
'@storybook/addon-docs',
],
framework: '@storybook/sveltekit',
// MSW-Service-Worker liegt in static/ (via `npx msw init static/`). Storybook
// muss static/ ausliefern, damit /mockServiceWorker.js erreichbar ist.
staticDirs: ['../static'],
// Tailwind v4 läuft als Vite-Plugin. Das sveltekit-Framework bringt nur die
// Svelte/SvelteKit-Integration mit ($app/$env/$lib-Mocks), nicht Tailwind —
// darum hier explizit dazuhängen, sonst greifen keine Utility-Klassen/Tokens.
viteFinal: async (viteConfig) => {
viteConfig.plugins = viteConfig.plugins ?? [];
viteConfig.plugins.push(tailwindcss());
// Same-origin-Routen, die im echten Frontend SvelteKit-Server-Endpoints
// sind (in Storybook nicht existieren), auf die Live-Site proxien. Damit
// rendern RustyImage-Bilder (/cms-images, /cms-files) und die Live-Daten-
// Blocks (/api/strommix, /api/stellungnahme) mit echten Daten — ohne
// Fixtures. Deterministische Offline-Mocks stattdessen via MSW (siehe
// .storybook/README oder Kommentar unten).
const PROXY_TARGET = 'https://windwiderstand.de';
viteConfig.server = viteConfig.server ?? {};
viteConfig.server.proxy = {
...(viteConfig.server.proxy ?? {}),
'/cms-images': { target: PROXY_TARGET, changeOrigin: true },
'/cms-files': { target: PROXY_TARGET, changeOrigin: true },
'/api/strommix': { target: PROXY_TARGET, changeOrigin: true },
'/api/stellungnahme': { target: PROXY_TARGET, changeOrigin: true },
};
return viteConfig;
},
};
export default config;
+13
View File
@@ -0,0 +1,13 @@
<!--
SvelteKit liest $env/dynamic/public im Browser aus globalThis.__sveltekit_dev.env.
Dieses Global setzt sonst nur der echte SvelteKit-Dev-Server. In Storybook hier
vorab setzen, sonst crasht jede Komponente mit `env.PUBLIC_*` (Windkarte,
Stellingnahme, …) mit "__sveltekit_dev is undefined". Läuft vor dem Story-Bundle.
-->
<script>
window.__sveltekit_dev = window.__sveltekit_dev || { env: {} };
window.__sveltekit_dev.env = Object.assign(
{ PUBLIC_CMS_URL: 'https://cms.pm86.de' },
window.__sveltekit_dev.env,
);
</script>
+54
View File
@@ -0,0 +1,54 @@
import type { Preview } from '@storybook/sveltekit';
import { initialize, mswLoader } from 'msw-storybook-addon';
import WithTranslations from './decorators/WithTranslations.svelte';
// Design-System-Styles: Fonts, Tailwind, Farbtokens (wald/erde/…). Zieht die
// echte app.css des Frontends → Stories sehen aus wie live.
import '../src/app.css';
// MSW starten. `onUnhandledRequest: 'bypass'` → Requests ohne Handler laufen
// normal durch (z.B. der Vite-Proxy für /cms-images). Nur explizit gemockte
// Endpoints werden abgefangen → Blocks mit fetch entwickeln sich deterministisch
// offline, per Story via `parameters.msw.handlers`.
initialize({ onUnhandledRequest: 'bypass' });
// Default-Mock für den SvelteKit-page-Store/-State ($app/stores + $app/state).
// Ohne das crasht jede Komponente, die $page.url liest (Header, PostActions, …).
// Einzelne Stories können via parameters.sveltekit_experimental überschreiben.
const mockPage = {
url: new URL('http://localhost:6006/'),
params: {},
route: { id: null },
status: 200,
error: null,
data: {},
form: null,
state: {},
};
const preview: Preview = {
loaders: [mswLoader],
// Alle Stories in den Translation-Context wrappen → echte Texte statt Keys.
decorators: [() => WithTranslations],
parameters: {
sveltekit_experimental: {
stores: { page: mockPage },
state: { page: mockPage },
},
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
// Frontend rendert auf hellem Grund; Storybook-Canvas dazu passend.
backgrounds: {
default: 'weiss',
values: [
{ name: 'weiss', value: '#ffffff' },
{ name: 'stein', value: '#f5f5f4' },
],
},
},
};
export default preview;
+2461 -13
View File
File diff suppressed because it is too large Load Diff
+10 -2
View File
@@ -17,11 +17,17 @@
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"prepare": "svelte-kit sync || true"
"prepare": "svelte-kit sync || true",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"devDependencies": {
"@iconify-json/lucide": "^1.2.114",
"@iconify-json/mdi": "^1.2.3",
"@storybook/addon-a11y": "^10.5.0",
"@storybook/addon-docs": "^10.5.0",
"@storybook/addon-svelte-csf": "^5.1.2",
"@storybook/sveltekit": "^10.5.0",
"@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/kit": "^2.65.0",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
@@ -31,7 +37,10 @@
"@types/qrcode": "^1.5.6",
"@types/sanitize-html": "^2.13.0",
"@vitest/coverage-v8": "^2.1.9",
"msw": "^2.15.0",
"msw-storybook-addon": "^2.0.7",
"openapi-typescript": "^7.4.0",
"storybook": "^10.5.0",
"svelte": "^5.28.2",
"svelte-check": "^4.1.4",
"tailwindcss": "^4.1.18",
@@ -40,7 +49,6 @@
"vitest": "^2.1.9"
},
"dependencies": {
"@fontsource-variable/aleo": "^5.2.7",
"@fontsource-variable/lora": "^5.2.8",
"@fontsource-variable/rubik": "^5.2.8",
"@fontsource/inter": "^5.2.8",
+2 -22
View File
@@ -191,7 +191,7 @@ main {
/* ==========================================================================
Typography Type Scale (02-typography.md)
Erlaubte Gewichte: 300, 400, 500, 600, 700. Inter primary, Aleo nur Zitate.
Erlaubte Gewichte: 300, 400, 500, 600, 700. Inter primary, Lora nur Zitate.
========================================================================== */
h1,
@@ -202,26 +202,6 @@ h4 {
text-wrap: balance;
}
/* Leitstatement redaktioneller Aufhänger: größer als Fließtext, leichter als H2 */
.leitstatement {
font-size: clamp(1.625rem, 3vw, 2.375rem); /* 2638px */
font-weight: 600;
line-height: 1.2;
letter-spacing: -0.02em;
color: var(--text-primary);
max-width: 24ch;
text-wrap: balance;
}
/* Kicker Sektionsmarke, z. B. „01 — ÜBER UNS" */
.kicker {
font-size: 0.75rem; /* 12px */
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent); /* wald */
}
/* Mobile first, dann Tablet (768px), Desktop (1024px) */
h1 {
font-size: 1.75rem;
@@ -409,7 +389,7 @@ main ol:not(.not-prose):where(:not(.not-prose *)) li {
.btn-tertiary,
.btn-outline,
.btn-warning {
@apply inline-flex items-center gap-1 no-underline whitespace-nowrap transition-colors duration-200 font-medium px-4 py-2 rounded-xs;
@apply inline-flex items-center gap-1 no-underline whitespace-nowrap transition-colors duration-200 font-medium px-4 py-2 rounded-md;
}
/* Buttons Wald (Primary) */
+42
View File
@@ -121,6 +121,12 @@ export interface ImageGalleryBlockData {
description?: string;
/** Optional: oberhalb des Galerie-Modals als Header. */
title?: string;
/** SectionHeader: Label über dem Titel (uppercase). */
kicker?: string;
/** SectionHeader: Index vor dem Kicker, z.B. "03". */
number?: string;
/** SectionHeader: CTA-Link (aufgelöst: url, linkName). */
action?: string | { url?: string; linkName?: string; newTab?: boolean };
/** "standard" = Carousel (Default), "grid" = Thumbnail-Grid mit Modal. */
variant?: "standard" | "grid";
images?: ImageGalleryImage[];
@@ -144,6 +150,12 @@ export interface FilesBlockData {
_type?: "files";
_slug?: string;
headline?: string;
/** SectionHeader: Label über dem Titel (uppercase). */
kicker?: string;
/** SectionHeader: Index vor dem Kicker, z.B. "03". */
number?: string;
/** SectionHeader: CTA-Link (aufgelöst: url, linkName). */
action?: string | { url?: string; linkName?: string; newTab?: boolean };
description?: string;
items?: Array<string | FilesBlockItem>;
forceDownload?: boolean;
@@ -174,6 +186,12 @@ export interface YoutubeVideoGalleryBlockData {
_slug?: string;
title?: string;
subtitle?: string;
/** SectionHeader: Label über dem Titel (uppercase). */
kicker?: string;
/** SectionHeader: Index vor dem Kicker, z.B. "03". */
number?: string;
/** SectionHeader: CTA-Link (aufgelöst: url, linkName). */
action?: string | { url?: string; linkName?: string; newTab?: boolean };
description?: string;
variant?: "carousel" | "grid";
videos?: YoutubeVideoBlockData[];
@@ -226,6 +244,12 @@ export interface PostOverviewBlockData {
_type?: "post_overview";
_slug?: string;
headline?: string;
/** SectionHeader: kleines Label über dem Titel (uppercase). */
kicker?: string;
/** SectionHeader: optionaler Index vor dem Kicker, z.B. "03". */
number?: string;
/** SectionHeader: CTA-Link (aufgelöst aus link-Collection: url, linkName). */
action?: string | { url?: string; linkName?: string; newTab?: boolean };
/** Einleitungstext (Markdown). */
text?: string;
/** true = alle Posts laden; false = posts (Slugs/Objekte) aus dem Block nutzen. */
@@ -286,6 +310,12 @@ export interface SearchableTextBlockData {
_slug?: string;
/** Titel der Suchkomponente. */
title?: string;
/** SectionHeader: Label über dem Titel (uppercase). */
kicker?: string;
/** SectionHeader: Index vor dem Kicker, z.B. "03". */
number?: string;
/** SectionHeader: CTA-Link (aufgelöst: url, linkName). */
action?: string | { url?: string; linkName?: string; newTab?: boolean };
/** Einleitung (Markdown). */
description?: string;
/** Slugs oder aufgelöste text_fragment-Einträge. */
@@ -326,6 +356,12 @@ export interface OrganisationsBlockData {
_type?: "organisations";
_slug?: string;
headline?: string;
/** SectionHeader: Label über dem Titel (uppercase). */
kicker?: string;
/** SectionHeader: Index vor dem Kicker, z.B. "03". */
number?: string;
/** SectionHeader: CTA-Link (aufgelöst: url, linkName). */
action?: string | { url?: string; linkName?: string; newTab?: boolean };
description?: string;
/** default = Karten-Grid; compact = engere Karten, Logo links; carousel = horizontaler Slider (3 Desktop / 2 Mobile) */
presentation?: "default" | "compact" | "carousel";
@@ -341,6 +377,12 @@ export interface OrganisationsMapBlockData {
_type?: "organisations_map";
_slug?: string;
headline?: string;
/** SectionHeader: Label über dem Titel (uppercase). */
kicker?: string;
/** SectionHeader: Index vor dem Kicker, z.B. "03". */
number?: string;
/** SectionHeader: CTA-Link (aufgelöst: url, linkName). */
action?: string | { url?: string; linkName?: string; newTab?: boolean };
organisations?: (string | OrganisationEntry)[];
layout?: BlockLayout;
}
@@ -0,0 +1,22 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import Accordion from './Accordion.svelte';
const { Story } = defineMeta({
title: 'Molecules/Accordion',
component: Accordion,
tags: ['autodocs'],
argTypes: {
open: { control: 'boolean' },
lazy: { control: 'boolean' },
},
});
</script>
<Story name="Geschlossen" args={{ label: 'Details anzeigen' }}>
<p class="p-4">Aufklappbarer Inhalt mit beliebigem Markup.</p>
</Story>
<Story name="Offen" args={{ label: 'Bereits offen', open: true }}>
<p class="p-4">Dieser Abschnitt startet aufgeklappt.</p>
</Story>
+14
View File
@@ -0,0 +1,14 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import Badge from './Badge.svelte';
const { Story } = defineMeta({
title: 'Molecules/Badge',
component: Badge,
tags: ['autodocs'],
});
</script>
<Story name="Standard">Neu</Story>
<Story name="Wald" args={{ color: 'wald' }}>Aktiv</Story>
<Story name="Fire" args={{ color: 'fire' }}>Dringend</Story>
+24
View File
@@ -0,0 +1,24 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import Card from './Card.svelte';
const { Story } = defineMeta({
title: 'Molecules/Card',
component: Card,
tags: ['autodocs'],
});
</script>
<Story name="Tile">
<div class="p-4">
<h3 class="font-semibold">Karten-Titel</h3>
<p class="text-sm text-stein-600">Inhalt der Kachel.</p>
</div>
</Story>
<Story name="AlsLink" args={{ href: '#', variant: 'tile' }}>
<div class="p-4">
<h3 class="font-semibold">Klickbare Karte</h3>
<p class="text-sm text-stein-600">Ganze Karte ist ein Link.</p>
</div>
</Story>
@@ -0,0 +1,33 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import Carousel from './Carousel.svelte';
const { Story } = defineMeta({
title: 'Organisms/Carousel',
component: Carousel,
tags: ['autodocs'],
});
const colors = ['wald', 'erde', 'himmel', 'fire', 'stein'];
</script>
<!-- Carousel bekommt die Anzahl + eine `item`-Snippet, die pro Index rendert. -->
<Story name="Standard" asChild>
<Carousel count={5} ariaLabel="Demo-Karussell">
{#snippet item(i)}
<div class="flex h-40 items-center justify-center rounded-sm bg-{colors[i]}-100 text-xl font-semibold">
Slide {i + 1}
</div>
{/snippet}
</Carousel>
</Story>
<Story name="AutoRotate" asChild>
<Carousel count={3} autoRotateMs={2000} ariaLabel="Auto-Karussell">
{#snippet item(i)}
<div class="flex h-40 items-center justify-center rounded-sm bg-wald-100 text-xl font-semibold">
Auto {i + 1}
</div>
{/snippet}
</Carousel>
</Story>
+16 -14
View File
@@ -3,6 +3,7 @@
import "$lib/iconify-offline";
import { env } from "$env/dynamic/public";
import { useTranslate, T } from "$lib/translations";
import Button from "$lib/ui/Button.svelte";
let {
pageId,
@@ -310,10 +311,7 @@
maxlength={bodyMax}
></textarea>
<div class="mt-1 flex items-center gap-3 text-xs">
<button
type="submit"
class="rounded-xs bg-stein-800 px-3 py-1 font-medium text-white hover:bg-stein-700"
>{t(T.comments_save)}</button>
<Button type="submit" variant="primary" size="sm" label={t(T.comments_save)} />
<button
type="button"
onclick={cancelEdit}
@@ -457,11 +455,14 @@
></textarea>
{#if replyError}<p class="text-fire-700 text-xs">{replyError}</p>{/if}
<div class="flex items-center gap-2">
<button
<Button
type="submit"
disabled={submitting}
class="px-3 py-1.5 bg-stein-800 text-white rounded-xs text-sm font-medium hover:bg-stein-700 disabled:opacity-50"
>{t(T.comments_reply_send)}</button>
variant="primary"
size="sm"
loading={submitting}
label={t(T.comments_reply_send)}
class="text-sm"
/>
<span class="ml-auto text-xs text-stein-400 tabular-nums">{replyBody.length} / {bodyMax}</span>
</div>
</form>
@@ -522,13 +523,14 @@
<p class="text-fire-700 text-xs">{submitError}</p>
{/if}
<div class="flex flex-wrap items-center gap-3 text-xs">
<button
<Button
type="submit"
disabled={submitting || !body.trim() || !author.trim()}
class="px-4 py-2 bg-stein-800 text-white rounded-xs text-sm font-medium hover:bg-stein-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? t(T.comments_sending) : t(T.comments_send)}
</button>
variant="primary"
loading={submitting}
disabled={!body.trim() || !author.trim()}
label={submitting ? t(T.comments_sending) : t(T.comments_send)}
class="text-sm"
/>
{#if requireApproval}
<span class="text-stein-500 inline-flex items-center gap-1">
<Icon icon="lucide:shield-check" class="size-3.5" />
+28
View File
@@ -0,0 +1,28 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import ContactCard from './ContactCard.svelte';
const meta = {
title: 'Molecules/ContactCard',
component: ContactCard,
tags: ['autodocs'],
} satisfies Meta<typeof ContactCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standard: Story = {
args: {
contact: {
name: 'Maria Müller',
role: 'Sprecherin',
email: 'maria@example.org',
phone: '01234 567890',
note: 'Erreichbar MoFr',
organisations: [{ name: 'BI Steigerwald' }],
},
},
};
export const Minimal: Story = {
args: { contact: { name: 'Hans Weber', role: 'Presse', email: 'presse@example.org' } },
};
+23
View File
@@ -0,0 +1,23 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import EventBadges from './EventBadges.svelte';
const meta = {
title: 'Molecules/EventBadges',
component: EventBadges,
tags: ['autodocs'],
} satisfies Meta<typeof EventBadges>;
export default meta;
type Story = StoryObj<typeof meta>;
export const MitOrt: Story = {
args: {
eventDate: '2026-08-15T18:00:00Z',
locationText: 'Rathaus Schleusingen',
locationHref: 'https://www.openstreetmap.org',
},
};
export const NurDatum: Story = {
args: { eventDate: '2026-09-05T19:00:00Z' },
};
+22
View File
@@ -0,0 +1,22 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import EventMap from './EventMap.svelte';
// Karten-Embed für Termine (iframe + Link). Tiles laden extern bei Netzzugriff.
const meta = {
title: 'Molecules/EventMap',
component: EventMap,
tags: ['autodocs'],
} satisfies Meta<typeof EventMap>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standard: Story = {
args: {
embedUrl:
'https://www.openstreetmap.org/export/embed.html?bbox=10.6,50.4,10.9,50.6&layer=mapnik',
linkUrl: 'https://www.openstreetmap.org/#map=13/50.51/10.75',
locationText: 'Rathaus Schleusingen',
label: 'Veranstaltungsort',
},
};
+37
View File
@@ -0,0 +1,37 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import Header from './Header.svelte';
// Nutzt den SvelteKit-page-Store (aktiver Link) — vom sveltekit-Framework
// gemockt. Logo als Inline-SVG-Platzhalter.
const meta = {
title: 'Organisms/Header',
component: Header,
tags: ['autodocs'],
parameters: { layout: 'fullscreen' },
} satisfies Meta<typeof Header>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standard: Story = {
args: {
logoSvgHtml: '<span style="font-weight:700;font-size:1.25rem">Windwiderstand</span>',
links: [
{ href: '/', label: 'Start' },
{
href: '/verfahren',
label: 'Verfahren',
children: [
{ href: '/verfahren/ablauf', label: 'Ablauf' },
{ href: '/verfahren/fristen', label: 'Fristen' },
],
},
{ href: '/posts', label: 'Aktuelles' },
{ href: '/mitmachen', label: 'Mitmachen' },
],
socialLinks: [
{ href: 'https://example.org', icon: 'mdi:instagram', label: 'Instagram' },
{ href: 'https://example.org', icon: 'mdi:youtube', label: 'YouTube' },
],
},
};
@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import InfoAccordion from './InfoAccordion.svelte';
const meta = {
title: 'Molecules/InfoAccordion',
component: InfoAccordion,
tags: ['autodocs'],
args: {
title: 'Wie läuft das Verfahren ab?',
body: 'Der Regionalplan wird ausgelegt, Einwendungen sind bis zum Fristende möglich.',
},
} satisfies Meta<typeof InfoAccordion>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Geschlossen: Story = {};
export const Offen: Story = { args: { open: true } };
export const Warnung: Story = {
args: { variant: 'amber', iconName: 'lucide:alert-triangle', title: 'Frist endet bald', open: true },
};
+17
View File
@@ -0,0 +1,17 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import Pagination from './Pagination.svelte';
const meta = {
title: 'Molecules/Pagination',
component: Pagination,
tags: ['autodocs'],
args: { currentPage: 2, totalPages: 5, basePath: '/posts' },
} satisfies Meta<typeof Pagination>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Mitte: Story = {};
export const ErsteSeite: Story = { args: { currentPage: 1 } };
export const LetzteSeite: Story = { args: { currentPage: 5 } };
export const Viele: Story = { args: { currentPage: 7, totalPages: 20 } };
+32
View File
@@ -0,0 +1,32 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import PostCard from './PostCard.svelte';
const CMS_ASSET =
'https://cms.pm86.de/api/assets/pages/img_2814-ar4x2.webp?_environment=windwiderstand';
const meta = {
title: 'Organisms/PostCard',
component: PostCard,
tags: ['autodocs'],
} satisfies Meta<typeof PostCard>;
export default meta;
type Story = StoryObj<typeof meta>;
const post = {
_slug: 'infoabend-schleusingen',
slug: 'infoabend-schleusingen',
headline: 'Infoabend in Schleusingen',
subheadline: 'Über 80 Bürgerinnen und Bürger kamen zusammen.',
created: '2026-06-12T18:00:00Z',
postTag: [{ name: 'Veranstaltung' }, { name: 'Verfahren' }],
_resolvedImageUrl: CMS_ASSET,
} as never;
export const MitBild: Story = {
args: { post, href: '/posts/infoabend-schleusingen', commentCount: 4 },
};
export const OhneKommentare: Story = {
args: { post, href: '/posts/infoabend-schleusingen', commentCount: null },
};
@@ -0,0 +1,28 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import PostCardCompact from './PostCardCompact.svelte';
const CMS_ASSET =
'https://cms.pm86.de/api/assets/pages/img_2814-ar4x2.webp?_environment=windwiderstand';
const meta = {
title: 'Organisms/PostCardCompact',
component: PostCardCompact,
tags: ['autodocs'],
} satisfies Meta<typeof PostCardCompact>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standard: Story = {
args: {
post: {
_slug: 'frist-laeuft',
slug: 'frist-laeuft',
headline: 'Frist für Einwendungen läuft',
created: '2026-06-01T09:00:00Z',
postTag: [{ name: 'Verfahren' }],
_resolvedImageUrl: CMS_ASSET,
} as never,
href: '/posts/frist-laeuft',
},
};
+20
View File
@@ -0,0 +1,20 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import PostMeta from './PostMeta.svelte';
const meta = {
title: 'Molecules/PostMeta',
component: PostMeta,
tags: ['autodocs'],
args: {
date: '2026-06-12T18:00:00Z',
tags: [{ name: 'Verfahren' }, { name: 'Natur' }],
tagBase: '/posts/tag',
},
} satisfies Meta<typeof PostMeta>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standard: Story = {};
export const NurDatum: Story = { args: { tags: [] } };
export const NurTags: Story = { args: { date: null } };
+19
View File
@@ -0,0 +1,19 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import QrButton from './QrButton.svelte';
// Button, der auf Klick ein QR-Modal öffnet (aus `value`).
const meta = {
title: 'Molecules/QrButton',
component: QrButton,
tags: ['autodocs'],
args: {
value: 'https://windwiderstand.de',
label: 'QR-Code',
sublabel: 'Seite teilen',
},
} satisfies Meta<typeof QrButton>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standard: Story = {};
+6 -4
View File
@@ -3,6 +3,7 @@
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
import QRCode from "qrcode";
import Button from "$lib/ui/Button.svelte";
let {
value,
@@ -87,15 +88,16 @@
<div class="flex h-48 items-center justify-center text-xs text-stein-300">Generiere…</div>
{/if}
<p class="mt-3 text-center text-[0.65rem] text-stein-400">QR-Code scannen</p>
<button
type="button"
<Button
variant="outline"
size="sm"
onclick={downloadPng}
disabled={!qrPngUrl}
class="btn-outline btn-sm mt-3 w-full justify-center"
class="mt-3 w-full"
>
<Icon icon="lucide:download" class="size-3.5" />
Als PNG herunterladen
</button>
</Button>
</div>
</div>
{/if}
@@ -0,0 +1,44 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import SectionHeader from './SectionHeader.svelte';
const meta = {
title: 'Molecules/SectionHeader',
component: SectionHeader,
tags: ['autodocs'],
argTypes: {
number: { control: 'text' },
kicker: { control: 'text' },
title: { control: 'text' },
subtitle: { control: 'text' },
actionLabel: { control: 'text' },
actionHref: { control: 'text' },
tag: { control: 'select', options: ['h1', 'h2', 'h3'] },
},
} satisfies Meta<typeof SectionHeader>;
export default meta;
type Story = StoryObj<typeof meta>;
export const MitAktion: Story = {
args: {
number: '03',
kicker: 'Vor Ort',
title: 'Beteiligte Initiativen',
subtitle: '13 Bürgerinitiativen im Bündnis — direkt vor Ort ansprechbar.',
actionLabel: 'Zum Adressbuch',
actionHref: '/adressbuch',
},
};
export const OhneAktion: Story = {
args: { number: '01', kicker: 'Über uns', title: 'Wer sind wir?' },
};
export const OhneNummer: Story = {
args: {
kicker: 'Aktuelles',
title: 'Die letzten Beiträge',
actionLabel: 'Alle Beiträge',
actionHref: '/aktuelles',
},
};
+80
View File
@@ -0,0 +1,80 @@
<script lang="ts">
/**
* SectionHeader — zusammengesetzter Sektionskopf (Kicker + Titel + Subline + Aktion).
* Nutzt HeadlineBlock NICHT (das bleibt CMS-Primitiv für Fließtext-Headlines);
* dies ist das wiederverwendbare Layout-Muster für Seitensektionen.
*
* <SectionHeader number="03" kicker="Vor Ort" title="Beteiligte Initiativen"
* subtitle="13 Bürgerinitiativen im Bündnis — direkt vor Ort ansprechbar."
* actionLabel="Zum Adressbuch" actionHref="/adressbuch" />
*/
import type { Snippet } from 'svelte';
let {
number = undefined,
kicker = undefined,
title,
subtitle = undefined,
actionLabel = undefined,
actionHref = undefined,
tag = 'h2',
children = undefined,
}: {
number?: string; // z. B. "03" — tabular-nums, wald-500
kicker?: string; // Label, uppercase — optional (CMS-Blocks liefern oft nur title)
title: string;
subtitle?: string;
actionLabel?: string;
actionHref?: string;
tag?: 'h1' | 'h2' | 'h3';
children?: Snippet; // optionaler eigener Aktions-Slot rechts
} = $props();
const hasAction = $derived(!!children || (!!actionLabel && !!actionHref));
// Kicker-Zeile (Nummer · Linie · Label) nur wenn Nummer ODER Kicker gesetzt —
// sonst dangelt die Deko-Linie allein.
const hasKickerRow = $derived(!!number || !!kicker);
</script>
<div class="flex items-end justify-between gap-6">
<div>
<!-- Kicker: Nummer · Linie · Label -->
{#if hasKickerRow}
<div class="mb-3.5 flex items-center gap-3">
{#if number}
<span class="text-[13px] font-semibold tabular-nums text-wald-500">{number}</span>
{/if}
<span class="h-px w-7 bg-wald-200"></span>
{#if kicker}
<span class="text-xs font-semibold uppercase tracking-[0.14em] text-stein-500">{kicker}</span>
{/if}
</div>
{/if}
<!-- Titel -->
<svelte:element this={tag} class="text-3xl font-bold tracking-[-0.02em] text-stein-800 lg:text-[34px]">
{title}
</svelte:element>
{#if subtitle}
<p class="mt-2 text-base text-stein-500">{subtitle}</p>
{/if}
</div>
<!-- Aktion (Slot hat Vorrang, sonst Label+Href) -->
{#if hasAction}
<div class="whitespace-nowrap">
{#if children}
{@render children()}
{:else}
<a
href={actionHref}
class="inline-flex items-center gap-1.5 text-[15px] font-semibold text-himmel-500 no-underline hover:text-himmel-600"
>
{actionLabel}
<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>
{/if}
</div>
+7 -6
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
import Button from "$lib/ui/Button.svelte";
let {
open,
@@ -169,10 +170,10 @@
onload={() => (loadedOg = true)}
/>
</div>
<a href={ogUrl} download={`${downloadPrefix}-${slug}-og-${theme}.png`} class="btn-outline btn-sm justify-center no-underline!">
<Button href={ogUrl} download={`${downloadPrefix}-${slug}-og-${theme}.png`} variant="outline" size="sm">
<Icon icon="lucide:download" class="size-3.5" />
Quer herunterladen
</a>
</Button>
</div>
<div class="flex flex-col gap-2">
<p class="text-xs font-medium text-stein-600">Quadrat · 1080 × 1080</p>
@@ -188,10 +189,10 @@
onload={() => (loadedSq = true)}
/>
</div>
<a href={sqUrl} download={`${downloadPrefix}-${slug}-square-${theme}.png`} class="btn-outline btn-sm justify-center no-underline!">
<Button href={sqUrl} download={`${downloadPrefix}-${slug}-square-${theme}.png`} variant="outline" size="sm">
<Icon icon="lucide:download" class="size-3.5" />
Quadrat herunterladen
</a>
</Button>
</div>
<div class="flex flex-col gap-2">
<p class="text-xs font-medium text-stein-600">Story · 1080 × 1920</p>
@@ -207,10 +208,10 @@
onload={() => (loadedStory = true)}
/>
</div>
<a href={storyUrl} download={`${downloadPrefix}-${slug}-story-${theme}.png`} class="btn-outline btn-sm justify-center no-underline!">
<Button href={storyUrl} download={`${downloadPrefix}-${slug}-story-${theme}.png`} variant="outline" size="sm">
<Icon icon="lucide:download" class="size-3.5" />
Story herunterladen
</a>
</Button>
</div>
</div>
+22
View File
@@ -0,0 +1,22 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import Tag from './Tag.svelte';
const meta = {
title: 'Molecules/Tag',
component: Tag,
tags: ['autodocs'],
args: { label: 'Verfahren' },
argTypes: {
variant: { control: 'select', options: ['white', 'green', 'blue', 'inactive', 'date'] },
},
} satisfies Meta<typeof Tag>;
export default meta;
type Story = StoryObj<typeof meta>;
export const White: Story = { args: { variant: 'white' } };
export const Green: Story = { args: { variant: 'green' } };
export const Blue: Story = { args: { variant: 'blue' } };
export const Inactive: Story = { args: { variant: 'inactive' } };
export const AlsLink: Story = { args: { variant: 'green', href: '/posts/tag/verfahren' } };
export const MitIcon: Story = { args: { variant: 'date', icon: 'lucide:calendar', label: '15.08.2026' } };
+19
View File
@@ -0,0 +1,19 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import Tags from './Tags.svelte';
const meta = {
title: 'Molecules/Tags',
component: Tags,
tags: ['autodocs'],
args: {
tags: [{ name: 'Verfahren' }, { name: 'Natur' }, { name: 'Landschaft' }],
variant: 'green',
tagBase: '/posts/tag',
},
} satisfies Meta<typeof Tags>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standard: Story = {};
export const Weiss: Story = { args: { variant: 'white' } };
+31
View File
@@ -0,0 +1,31 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import TopBanner from './TopBanner.svelte';
// Vollbreiter Seiten-Header mit Bild + Headline. `resolvedImages` = fertige
// Bild-URL(s); läuft über den /cms-images-Proxy.
const CMS_ASSET =
'https://cms.pm86.de/api/assets/pages/img_2814-ar4x2.webp?_environment=windwiderstand';
const meta = {
title: 'Organisms/TopBanner',
component: TopBanner,
tags: ['autodocs'],
parameters: { layout: 'fullscreen' },
} satisfies Meta<typeof TopBanner>;
export default meta;
type Story = StoryObj<typeof meta>;
export const MitBild: Story = {
args: {
banner: null,
resolvedImages: [CMS_ASSET],
focalCss: '50% 40%',
headline: 'Wald statt Windkraft',
subheadline: 'Für den Erhalt des Steigerwaldes',
},
};
export const NurText: Story = {
args: { banner: null, headline: 'Windwiderstand', subheadline: 'Bürgerinitiative Steigerwald' },
};
@@ -0,0 +1,25 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import WindAreaPanel from './WindAreaPanel.svelte';
import windAreaFixture from './blocks/_fixtures/wind_area.json';
// Detail-Panel für ein Wind-Vorranggebiet (aus WindkarteMap heraus geöffnet).
// Nutzt echtes wind_area-Objekt aus der CMS-Fixture.
const area = windAreaFixture.items[0] as never;
const meta = {
title: 'Organisms/WindAreaPanel',
component: WindAreaPanel,
tags: ['autodocs'],
} satisfies Meta<typeof WindAreaPanel>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standard: Story = {
args: {
area,
center: [50.5, 10.75],
cmsBase: 'https://cms.pm86.de',
onclose: () => {},
},
};
@@ -0,0 +1,36 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import AdressbuchBlock from './AdressbuchBlock.svelte';
// `resolvedContacts` wird server-seitig injiziert (nicht im CMS-Schema).
const meta = {
title: 'CMS-Blocks/Adressbuch',
component: AdressbuchBlock,
tags: ['autodocs'],
argTypes: { block: { control: 'object' } },
} satisfies Meta<typeof AdressbuchBlock>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standard: Story = {
args: {
block: {
resolvedContacts: [
{
name: 'Maria Müller',
role: 'Sprecherin',
email: 'maria@example.org',
phone: '01234 567890',
organisations: [{ name: 'BI Steigerwald' }],
},
{
name: 'Hans Weber',
role: 'Presse',
email: 'presse@example.org',
organisations: [{ name: 'Waldfreunde e.V.' }],
},
{ name: 'Petra Schmidt', role: 'Kasse', email: 'kasse@example.org' },
],
},
},
};
@@ -5,6 +5,7 @@
import type { AdressbuchBlockData, ContactEntry } from "$lib/block-types";
import { downloadVCard, downloadVCardAll, buildVCardString } from "$lib/vcard";
import QrButton from "$lib/components/QrButton.svelte";
import Button from "$lib/ui/Button.svelte";
let { block }: { block: AdressbuchBlockData } = $props();
@@ -219,14 +220,15 @@
</div>
<div class="mt-2 flex items-center justify-between">
<button
type="button"
<Button
variant="outline"
size="sm"
onclick={() => downloadVCardAll(filtered())}
class="no-print btn-outline btn-sm"
class="no-print"
>
<Icon icon="lucide:download" class="size-3.5" />
Alle exportieren (.vcf)
</button>
</Button>
<p class="text-xs text-stein-400">{filtered().length} Kontakt{filtered().length !== 1 ? "e" : ""}</p>
</div>
{/if}
@@ -0,0 +1,43 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import CalendarBlock from './CalendarBlock.svelte';
const meta = {
title: 'CMS-Blocks/Calendar',
component: CalendarBlock,
tags: ['autodocs'],
argTypes: { block: { control: 'object' } },
} satisfies Meta<typeof CalendarBlock>;
export default meta;
type Story = StoryObj<typeof meta>;
const items = [
{
_slug: 'infoabend',
title: 'Infoabend Schleusingen',
terminZeit: '2026-08-15T18:00:00Z',
description: 'Vorstellung des Verfahrens und Diskussion.',
location: { text: 'Rathaus Schleusingen', lat: 50.51, lng: 10.75 },
tags: ['veranstaltung'],
},
{
_slug: 'wanderung',
title: 'Waldwanderung',
terminZeit: '2026-08-23T10:00:00Z',
terminEnde: '2026-08-23T13:00:00Z',
description: 'Gemeinsame Wanderung durch das betroffene Gebiet.',
location: { text: 'Parkplatz Waldrand' },
tags: ['aktion'],
},
{
_slug: 'stammtisch',
title: 'Stammtisch',
terminZeit: '2026-09-05T19:00:00Z',
description: 'Offener Austausch.',
tags: ['treffen'],
},
];
export const Standard: Story = {
args: { block: { title: 'Termine', variant: 'default', fromPost: false, items } },
};
+39 -25
View File
@@ -4,6 +4,7 @@
import type { CalendarBlockData, CalendarItemData } from "$lib/block-types";
import { t as tStatic, T } from "$lib/translations";
import type { Translations } from "$lib/translations";
import Button from "$lib/ui/Button.svelte";
import {
downloadICS,
downloadICSFeed,
@@ -791,9 +792,10 @@
{#if item._slug || eventHref || locText || item.attachment?.src}
<div class="flex flex-wrap gap-2">
{#if item._slug}
<a
<Button
href={`/kalender/termin/${encodeURIComponent(item._slug)}/`}
class="btn-tertiary btn-sm"
variant="tertiary"
size="sm"
>
Details
<Icon
@@ -801,14 +803,15 @@
class="size-3.5"
aria-hidden="true"
/>
</a>
</Button>
{/if}
{#if eventHref}
<a
<Button
href={eventHref}
variant="tertiary"
size="sm"
target="_blank"
rel="noopener noreferrer"
class="btn-tertiary btn-sm"
>
{t(T.calendar_more_info)}
<Icon
@@ -816,14 +819,16 @@
class="size-3.5"
aria-hidden="true"
/>
</a>
</Button>
{/if}
{#if locText}
<a
<Button
href={mapsUrl(locText)}
variant="outline"
size="sm"
target="_blank"
rel="noopener noreferrer"
class="btn-outline btn-sm group"
class="group"
aria-label={t(T.calendar_open_maps)}
>
<Icon
@@ -832,13 +837,15 @@
aria-hidden="true"
/>
<span class="whitespace-nowrap">{t(T.calendar_open_maps)}</span>
</a>
</Button>
{/if}
{#if item.attachment?.src}
{@const dlSrc = `/cms-files?src=${encodeURIComponent(item.attachment.src)}&dl=1`}
<a
<Button
href={dlSrc}
class="btn-outline btn-sm group"
variant="outline"
size="sm"
class="group"
aria-label={item.attachment.label || "Anhang herunterladen"}
>
<Icon
@@ -847,7 +854,7 @@
aria-hidden="true"
/>
<span class="whitespace-nowrap">{item.attachment.label || "Anhang"}</span>
</a>
</Button>
{/if}
</div>
{/if}
@@ -1033,18 +1040,22 @@
<div
class="flex flex-wrap items-center gap-2 px-4 py-2.5 border-b border-stein-200 bg-himmel-100"
>
<a
<Button
href="/termin-melden"
class="btn-warning btn-sm inline-flex items-center gap-1.5 min-h-[36px]"
variant="warning"
size="sm"
class="min-h-[36px]"
>
<Icon icon="lucide:plus" class="size-3.5 shrink-0" aria-hidden="true" />
Termin melden
</a>
</Button>
{#if isApplePlatform}
<a
<Button
href={webcalUrl}
variant="primary"
size="sm"
aria-label={t(T.calendar_subscribe_aria)}
class="btn-primary btn-sm inline-flex items-center gap-1.5 min-h-[36px]"
class="min-h-[36px]"
>
<Icon
icon="lucide:calendar-plus"
@@ -1052,13 +1063,15 @@
aria-hidden="true"
/>
{t(T.calendar_subscribe)}
</a>
</Button>
{:else}
<a
<Button
href={icsUrl}
download="windwiderstand-termine.ics"
variant="primary"
size="sm"
aria-label={t(T.calendar_subscribe_aria)}
class="btn-primary btn-sm inline-flex items-center gap-1.5 min-h-[36px]"
class="min-h-[36px]"
>
<Icon
icon="lucide:calendar-plus"
@@ -1066,13 +1079,14 @@
aria-hidden="true"
/>
{t(T.calendar_subscribe)}
</a>
</Button>
{/if}
{#if items.length > 0}
<button
type="button"
<Button
variant="outline"
size="sm"
onclick={exportAll}
class="btn-outline btn-sm inline-flex items-center gap-1.5 min-h-[36px]"
class="min-h-[36px]"
>
<Icon
icon="lucide:download"
@@ -1080,7 +1094,7 @@
aria-hidden="true"
/>
{t(T.calendar_export_all)}
</button>
</Button>
{/if}
</div>
{/if}
@@ -0,0 +1,41 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import CalendarCompactBlock from './CalendarCompactBlock.svelte';
const meta = {
title: 'CMS-Blocks/CalendarCompact',
component: CalendarCompactBlock,
tags: ['autodocs'],
argTypes: { block: { control: 'object' } },
} satisfies Meta<typeof CalendarCompactBlock>;
export default meta;
type Story = StoryObj<typeof meta>;
const items = [
{
_slug: 'infoabend',
title: 'Infoabend Schleusingen',
terminZeit: '2026-08-15T18:00:00Z',
location: { text: 'Rathaus Schleusingen' },
},
{
_slug: 'wanderung',
title: 'Waldwanderung',
terminZeit: '2026-08-23T10:00:00Z',
location: { text: 'Parkplatz Waldrand' },
},
{ _slug: 'stammtisch', title: 'Stammtisch', terminZeit: '2026-09-05T19:00:00Z' },
];
export const Standard: Story = {
args: {
block: {
title: 'Nächste Termine',
variant: 'compact',
fromPost: false,
limit: 3,
linkTo: { _slug: 'termine' },
items,
},
},
};
@@ -0,0 +1,24 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import ContactsBlock from './ContactsBlock.svelte';
const meta = {
title: 'CMS-Blocks/Contacts',
component: ContactsBlock,
tags: ['autodocs'],
argTypes: { block: { control: 'object' } },
} satisfies Meta<typeof ContactsBlock>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standard: Story = {
args: {
block: {
headline: 'Ansprechpartner',
contacts: [
{ name: 'Maria Müller', role: 'Sprecherin', email: 'maria@example.org', phone: '01234 567890' },
{ name: 'Hans Weber', role: 'Presse', email: 'presse@example.org' },
],
},
},
};
@@ -0,0 +1,39 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import DeadlineBannerBlock from './DeadlineBannerBlock.svelte';
const meta = {
title: 'CMS-Blocks/DeadlineBanner',
component: DeadlineBannerBlock,
tags: ['autodocs'],
argTypes: { block: { control: 'object' } },
} satisfies Meta<typeof DeadlineBannerBlock>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Dringend: Story = {
args: {
block: {
mode: 'manual',
label: 'Einwendungsfrist',
text: 'Noch Zeit für Ihre Stellungnahme',
date: '2026-09-30',
variant: 'urgent',
showCountdown: true,
link: { url: '/mitmachen', linkName: 'Jetzt einwenden', newTab: false },
},
},
};
export const Info: Story = {
args: {
block: {
mode: 'manual',
label: 'Hinweis',
text: 'Nächste Infoveranstaltung',
date: '2026-08-15',
variant: 'info',
showCountdown: false,
},
},
};
@@ -0,0 +1,25 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import EventDateBox from './EventDateBox.svelte';
// Kleiner Datums-Kasten (Tag/Monat), genutzt von Kalender-Karten. Kein CMS-Block
// → als Molecule geführt. Nimmt echte Date-Objekte.
const meta = {
title: 'Molecules/EventDateBox',
component: EventDateBox,
tags: ['autodocs'],
} satisfies Meta<typeof EventDateBox>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Eintagestermin: Story = {
args: { start: new Date('2026-08-15T18:00:00') },
};
export const Mehrtagestermin: Story = {
args: {
start: new Date('2026-08-15T10:00:00'),
end: new Date('2026-08-17T16:00:00'),
multiDay: true,
},
};
@@ -0,0 +1,40 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import FilesBlock from './FilesBlock.svelte';
const meta = {
title: 'CMS-Blocks/Files',
component: FilesBlock,
tags: ['autodocs'],
argTypes: { block: { control: 'object' } },
} satisfies Meta<typeof FilesBlock>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standard: Story = {
args: {
block: {
number: '08',
kicker: 'Downloads',
headline: 'Dokumente',
action: { url: '/downloads', linkName: 'Alle Downloads' },
description: 'Downloads zum Verfahren',
items: [
{
src: 'https://cms.pm86.de/api/assets/files/beispiel.pdf?_environment=windwiderstand',
title: 'Einwendung Vorlage',
mime: 'application/pdf',
size: 248000,
filename: 'einwendung-vorlage.pdf',
},
{
src: 'https://cms.pm86.de/api/assets/files/karte.pdf?_environment=windwiderstand',
title: 'Übersichtskarte',
mime: 'application/pdf',
size: 1240000,
filename: 'uebersichtskarte.pdf',
},
],
},
},
};
+16 -3
View File
@@ -2,6 +2,7 @@
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
import { getBlockLayoutClasses } from "$lib/block-layout";
import SectionHeader from "$lib/components/SectionHeader.svelte";
import type { FilesBlockData } from "$lib/block-types";
import {
extractCmsFileField,
@@ -11,6 +12,9 @@
} from "$lib/rusty-file";
let { block }: { block: FilesBlockData } = $props();
const actionRef = $derived(
block.action && typeof block.action === "object" ? block.action : null,
);
const layoutClasses = $derived(
block.layout ? getBlockLayoutClasses(block.layout) : "col-span-12 mb-4",
@@ -108,9 +112,18 @@
<div class="files-block {layoutClasses}" data-block="Files" data-block-type="files" data-block-slug={block._slug}>
{#if block.headline}
<h3 class="mb-3 text-lg font-semibold">{block.headline}</h3>
{/if}
{#if block.description}
<div class="mb-3">
<SectionHeader
number={block.number}
kicker={block.kicker}
title={block.headline}
subtitle={block.description}
tag="h3"
actionLabel={actionRef?.linkName}
actionHref={actionRef?.url}
/>
</div>
{:else if block.description}
<p class="mb-3 text-sm text-stein-700">{block.description}</p>
{/if}
{#if resolved.length === 0}
@@ -0,0 +1,38 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import HeadlineBlock from './HeadlineBlock.svelte';
// CMS-Block in Isolation. `block` = die aufgelöste Block-Datenstruktur, wie sie
// der BlockRenderer von der API bekommt (siehe HeadlineBlockData in block-types.ts).
const meta = {
title: 'CMS-Blocks/Headline',
component: HeadlineBlock,
tags: ['autodocs'],
argTypes: {
block: { control: 'object' },
},
} satisfies Meta<typeof HeadlineBlock>;
export default meta;
type Story = StoryObj<typeof meta>;
export const H2: Story = {
args: {
block: { tag: 'h2', text: 'Windwiderstand im Steigerwald', align: 'left' },
},
};
export const Zentriert: Story = {
args: {
block: { tag: 'h1', text: 'Wald statt Windkraft', align: 'center' },
},
};
export const MitUmbruch: Story = {
args: {
block: {
tag: 'h2',
text: 'Erste Zeile\nZweite Zeile',
align: 'left',
},
},
};
@@ -0,0 +1,20 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import HtmlBlock from './HtmlBlock.svelte';
const meta = {
title: 'CMS-Blocks/Html',
component: HtmlBlock,
tags: ['autodocs'],
argTypes: { block: { control: 'object' } },
} satisfies Meta<typeof HtmlBlock>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standard: Story = {
args: {
block: {
html: '<div style="padding:1rem;border:1px dashed #999;border-radius:8px"><h3>Roher HTML-Block</h3><p>Beliebiges HTML aus dem CMS.</p></div>',
},
},
};
@@ -0,0 +1,22 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import IframeBlock from './IframeBlock.svelte';
const meta = {
title: 'CMS-Blocks/Iframe',
component: IframeBlock,
tags: ['autodocs'],
argTypes: { block: { control: 'object' } },
} satisfies Meta<typeof IframeBlock>;
export default meta;
type Story = StoryObj<typeof meta>;
export const OpenStreetMap: Story = {
args: {
block: {
iframe:
'<iframe width="100%" height="400" frameborder="0" src="https://www.openstreetmap.org/export/embed.html?bbox=10.4,49.9,10.7,50.1&layer=mapnik"></iframe>',
content: 'Kartenausschnitt Steigerwald.',
},
},
};
@@ -0,0 +1,28 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import ImageBlock from './ImageBlock.svelte';
// Bilder laufen über RustyImage → /cms-images. Der Storybook-Vite-Proxy leitet
// /cms-images auf https://windwiderstand.de weiter → echte, transformierte
// Bilder. `src` = rohe CMS-Asset-URL.
const CMS_ASSET =
'https://cms.pm86.de/api/assets/pages/img_2814-ar4x2.webp?_environment=windwiderstand';
const meta = {
title: 'CMS-Blocks/Image',
component: ImageBlock,
tags: ['autodocs'],
argTypes: { block: { control: 'object' } },
} satisfies Meta<typeof ImageBlock>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standard: Story = {
args: {
block: {
image: { src: CMS_ASSET, description: 'Wald im Steigerwald' },
caption: 'Blick über den Steigerwald',
aspectRatio: 16 / 9,
},
},
};
@@ -0,0 +1,41 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import ImageGalleryBlock from './ImageGalleryBlock.svelte';
const CMS_ASSET =
'https://cms.pm86.de/api/assets/pages/img_2814-ar4x2.webp?_environment=windwiderstand';
const meta = {
title: 'CMS-Blocks/ImageGallery',
component: ImageGalleryBlock,
tags: ['autodocs'],
argTypes: { block: { control: 'object' } },
} satisfies Meta<typeof ImageGalleryBlock>;
export default meta;
type Story = StoryObj<typeof meta>;
const images = [
{ src: CMS_ASSET, description: 'Bild 1', title: 'Steigerwald 1' },
{ src: CMS_ASSET, description: 'Bild 2', title: 'Steigerwald 2' },
{ src: CMS_ASSET, description: 'Bild 3', title: 'Steigerwald 3' },
];
export const Grid: Story = {
args: {
block: {
number: '06',
kicker: 'Impressionen',
variant: 'grid',
title: 'Galerie',
description: 'Impressionen',
action: { url: '/galerie', linkName: 'Alle Bilder' },
images,
},
},
};
export const Carousel: Story = {
args: {
block: { variant: 'standard', title: 'Galerie', images },
},
};
@@ -2,6 +2,7 @@
import { marked } from "$lib/markdown-safe";
import { fade } from "svelte/transition";
import { getBlockLayoutClasses } from "$lib/block-layout";
import SectionHeader from "$lib/components/SectionHeader.svelte";
import type { ImageGalleryBlockData, ImageGalleryImage } from "$lib/block-types";
import "$lib/iconify-offline";
import Icon from "@iconify/svelte";
@@ -12,6 +13,9 @@
const t = useTranslate();
let { block }: { block: ImageGalleryBlockData } = $props();
const actionRef = $derived(
block.action && typeof block.action === "object" ? block.action : null,
);
/** Hover-Label aus image-Field — title|alt|caption|description, in dieser Reihenfolge. */
function imageLabel(img: ImageGalleryImage): string {
@@ -166,7 +170,16 @@
<p class="text-sm text-stein-500">{t(T.image_gallery_empty)}</p>
{:else if block.variant === "grid"}
{#if block.title}
<h3 class="text-lg font-semibold text-stein-900 mb-3">{block.title}</h3>
<div class="mb-3">
<SectionHeader
number={block.number}
kicker={block.kicker}
title={block.title}
tag="h3"
actionLabel={actionRef?.linkName}
actionHref={actionRef?.url}
/>
</div>
{/if}
<ul class="not-prose grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4">
{#each withUrl as item, i}
@@ -0,0 +1,32 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import InternalComponentBlock from './InternalComponentBlock.svelte';
// Rendert interne Komponenten (Formulare). Submit läuft gegen SvelteKit-API-
// Routen → in Storybook nur via MSW/Proxy funktional; Darstellung stimmt.
const meta = {
title: 'CMS-Blocks/InternalComponent',
component: InternalComponentBlock,
tags: ['autodocs'],
argTypes: {
block: { control: 'object' },
},
} satisfies Meta<typeof InternalComponentBlock>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Newsletter: Story = {
args: { block: { component: 'form-newsletter' } },
};
export const NewsletterInline: Story = {
args: { block: { component: 'form-newsletter-inline' } },
};
export const Kontakt: Story = {
args: { block: { component: 'form-contact' } },
};
export const Mitmachen: Story = {
args: { block: { component: 'form-mitmachen' } },
};
@@ -0,0 +1,28 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import LinkListBlock from './LinkListBlock.svelte';
const meta = {
title: 'CMS-Blocks/LinkList',
component: LinkListBlock,
tags: ['autodocs'],
argTypes: { block: { control: 'object' } },
} satisfies Meta<typeof LinkListBlock>;
export default meta;
type Story = StoryObj<typeof meta>;
const links = [
{ url: 'https://windwiderstand.de', linkName: 'Startseite', newTab: false },
{ url: 'https://cms.pm86.de', linkName: 'CMS', newTab: true },
{ url: 'https://www.bund.net', linkName: 'BUND', newTab: true },
{ url: 'https://www.nabu.de', linkName: 'NABU', newTab: true },
{ url: 'https://www.lbv.de', linkName: 'LBV', newTab: true },
];
export const Mehrspaltig: Story = {
args: { block: { headline: 'Weiterführende Links', columns: 'auto', links } },
};
export const Einspaltig: Story = {
args: { block: { headline: 'Links', columns: 'single', links: links.slice(0, 3) } },
};
@@ -0,0 +1,33 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import MarkdownBlock from './MarkdownBlock.svelte';
// `resolvedContent` = fertiges HTML (im echten Flow von resolveContentImages
// erzeugt). Für die Story direkt HTML setzen.
const meta = {
title: 'CMS-Blocks/Markdown',
component: MarkdownBlock,
tags: ['autodocs'],
argTypes: { block: { control: 'object' } },
} satisfies Meta<typeof MarkdownBlock>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standard: Story = {
args: {
block: {
alignment: 'left',
resolvedContent:
'<h2>Warum Windwiderstand?</h2><p>Der Steigerwald ist ein zusammenhängender Laubwald von europäischem Rang. <strong>Industrielle Windkraft</strong> würde ihn zerschneiden.</p><ul><li>Rodung von Schneisen</li><li>Verlust von Habitaten</li></ul>',
},
},
};
export const Zentriert: Story = {
args: {
block: {
alignment: 'center',
resolvedContent: '<p>Kurzer, zentrierter Absatz.</p>',
},
},
};
@@ -0,0 +1,24 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import OpnFormBlock from './OpnFormBlock.svelte';
// Bettet ein OpnForm per iframe ein (externer Dienst opnform.pm86.de). Lädt in
// Storybook nur bei Netzzugriff; die Struktur/Layout ist trotzdem prüfbar.
const meta = {
title: 'CMS-Blocks/OpnForm',
component: OpnFormBlock,
tags: ['autodocs'],
argTypes: { block: { control: 'object' } },
} satisfies Meta<typeof OpnFormBlock>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standard: Story = {
args: {
block: {
formId: 'kontakt',
baseUrl: 'https://opnform.pm86.de',
iframeTitle: 'Kontaktformular',
},
},
};
@@ -0,0 +1,53 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import OrganisationsBlock from './OrganisationsBlock.svelte';
const CMS_ASSET =
'https://cms.pm86.de/api/assets/pages/img_2814-ar4x2.webp?_environment=windwiderstand';
const meta = {
title: 'CMS-Blocks/Organisations',
component: OrganisationsBlock,
tags: ['autodocs'],
argTypes: { block: { control: 'object' } },
} satisfies Meta<typeof OrganisationsBlock>;
export default meta;
type Story = StoryObj<typeof meta>;
const organisations = [
{
name: 'BI Steigerwald',
logo: { src: CMS_ASSET, description: 'Logo' },
description: 'Bürgerinitiative für den Erhalt des Waldes.',
link: { url: 'https://windwiderstand.de', linkName: 'Website', newTab: true },
location: { text: 'Schleusingen', lat: 50.5, lng: 10.75 },
},
{
name: 'Waldfreunde e.V.',
logo: { src: CMS_ASSET, description: 'Logo' },
description: 'Naturschutzverein der Region.',
link: { url: 'https://nabu.de', linkName: 'Website', newTab: true },
location: { text: 'Ebrach' },
},
];
export const Grid: Story = {
args: {
block: {
number: '03',
kicker: 'Vor Ort',
headline: 'Beteiligte Organisationen',
action: { url: '/adressbuch', linkName: 'Zum Adressbuch' },
presentation: 'default',
organisations,
},
},
};
export const Kompakt: Story = {
args: { block: { headline: 'Organisationen', presentation: 'compact', organisations } },
};
export const Carousel: Story = {
args: { block: { headline: 'Organisationen', presentation: 'carousel', organisations } },
};
@@ -9,6 +9,7 @@
import Badge from "$lib/components/Badge.svelte";
import RustyImage from "$lib/components/RustyImage.svelte";
import Button from "$lib/ui/Button.svelte";
import SectionHeader from "$lib/components/SectionHeader.svelte";
function organisationLogoField(o: OrganisationEntry) {
return extractCmsImageField(o.logo);
@@ -22,6 +23,9 @@
}
let { block }: { block: OrganisationsBlockData } = $props();
const actionRef = $derived(
block.action && typeof block.action === "object" ? block.action : null,
);
const layoutClasses = $derived(
block.layout ? getBlockLayoutClasses(block.layout) : "col-span-12 mb-4",
@@ -163,9 +167,20 @@
<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 compact}
<h3 class="mb-2 text-lg font-semibold text-stein-900">{block.headline}</h3>
{:else}
<div class="mb-4">
<SectionHeader
number={block.number}
kicker={block.kicker}
title={block.headline}
tag="h3"
actionLabel={actionRef?.linkName}
actionHref={actionRef?.url}
/>
</div>
{/if}
{/if}
{#if carousel}
@@ -0,0 +1,30 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import OrganisationsMapBlock from './OrganisationsMapBlock.svelte';
// Leaflet-Karte mit Organisations-Markern (location.lat/lng nötig). Kartentiles
// laden von externem Tile-Server bei Netzzugriff.
const meta = {
title: 'CMS-Blocks/OrganisationsMap',
component: OrganisationsMapBlock,
tags: ['autodocs'],
argTypes: { block: { control: 'object' } },
} satisfies Meta<typeof OrganisationsMapBlock>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standard: Story = {
args: {
block: {
number: '05',
kicker: 'Karte',
headline: 'Organisationen in der Region',
action: { url: '/adressbuch', linkName: 'Alle anzeigen' },
organisations: [
{ name: 'BI Steigerwald', location: { text: 'Schleusingen', lat: 50.51, lng: 10.75 } },
{ name: 'Waldfreunde e.V.', location: { text: 'Ebrach', lat: 49.85, lng: 10.5 } },
{ name: 'Naturschutz Nord', location: { text: 'Bamberg', lat: 49.89, lng: 10.9 } },
],
},
},
};
@@ -2,14 +2,19 @@
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(
@@ -127,7 +132,15 @@
<div class="not-prose {layoutClasses}" data-block-type="organisations_map">
{#if block.headline}
<h2 class="mb-3 text-xl font-bold text-stein-900">{block.headline}</h2>
<div class="mb-3">
<SectionHeader
number={block.number}
kicker={block.kicker}
title={block.headline}
actionLabel={actionRef?.linkName}
actionHref={actionRef?.url}
/>
</div>
{/if}
<div
@@ -222,15 +235,17 @@
{/if}
{#if getLink(selectedOrg)}
<a
href={getLink(selectedOrg)}
<Button
href={getLink(selectedOrg) ?? undefined}
variant="outline"
size="sm"
target="_blank"
rel="noopener noreferrer"
class="btn-outline btn-sm w-full justify-center no-underline!"
class="w-full"
>
<Icon icon="lucide:external-link" class="size-3.5" />
{getLinkLabel(selectedOrg)}
</a>
</Button>
{/if}
</div>
</div>
@@ -0,0 +1,59 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import PostOverviewBlock from './PostOverviewBlock.svelte';
// PostOverview rendert `block.postsResolved` (im echten Flow vom Resolver
// befüllt). Hier minimal-realistische Post-Objekte inline. `_resolvedImageUrl`
// läuft über den /cms-images-Proxy.
const CMS_ASSET =
'https://cms.pm86.de/api/assets/pages/img_2814-ar4x2.webp?_environment=windwiderstand';
const meta = {
title: 'CMS-Blocks/PostOverview',
component: PostOverviewBlock,
tags: ['autodocs'],
argTypes: { block: { control: 'object' } },
} satisfies Meta<typeof PostOverviewBlock>;
export default meta;
type Story = StoryObj<typeof meta>;
const postsResolved = [
{
_slug: 'infoabend-schleusingen',
title: 'Infoabend in Schleusingen',
summary: 'Bürgerinnen und Bürger informierten sich über das Verfahren.',
created: '2026-06-12T18:00:00Z',
tags: ['veranstaltung'],
_resolvedImageUrl: CMS_ASSET,
},
{
_slug: 'stellungnahme-frist',
title: 'Frist für Einwendungen läuft',
summary: 'Noch bis Ende September können Stellungnahmen eingereicht werden.',
created: '2026-06-01T09:00:00Z',
tags: ['verfahren'],
_resolvedImageUrl: CMS_ASSET,
},
];
export const Cards: Story = {
args: {
block: {
number: '04',
kicker: 'Aktuelles',
headline: 'Die letzten Beiträge',
action: { url: '/aktuelles', linkName: 'Alle Beiträge' },
design: 'cards',
text: 'Neuigkeiten aus der Region.',
postsResolved,
},
},
};
export const Liste: Story = {
args: { block: { headline: 'Aktuelles', design: 'list', postsResolved } },
};
export const Featured: Story = {
args: { block: { headline: 'Aktuelles', design: 'featured', postsResolved } },
};
@@ -5,6 +5,7 @@
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
import { getBlockLayoutClasses } from "$lib/block-layout";
import SectionHeader from "$lib/components/SectionHeader.svelte";
import type { PostOverviewBlockData } from "$lib/block-types";
import type { PostEntry } from "$lib/cms";
import { postHref } from "$lib/blog-utils";
@@ -24,6 +25,11 @@
const design = $derived(block.design || "cards");
const tagBase = "/posts/tag";
// SectionHeader-CTA: action ist eine link-Referenz (aufgelöst zu {url, linkName}).
const actionRef = $derived(
block.action && typeof block.action === "object" ? block.action : null,
);
let scroller: HTMLDivElement | undefined = $state();
function scrollByPage(dir: 1 | -1) {
if (!scroller) return;
@@ -54,7 +60,16 @@
data-block-slug={block._slug}
>
{#if block.headline}
<h3 class="mb-2">{block.headline}</h3>
<div class="mb-2">
<SectionHeader
number={block.number}
kicker={block.kicker}
title={block.headline}
tag="h3"
actionLabel={actionRef?.linkName}
actionHref={actionRef?.url}
/>
</div>
{/if}
{#if textHtml}
<div class="mb-2 markdown max-w-none prose prose-zinc">{@html textHtml}</div>
@@ -0,0 +1,41 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import QuoteBlock from './QuoteBlock.svelte';
const meta = {
title: 'CMS-Blocks/Quote',
component: QuoteBlock,
tags: ['autodocs'],
argTypes: {
block: { control: 'object' },
bare: { control: 'boolean' },
},
} satisfies Meta<typeof QuoteBlock>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Links: Story = {
args: {
block: {
quote: 'Der Wald ist kein Bauland.',
author: 'Bürgerinitiative',
variant: 'left',
},
},
};
export const Zentriert: Story = {
args: {
block: {
quote: 'Naturschutz ist Heimatschutz.',
author: 'Anwohner',
variant: 'center',
},
},
};
export const OhneAutor: Story = {
args: {
block: { quote: 'Kein Windrad im Naturwald.', variant: 'right' },
},
};
@@ -0,0 +1,28 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import QuoteCarouselBlock from './QuoteCarouselBlock.svelte';
const meta = {
title: 'CMS-Blocks/QuoteCarousel',
component: QuoteCarouselBlock,
tags: ['autodocs'],
argTypes: { block: { control: 'object' } },
} satisfies Meta<typeof QuoteCarouselBlock>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standard: Story = {
args: {
block: {
headline: 'Stimmen',
autoRotate: true,
intervalSeconds: 5,
showArrows: true,
quotes: [
{ quote: 'Der Wald ist kein Bauland.', author: 'Bürgerinitiative', variant: 'center' },
{ quote: 'Naturschutz ist Heimatschutz.', author: 'Anwohner', variant: 'center' },
{ quote: 'Kein Windrad im Naturwald.', author: 'Förster', variant: 'center' },
],
},
},
};
@@ -119,7 +119,7 @@
: 'pointer-events-none opacity-0'}"
aria-hidden={i !== index}
>
<p class="relative font-serif text-[26px] leading-tight text-wald-50 md:text-[34px] lg:text-[38px]">
<p class="relative font-serif text-[24px] leading-tight text-wald-50 md:text-[30px] lg:text-[34px]">
<span class="absolute right-full top-0 mr-1 text-[0.7em] text-wald-500" aria-hidden="true">«</span>{q.quote}<span class="align-top text-[0.7em] text-wald-500" aria-hidden="true">»</span>
</p>
{#if a.main || a.meta}
@@ -0,0 +1,41 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import SearchableTextBlock from './SearchableTextBlock.svelte';
const meta = {
title: 'CMS-Blocks/SearchableText',
component: SearchableTextBlock,
tags: ['autodocs'],
argTypes: { block: { control: 'object' } },
} satisfies Meta<typeof SearchableTextBlock>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standard: Story = {
args: {
block: {
number: '02',
kicker: 'Wissen',
title: 'Argumente durchsuchen',
action: { url: '/argumente', linkName: 'Alle Argumente' },
description: 'Filtere nach Thema.',
textFragments: [
{
title: 'Artenschutz',
text: 'Der Steigerwald beherbergt seltene Fledermaus- und Vogelarten.',
tags: [{ name: 'Natur' }],
},
{
title: 'Wasserhaushalt',
text: 'Rodungen beeinträchtigen die Grundwasserneubildung.',
tags: [{ name: 'Umwelt' }],
},
{
title: 'Landschaftsbild',
text: '240 m hohe Anlagen dominieren die Mittelgebirgslandschaft.',
tags: [{ name: 'Landschaft' }],
},
],
},
},
};
@@ -3,6 +3,7 @@
import { slide } from "svelte/transition";
import { cubicOut } from "svelte/easing";
import { getBlockLayoutClasses } from "$lib/block-layout";
import SectionHeader from "$lib/components/SectionHeader.svelte";
import type {
SearchableTextBlockData,
SearchableTextFragment,
@@ -21,6 +22,9 @@
class?: string;
translations?: Translations | null;
} = $props();
const actionRef = $derived(
block.action && typeof block.action === "object" ? block.action : null,
);
function t(key: string, replacements?: Record<string, string | number>) {
return tStatic(translations ?? null, key, replacements);
@@ -196,7 +200,13 @@
{#if block.title || descriptionHtml}
<div class="px-4 md:px-6 pt-5 pb-3">
{#if block.title}
<h2 class="text-xl font-bold text-stein-900 mb-1">{block.title}</h2>
<SectionHeader
number={block.number}
kicker={block.kicker}
title={block.title}
actionLabel={actionRef?.linkName}
actionHref={actionRef?.url}
/>
{/if}
{#if descriptionHtml}
<div class="markdown prose prose-sm max-w-none text-stein-600">
@@ -0,0 +1,46 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import { http, HttpResponse } from 'msw';
import StellingnahmeGeneratorBlock from './StellingnahmeGeneratorBlock.svelte';
// Der Einwendungs-Text wird client-seitig aus den Kriterien gebaut. Der einzige
// fetch (/api/stellungnahme) ist der Mail-Versand → per MSW auf OK gemockt,
// damit der Absenden-Button den Erfolgszustand zeigt.
const meta = {
title: 'CMS-Blocks/StellingnahmeGenerator',
component: StellingnahmeGeneratorBlock,
tags: ['autodocs'],
argTypes: { block: { control: 'object' } },
parameters: {
msw: {
handlers: [
http.post('/api/stellungnahme', () => HttpResponse.json({ ok: true })),
],
},
},
} satisfies Meta<typeof StellingnahmeGeneratorBlock>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standard: Story = {
args: {
block: {
headline: 'Einwendung erstellen',
intro: 'Formuliere deine Stellungnahme zum Vorranggebiet.',
deadline: '2026-09-30',
recipientEmail: 'planung@example.org',
defaultOpeningText: 'hiermit erhebe ich Einwendung gegen das Vorranggebiet {gebiet}.',
defaultClosingText: 'Ich fordere, das Gebiet aus der Planung zu streichen.',
windArea: {
_slug: 'gebiet-42',
gebiets_nr: '42',
bezeichnung: 'Steigerwald Nord',
stellungnahme_kriterien: [
{ _slug: 'artenschutz', title: 'Artenschutz', text: 'Das Gebiet beherbergt geschützte Fledermausarten.' },
{ _slug: 'wasser', title: 'Wasserhaushalt', text: 'Rodungen gefährden die Grundwasserneubildung.' },
{ _slug: 'landschaft', title: 'Landschaftsbild', text: 'Anlagen von 240 m Höhe dominieren die Landschaft.' },
],
} as never,
},
},
};
@@ -0,0 +1,70 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import { http, HttpResponse, delay } from 'msw';
import StrommixBlock from './StrommixBlock.svelte';
import strommixFixture from './_fixtures/strommix.json';
// Live-Daten-Block: holt beim Mount /api/strommix. In Storybook existiert die
// SvelteKit-Route nicht → MSW fängt den fetch ab und liefert eine echte,
// eingefrorene Snapshot-Fixture (gezogen via `curl https://windwiderstand.de/
// api/strommix`). Deterministisch, offline, kein Live-Server nötig.
//
// Neue Fixture ziehen: curl -s https://windwiderstand.de/api/strommix \
// > src/lib/components/blocks/_fixtures/strommix.json
const meta = {
title: 'CMS-Blocks/LiveStrommix',
component: StrommixBlock,
tags: ['autodocs'],
argTypes: {
block: { control: 'object' },
},
// Default-Handler für alle Stories dieses Blocks: liefert die Fixture.
parameters: {
msw: {
handlers: [
http.get('/api/strommix', () => HttpResponse.json(strommixFixture)),
],
},
},
} satisfies Meta<typeof StrommixBlock>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Full: Story = {
args: { block: { variant: 'full', intro_headline: 'Aktueller Strommix' } },
};
export const Hero: Story = {
args: { block: { variant: 'hero' } },
};
export const Compact: Story = {
args: { block: { variant: 'compact' } },
};
// Ladezustand: Handler verzögert die Antwort → Skeleton bleibt sichtbar.
export const Laden: Story = {
args: { block: { variant: 'full' } },
parameters: {
msw: {
handlers: [
http.get('/api/strommix', async () => {
await delay('infinite');
return HttpResponse.json(strommixFixture);
}),
],
},
},
};
// Fehlerzustand: Handler antwortet 500 → Block zeigt Fehler/Retry.
export const Fehler: Story = {
args: { block: { variant: 'full' } },
parameters: {
msw: {
handlers: [
http.get('/api/strommix', () => new HttpResponse(null, { status: 500 })),
],
},
},
};
@@ -0,0 +1,37 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import { http, HttpResponse } from 'msw';
import WindkarteBlock from './WindkarteBlock.svelte';
import windAreaFixture from './_fixtures/wind_area.json';
// Holt beim Mount wind_area vom CMS (absolute URL cms.pm86.de). MSW fängt das
// ab und liefert eine echte, eingefrorene Antwort (curl vom CMS gezogen).
// Leaflet-Tiles laden extern bei Netzzugriff.
const meta = {
title: 'CMS-Blocks/Windkarte',
component: WindkarteBlock,
tags: ['autodocs'],
argTypes: { block: { control: 'object' } },
parameters: {
msw: {
handlers: [
http.get('https://cms.pm86.de/api/content/wind_area', () =>
HttpResponse.json(windAreaFixture),
),
],
},
},
} satisfies Meta<typeof WindkarteBlock>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standard: Story = {
args: {
block: {
title: 'Vorranggebiete Wind',
subtitle: 'Geplante Flächen im Steigerwald',
info_text: 'Klicke auf ein Gebiet für Details.',
hinweis_text: 'Daten aus dem Regionalplan-Entwurf.',
},
},
};
@@ -0,0 +1,22 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import YoutubeVideoBlock from './YoutubeVideoBlock.svelte';
const meta = {
title: 'CMS-Blocks/YoutubeVideo',
component: YoutubeVideoBlock,
tags: ['autodocs'],
argTypes: { block: { control: 'object' } },
} satisfies Meta<typeof YoutubeVideoBlock>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standard: Story = {
args: {
block: {
youtubeId: 'aqz-KE-bpKQ',
title: 'Beispielvideo',
description: 'Kurzbeschreibung unter dem Video.',
},
},
};
@@ -0,0 +1,36 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import YoutubeVideoGalleryBlock from './YoutubeVideoGalleryBlock.svelte';
const meta = {
title: 'CMS-Blocks/YoutubeVideoGallery',
component: YoutubeVideoGalleryBlock,
tags: ['autodocs'],
argTypes: { block: { control: 'object' } },
} satisfies Meta<typeof YoutubeVideoGalleryBlock>;
export default meta;
type Story = StoryObj<typeof meta>;
const videos = [
{ youtubeId: 'aqz-KE-bpKQ', title: 'Video 1' },
{ youtubeId: 'dQw4w9WgXcQ', title: 'Video 2' },
{ youtubeId: 'ScMzIvxBSi4', title: 'Video 3' },
];
export const Grid: Story = {
args: {
block: {
number: '07',
kicker: 'Medien',
variant: 'grid',
title: 'Videos',
subtitle: 'Auswahl aus unserem Kanal',
action: { url: '/videos', linkName: 'Alle Videos' },
videos,
},
},
};
export const Carousel: Story = {
args: { block: { variant: 'carousel', title: 'Videos', videos } },
};
@@ -2,6 +2,7 @@
import { marked } from "$lib/markdown-safe";
import { fade } from "svelte/transition";
import { getBlockLayoutClasses } from "$lib/block-layout";
import SectionHeader from "$lib/components/SectionHeader.svelte";
import type {
YoutubeVideoGalleryBlockData,
YoutubeVideoBlockData,
@@ -72,6 +73,9 @@
}
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const actionRef = $derived(
block.action && typeof block.action === "object" ? block.action : null,
);
const descriptionHtml = $derived(
block.description && typeof block.description === "string"
? (marked.parse(block.description) as string)
@@ -149,10 +153,17 @@
data-block-slug={block._slug}
>
{#if block.title}
<h3 class="text-lg font-semibold text-stein-900 mb-1">{block.title}</h3>
{/if}
{#if block.subtitle}
<p class="text-sm text-stein-600 mb-3">{block.subtitle}</p>
<div class="mb-3">
<SectionHeader
number={block.number}
kicker={block.kicker}
title={block.title}
subtitle={block.subtitle}
tag="h3"
actionLabel={actionRef?.linkName}
actionHref={actionRef?.url}
/>
</div>
{/if}
{#if descriptionHtml}
<div class="markdown max-w-none prose prose-zinc mb-4">
File diff suppressed because one or more lines are too long
@@ -0,0 +1,312 @@
{
"blog_count": "{{visible}} von {{total}} Beiträgen, Seite {{current}} von {{totalPages}}",
"blog_filter_aria": "Nach Tag filtern",
"blog_filter_reset": "Filter zurücksetzen",
"blog_no_results": "Keine Treffer. Versuche einen anderen Suchbegriff.",
"blog_results_count": "{{count}} Treffer",
"blog_results_for": "für „{{query}}\"",
"blog_results_in_year": "im Jahr {{year}}",
"blog_rss_subscribe": "RSS-Feed abonnieren",
"blog_search_clear": "Suche leeren",
"blog_search_label": "Suche",
"blog_search_placeholder": "Beiträge durchsuchen…",
"blog_tag_all": "Alle",
"blog_upcoming_events": "Nächste Termine",
"blog_year_all": "Alle Jahre",
"blog_year_label": "Jahr",
"calendar_add_to_google": "In Google Kalender",
"calendar_all_tags": "Alle Tags",
"calendar_all_upcoming": "Alle kommenden Termine",
"calendar_clear_filter": "Filter zurücksetzen",
"calendar_download_ics": "ICS herunterladen",
"calendar_event_count": "{{n}} Termine",
"calendar_export_all": "Alle exportieren",
"calendar_filter_by_tag": "Nach Tag filtern",
"calendar_hide_past": "Vergangene ausblenden",
"calendar_in_days": "in {{n}} Tagen",
"calendar_jump_to_month": "Zu Monat springen",
"calendar_more_info": "Mehr erfahren",
"calendar_multi_day_range": "{{from}} {{to}}",
"calendar_next_events": "Nächste Termine",
"calendar_next_month": "Nächster Monat",
"calendar_no_events": "Keine Termine eingetragen.",
"calendar_no_future_events": "Keine kommenden Termine.",
"calendar_open_maps": "In Karte öffnen",
"calendar_past_events": "Vergangene Termine",
"calendar_prev_month": "Vorheriger Monat",
"calendar_running_now": "läuft gerade",
"calendar_select_day": "Wählen Sie einen Tag.",
"calendar_share": "Teilen",
"calendar_share_aria": "Termin teilen",
"calendar_share_copied": "Kopiert!",
"calendar_show_less": "Weniger",
"calendar_show_more": "Mehr",
"calendar_show_past": "Vergangene anzeigen",
"calendar_starts_in": "in {{n}} Std.",
"calendar_starts_in_minutes": "in {{n}} Min.",
"calendar_subscribe": "Abonnieren",
"calendar_subscribe_aria": "Kalender abonnieren (webcal)",
"calendar_time_suffix": "Uhr",
"calendar_today": "Heute",
"calendar_today_marker": "Heute",
"calendar_tomorrow": "Morgen",
"calendar_untitled": "Ohne Titel",
"calendar_weekday_di": "Di",
"calendar_weekday_do": "Do",
"calendar_weekday_fr": "Fr",
"calendar_weekday_mi": "Mi",
"calendar_weekday_mo": "Mo",
"calendar_weekday_sa": "Sa",
"calendar_weekday_so": "So",
"comments_approval_hint": "Wird vor Veröffentlichung geprüft.",
"comments_body_placeholder": "Schreibe etwas Konstruktives. Cmd/Ctrl+Enter sendet.",
"comments_cancel": "Abbrechen",
"comments_confirm_delete": "Kommentar löschen?",
"comments_count_aria": "{{count}} Kommentare",
"comments_delete": "Löschen",
"comments_edit": "Bearbeiten",
"comments_edited": "(bearbeitet)",
"comments_empty": "Noch keine Kommentare. Sei der Erste.",
"comments_form_title": "Kommentar schreiben",
"comments_jump_to": "Zu Kommentaren springen",
"comments_load_error_title": "Kommentare konnten nicht geladen werden.",
"comments_loading": "Lade Kommentare …",
"comments_name_placeholder": "Dein Name",
"comments_pending_full": "(wartet auf Freigabe)",
"comments_pending_short": "wartet",
"comments_pending_summary_one": "1 Kommentar wartet noch auf Freigabe (nur für dich sichtbar).",
"comments_pending_summary_other": "{{count}} Kommentare warten noch auf Freigabe (nur für dich sichtbar).",
"comments_reply": "Antworten",
"comments_reply_count_one": "1 Antwort",
"comments_reply_count_other": "{{count}} Antworten",
"comments_reply_placeholder": "Deine Antwort",
"comments_reply_send": "Antwort senden",
"comments_retry": "Erneut versuchen",
"comments_save": "Speichern",
"comments_self_marker": "du",
"comments_send": "Senden",
"comments_sending": "Sende …",
"footer_copyright": "© 2025 RustyAstro",
"header_menu_close": "Menü schließen",
"header_menu_open": "Menü öffnen",
"header_nav_aria": "Hauptnavigation",
"header_overlay_close": "Menü bzw. Suche schließen",
"header_search_close": "Suche schließen",
"header_search_open": "Suche öffnen",
"header_social_aria": "Social Media",
"iframe_activate_aria": "Klicken zum Aktivieren der Karte",
"iframe_missing": "Iframe-URL fehlt oder ist ungültig.",
"image_gallery_close": "Schließen",
"image_gallery_download": "Download",
"image_gallery_empty": "Keine Bilder in der Galerie.",
"image_gallery_modal_aria": "Bild-Vorschau",
"image_gallery_next_aria": "Nächstes Bild",
"image_gallery_open_aria": "Bild groß anzeigen",
"image_gallery_position_aria": "Galerieposition",
"image_gallery_prev_aria": "Vorheriges Bild",
"image_gallery_slide_aria": "Bild {{index}}",
"image_gallery_swipe_aria": "Galerie: wischen zum Wechseln",
"nav_home": "Startseite",
"nav_posts": "Beiträge",
"nav_start": "Start",
"page_404_back": "Zur Startseite",
"page_404_text": "Seite nicht gefunden.",
"page_404_title": "Seite nicht gefunden",
"pagination_next": "Nächste Seite",
"pagination_prev": "Vorherige Seite",
"post_action_copied": "Kopiert",
"post_action_copy": "Link kopieren",
"post_action_print": "Drucken",
"post_action_reading_time": "{{minutes}} min",
"post_action_share": "Teilen",
"post_comments": "Kommentare",
"post_map": "Karte",
"post_map_activate": "Klicken zum Aktivieren",
"post_map_open_osm": "Auf OpenStreetMap öffnen",
"post_overview_all": "Alle Beiträge",
"posts_cms_error": "CMS nicht erreichbar:",
"posts_subtitle": "Aktuelle Meldungen, Termine und Hintergründe aus den Initiativen",
"posts_tag_subtitle": "Aktuelle Meldungen, Termine und Hintergründe aus den Initiativen",
"posts_tag_title": "Alle Beiträge",
"posts_title": "Alle Beiträge",
"searchable_text_clear_search": "Suche leeren",
"searchable_text_copied": "Kopiert!",
"searchable_text_copy_aria": "Inhalt in Zwischenablage kopieren",
"searchable_text_copy_button": "Inhalt kopieren",
"searchable_text_count_all": "{{count}} Einträge",
"searchable_text_count_filtered": "{{visible}} von {{total}} Einträgen",
"searchable_text_help": "Hier könnt ihr in Titeln und Inhalten nach Stichwörtern suchen. Über die Schlagwörter unter dem Suchfeld lassen sich die Einträge eingrenzen. Öffnet einen Eintrag per Klick auf die Zeile; mit „Inhalt kopieren\" könnt ihr den Text in die Zwischenablage übernehmen (z. B. für Stellungnahmen).",
"searchable_text_help_aria": "Hilfe zur Suche anzeigen",
"searchable_text_no_results": "Keine Treffer. Versuchen Sie einen anderen Suchbegriff oder Schlagwort.",
"searchable_text_placeholder": "In Titeln und Inhalten suchen…",
"searchable_text_search_aria": "Suche in Titeln und Inhalten",
"searchable_text_tag_all": "Alle",
"searchable_text_untitled": "Ohne Titel",
"site_name_fallback": "RustyAstro",
"strommix_check_values": "Werte einzeln prüfen",
"strommix_compact_label": "{{pct}}% Wind & Solar · {{status}}",
"strommix_compact_more": "Details ansehen",
"strommix_conventional_explanation": "Konventionell = Braunkohle + Steinkohle + Erdgas + Kernkraft (Pumpspeicher, Sonstige Konventionelle und Wasserkraft sind separat ausgewiesen).",
"strommix_conventional_formula": "Braunkohle + Steinkohle + Erdgas + Kernkraft",
"strommix_conventional_now": "Konventionell jetzt",
"strommix_delayed": "verzögert",
"strommix_export": "Export",
"strommix_field_biomass": "Biomasse",
"strommix_field_gas": "Erdgas",
"strommix_field_hard_coal": "Steinkohle",
"strommix_field_hydro": "Wasserkraft",
"strommix_field_lignite": "Braunkohle",
"strommix_field_load": "Last (Stromverbrauch)",
"strommix_field_nuclear": "Kernkraft",
"strommix_field_solar": "Photovoltaik",
"strommix_field_wind_offshore": "Wind offshore",
"strommix_field_wind_onshore": "Wind onshore",
"strommix_header": "Strommix jetzt",
"strommix_history_summary": "Verlauf & Hintergrund",
"strommix_how_calculated": "Wie wird das berechnet?",
"strommix_import": "Import",
"strommix_load_error": "Live-Daten nicht erreichbar: {{error}}",
"strommix_load_missing": "Lastwert von SMARD gerade nicht verfügbar. Prozentanzeige ausgesetzt. Erzeugung wird unten in der Detailansicht weiter angezeigt.",
"strommix_loading": "Lade Live-Daten…",
"strommix_negative_price": "Negativpreis",
"strommix_price_de": "Strompreis DE: {{price}}",
"strommix_price_fr_inline": "(Frankreich: {{price}})",
"strommix_residual_today_desc": "Last minus erneuerbare Erzeugung was konventionell gedeckt werden muss.",
"strommix_residual_today_title": "Residuallast heute",
"strommix_retry": "Erneut versuchen",
"strommix_saldo": "Saldo",
"strommix_slot": "Slot {{time}}",
"strommix_source": "Quelle:",
"strommix_source_energy_charts": "energy-charts.info (ENTSO-E + SMARD)",
"strommix_source_hybrid": "smard.de + energy-charts.info (Last-Wert ergänzt)",
"strommix_source_resolution": "15-Min-Auflösung, Stand: {{time}} Uhr.",
"strommix_source_smard": "smard.de (Bundesnetzagentur)",
"strommix_sparkline_aria": "Verlauf der Wind- & Solar-Quote, {{from}} bis {{to}}",
"strommix_stale_badge": "veraltet",
"strommix_stand": "Stand: {{time}} Uhr",
"strommix_stored_data": "gespeicherte Daten (Upstream gerade unerreichbar)",
"strommix_trend_down": "{{delta}} %",
"strommix_trend_flat": "stabil",
"strommix_trend_up": "+{{delta}} %",
"strommix_wind_solar_share": "Wind & Solar am Bedarf",
"windkarte_action_copied": "Kopiert!",
"windkarte_action_copy_link": "Link kopieren",
"windkarte_action_download_geojson": "GeoJSON herunterladen",
"windkarte_action_download_kml": "KML herunterladen",
"windkarte_action_hide": "Ausblenden",
"windkarte_action_osm": "In OSM öffnen",
"windkarte_action_show": "Einblenden",
"windkarte_kriterien_loading": "Kriterien werden geladen…",
"windkarte_label_anlagen_geplant": "Geschätzte Anlagen",
"windkarte_label_anlagen_hinweis": "Hinweis zur geschätzten Anlagenzahl",
"windkarte_label_flaeche": "Fläche",
"windkarte_label_gemeinden": "Betroffene Gemeinden",
"windkarte_label_geodaten_export": "Geodaten-Export ({{nr}})",
"windkarte_label_hoehe_max": "Max. Nabenhöhe",
"windkarte_label_investor": "Investor / Betreiber",
"windkarte_label_kriterien": "Stellungnahme-Kriterien",
"windkarte_label_notizen": "Hinweise",
"windkarte_legend_distances": "Abstände:",
"windkarte_legend_entwurf_2": "2. Entwurf 2026",
"windkarte_legend_entwurf_2_voraussichtlich": "Voraussichtl. 2. Entwurf",
"windkarte_legend_rechtskraeftig": "Rechtskräftig (2012)",
"windkarte_loading": "Karte wird geladen …",
"windkarte_panel_close": "Panel schließen",
"windkarte_reset_aria": "Übersicht zurücksetzen",
"windkarte_status_entwurf_2": "2. Entwurf 2026",
"windkarte_status_entwurf_2_voraussichtlich": "Voraussichtlich 2. Entwurf",
"windkarte_status_entwurf_3": "3. Entwurf",
"windkarte_status_rechtskraeftig": "Rechtskräftig",
"windkarte_stellungnahme_link": "Zur Stellungnahme-Vorlage",
"windkarte_tap_to_activate": "Tippen zum Aktivieren der Karte",
"youtube_missing": "YouTube-Video-ID fehlt.",
"youtube_title_fallback": "YouTube-Video",
"contact_label_name": "Name",
"contact_placeholder_name": "Dein Name",
"contact_label_email": "E-Mail",
"contact_placeholder_email": "deine@email.de",
"contact_label_subject": "Betreff (optional)",
"contact_placeholder_subject": "Worum geht's?",
"contact_label_message": "Nachricht",
"contact_placeholder_message": "Deine Nachricht …",
"contact_message_remaining": "{{n}} Zeichen übrig",
"contact_message_overflow": "{{n}} Zeichen zu viel",
"contact_label_consent": "Ich willige ein, dass meine Angaben zur Beantwortung verarbeitet werden.",
"contact_privacy_note": "Die Daten werden ausschließlich zur Bearbeitung deiner Nachricht verwendet. Keine Weitergabe an Dritte.",
"contact_submit": "Nachricht senden",
"contact_submitting": "Sende …",
"contact_success_title": "Nachricht gesendet!",
"contact_success_body": "Vielen Dank — wir melden uns so bald wie möglich.",
"contact_fix_errors": "Bitte fehlende Felder ausfüllen.",
"contact_server_error_generic": "Unbekannter Fehler. Bitte später erneut versuchen.",
"contact_server_error_network": "Netzwerkfehler bitte erneut versuchen.",
"contact_server_error_blocked": "Anfrage wurde blockiert.",
"contact_server_error_throttle": "Zu viele Anfragen. Bitte später erneut versuchen.",
"contact_server_error_validation": "Bitte Eingaben prüfen.",
"contact_error_name_min": "Mindestens {{n}} Zeichen.",
"contact_error_name_max": "Höchstens {{n}} Zeichen.",
"contact_error_name_no_links": "Bitte keine Links im Namen.",
"contact_error_name_invalid_chars": "Ungültige Zeichen im Namen.",
"contact_error_email_required": "E-Mail ist erforderlich.",
"contact_error_email_max": "Höchstens {{n}} Zeichen.",
"contact_error_email_invalid": "Bitte gültige E-Mail-Adresse eingeben.",
"contact_error_subject_max": "Höchstens {{n}} Zeichen.",
"contact_error_subject_invalid_chars": "Ungültige Zeichen im Betreff.",
"contact_error_message_min": "Mindestens {{n}} Zeichen.",
"contact_error_message_max": "Höchstens {{n}} Zeichen.",
"contact_error_message_too_many_links": "Höchstens {{n}} Links erlaubt.",
"contact_error_message_invalid_chars": "Ungültige Zeichen in der Nachricht.",
"contact_error_consent_required": "Bitte Datenschutzhinweis bestätigen.",
"contact_captcha_pending": "Bitte warte, bis die Sicherheitsprüfung abgeschlossen ist.",
"contact_server_error_captcha": "Sicherheitsprüfung fehlgeschlagen. Bitte erneut versuchen.",
"mitmachen_label_title": "Name der Bürgerinitiative",
"mitmachen_placeholder_title": "z. B. BI Gegenwind Musterdorf",
"mitmachen_label_description": "Anliegen, Information",
"mitmachen_placeholder_description": "Erzählt uns von eurer Initiative: Region, geplante Windgebiete, aktueller Stand …",
"mitmachen_label_contact_name": "Ansprechpartner (optional)",
"mitmachen_placeholder_contact_name": "Vor- und Nachname",
"mitmachen_label_phone": "Telefonnummer (optional)",
"mitmachen_placeholder_phone": "z. B. 0361 1234567",
"mitmachen_label_email": "E-Mail (optional)",
"mitmachen_placeholder_email": "kontakt@eure-bi.de",
"mitmachen_label_files": "Logo, Bilder, Dokumente (optional)",
"mitmachen_files_help": "Max. {{n}} Dateien, je {{mb}} MB — JPG, PNG, WebP, GIF oder PDF.",
"mitmachen_files_add": "Dateien auswählen",
"mitmachen_files_remove": "Entfernen",
"mitmachen_label_consent": "Ich willige ein, dass meine Angaben zur Bearbeitung der Anfrage verarbeitet werden.",
"mitmachen_privacy_note": "Die Daten werden ausschließlich zur Bearbeitung eurer Anfrage verwendet. Keine Weitergabe an Dritte.",
"mitmachen_submit": "Anfrage senden",
"mitmachen_success_title": "Anfrage gesendet!",
"mitmachen_success_body": "Vielen Dank — wir melden uns so bald wie möglich bei euch.",
"mitmachen_error_title_min": "Mindestens {{n}} Zeichen.",
"mitmachen_error_title_max": "Höchstens {{n}} Zeichen.",
"mitmachen_error_title_invalid": "Bitte keine Links oder Sonderzeichen im Namen.",
"mitmachen_error_description_min": "Mindestens {{n}} Zeichen.",
"mitmachen_error_description_max": "Höchstens {{n}} Zeichen.",
"mitmachen_error_description_too_many_links": "Höchstens {{n}} Links erlaubt.",
"mitmachen_error_description_invalid": "Ungültige Zeichen im Text.",
"mitmachen_error_contact_name_max": "Höchstens {{n}} Zeichen.",
"mitmachen_error_contact_name_invalid": "Ungültige Zeichen im Namen.",
"mitmachen_error_phone_invalid": "Bitte gültige Telefonnummer eingeben.",
"mitmachen_error_email_invalid": "Bitte gültige E-Mail-Adresse eingeben.",
"mitmachen_error_consent_required": "Bitte Datenschutzhinweis bestätigen.",
"mitmachen_error_files_count": "Höchstens {{n}} Dateien.",
"mitmachen_error_files_size": "Jede Datei darf höchstens {{mb}} MB groß sein.",
"mitmachen_error_files_type": "Erlaubt sind JPG, PNG, WebP, GIF und PDF.",
"newsletter_label_name": "Name (optional)",
"newsletter_placeholder_name": "Vor- und Nachname",
"newsletter_label_email": "E-Mail",
"newsletter_placeholder_email": "name@beispiel.de",
"newsletter_label_consent": "Ich möchte den Newsletter erhalten und willige in die Verarbeitung meiner E-Mail-Adresse zu diesem Zweck ein.",
"newsletter_privacy_note": "Du erhältst eine E-Mail zur Bestätigung deiner Anmeldung (Double-Opt-in). Abmeldung jederzeit über den Link in jeder Newsletter-Mail.",
"newsletter_submit": "Newsletter abonnieren",
"newsletter_submitting": "Sende …",
"newsletter_success_title": "Fast geschafft!",
"newsletter_success_body": "Bitte bestätige deine Anmeldung über den Link in der E-Mail, die wir dir gerade geschickt haben.",
"newsletter_error_email_required": "E-Mail ist erforderlich.",
"newsletter_error_email_invalid": "Bitte gültige E-Mail-Adresse eingeben.",
"newsletter_error_email_max": "E-Mail darf höchstens {{n}} Zeichen lang sein.",
"newsletter_error_consent_required": "Bitte Einwilligung bestätigen.",
"newsletter_inline_heading": "Newsletter abonnieren",
"newsletter_inline_consent": "Ich willige in den Erhalt des Newsletters ein (Double-Opt-in, jederzeit abbestellbar)."
}
File diff suppressed because one or more lines are too long
@@ -3,6 +3,7 @@
import { env as publicEnv } from "$env/dynamic/public";
import { useTranslate, T } from "$lib/translations";
import { validateNewsletter, type NewsletterErrors, type NewsletterFields } from "./newsletter-form-validation";
import Button from "$lib/ui/Button.svelte";
type FormState = "idle" | "pending" | "success" | "error";
@@ -148,13 +149,13 @@
aria-label={t(T.newsletter_label_email)}
class="min-w-0 flex-1 rounded-sm border border-stein-700 bg-stein-800 px-3 py-2 text-sm text-stein-0 placeholder:text-stein-400 focus:border-wald-400 focus:outline-none"
/>
<button
<Button
type="submit"
disabled={formState === "pending"}
class="rounded-sm bg-wald-600 px-4 py-2 text-sm font-semibold text-stein-0 transition-colors hover:bg-wald-500 disabled:opacity-60 sm:shrink-0"
>
{formState === "pending" ? t(T.newsletter_submitting) : t(T.newsletter_submit)}
</button>
variant="primary"
loading={formState === "pending"}
label={formState === "pending" ? t(T.newsletter_submitting) : t(T.newsletter_submit)}
class="text-sm sm:shrink-0"
/>
</div>
{#if showError("email")}
<p class="mt-1 text-xs text-error" role="alert">{showError("email")}</p>
+16
View File
@@ -0,0 +1,16 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import Badge from './Badge.svelte';
const meta = {
title: 'Atoms/Badge',
component: Badge,
tags: ['autodocs'],
args: { label: 'Neu' },
} satisfies Meta<typeof Badge>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const AlsLink: Story = { args: { label: 'Mehr', href: '#' } };
export const Aktiv: Story = { args: { label: 'Aktiv', active: true } };
+21
View File
@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import Breadcrumbs from './Breadcrumbs.svelte';
const meta = {
title: 'Atoms/Breadcrumbs',
component: Breadcrumbs,
tags: ['autodocs'],
} satisfies Meta<typeof Breadcrumbs>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standard: Story = {
args: {
items: [
{ label: 'Start', href: '/' },
{ label: 'Themen', href: '/themen' },
{ label: 'Windkraft im Wald' },
],
},
};
+40
View File
@@ -0,0 +1,40 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import Button from './Button.svelte';
const { Story } = defineMeta({
title: 'Atoms/Button',
component: Button,
tags: ['autodocs'],
argTypes: {
variant: {
control: 'select',
options: ['primary', 'secondary', 'tertiary', 'outline', 'warning'],
},
size: { control: 'select', options: ['sm', 'md', 'lg'] },
disabled: { control: 'boolean' },
loading: { control: 'boolean' },
href: { control: 'text' },
},
});
</script>
<!-- Fünf Varianten (Quelle: .btn-* aus app.css) -->
<Story name="Primary" args={{ variant: 'primary' }}>Speichern</Story>
<Story name="Secondary" args={{ variant: 'secondary' }}>Abbrechen</Story>
<Story name="Tertiary" args={{ variant: 'tertiary' }}>Mehr Infos</Story>
<Story name="Outline" args={{ variant: 'outline' }}>Filter</Story>
<Story name="Warning" args={{ variant: 'warning' }}>Termin melden</Story>
<!-- Drei Größen -->
<Story name="Klein (sm)" args={{ variant: 'primary', size: 'sm' }}>Klein</Story>
<Story name="Mittel (md)" args={{ variant: 'primary', size: 'md' }}>Mittel</Story>
<Story name="Groß (lg)" args={{ variant: 'primary', size: 'lg' }}>Jetzt einwenden</Story>
<!-- Zustände -->
<Story name="Loading" args={{ variant: 'primary', loading: true }}>Senden</Story>
<Story name="Disabled" args={{ variant: 'primary', disabled: true }}>Gesperrt</Story>
<Story name="Disabled Outline" args={{ variant: 'outline', disabled: true }}>Gesperrt</Story>
<!-- href → rendert <a> statt <button> -->
<Story name="Als Link" args={{ variant: 'primary', href: '/mitmachen' }}>Zur Aktion</Story>
+68 -31
View File
@@ -1,59 +1,96 @@
<script lang="ts">
type Variant = 'primary' | 'secondary' | 'ghost';
type Size = 'sm' | 'md' | 'large';
/**
* Button — einzige Quelle: die .btn-*-Varianten aus app.css.
* Rendert <a> wenn href gesetzt ist, sonst <button>.
* Loading: Spinner NEBEN dem Label (kein Breitensprung), aria-busy.
*/
import type { Snippet } from 'svelte';
import type { HTMLButtonAttributes, HTMLAnchorAttributes } from 'svelte/elements';
type Variant = 'primary' | 'secondary' | 'tertiary' | 'outline' | 'warning';
type Size = 'sm' | 'md' | 'lg';
let {
variant = 'primary',
size = 'md',
disabled = false,
loading = false,
href = undefined,
type = 'button' as 'button' | 'submit' | 'reset',
label = '',
children = undefined,
class: className = '',
...rest
}: {
variant?: 'primary' | 'secondary' | 'ghost';
size?: 'sm' | 'md' | 'large';
variant?: Variant;
size?: Size;
disabled?: boolean;
loading?: boolean;
href?: string;
type?: 'button' | 'submit' | 'reset';
label?: string;
children?: Snippet;
} = $props();
/** Zusätzliche Layout-Klassen (w-full, justify-*, min-h-*, …). Werden an
* base/size/variant ANGEHÄNGT — überschreiben die Varianten-Styles nicht.
* Aus rest herausgezogen, sonst würde {...rest} das class-Attribut kapern. */
class?: string;
} & HTMLButtonAttributes &
HTMLAnchorAttributes = $props();
/* Basis: identisch zur .btn-*-Basis in app.css (font-medium = 500,
rounded-md, 200ms). Kein eigener Focus-Ring — der globale
*:focus-visible (wald-500) übernimmt. */
const base =
'inline-flex items-center justify-center gap-2 rounded-xs font-semibold transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-300 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-40';
'inline-flex items-center justify-center gap-1.5 rounded-md font-medium no-underline whitespace-nowrap transition-colors duration-200 disabled:cursor-not-allowed';
const sizeClasses = $derived(
{ sm: 'h-9 px-4 py-2 text-sm', md: 'h-12 min-w-[120px] px-6 py-3 text-base', large: 'h-14 px-8 py-4 text-lg' }[
size
]
/* Größen setzen NUR Maße — Varianten setzen NUR Farben. Kein Konflikt. */
const sizeClasses: Record<Size, string> = {
sm: 'px-2 py-1 text-xs gap-1',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg',
};
/* 1:1 aus app.css. Disabled: explizite Stein-Farben statt opacity —
auf Wald-Grün ist opacity-40 kaum als deaktiviert erkennbar. */
const variantClasses: Record<Variant, string> = {
primary:
'shadow-sm bg-btn-bg text-btn-txt hover:bg-btn-hover-bg active:bg-wald-700 disabled:bg-stein-200 disabled:text-stein-400 disabled:shadow-none',
secondary:
'shadow-sm border border-stein-300 bg-white text-stein-700 hover:bg-stein-100 hover:border-stein-400 active:bg-stein-200 disabled:bg-stein-50 disabled:text-stein-300 disabled:border-stein-200 disabled:shadow-none',
tertiary:
'shadow-sm bg-himmel-700 text-white hover:bg-himmel-800 active:bg-himmel-900 disabled:bg-stein-200 disabled:text-stein-400 disabled:shadow-none',
outline:
'border border-stein-300 text-stein-700 hover:bg-stein-100 active:bg-stein-200 disabled:text-stein-300 disabled:border-stein-200 disabled:hover:bg-transparent',
warning:
'shadow-sm border border-gelb-500 bg-gelb-300 text-gelb-900 hover:bg-gelb-400 active:bg-gelb-500 disabled:bg-stein-100 disabled:text-stein-400 disabled:border-stein-200 disabled:shadow-none',
};
const allClasses = $derived(
[base, sizeClasses[size], variantClasses[variant], className].filter(Boolean).join(' '),
);
const variantClasses = $derived(
{
primary: 'bg-wald-500 text-white hover:bg-wald-600 active:bg-wald-700',
secondary:
'border-[1.5px] border-wald-500 bg-transparent text-wald-500 hover:bg-wald-50 active:bg-wald-100',
ghost: 'bg-transparent px-4 py-3 text-stein-700 hover:bg-stein-100 active:bg-stein-200',
}[variant]
);
const allClasses = $derived([base, sizeClasses, variantClasses].filter(Boolean).join(' '));
const isDisabled = $derived(disabled || loading);
</script>
<button
{type}
{disabled}
class={allClasses}
>
{#snippet inner()}
{#if loading}
<span class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></span>
<span>Laden...</span>
{:else if children}
<span
class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"
aria-hidden="true"
></span>
{/if}
{#if children}
{@render children()}
{:else}
{label}
{/if}
</button>
{/snippet}
{#if href && !isDisabled}
<a {href} class={allClasses} {...rest}>
{@render inner()}
</a>
{:else}
<button {type} disabled={isDisabled} aria-busy={loading || undefined} class={allClasses} {...rest}>
{@render inner()}
</button>
{/if}
+17
View File
@@ -0,0 +1,17 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import Checkbox from './Checkbox.svelte';
const meta = {
title: 'Atoms/Checkbox',
component: Checkbox,
tags: ['autodocs'],
args: { label: 'Ich stimme zu' },
} satisfies Meta<typeof Checkbox>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Unchecked: Story = {};
export const Checked: Story = { args: { checked: true } };
export const Disabled: Story = { args: { disabled: true } };
export const Klein: Story = { args: { size: 'sm', label: 'Kompakt' } };
+16
View File
@@ -0,0 +1,16 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import Radio from './Radio.svelte';
const meta = {
title: 'Atoms/Radio',
component: Radio,
tags: ['autodocs'],
args: { name: 'auswahl', value: 'a', label: 'Option A', group: 'a' },
} satisfies Meta<typeof Radio>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Ausgewaehlt: Story = {};
export const NichtAusgewaehlt: Story = { args: { group: 'b' } };
export const Disabled: Story = { args: { disabled: true } };
+21
View File
@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import RustyImage from '$lib/components/RustyImage.svelte';
// Bild-Primitive: baut aus roher CMS-URL eine transformierte /cms-images-URL
// (AVIF/WebP-`<picture>`, Retina-srcset, CLS=0). Läuft über den Storybook-Proxy.
const CMS_ASSET =
'https://cms.pm86.de/api/assets/pages/img_2814-ar4x2.webp?_environment=windwiderstand';
const meta = {
title: 'Atoms/RustyImage',
component: RustyImage,
tags: ['autodocs'],
args: { src: CMS_ASSET, width: 640, aspect: '16/9', alt: 'Steigerwald' },
} satisfies Meta<typeof RustyImage>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Breitbild: Story = {};
export const Quadratisch: Story = { args: { aspect: '1/1', width: 400 } };
export const Portrait: Story = { args: { aspect: '3/4', width: 360 } };
+19
View File
@@ -0,0 +1,19 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import SearchField from './SearchField.svelte';
const meta = {
title: 'Atoms/SearchField',
component: SearchField,
tags: ['autodocs'],
args: { placeholder: 'Beiträge durchsuchen …' },
} satisfies Meta<typeof SearchField>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Hell: Story = { args: { tone: 'light' } };
export const Dunkel: Story = {
args: { tone: 'dark' },
parameters: { backgrounds: { default: 'stein' } },
};
export const MitWert: Story = { args: { value: 'Windkraft' } };
+23
View File
@@ -0,0 +1,23 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import Select from './Select.svelte';
const meta = {
title: 'Atoms/Select',
component: Select,
tags: ['autodocs'],
args: {
label: 'Sortierung',
options: [
{ value: 'newest', label: 'Neueste zuerst' },
{ value: 'oldest', label: 'Älteste zuerst' },
{ value: 'az', label: 'AZ' },
],
},
} satisfies Meta<typeof Select>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standard: Story = {};
export const MitAuswahl: Story = { args: { value: 'oldest' } };
export const Disabled: Story = { args: { disabled: true } };
+32
View File
@@ -0,0 +1,32 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import SidebarNav from './SidebarNav.svelte';
const meta = {
title: 'Atoms/SidebarNav',
component: SidebarNav,
tags: ['autodocs'],
} satisfies Meta<typeof SidebarNav>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standard: Story = {
args: {
groups: [
{
label: 'Verfahren',
links: [
{ href: '/verfahren/ablauf', label: 'Ablauf', active: true },
{ href: '/verfahren/fristen', label: 'Fristen' },
],
},
{
label: 'Mitmachen',
links: [
{ href: '/mitmachen/einwenden', label: 'Einwenden' },
{ href: '/mitmachen/spenden', label: 'Spenden' },
],
},
],
},
};
+15
View File
@@ -0,0 +1,15 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import Slider from './Slider.svelte';
const meta = {
title: 'Atoms/Slider',
component: Slider,
tags: ['autodocs'],
args: { min: 0, max: 100, value: 50, label: 'Umkreis (km)' },
} satisfies Meta<typeof Slider>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standard: Story = {};
export const Disabled: Story = { args: { disabled: true } };
+21
View File
@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import TabBar from './TabBar.svelte';
const meta = {
title: 'Atoms/TabBar',
component: TabBar,
tags: ['autodocs'],
args: {
tabs: [
{ id: 'karte', label: 'Karte' },
{ id: 'liste', label: 'Liste' },
{ id: 'info', label: 'Info' },
],
selectedId: 'karte',
},
} satisfies Meta<typeof TabBar>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standard: Story = {};
+22
View File
@@ -0,0 +1,22 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import TextInput from './TextInput.svelte';
const meta = {
title: 'Atoms/TextInput',
component: TextInput,
tags: ['autodocs'],
args: { label: 'Name', placeholder: 'Vor- und Nachname' },
} satisfies Meta<typeof TextInput>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standard: Story = {};
export const MitHilfetext: Story = {
args: { label: 'E-Mail', placeholder: 'name@example.org', helpText: 'Wird nicht veröffentlicht.' },
};
export const Fehler: Story = {
args: { label: 'E-Mail', value: 'ungültig', invalid: true, error: 'Bitte gültige E-Mail eingeben.' },
};
export const Disabled: Story = { args: { disabled: true, value: 'gesperrt' } };
export const Klein: Story = { args: { size: 'sm', label: 'PLZ', placeholder: '00000' } };
+17
View File
@@ -0,0 +1,17 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import Textarea from './Textarea.svelte';
const meta = {
title: 'Atoms/Textarea',
component: Textarea,
tags: ['autodocs'],
args: { label: 'Nachricht', placeholder: 'Ihre Nachricht …', rows: 4 },
} satisfies Meta<typeof Textarea>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standard: Story = {};
export const MitHilfetext: Story = { args: { helpText: 'Max. 500 Zeichen.' } };
export const Fehler: Story = { args: { invalid: true, error: 'Pflichtfeld.' } };
export const Disabled: Story = { args: { disabled: true, value: 'gesperrt' } };
+16
View File
@@ -0,0 +1,16 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import Toggle from './Toggle.svelte';
const meta = {
title: 'Atoms/Toggle',
component: Toggle,
tags: ['autodocs'],
args: { label: 'Vergangene ausblenden' },
} satisfies Meta<typeof Toggle>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Aus: Story = {};
export const An: Story = { args: { checked: true } };
export const Disabled: Story = { args: { disabled: true } };
+33
View File
@@ -0,0 +1,33 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import Tooltip from './Tooltip.svelte';
const { Story } = defineMeta({
title: 'Atoms/Tooltip',
component: Tooltip,
tags: ['autodocs'],
argTypes: {
placement: { control: 'select', options: ['top', 'bottom'] },
},
});
</script>
<!-- Tooltip umschließt einen Trigger (children). Zum Sehen: über den Button
hovern/fokussieren. -->
<Story name="Oben" args={{ content: 'Erklärung erscheint hier', placement: 'top' }}>
<button
type="button"
class="rounded-sm border border-stein-300 bg-white px-3 py-1.5 text-sm"
>
Hover mich
</button>
</Story>
<Story name="Unten" args={{ content: 'Tooltip unterhalb', placement: 'bottom' }}>
<button
type="button"
class="rounded-sm border border-stein-300 bg-white px-3 py-1.5 text-sm"
>
Hover mich
</button>
</Story>
+5 -10
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { page } from '$app/stores';
import { useTranslate } from '$lib/translations';
import Button from '$lib/ui/Button.svelte';
import Icon from '@iconify/svelte';
import '$lib/iconify-offline';
@@ -28,20 +29,14 @@
{/if}
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
<a
href="/"
class="btn-primary inline-flex items-center gap-2 text-sm font-medium"
>
<Button href="/" variant="primary" class="text-sm">
<Icon icon="lucide:house" class="h-4 w-4" />
{t('page_404_back')}
</a>
<a
href="/posts/"
class="btn-secondary inline-flex items-center gap-2 text-sm font-medium"
>
</Button>
<Button href="/posts/" variant="secondary" class="text-sm">
<Icon icon="lucide:file-text" class="h-4 w-4" />
Zu den Beiträgen
</a>
</Button>
</div>
</div>
</section>
+20 -13
View File
@@ -13,6 +13,7 @@
import QrModal from "$lib/components/QrModal.svelte";
import SocialImageModal from "$lib/components/SocialImageModal.svelte";
import ImageModal from "$lib/components/ImageModal.svelte";
import Button from "$lib/ui/Button.svelte";
let { data }: { data: PageData } = $props();
const t = useTranslate();
@@ -213,24 +214,27 @@
<div class="mt-6 flex flex-wrap gap-2">
{#if item.attachment}
<a
<Button
href={`/cms-files?src=${encodeURIComponent(item.attachment.src)}&dl=1`}
class="btn-outline btn-sm inline-flex group"
variant="outline"
size="sm"
class="group"
>
<Icon icon="lucide:file-down" class="size-4 shrink-0" aria-hidden="true" />
<span>{item.attachment.label}</span>
</a>
</Button>
{/if}
{#if item.externalLink}
<a
<Button
href={item.externalLink}
variant="tertiary"
size="sm"
target="_blank"
rel="noopener noreferrer"
class="btn-tertiary btn-sm inline-flex"
>
{t(T.calendar_more_info)}
<Icon icon="lucide:external-link" class="size-3.5" aria-hidden="true" />
</a>
</Button>
{/if}
</div>
</div>
@@ -242,23 +246,26 @@
Zum Kalender hinzufügen
</p>
<div class="grid gap-2">
<button
type="button"
<Button
variant="outline"
size="sm"
onclick={() => downloadICS(toICS(), fileSlug(item.title))}
class="btn-outline btn-sm w-full justify-start"
class="w-full justify-start!"
>
<Icon icon="lucide:calendar-arrow-up" class="size-4 shrink-0" aria-hidden="true" />
.ics herunterladen
</button>
<a
</Button>
<Button
href={googleCalUrl(toICS())}
variant="outline"
size="sm"
target="_blank"
rel="noopener noreferrer"
class="btn-outline btn-sm w-full justify-start no-underline"
class="w-full justify-start!"
>
<Icon icon="mdi:google" class="size-4 shrink-0" aria-hidden="true" />
Google Kalender
</a>
</Button>
</div>
<hr class="my-3 border-stein-200" />
+7 -4
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from "svelte";
import Button from "$lib/ui/Button.svelte";
let title = $state("");
let terminZeit = $state(""); // datetime-local
@@ -122,11 +123,13 @@
<p class="text-xs text-stein-400">Eingereichte Termine werden vor der Veröffentlichung geprüft.</p>
<button
<Button
type="submit"
class="rounded-lg bg-wald-700 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-wald-800 disabled:opacity-50"
disabled={formState === "sending"}
>{formState === "sending" ? "Wird gesendet …" : "Termin einreichen"}</button>
variant="primary"
loading={formState === "sending"}
label={formState === "sending" ? "Wird gesendet …" : "Termin einreichen"}
class="text-sm"
/>
</form>
{/if}
</div>
+361
View File
@@ -0,0 +1,361 @@
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker.
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.15.0'
const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
addEventListener('install', function () {
self.skipWaiting()
})
addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
addEventListener('message', async function (event) {
const clientId = Reflect.get(event.source || {}, 'id')
if (!clientId || !self.clients) {
return
}
const client = await self.clients.get(clientId)
if (!client) {
return
}
const allClients = await self.clients.matchAll({
type: 'window',
})
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
})
break
}
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: {
packageVersion: PACKAGE_VERSION,
checksum: INTEGRITY_CHECKSUM,
},
})
break
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId)
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: {
client: {
id: client.id,
frameType: client.frameType,
},
},
})
break
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
const remainingClients = allClients.filter((client) => {
return client.id !== clientId
})
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister()
}
break
}
}
})
addEventListener('fetch', function (event) {
const requestInterceptedAt = Date.now()
// Bypass navigation requests.
if (event.request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (
event.request.cache === 'only-if-cached' &&
event.request.mode !== 'same-origin'
) {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been terminated (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
const requestId = crypto.randomUUID()
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
})
/**
* @param {FetchEvent} event
* @param {string} requestId
* @param {number} requestInterceptedAt
*/
async function handleRequest(event, requestId, requestInterceptedAt) {
const client = await resolveMainClient(event)
const requestCloneForEvents = event.request.clone()
const response = await getResponse(
event,
client,
requestId,
requestInterceptedAt,
)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
const serializedRequest = await serializeRequest(requestCloneForEvents)
// Omit the body of server-sent event stream responses.
// Cloning such responses would prevent client-side stream cancelations
// from reaching the original stream (a teed stream only cancels its
// source once both of its branches cancel) and would buffer the
// entire stream into the unconsumed clone indefinitely.
const isEventStreamResponse = response.headers
.get('content-type')
?.toLowerCase()
.startsWith('text/event-stream')
// Clone the response so both the client and the library could consume it.
const responseClone = isEventStreamResponse ? null : response.clone()
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
isMockedResponse: IS_MOCKED_RESPONSE in response,
request: {
id: requestId,
...serializedRequest,
},
response: {
type: response.type,
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
body: responseClone ? responseClone.body : null,
},
},
},
responseClone && responseClone.body
? [serializedRequest.body, responseClone.body]
: [],
)
}
return response
}
/**
* Resolve the main client for the given event.
* Client that issues a request doesn't necessarily equal the client
* that registered the worker. It's with the latter the worker should
* communicate with during the response resolving phase.
* @param {FetchEvent} event
* @returns {Promise<Client | undefined>}
*/
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
if (activeClientIds.has(event.clientId)) {
return client
}
if (client?.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll({
type: 'window',
})
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
}
/**
* @param {FetchEvent} event
* @param {Client | undefined} client
* @param {string} requestId
* @param {number} requestInterceptedAt
* @returns {Promise<Response>}
*/
async function getResponse(event, client, requestId, requestInterceptedAt) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = event.request.clone()
function passthrough() {
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers)
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
const acceptHeader = headers.get('accept')
if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim())
const filteredValues = values.filter(
(value) => value !== 'msw/passthrough',
)
if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', '))
} else {
headers.delete('accept')
}
}
return fetch(requestClone, { headers })
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough()
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough()
}
// Notify the client that a request has been intercepted.
const serializedRequest = await serializeRequest(event.request)
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
payload: {
id: requestId,
interceptedAt: requestInterceptedAt,
...serializedRequest,
},
},
[serializedRequest.body],
)
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
}
case 'PASSTHROUGH': {
return passthrough()
}
}
return passthrough()
}
/**
* @param {Client} client
* @param {any} message
* @param {Array<Transferable>} transferrables
* @returns {Promise<any>}
*/
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error)
}
resolve(event.data)
}
client.postMessage(message, [
channel.port2,
...transferrables.filter(Boolean),
])
})
}
/**
* @param {Response} response
* @returns {Response}
*/
function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error()
}
const mockedResponse = new Response(response.body, response)
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
})
return mockedResponse
}
/**
* @param {Request} request
*/
async function serializeRequest(request) {
return {
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.arrayBuffer(),
keepalive: request.keepalive,
}
}