Compare commits

..

31 Commits

Author SHA1 Message Date
Peter Meier 4b87ad3c84 feat(top-banner): editorialer Hero-Scrim statt Farbkästen
Deploy / verify (push) Successful in 1m36s
Deploy / deploy (push) Successful in 1m36s
Titel-Overlay neu: linksseitiger dunkler Verlaufs-Scrim mit weißem
Titel + Subtitle und Wald-Eyebrow ("Windwiderstand Thüringen").
Ersetzt die beiden sich beißenden Farbkästen (fire-400/70 + white/50).
Eyebrow blendet aus, wenn er die Headline dupliziert (Home) — Content
und SEO unangetastet. Größen/Farben per `!` gegen unlayered base h1/h2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 10:28:52 +02:00
Peter Meier 275f66fb8f CalendarBlock: ruhigere, editorialere UI-Überarbeitung
Deploy / verify (push) Successful in 1m31s
Deploy / deploy (push) Successful in 1m36s
- Kopfleiste zusammengefasst: Suche-Pill + Themenfilter-Icon + Heute-Chip
- Monats-Grid neutralisiert: weisser Grund, Punkt-Marker statt Zahl-Badge
- Terminliste als Karten statt Zebra-Rows; Urgency in getönte Datums-Kachel
- Aktionsleiste ans Card-Ende verschoben, ruhige Text+Icon-Links

Enthält zusätzlich bestehende lokale Änderungen (Card, Header, ContactCard,
InfoCard, PostCardCompact, FilesBlock, app.css, Storybook-Fixtures).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:12:44 +02:00
Peter Meier 28816bca87 fix(section-header): Action-Link auf schmalen Viewports nicht mehr gequetscht
Deploy / verify (push) Successful in 1m35s
Deploy / deploy (push) Successful in 1m40s
Kicker/Titel/Action stacken jetzt vertikal unter sm: (statt erzwungenem
flex-row + whitespace-nowrap), Action bekommt volle Breite eigene Zeile.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:02:55 +02:00
Peter Meier 0473b92201 chore(footer): CSS-Hide-Hack fürs Copyright entfernt (CMS-Cleanup)
Deploy / verify (push) Successful in 1m28s
Deploy / deploy (push) Successful in 1m30s
footer-main.json5 splittet jetzt row1=Newsletter / row2=Link-Listen,
Copyright-Markdown-Block komplett aus rows entfernt (Bottom-Bar rendert
Copyright/Lizenz jetzt direkt). CSS-Hack `.content-row:last-child { display:none }`
dadurch überflüssig.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 12:52:44 +02:00
Peter Meier 57f4b226f9 feat(footer,org): Footer-Redesign + OrganisationCard auf InfoCard-Basis
Footer:
- Newsletter 2-spaltig (Titel/Desc links, Formular rechts), Desc als
  Translation-Key newsletter_inline_desc
- Link-Spalten-Überschriften als kleine Label-Kappen, Links ohne Unterstrich
- Bottom-Bar: Logo + Site-Name (data.siteName) links, Copyright/Lizenz rechts
- Site-Name-Default "Windwiderstand Thüringen" (constants.ts)

OrganisationCard:
- Neue Komponente auf InfoCard-Basis; InfoCard um href/media/badge/footer-Slots
  erweitert
