feat(design-system): Storybook + Button-Atom-Vereinheitlichung + SectionHeader
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:
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
Generated
+2461
-13
File diff suppressed because it is too large
Load Diff
+10
-2
@@ -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
@@ -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); /* 26–38px */
|
||||
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) */
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 Mo–Fr',
|
||||
organisations: [{ name: 'BI Steigerwald' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Minimal: Story = {
|
||||
args: { contact: { name: 'Hans Weber', role: 'Presse', email: 'presse@example.org' } },
|
||||
};
|
||||
@@ -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' },
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -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 },
|
||||
};
|
||||
@@ -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 } };
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -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 } };
|
||||
@@ -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 = {};
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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' } };
|
||||
@@ -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' } };
|
||||
@@ -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 } },
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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 } };
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
+65
-28
@@ -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',
|
||||
};
|
||||
|
||||
const variantClasses = $derived(
|
||||
{
|
||||
primary: 'bg-wald-500 text-white hover:bg-wald-600 active:bg-wald-700',
|
||||
/* 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:
|
||||
'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]
|
||||
);
|
||||
'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, variantClasses].filter(Boolean).join(' '));
|
||||
const allClasses = $derived(
|
||||
[base, sizeClasses[size], variantClasses[variant], className].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}
|
||||
{/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}
|
||||
|
||||
@@ -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' } };
|
||||
@@ -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 } };
|
||||
@@ -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 } };
|
||||
@@ -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' } };
|
||||
@@ -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: 'A–Z' },
|
||||
],
|
||||
},
|
||||
} 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 } };
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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 } };
|
||||
@@ -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 = {};
|
||||
@@ -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' } };
|
||||
@@ -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' } };
|
||||
@@ -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 } };
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user