- OrganisationsBlock: 4 divergierende Kartenpfade → 1 OrganisationCard
- Story + CTA-Button (mitmachen) size md + Icon

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 11:59:36 +02:00
Peter Meier 5a1a49a97b feat(calendar): section_header (kicker/number/action/title) im Schema + wired
Deploy / verify (push) Successful in 1m47s
Deploy / deploy (push) Successful in 1m32s
calendar-Schema extends section_header + title-Feld. CalendarBlockData +
kicker/number/action. CalendarCompact reicht kicker/number an SectionHeader.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 11:20:33 +02:00
Peter Meier c8334dbd6c feat(calendar): CalendarCompact nutzt SectionHeader; fix IconButton-Typing
Deploy / verify (push) Successful in 1m27s
Deploy / deploy (push) Successful in 1m44s
CalendarCompactBlock: plain h3 → SectionHeader (title + 'Alle kommenden Termine'-
Action). IconButton props via HTMLButton/AnchorAttributes statt Index-Signatur
(brach Storybook-Typing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 11:18:02 +02:00
Peter Meier 6ca8ae6720 fix(css): @layer base revert — Preflight schlug Heading-Defaults (Hero-Titel kaputt)
Deploy / verify (push) Failing after 1m16s
Deploy / deploy (push) Has been skipped
E rückgängig: Tailwind-Preflight resettet h1–h6 selbst in @layer base → gleiche
Layer, Preflight gewann → Headings kollabierten (Hero-Titel). Heading-Defaults
wieder unlayered; text-*!-Hacks in InfoCard/ContactFormBlock-Titeln zurück.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 11:14:51 +02:00
Peter Meier 3679978d6c refactor(consolidate): IconButton + Badge-solid + @layer base + Massen-Migration
Deploy / verify (push) Failing after 1m21s
Deploy / deploy (push) Has been skipped
E: app.css Base-h1–h6 in @layer base → text-*!-Hacks in Titeln entfernt.
Neue Atoms: IconButton (runde Icon-Buttons), Badge solid-Variante + uppercase.

Migrationen (6 parallele Pässe):
- IconButton: 19 runde Nav/Close-Buttons (Carousel, Galleries, QuoteCarousel,
  PostOverview, Organisations, ImageModal).
- Badge: Kategorie/Status-Pills (PostCard, BlogOverview, kalender, Comments,
  CalendarBlock, FilesBlock, SearchableText, Stellingnahme).
- ui-Atome: Formular-Inputs (Comments, termin-melden, Stellingnahme) → TextInput/
  Textarea/Checkbox/SearchField; dunkle NewsletterInline bewusst raw.
- Button-Atom: ~20 inline Buttons in StellingnahmeGeneratorBlock.
- Radius-Sweep: Cards→rounded-lg (viele via .card-surface), Buttons/Inputs→rounded-md,
  Chips→rounded-full; StrommixBlock/Calendar/Files/Adressbuch u.a.

svelte-check 0 errors, voller Prod-Build grün.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 11:11:48 +02:00
Peter Meier fe7bce2935 style(radius): Modal-Panels rounded-lg, ContentModal-Button rounded-md
Deploy / verify (push) Successful in 1m34s
Deploy / deploy (push) Successful in 1m31s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 10:55:23 +02:00
Peter Meier 657f726edb refactor(consolidate): ArrowLink tone + FormSuccess-Komponente
Deploy / verify (push) Successful in 1m39s
Deploy / deploy (push) Successful in 1m34s
A: ArrowLink um tone-Prop (himmel/wald/stein/white) — PostOverview + CalendarCompact
inline Arrow-Links → ArrowLink.
B: 3× identische Formular-Success-Boxen → internal/FormSuccess.svelte (rounded-lg).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 10:53:43 +02:00
Peter Meier f77314dc49 feat(blocks): SectionGrid/InfoCard/ContactForm + Konsolidierung (Tier 1)
Deploy / verify (push) Successful in 1m31s
Deploy / deploy (push) Successful in 1m33s
Neue Komponenten: SectionGrid, InfoCard (tone/compact), ArrowLink, ContactFormBlock,
SectionGridBlock. CMS-Blöcke section_grid + contact_form (info_card-Collection).
OrganisationsBlock: presentation "grid" (SectionGrid + InfoCard-Tiles).
Kontaktformular-Submit: size md + Send-Icon.

Konsolidierung Tier 1:
- .card-surface + Card-Atom → rounded-lg, .chip → rounded-full, Input-Atome → rounded-md
- tote .btn-*-Klassen aus app.css entfernt (Atom nutzt --color-btn-*-Tokens)
- hardcoded Hex → stein-Tokens (InfoCard, ContactFormBlock)
- redundante rounded-sm auf card-surface-Nutzern entfernt

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 10:49:54 +02:00
Peter Meier 8fd0112dcb style(infocard): exakte Specs — Titel 16px/700, Deko-Farben, p-22 gap-14
Deploy / verify (push) Successful in 1m27s
Deploy / deploy (push) Successful in 1m37s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 09:42:39 +02:00
Peter Meier 3878a2ed26 feat(section-grid): InfoCard + SectionGrid als CMS-Block
Deploy / verify (push) Successful in 2m27s
Deploy / deploy (push) Successful in 1m49s
Neuer section_grid-Block (SectionHeader + Card-Grid aus info_card-Refs) +
info_card-Collection. SectionGrid/InfoCard-Komponenten, BlockRenderer-Case,
content_layout erlaubt section_grid in Rows. SectionHeader.title jetzt optional
(Kicker-only-Header).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 09:37:05 +02:00
Peter Meier 7c7538a22c refactor(badge): eine Badge-Komponente überall, alle 7 CMS-Farben
Deploy / verify (push) Successful in 1m36s
Deploy / deploy (push) Successful in 1m42s
components/Badge.svelte = einzige Badge-Quelle. colorClasses deckt jetzt alle
CMS-badge.color-Werte ab (green/blue/amber/orange/purple/teal/slate) — vorher
fielen orange/purple/teal/slate auf grau (Regionen-Badges sahen gleich aus).

Ad-hoc Badge-Spans (ContactCard, AdressbuchBlock) durch <Badge> ersetzt.
Verwaiste ui/Badge.svelte (0 Nutzungen) + Atoms/Badge-Story entfernt.
Molecules/Badge-Story zeigt jetzt alle Farben.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 09:26:48 +02:00
Peter Meier b60da59c92 feat(headline): optionaler SectionHeader-Modus (kicker/number/action)
Deploy / verify (push) Successful in 1m28s
Deploy / deploy (push) Successful in 1m36s
HeadlineBlock rendert als SectionHeader sobald kicker oder action gesetzt ist,
sonst schlichte Text-Headline wie bisher (rückwärtskompatibel, opt-in).
SectionHeader-Titel unterstützt jetzt \n → <br>. headline-Schema extends
section_header.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:48:23 +02:00
Peter Meier 03e84c1f28 feat(design-system): Storybook + Button-Atom-Vereinheitlichung + SectionHeader
Deploy / verify (push) Successful in 1m39s
Deploy / deploy (push) Successful in 2m9s
Storybook (SvelteKit-Framework, Tailwind v4, MSW, Translation-Decorator):
Stories für Atoms/Molecules/Organisms + alle CMS-Blocks.

Button-Atom: 5 .btn-*-Varianten (primary/secondary/tertiary/outline/warning),
class-Merge-Prop, href→<a>, Loading-Spinner neben Label, rounded-md/font-medium.
Alle rohen .btn-*- und Hand-Style-Buttons app-weit aufs Atom migriert.

SectionHeader-Molecule (kicker/number/title/subtitle/action) + Integration in
7 CMS-Blocks (post_overview, organisations, organisations_map, searchable_text,
image_gallery, youtube_video_gallery, files) via neuem section_header-Schema.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:35:57 +02:00
Peter Meier 289cdb3951 feat(home): Featured-Post-Layout, Termine-Look, Zitat-Sektion, Typo-Regelwerk
Deploy / verify (push) Successful in 1m31s
Deploy / deploy (push) Successful in 2m10s
- PostOverview: neues design "featured" (1 großer Post links, Rest kompakt
  rechts als PostCardCompact – Bild links, Text rechts, keine Nested-Links)
- CalendarCompact: Termine als Liste mit Datum-Badge + Kicker-Headline
- QuoteCarousel: dunkle Full-Bleed-Sektion, Lora-Zitat mit «», Author-Split,
  Kicker mit Sektionsnummer, Pill-Pagination + Pfeile/Swipe
- Typo-Regelwerk: Lora statt Aleo (Zitate), Headings semibold (600),
  H2/H3-Skala, "Weiterlesen"-Link-Muster, docs/typography.md
- HeadlineBlock: \n → <br> für bewusste Umbrüche
- Layout: neues spaceTop-Feld (mt-*), analog spaceBottom
- OrganisationsBlock: CTA volle Breite, Button rechts

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 16:40:05 +02:00
Peter Meier 91e66d8d4f fix(docker): healthcheck 127.0.0.1 statt localhost (IPv6 ::1 refused)
Deploy / verify (push) Successful in 1m10s
Deploy / deploy (push) Successful in 1m27s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 23:25:36 +02:00
Peter Meier 4376c40369 feat(stellungnahme): zentrales KI-Regelwerk aus stellungnahme_config
Deploy / verify (push) Successful in 1m21s
Deploy / deploy (push) Successful in 2m13s
Die AI-Rewrite-Regeln (DEFAULT_AI_RULES) waren im Block hardcodiert und
hätten pro Generator (40 Instanzen) dupliziert werden müssen, um im CMS
editierbar zu sein. Stattdessen ein Singleton stellungnahme_config
(Slug "default") mit Feld aiRules: der Resolver lädt es einmal und hängt
block.aiRules an alle Generator-Blöcke. Komponente seedet daraus, mit
DEFAULT_AI_RULES als Code-Fallback; Reset-Button setzt auf den CMS-Wert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 13:56:16 +02:00
Peter Meier 7379d5750c fix(youtube-gallery): remove crossfade flash on carousel navigation
Deploy / verify (push) Successful in 1m23s
Deploy / deploy (push) Successful in 1m45s
The carousel iframe row was wrapped in {#key currentIndex} with
transition:fade, so switching slides (or navigating to the page) faded
the old and new row in overlap — briefly showing two rows / a blink.
Drop the fade from both iframe containers, matching the modal variant
which uses {#key} without a transition.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 22:48:10 +02:00
Peter Meier 84c41f80b1 feat(modal): route-targeted content modals on navigation
Deploy / verify (push) Successful in 1m12s
Deploy / deploy (push) Successful in 2m10s
Add a global modal/popup system driven entirely from the CMS. A new
`modal` config collection self-targets routes via a glob/regex urlPattern
(optional time window), and its `content` array of block references renders
through the existing BlockRenderer — so markdown, HTML and YouTube blocks
work exactly like a page row. A `modals` list collection holds the active
set behind an enable flag (analogous to `campaigns`).

- modal-match.ts: glob (`*`/`**`) + `~regex` pathname matcher, time window
  and modal selection helpers (+ unit tests)
- ContentModal.svelte: immediate/delay/scroll/exit_intent triggers, session
  vs. permanent dismiss (sessionStorage + localStorage, keyed by slug+version)
- +layout.server.ts: load `modals-global` in the bootstrap batch, resolve
  block images server-side
- +layout.svelte: mount on non-embed routes, match against the current path

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 09:47:49 +02:00
Peter Meier 121b593e4e Blog: Abstand unter Pagination/Sortier-Zeile (mobile)
Deploy / verify (push) Successful in 1m8s
Deploy / deploy (push) Successful in 1m34s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 10:42:22 +02:00
Peter Meier e96bfee317 Blog-Übersicht: Featured-Hero, Neu-Badge, Org-Sektion, Sortierung, konsolidiertes Suchfeld
Deploy / verify (push) Successful in 1m18s
Deploy / deploy (push) Successful in 1m31s
- SearchField-Atom (src/lib/ui/SearchField.svelte, light/dark) in Kalender,
  Beiträge und globaler Suche einheitlich eingesetzt
- Featured-Hero (neuester Beitrag groß, Seite 1 ungefiltert, nur bei "newest")
- Neu-Badge in PostCard (Beitrag < 7 Tage)
- Org-/BI-Tags (tag-org-*) in eigene Sektion; Tags ohne Beiträge ausgeblendet
- Inline-Newsletter-CTA, prominenter RSS-Link
- Sortierung Neueste/Älteste (Server-Param + URL-State, Toggle neben Pagination,
  Pagination trägt sort weiter)
- Pagination als Pills (rounded-full)
- Kalender: Tag-Filter einklappbar, Filterleiste mit Suche + Heute

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 10:40:07 +02:00
Peter Meier f86c302d49 feat(stellungnahme): UX-Politur Ausgabe + Betroffenheit
Deploy / verify (push) Successful in 1m7s
Deploy / deploy (push) Successful in 1m34s
- Output-Hierarchie: Schreibvorlage 'Empfohlen', KI-Block 'Optional'
- KI-Block: 4-Schritte-Anleitung eingeklappt (weniger dicht)
- Betroffenheit: konkrete Beispiel-Liste + Zeichenzaehler

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 16:24:07 +02:00
Peter Meier c60412bb53 style(windkarte): Hinweis-Icon (mouse-pointer-click) statt Emoji
Deploy / verify (push) Successful in 1m9s
Deploy / deploy (push) Successful in 1m35s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 16:03:37 +02:00
Peter Meier f8bbf59f90 style(windkarte): Hinweis kleiner (text-xs), Bindestrich raus
Deploy / verify (push) Successful in 1m16s
Deploy / deploy (push) Successful in 1m28s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 16:02:25 +02:00
Peter Meier c12d626507 feat(pwa): installierbar + Offline-Grundgeruest
Deploy / verify (push) Successful in 1m7s
Deploy / deploy (push) Successful in 1m45s
- Manifest (name/standalone/theme-color wald), Icons aus echtem Logo
  (192/512/maskable/apple-touch) via scripts/generate-pwa-icons.mjs
- app.html: manifest + theme-color + apple-touch-icon + iOS-Meta
- service-worker.ts: precache Build-Assets, Seiten network-first mit
  Offline-Cache-Fallback (SvelteKit registriert autom. in Prod)
- Push (VAPID) folgt separat

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 15:58:35 +02:00
Peter Meier e5566ff797 feat(regionalplan): Generatoren leichter finden
Deploy / verify (push) Successful in 1m12s
Deploy / deploy (push) Successful in 1m32s
- LinkListBlock: id+scroll-mt-24 -> Anker-Sprung moeglich
- Frist-CTA verlinkt auf #generatoren (springt direkt zur Liste)
- WindkarteBlock: Hinweis "Klicke dein Vorranggebiet" (Karte ist Picker)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 15:47:48 +02:00
Peter Meier 7a490b1ae1 feat(frist): Deadline-Banner im AnnouncementBanner-Look (rot), nur ein Balken
Deploy / verify (push) Successful in 1m11s
Deploy / deploy (push) Successful in 1m29s
- Look wie AnnouncementBanner: kompakte zentrierte Leiste, weiße Schrift, 0.7rem,
  variant-Farbe (urgent=rot), flex-wrap → fließt/stackt sauber mobil
- Countdown als dunkles Badge (bg-black/20)
- Layout: aktive Frist verdrängt NEU-Banner → nie 2 Balken über der Navi

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 15:32:40 +02:00
Peter Meier f7529d3b47 feat(frist): Deadline-Countdown site-weit + variant-Styling (urgent=rot)
- DeadlineBannerBlock: Farbe nach variant (urgent rot / accent / info / default)
- Layout: deadline-banner-auslegung-2026 site-weit im Bootstrap geladen + oben
  gerendert (vorher nur auf /regionalplan)
- page-regionalplan: inline-Banner entfernt (kein Doppel), Content folgt separat

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 15:19:38 +02:00
165 changed files with 8580 additions and 1344 deletions
+3
View File
@@ -19,3 +19,6 @@ dist/
# Logs # Logs
*.log *.log
npm-debug.log* npm-debug.log*
# Storybook
storybook-static/
@@ -0,0 +1,14 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import TranslationProvider from '$lib/components/TranslationProvider.svelte';
import strings from '$lib/components/blocks/_fixtures/translations.json';
// Storybook reicht die Story als `children`-Snippet rein. Wir setzen den
// Translation-Context (echte deutsche Strings aus dem CMS-Bundle), damit
// Blocks lesbare Texte statt roher Keys zeigen.
let { children }: { children: Snippet } = $props();
</script>
<TranslationProvider translations={strings}>
{@render children()}
</TranslationProvider>
+40
View File
@@ -0,0 +1,40 @@
import type { StorybookConfig } from '@storybook/sveltekit';
import tailwindcss from '@tailwindcss/vite';
const config: StorybookConfig = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|ts|svelte)'],
addons: [
'@storybook/addon-svelte-csf',
'@storybook/addon-a11y',
'@storybook/addon-docs',
],
framework: '@storybook/sveltekit',
// MSW-Service-Worker liegt in static/ (via `npx msw init static/`). Storybook
// muss static/ ausliefern, damit /mockServiceWorker.js erreichbar ist.
staticDirs: ['../static'],
// Tailwind v4 läuft als Vite-Plugin. Das sveltekit-Framework bringt nur die
// Svelte/SvelteKit-Integration mit ($app/$env/$lib-Mocks), nicht Tailwind —
// darum hier explizit dazuhängen, sonst greifen keine Utility-Klassen/Tokens.
viteFinal: async (viteConfig) => {
viteConfig.plugins = viteConfig.plugins ?? [];
viteConfig.plugins.push(tailwindcss());
// Same-origin-Routen, die im echten Frontend SvelteKit-Server-Endpoints
// sind (in Storybook nicht existieren), auf die Live-Site proxien. Damit
// rendern RustyImage-Bilder (/cms-images, /cms-files) und die Live-Daten-
// Blocks (/api/strommix, /api/stellungnahme) mit echten Daten — ohne
// Fixtures. Deterministische Offline-Mocks stattdessen via MSW (siehe
// .storybook/README oder Kommentar unten).
const PROXY_TARGET = 'https://windwiderstand.de';
viteConfig.server = viteConfig.server ?? {};
viteConfig.server.proxy = {
...(viteConfig.server.proxy ?? {}),
'/cms-images': { target: PROXY_TARGET, changeOrigin: true },
'/cms-files': { target: PROXY_TARGET, changeOrigin: true },
'/api/strommix': { target: PROXY_TARGET, changeOrigin: true },
'/api/stellungnahme': { target: PROXY_TARGET, changeOrigin: true },
};
return viteConfig;
},
};
export default config;
+13
View File
@@ -0,0 +1,13 @@
<!--
SvelteKit liest $env/dynamic/public im Browser aus globalThis.__sveltekit_dev.env.
Dieses Global setzt sonst nur der echte SvelteKit-Dev-Server. In Storybook hier
vorab setzen, sonst crasht jede Komponente mit `env.PUBLIC_*` (Windkarte,
Stellingnahme, …) mit "__sveltekit_dev is undefined". Läuft vor dem Story-Bundle.
-->
<script>
window.__sveltekit_dev = window.__sveltekit_dev || { env: {} };
window.__sveltekit_dev.env = Object.assign(
{ PUBLIC_CMS_URL: 'https://cms.pm86.de' },
window.__sveltekit_dev.env,
);
</script>
+54
View File
@@ -0,0 +1,54 @@
import type { Preview } from '@storybook/sveltekit';
import { initialize, mswLoader } from 'msw-storybook-addon';
import WithTranslations from './decorators/WithTranslations.svelte';
// Design-System-Styles: Fonts, Tailwind, Farbtokens (wald/erde/…). Zieht die
// echte app.css des Frontends → Stories sehen aus wie live.
import '../src/app.css';
// MSW starten. `onUnhandledRequest: 'bypass'` → Requests ohne Handler laufen
// normal durch (z.B. der Vite-Proxy für /cms-images). Nur explizit gemockte
// Endpoints werden abgefangen → Blocks mit fetch entwickeln sich deterministisch
// offline, per Story via `parameters.msw.handlers`.
initialize({ onUnhandledRequest: 'bypass' });
// Default-Mock für den SvelteKit-page-Store/-State ($app/stores + $app/state).
// Ohne das crasht jede Komponente, die $page.url liest (Header, PostActions, …).
// Einzelne Stories können via parameters.sveltekit_experimental überschreiben.
const mockPage = {
url: new URL('http://localhost:6006/'),
params: {},
route: { id: null },
status: 200,
error: null,
data: {},
form: null,
state: {},
};
const preview: Preview = {
loaders: [mswLoader],
// Alle Stories in den Translation-Context wrappen → echte Texte statt Keys.
decorators: [() => WithTranslations],
parameters: {
sveltekit_experimental: {
stores: { page: mockPage },
state: { page: mockPage },
},
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
// Frontend rendert auf hellem Grund; Storybook-Canvas dazu passend.
backgrounds: {
default: 'weiss',
values: [
{ name: 'weiss', value: '#ffffff' },
{ name: 'stein', value: '#f5f5f4' },
],
},
},
};
export default preview;
+1 -1
View File
@@ -36,6 +36,6 @@ EXPOSE 3001
ENV PORT=3001 ENV PORT=3001
HEALTHCHECK --interval=5s --timeout=3s --start-period=15s --retries=3 \ HEALTHCHECK --interval=5s --timeout=3s --start-period=15s --retries=3 \
CMD wget -qO- http://localhost:3001/ > /dev/null || exit 1 CMD wget -qO- http://127.0.0.1:3001/ > /dev/null || exit 1
CMD ["node", "build"] CMD ["node", "build"]
+44
View File
@@ -0,0 +1,44 @@
# Typo-Regelwerk Windwiderstand
Verbindliche Typografie für die komplette Seite. Umsetzung in `src/app.css`.
## Schriften
- **Alles in Inter.** Lora nur für Zitate (kursiv). Sonst nie.
- Erlaubte Gewichte: **300 / 400 / 500 / 600 / 700**. Nie 800/900.
- Immer Satzschreibung (sentence case). Kein ALL-CAPS außer kleinen Labels.
- Keine Ausrufezeichen. Keine Emoji.
## Skala
| Rolle | Größe | Gewicht | Line-height | Letter-spacing |
|---|---|---|---|---|
| Hero-H1 | clamp(3866px) | 700 | 1.05 | -0.025em |
| Leitstatement | clamp(2638px) | 600 | 1.2 | -0.02em |
| H2 Sektion | 34px | 600 | — | -0.02em |
| H3 Karte/Sub | 22px | 600 | — | -0.01em |
| Fließtext | 18px | 400 | 1.6 | — (bewusst groß & luftig) |
| Klein/Meta | 1314px | 400500 | — | — |
| Kicker-Label | 12px | 600 | — | 0.14em, UPPERCASE |
## Schriftfarbe
- Fließtext `#333735` (stein-700), Überschriften `#1f2221` (stein-800) — kein reines Schwarz
- Links `#436e85` (himmel-500), hover `#375a6d`
- Kicker / Nummer-Akzent `#2d7a45` (wald)
## Betonung
Über `<strong>` mit Gewicht **600** — nie kursiv, nie farbig.
## Rhythmus
- Nummerierte Sektionen (0105) + Kicker-Label als Sektionsmarke
- `text-wrap: balance` auf Headlines
- Fließtext auf ~6062ch begrenzen
- Leitstatement zusätzlich auf ~24ch begrenzen
- Großzügiger Weißraum, klare Hierarchie, keine Effekte
## Weiterlesen-Link
15px, Gewicht 600, Farbe himmel-500, dünne Unterlinie `#b8c9d4`, mit `mdi:arrow-right`.
+2466 -8
View File
File diff suppressed because it is too large Load Diff
+11 -2
View File
@@ -17,11 +17,17 @@
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:coverage": "vitest run --coverage", "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": { "devDependencies": {
"@iconify-json/lucide": "^1.2.114", "@iconify-json/lucide": "^1.2.114",
"@iconify-json/mdi": "^1.2.3", "@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/adapter-node": "^5.2.12",
"@sveltejs/kit": "^2.65.0", "@sveltejs/kit": "^2.65.0",
"@sveltejs/vite-plugin-svelte": "^5.0.3", "@sveltejs/vite-plugin-svelte": "^5.0.3",
@@ -31,7 +37,10 @@
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/sanitize-html": "^2.13.0", "@types/sanitize-html": "^2.13.0",
"@vitest/coverage-v8": "^2.1.9", "@vitest/coverage-v8": "^2.1.9",
"msw": "^2.15.0",
"msw-storybook-addon": "^2.0.7",
"openapi-typescript": "^7.4.0", "openapi-typescript": "^7.4.0",
"storybook": "^10.5.0",
"svelte": "^5.28.2", "svelte": "^5.28.2",
"svelte-check": "^4.1.4", "svelte-check": "^4.1.4",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
@@ -40,7 +49,7 @@
"vitest": "^2.1.9" "vitest": "^2.1.9"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/aleo": "^5.2.7", "@fontsource-variable/lora": "^5.2.8",
"@fontsource-variable/rubik": "^5.2.8", "@fontsource-variable/rubik": "^5.2.8",
"@fontsource/inter": "^5.2.8", "@fontsource/inter": "^5.2.8",
"@iconify/svelte": "^5.2.1", "@iconify/svelte": "^5.2.1",
+34
View File
@@ -0,0 +1,34 @@
// Erzeugt PWA-Icons aus dem echten App-Logo (logo_v3.svg) auf hellem Grund.
// Läuft manuell: `node scripts/generate-pwa-icons.mjs`
import sharp from "sharp";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const staticDir = join(root, "static");
const BG = "#f0f5f1"; // wald-50 (wie Header-Hintergrund)
const logoSvg = readFileSync(
join(root, "..", "cms_content", "windwiderstand", "assets", "logo_v3.svg"),
"utf8",
);
async function makeIcon(size, logoRatio, out) {
const logoPx = Math.round(size * logoRatio);
const logo = await sharp(Buffer.from(logoSvg), { density: 384 })
.resize(logoPx, logoPx, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } })
.png()
.toBuffer();
const pad = Math.round((size - logoPx) / 2);
await sharp({ create: { width: size, height: size, channels: 4, background: BG } })
.composite([{ input: logo, top: pad, left: pad }])
.png()
.toFile(join(staticDir, out));
console.log("wrote", out, `${size}px`);
}
await makeIcon(192, 0.78, "icon-192.png");
await makeIcon(512, 0.78, "icon-512.png");
await makeIcon(512, 0.6, "icon-maskable-512.png"); // mehr Rand für Maskable-Safe-Zone
await makeIcon(180, 0.78, "apple-touch-icon.png");
+58 -80
View File
@@ -6,7 +6,7 @@
@import "@fontsource/inter/latin-500.css"; @import "@fontsource/inter/latin-500.css";
@import "@fontsource/inter/latin-600.css"; @import "@fontsource/inter/latin-600.css";
@import "@fontsource/inter/latin-700.css"; @import "@fontsource/inter/latin-700.css";
@import "@fontsource-variable/aleo"; @import "@fontsource-variable/lora";
@import "tailwindcss"; @import "tailwindcss";
@@ -17,7 +17,12 @@
@layer components { @layer components {
/* Shared card surface: border, background, shadow, radius */ /* Shared card surface: border, background, shadow, radius */
.card-surface { .card-surface {
@apply rounded-sm border border-stein-200 bg-white shadow-sm; @apply rounded-lg border border-stein-200 bg-white shadow-sm;
}
/* Shared card hover: lift + shadow + wald border, used by all clickable card/tile/list-row components */
.card-hover-lift {
@apply transition-[transform,box-shadow,border-color] duration-200 hover:-translate-y-0.5 hover:border-wald-300 hover:shadow-md;
} }
/* Diagonal strikethrough for past calendar days */ /* Diagonal strikethrough for past calendar days */
@@ -133,15 +138,15 @@
/* Link (Himmel) für @apply text-link */ /* Link (Himmel) für @apply text-link */
--color-link: var(--color-himmel-500); --color-link: var(--color-himmel-500);
/* Fonts Tailwind-Utilities: font-sans (Inter, default), font-serif (Aleo, Zitate) */ /* Fonts Tailwind-Utilities: font-sans (Inter, default), font-serif (Lora, Zitate) */
--font-sans: Inter, ui-sans-serif, system-ui, -apple-system, sans-serif; --font-sans: Inter, ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-serif: "Aleo Variable", Aleo, Georgia, "Times New Roman", serif; --font-serif: "Lora Variable", Lora, Georgia, "Times New Roman", serif;
} }
:root { :root {
/* Typography Inter (primary), Aleo (Zitate) */ /* Typography Inter (primary), Lora (Zitate) */
--font-body: Inter, ui-sans-serif, system-ui, -apple-system, sans-serif; --font-body: Inter, ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-secondary: "Aleo Variable", Aleo, Georgia, "Times New Roman", serif; --font-secondary: "Lora Variable", Lora, Georgia, "Times New Roman", serif;
/* Semantic (nur Light Theme) */ /* Semantic (nur Light Theme) */
--bg-primary: #ffffff; --bg-primary: #ffffff;
@@ -177,7 +182,7 @@
font-family: var(--font-body); font-family: var(--font-body);
font-size: 1.125rem; /* 18px Body Large laut Design System */ font-size: 1.125rem; /* 18px Body Large laut Design System */
line-height: 1.556; line-height: 1.6;
color: var(--color-font); color: var(--color-font);
} }
@@ -191,36 +196,40 @@ main {
/* ========================================================================== /* ==========================================================================
Typography Type Scale (02-typography.md) 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.
========================================================================== */ ========================================================================== */
/* Heading-Defaults bewusst UNLAYERED: Tailwind-Preflight resettet h1h6 in
@layer base (font-size:inherit). Diese Rules müssen darüber liegen → unlayered.
Folge: Utilities auf Headings brauchen `!` (siehe InfoCard/ContactFormBlock). */
h1, h1,
h2, h2,
h3, h3,
h4 { h4 {
color: var(--color-headings); color: var(--color-headings);
text-wrap: balance;
} }
/* Mobile first, dann Tablet (768px), Desktop (1024px) */ /* Mobile first, dann Tablet (768px), Desktop (1024px) */
h1 { h1 {
font-size: 1.75rem; font-size: 1.75rem;
line-height: 2.125rem; line-height: 2.125rem;
font-weight: 700; font-weight: 600;
letter-spacing: -0.015em; letter-spacing: -0.015em;
} }
h2 { h2 {
font-size: 1.375rem; font-size: 1.5rem;
line-height: 1.75rem; line-height: 1.875rem;
font-weight: 700; font-weight: 600;
letter-spacing: -0.01em; letter-spacing: -0.02em;
} }
h3 { h3 {
font-size: 1.25rem; font-size: 1.25rem;
line-height: 1.625rem; line-height: 1.625rem;
font-weight: 600; font-weight: 600;
letter-spacing: -0.005em; letter-spacing: -0.01em;
} }
h4 { h4 {
@@ -235,8 +244,8 @@ h4 {
line-height: 2.625rem; line-height: 2.625rem;
} }
h2 { h2 {
font-size: 1.5rem; font-size: 1.75rem;
line-height: 2rem; line-height: 2.25rem;
} }
h3 { h3 {
font-size: 1.375rem; font-size: 1.375rem;
@@ -254,12 +263,12 @@ h4 {
line-height: 3rem; line-height: 3rem;
} }
h2 { h2 {
font-size: 1.75rem; font-size: 2.125rem;
line-height: 2.25rem; line-height: 2.625rem;
} }
h3 { h3 {
font-size: 1.5rem; font-size: 1.375rem;
line-height: 2rem; line-height: 1.875rem;
} }
} }
@@ -337,6 +346,29 @@ main a,
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
/* Weiterlesen-/„Mehr"-Links: ein Absatz, der NUR aus einem Link besteht, wird
* als kompakter CTA-Link gerendert (kleiner, dünne Unterlinie, Pfeil).
* Konvention: Link allein im Absatz = Weiterlesen-Link. `main …`-Prefix hebt
* die Spezifität über die generische Content-Link-Regel. */
main .markdown p:has(> a:only-child) {
margin-top: 0.75rem;
}
main .markdown p > a:only-child {
font-size: 15px; /* px, da :root=18px → rem-Basis verschoben */
font-weight: 600;
text-decoration-thickness: 1px;
text-decoration-color: #b8c9d4;
text-underline-offset: 3px;
}
/* nbsp + Pfeil bleiben im Textfluss → durchgehende Unterlinie ohne Bruch. */
main .markdown p > a:only-child::after {
content: "\00a0→";
font-weight: 400;
}
main .markdown p > a:only-child:hover {
text-decoration-color: currentColor;
}
main strong { main strong {
font-weight: 600; font-weight: 600;
} }
@@ -359,69 +391,14 @@ main ol:not(.not-prose):where(:not(.not-prose *)) li {
list-style-type: decimal; list-style-type: decimal;
} }
/* Buttons gemeinsame Basis */ /* .btn-*-Klassen entfernt (2026-07-12): Buttons laufen jetzt komplett über das
.btn-primary, Button-Atom (src/lib/ui/Button.svelte). Die --color-btn-* Tokens bleiben,
.btn-secondary, weil das Atom `bg-btn-bg`/`text-btn-txt`/`bg-btn-hover-bg` nutzt. */
.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;
}
/* Buttons Wald (Primary) */
.btn-primary {
@apply shadow-sm bg-btn-bg text-btn-txt;
}
.btn-primary:hover {
@apply bg-btn-hover-bg;
}
/* Buttons Stein outline (Secondary) */
.btn-secondary {
@apply border border-stein-300 bg-white text-stein-700 shadow-sm;
}
.btn-secondary:hover {
@apply bg-stein-100 border-stein-400;
}
/* Buttons Himmel (Tertiary) */
.btn-tertiary {
@apply shadow-sm bg-himmel-700 text-white;
}
.btn-tertiary:hover {
@apply bg-himmel-800;
}
/* Buttons Stein-Border klein (Outline) */
.btn-outline {
@apply border border-stein-300 text-stein-700;
}
.btn-outline:hover {
@apply bg-stein-100;
}
/* Buttons Gelb (Signal / Aufmerksamkeit, z. B. Termin melden) */
.btn-warning {
@apply border border-gelb-500 bg-gelb-300 text-gelb-900 shadow-sm;
}
.btn-warning:hover {
@apply bg-gelb-400;
}
/* Größen-Modifier — nach den btn-* definiert damit override greift */
.btn-sm {
@apply px-2 py-1 text-xs gap-1;
}
/* Filter-Chip — Basis für alle Filter-Chips (Kalender, Adressbuch, …). /* Filter-Chip — Basis für alle Filter-Chips (Kalender, Adressbuch, …).
Aktiv/inaktiv-Farben werden kontextuell per Klasse gesetzt. */ Aktiv/inaktiv-Farben werden kontextuell per Klasse gesetzt. */
.chip { .chip {
@apply inline-flex cursor-pointer items-center whitespace-nowrap rounded-xs border px-2 py-0.5 text-[11px] font-medium transition-colors; @apply inline-flex cursor-pointer items-center whitespace-nowrap rounded-full border px-2 py-0.5 text-[11px] font-medium transition-colors;
} }
/* Dekorativer Hintergrund mit zwei verschwommenen Verlaufs-Blobs (Wald → Erde). /* Dekorativer Hintergrund mit zwei verschwommenen Verlaufs-Blobs (Wald → Erde).
@@ -654,11 +631,12 @@ main pre code,
} }
} }
/* Zitate: Aleo (Design System nur für Zitate) */ /* Zitate: Lora kursiv (Design System nur für Zitate) */
blockquote[data-block-type="quote"], blockquote[data-block-type="quote"],
blockquote[data-block-type="quote"] p, blockquote[data-block-type="quote"] p,
blockquote[data-block-type="quote"] cite { blockquote[data-block-type="quote"] cite {
font-family: var(--font-secondary); font-family: var(--font-secondary);
font-style: italic;
} }
/* Footer */ /* Footer */
+6
View File
@@ -3,6 +3,12 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#236335" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Windwiderstand" />
<link rel="preconnect" href="https://cms.pm86.de"> <link rel="preconnect" href="https://cms.pm86.de">
<link rel="preconnect" href="https://api.iconify.design"> <link rel="preconnect" href="https://api.iconify.design">
<link rel="preconnect" href="https://analytics.pm86.de"> <link rel="preconnect" href="https://analytics.pm86.de">
+18 -1
View File
@@ -56,6 +56,8 @@ export type BlockLayout = {
| "7" | "8" | "9" | "10" | "11" | "12"; | "7" | "8" | "9" | "10" | "11" | "12";
/** Abstand nach unten (rem): 0, 0.5, 1, 1.5, 2. */ /** Abstand nach unten (rem): 0, 0.5, 1, 1.5, 2. */
spaceBottom?: 0 | 0.5 | 1 | 1.5 | 2; spaceBottom?: 0 | 0.5 | 1 | 1.5 | 2;
/** Abstand nach oben (rem): 0, 0.5, 1, 1.5, 2, 3, 4. */
spaceTop?: 0 | 0.5 | 1 | 1.5 | 2 | 3 | 4;
/** true = Block fullwidth aus dem Grid ausbrechen (viewport-breit). */ /** true = Block fullwidth aus dem Grid ausbrechen (viewport-breit). */
breakout?: boolean; breakout?: boolean;
}; };
@@ -127,6 +129,16 @@ const SPACE_BOTTOM: Record<number, string> = {
2: "mb-8", // 2rem 2: "mb-8", // 2rem
}; };
const SPACE_TOP: Record<number, string> = {
0: "mt-0",
0.5: "mt-2", // 0.5rem
1: "mt-4", // 1rem
1.5: "mt-6", // 1.5rem
2: "mt-8", // 2rem
3: "mt-12", // 3rem
4: "mt-16", // 4rem
};
/** /**
* Gibt Tailwind-Klassen für das Block-Layout zurück (12-Spalten-Grid + Abstand). * Gibt Tailwind-Klassen für das Block-Layout zurück (12-Spalten-Grid + Abstand).
* Parent muss grid grid-cols-12 haben. * Parent muss grid grid-cols-12 haben.
@@ -148,8 +160,12 @@ export function getBlockLayoutClasses(
: Number(layout.spaceBottom); : Number(layout.spaceBottom);
const spaceClass = SPACE_BOTTOM[sb as keyof typeof SPACE_BOTTOM] ?? "mb-4"; const spaceClass = SPACE_BOTTOM[sb as keyof typeof SPACE_BOTTOM] ?? "mb-4";
const st =
typeof layout.spaceTop === "number" ? layout.spaceTop : Number(layout.spaceTop);
const spaceTopClass = SPACE_TOP[st as keyof typeof SPACE_TOP] ?? "";
if (breakout) { if (breakout) {
return "w-full"; return [spaceTopClass, "w-full"].filter(Boolean).join(" ");
} }
const mobile = String(layout.mobile ?? "12"); const mobile = String(layout.mobile ?? "12");
@@ -164,6 +180,7 @@ export function getBlockLayoutClasses(
desktop ? (LG_COL_SPAN[desktop] ?? "") : "", desktop ? (LG_COL_SPAN[desktop] ?? "") : "",
tabletStart ? (MD_COL_START[tabletStart] ?? "") : "", tabletStart ? (MD_COL_START[tabletStart] ?? "") : "",
desktopStart ? (LG_COL_START[desktopStart] ?? "") : "", desktopStart ? (LG_COL_START[desktopStart] ?? "") : "",
spaceTopClass,
spaceClass, spaceClass,
]; ];
return parts.filter(Boolean).join(" "); return parts.filter(Boolean).join(" ");
+107 -3
View File
@@ -45,6 +45,12 @@ export interface HeadlineBlockData {
_slug?: string; _slug?: string;
tag?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; tag?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
text?: string; text?: string;
/** SectionHeader: Label über dem Titel (uppercase). Gesetzt → Headline rendert als Sektionskopf. */
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 };
align?: "left" | "center" | "right"; align?: "left" | "center" | "right";
layout?: BlockLayout; layout?: BlockLayout;
} }
@@ -121,6 +127,12 @@ export interface ImageGalleryBlockData {
description?: string; description?: string;
/** Optional: oberhalb des Galerie-Modals als Header. */ /** Optional: oberhalb des Galerie-Modals als Header. */
title?: string; 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. */ /** "standard" = Carousel (Default), "grid" = Thumbnail-Grid mit Modal. */
variant?: "standard" | "grid"; variant?: "standard" | "grid";
images?: ImageGalleryImage[]; images?: ImageGalleryImage[];
@@ -144,6 +156,12 @@ export interface FilesBlockData {
_type?: "files"; _type?: "files";
_slug?: string; _slug?: string;
headline?: 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; description?: string;
items?: Array<string | FilesBlockItem>; items?: Array<string | FilesBlockItem>;
forceDownload?: boolean; forceDownload?: boolean;
@@ -174,6 +192,12 @@ export interface YoutubeVideoGalleryBlockData {
_slug?: string; _slug?: string;
title?: string; title?: string;
subtitle?: 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; description?: string;
variant?: "carousel" | "grid"; variant?: "carousel" | "grid";
videos?: YoutubeVideoBlockData[]; videos?: YoutubeVideoBlockData[];
@@ -226,6 +250,12 @@ export interface PostOverviewBlockData {
_type?: "post_overview"; _type?: "post_overview";
_slug?: string; _slug?: string;
headline?: 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). */ /** Einleitungstext (Markdown). */
text?: string; text?: string;
/** true = alle Posts laden; false = posts (Slugs/Objekte) aus dem Block nutzen. */ /** true = alle Posts laden; false = posts (Slugs/Objekte) aus dem Block nutzen. */
@@ -238,8 +268,8 @@ export interface PostOverviewBlockData {
excludeTag?: string[]; excludeTag?: string[];
/** Max. Anzahl Einträge. */ /** Max. Anzahl Einträge. */
numberItems?: number; numberItems?: number;
/** "list" | "cards" | "carousel". */ /** "list" | "cards" | "carousel" | "featured" (1. Post groß links, Rest kompakt rechts). */
design?: "list" | "cards" | "carousel"; design?: "list" | "cards" | "carousel" | "featured";
layout?: BlockLayout; layout?: BlockLayout;
/** Nach resolvePostOverviewBlocks: geladene/gefilterte Posts (PostEntry[]). */ /** Nach resolvePostOverviewBlocks: geladene/gefilterte Posts (PostEntry[]). */
postsResolved?: unknown[]; postsResolved?: unknown[];
@@ -286,6 +316,12 @@ export interface SearchableTextBlockData {
_slug?: string; _slug?: string;
/** Titel der Suchkomponente. */ /** Titel der Suchkomponente. */
title?: string; 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). */ /** Einleitung (Markdown). */
description?: string; description?: string;
/** Slugs oder aufgelöste text_fragment-Einträge. */ /** Slugs oder aufgelöste text_fragment-Einträge. */
@@ -326,9 +362,15 @@ export interface OrganisationsBlockData {
_type?: "organisations"; _type?: "organisations";
_slug?: string; _slug?: string;
headline?: 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; description?: string;
/** default = Karten-Grid; compact = engere Karten, Logo links; carousel = horizontaler Slider (3 Desktop / 2 Mobile) */ /** default = Karten-Grid; compact = engere Karten, Logo links; carousel = horizontaler Slider (3 Desktop / 2 Mobile) */
presentation?: "default" | "compact" | "carousel"; presentation?: "default" | "compact" | "carousel" | "grid";
addOrganisationTitle?: string; addOrganisationTitle?: string;
addOrganisationMarkdown?: string; addOrganisationMarkdown?: string;
/** Referenz auf link-Collection (aufgelöst: url, linkName, newTab) */ /** Referenz auf link-Collection (aufgelöst: url, linkName, newTab) */
@@ -341,6 +383,12 @@ export interface OrganisationsMapBlockData {
_type?: "organisations_map"; _type?: "organisations_map";
_slug?: string; _slug?: string;
headline?: 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)[]; organisations?: (string | OrganisationEntry)[];
layout?: BlockLayout; layout?: BlockLayout;
} }
@@ -459,6 +507,12 @@ export type CalendarBlockData = Omit<
_type?: "calendar"; _type?: "calendar";
/** Optionaler Titel über dem Kalender. */ /** Optionaler Titel über dem Kalender. */
title?: string; 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 };
/** Slugs oder aufgelöste calendar_item-Einträge. */ /** Slugs oder aufgelöste calendar_item-Einträge. */
items?: (string | CalendarItemData)[]; items?: (string | CalendarItemData)[];
layout?: BlockLayout; layout?: BlockLayout;
@@ -524,5 +578,55 @@ export interface StellingnahmeGeneratorBlockData {
deadline?: string; deadline?: string;
/** E-Mail der Planungsstelle — aktiviert Mail-Button. */ /** E-Mail der Planungsstelle — aktiviert Mail-Button. */
recipientEmail?: string; recipientEmail?: string;
/** Vom Resolver befüllt: zentrales KI-Regelwerk aus stellungnahme_config (Singleton). */
aiRules?: string;
layout?: BlockLayout;
}
/** Einzelne Kachel (info_card): Icon/Bild + Titel + Beschreibung + optionaler Link. */
export interface InfoCardData {
_type?: "info_card";
_slug?: string;
/** Iconify-String ("mdi:email") oder Bild-URL — Komponente erkennt automatisch. */
icon?: string;
title?: string;
description?: string;
linkLabel?: string;
linkHref?: string;
external?: boolean;
/** Farbton der Kachel: default (weiß) oder green (grün getönt). */
tone?: "default" | "green";
}
/** Sektions-Grid (_type: "section_grid"): SectionHeader + info_card-Kacheln. */
export interface SectionGridBlockData {
_type?: "section_grid";
_slug?: string;
headline?: string;
subtitle?: string;
/** SectionHeader: Label über dem Titel (uppercase). */
kicker?: string;
/** SectionHeader: Index vor dem Kicker, z.B. "01". */
number?: string;
/** SectionHeader: CTA-Link (aufgelöst: url, linkName). */
action?: string | { url?: string; linkName?: string; newTab?: boolean };
columns?: "2" | "3" | "4";
/** Slugs oder aufgelöste info_card-Einträge. */
cards?: (string | InfoCardData)[];
layout?: BlockLayout;
}
/** Zweispaltiger Formular-Block (_type: "contact_form"): Form links, Aside rechts. */
export interface ContactFormBlockData {
_type?: "contact_form";
_slug?: string;
headline?: string;
kicker?: string;
number?: string;
action?: string | { url?: string; linkName?: string; newTab?: boolean };
form?: "form-contact" | "form-mitmachen" | "form-newsletter" | "form-newsletter-inline";
asideTitle?: string;
asideText?: string;
asideCards?: (string | InfoCardData)[];
layout?: BlockLayout; layout?: BlockLayout;
} }
+25 -1
View File
@@ -6,11 +6,13 @@ import {
getTextFragmentBySlug, getTextFragmentBySlug,
getTextFragmentsByTag, getTextFragmentsByTag,
getEntryBySlug, getEntryBySlug,
getStellungnahmeConfig,
getCalendarBySlug, getCalendarBySlug,
getCalendarItemBySlug, getCalendarItemBySlug,
getContacts, getContacts,
getCalendarItems, getCalendarItems,
} from "./cms"; } from "./cms";
import { STELLUNGNAHME_CONFIG_SLUG } from "./constants";
import type { RowContentLayout } from "./block-types"; import type { RowContentLayout } from "./block-types";
import type { CalendarItemData } from "./block-types"; import type { CalendarItemData } from "./block-types";
import { ensureTransformedImage, extractCmsImageField, type CmsImageField } from "./rusty-image"; import { ensureTransformedImage, extractCmsImageField, type CmsImageField } from "./rusty-image";
@@ -270,6 +272,8 @@ export function buildPostSearchIndex(posts: PostEntry[]): PostSearchIndexEntry[]
}); });
} }
export type PostSortOrder = "newest" | "oldest";
export type LoadPostsResult = { export type LoadPostsResult = {
posts: PostEntry[]; posts: PostEntry[];
tags: Awaited<ReturnType<typeof getTags>>; tags: Awaited<ReturnType<typeof getTags>>;
@@ -277,6 +281,7 @@ export type LoadPostsResult = {
currentPage: number; currentPage: number;
totalPages: number; totalPages: number;
totalPosts: number; totalPosts: number;
sort: PostSortOrder;
upcomingEvents: PostEntry[]; upcomingEvents: PostEntry[];
searchIndex: PostSearchIndexEntry[]; searchIndex: PostSearchIndexEntry[];
tagName: string | null; tagName: string | null;
@@ -287,9 +292,11 @@ export type LoadPostsResult = {
export async function loadPostsList(opts: { export async function loadPostsList(opts: {
tagSlug?: string | null; tagSlug?: string | null;
page?: number; page?: number;
sort?: PostSortOrder;
}): Promise<LoadPostsResult> { }): Promise<LoadPostsResult> {
const tagSlug = opts.tagSlug ?? null; const tagSlug = opts.tagSlug ?? null;
const requestedPage = Math.max(1, opts.page ?? 1); const requestedPage = Math.max(1, opts.page ?? 1);
const sort: PostSortOrder = opts.sort === "oldest" ? "oldest" : "newest";
const perPage = getPostsPerPage(); const perPage = getPostsPerPage();
let posts: PostEntry[] = []; let posts: PostEntry[] = [];
@@ -351,7 +358,10 @@ export async function loadPostsList(opts: {
cmsError = e instanceof Error ? e.message : String(e); cmsError = e instanceof Error ? e.message : String(e);
} }
const filtered = filterPostsByTag(posts, tagSlug); const filteredByTag = filterPostsByTag(posts, tagSlug);
// posts sind bereits neueste-zuerst sortiert; „oldest" = umgekehrte Reihenfolge.
const filtered =
sort === "oldest" ? [...filteredByTag].reverse() : filteredByTag;
const totalPages = getTotalPages(filtered.length, perPage); const totalPages = getTotalPages(filtered.length, perPage);
const pageNum = Math.min(requestedPage, totalPages); const pageNum = Math.min(requestedPage, totalPages);
const pagePosts = paginate(filtered, pageNum, perPage); const pagePosts = paginate(filtered, pageNum, perPage);
@@ -370,6 +380,7 @@ export async function loadPostsList(opts: {
currentPage: pageNum, currentPage: pageNum,
totalPages, totalPages,
totalPosts: filtered.length, totalPosts: filtered.length,
sort,
upcomingEvents, upcomingEvents,
searchIndex, searchIndex,
tagName, tagName,
@@ -771,6 +782,7 @@ function isStellingnahmeGeneratorBlock(item: unknown): boolean {
/** Löst stellungnahme_generator-Blöcke auf: /** Löst stellungnahme_generator-Blöcke auf:
* - windArea vollständiges wind_area-Objekt mit aufgelösten stellungnahme_kriterien * - windArea vollständiges wind_area-Objekt mit aufgelösten stellungnahme_kriterien
* - allgemeineArgumenteTag allgemeineArgumente[] via Tag-Filter * - allgemeineArgumenteTag allgemeineArgumente[] via Tag-Filter
* - aiRules zentrale stellungnahme_config (Singleton), einmal geladen, an alle Blöcke gehängt
*/ */
export async function resolveStellingnahmeGeneratorBlocks( export async function resolveStellingnahmeGeneratorBlocks(
layout: RowContentLayout | null | undefined, layout: RowContentLayout | null | undefined,
@@ -781,12 +793,24 @@ export async function resolveStellingnahmeGeneratorBlocks(
layout.row2Content ?? [], layout.row2Content ?? [],
layout.row3Content ?? [], layout.row3Content ?? [],
]; ];
// Zentrales Regelwerk nur laden, wenn überhaupt ein Generator-Block vorhanden ist.
const hasGenerator = rows.some(
(content) => Array.isArray(content) && content.some(isStellingnahmeGeneratorBlock),
);
const aiRules = hasGenerator
? (await getStellungnahmeConfig(STELLUNGNAHME_CONFIG_SLUG, { locale: "de" }))?.aiRules
: undefined;
for (const content of rows) { for (const content of rows) {
if (!Array.isArray(content)) continue; if (!Array.isArray(content)) continue;
for (const item of content) { for (const item of content) {
if (!isStellingnahmeGeneratorBlock(item)) continue; if (!isStellingnahmeGeneratorBlock(item)) continue;
const block = item as Record<string, unknown>; const block = item as Record<string, unknown>;
// Zentrales Regelwerk anhängen (Fallback auf Hardcode-Default in der Komponente)
if (aiRules) block.aiRules = aiRules;
// Resolve windArea // Resolve windArea
const windAreaRef = block.windArea; const windAreaRef = block.windArea;
const windAreaSlug = const windAreaSlug =
+21
View File
@@ -947,6 +947,27 @@ export async function getTranslationBundleBySlug(
}); });
} }
/** Zentrale Stellungnahme-Generator-Konfiguration (Singleton). */
export type StellungnahmeConfigEntry = {
aiRules?: string;
};
/**
* Lädt das zentrale Regelwerk für die Stellungnahme-Generatoren
* (GET /api/content/stellungnahme_config/:slug). Einmal definiert, von allen
* 40 Generator-Blöcken genutzt statt pro Block dupliziert.
*/
export async function getStellungnahmeConfig(
slug: string,
options?: { locale?: string },
): Promise<StellungnahmeConfigEntry | null> {
return getEntryBySlug<StellungnahmeConfigEntry>(
"stellungnahme_config",
slug,
{ locale: options?.locale },
);
}
/** /**
* Normalised keystring map for `event.locals.translations`. Cached on the * Normalised keystring map for `event.locals.translations`. Cached on the
* raw `strings` object identity so the per-request `Object.entries` loop runs * raw `strings` object identity so the per-request `Object.entries` loop runs
@@ -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>
+24
View File
@@ -0,0 +1,24 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import ArrowLink from './ArrowLink.svelte';
const meta = {
title: 'Molecules/ArrowLink',
component: ArrowLink,
tags: ['autodocs'],
parameters: { layout: 'centered' },
args: { href: '#', label: 'Mehr erfahren' },
argTypes: {
size: { control: 'select', options: ['sm', 'md'] },
tone: { control: 'select', options: ['himmel', 'wald', 'stein', 'white'] },
external: { control: 'boolean' },
},
} satisfies Meta<typeof ArrowLink>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Medium: Story = { args: { size: 'md', label: 'Zum Adressbuch' } };
export const Klein: Story = { args: { size: 'sm', label: 'cloud.windwiderstand.de' } };
export const Extern: Story = { args: { size: 'sm', label: 'Beitreten', external: true } };
export const Wald: Story = { args: { tone: 'wald', label: 'Alle Termine' } };
export const Stein: Story = { args: { tone: 'stein', size: 'sm', label: 'Alle Beiträge' } };
+48
View File
@@ -0,0 +1,48 @@
<script lang="ts">
/**
* ArrowLink — Text-Link mit Pfeil, himmel-Farbe. Die eine Quelle für die
* „… →"-Links (SectionHeader-CTA, InfoCard, Sektions-Aktionen).
* `size`: sm (kompakt, z.B. in Cards) · md (Sektionsköpfe).
*/
type Size = "sm" | "md";
type Tone = "himmel" | "wald" | "stein" | "white";
let {
href,
label,
external = false,
size = "md",
tone = "himmel",
}: {
href: string;
label: string;
external?: boolean;
size?: Size;
tone?: Tone;
} = $props();
const textClass: Record<Size, string> = {
sm: "text-[13px]",
md: "text-[15px]",
};
const arrowClass: Record<Size, string> = {
sm: "size-3.5",
md: "size-4",
};
const toneClass: Record<Tone, string> = {
himmel: "text-himmel-500 hover:text-himmel-600",
wald: "text-wald-700 hover:text-wald-800",
stein: "text-stein-600 hover:text-stein-900",
white: "text-white hover:text-white/80",
};
</script>
<a
{href}
target={external ? "_blank" : undefined}
rel={external ? "noopener noreferrer" : undefined}
class="inline-flex items-center gap-1.5 font-semibold no-underline {toneClass[tone]} {textClass[size]}"
>
{label}
<svg class={arrowClass[size]} 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>
+33
View File
@@ -0,0 +1,33 @@
<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'],
argTypes: {
color: {
control: 'select',
options: ['green', 'blue', 'amber', 'orange', 'purple', 'teal', 'slate', 'fire', 'white', undefined],
},
variant: { control: 'select', options: ['subtle', 'solid'] },
uppercase: { control: 'boolean' },
},
});
</script>
<!-- Die eine Badge. `color` deckt alle CMS-badge.color-Werte ab. -->
<Story name="Default">Neu</Story>
<Story name="Green" args={{ color: 'green' }}>Gründer</Story>
<Story name="Blue" args={{ color: 'blue' }}>Mitglied</Story>
<Story name="Amber" args={{ color: 'amber' }}>Anfrage läuft</Story>
<Story name="Orange" args={{ color: 'orange' }}>Südwestthüringen</Story>
<Story name="Purple" args={{ color: 'purple' }}>Ostthüringen</Story>
<Story name="Teal" args={{ color: 'teal' }}>Mittelthüringen</Story>
<Story name="Slate" args={{ color: 'slate' }}>Nordthüringen</Story>
<!-- Solid (gefüllt, weißer Text) — Kategorie/Status-Pills. -->
<Story name="Solid Green" args={{ variant: 'solid', color: 'green', uppercase: true }}>Neu</Story>
<Story name="Solid Slate" args={{ variant: 'solid', color: 'slate', uppercase: true }}>Vergangen</Story>
<Story name="Solid Fire" args={{ variant: 'solid', color: 'fire', uppercase: true }}>Live</Story>
+48 -7
View File
@@ -1,22 +1,63 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
type Variant = "subtle" | "solid";
let { let {
children, children,
color, color,
variant = "subtle",
uppercase = false,
class: className = "", class: className = "",
}: { children?: Snippet; color?: string; class?: string } = $props(); }: {
children?: Snippet;
color?: string;
/** subtle = getönt mit Border (Default) · solid = gefüllt, weißer Text. */
variant?: Variant;
uppercase?: boolean;
class?: string;
} = $props();
function colorClasses(c?: string): string { // Die einzige Badge-Farbquelle. Deckt alle CMS-badge.color-Werte ab
// (green/blue/amber/orange/purple/teal/slate) + Fallback. Marken-Töne
// (wald/himmel) wo möglich, sonst Tailwind-Palette. KEINE Eigenimplementierungen
// woanders — immer <Badge>.
function subtleClasses(c?: string): string {
switch (c) { switch (c) {
case "green": return "bg-wald-50 text-wald-700 border border-wald-200"; case "green": return "bg-wald-50 text-wald-700 border-wald-200";
case "blue": return "bg-himmel-50 text-himmel-700 border border-himmel-200"; case "blue": return "bg-himmel-50 text-himmel-700 border-himmel-200";
case "amber": return "bg-erde-50 text-erde-700 border border-erde-200"; case "amber": return "bg-amber-50 text-amber-700 border-amber-200";
default: return "bg-stein-100 text-stein-600 border border-stein-200"; case "orange": return "bg-orange-50 text-orange-700 border-orange-200";
case "purple": return "bg-purple-50 text-purple-700 border-purple-200";
case "teal": return "bg-teal-50 text-teal-700 border-teal-200";
case "slate": return "bg-slate-100 text-slate-700 border-slate-300";
default: return "bg-stein-100 text-stein-600 border-stein-200";
} }
} }
// Gefüllte Variante (weißer Text) — für Kategorie-/Status-Pills auf Bildern etc.
function solidClasses(c?: string): string {
switch (c) {
case "green": return "bg-wald-600 text-white border-transparent";
case "blue": return "bg-himmel-700 text-white border-transparent";
case "amber": return "bg-amber-500 text-white border-transparent";
case "orange": return "bg-orange-500 text-white border-transparent";
case "purple": return "bg-purple-600 text-white border-transparent";
case "teal": return "bg-teal-600 text-white border-transparent";
case "fire": return "bg-fire-700 text-white border-transparent";
case "white": return "bg-white/90 text-stein-800 border-transparent";
case "slate":
default: return "bg-stein-800 text-white border-transparent";
}
}
const colorClasses = $derived(
variant === "solid" ? solidClasses(color) : subtleClasses(color),
);
</script> </script>
<span class="shrink-0 rounded-full px-1.5 py-0 text-[0.65rem] font-medium {colorClasses(color)} {className}"> <span
class="shrink-0 whitespace-nowrap rounded-full border px-1.5 py-0 text-[0.65rem] font-medium {uppercase
? 'uppercase tracking-wide'
: ''} {colorClasses} {className}"
>
{@render children?.()} {@render children?.()}
</span> </span>
+8
View File
@@ -23,6 +23,8 @@
import WindkarteBlock from "./blocks/WindkarteBlock.svelte"; import WindkarteBlock from "./blocks/WindkarteBlock.svelte";
import AdressbuchBlock from "./blocks/AdressbuchBlock.svelte"; import AdressbuchBlock from "./blocks/AdressbuchBlock.svelte";
import OrganisationsMapBlock from "./blocks/OrganisationsMapBlock.svelte"; import OrganisationsMapBlock from "./blocks/OrganisationsMapBlock.svelte";
import SectionGridBlock from "./blocks/SectionGridBlock.svelte";
import ContactFormBlock from "./blocks/ContactFormBlock.svelte";
import type { Translations } from "$lib/translations"; import type { Translations } from "$lib/translations";
import type { import type {
ResolvedBlock, ResolvedBlock,
@@ -51,6 +53,8 @@
WindMapBlockData, WindMapBlockData,
StellingnahmeGeneratorBlockData, StellingnahmeGeneratorBlockData,
OrganisationsMapBlockData, OrganisationsMapBlockData,
SectionGridBlockData,
ContactFormBlockData,
} from "$lib/block-types"; } from "$lib/block-types";
let { let {
@@ -117,6 +121,10 @@
<AdressbuchBlock block={block as AdressbuchBlockData} /> <AdressbuchBlock block={block as AdressbuchBlockData} />
{:else if type === "organisations_map"} {:else if type === "organisations_map"}
<OrganisationsMapBlock block={block as OrganisationsMapBlockData} /> <OrganisationsMapBlock block={block as OrganisationsMapBlockData} />
{:else if type === "section_grid"}
<SectionGridBlock block={block as SectionGridBlockData} />
{:else if type === "contact_form"}
<ContactFormBlock block={block as ContactFormBlockData} />
{:else if type === "stellungnahme_generator"} {:else if type === "stellungnahme_generator"}
{#await import("./blocks/StellingnahmeGeneratorBlock.svelte") then m} {#await import("./blocks/StellingnahmeGeneratorBlock.svelte") then m}
{@const Comp = m.default} {@const Comp = m.default}
+284 -62
View File
@@ -5,8 +5,23 @@
import EventBadges from "./EventBadges.svelte"; import EventBadges from "./EventBadges.svelte";
import Tag from "./Tag.svelte"; import Tag from "./Tag.svelte";
import Pagination from "./Pagination.svelte"; import Pagination from "./Pagination.svelte";
import Card from "./Card.svelte";
import Badge from "./Badge.svelte";
import PostMeta from "./PostMeta.svelte";
import RustyImage from "./RustyImage.svelte";
import SearchField from "$lib/ui/SearchField.svelte";
import NewsletterInlineFormComponent from "./internal/NewsletterInlineFormComponent.svelte";
import type { PostEntry } from "$lib/cms"; import type { PostEntry } from "$lib/cms";
import { postHref, getPostEventInfo } from "$lib/blog-utils"; import { postHref, getPostEventInfo } from "$lib/blog-utils";
import type { PostSortOrder } from "$lib/blog-utils";
import { marked } from "$lib/markdown-safe";
import { goto } from "$app/navigation";
type PostWithImage = PostEntry & {
_resolvedImageUrl?: string;
_rawImageUrl?: string;
_imageFocal?: { x: number; y: number } | null;
};
import { t as tStatic, T } from "$lib/translations"; import { t as tStatic, T } from "$lib/translations";
import type { Translations } from "$lib/translations"; import type { Translations } from "$lib/translations";
import { import {
@@ -45,6 +60,7 @@
translations = {}, translations = {},
upcomingEvents = [], upcomingEvents = [],
searchIndex = null, searchIndex = null,
sort = "newest",
}: { }: {
posts: PostEntry[]; posts: PostEntry[];
tags: TagEntry[]; tags: TagEntry[];
@@ -56,10 +72,79 @@
translations?: Translations | null; translations?: Translations | null;
upcomingEvents?: PostEntry[]; upcomingEvents?: PostEntry[];
searchIndex?: SearchIndexEntry[] | null; searchIndex?: SearchIndexEntry[] | null;
sort?: PostSortOrder;
} = $props(); } = $props();
// Sortierung umschalten: Query-Param `sort`, immer zurück auf Seite 1
// (bei umgekehrter Reihenfolge stimmen alte Seitenzahlen nicht mehr).
function setSort(order: PostSortOrder) {
if (order === sort) return;
const base = activeTag
? `${basePath}/tag/${encodeURIComponent(activeTag)}/`
: `${basePath}/`;
goto(order === "oldest" ? `${base}?sort=oldest` : base);
}
let query = $state(""); let query = $state("");
let year = $state<string>(""); let year = $state<string>("");
// Nur Tags anzeigen, die auch in mindestens einem Beitrag vorkommen
// (searchIndex = alle listbaren Posts, tags als Namen). Aktiver Tag bleibt
// immer sichtbar. Ohne searchIndex nichts filtern.
const usedTagNames = $derived(
new Set((searchIndex ?? []).flatMap((e) => e.tags)),
);
const isTagUsed = (t: TagEntry) =>
!searchIndex ||
usedTagNames.has(t.name) ||
(t._slug ?? "") === activeTag;
// Org-/BI-Tags (slug `tag-org-*`, name „Org · …") in eigene Sektion trennen.
const isOrgTag = (t: TagEntry) => (t._slug ?? "").startsWith("tag-org-");
const orgTags = $derived(tags.filter((t) => isOrgTag(t) && isTagUsed(t)));
const topicTags = $derived(
tags.filter((t) => !isOrgTag(t) && isTagUsed(t)),
);
const orgLabel = (name: string) => name.replace(/^Org\s*·\s*/, "");
const activeIsOrg = $derived(
!!activeTag && orgTags.some((t) => (t._slug ?? "") === activeTag),
);
// Lange Themen-Liste einklappen (erste N + „mehr"). Der aktive Tag wird
// auch im eingeklappten Zustand immer angezeigt.
const INITIAL_TAGS = 12;
let showAllTags = $state(false);
const visibleTags = $derived.by(() => {
if (showAllTags) return topicTags;
const head = topicTags.slice(0, INITIAL_TAGS);
if (activeTag && !head.some((t) => (t._slug ?? "") === activeTag)) {
const act = topicTags.find((t) => (t._slug ?? "") === activeTag);
if (act) return [...head, act];
}
return head;
});
let showAllOrgs = $state(false);
const INITIAL_ORGS = 10;
const visibleOrgs = $derived.by(() => {
if (showAllOrgs) return orgTags;
const head = orgTags.slice(0, INITIAL_ORGS);
if (activeTag && !head.some((t) => (t._slug ?? "") === activeTag)) {
const act = orgTags.find((t) => (t._slug ?? "") === activeTag);
if (act) return [...head, act];
}
return head;
});
// Featured-Hero: neuester Beitrag groß, nur auf Seite 1 der ungefilterten
// Übersicht. Danach die restlichen Posts im Grid.
const showFeatured = $derived(
currentPage === 1 && !activeTag && sort === "newest" && posts.length > 1,
);
const featuredPost = $derived(showFeatured ? posts[0] : null);
const gridPosts = $derived(showFeatured ? posts.slice(1) : posts);
const featuredEvent = $derived(
featuredPost ? getPostEventInfo(featuredPost) : null,
);
const normalizedQuery = $derived(query.trim().toLowerCase()); const normalizedQuery = $derived(query.trim().toLowerCase());
const isFiltering = $derived(normalizedQuery.length >= 2 || year !== ""); const isFiltering = $derived(normalizedQuery.length >= 2 || year !== "");
@@ -128,30 +213,12 @@
<div class="blog-overview"> <div class="blog-overview">
{#if searchIndex} {#if searchIndex}
<div class="mb-4 flex flex-wrap items-center gap-2"> <div class="mb-4 flex flex-wrap items-center gap-2">
<label class="relative block w-full sm:max-w-sm"> <SearchField
<span class="sr-only">{t(T.blog_search_label)}</span> bind:value={query}
<span placeholder={t(T.blog_search_placeholder)}
class="pointer-events-none absolute top-1/2 left-2 -translate-y-1/2 text-stein-500" ariaLabel={t(T.blog_search_label)}
> class="w-full sm:max-w-sm"
<Icon icon="lucide:search" class="h-4 w-4" /> />
</span>
<input
type="search"
bind:value={query}
placeholder={t(T.blog_search_placeholder)}
class="w-full rounded-xs border border-stein-300 bg-white py-2 pr-9 pl-8 text-sm focus:border-stein-500 focus:outline-none"
/>
{#if query}
<button
type="button"
onclick={() => (query = "")}
class="absolute top-1/2 right-1.5 -translate-y-1/2 rounded-xs p-1 text-stein-500 hover:bg-stein-100 hover:text-stein-900"
aria-label={t(T.blog_search_clear)}
>
<Icon icon="lucide:x" class="h-4 w-4" />
</button>
{/if}
</label>
{#if availableYears.length > 0} {#if availableYears.length > 0}
<label class="relative inline-flex items-center"> <label class="relative inline-flex items-center">
<span class="sr-only">{t(T.blog_year_label)}</span> <span class="sr-only">{t(T.blog_year_label)}</span>
@@ -162,7 +229,7 @@
</span> </span>
<select <select
bind:value={year} bind:value={year}
class="appearance-none rounded-xs border border-stein-300 bg-white py-2 pr-7 pl-8 text-sm focus:border-stein-500 focus:outline-none" class="appearance-none rounded-md border border-stein-300 bg-white py-2 pr-7 pl-8 text-sm focus:border-wald-500 focus:outline-none focus:ring-2 focus:ring-wald-200"
> >
<option value="">{t(T.blog_year_all)}</option> <option value="">{t(T.blog_year_all)}</option>
{#each availableYears as y} {#each availableYears as y}
@@ -180,11 +247,9 @@
{/if} {/if}
{#if !isFiltering && upcomingEvents.length > 0} {#if !isFiltering && upcomingEvents.length > 0}
<section <Card variant="callout" class="not-prose mb-6">
class="not-prose mb-6 rounded-xs border border-stein-200 bg-stein-50 p-4"
>
<h2 <h2
class="mb-3 flex items-center gap-2 text-sm font-semibold tracking-wide text-stein-700" class="mb-1 flex items-center gap-2 text-sm font-semibold tracking-wide text-stein-700"
> >
<Icon icon="lucide:calendar-clock" class="size-7 shrink-0" /> <Icon icon="lucide:calendar-clock" class="size-7 shrink-0" />
{t(T.blog_upcoming_events)} {t(T.blog_upcoming_events)}
@@ -206,7 +271,7 @@
</li> </li>
{/each} {/each}
</ul> </ul>
</section> </Card>
{/if} {/if}
{#if isFiltering} {#if isFiltering}
@@ -233,9 +298,10 @@
class="py-2 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3" class="py-2 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
> >
{#each filteredResults as hit} {#each filteredResults as hit}
<a <Card
variant="tile"
layout="post"
href={searchResultHref(hit.slug)} href={searchResultHref(hit.slug)}
class="card-surface flex flex-col overflow-hidden text-stein-900 no-underline transition-colors hover:-translate-y-0.5 hover:shadow-md hover:border-wald-300 transition-[transform,box-shadow,border-color] duration-200"
> >
{#if hit.image} {#if hit.image}
<div <div
@@ -259,18 +325,18 @@
</p> </p>
{/if} {/if}
</div> </div>
</a> </Card>
{/each} {/each}
</div> </div>
{:else} {:else}
<div <div
class="rounded-xs border border-stein-200 bg-white p-6 text-center text-sm text-stein-600" class="rounded-lg border border-stein-200 bg-white p-6 text-center text-sm text-stein-600"
> >
{t(T.blog_no_results)} {t(T.blog_no_results)}
</div> </div>
{/if} {/if}
{:else} {:else}
{#if tags.length > 0} {#if topicTags.length > 0}
<div class="mb-2"> <div class="mb-2">
<div <div
class="flex flex-wrap gap-2" class="flex flex-wrap gap-2"
@@ -283,7 +349,7 @@
variant={activeTag ? "blue" : "green"} variant={activeTag ? "blue" : "green"}
active={!activeTag} active={!activeTag}
/> />
{#each tags as tag} {#each visibleTags as tag}
{@const slug = tag._slug ?? ""} {@const slug = tag._slug ?? ""}
<Tag <Tag
label={tag.name} label={tag.name}
@@ -296,14 +362,164 @@
: tag.color?.trim() || null} : tag.color?.trim() || null}
/> />
{/each} {/each}
{#if topicTags.length > INITIAL_TAGS}
<Tag
label={showAllTags
? "weniger"
: `+${topicTags.length - INITIAL_TAGS} weitere`}
variant="white"
icon={showAllTags ? "lucide:chevron-up" : "lucide:chevron-down"}
onclick={() => (showAllTags = !showAllTags)}
/>
{/if}
</div> </div>
</div> </div>
{/if} {/if}
<Pagination {currentPage} {totalPages} {basePath} {activeTag} /> {#if orgTags.length > 0}
<div class="mb-4 mt-5">
<div
class="mb-1.5 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-stein-500"
>
<Icon icon="lucide:users" class="size-3.5" />
Bürgerinitiativen &amp; Organisationen
</div>
<div
class="flex flex-wrap gap-2"
role="navigation"
aria-label="Nach Organisation filtern"
>
{#each visibleOrgs as tag}
{@const slug = tag._slug ?? ""}
<Tag
label={orgLabel(tag.name)}
href={tagHref(slug)}
variant={activeTag === slug ? "inactive" : "white"}
active={activeTag === slug}
icon={tag.icon?.trim() || "lucide:flag"}
/>
{/each}
{#if orgTags.length > INITIAL_ORGS && !activeIsOrg}
<Tag
label={showAllOrgs
? "weniger"
: `+${orgTags.length - INITIAL_ORGS} weitere`}
variant="white"
icon={showAllOrgs ? "lucide:chevron-up" : "lucide:chevron-down"}
onclick={() => (showAllOrgs = !showAllOrgs)}
/>
{/if}
</div>
</div>
{/if}
<div class="mt-5 mb-4 flex flex-wrap items-center gap-2">
<Pagination {currentPage} {totalPages} {basePath} {activeTag} {sort} />
<div class="grow"></div>
<div
class="inline-flex overflow-hidden rounded-full border border-stein-200 bg-white text-xs"
role="group"
aria-label="Sortierung"
>
<button
type="button"
onclick={() => setSort("newest")}
aria-pressed={sort === "newest"}
class="px-3 py-1.5 font-medium transition-colors {sort === 'newest'
? 'bg-wald-600 text-white'
: 'text-stein-600 hover:bg-stein-50'}"
>
Neueste
</button>
<button
type="button"
onclick={() => setSort("oldest")}
aria-pressed={sort === "oldest"}
class="px-3 py-1.5 font-medium transition-colors {sort === 'oldest'
? 'bg-wald-600 text-white'
: 'text-stein-600 hover:bg-stein-50'}"
>
Älteste
</button>
</div>
</div>
{#if featuredPost}
{@const fp = featuredPost as PostWithImage}
<a
href={postHref(fp)}
class="not-prose group mb-6 grid overflow-hidden rounded-lg border border-stein-200 bg-white no-underline transition-[transform,box-shadow,border-color] duration-200 hover:-translate-y-0.5 hover:border-wald-300 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-500 focus-visible:ring-offset-2 md:grid-cols-2"
>
<div class="relative aspect-video w-full overflow-hidden bg-stein-100 md:aspect-auto">
{#if fp._rawImageUrl}
<RustyImage
src={fp._rawImageUrl}
focal={fp._imageFocal ?? null}
width={720}
aspect="16/9"
alt={fp.headline ?? ""}
sizes="(min-width: 768px) 50vw, 100vw"
class="h-full w-full object-cover"
/>
{:else if fp._resolvedImageUrl}
<img
src={fp._resolvedImageUrl}
alt={fp.headline ?? ""}
class="h-full w-full object-cover"
/>
{/if}
<Badge
variant="solid"
color="green"
uppercase
class="absolute left-3 top-3 inline-flex items-center gap-1 shadow-sm"
>
<Icon icon="lucide:star" class="size-3" />
Neuester Beitrag
</Badge>
{#if featuredEvent?.eventDate}
<div class="absolute bottom-3 left-3 right-3 min-w-0">
<EventBadges
eventDate={featuredEvent.eventDate}
locationText={featuredEvent.eventLocation?.text ?? null}
/>
</div>
{/if}
</div>
<div class="flex flex-col justify-center p-5 md:p-6">
<PostMeta
date={fp.created ?? null}
tags={fp.postTag ?? []}
tagBase={`${basePath}/tag`}
/>
<h2 class="mb-2 mt-1 text-xl font-semibold leading-tight text-stein-900 md:text-2xl">
{fp.headline ?? fp.linkName ?? fp.slug ?? fp._slug}
</h2>
{#if fp.subheadline}
<div class="mb-2 text-sm text-stein-700">
{@html marked.parseInline(fp.subheadline)}
</div>
{/if}
{#if fp.excerpt}
<p class="line-clamp-3 text-sm font-light text-stein-600">
{fp.excerpt}
</p>
{/if}
<span
class="mt-3 inline-flex items-center gap-1 text-sm font-medium text-wald-700 group-hover:gap-1.5"
>
Weiterlesen
<Icon
icon="lucide:arrow-right"
class="size-4 transition-transform group-hover:translate-x-0.5"
/>
</span>
</div>
</a>
{/if}
<div class="py-2 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div class="py-2 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each posts as post} {#each gridPosts as post}
<PostCard <PostCard
{post} {post}
href={postHref(post)} href={postHref(post)}
@@ -314,36 +530,42 @@
{/each} {/each}
</div> </div>
<div class="flex flex-wrap items-center gap-2"> {#if currentPage === 1 && !activeTag}
<Pagination {currentPage} {totalPages} {basePath} {activeTag} /> <div
<div class="grow"></div> class="not-prose my-6 rounded-lg bg-stein-900 p-5 sm:p-6 [&_h3]:!text-stein-0"
<div class="bg-white rounded-xs font-light"> >
<div class="inline-flex text-xs p-2"> <NewsletterInlineFormComponent />
{#if totalCount > 0}
{t(T.blog_count, {
visible: posts.length,
total: totalCount,
current: currentPage,
totalPages,
})}
{:else}
{t(T.blog_count, {
visible: 0,
total: 0,
current: currentPage,
totalPages,
})}
{/if}
</div>
</div> </div>
{/if}
<div class="flex flex-wrap items-center gap-2">
<Pagination {currentPage} {totalPages} {basePath} {activeTag} {sort} />
<div class="grow"></div>
<Badge>
{#if totalCount > 0}
{t(T.blog_count, {
visible: posts.length,
total: totalCount,
current: currentPage,
totalPages,
})}
{:else}
{t(T.blog_count, {
visible: 0,
total: 0,
current: currentPage,
totalPages,
})}
{/if}
</Badge>
</div> </div>
<div class="mt-4 text-xs text-stein-600"> <div class="mt-5">
<a <a
href="/posts/rss.xml" href="/posts/rss.xml"
class="inline-flex items-center gap-1 hover:underline" class="inline-flex items-center gap-2 rounded-full border border-stein-200 bg-white px-3.5 py-2 text-sm font-medium text-stein-700 no-underline transition-colors hover:border-wald-300 hover:bg-stein-50 hover:text-wald-700"
> >
<Icon icon="lucide:rss" class="h-3.5 w-3.5" /> <Icon icon="lucide:rss" class="size-4 text-wald-600" />
{t(T.blog_rss_subscribe)} {t(T.blog_rss_subscribe)}
</a> </a>
</div> </div>
+24
View File
@@ -0,0 +1,24 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import Card from './Card.svelte';
const { Story } = defineMeta({
title: 'Molecules/Card',
component: Card,
tags: ['autodocs'],
});
</script>
<Story name="Tile">
<div class="p-4">
<h3 class="font-semibold">Karten-Titel</h3>
<p class="text-sm text-stein-600">Inhalt der Kachel.</p>
</div>
</Story>
<Story name="AlsLink" args={{ href: '#', variant: 'tile' }}>
<div class="p-4">
<h3 class="font-semibold">Klickbare Karte</h3>
<p class="text-sm text-stein-600">Ganze Karte ist ein Link.</p>
</div>
</Story>
+2 -2
View File
@@ -7,7 +7,7 @@
* tile + post: PostCard Shell ohne Padding, Medien bündig, Text mit p-4 im Kind. * tile + post: PostCard Shell ohne Padding, Medien bündig, Text mit p-4 im Kind.
* callout: CTA; layout wird ignoriert. * callout: CTA; layout wird ignoriert.
*/ */
const cardSurface = cva("relative min-w-0 overflow-hidden rounded-sm", { const cardSurface = cva("relative min-w-0 overflow-hidden rounded-lg", {
variants: { variants: {
variant: { variant: {
tile: "", tile: "",
@@ -44,7 +44,7 @@
variants: { variants: {
variant: { variant: {
callout: "cursor-pointer transition-colors no-underline text-inherit hover:bg-wald-100", callout: "cursor-pointer transition-colors no-underline text-inherit hover:bg-wald-100",
tile: "transition-[transform,box-shadow,border-color] duration-200 no-underline hover:-translate-y-0.5 hover:shadow-md hover:border-wald-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-500 focus-visible:ring-offset-2", tile: "card-hover-lift no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-500 focus-visible:ring-offset-2",
}, },
}, },
defaultVariants: { variant: "tile" }, defaultVariants: { variant: "tile" },
@@ -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 -18
View File
@@ -1,8 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import Icon from "@iconify/svelte"; import IconButton from "$lib/components/IconButton.svelte";
import "$lib/iconify-offline";
let { let {
count, count,
@@ -67,14 +66,7 @@
> >
<div class="flex items-center gap-1.5 sm:gap-2"> <div class="flex items-center gap-1.5 sm:gap-2">
{#if showArrows && count > 1} {#if showArrows && count > 1}
<button <IconButton icon="lucide:chevron-left" label="Vorheriger Eintrag" variant="surface" size="sm" onclick={prev} />
type="button"
onclick={prev}
aria-label="Vorheriger Eintrag"
class="shrink-0 inline-flex items-center justify-center size-7 rounded-full bg-white/90 border border-stein-200 shadow-sm hover:bg-white hover:border-wald-700 transition"
>
<Icon icon="lucide:chevron-left" class="size-4" aria-hidden="true" />
</button>
{/if} {/if}
<div class="grid flex-1 min-w-0" ontouchstart={onTouchStart} ontouchend={onTouchEnd}> <div class="grid flex-1 min-w-0" ontouchstart={onTouchStart} ontouchend={onTouchEnd}>
@@ -91,14 +83,7 @@
</div> </div>
{#if showArrows && count > 1} {#if showArrows && count > 1}
<button <IconButton icon="lucide:chevron-right" label="Nächster Eintrag" variant="surface" size="sm" onclick={next} />
type="button"
onclick={next}
aria-label="Nächster Eintrag"
class="shrink-0 inline-flex items-center justify-center size-7 rounded-full bg-white/90 border border-stein-200 shadow-sm hover:bg-white hover:border-wald-700 transition"
>
<Icon icon="lucide:chevron-right" class="size-4" aria-hidden="true" />
</button>
{/if} {/if}
</div> </div>
+29 -27
View File
@@ -3,6 +3,9 @@
import "$lib/iconify-offline"; import "$lib/iconify-offline";
import { env } from "$env/dynamic/public"; import { env } from "$env/dynamic/public";
import { useTranslate, T } from "$lib/translations"; import { useTranslate, T } from "$lib/translations";
import Button from "$lib/ui/Button.svelte";
import TextInput from "$lib/ui/TextInput.svelte";
import Badge from "$lib/components/Badge.svelte";
let { let {
pageId, pageId,
@@ -310,10 +313,7 @@
maxlength={bodyMax} maxlength={bodyMax}
></textarea> ></textarea>
<div class="mt-1 flex items-center gap-3 text-xs"> <div class="mt-1 flex items-center gap-3 text-xs">
<button <Button type="submit" variant="primary" size="sm" label={t(T.comments_save)} />
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 <button
type="button" type="button"
onclick={cancelEdit} onclick={cancelEdit}
@@ -363,10 +363,10 @@
<span class="text-stein-400 text-xs" title={fmtAbs(c.updated_at)}>{t(T.comments_edited)}</span> <span class="text-stein-400 text-xs" title={fmtAbs(c.updated_at)}>{t(T.comments_edited)}</span>
{/if} {/if}
{#if c.status === "pending"} {#if c.status === "pending"}
<span class="ml-auto inline-flex items-center gap-1 rounded-full bg-erde-100 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-erde-800"> <Badge color="amber" uppercase class="ml-auto inline-flex items-center gap-1">
<Icon icon="lucide:clock" class="size-3" /> <Icon icon="lucide:clock" class="size-3" />
{t(T.comments_pending_short)} {t(T.comments_pending_short)}
</span> </Badge>
{/if} {/if}
</div> </div>
{/snippet} {/snippet}
@@ -386,7 +386,7 @@
{/each} {/each}
</ul> </ul>
{:else if loadError} {:else if loadError}
<div class="flex items-start gap-2 rounded-xs border border-fire-200 bg-fire-50 p-3 text-sm text-fire-800"> <div class="flex items-start gap-2 rounded-lg border border-fire-200 bg-fire-50 p-3 text-sm text-fire-800">
<Icon icon="lucide:circle-alert" class="size-5 shrink-0 mt-0.5" /> <Icon icon="lucide:circle-alert" class="size-5 shrink-0 mt-0.5" />
<div class="flex-1"> <div class="flex-1">
<p class="font-medium">{t(T.comments_load_error_title)}</p> <p class="font-medium">{t(T.comments_load_error_title)}</p>
@@ -410,7 +410,7 @@
{@const replies = repliesOf(c.id)} {@const replies = repliesOf(c.id)}
<li <li
class="group flex gap-3 list-none! {c.status === 'pending' class="group flex gap-3 list-none! {c.status === 'pending'
? 'rounded-xs border border-erde-200 bg-erde-50/50 p-3' ? 'rounded-lg border border-erde-200 bg-erde-50/50 p-3'
: ''}" : ''}"
> >
{@render avatar(c.author)} {@render avatar(c.author)}
@@ -437,13 +437,12 @@
{#if replyTo === c.id} {#if replyTo === c.id}
<form onsubmit={(e) => onReply(e, c.id)} class="mt-3 space-y-2"> <form onsubmit={(e) => onReply(e, c.id)} class="mt-3 space-y-2">
<input <TextInput
type="text"
bind:value={replyAuthor} bind:value={replyAuthor}
placeholder={t(T.comments_name_placeholder)} placeholder={t(T.comments_name_placeholder)}
label=""
required required
maxlength="80" size="sm"
class="w-full p-2 border border-stein-300 rounded-xs text-sm focus:outline-none focus:ring-2 focus:ring-stein-400"
/> />
<textarea <textarea
bind:value={replyBody} bind:value={replyBody}
@@ -457,11 +456,14 @@
></textarea> ></textarea>
{#if replyError}<p class="text-fire-700 text-xs">{replyError}</p>{/if} {#if replyError}<p class="text-fire-700 text-xs">{replyError}</p>{/if}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button <Button
type="submit" type="submit"
disabled={submitting} variant="primary"
class="px-3 py-1.5 bg-stein-800 text-white rounded-xs text-sm font-medium hover:bg-stein-700 disabled:opacity-50" size="sm"
>{t(T.comments_reply_send)}</button> 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> <span class="ml-auto text-xs text-stein-400 tabular-nums">{replyBody.length} / {bodyMax}</span>
</div> </div>
</form> </form>
@@ -472,7 +474,7 @@
{#each replies as r (r.id)} {#each replies as r (r.id)}
<li <li
class="group flex gap-3 list-none! {r.status === 'pending' class="group flex gap-3 list-none! {r.status === 'pending'
? 'rounded-xs border border-erde-200 bg-erde-50/50 p-2' ? 'rounded-lg border border-erde-200 bg-erde-50/50 p-2'
: ''}" : ''}"
> >
{@render avatar(r.author, "sm")} {@render avatar(r.author, "sm")}
@@ -492,13 +494,12 @@
<form onsubmit={onSubmit} class="border-t border-stein-200 pt-5 space-y-3"> <form onsubmit={onSubmit} class="border-t border-stein-200 pt-5 space-y-3">
<h3 class="font-medium text-sm text-stein-900">{t(T.comments_form_title)}</h3> <h3 class="font-medium text-sm text-stein-900">{t(T.comments_form_title)}</h3>
<input <TextInput
type="text"
bind:value={author} bind:value={author}
placeholder={t(T.comments_name_placeholder)} placeholder={t(T.comments_name_placeholder)}
label=""
required required
maxlength="80" size="sm"
class="w-full p-2 border border-stein-300 rounded-xs text-sm focus:outline-none focus:ring-2 focus:ring-stein-400"
/> />
<textarea <textarea
bind:value={body} bind:value={body}
@@ -522,13 +523,14 @@
<p class="text-fire-700 text-xs">{submitError}</p> <p class="text-fire-700 text-xs">{submitError}</p>
{/if} {/if}
<div class="flex flex-wrap items-center gap-3 text-xs"> <div class="flex flex-wrap items-center gap-3 text-xs">
<button <Button
type="submit" type="submit"
disabled={submitting || !body.trim() || !author.trim()} variant="primary"
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" loading={submitting}
> disabled={!body.trim() || !author.trim()}
{submitting ? t(T.comments_sending) : t(T.comments_send)} label={submitting ? t(T.comments_sending) : t(T.comments_send)}
</button> class="text-sm"
/>
{#if requireApproval} {#if requireApproval}
<span class="text-stein-500 inline-flex items-center gap-1"> <span class="text-stein-500 inline-flex items-center gap-1">
<Icon icon="lucide:shield-check" class="size-3.5" /> <Icon icon="lucide:shield-check" class="size-3.5" />
+28
View File
@@ -0,0 +1,28 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import ContactCard from './ContactCard.svelte';
const meta = {
title: 'Molecules/ContactCard',
component: ContactCard,
tags: ['autodocs'],
} satisfies Meta<typeof ContactCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standard: Story = {
args: {
contact: {
name: 'Maria Müller',
role: 'Sprecherin',
email: 'maria@example.org',
phone: '01234 567890',
note: 'Erreichbar MoFr',
organisations: [{ name: 'BI Steigerwald' }],
},
},
};
export const Minimal: Story = {
args: { contact: { name: 'Hans Weber', role: 'Presse', email: 'presse@example.org' } },
};
+4 -7
View File
@@ -3,6 +3,7 @@
import "$lib/iconify-offline"; import "$lib/iconify-offline";
import type { ContactEntry } from "$lib/block-types"; import type { ContactEntry } from "$lib/block-types";
import { downloadVCard } from "$lib/vcard"; import { downloadVCard } from "$lib/vcard";
import Badge from "$lib/components/Badge.svelte";
let { contact }: { contact: ContactEntry } = $props(); let { contact }: { contact: ContactEntry } = $props();
@@ -15,7 +16,7 @@
</script> </script>
<div <div
class="not-prose card-surface relative flex h-full min-w-0 flex-col gap-2 overflow-hidden rounded-sm p-4 transition-[transform,box-shadow,border-color] duration-200 hover:-translate-y-0.5 hover:border-wald-300 hover:shadow-md" class="not-prose card-surface card-hover-lift relative flex h-full min-w-0 flex-col gap-2 overflow-hidden p-4"
> >
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
<p class="truncate pr-6 text-sm font-medium leading-tight text-stein-900">{contact.name ?? ""}</p> <p class="truncate pr-6 text-sm font-medium leading-tight text-stein-900">{contact.name ?? ""}</p>
@@ -23,14 +24,10 @@
{#if contact.role || orgNames.length > 0} {#if contact.role || orgNames.length > 0}
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
{#if contact.role} {#if contact.role}
<span class="shrink-0 rounded-full border border-stein-200 bg-stein-100 px-1.5 py-0 text-[0.65rem] font-medium text-stein-600"> <Badge>{contact.role}</Badge>
{contact.role}
</span>
{/if} {/if}
{#each orgNames as org} {#each orgNames as org}
<span class="shrink-0 rounded-full border border-wald-200 bg-wald-50 px-1.5 py-0 text-[0.65rem] font-medium text-wald-700"> <Badge color="green">{org}</Badge>
{org}
</span>
{/each} {/each}
</div> </div>
{/if} {/if}
+198
View File
@@ -0,0 +1,198 @@
<script lang="ts">
import { browser } from "$app/environment";
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
import BlockRenderer from "./BlockRenderer.svelte";
import type { Translations } from "$lib/translations";
import type { ResolvedBlock } from "$lib/block-types";
import {
selectModal,
dismissKey,
type ModalLike,
} from "$lib/modal-match";
interface ModalEntry extends ModalLike {
trigger?: "immediate" | "delay" | "scroll" | "exit_intent";
delaySeconds?: number;
scrollPercent?: number;
content?: unknown[];
dismissible?: boolean;
size?: "sm" | "md" | "lg";
}
let {
modals = [],
pathname,
translations = {},
}: {
modals?: ModalEntry[];
pathname: string;
translations?: Translations | null;
} = $props();
let active = $state<ModalEntry | null>(null);
let open = $state(false);
let dontShowAgain = $state(false);
/** Nur diese Session ausgeblendet (X/Escape/Backdrop). */
function sessionKey(m: ModalEntry): string {
return `modal-session:${m.slug ?? m._slug ?? "unknown"}:${m.version ?? ""}`;
}
function isDismissed(m: ModalEntry): boolean {
try {
if (localStorage.getItem(dismissKey(m))) return true;
if (sessionStorage.getItem(sessionKey(m))) return true;
} catch {
/* Storage gesperrt (Private Mode) → nicht als dismissed werten. */
}
return false;
}
function close(permanent: boolean) {
const m = active;
open = false;
if (!m) return;
try {
if (permanent && m.dismissible !== false) {
localStorage.setItem(dismissKey(m), "1");
} else {
sessionStorage.setItem(sessionKey(m), "1");
}
} catch {
/* Storage gesperrt → Modal kommt beim nächsten Load wieder. */
}
}
function onKeydown(e: KeyboardEvent) {
if (e.key === "Escape" && open) close(dontShowAgain);
}
const sizeClass = $derived(
active?.size === "sm"
? "max-w-md"
: active?.size === "lg"
? "max-w-3xl"
: "max-w-xl",
);
// Kandidat je Route neu wählen und Trigger scharfschalten. Cleanup läuft vor
// jedem Re-Run (Navigation) und beim Destroy — entfernt Timer/Listener.
$effect(() => {
if (!browser) return;
const path = pathname;
const list = modals ?? [];
open = false;
dontShowAgain = false;
const m = selectModal<ModalEntry>(list, path, Date.now(), isDismissed);
active = m;
if (!m) return;
const show = () => {
open = true;
};
let removeScroll: (() => void) | null = null;
let removeExit: (() => void) | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;
switch (m.trigger ?? "delay") {
case "immediate":
show();
break;
case "delay":
timer = setTimeout(show, Math.max(0, m.delaySeconds ?? 3) * 1000);
break;
case "scroll": {
const pct = Math.min(100, Math.max(1, m.scrollPercent ?? 30));
const onScroll = () => {
const h =
document.documentElement.scrollHeight - window.innerHeight;
const scrolled = h > 0 ? (window.scrollY / h) * 100 : 100;
if (scrolled >= pct) {
show();
removeScroll?.();
removeScroll = null;
}
};
window.addEventListener("scroll", onScroll, { passive: true });
removeScroll = () =>
window.removeEventListener("scroll", onScroll);
onScroll(); // Fall: Seite kürzer als Trigger → sofort
break;
}
case "exit_intent": {
// Nur Desktop (feiner Zeiger) — Touch hat kein Exit-Intent.
if (window.matchMedia("(pointer: fine)").matches) {
const onOut = (e: MouseEvent) => {
if (e.clientY <= 0) show();
};
document.addEventListener("mouseout", onOut);
removeExit = () =>
document.removeEventListener("mouseout", onOut);
}
break;
}
}
return () => {
if (timer) clearTimeout(timer);
removeScroll?.();
removeExit?.();
};
});
const blocks = $derived((active?.content ?? []) as ResolvedBlock[]);
</script>
<svelte:window on:keydown={onKeydown} />
{#if open && active}
<div
class="fixed inset-0 z-[9998] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm"
role="presentation"
onclick={() => close(dontShowAgain)}
>
<div
class="relative w-full {sizeClass} max-h-[90vh] overflow-y-auto rounded-lg bg-white p-6 shadow-2xl md:p-8"
role="dialog"
aria-modal="true"
onclick={(e) => e.stopPropagation()}
>
<button
type="button"
onclick={() => close(dontShowAgain)}
class="absolute top-3 right-3 rounded-full p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-800"
aria-label="Schließen"
>
<Icon icon="lucide:x" class="size-5" aria-hidden="true" />
</button>
<div class="content-modal-body">
{#each blocks as block (block)}
<BlockRenderer {block} {translations} />
{/each}
</div>
{#if active.dismissible !== false}
<div class="mt-6 flex items-center justify-between gap-4 border-t border-gray-200 pt-4">
<label class="flex cursor-pointer items-center gap-2 text-sm text-gray-600">
<input
type="checkbox"
class="size-4 accent-wald-700"
bind:checked={dontShowAgain}
/>
<span>Dauerhaft nicht mehr anzeigen</span>
</label>
<button
type="button"
class="rounded-md bg-wald-700 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-wald-800"
onclick={() => close(dontShowAgain)}
>
Schließen
</button>
</div>
{/if}
</div>
</div>
{/if}
+23
View File
@@ -0,0 +1,23 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import EventBadges from './EventBadges.svelte';
const meta = {
title: 'Molecules/EventBadges',
component: EventBadges,
tags: ['autodocs'],
} satisfies Meta<typeof EventBadges>;
export default meta;
type Story = StoryObj<typeof meta>;
export const MitOrt: Story = {
args: {
eventDate: '2026-08-15T18:00:00Z',
locationText: 'Rathaus Schleusingen',
locationHref: 'https://www.openstreetmap.org',
},
};
export const NurDatum: Story = {
args: { eventDate: '2026-09-05T19:00:00Z' },
};
+22
View File
@@ -0,0 +1,22 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import EventMap from './EventMap.svelte';
// Karten-Embed für Termine (iframe + Link). Tiles laden extern bei Netzzugriff.
const meta = {
title: 'Molecules/EventMap',
component: EventMap,
tags: ['autodocs'],
} satisfies Meta<typeof EventMap>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standard: Story = {
args: {
embedUrl:
'https://www.openstreetmap.org/export/embed.html?bbox=10.6,50.4,10.9,50.6&layer=mapnik',
linkUrl: 'https://www.openstreetmap.org/#map=13/50.51/10.75',
locationText: 'Rathaus Schleusingen',
label: 'Veranstaltungsort',
},
};
+50 -5
View File
@@ -8,12 +8,26 @@
id?: string; id?: string;
} }
let { footer = null, translations = {} }: { footer?: FooterEntry | null; translations?: Translations | null } = $props(); let {
footer = null,
translations = {},
logoSvgHtml = null,
logoUrl = null,
siteName = "",
}: {
footer?: FooterEntry | null;
translations?: Translations | null;
logoSvgHtml?: string | null;
logoUrl?: string | null;
siteName?: string;
} = $props();
function t(key: string) { function t(key: string) {
return tStatic(translations ?? null, key); return tStatic(translations ?? null, key);
} }
const year = new Date().getFullYear();
const hasRowContent = $derived( const hasRowContent = $derived(
!!footer && !!footer &&
((footer.row1Content?.length ?? 0) > 0 || ((footer.row1Content?.length ?? 0) > 0 ||
@@ -32,13 +46,29 @@
<div class="content-footer text-sm text-stein-200"> <div class="content-footer text-sm text-stein-200">
<ContentRows layout={footer} translations={translations} /> <ContentRows layout={footer} translations={translations} />
</div> </div>
{:else} {/if}
<p class="text-sm text-stein-0/80 text-center">
<!-- Bottom-Bar: Brand links, Copyright/Lizenz rechts (immer, unabhängig vom CMS-Content). -->
<div
class="mt-8 flex flex-col gap-4 border-t border-stein-700 pt-6 text-xs text-stein-400 md:flex-row md:items-center md:justify-between"
>
<div class="flex items-center gap-2.5 text-stein-0">
{#if logoSvgHtml}
<span
class="footer-logo inline-flex h-6 w-auto items-center [&_svg]:block [&_svg]:h-full [&_svg]:w-auto"
aria-hidden="true">{@html logoSvgHtml}</span
>
{:else if logoUrl && logoUrl.trim() !== ""}
<img src={logoUrl} alt="" class="h-6 w-auto object-contain" />
{/if}
<span class="font-bold">{siteName}</span>
</div>
<p class="md:text-right">
{t(T.footer_copyright) !== T.footer_copyright {t(T.footer_copyright) !== T.footer_copyright
? t(T.footer_copyright) ? t(T.footer_copyright)
: `© ${new Date().getFullYear()} Windwiderstand`} : `Copyright © ${year} · Inhalte frei nutzbar mit Quellenangabe „windwiderstand.de“ · CC BY 4.0`}
</p> </p>
{/if} </div>
</div> </div>
</footer> </footer>
{/if} {/if}
@@ -82,6 +112,21 @@
.content-footer :global(h3) { .content-footer :global(h3) {
color: var(--color-stein-0); color: var(--color-stein-0);
} }
/* Link-Listen-Überschriften ("Seiten", "Kontakt & Community") als kleine
Label-Kappen — überschreibt die unlayered h3-Defaults (daher !important). */
.content-footer :global(.link-list h3) {
font-size: 0.75rem !important;
font-weight: 600 !important;
line-height: 1.2 !important;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-stein-400) !important;
margin-bottom: 0.9rem;
}
/* Footer-Navigationslinks ohne Unterstrich (Markdown-Links bleiben unterstrichen). */
.content-footer :global(.link-list a) {
text-decoration: none !important;
}
.content-footer :global(.markdown), .content-footer :global(.markdown),
.content-footer :global(.prose) { .content-footer :global(.prose) {
color: var(--color-stein-200); color: var(--color-stein-200);
+37
View File
@@ -0,0 +1,37 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import Header from './Header.svelte';
// Nutzt den SvelteKit-page-Store (aktiver Link) — vom sveltekit-Framework
// gemockt. Logo als Inline-SVG-Platzhalter.
const meta = {
title: 'Organisms/Header',
component: Header,
tags: ['autodocs'],
parameters: { layout: 'fullscreen' },
} satisfies Meta<typeof Header>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standard: Story = {
args: {
logoSvgHtml: '<span style="font-weight:700;font-size:1.25rem">Windwiderstand</span>',
links: [
{ href: '/', label: 'Start' },
{
href: '/verfahren',
label: 'Verfahren',
children: [
{ href: '/verfahren/ablauf', label: 'Ablauf' },
{ href: '/verfahren/fristen', label: 'Fristen' },
],
},
{ href: '/posts', label: 'Aktuelles' },
{ href: '/mitmachen', label: 'Mitmachen' },
],
socialLinks: [
{ href: 'https://example.org', icon: 'mdi:instagram', label: 'Instagram' },
{ href: 'https://example.org', icon: 'mdi:youtube', label: 'YouTube' },
],
},
};
+6 -6
View File
@@ -145,26 +145,26 @@
{#each links as link} {#each links as link}
{@const active = isActiveTree(link, $page.url.pathname)} {@const active = isActiveTree(link, $page.url.pathname)}
{#if link.children && link.children.length > 0} {#if link.children && link.children.length > 0}
<div class="relative group"> <div class="relative group inline-flex items-center">
{#if link.href && link.href !== "#"} {#if link.href && link.href !== "#"}
<a <a
href={link.href} href={link.href}
class="text-[.7rem] py-1 inline-flex items-center gap-0.5 {active ? 'font-bold' : ''}" class="text-[.7rem] leading-none py-1 inline-flex items-center gap-0.5 {active ? 'font-bold' : ''}"
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded="false" aria-expanded="false"
> >
{link.label} {link.label}
<Icon icon="lucide:chevron-down" class="size-3" aria-hidden="true" /> <Icon icon="lucide:chevron-down" class="size-3 shrink-0" aria-hidden="true" />
</a> </a>
{:else} {:else}
<button <button
type="button" type="button"
class="text-[.7rem] py-1 inline-flex items-center gap-0.5 {active ? 'font-bold' : ''}" class="text-[.7rem] leading-none py-1 inline-flex items-center gap-0.5 {active ? 'font-bold' : ''}"
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded="false" aria-expanded="false"
> >
{link.label} {link.label}
<Icon icon="lucide:chevron-down" class="size-3" aria-hidden="true" /> <Icon icon="lucide:chevron-down" class="size-3 shrink-0" aria-hidden="true" />
</button> </button>
{/if} {/if}
<div <div
@@ -191,7 +191,7 @@
{:else} {:else}
<a <a
href={link.href} href={link.href}
class="text-[.7rem] py-1 {active ? 'font-bold ' : ''}" class="text-[.7rem] leading-none py-1 inline-flex items-center {active ? 'font-bold ' : ''}"
aria-current={active ? "page" : undefined} aria-current={active ? "page" : undefined}
> >
{link.label} {link.label}
+23
View File
@@ -0,0 +1,23 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import IconButton from './IconButton.svelte';
const meta = {
title: 'Atoms/IconButton',
component: IconButton,
tags: ['autodocs'],
parameters: { layout: 'centered' },
args: { icon: 'lucide:chevron-right', label: 'Weiter' },
argTypes: {
size: { control: 'select', options: ['sm', 'md'] },
variant: { control: 'select', options: ['surface', 'ghost'] },
disabled: { control: 'boolean' },
},
} satisfies Meta<typeof IconButton>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Surface: Story = { args: { variant: 'surface' } };
export const Ghost: Story = { args: { variant: 'ghost', icon: 'lucide:x', label: 'Schließen' } };
export const Klein: Story = { args: { variant: 'surface', size: 'sm', icon: 'lucide:chevron-left', label: 'Zurück' } };
export const Disabled: Story = { args: { variant: 'surface', disabled: true } };
+57
View File
@@ -0,0 +1,57 @@
<script lang="ts">
/**
* IconButton — runder Icon-only Button (Carousel-Pfeile, Modal-Close, …).
* Die eine Quelle für kreisförmige Icon-Buttons. `label` = aria-label (Pflicht).
* variant: surface (weiß, Border, Shadow — z.B. Slider-Pfeile) · ghost (transparent).
*/
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
import type { HTMLButtonAttributes, HTMLAnchorAttributes } from "svelte/elements";
type Size = "sm" | "md";
type Variant = "surface" | "ghost";
let {
icon,
label,
href = undefined,
onclick = undefined,
size = "md",
variant = "surface",
disabled = false,
class: className = "",
...rest
}: {
icon: string;
label: string;
href?: string;
onclick?: (e: MouseEvent) => void;
size?: Size;
variant?: Variant;
disabled?: boolean;
class?: string;
} & HTMLButtonAttributes &
HTMLAnchorAttributes = $props();
const sizeClass: Record<Size, string> = { sm: "size-7", md: "size-9" };
const iconClass: Record<Size, string> = { sm: "size-4", md: "size-5" };
const variantClass: Record<Variant, string> = {
surface:
"border border-stein-200 bg-white text-stein-700 shadow-md hover:bg-stein-50",
ghost: "text-stein-600 hover:bg-stein-100",
};
const cls = $derived(
`inline-flex shrink-0 items-center justify-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${sizeClass[size]} ${variantClass[variant]} ${className}`,
);
</script>
{#if href && !disabled}
<a {href} class={cls} aria-label={label} {...rest}>
<Icon icon={icon} class={iconClass[size]} aria-hidden="true" />
</a>
{:else}
<button type="button" {disabled} {onclick} class={cls} aria-label={label} {...rest}>
<Icon icon={icon} class={iconClass[size]} aria-hidden="true" />
</button>
{/if}
+2 -10
View File
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import Icon from "@iconify/svelte"; import IconButton from "$lib/components/IconButton.svelte";
import "$lib/iconify-offline";
let { let {
src, src,
@@ -33,12 +32,5 @@
class="max-w-full max-h-full object-contain rounded shadow-2xl cursor-default" class="max-w-full max-h-full object-contain rounded shadow-2xl cursor-default"
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
/> />
<button <IconButton icon="lucide:x" label="Schließen" variant="ghost" size="md" onclick={onclose} class="absolute top-4 right-4" />
type="button"
onclick={onclose}
class="absolute top-4 right-4 p-2 rounded-full bg-black/50 text-white hover:bg-black/70 transition-colors"
aria-label="Schließen"
>
<Icon icon="lucide:x" class="size-5" aria-hidden="true" />
</button>
</div> </div>
@@ -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 },
};
+64
View File
@@ -0,0 +1,64 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import InfoCard from './InfoCard.svelte';
const CMS_ASSET =
'https://cms.pm86.de/api/assets/pages/img_2814-ar4x2.webp?_environment=windwiderstand';
const meta = {
title: 'Molecules/InfoCard',
component: InfoCard,
tags: ['autodocs'],
parameters: { layout: 'centered' },
argTypes: {
icon: { control: 'text' },
title: { control: 'text' },
description: { control: 'text' },
linkLabel: { control: 'text' },
linkHref: { control: 'text' },
external: { control: 'boolean' },
},
} satisfies Meta<typeof InfoCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const MitIcon: Story = {
args: {
icon: 'mdi:email-outline',
title: 'E-Mail',
description: 'Für alle Fragen und Anregungen',
linkLabel: 'mail@windwiderstand.de',
linkHref: 'mailto:mail@windwiderstand.de',
},
};
export const ExternerLink: Story = {
args: {
icon: 'mdi:whatsapp',
title: 'WhatsApp-Community',
description: 'Aktuelles und Austausch vor Ort',
linkLabel: 'Beitreten',
linkHref: 'https://chat.whatsapp.com/xyz',
external: true,
},
};
// icon = Bild-URL statt Iconify-String → wird als Bild gerendert.
export const MitBild: Story = {
args: {
icon: CMS_ASSET,
title: 'Unsere Cloud',
description: 'Dokumente und Materialien zum Mitnehmen',
linkLabel: 'cloud.windwiderstand.de',
linkHref: 'https://cloud.windwiderstand.de',
external: true,
},
};
export const OhneLink: Story = {
args: {
icon: 'mdi:youtube',
title: 'YouTube-Kanal',
description: 'Aufzeichnungen und Visualisierungen',
},
};
+145
View File
@@ -0,0 +1,145 @@
<script lang="ts">
/**
* InfoCard — Icon/Bild + Titel + Beschreibung + optionaler Link.
* Wiederverwendbare Kachel für Sektions-Grids (siehe SectionGrid) UND als
* Basis für spezialisierte Karten (OrganisationCard) via Snippet-Slots.
*
* `icon` ist entweder ein Iconify-String ("mdi:email") ODER eine Bild-URL
* (https://… oder /…) — wird automatisch erkannt.
* `tone`: default (weiß) oder green (grün getönt, z.B. Aside-Boxen).
* `compact`: kleinere Maße + Icon inline neben dem Titel (spart Höhe).
*
* Erweiterungspunkte (halten die Basis konsistent, ohne Besonderheiten zu verlieren):
* - `href` → ganze Karte wird Link (Tile-Hover wie Card).
* - `media` → ersetzt das Standard-Icon-Badge (z.B. quadratisches Logo + Fallback).
* - `badge` → absolut oben-rechts overlay.
* - `footer` → am unteren Rand (z.B. Social-Chips).
*/
import type { Snippet } from "svelte";
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
import RustyImage from "$lib/components/RustyImage.svelte";
import ArrowLink from "$lib/components/ArrowLink.svelte";
type Tone = "default" | "green";
let {
icon = undefined,
title,
description = undefined,
linkLabel = undefined,
linkHref = undefined,
external = false,
tone = "default",
compact = false,
href = undefined,
target = undefined,
rel = undefined,
media = undefined,
badge = undefined,
footer = undefined,
}: {
icon?: string;
title: string;
description?: string;
linkLabel?: string;
linkHref?: string;
external?: boolean;
tone?: Tone;
compact?: boolean;
href?: string;
target?: string;
rel?: string;
media?: Snippet;
badge?: Snippet;
footer?: Snippet;
} = $props();
const isImage = $derived(!!icon && (/^https?:\/\//.test(icon) || icon.startsWith("/")));
const hasLink = $derived(!!linkLabel && !!linkHref);
const shell = $derived(
`relative flex h-full flex-col rounded-lg border ${compact ? "gap-2 p-4" : "gap-[14px] p-[22px]"} ${
tone === "green" ? "border-wald-100 bg-wald-50" : "border-stein-200 bg-white"
}`,
);
/* Klickbare Karte: Tile-Hover identisch zu Card.svelte (variant=tile), s. card-hover-lift in app.css. */
const linkCls =
"card-hover-lift no-underline text-inherit focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-500 focus-visible:ring-offset-2";
</script>
{#snippet iconBadge(sizeClass: string, iconSize: string)}
<div
class="flex shrink-0 items-center justify-center overflow-hidden rounded-full {sizeClass} {isImage
? 'border border-stein-200 bg-white'
: tone === 'green'
? 'bg-white text-wald-600'
: 'bg-wald-50 text-wald-500'}"
>
{#if isImage}
<RustyImage src={icon ?? ""} width={44} aspect="1/1" fit="contain" alt="" class="size-full object-contain p-0.5" />
{:else}
<Icon icon={icon ?? ""} class={iconSize} aria-hidden="true" />
{/if}
</div>
{/snippet}
{#snippet content()}
{#if badge}
<div class="absolute right-2 top-2 z-10">{@render badge()}</div>
{/if}
{#if compact}
<!-- Kompakt: Icon hängt links, Text-Spalte (Titel/Desc/Link) rechts bündig. -->
<div class="flex gap-2.5">
{#if media}
<div class="pt-px">{@render media()}</div>
{:else if icon}
<div class="pt-px">{@render iconBadge("size-5", "size-3")}</div>
{/if}
<div class="flex min-w-0 flex-1 flex-col gap-2">
<h3 class="text-sm! font-bold! leading-[1.3]! tracking-[-0.01em] text-stein-800">{title}</h3>
{#if description}
<p class="line-clamp-3 text-[13px] font-normal leading-[1.45] text-stein-500">{description}</p>
{/if}
{#if hasLink}
<div class="pt-0.5">
<ArrowLink href={linkHref ?? "#"} label={linkLabel ?? ""} {external} size="sm" />
</div>
{/if}
{#if footer}
<div class="pt-0.5">{@render footer()}</div>
{/if}
</div>
</div>
{:else}
{#if media}
{@render media()}
{:else if icon}
{@render iconBadge("size-11", "size-[23px]")}
{/if}
<div class="flex flex-col gap-1">
<h3 class="text-base! font-bold! leading-[1.3]! tracking-[-0.01em] text-stein-800">{title}</h3>
{#if description}
<p class="line-clamp-3 text-sm font-normal leading-[1.45] text-stein-500">{description}</p>
{/if}
</div>
{#if hasLink}
<div class="mt-auto pt-0.5">
<ArrowLink href={linkHref ?? "#"} label={linkLabel ?? ""} {external} size="sm" />
</div>
{/if}
{#if footer}
<div class="mt-auto pt-1">{@render footer()}</div>
{/if}
{/if}
{/snippet}
{#if href}
<a {href} {target} {rel} class="{shell} {linkCls}">
{@render content()}
</a>
{:else}
<div class={shell}>
{@render content()}
</div>
{/if}
@@ -0,0 +1,54 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import OrganisationCard from './OrganisationCard.svelte';
import type { OrganisationEntry } from '$lib/block-types';
const LOGO =
'https://cms.pm86.de/api/assets/pages/img_2814-ar4x2.webp?_environment=windwiderstand';
const base: OrganisationEntry = {
name: 'BI Steigerwald',
description:
'Bürgerinitiative gegen überdimensionierte Windkraftplanung im Steigerwald. Aktiv seit 2021.',
link: { url: 'https://example.org' },
};
const meta = {
title: 'Molecules/OrganisationCard',
component: OrganisationCard,
tags: ['autodocs'],
parameters: { layout: 'centered' },
argTypes: {
compact: { control: 'boolean' },
},
} satisfies Meta<typeof OrganisationCard>;
export default meta;
type Story = StoryObj<typeof meta>;
// Logo aus CMS.
export const MitLogo: Story = {
args: { org: { ...base, logo: { src: LOGO } } },
};
// Ohne Logo → Fisch-Fallback.
export const FischFallback: Story = {
args: { org: { ...base, logo: undefined } },
};
// Badge oben rechts + Social-Chips.
export const BadgeUndSocial: Story = {
args: {
org: {
...base,
logo: { src: LOGO },
badge: { label: 'Neu', color: 'green' },
whatsapp: 'https://chat.whatsapp.com/xyz',
youtube: 'https://youtube.com/@example',
},
},
};
// Kompakt: Logo links, kein Social, dichteres Grid.
export const Kompakt: Story = {
args: { org: { ...base, logo: { src: LOGO } }, compact: true },
};
+123
View File
@@ -0,0 +1,123 @@
<script lang="ts">
/**
* OrganisationCard — Organisations-Kachel auf InfoCard-Basis.
* Teilt Shell/Typo/Link-Hover mit InfoCard, behält aber die Besonderheiten:
* - quadratisches Logo + Fisch-Fallback (statt rundem Icon) via media-Snippet
* - Badge oben rechts
* - Social-Chips (WhatsApp/YouTube) im footer
* - ganze Karte klickbar (org.link)
* `compact` = engere Variante (Logo links, keine Social-Chips) für dichte Grids.
*/
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
import InfoCard from "$lib/components/InfoCard.svelte";
import Badge from "$lib/components/Badge.svelte";
import RustyImage from "$lib/components/RustyImage.svelte";
import { extractCmsImageField } from "$lib/rusty-image";
import type { OrganisationEntry } from "$lib/block-types";
let {
org,
compact = false,
index = 0,
}: { org: OrganisationEntry; compact?: boolean; index?: number } = $props();
const logoField = $derived(extractCmsImageField(org.logo));
const alt = $derived(
org.logo && typeof org.logo === "object" && org.logo.description ? org.logo.description : (org.name ?? ""),
);
const badge = $derived(org.badge && typeof org.badge === "object" ? org.badge : null);
const href = $derived(
typeof org.link === "object" && org.link !== null && typeof org.link.url === "string" && org.link.url.trim() !== ""
? org.link.url.trim()
: undefined,
);
const hasSocial = $derived(!compact && (!!org.whatsapp || !!org.youtube));
const fishMaskId = $derived(`org-logo-fallback-${index}`);
</script>
{#snippet media()}
<div
class="flex shrink-0 items-center justify-center overflow-hidden rounded-sm border border-stein-100 bg-stein-100 {compact
? 'h-10 w-10'
: 'h-12 w-12'}"
>
{#if logoField}
<RustyImage
src={logoField.url}
focal={logoField.focal ?? null}
width={96}
aspect="1/1"
fit="contain"
quality={85}
{alt}
class="h-full w-full object-contain p-0.5"
loading="lazy"
/>
{:else}
<svg
class="text-stein-300 {compact ? 'h-7 w-7' : 'h-8 w-8'}"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 48 48"
fill="currentColor"
aria-hidden="true"
>
<defs>
<mask id={fishMaskId}>
<g fill="none">
<path stroke="#fff" stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="M24 30v14" />
<path fill="#555555" stroke="#fff" stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="M29 23c11 5 16 14 16 14s-12 0-21-8c-9 8-21 8-21 8s5-10 16-14c0-13 5-19 5-19s5 6 5 19" />
<circle cx="24" cy="24" r="2" fill="#fff" />
</g>
</mask>
</defs>
<path fill="currentColor" d="M0 0h48v48H0z" mask={`url(#${fishMaskId})`} />
</svg>
{/if}
</div>
{/snippet}
{#snippet badgeSlot()}
<Badge color={badge?.color as never}>{badge?.label}</Badge>
{/snippet}
{#snippet footer()}
<div class="flex gap-1.5">
{#if org.whatsapp}
<a
href={org.whatsapp}
target="_blank"
rel="noopener noreferrer"
title="WhatsApp"
onclick={(e) => e.stopPropagation()}
class="inline-flex items-center gap-1 rounded-sm border border-stein-200 bg-stein-50 px-2 py-0.5 text-[0.6rem] text-stein-600 no-underline transition-colors hover:border-green-300 hover:bg-green-50 hover:text-green-700"
>
<Icon icon="mdi:whatsapp" class="size-3 shrink-0" />
<span>WhatsApp</span>
</a>
{/if}
{#if org.youtube}
<a
href={org.youtube}
target="_blank"
rel="noopener noreferrer"
title="YouTube"
onclick={(e) => e.stopPropagation()}
class="inline-flex items-center gap-1 rounded-sm border border-stein-200 bg-stein-50 px-2 py-0.5 text-[0.6rem] text-stein-600 no-underline transition-colors hover:border-red-300 hover:bg-red-50 hover:text-red-700"
>
<Icon icon="lucide:youtube" class="size-3 shrink-0" />
<span>YouTube</span>
</a>
{/if}
</div>
{/snippet}
<InfoCard
{href}
title={org.name ?? ""}
description={org.description}
{compact}
{media}
badge={badge?.label ? badgeSlot : undefined}
footer={hasSocial ? footer : undefined}
/>
+17
View File
@@ -0,0 +1,17 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import Pagination from './Pagination.svelte';
const meta = {
title: 'Molecules/Pagination',
component: Pagination,
tags: ['autodocs'],
args: { currentPage: 2, totalPages: 5, basePath: '/posts' },
} satisfies Meta<typeof Pagination>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Mitte: Story = {};
export const ErsteSeite: Story = { args: { currentPage: 1 } };
export const LetzteSeite: Story = { args: { currentPage: 5 } };
export const Viele: Story = { args: { currentPage: 7, totalPages: 20 } };
+8 -4
View File
@@ -11,16 +11,20 @@
totalPages = 1, totalPages = 1,
basePath = "/posts", basePath = "/posts",
activeTag = null, activeTag = null,
sort = "newest",
}: { }: {
currentPage: number; currentPage: number;
totalPages: number; totalPages: number;
basePath?: string; basePath?: string;
activeTag?: string | null; activeTag?: string | null;
sort?: "newest" | "oldest";
} = $props(); } = $props();
const query = $derived(sort === "oldest" ? "?sort=oldest" : "");
function tagHref(tagSlug: string | null): string { function tagHref(tagSlug: string | null): string {
if (!tagSlug) return `${basePath}/`; if (!tagSlug) return `${basePath}/${query}`;
return `${basePath}/tag/${encodeURIComponent(tagSlug)}/`; return `${basePath}/tag/${encodeURIComponent(tagSlug)}/${query}`;
} }
function pageHref(page: number): string { function pageHref(page: number): string {
@@ -28,7 +32,7 @@
const tagPart = activeTag const tagPart = activeTag
? `/tag/${encodeURIComponent(activeTag)}` ? `/tag/${encodeURIComponent(activeTag)}`
: ""; : "";
return `${basePath}${tagPart}/page/${page}/`; return `${basePath}${tagPart}/page/${page}/${query}`;
} }
const showPagination = $derived(totalPages > 1); const showPagination = $derived(totalPages > 1);
@@ -46,7 +50,7 @@
}); });
const baseShape = const baseShape =
"rounded-xs h-8 mx-[2px] flex justify-center items-center no-underline text-sm font-medium border transition-colors"; "rounded-full h-8 mx-[3px] flex justify-center items-center no-underline text-xs font-medium border transition-colors";
const inactiveColors = const inactiveColors =
"text-stein-700 border-stein-200 bg-white hover:bg-stein-50 hover:border-wald-300"; "text-stein-700 border-stein-200 bg-white hover:bg-stein-50 hover:border-wald-300";
const activeColors = const activeColors =
+32
View File
@@ -0,0 +1,32 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import PostCard from './PostCard.svelte';
const CMS_ASSET =
'https://cms.pm86.de/api/assets/pages/img_2814-ar4x2.webp?_environment=windwiderstand';
const meta = {
title: 'Organisms/PostCard',
component: PostCard,
tags: ['autodocs'],
} satisfies Meta<typeof PostCard>;
export default meta;
type Story = StoryObj<typeof meta>;
const post = {
_slug: 'infoabend-schleusingen',
slug: 'infoabend-schleusingen',
headline: 'Infoabend in Schleusingen',
subheadline: 'Über 80 Bürgerinnen und Bürger kamen zusammen.',
created: '2026-06-12T18:00:00Z',
postTag: [{ name: 'Veranstaltung' }, { name: 'Verfahren' }],
_resolvedImageUrl: CMS_ASSET,
} as never;
export const MitBild: Story = {
args: { post, href: '/posts/infoabend-schleusingen', commentCount: 4 },
};
export const OhneKommentare: Story = {
args: { post, href: '/posts/infoabend-schleusingen', commentCount: null },
};
+35 -10
View File
@@ -6,6 +6,7 @@
import EventBadges from "./EventBadges.svelte"; import EventBadges from "./EventBadges.svelte";
import RustyImage from "./RustyImage.svelte"; import RustyImage from "./RustyImage.svelte";
import Card from "./Card.svelte"; import Card from "./Card.svelte";
import Badge from "./Badge.svelte";
import { getPostEventInfo } from "$lib/blog-utils"; import { getPostEventInfo } from "$lib/blog-utils";
import { useTranslate, T } from "$lib/translations"; import { useTranslate, T } from "$lib/translations";
import { marked } from "$lib/markdown-safe"; import { marked } from "$lib/markdown-safe";
@@ -50,6 +51,16 @@
const isPastEvent = $derived( const isPastEvent = $derived(
hasTerminTag && !!eventDate && new Date(eventDate).getTime() < Date.now(), hasTerminTag && !!eventDate && new Date(eventDate).getTime() < Date.now(),
); );
// „Neu"-Badge: Beitrag jünger als 7 Tage (nach Erstellung). Nicht bei
// vergangenen Terminen.
const NEW_MS = 7 * 24 * 60 * 60 * 1000;
const isNew = $derived.by(() => {
if (isPastEvent || !post.created) return false;
const ts = new Date(post.created).getTime();
if (Number.isNaN(ts)) return false;
return Date.now() - ts < NEW_MS;
});
</script> </script>
<Card <Card
@@ -90,23 +101,37 @@
{/if} {/if}
{#if isPastEvent} {#if isPastEvent}
<div class="absolute top-2 right-2"> <div class="absolute top-2 right-2">
<span class="inline-flex items-center gap-1 rounded-full bg-stein-800/90 px-2.5 py-1 text-[.65rem] font-semibold uppercase tracking-wide text-white shadow-sm"> <Badge variant="solid" color="slate" uppercase class="shadow-sm">Vergangen</Badge>
Vergangen
</span>
</div> </div>
{/if} {/if}
{#if commentCount != null} <div class="absolute top-2 left-2 flex flex-col items-start gap-1">
<div class="absolute top-2 left-2"> {#if isNew}
<Badge
variant="solid"
color="green"
uppercase
class="inline-flex items-center gap-1 shadow-sm"
>
<Icon icon="lucide:sparkles" class="size-3" />
Neu
</Badge>
{/if}
{#if commentCount != null}
<span <span
class="inline-flex items-center gap-1 rounded-full bg-white/90 px-2 py-1 text-[.65rem] font-semibold tabular-nums text-stein-700 shadow-sm"
aria-label={t(T.comments_count_aria, { count: commentCount })} aria-label={t(T.comments_count_aria, { count: commentCount })}
title={t(T.comments_count_aria, { count: commentCount })} title={t(T.comments_count_aria, { count: commentCount })}
> >
<Icon icon="lucide:message-circle" class="size-3" /> <Badge
{commentCount} variant="solid"
color="white"
class="inline-flex items-center gap-1 tabular-nums shadow-sm"
>
<Icon icon="lucide:message-circle" class="size-3" />
{commentCount}
</Badge>
</span> </span>
</div> {/if}
{/if} </div>
</div> </div>
<div class="flex-1 flex flex-col justify-start p-4"> <div class="flex-1 flex flex-col justify-start p-4">
<PostMeta <PostMeta
@@ -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',
},
};
+76
View File
@@ -0,0 +1,76 @@
<script lang="ts">
import type { PostEntry } from "$lib/cms";
import RustyImage from "./RustyImage.svelte";
import { formatPostDate } from "$lib/blog-utils";
type PostWithImage = PostEntry & {
_resolvedImageUrl?: string;
_rawImageUrl?: string;
_imageFocal?: { x: number; y: number } | null;
};
let {
post,
href,
}: {
post: PostWithImage;
href: string;
} = $props();
const rawImg = $derived(post._rawImageUrl);
const focal = $derived(post._imageFocal ?? null);
const fallbackImg = $derived(post._resolvedImageUrl);
const dateStr = $derived(formatPostDate(post.created ?? undefined));
// Alle Tags als schlichter Text (keine Links — die ganze Karte ist EIN <a>,
// ein innerer <a> würde die Verschachtelung brechen).
const tagNames = $derived(
(post.postTag ?? [])
.map((t) =>
typeof t === "string"
? t
: ((t as { name?: string; _slug?: string })?.name ?? (t as { _slug?: string })?._slug ?? ""),
)
.filter(Boolean),
);
</script>
<a
{href}
class="group flex gap-3 overflow-hidden card-surface card-hover-lift p-0 text-stein-900 no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-500 focus-visible:ring-offset-2"
>
<div class="relative w-28 shrink-0 self-stretch overflow-hidden bg-stein-100 sm:w-32">
{#if rawImg}
<RustyImage
src={rawImg}
focal={focal}
width={200}
aspect="1/1"
alt={post.headline ?? ""}
sizes="128px"
class="absolute inset-0 h-full w-full object-cover"
loading="lazy"
/>
{:else if fallbackImg}
<img src={fallbackImg} alt={post.headline ?? ""} class="absolute inset-0 h-full w-full object-cover" loading="lazy" />
{:else}
<div class="flex h-full w-full items-center justify-center text-stein-300">
<svg xmlns="http://www.w3.org/2000/svg" class="size-8" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
</svg>
</div>
{/if}
</div>
<div class="flex min-w-0 flex-col justify-center py-3 pr-3">
<div class="mb-1 text-xs text-stein-500">
{dateStr}{#if tagNames.length} · <span class="text-wald-700">{tagNames.join(", ")}</span>{/if}
</div>
<h5 class="mb-1 line-clamp-2 text-sm font-semibold leading-tight">
{post.headline ?? post.linkName ?? post.slug ?? post._slug}
</h5>
{#if post.excerpt}
<p class="line-clamp-2 text-xs font-light text-stein-500">{post.excerpt}</p>
{/if}
</div>
</a>
+20
View File
@@ -0,0 +1,20 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import PostMeta from './PostMeta.svelte';
const meta = {
title: 'Molecules/PostMeta',
component: PostMeta,
tags: ['autodocs'],
args: {
date: '2026-06-12T18:00:00Z',
tags: [{ name: 'Verfahren' }, { name: 'Natur' }],
tagBase: '/posts/tag',
},
} satisfies Meta<typeof PostMeta>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standard: Story = {};
export const NurDatum: Story = { args: { tags: [] } };
export const NurTags: Story = { args: { date: null } };
+19
View File
@@ -0,0 +1,19 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import QrButton from './QrButton.svelte';
// Button, der auf Klick ein QR-Modal öffnet (aus `value`).
const meta = {
title: 'Molecules/QrButton',
component: QrButton,
tags: ['autodocs'],
args: {
value: 'https://windwiderstand.de',
label: 'QR-Code',
sublabel: 'Seite teilen',
},
} satisfies Meta<typeof QrButton>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standard: Story = {};
+7 -5
View File
@@ -3,6 +3,7 @@
import Icon from "@iconify/svelte"; import Icon from "@iconify/svelte";
import "$lib/iconify-offline"; import "$lib/iconify-offline";
import QRCode from "qrcode"; import QRCode from "qrcode";
import Button from "$lib/ui/Button.svelte";
let { let {
value, value,
@@ -60,7 +61,7 @@
onclick={onclose} onclick={onclose}
> >
<div <div
class="relative w-full max-w-xs rounded-sm border border-stein-200 bg-white p-6 shadow-xl" class="relative w-full max-w-xs rounded-lg border border-stein-200 bg-white p-6 shadow-xl"
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
@@ -87,15 +88,16 @@
<div class="flex h-48 items-center justify-center text-xs text-stein-300">Generiere…</div> <div class="flex h-48 items-center justify-center text-xs text-stein-300">Generiere…</div>
{/if} {/if}
<p class="mt-3 text-center text-[0.65rem] text-stein-400">QR-Code scannen</p> <p class="mt-3 text-center text-[0.65rem] text-stein-400">QR-Code scannen</p>
<button <Button
type="button" variant="outline"
size="sm"
onclick={downloadPng} onclick={downloadPng}
disabled={!qrPngUrl} 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" /> <Icon icon="lucide:download" class="size-3.5" />
Als PNG herunterladen Als PNG herunterladen
</button> </Button>
</div> </div>
</div> </div>
{/if} {/if}
+8 -23
View File
@@ -4,6 +4,7 @@
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import Icon from "@iconify/svelte"; import Icon from "@iconify/svelte";
import "$lib/iconify-offline"; import "$lib/iconify-offline";
import SearchField from "$lib/ui/SearchField.svelte";
type SearchHit = { type SearchHit = {
type: "page" | "post"; type: "page" | "post";
@@ -161,29 +162,13 @@
out:slide={{ duration: 150 }} out:slide={{ duration: 150 }}
> >
<div class="container-custom pt-4 pb-[calc(2rem+env(safe-area-inset-bottom))]"> <div class="container-custom pt-4 pb-[calc(2rem+env(safe-area-inset-bottom))]">
<label class="relative block max-w-xl mx-auto"> <SearchField
<span class="sr-only">Suche</span> bind:value={query}
<span class="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-stein-300"> bind:inputEl
<Icon icon="lucide:search" class="h-5 w-5" /> tone="dark"
</span> placeholder="Seiten und Beiträge durchsuchen…"
<input class="max-w-xl mx-auto"
bind:this={inputEl} />
type="search"
bind:value={query}
placeholder="Seiten und Beiträge durchsuchen…"
class="w-full rounded-xs border border-stein-0/20 bg-stein-800/70 py-3 pl-10 pr-10 text-sm text-stein-0 placeholder:text-stein-400 focus:border-wald-400 focus:outline-none"
/>
{#if query}
<button
type="button"
onclick={() => (query = "")}
aria-label="Eingabe leeren"
class="absolute top-1/2 right-2 -translate-y-1/2 rounded-xs p-1 text-stein-300 hover:bg-stein-0/10 hover:text-stein-0"
>
<Icon icon="lucide:x" class="h-4 w-4" />
</button>
{/if}
</label>
<div class="max-w-xl mx-auto mt-4 text-sm"> <div class="max-w-xl mx-auto mt-4 text-sm">
<p class="sr-only" role="status" aria-live="polite"> <p class="sr-only" role="status" aria-live="polite">
@@ -0,0 +1,70 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import SectionGrid from './SectionGrid.svelte';
import InfoCard from './InfoCard.svelte';
const { Story } = defineMeta({
title: 'Organisms/SectionGrid',
component: SectionGrid,
tags: ['autodocs'],
parameters: { layout: 'fullscreen' },
});
</script>
<!-- Reproduktion des Screenshots: Kicker-only-Header + 4 InfoCards. -->
<Story name="Direkter Draht" asChild>
<div class="p-8">
<SectionGrid number="01" kicker="Direkter Draht" columns={4}>
<InfoCard
icon="mdi:email-outline"
title="E-Mail"
description="Für alle Fragen und Anregungen"
linkLabel="mail@windwiderstand.de"
linkHref="mailto:mail@windwiderstand.de"
/>
<InfoCard
icon="mdi:whatsapp"
title="WhatsApp-Community"
description="Aktuelles und Austausch vor Ort"
linkLabel="Beitreten"
linkHref="#"
external
/>
<InfoCard
icon="mdi:youtube"
title="YouTube-Kanal"
description="Aufzeichnungen und Visualisierungen"
linkLabel="Ansehen"
linkHref="#"
external
/>
<InfoCard
icon="mdi:cloud-outline"
title="Unsere Cloud"
description="Dokumente und Materialien zum Mitnehmen"
linkLabel="cloud.windwiderstand.de"
linkHref="#"
external
/>
</SectionGrid>
</div>
</Story>
<!-- Mit vollem Header (Titel + CTA) und 3 Spalten. -->
<Story name="Mit Titel + CTA" asChild>
<div class="p-8">
<SectionGrid
number="03"
kicker="Vor Ort"
title="Beteiligte Initiativen"
subtitle="13 Bürgerinitiativen im Bündnis."
actionLabel="Zum Adressbuch"
actionHref="/adressbuch"
columns={3}
>
<InfoCard icon="mdi:account-group" title="BI Steigerwald" description="Schleusingen" linkLabel="Mehr" linkHref="#" />
<InfoCard icon="mdi:pine-tree" title="Waldfreunde e.V." description="Ebrach" linkLabel="Mehr" linkHref="#" />
<InfoCard icon="mdi:leaf" title="Naturschutz Nord" description="Bamberg" linkLabel="Mehr" linkHref="#" />
</SectionGrid>
</div>
</Story>
+65
View File
@@ -0,0 +1,65 @@
<script lang="ts">
/**
* SectionGrid — SectionHeader (Kicker/Nummer/Titel/CTA) oben + responsives
* Card-Grid darunter. Die Cards kommen als children-Slot rein (z.B. InfoCard,
* Organisations-Karten …). Generisch → auch für Organisationen nutzbar.
*
* <SectionGrid number="01" kicker="Direkter Draht" columns={4}>
* <InfoCard /> <InfoCard />
* </SectionGrid>
*/
import type { Snippet } from "svelte";
import SectionHeader from "$lib/components/SectionHeader.svelte";
let {
number = undefined,
kicker = undefined,
title = undefined,
subtitle = undefined,
actionLabel = undefined,
actionHref = undefined,
tag = "h2",
columns = 4,
children,
}: {
number?: string;
kicker?: string;
title?: string;
subtitle?: string;
actionLabel?: string;
actionHref?: string;
tag?: "h1" | "h2" | "h3";
/** Spalten auf Desktop (Mobil immer 1, ab sm 2). */
columns?: 2 | 3 | 4;
children: Snippet;
} = $props();
const hasHeader = $derived(!!number || !!kicker || !!title || (!!actionLabel && !!actionHref));
// Statische Grid-Klassen (Tailwind kann keine dynamischen Spaltenzahlen scannen).
const gridCols: Record<2 | 3 | 4, string> = {
2: "sm:grid-cols-2",
3: "sm:grid-cols-2 lg:grid-cols-3",
4: "sm:grid-cols-2 lg:grid-cols-4",
};
</script>
<section>
{#if hasHeader}
<div class="mb-4">
<SectionHeader
{number}
{kicker}
{title}
{subtitle}
{tag}
{actionLabel}
{actionHref}
/>
</div>
{/if}
<div class="grid grid-cols-1 gap-4 {gridCols[columns]}">
{@render children()}
</div>
</section>
@@ -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',
},
};
+77
View File
@@ -0,0 +1,77 @@
<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';
import ArrowLink from '$lib/components/ArrowLink.svelte';
let {
number = undefined,
kicker = undefined,
title = undefined,
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; // optional — z.B. Kicker-only-Header (nur "01 · DIREKTER DRAHT")
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 flex-col gap-3 sm:flex-row sm:items-end sm:justify-between sm: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 (\n → harter Umbruch, wie HeadlineBlock). Optional — Kicker-only-Header. -->
{#if title}
<svelte:element this={tag} class="text-3xl font-bold tracking-[-0.02em] text-stein-800 lg:text-[34px]">
{#each title.split('\n') as line, i}{#if i > 0}<br />{/if}{line}{/each}
</svelte:element>
{/if}
{#if subtitle}
<p class="mt-2 text-base text-stein-500">{subtitle}</p>
{/if}
</div>
<!-- Aktion (Slot hat Vorrang, sonst Label+Href) -->
{#if hasAction}
<div class="sm:whitespace-nowrap">
{#if children}
{@render children()}
{:else}
<ArrowLink href={actionHref ?? "#"} label={actionLabel ?? ""} size="md" />
{/if}
</div>
{/if}
</div>
+7 -6
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import Icon from "@iconify/svelte"; import Icon from "@iconify/svelte";
import "$lib/iconify-offline"; import "$lib/iconify-offline";
import Button from "$lib/ui/Button.svelte";
let { let {
open, open,
@@ -169,10 +170,10 @@
onload={() => (loadedOg = true)} onload={() => (loadedOg = true)}
/> />
</div> </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" /> <Icon icon="lucide:download" class="size-3.5" />
Quer herunterladen Quer herunterladen
</a> </Button>
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<p class="text-xs font-medium text-stein-600">Quadrat · 1080 × 1080</p> <p class="text-xs font-medium text-stein-600">Quadrat · 1080 × 1080</p>
@@ -188,10 +189,10 @@
onload={() => (loadedSq = true)} onload={() => (loadedSq = true)}
/> />
</div> </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" /> <Icon icon="lucide:download" class="size-3.5" />
Quadrat herunterladen Quadrat herunterladen
</a> </Button>
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<p class="text-xs font-medium text-stein-600">Story · 1080 × 1920</p> <p class="text-xs font-medium text-stein-600">Story · 1080 × 1920</p>
@@ -207,10 +208,10 @@
onload={() => (loadedStory = true)} onload={() => (loadedStory = true)}
/> />
</div> </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" /> <Icon icon="lucide:download" class="size-3.5" />
Story herunterladen Story herunterladen
</a> </Button>
</div> </div>
</div> </div>
+22
View File
@@ -0,0 +1,22 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import Tag from './Tag.svelte';
const meta = {
title: 'Molecules/Tag',
component: Tag,
tags: ['autodocs'],
args: { label: 'Verfahren' },
argTypes: {
variant: { control: 'select', options: ['white', 'green', 'blue', 'inactive', 'date'] },
},
} satisfies Meta<typeof Tag>;
export default meta;
type Story = StoryObj<typeof meta>;
export const White: Story = { args: { variant: 'white' } };
export const Green: Story = { args: { variant: 'green' } };
export const Blue: Story = { args: { variant: 'blue' } };
export const Inactive: Story = { args: { variant: 'inactive' } };
export const AlsLink: Story = { args: { variant: 'green', href: '/posts/tag/verfahren' } };
export const MitIcon: Story = { args: { variant: 'date', icon: 'lucide:calendar', label: '15.08.2026' } };
+19
View File
@@ -0,0 +1,19 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import Tags from './Tags.svelte';
const meta = {
title: 'Molecules/Tags',
component: Tags,
tags: ['autodocs'],
args: {
tags: [{ name: 'Verfahren' }, { name: 'Natur' }, { name: 'Landschaft' }],
variant: 'green',
tagBase: '/posts/tag',
},
} satisfies Meta<typeof Tags>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standard: Story = {};
export const Weiss: Story = { args: { variant: 'white' } };
+31
View File
@@ -0,0 +1,31 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import TopBanner from './TopBanner.svelte';
// Vollbreiter Seiten-Header mit Bild + Headline. `resolvedImages` = fertige
// Bild-URL(s); läuft über den /cms-images-Proxy.
const CMS_ASSET =
'https://cms.pm86.de/api/assets/pages/img_2814-ar4x2.webp?_environment=windwiderstand';
const meta = {
title: 'Organisms/TopBanner',
component: TopBanner,
tags: ['autodocs'],
parameters: { layout: 'fullscreen' },
} satisfies Meta<typeof TopBanner>;
export default meta;
type Story = StoryObj<typeof meta>;
export const MitBild: Story = {
args: {
banner: null,
resolvedImages: [CMS_ASSET],
focalCss: '50% 40%',
headline: 'Wald statt Windkraft',
subheadline: 'Für den Erhalt des Steigerwaldes',
},
};
export const NurText: Story = {
args: { banner: null, headline: 'Windwiderstand', subheadline: 'Bürgerinitiative Steigerwald' },
};
+32 -9
View File
@@ -68,6 +68,13 @@
subheadline?.trim() ? (marked.parseInline(subheadline) as string) : "", subheadline?.trim() ? (marked.parseInline(subheadline) as string) : "",
); );
const eyebrowLabel = "Windwiderstand Thüringen";
// Eyebrow überall — nur ausblenden, wenn er die Headline dupliziert (Home).
const showEyebrow = $derived(
(headline?.replace(/<[^>]*>/g, "").trim().toLowerCase() ?? "") !==
eyebrowLabel.toLowerCase(),
);
const hasResponsive = $derived( const hasResponsive = $derived(
responsive != null && responsive.fallback.trim().length > 0, responsive != null && responsive.fallback.trim().length > 0,
); );
@@ -125,15 +132,31 @@
{/key} {/key}
{/if} {/if}
{#if hasPageTitle} {#if hasPageTitle}
<div class="absolute inset-0 flex items-center justify-start "> <!-- Verlaufs-Scrim: links dunkel -> rechts transparent, hält Text lesbar ohne das Foto zuzukleistern -->
<div class="container-custom mx-auto px-6 relative"> <div
{#if headlineHtml} class="absolute inset-0 bg-gradient-to-r from-black/80 via-black/55 to-transparent"
<h1 class="bg-fire-400/70 inline-block py-1 px-2 -ml-2 backdrop-blur">{@html headlineHtml}</h1> aria-hidden="true"
{/if} ></div>
<div></div> <div class="absolute inset-0 flex items-center justify-start">
{#if subheadlineHtml} <div class="container-custom mx-auto px-6">
<h2 class="bg-white/50 inline-block py-1 px-2 -ml-2 backdrop-blur font-light! text-xl!">{@html subheadlineHtml}</h2> <div class="max-w-xl text-white">
{/if} {#if showEyebrow}
<span class="mb-3 inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.08em] text-wald-200">
<span class="h-px w-6 bg-wald-400"></span>
{eyebrowLabel}
</span>
{/if}
{#if headlineHtml}
<h1 class="text-4xl font-bold leading-[1.05] tracking-tight text-white! drop-shadow-sm lg:text-5xl">
{@html headlineHtml}
</h1>
{/if}
{#if subheadlineHtml}
<h2 class="mt-4 text-xl! font-normal! leading-snug text-stein-100! lg:text-2xl!">
{@html subheadlineHtml}
</h2>
{/if}
</div>
</div> </div>
</div> </div>
{/if} {/if}
@@ -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,9 @@
import type { AdressbuchBlockData, ContactEntry } from "$lib/block-types"; import type { AdressbuchBlockData, ContactEntry } from "$lib/block-types";
import { downloadVCard, downloadVCardAll, buildVCardString } from "$lib/vcard"; import { downloadVCard, downloadVCardAll, buildVCardString } from "$lib/vcard";
import QrButton from "$lib/components/QrButton.svelte"; import QrButton from "$lib/components/QrButton.svelte";
import Button from "$lib/ui/Button.svelte";
import Badge from "$lib/components/Badge.svelte";
import SearchField from "$lib/ui/SearchField.svelte";
let { block }: { block: AdressbuchBlockData } = $props(); let { block }: { block: AdressbuchBlockData } = $props();
@@ -121,17 +124,14 @@
<!-- Search + Filter bar --> <!-- Search + Filter bar -->
<div class="no-print mb-4 flex flex-col gap-2"> <div class="no-print mb-4 flex flex-col gap-2">
<div class="flex gap-2"> <div class="flex gap-2">
<div class="relative max-w-sm flex-1"> <SearchField
<Icon icon="lucide:search" class="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-stein-400" /> bind:value={search}
<input placeholder="Suchen…"
type="search" ariaLabel="Kontakte durchsuchen"
placeholder="Suchen…" class="max-w-sm flex-1"
bind:value={search} />
class="w-full rounded-sm border border-stein-200 bg-white py-1.5 pl-8 pr-3 text-sm text-stein-900 placeholder:text-stein-400 focus:border-wald-400 focus:outline-none focus:ring-1 focus:ring-wald-400"
/>
</div>
<!-- View toggle --> <!-- View toggle -->
<div class="flex rounded-sm border border-stein-200 overflow-hidden text-[11px] font-medium"> <div class="flex rounded-md border border-stein-200 overflow-hidden text-[11px] font-medium">
<button <button
type="button" type="button"
onclick={() => { viewMode = "alpha"; activeLetter = null; }} onclick={() => { viewMode = "alpha"; activeLetter = null; }}
@@ -196,7 +196,7 @@
{#if filtered().length === 0} {#if filtered().length === 0}
<p class="text-sm text-stein-400">Keine Kontakte gefunden.</p> <p class="text-sm text-stein-400">Keine Kontakte gefunden.</p>
{:else} {:else}
<div class="divide-y divide-stein-100 rounded-sm border border-stein-200 bg-white"> <div class="divide-y divide-stein-100 rounded-lg border border-stein-200 bg-white">
{#if viewMode === "alpha"} {#if viewMode === "alpha"}
{#each alphaGrouped() as [letter, contacts]} {#each alphaGrouped() as [letter, contacts]}
<div class="sticky top-0 z-10 border-b border-stein-100 bg-stein-50 px-4 py-1 text-[0.65rem] font-semibold tracking-widest text-stein-400"> <div class="sticky top-0 z-10 border-b border-stein-100 bg-stein-50 px-4 py-1 text-[0.65rem] font-semibold tracking-widest text-stein-400">
@@ -219,14 +219,15 @@
</div> </div>
<div class="mt-2 flex items-center justify-between"> <div class="mt-2 flex items-center justify-between">
<button <Button
type="button" variant="outline"
size="sm"
onclick={() => downloadVCardAll(filtered())} onclick={() => downloadVCardAll(filtered())}
class="no-print btn-outline btn-sm" class="no-print"
> >
<Icon icon="lucide:download" class="size-3.5" /> <Icon icon="lucide:download" class="size-3.5" />
Alle exportieren (.vcf) Alle exportieren (.vcf)
</button> </Button>
<p class="text-xs text-stein-400">{filtered().length} Kontakt{filtered().length !== 1 ? "e" : ""}</p> <p class="text-xs text-stein-400">{filtered().length} Kontakt{filtered().length !== 1 ? "e" : ""}</p>
</div> </div>
{/if} {/if}
@@ -239,14 +240,10 @@
{#if contact.role || (contact.organisations ?? []).some((o) => typeof o === "object" && o.name)} {#if contact.role || (contact.organisations ?? []).some((o) => typeof o === "object" && o.name)}
<div class="mt-0.5 flex flex-nowrap gap-1 overflow-hidden"> <div class="mt-0.5 flex flex-nowrap gap-1 overflow-hidden">
{#if contact.role} {#if contact.role}
<span class="shrink-0 whitespace-nowrap rounded-full border border-stein-200 bg-stein-100 px-1.5 py-0 text-[0.65rem] font-medium text-stein-600"> <Badge>{contact.role}</Badge>
{contact.role}
</span>
{/if} {/if}
{#each (contact.organisations ?? []).filter((o): o is { _slug?: string; name?: string } => typeof o === "object" && !!o.name) as org} {#each (contact.organisations ?? []).filter((o): o is { _slug?: string; name?: string } => typeof o === "object" && !!o.name) as org}
<span class="shrink-0 whitespace-nowrap rounded-full border border-wald-200 bg-wald-50 px-1.5 py-0 text-[0.65rem] font-medium text-wald-700"> <Badge color="green">{org.name}</Badge>
{org.name}
</span>
{/each} {/each}
</div> </div>
{/if} {/if}
@@ -0,0 +1,47 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import CalendarBlock from './CalendarBlock.svelte';
// CalendarBlock liest Übersetzungen über die `translations`-Prop (nicht den
// Context/WithTranslations-Decorator) → Fixture explizit reinreichen, sonst
// rohe Keys (calendar_weekday_mo, calendar_all_upcoming, …).
import translations from './_fixtures/translations.json';
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 }, translations },
};
+276 -220
View File
@@ -4,6 +4,7 @@
import type { CalendarBlockData, CalendarItemData } from "$lib/block-types"; import type { CalendarBlockData, CalendarItemData } from "$lib/block-types";
import { t as tStatic, T } from "$lib/translations"; import { t as tStatic, T } from "$lib/translations";
import type { Translations } from "$lib/translations"; import type { Translations } from "$lib/translations";
import Button from "$lib/ui/Button.svelte";
import { import {
downloadICS, downloadICS,
downloadICSFeed, downloadICSFeed,
@@ -15,10 +16,11 @@
import { marked } from "$lib/markdown-safe"; import { marked } from "$lib/markdown-safe";
import "$lib/iconify-offline"; import "$lib/iconify-offline";
import Icon from "@iconify/svelte"; import Icon from "@iconify/svelte";
import SearchField from "$lib/ui/SearchField.svelte";
import Badge from "$lib/components/Badge.svelte";
import ImageModal from "$lib/components/ImageModal.svelte"; import ImageModal from "$lib/components/ImageModal.svelte";
import SocialImageModal from "$lib/components/SocialImageModal.svelte"; import SocialImageModal from "$lib/components/SocialImageModal.svelte";
import QrModal from "$lib/components/QrModal.svelte"; import QrModal from "$lib/components/QrModal.svelte";
import EventDateBox from "./EventDateBox.svelte";
type EventCardItem = CalendarItemData & { type EventCardItem = CalendarItemData & {
start: Date; start: Date;
@@ -50,8 +52,11 @@
* + selected day. */ * + selected day. */
let showPast = $state(false); let showPast = $state(false);
let activeTag = $state<string | null>(null); let activeTag = $state<string | null>(null);
let search = $state("");
let currentMonth = $state(new Date()); let currentMonth = $state(new Date());
let selectedDay = $state<string | null>(null); let selectedDay = $state<string | null>(null);
/** Themenfilter-Popover unter der Kopfleiste (Icon-Button toggelt). */
let tagFilterOpen = $state(false);
function parseDate(iso: string | undefined | null): Date | null { function parseDate(iso: string | undefined | null): Date | null {
if (!iso) return null; if (!iso) return null;
@@ -153,11 +158,29 @@
}); });
const filteredItems = $derived.by(() => { const filteredItems = $derived.by(() => {
if (!activeTag) return items; let list = items;
const needle = activeTag.toLowerCase(); if (activeTag) {
return items.filter((it) => const needle = activeTag.toLowerCase();
(it.tags ?? []).some((t) => t.toLowerCase() === needle), list = list.filter((it) =>
); (it.tags ?? []).some((t) => t.toLowerCase() === needle),
);
}
const q = search.trim().toLowerCase();
if (q) {
list = list.filter((it) => {
const hay = [
it.title,
locationText(it.location),
it.description,
...(it.tags ?? []),
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return hay.includes(q);
});
}
return list;
}); });
/** Map a day-key → events overlapping that day (multi-day events /** Map a day-key → events overlapping that day (multi-day events
@@ -217,6 +240,20 @@
selectedDay ? (eventsByDay.get(selectedDay) ?? []) : [], selectedDay ? (eventsByDay.get(selectedDay) ?? []) : [],
); );
// Lange Zukunfts-Liste zunächst auf die nächsten Tage begrenzen, Rest per
// „Weitere anzeigen" ausklappbar.
const INITIAL_DAY_GROUPS = 5;
let showAllFuture = $state(false);
const visibleFutureGroups = $derived(
showAllFuture
? futureEventsByDay
: futureEventsByDay.slice(0, INITIAL_DAY_GROUPS),
);
const hiddenFutureCount = $derived(
futureEvents.length -
visibleFutureGroups.reduce((n, g) => n + g.events.length, 0),
);
const todayKey = $derived.by(() => dayKey(new Date(nowMs))); const todayKey = $derived.by(() => dayKey(new Date(nowMs)));
// ── Calendar-grid derived state ────────────────────────────────── // ── Calendar-grid derived state ──────────────────────────────────
@@ -253,6 +290,12 @@
month: "short", month: "short",
}); });
} }
/** Kurzer Wochentag ohne Punkt für die Datums-Kachel der Karte. */
function fmtWd(d: Date): string {
return d
.toLocaleDateString("de-DE", { weekday: "short" })
.replace(/\.$/, "");
}
function fmtGroupHeader(d: Date): string { function fmtGroupHeader(d: Date): string {
return d.toLocaleDateString("de-DE", { return d.toLocaleDateString("de-DE", {
weekday: "long", weekday: "long",
@@ -339,6 +382,11 @@
selectedDay = null; selectedDay = null;
} }
function goToToday() {
currentMonth = new Date();
selectedDay = null;
}
/** "Show me this in the calendar" — used from accordion bodies for /** "Show me this in the calendar" — used from accordion bodies for
* events that may be in a different month than the current view. * events that may be in a different month than the current view.
* Scrolls back up to the widget since it now sits above the list. */ * Scrolls back up to the widget since it now sits above the list. */
@@ -371,6 +419,20 @@
return "later"; return "later";
} }
/** Getönte Datums-Kachel je Urgency (ersetzt den früheren farbigen
* Left-Border-Strich der Event-Rows). */
function urgencyTile(u: Urgency): string {
return (
{
now: "bg-fire-50 text-fire-700",
today: "bg-fire-50 text-fire-600",
tomorrow: "bg-erde-50 text-erde-700",
week: "bg-himmel-50 text-himmel-700",
later: "bg-stein-100 text-stein-600",
}[u] ?? "bg-stein-100 text-stein-600"
);
}
function toICS(ev: EventCardItem): CalendarItemICS { function toICS(ev: EventCardItem): CalendarItemICS {
return { return {
title: ev.title || t(T.calendar_untitled), title: ev.title || t(T.calendar_untitled),
@@ -593,42 +655,58 @@
locText || locText ||
item.isMultiDay item.isMultiDay
)} )}
<li <li id={eventId(item)} class="event-card">
id={eventId(item)}
class="event-item {isNext ? 'event-item-next' : ''}"
data-urgency={urgency}
>
<details <details
class="group/event" class="group/event"
open={openByDefault || linkedSlug === eventId(item)} open={openByDefault || linkedSlug === eventId(item)}
> >
<summary <summary
class="cursor-pointer list-none p-3 flex items-start gap-3 hover:bg-himmel-50/60 focus-visible:outline focus-visible:outline-2 focus-visible:outline-himmel-500" class="flex cursor-pointer list-none items-center gap-3 p-3 hover:bg-stein-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-himmel-500"
> >
<!-- Datum-Kachel: Wochentag/Tag/Monat (Einzel) bzw. <!-- Datums-Kachel: Urgency-getönt, Einzeltag bzw.
Wochentag- und Tag-Range (mehrtägig). --> Tag-Range (mehrtägig). -->
<EventDateBox <div
start={item.start} class="flex w-[50px] shrink-0 flex-col items-center rounded-lg py-1.5 {urgencyTile(
end={item.end} urgency,
multiDay={!!item.isMultiDay} )}"
/> >
<div class="flex-1 min-w-0"> <span class="text-[9px] font-semibold uppercase">
<div class="flex items-baseline gap-2 flex-wrap"> {#if item.isMultiDay && item.end}{fmtWd(
item.start,
)}{fmtWd(item.end)}{:else}{fmtWd(
item.start,
)}{/if}
</span>
<span
class="font-bold leading-none {item.isMultiDay &&
item.end
? 'text-sm'
: 'text-xl'}"
>
{#if item.isMultiDay && item.end}{item.start.getDate()}{item.end.getDate()}{:else}{item.start.getDate()}{/if}
</span>
</div>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span <span
class="text-sm font-semibold text-stein-900 leading-snug" class="text-sm font-semibold text-stein-900"
> >
{title} {title}
</span> </span>
{#if live} {#if live}
<span <Badge color="fire" variant="solid" uppercase>
class="text-[10px] uppercase tracking-wide font-semibold text-fire-50 bg-fire-700 px-1.5 py-0.5 rounded-xs shrink-0"
>
{live} {live}
</Badge>
{:else if relativeDays(item.start)}
<span
class="rounded-full bg-stein-100 px-2 py-0.5 text-[10px] font-semibold text-stein-600"
>
{relativeDays(item.start)}
</span> </span>
{/if} {/if}
</div> </div>
<div <div
class="mt-0.5 flex items-center gap-2 flex-wrap text-[11px] text-stein-600" class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-stein-600"
> >
{#if item.timeStr && !item.isMultiDay} {#if item.timeStr && !item.isMultiDay}
<span <span
@@ -644,7 +722,7 @@
{/if} {/if}
{#if locText} {#if locText}
<span <span
class="inline-flex items-center gap-1 min-w-0" class="inline-flex min-w-0 items-center gap-1"
> >
<Icon <Icon
icon="lucide:map-pin" icon="lucide:map-pin"
@@ -654,25 +732,11 @@
<span class="truncate">{locText}</span> <span class="truncate">{locText}</span>
</span> </span>
{/if} {/if}
{#if item.tags && item.tags.length > 0}
{#each item.tags.slice(0, 2) as tg}
<span
class="bg-himmel-100 text-himmel-800 text-[10px] px-1.5 py-0.5 rounded-xs"
>
{tg}
</span>
{/each}
{#if item.tags.length > 2}
<span class="text-[10px] text-stein-500"
>+{item.tags.length - 2}</span
>
{/if}
{/if}
</div> </div>
</div> </div>
<Icon <Icon
icon="lucide:chevron-down" icon="lucide:chevron-down"
class="size-4 text-stein-400 shrink-0 mt-1 transition-transform group-open/event:rotate-180" class="size-4 shrink-0 text-stein-400 transition-transform group-open/event:rotate-180"
aria-hidden="true" aria-hidden="true"
/> />
</summary> </summary>
@@ -692,11 +756,7 @@
{#if item.tags && item.tags.length > 2} {#if item.tags && item.tags.length > 2}
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
{#each item.tags as tg} {#each item.tags as tg}
<span <Badge color="blue">{tg}</Badge>
class="bg-himmel-100 text-himmel-800 text-[10px] px-1.5 py-0.5 rounded-xs"
>
{tg}
</span>
{/each} {/each}
</div> </div>
{/if} {/if}
@@ -752,9 +812,10 @@
{#if item._slug || eventHref || locText || item.attachment?.src} {#if item._slug || eventHref || locText || item.attachment?.src}
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
{#if item._slug} {#if item._slug}
<a <Button
href={`/kalender/termin/${encodeURIComponent(item._slug)}/`} href={`/kalender/termin/${encodeURIComponent(item._slug)}/`}
class="btn-tertiary btn-sm" variant="tertiary"
size="sm"
> >
Details Details
<Icon <Icon
@@ -762,14 +823,15 @@
class="size-3.5" class="size-3.5"
aria-hidden="true" aria-hidden="true"
/> />
</a> </Button>
{/if} {/if}
{#if eventHref} {#if eventHref}
<a <Button
href={eventHref} href={eventHref}
variant="tertiary"
size="sm"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="btn-tertiary btn-sm"
> >
{t(T.calendar_more_info)} {t(T.calendar_more_info)}
<Icon <Icon
@@ -777,14 +839,16 @@
class="size-3.5" class="size-3.5"
aria-hidden="true" aria-hidden="true"
/> />
</a> </Button>
{/if} {/if}
{#if locText} {#if locText}
<a <Button
href={mapsUrl(locText)} href={mapsUrl(locText)}
variant="outline"
size="sm"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="btn-outline btn-sm group" class="group"
aria-label={t(T.calendar_open_maps)} aria-label={t(T.calendar_open_maps)}
> >
<Icon <Icon
@@ -793,13 +857,15 @@
aria-hidden="true" aria-hidden="true"
/> />
<span class="whitespace-nowrap">{t(T.calendar_open_maps)}</span> <span class="whitespace-nowrap">{t(T.calendar_open_maps)}</span>
</a> </Button>
{/if} {/if}
{#if item.attachment?.src} {#if item.attachment?.src}
{@const dlSrc = `/cms-files?src=${encodeURIComponent(item.attachment.src)}&dl=1`} {@const dlSrc = `/cms-files?src=${encodeURIComponent(item.attachment.src)}&dl=1`}
<a <Button
href={dlSrc} href={dlSrc}
class="btn-outline btn-sm group" variant="outline"
size="sm"
class="group"
aria-label={item.attachment.label || "Anhang herunterladen"} aria-label={item.attachment.label || "Anhang herunterladen"}
> >
<Icon <Icon
@@ -808,7 +874,7 @@
aria-hidden="true" aria-hidden="true"
/> />
<span class="whitespace-nowrap">{item.attachment.label || "Anhang"}</span> <span class="whitespace-nowrap">{item.attachment.label || "Anhang"}</span>
</a> </Button>
{/if} {/if}
</div> </div>
{/if} {/if}
@@ -922,15 +988,59 @@
</h3> </h3>
{/if} {/if}
{#if allTags.length > 0} <!-- Kopfleiste: Suche (Pill) · Themenfilter (Icon-Button) · „Heute" (Chip).
<!-- Tag-Filter chips. "Alle" = activeTag null. Sichtbar nur, wenn Themenfilter öffnet die Chips-Zeile darunter (tagFilterOpen). -->
mindestens ein Termin Tags hat — sonst Lärm. --> <div class="flex items-center gap-2 border-b border-stein-200 px-4 py-3">
<div <SearchField
class="flex flex-wrap gap-1.5 items-center px-4 py-2 border-b border-stein-200 bg-stein-50" bind:value={search}
placeholder="Termine durchsuchen …"
ariaLabel="Termine durchsuchen"
class="min-w-0 flex-1 rounded-full bg-stein-50"
/>
{#if allTags.length > 0}
<button
type="button"
onclick={() => (tagFilterOpen = !tagFilterOpen)}
class="relative grid size-9 shrink-0 place-items-center rounded-full text-stein-600 hover:bg-stein-100 {tagFilterOpen
? 'bg-stein-100'
: 'bg-stein-50'}"
aria-label={t(T.calendar_filter_by_tag)}
aria-pressed={!!activeTag}
aria-expanded={tagFilterOpen}
>
<Icon
icon="lucide:sliders-horizontal"
class="size-4"
aria-hidden="true"
/>
{#if activeTag}
<span
class="absolute -right-0.5 -top-0.5 size-2.5 rounded-full bg-himmel-600 ring-2 ring-stein-0"
aria-hidden="true"
></span>
{/if}
</button>
{/if}
<button
type="button"
onclick={goToToday}
class="inline-flex items-center gap-1.5 rounded-full bg-wald-50 px-3.5 py-2 text-xs font-semibold text-wald-700 hover:bg-wald-100"
>
<Icon
icon="lucide:calendar-check"
class="size-3.5"
aria-hidden="true"
/>
Heute
</button>
</div>
{#if allTags.length > 0 && tagFilterOpen}
<!-- Themen-Chips: eingeblendet per Filter-Icon. "Alle" = activeTag null.
Aktiver Tag zusätzlich als Punkt am Filter-Icon signalisiert. -->
<div
class="flex flex-wrap items-center gap-1.5 border-b border-stein-200 bg-stein-50 px-4 py-2"
> >
<span class="text-[11px] uppercase tracking-wide text-stein-500">
{t(T.calendar_filter_by_tag)}
</span>
<button <button
type="button" type="button"
onclick={() => (activeTag = null)} onclick={() => (activeTag = null)}
@@ -956,82 +1066,20 @@
</div> </div>
{/if} {/if}
<!-- Subscribe + Export Actions. Nur nach mount sichtbar (feedHost gesetzt).
Apple: webcal:// Link → öffnet nativ Kalender.app / iOS Kalender.
Alle anderen: direkter ICS-Download als Fallback. -->
{#if feedHost}
<div
class="flex flex-wrap items-center gap-2 px-4 py-2.5 border-b border-stein-200 bg-himmel-100"
>
<a
href="/termin-melden"
class="btn-warning btn-sm inline-flex items-center gap-1.5 min-h-[36px]"
>
<Icon icon="lucide:plus" class="size-3.5 shrink-0" aria-hidden="true" />
Termin melden
</a>
{#if isApplePlatform}
<a
href={webcalUrl}
aria-label={t(T.calendar_subscribe_aria)}
class="btn-primary btn-sm inline-flex items-center gap-1.5 min-h-[36px]"
>
<Icon
icon="lucide:calendar-plus"
class="size-3.5 shrink-0"
aria-hidden="true"
/>
{t(T.calendar_subscribe)}
</a>
{:else}
<a
href={icsUrl}
download="windwiderstand-termine.ics"
aria-label={t(T.calendar_subscribe_aria)}
class="btn-primary btn-sm inline-flex items-center gap-1.5 min-h-[36px]"
>
<Icon
icon="lucide:calendar-plus"
class="size-3.5 shrink-0"
aria-hidden="true"
/>
{t(T.calendar_subscribe)}
</a>
{/if}
{#if items.length > 0}
<button
type="button"
onclick={exportAll}
class="btn-outline btn-sm inline-flex items-center gap-1.5 min-h-[36px]"
>
<Icon
icon="lucide:download"
class="size-3.5 shrink-0"
aria-hidden="true"
/>
{t(T.calendar_export_all)}
</button>
{/if}
</div>
{/if}
<!-- Kalender-Widget: oben, kompakt auf Mobile-Breite gecappt (sonst <!-- Kalender-Widget: oben, kompakt auf Mobile-Breite gecappt (sonst
werden aspect-square cells auf Desktop gigantisch). Monat-Nav + werden aspect-square cells auf Desktop gigantisch). Monat-Nav +
Tag-Grid. Klick auf Tag filtert Liste unten. --> Tag-Grid. Klick auf Tag filtert Liste unten. -->
<div <div bind:this={widgetEl} class="calendar-widget bg-stein-0 p-4">
bind:this={widgetEl}
class="calendar-widget bg-himmel-100 text-himmel-900 p-4"
>
<div class="w-full"> <div class="w-full">
<div class="flex items-center justify-between gap-2 mb-2"> <div class="flex items-center justify-between gap-2 mb-2">
<span <span
class="text-xl font-semibold text-himmel-900 capitalize whitespace-nowrap" class="text-xl font-semibold text-stein-900 capitalize whitespace-nowrap"
>{monthLabel}</span >{monthLabel}</span
> >
<div class="flex items-center gap-0.5"> <div class="flex items-center gap-0.5">
<button <button
type="button" type="button"
class="p-2 rounded-xs text-himmel-800 hover:bg-himmel-200 hover:text-himmel-900 transition-colors" class="p-2 rounded-md text-stein-500 hover:bg-stein-100 hover:text-stein-800 transition-colors"
aria-label={t(T.calendar_prev_month)} aria-label={t(T.calendar_prev_month)}
onclick={prevMonth} onclick={prevMonth}
> >
@@ -1043,7 +1091,7 @@
</button> </button>
<button <button
type="button" type="button"
class="p-2 rounded-xs text-himmel-800 hover:bg-himmel-200 hover:text-himmel-900 transition-colors" class="p-2 rounded-md text-stein-500 hover:bg-stein-100 hover:text-stein-800 transition-colors"
aria-label={t(T.calendar_next_month)} aria-label={t(T.calendar_next_month)}
onclick={nextMonth} onclick={nextMonth}
> >
@@ -1056,43 +1104,34 @@
</div> </div>
</div> </div>
<div class="grid grid-cols-7 gap-1.5 text-center text-sm"> <div class="mb-1 grid grid-cols-7 text-center">
{#each weekdays as w, i} {#each weekdays as w}
<div <div
class="py-1.5 text-xs font-bold rounded-sm {i >= 5 class="py-1 text-[10px] font-semibold uppercase tracking-wide text-stein-400"
? 'bg-stein-100/80 text-stein-600'
: 'bg-himmel-200/50 text-himmel-800'}"
> >
{w} {w}
</div> </div>
{/each} {/each}
</div>
<div class="grid grid-cols-7 gap-0.5 text-center">
{#each calendarDays as d} {#each calendarDays as d}
{#if d === null} {#if d === null}
<div class="h-10" aria-hidden="true"></div> <div class="h-9" aria-hidden="true"></div>
{:else} {:else}
{@const key = dayKey(d)} {@const key = dayKey(d)}
{@const isCurrentMonth = d.getMonth() === month}
{@const dayEvents = eventsByDay.get(key) ?? []} {@const dayEvents = eventsByDay.get(key) ?? []}
{@const hasEvents = dayEvents.length > 0} {@const hasEvents = dayEvents.length > 0}
{@const isSelected = selectedDay === key} {@const isSelected = selectedDay === key}
{@const isToday = key === todayKey} {@const isToday = key === todayKey}
{@const isFuture = key >= todayKey} {@const isFuture = key >= todayKey}
{@const isWeekend =
d.getDay() === 0 || d.getDay() === 6}
{#if hasEvents} {#if hasEvents}
<button <button
type="button" type="button"
class="relative h-10 rounded-xs flex flex-col items-center justify-center min-w-0 transition-colors cursor-pointer font-semibold class="relative grid h-9 place-items-center rounded-lg text-[13px] font-bold cursor-pointer transition-colors
{!isFuture ? 'opacity-20 day-past' : ''} {isFuture
{isCurrentMonth ? 'text-wald-700 bg-wald-50 hover:bg-wald-100'
? 'text-himmel-900' : 'text-stein-400 bg-stein-50 hover:bg-stein-100'}
: 'text-himmel-700 opacity-60 hover:opacity-80'} {isSelected ? 'ring-2 ring-wald-500' : ''}
{isWeekend
? 'bg-erde-100 hover:bg-erde-200'
: 'bg-himmel-200/80 hover:bg-himmel-300'}
{isSelected
? 'ring-2 ring-himmel-700 brightness-95'
: ''}
{isToday ? 'today-cell' : ''}" {isToday ? 'today-cell' : ''}"
onclick={() => selectDay(key)} onclick={() => selectDay(key)}
onmouseenter={(e) => showTooltip(e, key)} onmouseenter={(e) => showTooltip(e, key)}
@@ -1109,30 +1148,22 @@
{ n: dayEvents.length }, { n: dayEvents.length },
).replace(/\d+\s*/, '')}" ).replace(/\d+\s*/, '')}"
> >
<span class="text-xs">{d.getDate()}</span> <span>{d.getDate()}</span>
<span <span
class="absolute -bottom-1 -right-1 inline-flex items-center justify-center min-w-3.5 h-3.5 sm:min-w-4.5 sm:h-4.5 px-0.5 rounded-full text-stein-0 text-[9px] sm:text-[10px] font-medium leading-none {isFuture class="absolute bottom-1.5 size-[5px] rounded-full {isFuture
? 'bg-wald-400' ? 'bg-wald-500'
: 'bg-error'}" : 'bg-stein-400'}"
aria-hidden="true" aria-hidden="true"
> ></span>
{dayEvents.length}
</span>
</button> </button>
{:else} {:else}
<div <div
class="relative h-10 rounded-xs flex flex-col items-center justify-center min-w-0 cursor-default class="grid h-9 place-items-center rounded-lg text-[13px]
{!isFuture ? 'opacity-20 day-past' : ''} {isFuture ? 'text-stein-700' : 'text-stein-300'}
{isWeekend
? 'bg-stein-100/60'
: 'bg-himmel-50/50'}
{isCurrentMonth
? 'text-himmel-800'
: 'text-himmel-600 opacity-50'}
{isToday ? 'today-cell' : ''}" {isToday ? 'today-cell' : ''}"
aria-current={isToday ? "date" : undefined} aria-current={isToday ? "date" : undefined}
> >
<span class="text-xs">{d.getDate()}</span> <span>{d.getDate()}</span>
{#if isToday} {#if isToday}
<span class="sr-only" <span class="sr-only"
>{t(T.calendar_today_marker)}</span >{t(T.calendar_today_marker)}</span
@@ -1222,7 +1253,7 @@
</span> </span>
</div> </div>
<ul class="m-0! p-0! list-none"> <ul class="m-0! p-0! list-none">
{#each futureEventsByDay as group, gIdx} {#each visibleFutureGroups as group, gIdx}
<li class="day-group"> <li class="day-group">
<div <div
class="px-3 py-1 bg-himmel-50/60 text-[11px] uppercase tracking-wide font-medium text-stein-600 sticky top-0 z-10 backdrop-blur-sm" class="px-3 py-1 bg-himmel-50/60 text-[11px] uppercase tracking-wide font-medium text-stein-600 sticky top-0 z-10 backdrop-blur-sm"
@@ -1245,6 +1276,22 @@
</li> </li>
{/each} {/each}
</ul> </ul>
{#if futureEventsByDay.length > INITIAL_DAY_GROUPS}
<button
type="button"
onclick={() => (showAllFuture = !showAllFuture)}
class="inline-flex w-full items-center justify-center gap-1.5 border-t border-stein-200 px-3 py-2 text-xs font-medium text-himmel-700 hover:bg-himmel-50"
>
<Icon
icon={showAllFuture ? "lucide:chevron-up" : "lucide:chevron-down"}
class="size-3.5"
aria-hidden="true"
/>
{showAllFuture
? "Weniger anzeigen"
: `Weitere ${hiddenFutureCount} Termine anzeigen`}
</button>
{/if}
{#if pastEvents.length > 0} {#if pastEvents.length > 0}
<details <details
class="border-t border-stein-200 bg-stein-50 group/past" class="border-t border-stein-200 bg-stein-50 group/past"
@@ -1302,6 +1349,51 @@
</p> </p>
{/if} {/if}
</div> </div>
<!-- Aktionsleiste: Termin melden · Abonnieren (Apple → webcal, sonst ICS)
· Export. Ruhige Text+Icon-Links, nur nach mount (feedHost). -->
{#if feedHost}
<div
class="flex items-center gap-4 border-t border-stein-100 px-4 py-3 text-xs font-semibold"
>
<a
href="/termin-melden"
class="inline-flex items-center gap-1.5 text-wald-700 no-underline hover:underline"
>
<Icon icon="lucide:plus" class="size-3.5" aria-hidden="true" />
Termin melden
</a>
<a
href={isApplePlatform ? webcalUrl : icsUrl}
download={isApplePlatform
? undefined
: "windwiderstand-termine.ics"}
aria-label={t(T.calendar_subscribe_aria)}
class="inline-flex items-center gap-1.5 text-himmel-700 no-underline hover:underline"
>
<Icon
icon="lucide:calendar-plus"
class="size-3.5"
aria-hidden="true"
/>
{t(T.calendar_subscribe)}
</a>
{#if items.length > 0}
<button
type="button"
onclick={exportAll}
class="ml-auto inline-flex items-center gap-1.5 text-stein-600 hover:text-stein-900"
>
<Icon
icon="lucide:download"
class="size-3.5"
aria-hidden="true"
/>
{t(T.calendar_export_all)}
</button>
{/if}
</div>
{/if}
</div> </div>
{#if modalImage} {#if modalImage}
@@ -1345,10 +1437,6 @@
transparent transparent
); );
} }
/* Event-Rows visuell trennen: Card-Optik mit Urgency-Akzent links,
Zebra-Stripe ab dem zweiten Eintrag, deutlicher Separator
zwischen Tagesgruppen. So lassen sich Termine optisch klar
auseinanderhalten ohne komplette Kacheloptik. */
/* iOS Safari zeigt trotz list-style:none noch den Disclosure-Marker /* iOS Safari zeigt trotz list-style:none noch den Disclosure-Marker
(dunkler Punkt oben-links der Kachel) → explizit ausblenden. */ (dunkler Punkt oben-links der Kachel) → explizit ausblenden. */
.calendar-block summary { .calendar-block summary {
@@ -1357,59 +1445,27 @@
.calendar-block summary::-webkit-details-marker { .calendar-block summary::-webkit-details-marker {
display: none; display: none;
} }
.calendar-block .event-item { /* Event-Karten: eigenständige gerundete Karten mit Abstand statt
position: relative; Zebra + Left-Border. Urgency wandert in die getönte Datums-Kachel
(urgencyTile), hier nur ruhige Card-Kante + Hover. */
.calendar-block .event-card {
list-style: none; list-style: none;
border-left: 4px solid transparent; margin: 8px 12px;
border: 1px solid var(--color-stein-200, #e0dcd5);
border-radius: 10px;
background: var(--color-stein-0, #fff); background: var(--color-stein-0, #fff);
transition: background-color 150ms ease; transition:
border-color 150ms ease,
box-shadow 150ms ease;
} }
/* iOS/WebKit zeigt sonst den <li>-Disc-Marker (dunkler Punkt links der /* iOS/WebKit zeigt sonst den <li>-Disc-Marker (dunkler Punkt links der
Kachel) — list-none am <ul> greift wegen globaler Typo-Regel nicht. */ Kachel) — list-none am <ul> greift wegen globaler Typo-Regel nicht. */
.calendar-block .event-item::marker { .calendar-block .event-card::marker {
content: ""; content: "";
} }
.calendar-block .event-item + .event-item { .calendar-block .event-card:hover {
border-top: 1px solid var(--color-stein-200, #e0dcd5); border-color: var(--color-wald-300, #93d8ac);
} box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.06);
/* Zebra: jeder zweite Termin in einer Tagesgruppe leicht abgedunkelt
— sauber per :nth-of-type, ohne dass Day-Group-Header einen Slot
verbraucht (event-item ist eigene <li>-Sequenz). */
.calendar-block .event-item:nth-of-type(even) {
background: var(--color-stein-50, #f7f5f1);
}
.calendar-block .event-item:hover {
background: var(--color-himmel-50, #eef4f8);
}
/* Urgency-Akzent: 4px farbiger Strich links. Alphabetische
Wichtigkeit: now (rot stark) → today (rot) → tomorrow (warm) →
week (kühl) → later (neutral). */
.calendar-block .event-item[data-urgency="now"] {
border-left-color: var(--color-fire-700, #b91c1c);
background: var(--color-fire-50, #fef2f2);
}
.calendar-block .event-item[data-urgency="today"] {
border-left-color: var(--color-fire-500, #ef4444);
}
.calendar-block .event-item[data-urgency="tomorrow"] {
border-left-color: var(--color-erde-500, #c97a3b);
}
.calendar-block .event-item[data-urgency="week"] {
border-left-color: var(--color-himmel-500, #4798c4);
}
.calendar-block .event-item[data-urgency="later"] {
border-left-color: var(--color-stein-300, #c4bfb4);
}
/* Next-up: kräftigere linke Kante + leichter himmel-Tint, plus
ein subtiler ring damit der Eintrag beim Scroll-into-view
visuell anspringt. */
.calendar-block .event-item.event-item-next {
border-left-width: 6px;
box-shadow: inset 4px 0 0 var(--color-himmel-100, #d8e6ef);
}
/* Day-Group: dickerer Separator + etwas Luft zwischen Gruppen. */
.calendar-block .day-group + .day-group {
border-top: 4px solid var(--color-stein-100, #ede8df);
} }
/* Hover-Tooltip: fixed über dem Grid, zeigt Event-Vorschau auf hover. /* Hover-Tooltip: fixed über dem Grid, zeigt Event-Vorschau auf hover.
pointer-events:none damit kein mouseenter/leave Loop entsteht. */ pointer-events:none damit kein mouseenter/leave Loop entsteht. */
@@ -0,0 +1,44 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import CalendarCompactBlock from './CalendarCompactBlock.svelte';
// Übersetzungen laufen über die `translations`-Prop (nicht Context), s. CalendarBlock.
import translations from './_fixtures/translations.json';
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,
},
translations,
},
};
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { getBlockLayoutClasses } from "$lib/block-layout"; import { getBlockLayoutClasses } from "$lib/block-layout";
import SectionHeader from "$lib/components/SectionHeader.svelte";
import type { CalendarBlockData, CalendarItemData } from "$lib/block-types"; import type { CalendarBlockData, CalendarItemData } from "$lib/block-types";
import { t as tStatic, T } from "$lib/translations"; import { t as tStatic, T } from "$lib/translations";
import type { Translations } from "$lib/translations"; import type { Translations } from "$lib/translations";
@@ -41,7 +42,7 @@
return d.toLocaleDateString("de-DE", { return d.toLocaleDateString("de-DE", {
weekday: "short", weekday: "short",
day: "2-digit", day: "2-digit",
month: "short", month: "long",
}); });
} }
function fmtTime(d: Date): string { function fmtTime(d: Date): string {
@@ -51,6 +52,18 @@
return d.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }); return d.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
} }
/** Titel + optional nachgestellte "(…)"-Klammer separat (Klammer gedimmt wie im Design). */
function splitTitle(raw: string): { main: string; paren: string } {
const m = raw.match(/^(.*?)\s*(\([^()]*\))\s*$/);
if (m) return { main: m[1], paren: m[2] };
return { main: raw, paren: "" };
}
function locationText(it: CalendarItemData): string {
if (!it.location) return "";
return typeof it.location === "string" ? it.location : ((it.location as { text?: string })?.text ?? "");
}
const linkHref = $derived(() => { const linkHref = $derived(() => {
const lt = block.linkTo; const lt = block.linkTo;
if (!lt) return null; if (!lt) return null;
@@ -62,50 +75,58 @@
</script> </script>
<div <div
class="not-prose {layoutClasses} w-full min-w-0 card-surface overflow-hidden rounded-sm border border-stein-200 bg-gradient-to-br from-wald-50/60 via-white to-white" class="not-prose {layoutClasses} w-full min-w-0"
data-block="CalendarCompact" data-block="CalendarCompact"
data-block-type="calendar" data-block-type="calendar"
data-block-variant="compact" data-block-variant="compact"
data-block-slug={block._slug} data-block-slug={block._slug}
> >
<header class="flex items-center gap-2 border-b border-stein-100 px-3 py-2"> <div class="mb-4">
<Icon icon="lucide:calendar" class="size-8 shrink-0 text-stein-900" aria-hidden="true" /> <SectionHeader
<h2 class="text-sm font-semibold leading-none text-stein-800"> number={block.number}
{block.title ?? t(T.calendar_next_events)} kicker={block.kicker}
</h2> title={block.title ?? t(T.calendar_next_events)}
</header> tag="h3"
actionLabel={linkHref() ? t(T.calendar_all_upcoming) : undefined}
actionHref={linkHref() ?? undefined}
/>
</div>
{#if upcoming.length === 0} {#if upcoming.length === 0}
<p class="px-3 py-4 text-center text-xs text-stein-500"> <p class="rounded-lg border border-stein-200 px-3 py-6 text-center text-sm text-stein-500">
{t(T.calendar_no_future_events)} {t(T.calendar_no_future_events)}
</p> </p>
{:else} {:else}
<ul class="divide-y divide-stein-100"> <ul class="divide-y divide-stein-100 overflow-hidden rounded-lg border border-stein-200 bg-white">
{#each upcoming as { it, start } (it._slug ?? it.title)} {#each upcoming as { it, start } (it._slug ?? it.title)}
{@const timeStr = fmtTime(start)} {@const timeStr = fmtTime(start)}
{@const loc = locationText(it)}
{@const title = splitTitle(it.title ?? t(T.calendar_untitled))}
{@const base = linkHref()} {@const base = linkHref()}
{@const itemHref = base && it._slug ? `${base}#event-${it._slug}` : null} {@const itemHref = base && it._slug ? `${base}#event-${it._slug}` : null}
<li> <li>
<svelte:element <svelte:element
this={itemHref ? "a" : "div"} this={itemHref ? "a" : "div"}
href={itemHref ?? undefined} href={itemHref ?? undefined}
class="flex items-start gap-2.5 px-3 py-2 {itemHref ? 'transition-colors hover:bg-wald-50/40' : ''}" class="flex items-start gap-3 px-4 py-3 no-underline {itemHref ? 'transition-colors hover:bg-stein-50' : ''}"
> >
<div class="flex w-11 shrink-0 flex-col items-center rounded-sm bg-wald-50 px-1 py-1 text-wald-700"> <div class="flex w-12 shrink-0 flex-col items-center rounded-sm bg-stein-800 px-1 py-1.5 text-white">
<span class="text-[0.6rem] font-semibold uppercase leading-none"> <span class="text-[0.6rem] font-semibold uppercase leading-none tracking-wide">
{start.toLocaleDateString("de-DE", { month: "short" }).replace(".", "")} {start.toLocaleDateString("de-DE", { month: "short" }).replace(".", "")}
</span> </span>
<span class="text-base font-bold tabular-nums leading-none mt-0.5"> <span class="mt-0.5 text-lg font-bold tabular-nums leading-none">
{start.getDate()} {start.getDate()}
</span> </span>
</div> </div>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="truncate text-xs font-semibold text-stein-800">{it.title ?? t(T.calendar_untitled)}</p> <p class="text-sm font-semibold leading-snug text-stein-900">
<p class="mt-0.5 text-[0.7rem] text-stein-500"> {title.main}{#if title.paren}{" "}<span class="font-normal text-stein-400">{title.paren}</span>{/if}
{fmtDate(start)}{timeStr ? ` · ${timeStr}` : ""} </p>
{#if it.location} <p class="mt-1 flex items-center gap-1.5 text-xs text-stein-500">
<span class="ml-1">· {typeof it.location === "string" ? it.location : (it.location as { text?: string })?.text ?? ""}</span> <Icon icon="lucide:clock" class="size-3.5 shrink-0" aria-hidden="true" />
{/if} <span class="min-w-0 truncate">
{fmtDate(start)}{timeStr ? ` · ${timeStr}` : ""}{loc ? ` · ${loc}` : ""}
</span>
</p> </p>
</div> </div>
</svelte:element> </svelte:element>
@@ -113,14 +134,4 @@
{/each} {/each}
</ul> </ul>
{/if} {/if}
{#if linkHref()}
<a
href={linkHref()}
class="flex items-center justify-center gap-1 border-t border-stein-100 bg-stein-50 px-3 py-1.5 text-xs font-medium text-wald-700 hover:bg-wald-50"
>
{t(T.calendar_all_upcoming)}
<Icon icon="lucide:arrow-right" class="size-3.5" aria-hidden="true" />
</a>
{/if}
</div> </div>
@@ -0,0 +1,77 @@
<script lang="ts">
import { marked } from "$lib/markdown-safe";
import { getBlockLayoutClasses } from "$lib/block-layout";
import SectionHeader from "$lib/components/SectionHeader.svelte";
import InfoCard from "$lib/components/InfoCard.svelte";
import InternalComponentBlock from "./InternalComponentBlock.svelte";
import type { ContactFormBlockData, InfoCardData } from "$lib/block-types";
let { block }: { block: ContactFormBlockData } = $props();
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const actionRef = $derived(
block.action && typeof block.action === "object" ? block.action : null,
);
const hasHeader = $derived(!!block.number || !!block.kicker || !!block.headline);
const asideHtml = $derived(
block.asideText && typeof block.asideText === "string"
? (marked.parse(block.asideText) as string)
: "",
);
const cards = $derived(
(block.asideCards ?? []).filter(
(c): c is InfoCardData => typeof c === "object" && c !== null,
),
);
const formId = $derived(block.form ?? "form-contact");
</script>
<div
class={layoutClasses}
data-block="ContactForm"
data-block-type="contact_form"
data-block-slug={block._slug}
>
{#if hasHeader}
<div class="mb-4">
<SectionHeader
number={block.number}
kicker={block.kicker}
title={block.headline}
actionLabel={actionRef?.linkName}
actionHref={actionRef?.url}
/>
</div>
{/if}
<div class="grid grid-cols-1 gap-8 lg:grid-cols-[1.4fr_1fr] lg:items-start">
<!-- links: Formular -->
<div>
<InternalComponentBlock block={{ _type: "internal_component", component: formId }} />
</div>
<!-- rechts: Aside -->
<aside class="flex flex-col gap-5">
{#if block.asideTitle}
<h3 class="text-xl! font-bold! leading-tight! tracking-[-0.01em] text-stein-800">
{block.asideTitle}
</h3>
{/if}
{#if asideHtml}
<div class="markdown prose prose-stein max-w-none text-stein-500">{@html asideHtml}</div>
{/if}
{#each cards as card}
<InfoCard
icon={card.icon}
title={card.title ?? ""}
description={card.description}
linkLabel={card.linkLabel}
linkHref={card.linkHref}
external={card.external}
tone={card.tone}
compact
/>
{/each}
</aside>
</div>
</div>
@@ -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,
},
},
};
@@ -1,15 +1,26 @@
<script lang="ts"> <script lang="ts">
import { getBlockLayoutClasses } from "$lib/block-layout"; import { getBlockLayoutClasses } from "$lib/block-layout";
import type { DeadlineBannerBlockData } from "$lib/block-types"; import type { DeadlineBannerBlockData } from "$lib/block-types";
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
let { block }: { block: DeadlineBannerBlockData } = $props(); let { block }: { block: DeadlineBannerBlockData } = $props();
const layoutClasses = $derived(getBlockLayoutClasses(block.layout)); const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const showCountdown = $derived(block.showCountdown !== false); const showCountdown = $derived(block.showCountdown !== false);
let dismissed = $state(false); // Look wie AnnouncementBanner (kompakte, zentrierte Leiste) — nur farbig.
// "urgent" = rot, damit die Frist wirkt.
const barClass = $derived.by(() => {
switch (block.variant) {
case "accent":
return "bg-himmel-600";
case "info":
return "bg-wald-600";
case "urgent":
return "bg-fire-600";
default:
return "bg-stein-700";
}
});
type ResolvedItem = { type ResolvedItem = {
title?: string; title?: string;
@@ -102,56 +113,37 @@
const sentence = $derived.by(() => { const sentence = $derived.by(() => {
const parts: string[] = []; const parts: string[] = [];
if (text) parts.push(text); if (text) parts.push(text);
if (dateStr) parts.push(`am ${dateStr}`); // Datum nur im Auto-Modus anhängen — im Manual-Text steht es meist schon.
if (dateStr && block.mode === "auto") parts.push(`am ${dateStr}`);
return parts.join(" "); return parts.join(" ");
}); });
const hasContent = $derived(!!(sentence || countdownStr)); const hasContent = $derived(!!(sentence || countdownStr));
</script> </script>
{#if hasContent && !dismissed} {#if hasContent}
<div <div
class="deadline-banner {layoutClasses}" class="deadline-banner {layoutClasses} w-full {barClass} text-white print:hidden"
data-block="DeadlineBanner" data-block-type="deadline_banner" data-block="DeadlineBanner" data-block-type="deadline_banner"
data-block-slug={block._slug} data-block-slug={block._slug}
role="note"
style="font-size: 0.7rem;"
> >
<div <div class="container-custom flex flex-wrap items-center justify-center gap-x-3 gap-y-1.5 py-2 text-center">
class="gradient-blob-bg bg-gray-50 py-2.5" {#if sentence}
> <span>{sentence}</span>
<div class="container-custom flex items-center gap-x-6"> {/if}
<p class="my-0! flex-1 text-sm/6 text-gray-900"> {#if countdownStr}
{sentence}{#if countdownStr}{sentence ? " · " : ""}<span <span class="whitespace-nowrap rounded-full bg-black/20 px-2 py-0.5 font-bold">{countdownStr}</span>
class="font-semibold">{countdownStr}</span {/if}
>{/if}{#if href} {#if href}
{" "}<a <a
{href} {href}
target={newTab ? "_blank" : undefined} target={newTab ? "_blank" : undefined}
rel={newTab ? "noopener noreferrer" : undefined} rel={newTab ? "noopener noreferrer" : undefined}
class="font-semibold whitespace-nowrap text-gray-900 hover:text-gray-700" class="whitespace-nowrap font-semibold underline underline-offset-2 hover:no-underline"
> >{ctaLabel}</a>
{ctaLabel}&nbsp;<Icon {/if}
icon="lucide:arrow-right"
class="inline-block size-4 align-[-0.15em]"
aria-hidden="true"
/>
</a>
{/if}
</p>
<div class="shrink-0">
<button
type="button"
onclick={() => (dismissed = true)}
class="-m-3 p-3 focus-visible:-outline-offset-4"
>
<span class="sr-only">Schließen</span>
<Icon
icon="lucide:x"
class="size-5 text-gray-900"
aria-hidden="true"
/>
</button>
</div>
</div>
</div> </div>
</div> </div>
{/if} {/if}
@@ -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,45 @@
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 = {
// addon-a11y's Live-Scan crasht in Docs-Mode bei echten fokussierbaren
// <a>-Elementen (react-aria Cross-Iframe-Bug, Storybook 10). Files ist die
// einzige Story mit echten Links → hier deaktivieren.
parameters: { a11y: { test: 'off' } },
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',
description: 'Zum Ausfüllen und Einreichen beim zuständigen Landratsamt',
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',
},
],
},
},
};
+32 -69
View File
@@ -2,6 +2,7 @@
import Icon from "@iconify/svelte"; import Icon from "@iconify/svelte";
import "$lib/iconify-offline"; import "$lib/iconify-offline";
import { getBlockLayoutClasses } from "$lib/block-layout"; import { getBlockLayoutClasses } from "$lib/block-layout";
import SectionHeader from "$lib/components/SectionHeader.svelte";
import type { FilesBlockData } from "$lib/block-types"; import type { FilesBlockData } from "$lib/block-types";
import { import {
extractCmsFileField, extractCmsFileField,
@@ -11,6 +12,9 @@
} from "$lib/rusty-file"; } from "$lib/rusty-file";
let { block }: { block: FilesBlockData } = $props(); let { block }: { block: FilesBlockData } = $props();
const actionRef = $derived(
block.action && typeof block.action === "object" ? block.action : null,
);
const layoutClasses = $derived( const layoutClasses = $derived(
block.layout ? getBlockLayoutClasses(block.layout) : "col-span-12 mb-4", block.layout ? getBlockLayoutClasses(block.layout) : "col-span-12 mb-4",
@@ -21,66 +25,9 @@
label: string; label: string;
description?: string; description?: string;
sizeLabel: string | null; sizeLabel: string | null;
ext: string | null; extLabel: string;
iconName: string;
}; };
function iconForExt(ext: string | null): string {
switch (ext) {
case "pdf":
return "lucide:file-text";
case "doc":
case "docx":
case "rtf":
case "odt":
return "lucide:file-text";
case "xls":
case "xlsx":
case "ods":
case "csv":
case "tsv":
return "lucide:file-spreadsheet";
case "ppt":
case "pptx":
return "lucide:presentation";
case "zip":
case "tar":
case "gz":
case "7z":
return "lucide:folder-archive";
case "jpg":
case "jpeg":
case "png":
case "webp":
case "gif":
case "svg":
return "lucide:file-image";
case "txt":
case "md":
return "lucide:file-text";
case "json":
return "lucide:braces";
case "xml":
case "html":
case "htm":
return "lucide:code";
case "py":
return "mdi:language-python";
case "js":
case "ts":
case "tsx":
case "jsx":
return "mdi:language-javascript";
case "rs":
return "mdi:language-rust";
case "sh":
case "bash":
return "lucide:terminal";
default:
return "lucide:file";
}
}
const resolved = $derived( const resolved = $derived(
((block.items ?? []) as unknown[]) ((block.items ?? []) as unknown[])
.map((raw): Resolved | null => { .map((raw): Resolved | null => {
@@ -98,8 +45,7 @@
label, label,
description: field.description, description: field.description,
sizeLabel: formatFileSize(field.size), sizeLabel: formatFileSize(field.size),
ext, extLabel: ext ? ext.slice(0, 4).toUpperCase() : "FILE",
iconName: iconForExt(ext),
}; };
}) })
.filter((r): r is Resolved => r !== null), .filter((r): r is Resolved => r !== null),
@@ -108,35 +54,52 @@
<div class="files-block {layoutClasses}" data-block="Files" data-block-type="files" data-block-slug={block._slug}> <div class="files-block {layoutClasses}" data-block="Files" data-block-type="files" data-block-slug={block._slug}>
{#if block.headline} {#if block.headline}
<h3 class="mb-3 text-lg font-semibold">{block.headline}</h3> <div class="mb-3">
{/if} <SectionHeader
{#if block.description} 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> <p class="mb-3 text-sm text-stein-700">{block.description}</p>
{/if} {/if}
{#if resolved.length === 0} {#if resolved.length === 0}
<p class="text-sm text-stein-500">Keine Dateien verfügbar.</p> <p class="text-sm text-stein-500">Keine Dateien verfügbar.</p>
{:else} {:else}
<ul class="flex flex-col gap-1 list-none! p-0 m-0! ml-0"> <ul class="flex flex-col gap-3 list-none! p-0 m-0! ml-0">
{#each resolved as f} {#each resolved as f}
<li class="list-none! ml-0"> <li class="list-none! ml-0">
<a <a
href={f.href} href={f.href}
class="group flex items-start gap-2 rounded-xs border border-stein-200 bg-white px-2.5 py-1.5 hover:border-stein-400 hover:bg-stein-50 transition-colors no-underline" class="group card-hover-lift flex items-center gap-3 rounded-xl border border-stein-200 bg-white px-3.5 py-2.5 no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-500 focus-visible:ring-offset-2"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<Icon icon={f.iconName} class="size-5 shrink-0 text-stein-500 mt-0.5" aria-hidden="true" /> <span
class="shrink-0 size-9 flex items-center justify-center rounded-lg bg-fire-50 text-[10px] font-bold tracking-wide text-fire-700"
>{f.extLabel}</span
>
<span class="flex-1 min-w-0 flex flex-col"> <span class="flex-1 min-w-0 flex flex-col">
<span class="flex items-center gap-1.5 min-w-0"> <span class="flex items-baseline gap-1.5 min-w-0">
<span class="text-sm text-stein-900 group-hover:underline truncate">{f.label}</span> <span class="text-sm text-stein-900 truncate">{f.label}</span>
{#if f.sizeLabel} {#if f.sizeLabel}
<span class="shrink-0 text-[.6rem] tabular-nums whitespace-nowrap bg-stein-100 text-stein-500 px-1 py-px rounded-xs">{f.sizeLabel}</span> <span class="shrink-0 text-xs text-stein-400 tabular-nums">({f.sizeLabel})</span>
{/if} {/if}
</span> </span>
{#if f.description} {#if f.description}
<span class="text-xs text-stein-400">{f.description}</span> <span class="text-xs text-stein-400">{f.description}</span>
{/if} {/if}
</span> </span>
<Icon
icon="lucide:download"
class="size-5 shrink-0 text-stein-400 group-hover:text-stein-600 transition-colors"
aria-hidden="true"
/>
</a> </a>
</li> </li>
{/each} {/each}
@@ -0,0 +1,50 @@
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',
},
},
};
// Sobald kicker/action gesetzt → Headline rendert als SectionHeader.
export const AlsSektionskopf: Story = {
args: {
block: {
tag: 'h2',
text: 'Wir sind keine Lobby,\nkeine Partei, keine NGO.',
kicker: 'Über uns',
action: { url: '/about', linkName: 'Mehr über uns' },
},
},
};
+32 -6
View File
@@ -1,10 +1,21 @@
<script lang="ts"> <script lang="ts">
import { getBlockLayoutClasses } from "$lib/block-layout"; import { getBlockLayoutClasses } from "$lib/block-layout";
import SectionHeader from "$lib/components/SectionHeader.svelte";
import type { HeadlineBlockData } from "$lib/block-types"; import type { HeadlineBlockData } from "$lib/block-types";
let { block }: { block: HeadlineBlockData } = $props(); let { block }: { block: HeadlineBlockData } = $props();
const layoutClasses = $derived(getBlockLayoutClasses(block.layout)); const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
// Sektionskopf-Modus: sobald kicker ODER action gesetzt ist, rendert die
// Headline als SectionHeader. Sonst schlichte Text-Headline wie bisher.
const actionRef = $derived(
block.action && typeof block.action === "object" ? block.action : null,
);
const useSection = $derived(!!block.kicker || !!actionRef);
// SectionHeader kennt nur h1h3; h4h6 → h2.
const sectionTag = $derived(
block.tag === "h1" || block.tag === "h2" || block.tag === "h3" ? block.tag : "h2",
);
const alignClass = $derived( const alignClass = $derived(
block.align === "center" block.align === "center"
? "text-center" ? "text-center"
@@ -13,13 +24,28 @@
: "text-left", : "text-left",
); );
const Tag = $derived((block.tag || "h2") as "h1" | "h2" | "h3" | "h4" | "h5" | "h6"); const Tag = $derived((block.tag || "h2") as "h1" | "h2" | "h3" | "h4" | "h5" | "h6");
// Zeilenumbrüche (\n im CMS-Text) → harte <br>. Erlaubt bewusste Umbrüche in
// Headlines, ohne HTML im Text-Feld zuzulassen.
const lines = $derived((block.text ?? "").split("\n"));
</script> </script>
<div class={layoutClasses} data-block="Headline" data-block-type="headline" data-block-slug={block._slug}> <div class={layoutClasses} data-block="Headline" data-block-type="headline" data-block-slug={block._slug}>
<svelte:element {#if useSection}
this={Tag} <SectionHeader
class={alignClass} number={block.number}
> kicker={block.kicker}
{block.text ?? ""} title={block.text ?? ""}
</svelte:element> tag={sectionTag}
actionLabel={actionRef?.linkName}
actionHref={actionRef?.url}
/>
{:else}
<svelte:element
this={Tag}
class={alignClass}
>
{#each lines as line, i}{#if i > 0}<br />{/if}{line}{/each}
</svelte:element>
{/if}
</div> </div>
@@ -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,16 +2,21 @@
import { marked } from "$lib/markdown-safe"; import { marked } from "$lib/markdown-safe";
import { fade } from "svelte/transition"; import { fade } from "svelte/transition";
import { getBlockLayoutClasses } from "$lib/block-layout"; import { getBlockLayoutClasses } from "$lib/block-layout";
import SectionHeader from "$lib/components/SectionHeader.svelte";
import type { ImageGalleryBlockData, ImageGalleryImage } from "$lib/block-types"; import type { ImageGalleryBlockData, ImageGalleryImage } from "$lib/block-types";
import "$lib/iconify-offline"; import "$lib/iconify-offline";
import Icon from "@iconify/svelte"; import Icon from "@iconify/svelte";
import { useTranslate } from "$lib/translations"; import { useTranslate } from "$lib/translations";
import { T } from "$lib/translations"; import { T } from "$lib/translations";
import RustyImage from "$lib/components/RustyImage.svelte"; import RustyImage from "$lib/components/RustyImage.svelte";
import IconButton from "$lib/components/IconButton.svelte";
import { extractCmsImageField } from "$lib/rusty-image"; import { extractCmsImageField } from "$lib/rusty-image";
const t = useTranslate(); const t = useTranslate();
let { block }: { block: ImageGalleryBlockData } = $props(); 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. */ /** Hover-Label aus image-Field — title|alt|caption|description, in dieser Reihenfolge. */
function imageLabel(img: ImageGalleryImage): string { function imageLabel(img: ImageGalleryImage): string {
@@ -166,7 +171,16 @@
<p class="text-sm text-stein-500">{t(T.image_gallery_empty)}</p> <p class="text-sm text-stein-500">{t(T.image_gallery_empty)}</p>
{:else if block.variant === "grid"} {:else if block.variant === "grid"}
{#if block.title} {#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} {/if}
<ul class="not-prose grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4"> <ul class="not-prose grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4">
{#each withUrl as item, i} {#each withUrl as item, i}
@@ -243,31 +257,10 @@
</a> </a>
</div> </div>
{#if withUrl.length > 1} {#if withUrl.length > 1}
<button <IconButton icon="lucide:chevron-left" label={t(T.image_gallery_prev_aria)} variant="surface" size="md" onclick={modalPrev} class="absolute left-2 top-1/2 -translate-y-1/2" />
type="button" <IconButton icon="lucide:chevron-right" label={t(T.image_gallery_next_aria)} variant="surface" size="md" onclick={modalNext} class="absolute right-2 top-1/2 -translate-y-1/2" />
class="absolute left-2 top-1/2 -translate-y-1/2 inline-flex h-9 w-9 items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 focus:outline-none focus:ring-2 focus:ring-white/50"
onclick={modalPrev}
aria-label={t(T.image_gallery_prev_aria)}
>
<Icon icon="lucide:chevron-left" class="text-2xl" aria-hidden="true" />
</button>
<button
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 inline-flex h-9 w-9 items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 focus:outline-none focus:ring-2 focus:ring-white/50"
onclick={modalNext}
aria-label={t(T.image_gallery_next_aria)}
>
<Icon icon="lucide:chevron-right" class="text-2xl" aria-hidden="true" />
</button>
{/if} {/if}
<button <IconButton icon="lucide:x" label={t(T.image_gallery_close)} variant="ghost" size="md" onclick={closeModal} class="absolute -top-2 -right-2" />
type="button"
class="absolute -top-2 -right-2 inline-flex h-8 w-8 items-center justify-center rounded-full bg-white text-stein-900 shadow-md hover:bg-stein-100 focus:outline-none focus:ring-2 focus:ring-white/50"
onclick={closeModal}
aria-label={t(T.image_gallery_close)}
>
<Icon icon="lucide:x" class="text-lg" aria-hidden="true" />
</button>
</div> </div>
</div> </div>
{/if} {/if}
@@ -331,22 +324,8 @@
{/key} {/key}
</div> </div>
<button <IconButton icon="lucide:chevron-left" label={t(T.image_gallery_prev_aria)} variant="surface" size="sm" onclick={prev} class="absolute left-2 top-1/2 -translate-y-1/2 transition-opacity lg:opacity-0 lg:group-hover:opacity-100" />
type="button" <IconButton icon="lucide:chevron-right" label={t(T.image_gallery_next_aria)} variant="surface" size="sm" onclick={next} class="absolute right-2 top-1/2 -translate-y-1/2 transition-opacity lg:opacity-0 lg:group-hover:opacity-100" />
class="absolute left-2 top-1/2 -translate-y-1/2 w-6 h-6 flex items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 transition-opacity focus:outline-none focus:ring-2 focus:ring-wald-500 lg:opacity-0 lg:group-hover:opacity-100"
aria-label={t(T.image_gallery_prev_aria)}
onclick={prev}
>
<Icon icon="lucide:chevron-left" class="text-2xl" aria-hidden="true" />
</button>
<button
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 w-6 h-6 flex items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 transition-opacity focus:outline-none focus:ring-2 focus:ring-wald-500 lg:opacity-0 lg:group-hover:opacity-100"
aria-label={t(T.image_gallery_next_aria)}
onclick={next}
>
<Icon icon="lucide:chevron-right" class="text-2xl" aria-hidden="true" />
</button>
<div class="flex justify-center gap-1.5 mt-2" role="tablist" aria-label={t(T.image_gallery_position_aria)}> <div class="flex justify-center gap-1.5 mt-2" role="tablist" aria-label={t(T.image_gallery_position_aria)}>
{#each withUrl as _, i} {#each withUrl as _, 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) } },
};
@@ -30,7 +30,7 @@
const multiColumn = $derived(block.columns !== "single" && links.length > 4); const multiColumn = $derived(block.columns !== "single" && links.length > 4);
</script> </script>
<div class="link-list {layoutClasses}" data-block="LinkList" data-block-type="link_list" data-block-slug={block._slug}> <div id={block._slug} class="link-list scroll-mt-24 {layoutClasses}" data-block="LinkList" data-block-type="link_list" data-block-slug={block._slug}>
{#if block.headline} {#if block.headline}
<h3 class="mb-3 text-sm font-semibold">{block.headline}</h3> <h3 class="mb-3 text-sm font-semibold">{block.headline}</h3>
{/if} {/if}
@@ -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 } },
};
@@ -4,30 +4,25 @@
import "$lib/iconify-offline"; import "$lib/iconify-offline";
import { getBlockLayoutClasses } from "$lib/block-layout"; import { getBlockLayoutClasses } from "$lib/block-layout";
import type { OrganisationsBlockData, OrganisationEntry } from "$lib/block-types"; import type { OrganisationsBlockData, OrganisationEntry } from "$lib/block-types";
import { extractCmsImageField } from "$lib/rusty-image";
import Card from "$lib/components/Card.svelte"; import Card from "$lib/components/Card.svelte";
import Badge from "$lib/components/Badge.svelte";
import RustyImage from "$lib/components/RustyImage.svelte";
import Button from "$lib/ui/Button.svelte"; import Button from "$lib/ui/Button.svelte";
import SectionHeader from "$lib/components/SectionHeader.svelte";
function organisationLogoField(o: OrganisationEntry) { import SectionGrid from "$lib/components/SectionGrid.svelte";
return extractCmsImageField(o.logo); import OrganisationCard from "$lib/components/OrganisationCard.svelte";
} import IconButton from "$lib/components/IconButton.svelte";
function organisationLogoAlt(o: OrganisationEntry): string {
if (o.logo && typeof o.logo === "object" && o.logo.description) {
return o.logo.description;
}
return o.name ?? "";
}
let { block }: { block: OrganisationsBlockData } = $props(); let { block }: { block: OrganisationsBlockData } = $props();
const actionRef = $derived(
block.action && typeof block.action === "object" ? block.action : null,
);
const layoutClasses = $derived( const layoutClasses = $derived(
block.layout ? getBlockLayoutClasses(block.layout) : "col-span-12 mb-4", block.layout ? getBlockLayoutClasses(block.layout) : "col-span-12 mb-4",
); );
const compact = $derived(block.presentation === "compact"); const compact = $derived(block.presentation === "compact");
const carousel = $derived(block.presentation === "carousel"); const carousel = $derived(block.presentation === "carousel");
// "grid" = SectionGrid + InfoCard-Tiles (Logo im Kreis, Name, Beschreibung).
const grid = $derived(block.presentation === "grid");
const orgs = $derived(block.organisations ?? []); const orgs = $derived(block.organisations ?? []);
const ctaHtml = $derived( const ctaHtml = $derived(
block.addOrganisationMarkdown block.addOrganisationMarkdown
@@ -62,78 +57,6 @@
} }
</script> </script>
{#snippet defaultCard(o: OrganisationEntry, linkUrl: string | undefined, logoField: ReturnType<typeof organisationLogoField>, orgIndex: number)}
<Card href={linkUrl} class="h-full">
<div class="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-sm border border-stein-100 bg-stein-100">
{#if logoField}
<RustyImage
src={logoField.url}
focal={logoField.focal ?? null}
width={96}
aspect="1/1"
fit="contain"
quality={85}
alt={organisationLogoAlt(o)}
class="h-full w-full object-contain p-0.5"
loading="lazy"
/>
{:else}
{@const fishMaskId = `org-logo-fallback-mask-${orgIndex}`}
<svg class="h-8 w-8 text-stein-300" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="currentColor" aria-hidden="true">
<defs>
<mask id={fishMaskId}>
<g fill="none">
<path stroke="#fff" stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="M24 30v14" />
<path fill="#555555" stroke="#fff" stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="M29 23c11 5 16 14 16 14s-12 0-21-8c-9 8-21 8-21 8s5-10 16-14c0-13 5-19 5-19s5 6 5 19" />
<circle cx="24" cy="24" r="2" fill="#fff" />
</g>
</mask>
</defs>
<path fill="currentColor" d="M0 0h48v48H0z" mask={`url(#${fishMaskId})`} />
</svg>
{/if}
</div>
{#if o.badge && typeof o.badge === "object" && o.badge.label}
<div class="absolute top-2 right-2">
<Badge color={o.badge.color}>{o.badge.label}</Badge>
</div>
{/if}
<h5 class="min-w-0 truncate text-sm font-medium leading-tight text-stein-900">{o.name ?? ""}</h5>
{#if o.description}
<p class="line-clamp-2 text-xs text-stein-500">{o.description}</p>
{/if}
{#if o.whatsapp || o.youtube}
<div class="mt-auto flex gap-1.5 pt-1">
{#if o.whatsapp}
<a
href={o.whatsapp}
target="_blank"
rel="noopener noreferrer"
title="WhatsApp"
onclick={(e) => e.stopPropagation()}
class="inline-flex items-center gap-1 rounded-sm border border-stein-200 bg-stein-50 px-2 py-0.5 text-[0.6rem] text-stein-600 no-underline transition-colors hover:border-green-300 hover:bg-green-50 hover:text-green-700"
>
<Icon icon="mdi:whatsapp" class="size-3 shrink-0" />
<span>WhatsApp</span>
</a>
{/if}
{#if o.youtube}
<a
href={o.youtube}
target="_blank"
rel="noopener noreferrer"
title="YouTube"
onclick={(e) => e.stopPropagation()}
class="inline-flex items-center gap-1 rounded-sm border border-stein-200 bg-stein-50 px-2 py-0.5 text-[0.6rem] text-stein-600 no-underline transition-colors hover:border-red-300 hover:bg-red-50 hover:text-red-700"
>
<Icon icon="lucide:youtube" class="size-3 shrink-0" />
<span>YouTube</span>
</a>
{/if}
</div>
{/if}
</Card>
{/snippet}
{#snippet ctaCard()} {#snippet ctaCard()}
<Card <Card
@@ -141,17 +64,20 @@
href={addOrganisationCtaLink?.url} href={addOrganisationCtaLink?.url}
target={addOrganisationCtaLink?.newTab ? "_blank" : undefined} target={addOrganisationCtaLink?.newTab ? "_blank" : undefined}
rel={addOrganisationCtaLink?.newTab ? "noopener noreferrer" : undefined} rel={addOrganisationCtaLink?.newTab ? "noopener noreferrer" : undefined}
class="h-full w-full max-w-full md:max-w-md" class="h-full w-full max-w-full md:flex-row md:items-center md:justify-between md:gap-4"
> >
{#if block.addOrganisationTitle} <div class="flex flex-col gap-1.5 min-w-0">
<h5 class="text-xl font-semibold text-wald-800 leading-snug">{block.addOrganisationTitle}</h5> {#if block.addOrganisationTitle}
{/if} <h5 class="text-xl font-semibold text-wald-800 leading-snug">{block.addOrganisationTitle}</h5>
{#if ctaHtml} {/if}
<div class="markdown text-xs text-wald-700">{@html ctaHtml}</div> {#if ctaHtml}
{/if} <div class="markdown text-xs text-wald-700">{@html ctaHtml}</div>
{/if}
</div>
{#if addOrganisationCtaLink} {#if addOrganisationCtaLink}
<div class="self-start"> <div class="self-start md:self-center md:shrink-0">
<Button variant="primary" size="sm"> <Button variant="primary" size="md">
<Icon icon="lucide:users" class="size-4 shrink-0" aria-hidden="true" />
{addOrganisationCtaLink.linkName} {addOrganisationCtaLink.linkName}
</Button> </Button>
</div> </div>
@@ -160,13 +86,40 @@
{/snippet} {/snippet}
<div class={layoutClasses} data-block="Organisations" data-block-type="organisations" data-block-slug={block._slug}> <div class={layoutClasses} data-block="Organisations" data-block-type="organisations" data-block-slug={block._slug}>
{#if block.headline} {#if block.headline && !grid}
<h3 {#if compact}
class={compact ? "mb-2 text-lg font-semibold text-stein-900" : "mb-4"} <h3 class="mb-2 text-lg font-semibold text-stein-900">{block.headline}</h3>
>{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}
{#if carousel} {#if grid}
<SectionGrid
number={block.number}
kicker={block.kicker}
title={block.headline}
subtitle={block.description}
actionLabel={actionRef?.linkName}
actionHref={actionRef?.url}
columns={3}
>
{#each orgs as org, orgIndex}
{#if typeof org === "object" && org !== null}
<OrganisationCard org={org as OrganisationEntry} index={orgIndex} />
{/if}
{/each}
</SectionGrid>
{:else if carousel}
<div class="relative"> <div class="relative">
<div <div
bind:this={scroller} bind:this={scroller}
@@ -174,40 +127,17 @@
> >
{#each orgs as org, orgIndex} {#each orgs as org, orgIndex}
{#if typeof org === "object" && org !== null} {#if typeof org === "object" && org !== null}
{@const o = org as OrganisationEntry}
{@const linkUrl =
typeof o.link === "object" &&
o.link !== null &&
typeof o.link.url === "string" &&
o.link.url.trim() !== ""
? o.link.url.trim()
: undefined}
{@const logoField = organisationLogoField(o)}
<div <div
class="shrink-0 snap-start w-[calc((100%-0.5rem)/1.5)] sm:w-[calc((100%-1rem)/2)] lg:w-[calc((100%-2rem)/3)]" class="shrink-0 snap-start w-[calc((100%-0.5rem)/1.5)] sm:w-[calc((100%-1rem)/2)] lg:w-[calc((100%-2rem)/3)]"
> >
{@render defaultCard(o, linkUrl, logoField, orgIndex)} <OrganisationCard org={org as OrganisationEntry} index={orgIndex} />
</div> </div>
{/if} {/if}
{/each} {/each}
</div> </div>
{#if orgs.length > 2} {#if orgs.length > 2}
<button <IconButton icon="lucide:chevron-left" label="Vorherige Organisationen" variant="surface" size="sm" onclick={() => scrollByPage(-1)} class="hidden md:flex absolute -left-3 top-1/2 -translate-y-1/2 -translate-x-1/2" />
type="button" <IconButton icon="lucide:chevron-right" label="Weitere Organisationen" variant="surface" size="sm" onclick={() => scrollByPage(1)} class="hidden md:flex absolute -right-3 top-1/2 -translate-y-1/2 translate-x-1/2" />
onclick={() => scrollByPage(-1)}
aria-label="Vorherige Organisationen"
class="hidden md:flex absolute -left-3 top-1/2 -translate-y-1/2 -translate-x-1/2 size-7 items-center justify-center rounded-full bg-white border border-stein-200 shadow-md hover:bg-stein-50 transition"
>
<Icon icon="lucide:chevron-left" class="size-4" aria-hidden="true" />
</button>
<button
type="button"
onclick={() => scrollByPage(1)}
aria-label="Weitere Organisationen"
class="hidden md:flex absolute -right-3 top-1/2 -translate-y-1/2 translate-x-1/2 size-7 items-center justify-center rounded-full bg-white border border-stein-200 shadow-md hover:bg-stein-50 transition"
>
<Icon icon="lucide:chevron-right" class="size-4" aria-hidden="true" />
</button>
{/if} {/if}
</div> </div>
{#if showAddOrganisationCard} {#if showAddOrganisationCard}
@@ -223,56 +153,7 @@
> >
{#each orgs as org, orgIndex} {#each orgs as org, orgIndex}
{#if typeof org === "object" && org !== null} {#if typeof org === "object" && org !== null}
{@const o = org as OrganisationEntry} <OrganisationCard org={org as OrganisationEntry} {compact} index={orgIndex} />
{@const linkUrl =
typeof o.link === "object" &&
o.link !== null &&
typeof o.link.url === "string" &&
o.link.url.trim() !== ""
? o.link.url.trim()
: undefined}
{@const logoField = organisationLogoField(o)}
{#if compact}
<Card href={linkUrl} layout="inline" class="h-full">
<div class="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden bg-stein-100">
{#if logoField}
<RustyImage
src={logoField.url}
focal={logoField.focal ?? null}
width={80}
aspect="1/1"
fit="contain"
quality={85}
alt={organisationLogoAlt(o)}
class="h-full w-full object-contain p-0.5"
loading="lazy"
/>
{:else}
{@const fishMaskId = `org-logo-fallback-mask-c-${orgIndex}`}
<svg class="h-7 w-7 text-stein-300" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="currentColor" aria-hidden="true">
<defs>
<mask id={fishMaskId}>
<g fill="none">
<path stroke="#fff" stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="M24 30v14" />
<path fill="#555555" stroke="#fff" stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="M29 23c11 5 16 14 16 14s-12 0-21-8c-9 8-21 8-21 8s5-10 16-14c0-13 5-19 5-19s5 6 5 19" />
<circle cx="24" cy="24" r="2" fill="#fff" />
</g>
</mask>
</defs>
<path fill="currentColor" d="M0 0h48v48H0z" mask={`url(#${fishMaskId})`} />
</svg>
{/if}
</div>
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<h5 class="text-sm font-medium leading-tight text-stein-900">{o.name ?? ""}</h5>
{#if o.description}
<p class="line-clamp-2 text-xs font-light text-stein-600">{o.description}</p>
{/if}
</div>
</Card>
{:else}
{@render defaultCard(o, linkUrl, logoField, orgIndex)}
{/if}
{/if} {/if}
{/each} {/each}
@@ -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 "leaflet/dist/leaflet.css";
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { getBlockLayoutClasses } from "$lib/block-layout"; import { getBlockLayoutClasses } from "$lib/block-layout";
import SectionHeader from "$lib/components/SectionHeader.svelte";
import type { OrganisationsMapBlockData, OrganisationEntry, ContactEntry } from "$lib/block-types"; import type { OrganisationsMapBlockData, OrganisationEntry, ContactEntry } from "$lib/block-types";
import Icon from "@iconify/svelte"; import Icon from "@iconify/svelte";
import "$lib/iconify-offline"; import "$lib/iconify-offline";
import { marked } from "$lib/markdown-safe"; import { marked } from "$lib/markdown-safe";
import Button from "$lib/ui/Button.svelte";
let { block }: { block: OrganisationsMapBlockData } = $props(); let { block }: { block: OrganisationsMapBlockData } = $props();
const layoutClasses = $derived(getBlockLayoutClasses(block.layout)); const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const actionRef = $derived(
block.action && typeof block.action === "object" ? block.action : null,
);
const orgs = $derived( const orgs = $derived(
(block.organisations ?? []).filter( (block.organisations ?? []).filter(
@@ -127,11 +132,19 @@
<div class="not-prose {layoutClasses}" data-block-type="organisations_map"> <div class="not-prose {layoutClasses}" data-block-type="organisations_map">
{#if block.headline} {#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} {/if}
<div <div
class="relative h-[320px] overflow-hidden rounded-sm border border-stein-200 sm:h-[clamp(400px,65vh,560px)]" class="relative h-[320px] overflow-hidden rounded-lg border border-stein-200 sm:h-[clamp(400px,65vh,560px)]"
> >
<!-- Leaflet map fills the container --> <!-- Leaflet map fills the container -->
<div bind:this={mapEl} class="absolute inset-0"></div> <div bind:this={mapEl} class="absolute inset-0"></div>
@@ -222,15 +235,17 @@
{/if} {/if}
{#if getLink(selectedOrg)} {#if getLink(selectedOrg)}
<a <Button
href={getLink(selectedOrg)} href={getLink(selectedOrg) ?? undefined}
variant="outline"
size="sm"
target="_blank" target="_blank"
rel="noopener noreferrer" 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" /> <Icon icon="lucide:external-link" class="size-3.5" />
{getLinkLabel(selectedOrg)} {getLinkLabel(selectedOrg)}
</a> </Button>
{/if} {/if}
</div> </div>
</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 } },
};
@@ -1,9 +1,11 @@
<script lang="ts"> <script lang="ts">
import { marked } from "$lib/markdown-safe"; import { marked } from "$lib/markdown-safe";
import PostCard from "../PostCard.svelte"; import PostCard from "../PostCard.svelte";
import Icon from "@iconify/svelte"; import PostCardCompact from "../PostCardCompact.svelte";
import "$lib/iconify-offline"; import IconButton from "$lib/components/IconButton.svelte";
import { getBlockLayoutClasses } from "$lib/block-layout"; import { getBlockLayoutClasses } from "$lib/block-layout";
import SectionHeader from "$lib/components/SectionHeader.svelte";
import ArrowLink from "$lib/components/ArrowLink.svelte";
import type { PostOverviewBlockData } from "$lib/block-types"; import type { PostOverviewBlockData } from "$lib/block-types";
import type { PostEntry } from "$lib/cms"; import type { PostEntry } from "$lib/cms";
import { postHref } from "$lib/blog-utils"; import { postHref } from "$lib/blog-utils";
@@ -23,6 +25,11 @@
const design = $derived(block.design || "cards"); const design = $derived(block.design || "cards");
const tagBase = "/posts/tag"; 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(); let scroller: HTMLDivElement | undefined = $state();
function scrollByPage(dir: 1 | -1) { function scrollByPage(dir: 1 | -1) {
if (!scroller) return; if (!scroller) return;
@@ -53,7 +60,16 @@
data-block-slug={block._slug} data-block-slug={block._slug}
> >
{#if block.headline} {#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}
{#if textHtml} {#if textHtml}
<div class="mb-2 markdown max-w-none prose prose-zinc">{@html textHtml}</div> <div class="mb-2 markdown max-w-none prose prose-zinc">{@html textHtml}</div>
@@ -81,6 +97,22 @@
/> />
{/each} {/each}
</div> </div>
{:else if design === "featured" && posts.length > 0}
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:items-start">
<PostCard
post={posts[0]}
href={postHref(posts[0])}
tagBase={tagBase}
commentCount={commentCounts[postSlugForComments(posts[0])] ?? null}
/>
{#if posts.length > 1}
<div class="flex flex-col gap-4">
{#each posts.slice(1) as post}
<PostCardCompact post={post} href={postHref(post)} />
{/each}
</div>
{/if}
</div>
{:else if design === "carousel" && posts.length > 0} {:else if design === "carousel" && posts.length > 0}
<div class="relative"> <div class="relative">
<div <div
@@ -100,32 +132,15 @@
{/each} {/each}
</div> </div>
{#if posts.length > 1} {#if posts.length > 1}
<button <IconButton icon="lucide:chevron-left" label="Zurück" variant="surface" size="sm" onclick={() => scrollByPage(-1)} class="hidden md:flex absolute -left-3 top-1/2 -translate-y-1/2 -translate-x-1/2" />
type="button" <IconButton icon="lucide:chevron-right" label="Weiter" variant="surface" size="sm" onclick={() => scrollByPage(1)} class="hidden md:flex absolute -right-3 top-1/2 -translate-y-1/2 translate-x-1/2" />
onclick={() => scrollByPage(-1)}
aria-label="Zurück"
class="hidden md:flex absolute -left-3 top-1/2 -translate-y-1/2 -translate-x-1/2 size-7 items-center justify-center rounded-full bg-white/90 border border-stein-200 shadow-sm hover:bg-white hover:border-wald-700 transition"
>
<Icon icon="lucide:chevron-left" class="size-4" aria-hidden="true" />
</button>
<button
type="button"
onclick={() => scrollByPage(1)}
aria-label="Weiter"
class="hidden md:flex absolute -right-3 top-1/2 -translate-y-1/2 translate-x-1/2 size-7 items-center justify-center rounded-full bg-white/90 border border-stein-200 shadow-sm hover:bg-white hover:border-wald-700 transition"
>
<Icon icon="lucide:chevron-right" class="size-4" aria-hidden="true" />
</button>
{/if} {/if}
</div> </div>
{/if} {/if}
{#if posts.length > 0} {#if posts.length > 0}
<div class="mt-3 text-xs px-2"> <div class="mt-3 px-2">
<a href="/posts/" class="inline-flex items-center gap-1 text-stein-700 hover:text-stein-900 hover:underline !no-underline"> <ArrowLink href="/posts/" label={t(T.post_overview_all)} tone="stein" size="sm" />
{t(T.post_overview_all)}
<span aria-hidden="true"></span>
</a>
</div> </div>
{/if} {/if}
</div> </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' },
],
},
},
};
@@ -1,46 +1,159 @@
<script lang="ts"> <script lang="ts">
import { getBlockLayoutClasses } from "$lib/block-layout"; import { getBlockLayoutClasses } from "$lib/block-layout";
import type { QuoteBlockData, QuoteCarouselBlockData } from "$lib/block-types"; import type { QuoteCarouselBlockData } from "$lib/block-types";
import QuoteBlock from "./QuoteBlock.svelte"; import { onMount, onDestroy } from "svelte";
import Carousel from "$lib/components/Carousel.svelte"; import IconButton from "$lib/components/IconButton.svelte";
type QuoteItem = { quote?: string; author?: string; variant?: "left" | "right" | "center" }; type QuoteItem = { quote?: string; author?: string };
let { block }: { block: QuoteCarouselBlockData } = $props(); let { block }: { block: QuoteCarouselBlockData } = $props();
const layoutClasses = $derived(getBlockLayoutClasses(block.layout)); const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const quotes = $derived( const quotes = $derived(
((block.quotes ?? []) as unknown[]).filter( ((block.quotes ?? []) as unknown[]).filter(
(q): q is QuoteItem => (q): q is QuoteItem => typeof q === "object" && q !== null && "quote" in q,
typeof q === "object" && q !== null && "quote" in q,
), ),
); );
// Kicker: führende Zahl aus der headline ("04 Stimmen aus der Region") wird
// als Sektionsnummer separiert, Rest = Label.
const kicker = $derived.by(() => {
const h = (block.headline ?? "").trim();
const m = h.match(/^(\d+)\s+(.*)$/);
return m ? { num: m[1], label: m[2] } : { num: "", label: h };
});
// Author "Name, Rolle (Zusatz), Datum" → Hauptzeile + Meta (Zusatz · Datum).
function splitAuthor(a: string | undefined): { main: string; meta: string } {
if (!a) return { main: "", meta: "" };
const m = a.match(/^(.*?)\s*\(([^)]*)\)\s*(.*)$/);
if (m) {
const main = m[1].replace(/,\s*$/, "").trim();
const after = m[3].replace(/^,\s*/, "").trim();
const meta = [m[2].trim(), after].filter(Boolean).join(" · ");
return { main, meta };
}
return { main: a.trim(), meta: "" };
}
const autoRotateMs = $derived( const autoRotateMs = $derived(
block.autoRotate !== false ? Math.max(2, block.intervalSeconds ?? 6) * 1000 : 0, block.autoRotate !== false ? Math.max(2, block.intervalSeconds ?? 6) * 1000 : 0,
); );
let index = $state(0);
let paused = $state(false);
let timer: ReturnType<typeof setInterval> | null = null;
let touchStartX = 0;
function go(i: number) {
const n = quotes.length;
if (n > 0) index = ((i % n) + n) % n;
}
function onTouchStart(e: TouchEvent) {
touchStartX = e.touches[0].clientX;
}
function onTouchEnd(e: TouchEvent) {
const dx = e.changedTouches[0].clientX - touchStartX;
if (Math.abs(dx) < 40) return;
go(index + (dx < 0 ? 1 : -1));
}
function startTimer() {
stopTimer();
if (!autoRotateMs || paused || quotes.length < 2) return;
timer = setInterval(() => go(index + 1), autoRotateMs);
}
function stopTimer() {
if (timer) {
clearInterval(timer);
timer = null;
}
}
$effect(() => {
void autoRotateMs;
void paused;
void quotes.length;
startTimer();
return stopTimer;
});
onMount(startTimer);
onDestroy(stopTimer);
</script> </script>
<div <div
class="quote-carousel {layoutClasses}" class="quote-carousel {layoutClasses}"
data-block="QuoteCarousel" data-block-type="quote_carousel" data-block="QuoteCarousel"
data-block-type="quote_carousel"
data-block-slug={block._slug} data-block-slug={block._slug}
> >
{#if block.headline}
<h3 class="mb-8 text-lg font-semibold">{block.headline}</h3>
{/if}
{#if quotes.length === 0} {#if quotes.length === 0}
<p class="text-sm text-stein-500 italic">Keine Zitate vorhanden.</p> <p class="text-sm italic text-stein-500">Keine Zitate vorhanden.</p>
{:else} {:else}
<Carousel <!-- Full-Bleed dunkelgrüne Sektion -->
count={quotes.length} <div class="relative left-1/2 w-screen -translate-x-1/2 bg-wald-800">
ariaLabel={block.headline ?? "Zitat-Karussell"} <div
{autoRotateMs} class="container-custom py-12 md:py-16"
showArrows={block.showArrows === true} role="region"
> aria-roledescription="carousel"
{#snippet item(i)} aria-label={kicker.label || "Zitate"}
<QuoteBlock block={{ ...(quotes[i] as QuoteBlockData), variant: "left" }} bare extraClass="py-2" /> onmouseenter={() => (paused = true)}
{/snippet} onmouseleave={() => (paused = false)}
</Carousel> >
{#if kicker.label}
<p class="mb-8 flex items-center gap-3 text-xs font-semibold uppercase tracking-[0.14em] text-wald-300">
{#if kicker.num}<span class="tabular-nums">{kicker.num}</span>{/if}
<span class="inline-block h-px w-8 bg-wald-600"></span>
<span>{kicker.label}</span>
</p>
{/if}
<!-- Grid-Stack: alle Slides in derselben Zelle → Höhe = längstes Zitat,
kein Springen des nachfolgenden Contents beim Wechsel. -->
<div class="grid" role="group" ontouchstart={onTouchStart} ontouchend={onTouchEnd}>
{#each quotes as q, i (i)}
{@const a = splitAuthor(q.author)}
<blockquote
class="col-start-1 row-start-1 transition-opacity duration-500 {i === index
? 'opacity-100'
: 'pointer-events-none opacity-0'}"
aria-hidden={i !== index}
>
<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}
<footer class="mt-6 not-italic">
{#if a.main}<div class="text-wald-100">{a.main}</div>{/if}
{#if a.meta}<div class="mt-0.5 text-sm text-wald-300/80">{a.meta}</div>{/if}
</footer>
{/if}
</blockquote>
{/each}
</div>
{#if quotes.length > 1}
<div class="mt-8 flex items-center justify-between gap-4">
<div class="flex items-center gap-1.5" role="tablist">
{#each quotes as _, i (i)}
<button
type="button"
role="tab"
aria-selected={i === index}
aria-label={`Zitat ${i + 1}`}
onclick={() => go(i)}
class="h-1.5 rounded-full transition-all duration-300 {i === index
? 'w-6 bg-wald-300'
: 'w-1.5 bg-wald-600 hover:bg-wald-500'}"
></button>
{/each}
</div>
<div class="flex items-center gap-2">
<IconButton icon="lucide:chevron-left" label="Vorheriges Zitat" variant="surface" size="md" onclick={() => go(index - 1)} />
<IconButton icon="lucide:chevron-right" label="Nächstes Zitat" variant="surface" size="md" onclick={() => go(index + 1)} />
</div>
</div>
{/if}
</div>
</div>
{/if} {/if}
</div> </div>
@@ -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,12 +3,15 @@
import { slide } from "svelte/transition"; import { slide } from "svelte/transition";
import { cubicOut } from "svelte/easing"; import { cubicOut } from "svelte/easing";
import { getBlockLayoutClasses } from "$lib/block-layout"; import { getBlockLayoutClasses } from "$lib/block-layout";
import SectionHeader from "$lib/components/SectionHeader.svelte";
import type { import type {
SearchableTextBlockData, SearchableTextBlockData,
SearchableTextFragment, SearchableTextFragment,
} from "$lib/block-types"; } from "$lib/block-types";
import "$lib/iconify-offline"; import "$lib/iconify-offline";
import Icon from "@iconify/svelte"; import Icon from "@iconify/svelte";
import SearchField from "$lib/ui/SearchField.svelte";
import Badge from "$lib/components/Badge.svelte";
import { t as tStatic, T } from "$lib/translations"; import { t as tStatic, T } from "$lib/translations";
import type { Translations } from "$lib/translations"; import type { Translations } from "$lib/translations";
@@ -21,6 +24,9 @@
class?: string; class?: string;
translations?: Translations | null; translations?: Translations | null;
} = $props(); } = $props();
const actionRef = $derived(
block.action && typeof block.action === "object" ? block.action : null,
);
function t(key: string, replacements?: Record<string, string | number>) { function t(key: string, replacements?: Record<string, string | number>) {
return tStatic(translations ?? null, key, replacements); return tStatic(translations ?? null, key, replacements);
@@ -196,7 +202,13 @@
{#if block.title || descriptionHtml} {#if block.title || descriptionHtml}
<div class="px-4 md:px-6 pt-5 pb-3"> <div class="px-4 md:px-6 pt-5 pb-3">
{#if block.title} {#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}
{#if descriptionHtml} {#if descriptionHtml}
<div class="markdown prose prose-sm max-w-none text-stein-600"> <div class="markdown prose prose-sm max-w-none text-stein-600">
@@ -210,26 +222,12 @@
class="sticky top-14 z-10 px-4 md:px-6 py-3 border-b border-stein-200 bg-white/95 backdrop-blur-sm shadow-sm" class="sticky top-14 z-10 px-4 md:px-6 py-3 border-b border-stein-200 bg-white/95 backdrop-blur-sm shadow-sm"
> >
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="relative max-w-sm flex-1"> <SearchField
<Icon icon="lucide:search" class="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-stein-400 pointer-events-none" aria-hidden="true" /> bind:value={searchQuery}
<input placeholder={t(T.searchable_text_placeholder)}
type="search" ariaLabel={t(T.searchable_text_search_aria)}
placeholder={t(T.searchable_text_placeholder)} class="max-w-sm flex-1"
class="w-full rounded-sm border border-stein-200 bg-white py-1.5 pl-8 pr-8 text-sm text-stein-900 placeholder:text-stein-400 focus:border-wald-400 focus:outline-none focus:ring-1 focus:ring-wald-400" />
bind:value={searchQuery}
aria-label={t(T.searchable_text_search_aria)}
/>
{#if searchQuery}
<button
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 text-stein-400 hover:text-stein-700 transition-colors"
onclick={() => (searchQuery = "")}
aria-label={t(T.searchable_text_clear_search)}
>
<Icon icon="lucide:x" class="size-4" aria-hidden="true" />
</button>
{/if}
</div>
{#if allTags.length > 0} {#if allTags.length > 0}
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
<button <button
@@ -277,7 +275,7 @@
</div> </div>
<button <button
type="button" type="button"
class="text-xs px-3 py-1.5 rounded-xs border border-stein-200 bg-white text-stein-600 hover:bg-wald-50 hover:border-wald-300 hover:text-wald-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-500" class="text-xs px-3 py-1.5 rounded-md border border-stein-200 bg-white text-stein-600 hover:bg-wald-50 hover:border-wald-300 hover:text-wald-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-500"
onclick={() => { searchQuery = ""; selectedTag = "all"; }} onclick={() => { searchQuery = ""; selectedTag = "all"; }}
> >
Filter zurücksetzen Filter zurücksetzen
@@ -329,7 +327,7 @@
{#if fragment.tags.length > 0} {#if fragment.tags.length > 0}
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
{#each fragment.tags as tag} {#each fragment.tags as tag}
<span class="inline-flex items-center whitespace-nowrap rounded-xs border border-stein-200 bg-stein-100 px-1.5 py-0 text-[0.65rem] font-medium text-stein-600">{tag}</span> <Badge>{tag}</Badge>
{/each} {/each}
</div> </div>
{/if} {/if}

Some files were not shown because too many files have changed in this diff Show More