Compare commits

...

86 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
Peter Meier 3908c117df style(header mobile): Menü-Button farbiger hinterlegt (wald-600/25)
Deploy / verify (push) Successful in 1m12s
Deploy / deploy (push) Successful in 1m27s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 15:06:16 +02:00
Peter Meier 5591bd86f0 feat(post): Vorlesen-Button (Web Speech API), nur wenn unterstützt
Deploy / verify (push) Successful in 1m17s
Deploy / deploy (push) Successful in 1m39s
PostActions: 🔊 Vorlesen liest Artikeltext (Titel+Body, versteckte Elemente
gefiltert), Toggle Pause/Weiter + Stop, deutsche Stimme, satzweise, stoppt
bei Seitenwechsel. Button erscheint nur wenn speechSynthesis verfügbar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 09:54:12 +02:00
Peter Meier 85d6314ab4 style(header mobile): Menü kompakter + Such-Overlay an Menü-Look angeglichen
Deploy / verify (push) Successful in 1m18s
Deploy / deploy (push) Successful in 1m42s
- Menü: Hauptpunkte text-sm + py-2.5 (kein Größensprung zu Subpunkten),
  Active nur per Farbe (kein font-bold), CTA-Buttons Ghost + Icons
- Search.svelte: dunkel-frosted Overlay (bg-stein-900/90 + blur), absolute
  statt in-flow, dunkler Input, helle Ergebnisse — konsistent mit dem Menü

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:04:50 +02:00
Peter Meier 7f67f59c8c style(header mobile): mehr Bottom-Platz im Menü, CTA-Buttons rounded-full
Deploy / verify (push) Successful in 1m7s
Deploy / deploy (push) Successful in 1m24s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:55:54 +02:00
Peter Meier 25c1c5f2ed feat(header mobile): Pills wie Desktop, Menü-Overlay optimiert
Deploy / verify (push) Successful in 1m8s
Deploy / deploy (push) Successful in 1m27s
- Suche + Menü als Desktop-Pills (rounded-full, wald-700/8), Icons text-sm,
  Menü mit Label „Menü/Schließen"
- Menü-Overlay: absolute (überlagert statt schiebt), bg-stein-900/90 +
  backdrop-blur-lg, scrollbar (max-h dvh, overscroll-contain, Safe-Area-pb)
- CTA-Block oben: Mitmachen + Termin melden (btn-sm)
- aktive Sektion auto-aufgeklappt, Submenu-Targets py-3
- Logo mobil h-8 (Desktop h-10)
- Header backdrop-blur-md entfernt (entsperrt Kind-backdrop-filter)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:49:59 +02:00
Peter Meier 9115134852 fix(header): mobiles Menü — Safe-Area-Bottom-Padding (iOS letzter Punkt verdeckt)
Deploy / verify (push) Successful in 1m12s
Deploy / deploy (push) Successful in 1m39s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:29:35 +02:00
Peter Meier fe8afdb08e fix(header): mobiles Menü scrollbar (Overlay-Scroll-Container)
Deploy / verify (push) Successful in 1m16s
Deploy / deploy (push) Successful in 1m47s
Body-Lock sperrt Seiten-Scroll; mobile-nav hatte overflow-hidden ohne
Höhe → auf Android nicht scrollbar. Jetzt max-h(100dvh-Header) +
overflow-y-auto + overscroll-contain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:24:21 +02:00
Peter Meier 4d45f56719 refactor(ui): btn-warning Button-Variante + gelb-Token statt inline-style
Deploy / verify (push) Successful in 1m18s
Deploy / deploy (push) Successful in 1m33s
gelb-Palette ins @theme (fixt auch bisher wirkungslose gelb-* Klassen im
Deadline-Banner), .btn-warning als wiederverwendbare Variante; Termin-melden-
Button nutzt sie statt inline-style/arbitrary utilities.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 12:47:44 +02:00
Peter Meier 2c61cc03b2 style(kalender): Termin-melden-Button Gelb als inline-style
Deploy / verify (push) Successful in 1m10s
Deploy / deploy (push) Successful in 1m47s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 12:44:53 +02:00
Peter Meier 514e4425b5 style(kalender): Termin-melden-Button gelb hinterlegt
Deploy / verify (push) Successful in 1m15s
Deploy / deploy (push) Successful in 1m56s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 12:44:14 +02:00
Peter Meier 4f408ee204 feat(termine): „Termin melden"-Link in Kalender + Footer-Sitemap
Deploy / verify (push) Successful in 1m20s
Deploy / deploy (push) Successful in 1m39s
CalendarBlock: Termin-melden-Button in Action-Zeile.
Footer-Sitemap: link-termin-melden (CMS) → /termin-melden.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 12:03:31 +02:00
Peter Meier 8c537a0ae7 feat(stellungnahme): KI-Regeln — Quellen konkret nennen, keine Anlagen-Verweise
Deploy / verify (push) Successful in 1m34s
Deploy / deploy (push) Successful in 1m53s
Regel 6: Quellen/Normen/Aktenzeichen direkt im Fließtext benennen.
Regel 7: keine Verweise auf Anlagen/Anhänge — jeder Beleg muss vollständig
im Dokument selbst stehen (nichts wird beigefügt).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 10:37:19 +02:00
Peter Meier ca4dfad8be feat(termine+matrix): Nutzer-Termin-Einreichung (Draft) + Matrix-Notifications
Deploy / verify (push) Successful in 1m32s
Deploy / deploy (push) Successful in 1m54s
- /termin-melden: öffentliches Formular → /api/termin legt calendar_item als
  Draft an (scoped Write-Key), Admin bestätigt per draft→published
- /api/cms-webhook: empfängt RustyCMS-Webhooks (X-Webhook-Secret) → Matrix
- src/lib/server/matrix.ts: notifyMatrix() Helper
- Form-Proxies (stellungnahme/contact/newsletter/mitmachen) pingen Matrix
- deploy.yml: Matrix/Webhook/Termin-Key Env-Vars

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 10:32:36 +02:00
Peter Meier 5b5fe8789e feat(stellungnahme): Send-Panel — tägliche Bearbeitung + E-Mail-Versand-Hinweis
Deploy / verify (push) Successful in 1m19s
Deploy / deploy (push) Successful in 1m34s
Hinweis dass Einsendungen einmal täglich bearbeitet und die fertige
Stellungnahme per E-Mail zurückgesendet werden; E-Mail als Pflichtfeld
markiert (required + Kennzeichnung), sent-Bestätigung entsprechend angepasst.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 20:43:03 +02:00
Peter Meier cb66442aac fix(stellungnahme): Sende-Status bei Neu-Erzeugen zurücksetzen
Deploy / verify (push) Successful in 1m33s
Deploy / deploy (push) Successful in 1m43s
generateOutput() setzt sendState/sendError auf idle, damit nach erfolgreichem
Senden ein neu erzeugter Brieftext nicht am stale "übermittelt"-Status hängt.
resetAll (Neustart) resettet die Sende-Felder weiterhin.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:21:31 +02:00
Peter Meier 7788e0370e feat(link-list): CMS-Feld columns (auto|single) für Spaltenlayout
Deploy / verify (push) Successful in 1m13s
Deploy / deploy (push) Successful in 1m28s
single = immer untereinander, auto = breitenbasiert mehrspaltig ab >4.
Regionalplan-Generatorenliste (w1-w40) auf single.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:40:30 +02:00
Peter Meier 552446b670 feat(stellungnahme): "An das Team senden" — Prompt landet in RustyCMS
Deploy / verify (push) Successful in 1m16s
Deploy / deploy (push) Successful in 1m37s
- StellingnahmeGeneratorBlock: Senden-Panel im KI-Block (Name, Email,
  Vorranggebiet, generierter Prompt) → /api/stellungnahme, Consent + Honeypot
- neuer Endpoint /api/stellungnahme spiegelt /api/contact (Origin, Honeypot,
  client-age, Validierung) → forwarded an CMS forms-Plugin
- Privacy-Notice: Senden = ausdrückliche Übermittlung

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:31:39 +02:00
Peter Meier d64754c238 feat(search): Multi-Wort + Ranking + Keyboard-Nav; fix LinkList-Spalten
Deploy / verify (push) Successful in 1m18s
Deploy / deploy (push) Successful in 1m33s
- Search: AND-Match über Tokens, Feld-gewichtetes Ranking, ↑↓⏎-Nav,
  Match-Highlight, Trefferzahl + aria-live
- LinkListBlock: breitenbasierte Spalten (auto-fill minmax) statt fixer
  4-Zeilen-Grid — verhinderte Zerbröseln bei vielen langen Labels

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 21:44:34 +02:00
Peter Meier 52929122f5 fix(ui): roter NEU-Badge, separate Nav-Pills, Footer-Headlines + Spalten
Deploy / verify (push) Successful in 1m26s
Deploy / deploy (push) Successful in 1m44s
- AnnouncementBanner: führendes **bold** rendert als roter Pill-Badge
- Header: Kalender/Adressbuch/Suche je eigene Pill statt geteiltem Container
- LinkListBlock: uppercase raus, >4 Links → 4er-Spalten ab sm

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 12:20:24 +02:00
Peter Meier 81c067fa0c fix(newsletter): stack inline footer form on mobile
Deploy / verify (push) Successful in 1m14s
Deploy / deploy (push) Successful in 1m30s
Email field + submit button stacked on mobile (button full width below the
input) and side by side from sm up. The long "Newsletter abonnieren" button
was squeezing the email field on narrow screens.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:38:57 +02:00
Peter Meier d8a6508cde feat(footer): use the grid layout system instead of fixed flex columns
Deploy / verify (push) Successful in 1m18s
Deploy / deploy (push) Successful in 1m28s
Drop the footer's flex override so it renders through the standard 12-col
grid + per-block layout (mobile/tablet/desktop) like pages do. Add optional
column-start (desktopStart/tabletStart) to the layout system so blocks can be
offset in the grid — used to place the inline newsletter form directly under
the "Kontakt & Community" column.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:19:59 +02:00
Peter Meier 686af53be0 feat(newsletter): slim inline newsletter form for footer
Deploy / verify (push) Successful in 1m12s
Deploy / deploy (push) Successful in 1m54s
Add form-newsletter-inline internal component: compact email + button row
with a consent checkbox, dark-themed for the footer. Turnstile loads lazily
on first email focus (footer is on every page). Reuses /api/newsletter and
the newsletter validation. Wired into footer-main next to Kontakt & Community.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:08:27 +02:00
Peter Meier af90483dc4 feat(newsletter): newsletter signup as internal component
Deploy / verify (push) Successful in 1m18s
Deploy / deploy (push) Successful in 1m34s
Wire a newsletter signup form into the CMS internal-component pattern
(form-newsletter). Posts to /api/newsletter which subscribes to listmonk
(double opt-in) and mirrors a best-effort backup record to the RustyCMS
forms plugin. Reuses the contact-form anti-spam stack: Cloudflare
Turnstile, honeypots, client-age check, origin validation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:02:48 +02:00
Peter Meier 0ab2c58eb4 feat(header): icon nav redesign + migrate icons mdi→lucide
Deploy / verify (push) Successful in 1m26s
Deploy / deploy (push) Successful in 2m26s
- Header desktop nav: social icons grouped in a wald-tinted pill with
  labels, active-state (aria-current + bold), opacity hover (no per-item bg)
- Mobile overlay: social links as icon+label rows with active-state
- Search button: lucide:search, icon-only
- Migrate all icons mdi→lucide app-wide (234 refs); brand/language logos
  (google, whatsapp, mastodon, telegram, language-*) stay on mdi
- generate-icons: emit mdi + lucide subsets; add @iconify-json/lucide
- iconify-offline: register lucide collection
- scripts/icon-map|validate|apply: reusable migration tooling

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:44:56 +02:00
Peter Meier e434ee3620 feat(termin): Termin-Detail unter /kalender/termin/<slug>, 302 von alt
Deploy / verify (push) Successful in 1m21s
Deploy / deploy (push) Successful in 1m39s
Route /termin/[slug] -> /kalender/termin/[slug] verschoben, alte URL
302-redirect (QR-Codes/geteilte Links bleiben gueltig). Breadcrumb 3-stufig
(Start > Kalender > Titel). QR-/Detail-/OG-Links auf neuen Pfad.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 10:35:15 +02:00
Peter Meier a628e0e8d1 fix(calendar): <li>-Disc-Marker (Punkt) auf iOS/WebKit entfernen
Deploy / verify (push) Successful in 1m12s
Deploy / deploy (push) Successful in 1m34s
Punkt links der Datums-Kachel war der event-item <li>-Marker, nicht der
summary-Disclosure-Marker. list-none am <ul> wird von globaler Typo-Regel
überschrieben → list-style:none + ::marker{content:""} direkt auf .event-item.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:18:56 +02:00
Peter Meier a0e2015eeb fix(calendar): EventDateBox simpler flex-center Layout (Safari-fest)
Deploy / verify (push) Successful in 1m17s
Deploy / deploy (push) Successful in 1m30s
Grid/calc/leading-Experimente raus. Schlichtes flex justify/items-center
mit innerem text-center-Block (Wochentag/Tag/Monat) — rendert auf iOS
Safari zuverlässig. wald-Farbschema + rounded-br-4xl + shadow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:11:10 +02:00
Peter Meier d484a7d90b fix(calendar): leading-none auf Tag-Zahl — Tailwind text-* default line-height blähte Zeilenbox, JUN an Unterkante
Deploy / verify (push) Successful in 1m11s
Deploy / deploy (push) Successful in 1m30s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:18:26 +02:00
Peter Meier 8a0d3280f9 fix(calendar): EventDateBox neu — feste rem-Größen, kein Range-Overflow
Deploy / verify (push) Successful in 1m11s
Deploy / deploy (push) Successful in 1m55s
EventDateBox von Grund auf neu: <time> mit a11y-Label, feste rem-Schriften
statt fluid/calc-Skalierung, size-16/sm:size-20. Range-Box: gleiche Höhe,
nur breiter; symmetrisches gap → Monat rutscht nicht mehr aus der Box.
Zusätzlich iOS-Safari Disclosure-Marker (dunkler Punkt) in CalendarBlock
ausgeblendet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:14:39 +02:00
Peter Meier 93fcf79f86 fix(calendar): EventDateBox skaliert proportional zur Quadratgröße
Deploy / verify (push) Successful in 1m13s
Deploy / deploy (push) Successful in 1m34s
Feste px-Schriftgrößen + h-20 verursachten auf Mobile Overflow ("Jun"
rutschte raus). Jetzt eine CSS-Var --s (Kantenlänge, responsive clamp);
alle Innenmaße per calc() abgeleitet. Range-Box nur breiter, gleiche
Höhe → identische Schrift wie Einzel-Box.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:55:03 +02:00
Peter Meier ccf500fc62 style: Lead-Absatz schwarz + Kommentar-Empty-State dezenter
Deploy / verify (push) Successful in 1m20s
Deploy / deploy (push) Successful in 1m36s
- app.css: erster Markdown-Absatz im Artikel auf --text-primary (schwarz)
  statt --text-secondary (grau).
- Comments-Empty-State: kleineres helleres Icon (size-5/stein-300), Text
  semibold + stein-300 (gleich hell wie Icon), engerer Icon-Text-Abstand.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:07:46 +02:00
Peter Meier 0b3ddd86e3 feat(termin): Detailseite-Redesign + gedeckeltes Bild
Deploy / verify (push) Successful in 1m20s
Deploy / deploy (push) Successful in 1m37s
- 2-Spalten-Layout (Inhalt auf Lesebreite, sticky Aktions-Card rechts)
- Bild gedeckelt (max-h-440, contained, Hover-Zoom) statt full-width
- Countdown-Badge (Heute/Morgen/in X Tagen/vergangen)
- Aktionen gruppiert: "Zum Kalender" (ICS/Google) + "Teilen" (Link/QR/Bild)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 13:17:27 +02:00
Peter Meier 905c2e4d36 refactor(calendar): EventDateBox-Komponente für Datums-Kachel
Deploy / verify (push) Successful in 1m7s
Deploy / deploy (push) Successful in 1m59s
Einzel- und Mehrtages-Datum in eine dedizierte Komponente ausgelagert.
Beide Varianten gleiche Höhe (h-20); Mehrtag zeigt Wochentag- und Tag-Range
(z. B. "Sa–So" / "27–28") und wächst nur in der Breite. Zentriert gestapelt
(kein Overflow), himmel-Box konsistent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 14:00:54 +02:00
Peter Meier 6925ffee45 feat(termin): vollwertige Detailseite + QR/Detail-Link im Kalender
Deploy / verify (push) Successful in 1m15s
Deploy / deploy (push) Successful in 1m25s
/termin/<slug> ist jetzt echte Seite statt Redirect:
- alle Infos ungekürzt (Datum/Zeit in Europe/Berlin, Ort+Maps, Tags,
  Bild+Lightbox, volle Beschreibung, Anhang, externer Link)
- alle Aktionen: ICS, Google, Link kopieren, Teilen, QR, Social-Bild
- Link zum Kalender drunter (kein Auto-Redirect mehr)
- Breite via Layout-.container-custom (gleich wie andere Seiten)
- SEO: OG/Twitter/canonical zentral aus Layout (kein Doppel-Tag),
  Event-JSON-LD + Breadcrumb für Rich Results / Share-Preview

CalendarBlock: QR-Button + "Details →"-Link je Termin (→ /termin/<slug>/).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:50:09 +02:00
Peter Meier fdc18ab5b8 fix(social-image): Uhrzeit/Datum in Europe/Berlin rendern
Deploy / verify (push) Successful in 1m14s
Deploy / deploy (push) Successful in 2m8s
Social-Image-Generierung lief SSR auf Server-TZ (UTC) → terminZeit
"10:00+02:00" wurde als "08:00" aufs Bild gerendert (−2h im Sommer).
toLocaleDate/TimeString in Kalender- und Post-Route auf timeZone
"Europe/Berlin" gepinnt. Cache-Version v26→v27 invalidiert alte PNGs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:20:01 +02:00
Peter Meier 781869b2b4 fix(footer): Lizenz-/Copyright-Markdown sauber rendern
Deploy / verify (push) Successful in 1m12s
Deploy / deploy (push) Successful in 2m6s
- globalen Grid-Row-Trenner (::before aus app.css) im Flex-Footer abschalten
  → kein Phantom-Flex-Item mehr, Inhalt bündig links
- Trailing-Margins (letzter Block + letztes <p>) auf 0 → kein Leerraum unten
- Markdown-Links unterstreichen (konsistent mit den Link-Listen)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 09:49:21 +02:00
Peter Meier eee25e8275 fix(types): GeoJSON via explizite geojson-Imports
Deploy / verify (push) Successful in 1m12s
Deploy / deploy (push) Successful in 2m9s
Kit-Bump auf 2.65 entfernte transitives @types/geojson aus dem Lockfile —
CI-tsc fand den globalen GeoJSON-Namespace nicht mehr. @types/geojson als
devDependency + explizite Type-Imports statt UMD-Namespace.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:06:25 +02:00
Peter Meier 77ff44f6ef fix(csrf): non-www-Canonical + trustedOrigins für www-Fallback
Deploy / verify (push) Failing after 1m12s
Deploy / deploy (push) Has been skipped
SvelteKits CSRF-Check blockte multipart-POSTs (/api/mitmachen) mit 403:
ORIGIN env stand auf www, Caddy redirected aber www -> non-www — jeder
Besucher war auf non-www, Origin-Header passte nie. Env-Flip auf non-www
(secrets), hier: www als trustedOrigin-Fallback + Kit-Bump auf ^2.65
(trustedOrigins gibt es ab 2.23).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 22:58:28 +02:00
Peter Meier ae9536db3a fix(forms): unsichtbare Unicode-Zeichen in Eingaben strippen + FormCard-Rahmen
Deploy / verify (push) Successful in 1m2s
Deploy / deploy (push) Successful in 1m19s
- cleanText(): Zero-Width-/Bidi-Marks/BOM (Copy-Paste aus iOS/WhatsApp)
  vor Validierung und im Payload entfernen — Telefonnummern mit
  U+202D/U+202C fielen sonst durch die Validierung
- FormCard.svelte: gerahmter Container, umschließt Kontakt- und
  Mitmachen-Formular

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 12:36:47 +02:00
Peter Meier b29dd587b8 feat: Mitmachen-Formular nativ (löst OpnForm ab)
Deploy / verify (push) Successful in 1m8s
Deploy / deploy (push) Successful in 1m17s
- MitmachenFormComponent: Name der BI*, Anliegen*, Ansprechpartner,
  Telefon, E-Mail, Datei-Upload (max 5×5MB, JPG/PNG/WebP/GIF/PDF),
  Consent — Honeypots + Client-Age + Turnstile wie Kontaktformular
- /api/mitmachen: Multipart-Validierung (shared Modul), Forwarding an
  CMS /api/forms/mitmachen/submit-multipart
- internal_component: form-mitmachen registriert
- ~35 neue mitmachen_*-Translation-Keys

Prod braucht BODY_SIZE_LIMIT=30M (adapter-node default 512K) — via
secrets .env.prod deployt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:59:31 +02:00
Peter Meier d9519a867e perf: LCP-Fix, Banner-Crops, SSR-Cache, Bulk-Windkarte
Deploy / verify (push) Successful in 1m3s
Deploy / deploy (push) Successful in 1m47s
- TopBanner: JS-Fade-Gate entfernt (opacity-0 bis Hydration schob LCP
  von ~3s auf ~8.6s) — Bild malt jetzt progressiv über den LQIP
- buildBannerResponsive: Breakpoint-Varianten (1/1, 16/9, 24/9) mit
  media-Attributen, quality 60; CMS braucht explizites h neben ar,
  sonst kein Crop (Mobile bekam 1.92:1 unscharf hochskaliert)
- Layout-Preload: ein Link pro Breakpoint-Variante (WebP)
- Layout-Bootstrap-Batch + Logo-SVG-Fetch TTL-gecacht (liefen pro
  SSR-Request); invalidateCollection versteht CSV-Key-Heads
- WindkarteBlock: 40 Einzel-Fetches -> 1 List-Request mit Filter
- app.html: preconnect cms.pm86.de, api.iconify.design, analytics
- u-url Microformat: echte canonicalUrl statt href="#" (SEO-Audit)
- Icon-Subset: 'cloud' ergänzt (kein Runtime-Fetch an iconify mehr)
- StrommixBlock: nutzloses transition-colors entfernt (CLS-Audit)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 09:50:01 +02:00
Peter Meier 41597f87b5 feat(windkarte): InfoAccordion, anlagen-Hinweis, Tooltip ohne Icons
Deploy / verify (push) Successful in 1m6s
Deploy / deploy (push) Successful in 1m18s
- Disclaimer aus CMS-Feld (hinweis_text) via InfoAccordion statt Hardcode
- info_text-Feld für Block-Fließtext ergänzt
- anlagen_geplant_hinweis-Feld in WindArea + Panel-Anzeige
- Tooltip-Icons entfernt (square-outline/wind-turbine/map-marker inline SVGs)
- Panel: square-outline → ruler-square für Fläche
- Iconify-Subset neu generiert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 12:35:29 +02:00
Peter Meier 1c63f68c0b feat(social): add post image thumbnail to OG social card
Deploy / verify (push) Successful in 1m0s
Deploy / deploy (push) Successful in 1m57s
Ports the same loadPostImage + thumbBlock pattern from the calendar
social route. Reads postImage.src (fallback: image.src), loads via
CMS transform API as base64 JPEG, renders next to QR code.
Posts without an image are unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 09:24:41 +02:00
Peter Meier 08181129c4 feat: Event-Bild im Social-Bild + UX-Verbesserungen
Deploy / verify (push) Successful in 1m3s
Deploy / deploy (push) Successful in 1m23s
Social-Bild Calendar:
- Termin-Bild als Thumbnail neben QR-Code (Reihenfolge: QR | Bild)
- Wrapper transparent, Bild im natürlichen Aspect via object-fit:contain
  und fit=inside auf transform-API — kein Crop, kein Letterbox
- Wachstum auf qrSize+28 begrenzt
- Title-Fontsize bei vorhandenem Thumb zusätzlich verkleinert
- Eckige Kanten (kein border-radius) für QR-Box und Thumb

Calendar-Block:
- Vorschaubild im Termin-Item gleich gerendert: object-contain,
  max-w-24/max-h-24, kein Crop
- "Teilen"-Gruppe in Aktions-Toolbar bricht auf Mobile auf eigene
  Zeile um, Trennstrich nur auf sm+ sichtbar

Direkt-Link:
- Calendar: /kalender/?social=<slug> öffnet Modal automatisch
- Post: /post/<slug>?social=1 öffnet Modal automatisch
- Modal-Header: "Direktlink"-Button kopiert URL ins Clipboard

Skeleton-Loader:
- Animierter grauer Skeleton während Bild-Render via satori
- Per Theme-Wechsel resetten, fade-in via opacity-Transition
- Story-Container Fix: explizite 280x158 Maße statt aspect-ratio

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 13:35:38 +02:00
Peter Meier c225fb8be5 feat: Daten-Disclaimer Accordion oberhalb der Windkarte
Deploy / verify (push) Successful in 1m2s
Deploy / deploy (push) Successful in 1m18s
Erklärt warum Anlagenzahl/Höhe-Schätzwerte entfernt wurden. Default
geschlossen, Chevron-Pfeil als Indikator zum Aufklappen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 08:51:00 +02:00
Peter Meier 4f0ec5ce21 refactor: Anlagen-Zahl + Max.-Höhe komplett aus UI entfernt
Deploy / verify (push) Successful in 1m5s
Deploy / deploy (push) Successful in 1m17s
Beide Felder waren nicht belastbar (Schätzungen bzw. nicht final
festgelegt). Entfernt aus:
- Windkarte-Hover-Tooltip
- WindAreaPanel (Kenndaten + Export-Info)
- StellingnahmeGenerator-Steckbrief + Disclaimer-Footnote

Übrig: Fläche, Investor, Status. Genaue Anlagenzahl wird erst im
Genehmigungsverfahren bestimmt — keine vorausgreifende Vermittlung.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 08:39:35 +02:00
Peter Meier f90116a923 fix: Windkarten-Tooltip + Anlagenzahl-Disclaimer überarbeitet
Deploy / verify (push) Successful in 1m4s
Deploy / deploy (push) Successful in 1m25s
Windkarte-Tooltip:
- Auf Touch-Geräten (@media hover:none) ausgeblendet, Detail-Panel
  öffnet eh bei Tap
- max-width 360px, min-width 220px, white-space:normal,
  größeres padding/Schrift für bessere Lesbarkeit
- Anlagen-Zahl aus Tooltip entfernt (nicht belastbar)
- Gemeinden ungekürzt anzeigen, Truncate auf 3 entfernt

WindAreaPanel:
- "Anlagen geplant" nach unten verschoben (nach Investor), Zeile mit
  opacity-60 + italic + mute-Farbe — zeigt visuell dass es nur eine
  Schätzung ist
- Disclaimer-Text überarbeitet: Obergrenzen-Schätzung 2-3 Anlagen je
  100 ha (270-Meter-Klasse) statt Faustregel 4-6

StellingnahmeGenerator: gleicher Disclaimer-Text-Wortlaut.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 08:15:06 +02:00
Peter Meier 21b555b61d fix: Calendar-Toolbar bricht Mobile-Wrap-Layout pro Gruppe sauber um
Deploy / verify (push) Successful in 1m0s
Deploy / deploy (push) Successful in 1m28s
Label und Buttons jeder Gruppe (Kalender / Teilen / Anzeigen) in
eigene inline-flex-Container gekapselt. Umbruch passiert nur zwischen
Gruppen, Labels werden nicht mehr von ihren Buttons getrennt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 13:23:41 +02:00
Peter Meier 155d9fb4b0 feat: Social-Bild-Generator auch für Posts (Endpoint, Modal, PostActions-Button)
Deploy / verify (push) Successful in 1m4s
Deploy / deploy (push) Successful in 1m23s
Neuer Endpoint /api/social/post/[slug] mit gleichem Pattern wie für
Termine: Logo, Tag-Pill aus Title/Tags-Keywords (Presse, Demo,
Rückblick, Interview, Studie, Rechtlich, Meinung etc.), Datum,
Headline mit dynamischer Fontsize, Subheadline/Excerpt-Snippet, QR
zum Beitrag, Footer. 3 Formate × 3 Themes wie Kalender.

SocialImageModal in eigene wiederverwendbare Komponente extrahiert
und sowohl im PostActions als auch im CalendarBlock eingebunden.
PostActions akzeptiert socialSlug-Prop und blendet bei vorhandenem
Slug einen Modal-Trigger-Button neben den anderen Aktions-Icons ein.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 13:14:14 +02:00
Peter Meier 287a44ff6b feat: Social-Bild erweitert (Logo, Status-Pill, Description, Story, Themes) + OG-Route + Calendar-Buttons aufgeräumt
Deploy / verify (push) Successful in 1m0s
Deploy / deploy (push) Successful in 1m19s
Social-Bild-Endpoint:
- Logo aus page_config (gefärbt in Theme-Akzent) statt Wordmark
- Status-Pill (ONLINE/Demo/Info/Anhörung/Stammtisch/Sitzung) aus
  Titel-Keywords und Tags, mit eigener Farbe
- Description-Snippet unter Titel (Square + Story)
- Story-Format 1080x1920 ergänzt
- Theme-Varianten: wald (default), transparent-onlight,
  transparent-ondark
- Uhrzeit unter Datum (2. Zeile) mit Clock-Icon im Akzent-Grün
- QR linksbündig bei Square + Story, kleiner (180px)
- Location-Pin top-aligned, Title-Fontsize dynamisch bei langen Titeln

Neue Route /termin/[slug]: OG-Meta-Tags für Link-Previews
(FB/WhatsApp/Twitter), JS-Redirect zu /kalender/#event-<slug>.
QR-Code im Social-Bild zeigt jetzt auf /termin/[slug].

Calendar-Block Aktionen reorganisiert:
- Primary-Reihe: Mehr Info, Auf Karte, Anhang
- Kompakte Toolbar (gerahmt): Kalender (ICS, Google) | Teilen (Link,
  Share, Bild) | Im Grid
- Theme-Selector im Social-Modal mit Vorschau-Hintergrund-Indikator

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 13:02:47 +02:00
Peter Meier 6d1a084973 refactor: Stellingnahme-Generator UX-Überarbeitung Step 5
Deploy / verify (push) Successful in 1m4s
Deploy / deploy (push) Successful in 1m15s
- Sticky farb-hinterlegter Stepper (Sprung zwischen Schritten klarer)
- Step 5 reorganisiert: Setup oben, "═══ Ergebnis ═══" Trenner,
  Schreibvorlage- und KI-Cards primär sichtbar, Feintuning
  (Vorschau, Argument-Reihenfolge, Einleitung, Abschluss) in
  ein collapsed "Brief im Detail anpassen"-Accordion zusammengelegt
- "Mit Platzhaltern füllen"-Button für Absender-Felder
- KI-Block: nummerierte 1-2-3-4-Anleitung, "In Zwischenablage
  kopieren"-Button + .txt-Download, editierbare Inhalts-Regeln
- KI-Prompt nachgeschärft: konkrete Rechtsgrundlagen, klare Anti-
  KI-Floskel-Liste, expliziter Hinweis "Entwurf-Banner entfernen",
  Verbot von em/en-dashes als Satz-Trenner
- buildText post-processed em/en-dashes zu Kommas
- UI-Texte: em/en-dashes durch Kommas ersetzt
- "Anlagen geplant"-Disclaimer (Schätzung ~4-6 WEA/100ha) in
  Stellingnahme-Card und WindAreaPanel ergänzt
- Reset setzt Step + Scroll zurück, History bleibt erhalten

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 11:02:48 +02:00
Peter Meier ace3562111 feat: Social-Bild-Modal mit QR-Code für Kalender-Termine
Deploy / verify (push) Successful in 1m7s
Deploy / deploy (push) Successful in 1m39s
Single Button öffnet Vorschau-Modal mit beiden Formaten side-by-side.
Pro Format Download-Button. ESC + Click-outside schließen.

Endpoint generiert zusätzlich QR-Code (qrcode → PNG-Buffer), der
direkt auf den Termin im Kalender-Grid verweist
(/kalender/#event-<slug>). Pin-Icon, QR und Layout für OG (1200x630)
und Square (1080x1080).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 10:25:07 +02:00
Peter Meier f0d9fecfb8 feat: Social-Media-Bild-Generator für Kalender-Termine
Neuer Endpoint /api/social/calendar/[slug] rendert Termin via satori +
resvg zu PNG. Formate: og (1200x630) und square (1080x1080).
Gebrandetes Layout mit Datum, Titel, Ort. Inter-Font lazy von gstatic,
10min In-Memory-Cache pro Slug+Format.

CalendarBlock: zwei Download-Buttons pro Termin (Bild quer / quadr.).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 09:28:00 +02:00
Peter Meier d921b2758b refactor: AI-Prompt für Stellungnahme nachgeschärft
Klare Rolle, zwingende Regeln (Argumente erhalten, keine Erfindungen,
Briefkopf-Struktur, ≥70 % neu formuliert), explizites Ausgabeformat.
Kontext zum Windvorranggebiet und persönliche Betroffenheit prominent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 09:22:06 +02:00
Peter Meier 415e772e57 fix: Windkarte verbirgt Labels ausgeblendeter Status
Per-Status LayerGroups statt einer globalen labelLayer. syncLabels
respektiert hiddenStatuses und entfernt Labels für versteckte
Status-Kategorien beim Zoomen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 09:22:00 +02:00
207 changed files with 14744 additions and 2216 deletions
+5
View File
@@ -91,6 +91,11 @@ jobs:
PUBLIC_UMAMI_WEBSITE_ID=${{ secrets.PUBLIC_UMAMI_WEBSITE_ID }}
PUBLIC_TURNSTILE_SITE_KEY=${{ secrets.PUBLIC_TURNSTILE_SITE_KEY }}
TURNSTILE_SECRET=${{ secrets.TURNSTILE_SECRET }}
MATRIX_NOTIFY_URL=${{ secrets.MATRIX_NOTIFY_URL }}
MATRIX_NOTIFY_TOKEN=${{ secrets.MATRIX_NOTIFY_TOKEN }}
MATRIX_NOTIFY_ROOM=${{ secrets.MATRIX_NOTIFY_ROOM }}
CMS_WEBHOOK_SECRET=${{ secrets.CMS_WEBHOOK_SECRET }}
RUSTYCMS_TERMIN_API_KEY=${{ secrets.RUSTYCMS_TERMIN_API_KEY }}
ENVEOF
- name: Pull and restart container
+3
View File
@@ -19,3 +19,6 @@ dist/
# Logs
*.log
npm-debug.log*
# Storybook
storybook-static/
@@ -0,0 +1,14 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import TranslationProvider from '$lib/components/TranslationProvider.svelte';
import strings from '$lib/components/blocks/_fixtures/translations.json';
// Storybook reicht die Story als `children`-Snippet rein. Wir setzen den
// Translation-Context (echte deutsche Strings aus dem CMS-Bundle), damit
// Blocks lesbare Texte statt roher Keys zeigen.
let { children }: { children: Snippet } = $props();
</script>
<TranslationProvider translations={strings}>
{@render children()}
</TranslationProvider>
+40
View File
@@ -0,0 +1,40 @@
import type { StorybookConfig } from '@storybook/sveltekit';
import tailwindcss from '@tailwindcss/vite';
const config: StorybookConfig = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|ts|svelte)'],
addons: [
'@storybook/addon-svelte-csf',
'@storybook/addon-a11y',
'@storybook/addon-docs',
],
framework: '@storybook/sveltekit',
// MSW-Service-Worker liegt in static/ (via `npx msw init static/`). Storybook
// muss static/ ausliefern, damit /mockServiceWorker.js erreichbar ist.
staticDirs: ['../static'],
// Tailwind v4 läuft als Vite-Plugin. Das sveltekit-Framework bringt nur die
// Svelte/SvelteKit-Integration mit ($app/$env/$lib-Mocks), nicht Tailwind —
// darum hier explizit dazuhängen, sonst greifen keine Utility-Klassen/Tokens.
viteFinal: async (viteConfig) => {
viteConfig.plugins = viteConfig.plugins ?? [];
viteConfig.plugins.push(tailwindcss());
// Same-origin-Routen, die im echten Frontend SvelteKit-Server-Endpoints
// sind (in Storybook nicht existieren), auf die Live-Site proxien. Damit
// rendern RustyImage-Bilder (/cms-images, /cms-files) und die Live-Daten-
// Blocks (/api/strommix, /api/stellungnahme) mit echten Daten — ohne
// Fixtures. Deterministische Offline-Mocks stattdessen via MSW (siehe
// .storybook/README oder Kommentar unten).
const PROXY_TARGET = 'https://windwiderstand.de';
viteConfig.server = viteConfig.server ?? {};
viteConfig.server.proxy = {
...(viteConfig.server.proxy ?? {}),
'/cms-images': { target: PROXY_TARGET, changeOrigin: true },
'/cms-files': { target: PROXY_TARGET, changeOrigin: true },
'/api/strommix': { target: PROXY_TARGET, changeOrigin: true },
'/api/stellungnahme': { target: PROXY_TARGET, changeOrigin: true },
};
return viteConfig;
},
};
export default config;
+13
View File
@@ -0,0 +1,13 @@
<!--
SvelteKit liest $env/dynamic/public im Browser aus globalThis.__sveltekit_dev.env.
Dieses Global setzt sonst nur der echte SvelteKit-Dev-Server. In Storybook hier
vorab setzen, sonst crasht jede Komponente mit `env.PUBLIC_*` (Windkarte,
Stellingnahme, …) mit "__sveltekit_dev is undefined". Läuft vor dem Story-Bundle.
-->
<script>
window.__sveltekit_dev = window.__sveltekit_dev || { env: {} };
window.__sveltekit_dev.env = Object.assign(
{ PUBLIC_CMS_URL: 'https://cms.pm86.de' },
window.__sveltekit_dev.env,
);
</script>
+54
View File
@@ -0,0 +1,54 @@
import type { Preview } from '@storybook/sveltekit';
import { initialize, mswLoader } from 'msw-storybook-addon';
import WithTranslations from './decorators/WithTranslations.svelte';
// Design-System-Styles: Fonts, Tailwind, Farbtokens (wald/erde/…). Zieht die
// echte app.css des Frontends → Stories sehen aus wie live.
import '../src/app.css';
// MSW starten. `onUnhandledRequest: 'bypass'` → Requests ohne Handler laufen
// normal durch (z.B. der Vite-Proxy für /cms-images). Nur explizit gemockte
// Endpoints werden abgefangen → Blocks mit fetch entwickeln sich deterministisch
// offline, per Story via `parameters.msw.handlers`.
initialize({ onUnhandledRequest: 'bypass' });
// Default-Mock für den SvelteKit-page-Store/-State ($app/stores + $app/state).
// Ohne das crasht jede Komponente, die $page.url liest (Header, PostActions, …).
// Einzelne Stories können via parameters.sveltekit_experimental überschreiben.
const mockPage = {
url: new URL('http://localhost:6006/'),
params: {},
route: { id: null },
status: 200,
error: null,
data: {},
form: null,
state: {},
};
const preview: Preview = {
loaders: [mswLoader],
// Alle Stories in den Translation-Context wrappen → echte Texte statt Keys.
decorators: [() => WithTranslations],
parameters: {
sveltekit_experimental: {
stores: { page: mockPage },
state: { page: mockPage },
},
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
// Frontend rendert auf hellem Grund; Storybook-Canvas dazu passend.
backgrounds: {
default: 'weiss',
values: [
{ name: 'weiss', value: '#ffffff' },
{ name: 'stein', value: '#f5f5f4' },
],
},
},
};
export default preview;
+1 -1
View File
@@ -36,6 +36,6 @@ EXPOSE 3001
ENV PORT=3001
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"]
+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`.
+2912 -19
View File
File diff suppressed because it is too large Load Diff
+17 -3
View File
@@ -17,19 +17,30 @@
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"prepare": "svelte-kit sync || true"
"prepare": "svelte-kit sync || true",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"devDependencies": {
"@iconify-json/lucide": "^1.2.114",
"@iconify-json/mdi": "^1.2.3",
"@storybook/addon-a11y": "^10.5.0",
"@storybook/addon-docs": "^10.5.0",
"@storybook/addon-svelte-csf": "^5.1.2",
"@storybook/sveltekit": "^10.5.0",
"@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/kit": "^2.21.4",
"@sveltejs/kit": "^2.65.0",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/vite": "^4.1.18",
"@types/geojson": "^7946.0.16",
"@types/node": "^22.10.0",
"@types/qrcode": "^1.5.6",
"@types/sanitize-html": "^2.13.0",
"@vitest/coverage-v8": "^2.1.9",
"msw": "^2.15.0",
"msw-storybook-addon": "^2.0.7",
"openapi-typescript": "^7.4.0",
"storybook": "^10.5.0",
"svelte": "^5.28.2",
"svelte-check": "^4.1.4",
"tailwindcss": "^4.1.18",
@@ -38,10 +49,11 @@
"vitest": "^2.1.9"
},
"dependencies": {
"@fontsource-variable/aleo": "^5.2.7",
"@fontsource-variable/lora": "^5.2.8",
"@fontsource-variable/rubik": "^5.2.8",
"@fontsource/inter": "^5.2.8",
"@iconify/svelte": "^5.2.1",
"@resvg/resvg-js": "^2.6.2",
"@turf/buffer": "^7.3.5",
"@types/leaflet": "^1.9.21",
"blurhash": "^2.0.5",
@@ -50,6 +62,8 @@
"marked": "^17.0.2",
"qrcode": "^1.5.4",
"sanitize-html": "^2.13.1",
"satori": "^0.26.0",
"satori-html": "^0.3.2",
"sharp": "^0.34.5"
},
"type": "module"
+82 -54
View File
@@ -19,8 +19,6 @@ import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const SRC_DIR = path.join(ROOT, 'src');
const ICONS_JSON = path.join(ROOT, 'node_modules', '@iconify-json', 'mdi', 'icons.json');
const OUT_FILE = path.join(ROOT, 'src', 'lib', 'iconify-mdi-subset.generated.json');
/**
* Icons that appear via dynamic bindings (CMS data, conditional expressions)
@@ -28,49 +26,70 @@ const OUT_FILE = path.join(ROOT, 'src', 'lib', 'iconify-mdi-subset.generated.jso
* pick icons that appear blank — better than shipping the full collection.
*/
const EXTRA_ICONS = [
// PostActions: dynamic share/copy state
'share-variant-outline',
'link-variant',
// Search: file-type fallback
'file-outline',
// Common social icons editors pick (Header.svelte: social.icon)
// Brand/Logo-Icons ohne Lucide-Pendant (Editors picken sie als social.icon)
'google',
'whatsapp',
'mastodon',
'telegram',
// Sprach-Logos (CodeBlock: dynamische language->icon-Map, kein literal icon="")
'language-javascript',
'language-python',
'language-rust',
];
/**
* Lucide-Icons aus CMS-Daten (Header social.icon, FilesBlock file types, …) +
* dynamische Bindings, die der statische Scan nicht findet.
*/
const EXTRA_LUCIDE = [
// Nav / Header social.icon (CMS)
'calendar-1',
'contact-round',
'search',
'x',
'cloud',
'mail',
'facebook',
'youtube',
'map',
'video',
'instagram',
'twitter',
'youtube',
'linkedin',
'mastodon',
'github',
'email',
'email-outline',
'phone',
'whatsapp',
'telegram',
'import-contacts',
'arrow-top-right',
'open-in-new',
'card-account-details-outline',
'qrcode',
'sort-alphabetical-ascending',
'account-group-outline',
// Common file-type icons (FilesBlock: f.iconName)
'file-pdf-box',
'file-word-box',
'file-excel-box',
// PostActions / Links
'share-2',
'link',
'external-link',
'arrow-up-right',
// Kontakt/Verzeichnis
'id-card',
'qr-code',
'arrow-down-a-z',
'users',
// File-type icons (FilesBlock: dynamic f.iconName)
'file',
'file-text',
'file-spreadsheet',
'file-image',
'file-video',
'file-music',
// Tag/link/banner fallbacks (LinkListBlock, Tags, DeadlineBannerBlock)
'link',
'tag-outline',
'flag-outline',
'information-outline',
// OrganisationsMapBlock: activate-overlay
'cursor-default-click-outline',
'file-down',
'presentation',
// Tag/flag/info/format fallbacks
'tag',
'flag',
'info',
'type',
'image',
// Windkarte / Map / Generator
'fan',
'mouse-pointer-click',
'sliders-horizontal',
'badge-check',
];
const STATIC_PATTERN = /icon="mdi:([a-z0-9-]+)"/g;
function walk(dir) {
const out = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
@@ -85,27 +104,28 @@ function walk(dir) {
return out;
}
function scanStatic() {
function scanStatic(prefix) {
const pattern = new RegExp(`icon="${prefix}:([a-z0-9-]+)"`, 'g');
const found = new Set();
for (const file of walk(SRC_DIR)) {
const content = fs.readFileSync(file, 'utf8');
let m;
while ((m = STATIC_PATTERN.exec(content)) !== null) {
while ((m = pattern.exec(content)) !== null) {
found.add(m[1]);
}
}
return found;
}
function main() {
if (!fs.existsSync(ICONS_JSON)) {
console.error(
`[generate-icons] missing ${ICONS_JSON} — run \`npm install\` first.`,
);
/** Build a tree-shaken subset for one Iconify collection. */
function buildSubset({ prefix, pkg, outFile, extra }) {
const iconsJson = path.join(ROOT, 'node_modules', '@iconify-json', pkg, 'icons.json');
if (!fs.existsSync(iconsJson)) {
console.error(`[generate-icons] missing ${iconsJson} — run \`npm install\` first.`);
process.exit(1);
}
const collection = JSON.parse(fs.readFileSync(ICONS_JSON, 'utf8'));
const wanted = new Set([...scanStatic(), ...EXTRA_ICONS]);
const collection = JSON.parse(fs.readFileSync(iconsJson, 'utf8'));
const wanted = new Set([...scanStatic(prefix), ...extra]);
const icons = {};
const aliases = {};
@@ -132,22 +152,30 @@ function main() {
icons,
...(Object.keys(aliases).length ? { aliases } : {}),
};
fs.writeFileSync(outFile, JSON.stringify(subset, null, 2) + '\n');
fs.writeFileSync(OUT_FILE, JSON.stringify(subset, null, 2) + '\n');
const sizeBefore = fs.statSync(ICONS_JSON).size;
const sizeAfter = fs.statSync(OUT_FILE).size;
const sizeAfter = fs.statSync(outFile).size;
console.log(
`[generate-icons] ${Object.keys(icons).length} icons + ${Object.keys(aliases).length} aliases → ${path.relative(ROOT, OUT_FILE)}`,
);
console.log(
`[generate-icons] ${(sizeBefore / 1024 / 1024).toFixed(2)} MB → ${(sizeAfter / 1024).toFixed(1)} KB (${((1 - sizeAfter / sizeBefore) * 100).toFixed(1)}% smaller)`,
`[generate-icons] ${prefix}: ${Object.keys(icons).length} icons + ${Object.keys(aliases).length} aliases → ${path.relative(ROOT, outFile)} (${(sizeAfter / 1024).toFixed(1)} KB)`,
);
if (missing.length) {
console.warn(
`[generate-icons] ${missing.length} icon(s) not found in MDI: ${missing.join(', ')}`,
);
console.warn(`[generate-icons] ${prefix}: ${missing.length} not found: ${missing.join(', ')}`);
}
}
function main() {
buildSubset({
prefix: 'mdi',
pkg: 'mdi',
outFile: path.join(ROOT, 'src', 'lib', 'iconify-mdi-subset.generated.json'),
extra: EXTRA_ICONS,
});
buildSubset({
prefix: 'lucide',
pkg: 'lucide',
outFile: path.join(ROOT, 'src', 'lib', 'iconify-lucide-subset.generated.json'),
extra: EXTRA_LUCIDE,
});
}
main();
+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");
+55
View File
@@ -0,0 +1,55 @@
// Rewrite `mdi:NAME` -> `lucide:MAPPED` across frontend src + CMS content.
// KEEP_MDI names are left untouched (brand/language logos with no lucide pendant).
import { MAP, KEEP_MDI } from './icon-map.mjs';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const WEB_ROOT = path.resolve(ROOT, '..');
const TARGETS = [
{ dir: path.join(ROOT, 'src'), exts: /\.(svelte|ts|tsx|js|mjs)$/ },
{ dir: path.join(WEB_ROOT, 'cms_content', 'windwiderstand'), exts: /\.json5$/ },
];
const SKIP_DIR = /(node_modules|\.history|\.search|_legacy|\.svelte-kit)/;
const stats = { files: 0, repl: 0, kept: {} };
function walk(dir, exts, acc) {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
if (e.name.startsWith('.') && e.name !== '.') continue;
const full = path.join(dir, e.name);
if (SKIP_DIR.test(full)) continue;
if (e.isDirectory()) walk(full, exts, acc);
else if (exts.test(e.name)) acc.push(full);
}
return acc;
}
const RE = /mdi:([a-z0-9-]+)/g;
for (const { dir, exts } of TARGETS) {
if (!fs.existsSync(dir)) continue;
for (const file of walk(dir, exts, [])) {
const src = fs.readFileSync(file, 'utf8');
let n = 0;
const out = src.replace(RE, (m, name) => {
if (KEEP_MDI.has(name)) {
stats.kept[name] = (stats.kept[name] || 0) + 1;
return m;
}
const l = MAP[name];
if (!l) return m; // unknown -> leave, validator should have caught
n++;
return `lucide:${l}`;
});
if (n > 0) {
fs.writeFileSync(file, out);
stats.files++;
stats.repl += n;
}
}
}
console.log(`rewrote ${stats.repl} refs in ${stats.files} files`);
console.log('kept on mdi:', JSON.stringify(stats.kept));
+104
View File
@@ -0,0 +1,104 @@
// mdi -> lucide migration map. Names without a sensible lucide pendant
// (brand/language logos) intentionally stay on mdi — listed in KEEP_MDI.
export const KEEP_MDI = new Set([
'google', 'whatsapp', 'mastodon', // brands missing in lucide
'language-javascript', 'language-python', 'language-rust', // language logos
]);
export const MAP = {
'account-group-outline': 'users',
'alert-circle': 'circle-alert',
'alert-circle-outline': 'circle-alert',
'arrow-right': 'arrow-right',
'arrow-top-right': 'arrow-up-right',
'calendar': 'calendar',
'calendar-blank': 'calendar',
'calendar-clock': 'calendar-clock',
'calendar-export': 'calendar-arrow-up',
'calendar-month-outline': 'calendar',
'calendar-plus': 'calendar-plus',
'calendar-search': 'calendar-search',
'card-account-details-outline': 'id-card',
'check': 'check',
'check-circle': 'circle-check',
'check-decagram': 'badge-check',
'chevron-down': 'chevron-down',
'chevron-left': 'chevron-left',
'chevron-right': 'chevron-right',
'chevron-up': 'chevron-up',
'clock-alert-outline': 'alarm-clock',
'clock-outline': 'clock',
'clock-remove-outline': 'clock-fading',
'close': 'x',
'cloud': 'cloud',
'cloud-off-outline': 'cloud-off',
'code-json': 'braces',
'comment-outline': 'message-circle',
'comment-text-outline': 'message-square-text',
'console': 'terminal',
'content-copy': 'copy',
'content-save-outline': 'save',
'cursor-default-click': 'mouse-pointer-click',
'cursor-default-click-outline': 'mouse-pointer-click',
'database-outline': 'database',
'domain': 'building-2',
'download': 'download',
'download-outline': 'download',
'drag-vertical': 'grip-vertical',
'email': 'mail',
'email-outline': 'mail',
'facebook': 'facebook',
'file-document-edit-outline': 'file-pen',
'file-document-outline': 'file-text',
'file-download': 'file-down',
'file-excel-box': 'file-spreadsheet',
'file-image': 'file-image',
'file-outline': 'file',
'file-pdf-box': 'file-text',
'file-powerpoint-box': 'presentation',
'file-word-box': 'file-text',
'flag-outline': 'flag',
'folder-zip': 'folder-archive',
'format-text-variant-outline': 'type',
'home': 'house',
'home-group': 'house',
'home-outline': 'house',
'image-outline': 'image',
'information-outline': 'info',
'link': 'link',
'link-variant': 'link',
'loading': 'loader-circle',
'magnify': 'search',
'magnify-plus': 'zoom-in',
'map': 'map',
'map-marker': 'map-pin',
'map-marker-outline': 'map-pin',
'map-outline': 'map',
'menu': 'menu',
'open-in-new': 'external-link',
'pencil-outline': 'pencil',
'phone': 'phone',
'phone-outline': 'phone',
'play': 'play',
'printer': 'printer',
'printer-outline': 'printer',
'qrcode': 'qr-code',
'refresh': 'refresh-cw',
'reply': 'reply',
'robot-outline': 'bot',
'rss': 'rss',
'ruler-square': 'ruler',
'share-variant': 'share-2',
'share-variant-outline': 'share-2',
'shield-check-outline': 'shield-check',
'sort-alphabetical-ascending': 'arrow-down-a-z',
'transmission-tower': 'radio-tower',
'trash-can-outline': 'trash-2',
'tune': 'sliders-horizontal',
'tune-variant': 'sliders-horizontal',
'vector-square': 'square-dashed',
'video': 'video',
'wind-turbine': 'fan',
'xml': 'code',
'youtube': 'youtube',
};
+33
View File
@@ -0,0 +1,33 @@
import { MAP, KEEP_MDI } from './icon-map.mjs';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const lucide = JSON.parse(
fs.readFileSync(path.join(ROOT, 'node_modules/@iconify-json/lucide/icons.json'), 'utf8'),
);
const has = (n) => Boolean(lucide.icons[n] || (lucide.aliases && lucide.aliases[n]));
const all = fs
.readFileSync(process.env.TMPDIR + '/mdi_all.txt', 'utf8')
.trim()
.split('\n')
.map((s) => s.replace('mdi:', ''));
const cms = ['cloud', 'email', 'facebook', 'map', 'video', 'whatsapp', 'youtube'];
const set = [...new Set([...all, ...cms])].sort();
const bad = [];
const unmapped = [];
for (const m of set) {
if (KEEP_MDI.has(m)) continue;
const l = MAP[m];
if (l === undefined) {
unmapped.push(m);
continue;
}
if (has(l) === false) bad.push(`${m} -> lucide:${l}`);
}
console.log('total mdi:', set.length, '| keep-mdi:', [...KEEP_MDI].filter((k) => set.includes(k)).length);
console.log('UNMAPPED:', unmapped.length ? unmapped.join(', ') : 'none');
console.log('INVALID lucide names:', bad.length ? '\n ' + bad.join('\n ') : 'none');
+71 -71
View File
@@ -6,7 +6,7 @@
@import "@fontsource/inter/latin-500.css";
@import "@fontsource/inter/latin-600.css";
@import "@fontsource/inter/latin-700.css";
@import "@fontsource-variable/aleo";
@import "@fontsource-variable/lora";
@import "tailwindcss";
@@ -17,7 +17,12 @@
@layer components {
/* Shared card surface: border, background, shadow, radius */
.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 */
@@ -90,6 +95,18 @@
--color-fire-900: #981b18;
--color-fire-950: #5d0f0b;
/* Palette: Gelb (Signal / Warnung / Aufmerksamkeit) */
--color-gelb-50: #fefce8;
--color-gelb-100: #fef9c3;
--color-gelb-200: #fef08a;
--color-gelb-300: #fde047;
--color-gelb-400: #facc15;
--color-gelb-500: #eab308;
--color-gelb-600: #ca8a04;
--color-gelb-700: #a16207;
--color-gelb-800: #854d0e;
--color-gelb-900: #713f12;
/* Palette: Stein (Neutrals) */
--color-stein-0: #ffffff;
--color-stein-50: #f7f8f7;
@@ -121,15 +138,15 @@
/* Link (Himmel) für @apply text-link */
--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-serif: "Aleo Variable", Aleo, Georgia, "Times New Roman", serif;
--font-serif: "Lora Variable", Lora, Georgia, "Times New Roman", serif;
}
:root {
/* Typography Inter (primary), Aleo (Zitate) */
/* Typography Inter (primary), Lora (Zitate) */
--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) */
--bg-primary: #ffffff;
@@ -165,7 +182,7 @@
font-family: var(--font-body);
font-size: 1.125rem; /* 18px Body Large laut Design System */
line-height: 1.556;
line-height: 1.6;
color: var(--color-font);
}
@@ -179,36 +196,40 @@ main {
/* ==========================================================================
Typography Type Scale (02-typography.md)
Erlaubte Gewichte: 300, 400, 500, 600, 700. Inter primary, Aleo nur Zitate.
Erlaubte Gewichte: 300, 400, 500, 600, 700. Inter primary, Lora nur Zitate.
========================================================================== */
/* 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,
h2,
h3,
h4 {
color: var(--color-headings);
text-wrap: balance;
}
/* Mobile first, dann Tablet (768px), Desktop (1024px) */
h1 {
font-size: 1.75rem;
line-height: 2.125rem;
font-weight: 700;
font-weight: 600;
letter-spacing: -0.015em;
}
h2 {
font-size: 1.375rem;
line-height: 1.75rem;
font-weight: 700;
letter-spacing: -0.01em;
font-size: 1.5rem;
line-height: 1.875rem;
font-weight: 600;
letter-spacing: -0.02em;
}
h3 {
font-size: 1.25rem;
line-height: 1.625rem;
font-weight: 600;
letter-spacing: -0.005em;
letter-spacing: -0.01em;
}
h4 {
@@ -223,8 +244,8 @@ h4 {
line-height: 2.625rem;
}
h2 {
font-size: 1.5rem;
line-height: 2rem;
font-size: 1.75rem;
line-height: 2.25rem;
}
h3 {
font-size: 1.375rem;
@@ -242,12 +263,12 @@ h4 {
line-height: 3rem;
}
h2 {
font-size: 1.75rem;
line-height: 2.25rem;
font-size: 2.125rem;
line-height: 2.625rem;
}
h3 {
font-size: 1.5rem;
line-height: 2rem;
font-size: 1.375rem;
line-height: 1.875rem;
}
}
@@ -325,6 +346,29 @@ main a,
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 {
font-weight: 600;
}
@@ -347,59 +391,14 @@ main ol:not(.not-prose):where(:not(.not-prose *)) li {
list-style-type: decimal;
}
/* Buttons gemeinsame Basis */
.btn-primary,
.btn-secondary,
.btn-tertiary,
.btn-outline {
@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;
}
/* Größen-Modifier — nach den btn-* definiert damit override greift */
.btn-sm {
@apply px-2 py-1 text-xs gap-1;
}
/* .btn-*-Klassen entfernt (2026-07-12): Buttons laufen jetzt komplett über das
Button-Atom (src/lib/ui/Button.svelte). Die --color-btn-* Tokens bleiben,
weil das Atom `bg-btn-bg`/`text-btn-txt`/`bg-btn-hover-bg` nutzt. */
/* Filter-Chip — Basis für alle Filter-Chips (Kalender, Adressbuch, …).
Aktiv/inaktiv-Farben werden kontextuell per Klasse gesetzt. */
.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).
@@ -468,7 +467,7 @@ article [data-block-type="markdown"] .markdown > p:first-child {
font-size: 1.25rem;
line-height: 1.55;
font-weight: 300;
color: var(--text-secondary);
color: var(--text-primary);
margin-bottom: 1.25em;
}
@@ -632,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"] p,
blockquote[data-block-type="quote"] cite {
font-family: var(--font-secondary);
font-style: italic;
}
/* Footer */
+9
View File
@@ -3,6 +3,15 @@
<head>
<meta charset="utf-8" />
<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://api.iconify.design">
<link rel="preconnect" href="https://analytics.pm86.de">
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
+44 -1
View File
@@ -46,12 +46,36 @@ export type BlockLayout = {
| "10"
| "11"
| "12";
/** Start-Spalte auf Desktop (112) = Grid-Versatz (col-start). Optional. */
desktopStart?:
| "1" | "2" | "3" | "4" | "5" | "6"
| "7" | "8" | "9" | "10" | "11" | "12";
/** Start-Spalte auf Tablet (112). Optional. */
tabletStart?:
| "1" | "2" | "3" | "4" | "5" | "6"
| "7" | "8" | "9" | "10" | "11" | "12";
/** Abstand nach unten (rem): 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). */
breakout?: boolean;
};
const MD_COL_START: Record<string, string> = {
"1": "md:col-start-1", "2": "md:col-start-2", "3": "md:col-start-3",
"4": "md:col-start-4", "5": "md:col-start-5", "6": "md:col-start-6",
"7": "md:col-start-7", "8": "md:col-start-8", "9": "md:col-start-9",
"10": "md:col-start-10", "11": "md:col-start-11", "12": "md:col-start-12",
};
const LG_COL_START: Record<string, string> = {
"1": "lg:col-start-1", "2": "lg:col-start-2", "3": "lg:col-start-3",
"4": "lg:col-start-4", "5": "lg:col-start-5", "6": "lg:col-start-6",
"7": "lg:col-start-7", "8": "lg:col-start-8", "9": "lg:col-start-9",
"10": "lg:col-start-10", "11": "lg:col-start-11", "12": "lg:col-start-12",
};
const COL_SPAN: Record<string, string> = {
"1": "col-span-1",
"2": "col-span-2",
@@ -105,6 +129,16 @@ const SPACE_BOTTOM: Record<number, string> = {
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).
* Parent muss grid grid-cols-12 haben.
@@ -126,18 +160,27 @@ export function getBlockLayoutClasses(
: Number(layout.spaceBottom);
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) {
return "w-full";
return [spaceTopClass, "w-full"].filter(Boolean).join(" ");
}
const mobile = String(layout.mobile ?? "12");
const tablet = layout.tablet != null ? String(layout.tablet) : undefined;
const desktop = layout.desktop != null ? String(layout.desktop) : undefined;
const tabletStart = layout.tabletStart != null ? String(layout.tabletStart) : undefined;
const desktopStart = layout.desktopStart != null ? String(layout.desktopStart) : undefined;
const parts: string[] = [
COL_SPAN[mobile] ?? "col-span-12",
tablet ? (MD_COL_SPAN[tablet] ?? "") : "",
desktop ? (LG_COL_SPAN[desktop] ?? "") : "",
tabletStart ? (MD_COL_START[tabletStart] ?? "") : "",
desktopStart ? (LG_COL_START[desktopStart] ?? "") : "",
spaceTopClass,
spaceClass,
];
return parts.filter(Boolean).join(" ");
+116 -4
View File
@@ -45,6 +45,12 @@ export interface HeadlineBlockData {
_slug?: string;
tag?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
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";
layout?: BlockLayout;
}
@@ -121,6 +127,12 @@ export interface ImageGalleryBlockData {
description?: string;
/** Optional: oberhalb des Galerie-Modals als Header. */
title?: string;
/** SectionHeader: Label über dem Titel (uppercase). */
kicker?: string;
/** SectionHeader: Index vor dem Kicker, z.B. "03". */
number?: string;
/** SectionHeader: CTA-Link (aufgelöst: url, linkName). */
action?: string | { url?: string; linkName?: string; newTab?: boolean };
/** "standard" = Carousel (Default), "grid" = Thumbnail-Grid mit Modal. */
variant?: "standard" | "grid";
images?: ImageGalleryImage[];
@@ -144,6 +156,12 @@ export interface FilesBlockData {
_type?: "files";
_slug?: string;
headline?: string;
/** SectionHeader: Label über dem Titel (uppercase). */
kicker?: string;
/** SectionHeader: Index vor dem Kicker, z.B. "03". */
number?: string;
/** SectionHeader: CTA-Link (aufgelöst: url, linkName). */
action?: string | { url?: string; linkName?: string; newTab?: boolean };
description?: string;
items?: Array<string | FilesBlockItem>;
forceDownload?: boolean;
@@ -174,6 +192,12 @@ export interface YoutubeVideoGalleryBlockData {
_slug?: string;
title?: string;
subtitle?: string;
/** SectionHeader: Label über dem Titel (uppercase). */
kicker?: string;
/** SectionHeader: Index vor dem Kicker, z.B. "03". */
number?: string;
/** SectionHeader: CTA-Link (aufgelöst: url, linkName). */
action?: string | { url?: string; linkName?: string; newTab?: boolean };
description?: string;
variant?: "carousel" | "grid";
videos?: YoutubeVideoBlockData[];
@@ -216,6 +240,8 @@ export interface LinkListBlockData {
_slug?: string;
headline?: string;
links?: Array<string | { url?: string; linkName?: string; newTab?: boolean }>;
/** Spaltenlayout: "auto" = mehrspaltig bei >4 Links, "single" = immer untereinander. */
columns?: "auto" | "single";
layout?: BlockLayout;
}
@@ -224,6 +250,12 @@ export interface PostOverviewBlockData {
_type?: "post_overview";
_slug?: string;
headline?: string;
/** SectionHeader: kleines Label über dem Titel (uppercase). */
kicker?: string;
/** SectionHeader: optionaler Index vor dem Kicker, z.B. "03". */
number?: string;
/** SectionHeader: CTA-Link (aufgelöst aus link-Collection: url, linkName). */
action?: string | { url?: string; linkName?: string; newTab?: boolean };
/** Einleitungstext (Markdown). */
text?: string;
/** true = alle Posts laden; false = posts (Slugs/Objekte) aus dem Block nutzen. */
@@ -236,8 +268,8 @@ export interface PostOverviewBlockData {
excludeTag?: string[];
/** Max. Anzahl Einträge. */
numberItems?: number;
/** "list" | "cards" | "carousel". */
design?: "list" | "cards" | "carousel";
/** "list" | "cards" | "carousel" | "featured" (1. Post groß links, Rest kompakt rechts). */
design?: "list" | "cards" | "carousel" | "featured";
layout?: BlockLayout;
/** Nach resolvePostOverviewBlocks: geladene/gefilterte Posts (PostEntry[]). */
postsResolved?: unknown[];
@@ -284,6 +316,12 @@ export interface SearchableTextBlockData {
_slug?: string;
/** Titel der Suchkomponente. */
title?: string;
/** SectionHeader: Label über dem Titel (uppercase). */
kicker?: string;
/** SectionHeader: Index vor dem Kicker, z.B. "03". */
number?: string;
/** SectionHeader: CTA-Link (aufgelöst: url, linkName). */
action?: string | { url?: string; linkName?: string; newTab?: boolean };
/** Einleitung (Markdown). */
description?: string;
/** Slugs oder aufgelöste text_fragment-Einträge. */
@@ -324,9 +362,15 @@ export interface OrganisationsBlockData {
_type?: "organisations";
_slug?: string;
headline?: string;
/** SectionHeader: Label über dem Titel (uppercase). */
kicker?: string;
/** SectionHeader: Index vor dem Kicker, z.B. "03". */
number?: string;
/** SectionHeader: CTA-Link (aufgelöst: url, linkName). */
action?: string | { url?: string; linkName?: string; newTab?: boolean };
description?: string;
/** default = Karten-Grid; compact = engere Karten, Logo links; carousel = horizontaler Slider (3 Desktop / 2 Mobile) */
presentation?: "default" | "compact" | "carousel";
presentation?: "default" | "compact" | "carousel" | "grid";
addOrganisationTitle?: string;
addOrganisationMarkdown?: string;
/** Referenz auf link-Collection (aufgelöst: url, linkName, newTab) */
@@ -339,6 +383,12 @@ export interface OrganisationsMapBlockData {
_type?: "organisations_map";
_slug?: string;
headline?: string;
/** SectionHeader: Label über dem Titel (uppercase). */
kicker?: string;
/** SectionHeader: Index vor dem Kicker, z.B. "03". */
number?: string;
/** SectionHeader: CTA-Link (aufgelöst: url, linkName). */
action?: string | { url?: string; linkName?: string; newTab?: boolean };
organisations?: (string | OrganisationEntry)[];
layout?: BlockLayout;
}
@@ -457,6 +507,12 @@ export type CalendarBlockData = Omit<
_type?: "calendar";
/** Optionaler Titel über dem Kalender. */
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. */
items?: (string | CalendarItemData)[];
layout?: BlockLayout;
@@ -469,7 +525,7 @@ export type CalendarBlockData = Omit<
};
/** Internal component block (_type: "internal_component"). */
export type InternalComponentId = "form-contact";
export type InternalComponentId = "form-contact" | "form-mitmachen" | "form-newsletter" | "form-newsletter-inline";
export interface InternalComponentBlockData {
_type?: "internal_component";
@@ -486,6 +542,12 @@ export interface WindMapBlockData {
subtitle?: string;
/** Explizite Auswahl von wind_area-Slugs. Leer/null = alle published areas. */
areas?: (string | { _slug: string;[key: string]: unknown })[];
/** Optionales Disclaimer-Accordion oberhalb der Karte. HTML/Markdown aus
* dem CMS — Komponente rendert es via {@html}. */
hinweis_text?: string;
/** Beschreibender Fließtext direkt unter dem Titel (Statistik, Interaktions-
* Hinweise, Methodik). HTML aus dem CMS. */
info_text?: string;
layout?: BlockLayout;
}
@@ -516,5 +578,55 @@ export interface StellingnahmeGeneratorBlockData {
deadline?: string;
/** E-Mail der Planungsstelle — aktiviert Mail-Button. */
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;
}
+25 -1
View File
@@ -6,11 +6,13 @@ import {
getTextFragmentBySlug,
getTextFragmentsByTag,
getEntryBySlug,
getStellungnahmeConfig,
getCalendarBySlug,
getCalendarItemBySlug,
getContacts,
getCalendarItems,
} from "./cms";
import { STELLUNGNAHME_CONFIG_SLUG } from "./constants";
import type { RowContentLayout } from "./block-types";
import type { CalendarItemData } from "./block-types";
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 = {
posts: PostEntry[];
tags: Awaited<ReturnType<typeof getTags>>;
@@ -277,6 +281,7 @@ export type LoadPostsResult = {
currentPage: number;
totalPages: number;
totalPosts: number;
sort: PostSortOrder;
upcomingEvents: PostEntry[];
searchIndex: PostSearchIndexEntry[];
tagName: string | null;
@@ -287,9 +292,11 @@ export type LoadPostsResult = {
export async function loadPostsList(opts: {
tagSlug?: string | null;
page?: number;
sort?: PostSortOrder;
}): Promise<LoadPostsResult> {
const tagSlug = opts.tagSlug ?? null;
const requestedPage = Math.max(1, opts.page ?? 1);
const sort: PostSortOrder = opts.sort === "oldest" ? "oldest" : "newest";
const perPage = getPostsPerPage();
let posts: PostEntry[] = [];
@@ -351,7 +358,10 @@ export async function loadPostsList(opts: {
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 pageNum = Math.min(requestedPage, totalPages);
const pagePosts = paginate(filtered, pageNum, perPage);
@@ -370,6 +380,7 @@ export async function loadPostsList(opts: {
currentPage: pageNum,
totalPages,
totalPosts: filtered.length,
sort,
upcomingEvents,
searchIndex,
tagName,
@@ -771,6 +782,7 @@ function isStellingnahmeGeneratorBlock(item: unknown): boolean {
/** Löst stellungnahme_generator-Blöcke auf:
* - windArea → vollständiges wind_area-Objekt mit aufgelösten stellungnahme_kriterien
* - allgemeineArgumenteTag → allgemeineArgumente[] via Tag-Filter
* - aiRules ← zentrale stellungnahme_config (Singleton), einmal geladen, an alle Blöcke gehängt
*/
export async function resolveStellingnahmeGeneratorBlocks(
layout: RowContentLayout | null | undefined,
@@ -781,12 +793,24 @@ export async function resolveStellingnahmeGeneratorBlocks(
layout.row2Content ?? [],
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) {
if (!Array.isArray(content)) continue;
for (const item of content) {
if (!isStellingnahmeGeneratorBlock(item)) continue;
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
const windAreaRef = block.windArea;
const windAreaSlug =
+4 -4
View File
@@ -4024,7 +4024,7 @@ export interface components {
* @default false
*/
external: boolean;
/** @description Icon identifier (e.g. Iconify). Used for social links; fallback 'mdi:link'. */
/** @description Icon identifier (e.g. Iconify). Used for social links; fallback 'lucide:link'. */
icon?: string;
/** @description Internal key. Not used in app. */
internal?: string;
@@ -4065,7 +4065,7 @@ export interface components {
* @default false
*/
external: boolean;
/** @description Icon identifier (e.g. Iconify). Used for social links; fallback 'mdi:link'. */
/** @description Icon identifier (e.g. Iconify). Used for social links; fallback 'lucide:link'. */
icon?: string;
/** @description Internal key. Not used in app. */
internal?: string;
@@ -5400,7 +5400,7 @@ export interface components {
readonly _slug?: string;
/** @description Optional pill color (CSS, e.g. #2d5a27 or rgb()). Admin: color picker + text field. */
color?: string;
/** @description Optional Iconify icon id (e.g. mdi:map-marker, same as link.icon). */
/** @description Optional Iconify icon id (e.g. lucide:map-pin, same as link.icon). */
icon?: string;
/** @description Tag name (e.g. politik, termin). Must be unique. */
name: string;
@@ -5410,7 +5410,7 @@ export interface components {
_slug: string;
/** @description Optional pill color (CSS, e.g. #2d5a27 or rgb()). Admin: color picker + text field. */
color?: string;
/** @description Optional Iconify icon id (e.g. mdi:map-marker, same as link.icon). */
/** @description Optional Iconify icon id (e.g. lucide:map-pin, same as link.icon). */
icon?: string;
/** @description Tag name (e.g. politik, termin). Must be unique. */
name: string;
+28 -3
View File
@@ -90,9 +90,11 @@ function ttlFor(collection: string): number {
/**
* Cached fetch mit TTL + in-flight dedup. Key-Format: "<collection>:<op>:<params>".
* Bei Fehler wird nichts gecacht.
* Multi-Collection-Einträge (z.B. Layout-Bootstrap-Batch) nutzen eine CSV-Liste
* als Key-Head: "navigation,footer:op:" — invalidateCollection purged dann bei
* Mutation jeder gelisteten Collection. Bei Fehler wird nichts gecacht.
*/
async function cached<T>(
export async function cached<T>(
collection: string,
key: string,
fn: () => Promise<T>,
@@ -122,7 +124,9 @@ async function cached<T>(
export function invalidateCollection(collection: string): number {
let n = 0;
for (const k of cache.keys()) {
if (k.startsWith(`${collection}:`)) {
const sep = k.indexOf(':');
if (sep === -1) continue;
if (k.slice(0, sep).split(',').includes(collection)) {
cache.delete(k);
n++;
}
@@ -943,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 key→string map for `event.locals.translations`. Cached on the
* 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>
+1 -1
View File
@@ -34,7 +34,7 @@
<details {id} {open} ontoggle={onToggle} class="pt-4 border-t border-stein-200 group {cls}">
<summary class="flex cursor-pointer list-none items-center gap-2 text-sm font-medium select-none">
<Icon icon="mdi:chevron-right" class="size-4 shrink-0 transition-transform group-open:rotate-90" />
<Icon icon="lucide:chevron-right" class="size-4 shrink-0 transition-transform group-open:rotate-90" />
{label}
</summary>
<div class="mt-4">
+19 -1
View File
@@ -10,7 +10,7 @@
{#if banner?.text}
<div class="w-full bg-wald-600 text-white print:hidden" role="note" style="font-size: 0.7rem;">
<div class="container-custom flex items-center gap-3 py-2 md:justify-center md:text-center">
<span>{@html textHtml}</span>
<span class="banner-text">{@html textHtml}</span>
{#if banner.link}
<a
href={banner.link}
@@ -22,3 +22,21 @@
</div>
</div>
{/if}
<style>
/* Leading **bold** in the banner text renders as a red "NEU"-style badge. */
.banner-text :global(strong:first-child) {
display: inline-block;
vertical-align: middle;
margin-right: 0.3em;
padding: 0.1em 0.5em;
border-radius: 9999px;
background: var(--color-fire-600);
color: #fff;
font-size: 0.85em;
font-weight: 700;
line-height: 1.2;
text-transform: uppercase;
letter-spacing: 0.03em;
}
</style>
+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">
import type { Snippet } from "svelte";
type Variant = "subtle" | "solid";
let {
children,
color,
variant = "subtle",
uppercase = false,
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) {
case "green": return "bg-wald-50 text-wald-700 border border-wald-200";
case "blue": return "bg-himmel-50 text-himmel-700 border border-himmel-200";
case "amber": return "bg-erde-50 text-erde-700 border border-erde-200";
default: return "bg-stein-100 text-stein-600 border border-stein-200";
case "green": return "bg-wald-50 text-wald-700 border-wald-200";
case "blue": return "bg-himmel-50 text-himmel-700 border-himmel-200";
case "amber": return "bg-amber-50 text-amber-700 border-amber-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>
<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?.()}
</span>
+8
View File
@@ -23,6 +23,8 @@
import WindkarteBlock from "./blocks/WindkarteBlock.svelte";
import AdressbuchBlock from "./blocks/AdressbuchBlock.svelte";
import OrganisationsMapBlock from "./blocks/OrganisationsMapBlock.svelte";
import SectionGridBlock from "./blocks/SectionGridBlock.svelte";
import ContactFormBlock from "./blocks/ContactFormBlock.svelte";
import type { Translations } from "$lib/translations";
import type {
ResolvedBlock,
@@ -51,6 +53,8 @@
WindMapBlockData,
StellingnahmeGeneratorBlockData,
OrganisationsMapBlockData,
SectionGridBlockData,
ContactFormBlockData,
} from "$lib/block-types";
let {
@@ -117,6 +121,10 @@
<AdressbuchBlock block={block as AdressbuchBlockData} />
{:else if type === "organisations_map"}
<OrganisationsMapBlock block={block as OrganisationsMapBlockData} />
{:else if type === "section_grid"}
<SectionGridBlock block={block as SectionGridBlockData} />
{:else if type === "contact_form"}
<ContactFormBlock block={block as ContactFormBlockData} />
{:else if type === "stellungnahme_generator"}
{#await import("./blocks/StellingnahmeGeneratorBlock.svelte") then m}
{@const Comp = m.default}
+287 -65
View File
@@ -5,8 +5,23 @@
import EventBadges from "./EventBadges.svelte";
import Tag from "./Tag.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 { 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 type { Translations } from "$lib/translations";
import {
@@ -45,6 +60,7 @@
translations = {},
upcomingEvents = [],
searchIndex = null,
sort = "newest",
}: {
posts: PostEntry[];
tags: TagEntry[];
@@ -56,10 +72,79 @@
translations?: Translations | null;
upcomingEvents?: PostEntry[];
searchIndex?: SearchIndexEntry[] | null;
sort?: PostSortOrder;
} = $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 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 isFiltering = $derived(normalizedQuery.length >= 2 || year !== "");
@@ -128,41 +213,23 @@
<div class="blog-overview">
{#if searchIndex}
<div class="mb-4 flex flex-wrap items-center gap-2">
<label class="relative block w-full sm:max-w-sm">
<span class="sr-only">{t(T.blog_search_label)}</span>
<span
class="pointer-events-none absolute top-1/2 left-2 -translate-y-1/2 text-stein-500"
>
<Icon icon="mdi:magnify" 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="mdi:close" class="h-4 w-4" />
</button>
{/if}
</label>
<SearchField
bind:value={query}
placeholder={t(T.blog_search_placeholder)}
ariaLabel={t(T.blog_search_label)}
class="w-full sm:max-w-sm"
/>
{#if availableYears.length > 0}
<label class="relative inline-flex items-center">
<span class="sr-only">{t(T.blog_year_label)}</span>
<span
class="pointer-events-none absolute top-1/2 left-2 -translate-y-1/2 text-stein-500"
>
<Icon icon="mdi:calendar" class="h-4 w-4" />
<Icon icon="lucide:calendar" class="h-4 w-4" />
</span>
<select
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>
{#each availableYears as y}
@@ -172,7 +239,7 @@
<span
class="pointer-events-none absolute top-1/2 right-1.5 -translate-y-1/2 text-stein-500"
>
<Icon icon="mdi:chevron-down" class="h-4 w-4" />
<Icon icon="lucide:chevron-down" class="h-4 w-4" />
</span>
</label>
{/if}
@@ -180,13 +247,11 @@
{/if}
{#if !isFiltering && upcomingEvents.length > 0}
<section
class="not-prose mb-6 rounded-xs border border-stein-200 bg-stein-50 p-4"
>
<Card variant="callout" class="not-prose mb-6">
<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="mdi:calendar-clock" class="size-7 shrink-0" />
<Icon icon="lucide:calendar-clock" class="size-7 shrink-0" />
{t(T.blog_upcoming_events)}
</h2>
<ul class="not-prose list-none space-y-2 pl-0">
@@ -206,7 +271,7 @@
</li>
{/each}
</ul>
</section>
</Card>
{/if}
{#if isFiltering}
@@ -233,9 +298,10 @@
class="py-2 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
>
{#each filteredResults as hit}
<a
<Card
variant="tile"
layout="post"
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}
<div
@@ -259,18 +325,18 @@
</p>
{/if}
</div>
</a>
</Card>
{/each}
</div>
{:else}
<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)}
</div>
{/if}
{:else}
{#if tags.length > 0}
{#if topicTags.length > 0}
<div class="mb-2">
<div
class="flex flex-wrap gap-2"
@@ -283,7 +349,7 @@
variant={activeTag ? "blue" : "green"}
active={!activeTag}
/>
{#each tags as tag}
{#each visibleTags as tag}
{@const slug = tag._slug ?? ""}
<Tag
label={tag.name}
@@ -296,14 +362,164 @@
: tag.color?.trim() || null}
/>
{/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>
{/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">
{#each posts as post}
{#each gridPosts as post}
<PostCard
{post}
href={postHref(post)}
@@ -314,36 +530,42 @@
{/each}
</div>
<div class="flex flex-wrap items-center gap-2">
<Pagination {currentPage} {totalPages} {basePath} {activeTag} />
<div class="grow"></div>
<div class="bg-white rounded-xs font-light">
<div class="inline-flex text-xs p-2">
{#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>
{#if currentPage === 1 && !activeTag}
<div
class="not-prose my-6 rounded-lg bg-stein-900 p-5 sm:p-6 [&_h3]:!text-stein-0"
>
<NewsletterInlineFormComponent />
</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 class="mt-4 text-xs text-stein-600">
<div class="mt-5">
<a
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="mdi:rss" class="h-3.5 w-3.5" />
<Icon icon="lucide:rss" class="size-4 text-wald-600" />
{t(T.blog_rss_subscribe)}
</a>
</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.
* 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: {
variant: {
tile: "",
@@ -44,7 +44,7 @@
variants: {
variant: {
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" },
@@ -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">
import { onMount, onDestroy } from "svelte";
import type { Snippet } from "svelte";
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
import IconButton from "$lib/components/IconButton.svelte";
let {
count,
@@ -67,14 +66,7 @@
>
<div class="flex items-center gap-1.5 sm:gap-2">
{#if showArrows && count > 1}
<button
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="mdi:chevron-left" class="size-4" aria-hidden="true" />
</button>
<IconButton icon="lucide:chevron-left" label="Vorheriger Eintrag" variant="surface" size="sm" onclick={prev} />
{/if}
<div class="grid flex-1 min-w-0" ontouchstart={onTouchStart} ontouchend={onTouchEnd}>
@@ -91,14 +83,7 @@
</div>
{#if showArrows && count > 1}
<button
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="mdi:chevron-right" class="size-4" aria-hidden="true" />
</button>
<IconButton icon="lucide:chevron-right" label="Nächster Eintrag" variant="surface" size="sm" onclick={next} />
{/if}
</div>
+38 -36
View File
@@ -3,6 +3,9 @@
import "$lib/iconify-offline";
import { env } from "$env/dynamic/public";
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 {
pageId,
@@ -310,10 +313,7 @@
maxlength={bodyMax}
></textarea>
<div class="mt-1 flex items-center gap-3 text-xs">
<button
type="submit"
class="rounded-xs bg-stein-800 px-3 py-1 font-medium text-white hover:bg-stein-700"
>{t(T.comments_save)}</button>
<Button type="submit" variant="primary" size="sm" label={t(T.comments_save)} />
<button
type="button"
onclick={cancelEdit}
@@ -332,7 +332,7 @@
onclick={() => startEdit(c)}
class="text-xs text-stein-600 hover:text-stein-900 inline-flex items-center gap-1"
>
<Icon icon="mdi:pencil-outline" class="size-3" />
<Icon icon="lucide:pencil" class="size-3" />
{t(T.comments_edit)}
</button>
<button
@@ -340,7 +340,7 @@
onclick={() => deleteComment(c.id)}
class="text-xs text-stein-600 hover:text-fire-700 inline-flex items-center gap-1"
>
<Icon icon="mdi:trash-can-outline" class="size-3" />
<Icon icon="lucide:trash-2" class="size-3" />
{t(T.comments_delete)}
</button>
</div>
@@ -363,10 +363,10 @@
<span class="text-stein-400 text-xs" title={fmtAbs(c.updated_at)}>{t(T.comments_edited)}</span>
{/if}
{#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">
<Icon icon="mdi:clock-outline" class="size-3" />
<Badge color="amber" uppercase class="ml-auto inline-flex items-center gap-1">
<Icon icon="lucide:clock" class="size-3" />
{t(T.comments_pending_short)}
</span>
</Badge>
{/if}
</div>
{/snippet}
@@ -386,8 +386,8 @@
{/each}
</ul>
{: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">
<Icon icon="mdi:alert-circle-outline" class="size-5 shrink-0 mt-0.5" />
<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" />
<div class="flex-1">
<p class="font-medium">{t(T.comments_load_error_title)}</p>
<p class="mt-1 text-xs text-fire-700">{loadError}</p>
@@ -400,9 +400,9 @@
</div>
{:else}
{#if topLevel.length === 0}
<div class="flex flex-col items-center gap-2 py-8 text-center sm:flex-row sm:items-center sm:text-left sm:py-4 sm:gap-3">
<Icon icon="mdi:comment-text-outline" class="size-10 text-stein-300 shrink-0" />
<p class="text-sm text-stein-500">{t(T.comments_empty)}</p>
<div class="flex flex-col items-center gap-1 py-8 text-center sm:flex-row sm:items-center sm:text-left sm:py-4 sm:gap-1.5">
<Icon icon="lucide:message-square-text" class="size-5 text-stein-300 shrink-0" />
<p class="text-sm font-semibold text-stein-300">{t(T.comments_empty)}</p>
</div>
{:else}
<ul class="space-y-5 mb-8 list-none p-0">
@@ -410,7 +410,7 @@
{@const replies = repliesOf(c.id)}
<li
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)}
@@ -424,7 +424,7 @@
onclick={() => (replyTo = replyTo === c.id ? null : c.id)}
class="text-xs text-stein-600 hover:text-stein-900 inline-flex items-center gap-1"
>
<Icon icon="mdi:reply" class="size-3" />
<Icon icon="lucide:reply" class="size-3" />
{replyTo === c.id
? t(T.comments_cancel)
: replies.length === 0
@@ -437,13 +437,12 @@
{#if replyTo === c.id}
<form onsubmit={(e) => onReply(e, c.id)} class="mt-3 space-y-2">
<input
type="text"
<TextInput
bind:value={replyAuthor}
placeholder={t(T.comments_name_placeholder)}
label=""
required
maxlength="80"
class="w-full p-2 border border-stein-300 rounded-xs text-sm focus:outline-none focus:ring-2 focus:ring-stein-400"
size="sm"
/>
<textarea
bind:value={replyBody}
@@ -457,11 +456,14 @@
></textarea>
{#if replyError}<p class="text-fire-700 text-xs">{replyError}</p>{/if}
<div class="flex items-center gap-2">
<button
<Button
type="submit"
disabled={submitting}
class="px-3 py-1.5 bg-stein-800 text-white rounded-xs text-sm font-medium hover:bg-stein-700 disabled:opacity-50"
>{t(T.comments_reply_send)}</button>
variant="primary"
size="sm"
loading={submitting}
label={t(T.comments_reply_send)}
class="text-sm"
/>
<span class="ml-auto text-xs text-stein-400 tabular-nums">{replyBody.length} / {bodyMax}</span>
</div>
</form>
@@ -472,7 +474,7 @@
{#each replies as r (r.id)}
<li
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")}
@@ -492,13 +494,12 @@
<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>
<input
type="text"
<TextInput
bind:value={author}
placeholder={t(T.comments_name_placeholder)}
label=""
required
maxlength="80"
class="w-full p-2 border border-stein-300 rounded-xs text-sm focus:outline-none focus:ring-2 focus:ring-stein-400"
size="sm"
/>
<textarea
bind:value={body}
@@ -522,16 +523,17 @@
<p class="text-fire-700 text-xs">{submitError}</p>
{/if}
<div class="flex flex-wrap items-center gap-3 text-xs">
<button
<Button
type="submit"
disabled={submitting || !body.trim() || !author.trim()}
class="px-4 py-2 bg-stein-800 text-white rounded-xs text-sm font-medium hover:bg-stein-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? t(T.comments_sending) : t(T.comments_send)}
</button>
variant="primary"
loading={submitting}
disabled={!body.trim() || !author.trim()}
label={submitting ? t(T.comments_sending) : t(T.comments_send)}
class="text-sm"
/>
{#if requireApproval}
<span class="text-stein-500 inline-flex items-center gap-1">
<Icon icon="mdi:shield-check-outline" class="size-3.5" />
<Icon icon="lucide:shield-check" class="size-3.5" />
{t(T.comments_approval_hint)}
</span>
{/if}
+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' } },
};
+7 -10
View File
@@ -3,6 +3,7 @@
import "$lib/iconify-offline";
import type { ContactEntry } from "$lib/block-types";
import { downloadVCard } from "$lib/vcard";
import Badge from "$lib/components/Badge.svelte";
let { contact }: { contact: ContactEntry } = $props();
@@ -15,7 +16,7 @@
</script>
<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">
<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}
<div class="flex flex-wrap gap-1">
{#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">
{contact.role}
</span>
<Badge>{contact.role}</Badge>
{/if}
{#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">
{org}
</span>
<Badge color="green">{org}</Badge>
{/each}
</div>
{/if}
@@ -43,20 +40,20 @@
onclick={() => downloadVCard(contact)}
class="absolute top-3 right-3 text-stein-300 transition-colors hover:text-wald-600"
>
<Icon icon="mdi:card-account-details-outline" class="size-4" />
<Icon icon="lucide:id-card" class="size-4" />
</button>
{#if contact.phone || contact.email}
<div class="flex flex-col gap-1">
{#if contact.phone}
<a href="tel:{contact.phone.replace(/\s/g, '')}" class="inline-flex items-center gap-1.5 text-xs text-stein-600 no-underline hover:text-wald-700">
<Icon icon="mdi:phone-outline" class="size-3.5 shrink-0 text-stein-400" />
<Icon icon="lucide:phone" class="size-3.5 shrink-0 text-stein-400" />
<span class="truncate">{contact.phone}</span>
</a>
{/if}
{#if contact.email}
<a href="mailto:{contact.email}" class="inline-flex items-center gap-1.5 text-xs text-stein-600 no-underline hover:text-wald-700">
<Icon icon="mdi:email-outline" class="size-3.5 shrink-0 text-stein-400" />
<Icon icon="lucide:mail" class="size-3.5 shrink-0 text-stein-400" />
<span class="truncate">{contact.email}</span>
</a>
{/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' },
};
+3 -3
View File
@@ -28,19 +28,19 @@
<div class="flex flex-wrap gap-2">
{#if dateLabel}
<span class="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full bg-wald-500 px-3 py-1 text-[.65rem] font-semibold text-white shadow-sm no-underline! [text-decoration:none]">
<Icon icon="mdi:calendar" class="size-3.5 shrink-0 opacity-90" aria-hidden="true" />
<Icon icon="lucide:calendar" class="size-3.5 shrink-0 opacity-90" aria-hidden="true" />
{dateLabel}
</span>
{/if}
{#if locationText}
{#if locationHref}
<a href={locationHref} target={locationHref.startsWith('#') ? undefined : '_blank'} rel={locationHref.startsWith('#') ? undefined : 'noopener noreferrer'} title={locationText} class="inline-flex min-w-0 items-center gap-1.5 rounded-full bg-stein-800 text-stein-50! px-3 py-1 text-[.65rem] shadow-sm hover:bg-stein-700 transition-colors no-underline!">
<Icon icon="mdi:map-marker" class="size-3.5 shrink-0 opacity-90" aria-hidden="true" />
<Icon icon="lucide:map-pin" class="size-3.5 shrink-0 opacity-90" aria-hidden="true" />
<span class="truncate max-w-[16rem]">{locationText}</span>
</a>
{:else}
<span title={locationText} class="inline-flex min-w-0 items-center gap-1.5 rounded-full bg-stein-800 text-stein-50 px-3 py-1 text-[.65rem] font-medium shadow-sm no-underline! [text-decoration:none]">
<Icon icon="mdi:map-marker" class="size-3.5 shrink-0 opacity-90" aria-hidden="true" />
<Icon icon="lucide:map-pin" class="size-3.5 shrink-0 opacity-90" aria-hidden="true" />
<span class="truncate max-w-[16rem]">{locationText}</span>
</span>
{/if}
+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',
},
};
+3 -3
View File
@@ -45,7 +45,7 @@
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') overlayVisible = false; }}
>
<div class="flex items-center gap-2 rounded-full bg-white/90 px-4 py-2 text-xs font-medium text-stein-700 shadow">
<Icon icon="mdi:cursor-default-click" class="size-4 shrink-0" aria-hidden="true" />
<Icon icon="lucide:mouse-pointer-click" class="size-4 shrink-0" aria-hidden="true" />
{t(translations, T.post_map_activate)}
</div>
</div>
@@ -58,7 +58,7 @@
rel="noopener noreferrer"
class="flex items-center gap-1.5 px-3 py-1.5 bg-wald-50 text-wald-700 text-[.65rem] font-medium hover:bg-wald-100 transition-colors border-t border-wald-200"
>
<Icon icon="mdi:open-in-new" class="size-3.5 shrink-0" aria-hidden="true" />
<Icon icon="lucide:external-link" class="size-3.5 shrink-0" aria-hidden="true" />
{t(translations, T.post_map_open_osm)}
</a>
{/if}
@@ -70,7 +70,7 @@
rel="noopener noreferrer"
class="flex items-center gap-2 px-3 py-2 bg-wald-50 text-wald-700 text-xs font-medium hover:bg-wald-100 transition-colors"
>
<Icon icon="mdi:map-marker" class="size-4 shrink-0" aria-hidden="true" />
<Icon icon="lucide:map-pin" class="size-4 shrink-0" aria-hidden="true" />
{locationText ?? t(translations, T.post_map_open_osm)}
</a>
{/if}
+71 -16
View File
@@ -8,12 +8,26 @@
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) {
return tStatic(translations ?? null, key);
}
const year = new Date().getFullYear();
const hasRowContent = $derived(
!!footer &&
((footer.row1Content?.length ?? 0) > 0 ||
@@ -32,13 +46,29 @@
<div class="content-footer text-sm text-stein-200">
<ContentRows layout={footer} translations={translations} />
</div>
{:else}
<p class="text-sm text-stein-0/80 text-center">
{/if}
<!-- 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)
: `© ${new Date().getFullYear()} Windwiderstand`}
: `Copyright © ${year} · Inhalte frei nutzbar mit Quellenangabe „windwiderstand.de“ · CC BY 4.0`}
</p>
{/if}
</div>
</div>
</footer>
{/if}
@@ -47,31 +77,56 @@
.content-footer :global(.content) {
margin: 0;
}
.content-footer :global(.content-row) {
display: flex;
flex-wrap: wrap;
gap: 2rem;
align-items: flex-start;
}
.content-footer :global(.content-row > *) {
flex: 1 1 200px;
grid-column: auto !important;
min-width: 0;
}
/* Footer nutzt jetzt das normale 12-Spalten-Grid (grid grid-cols-12 aus
ContentRows) + per-Block layout (mobile/tablet/desktop + desktopStart).
Kein Flex-Override mehr — Spaltigkeit kommt aus dem CMS-layout. */
.content-footer :global(.content-row + .content-row) {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid var(--color-stein-700);
}
/* Globaler Row-Trenner (::before aus app.css) doppelt den eigenen
border-top-Trenner → hier abschalten. */
.content-footer :global(.content-row + .content-row::before) {
display: none;
}
/* Trailing-Margins im Footer killen: letzter Block (Wrapper mb-4) und
letzter Absatz (.markdown p mb-4) sonst → Leerraum unter Copyright/Lizenz
vor dem Footer-Rand (zusätzlich zum py-10-Padding). */
.content-footer :global(.content-row:last-child > *:last-child),
.content-footer :global(.markdown > p:last-child) {
margin-bottom: 0;
}
.content-footer :global(a) {
color: var(--color-stein-200);
}
/* Markdown-Links (z. B. „Mehr dazu" in Copyright-Zeile) unterstreichen
konsistent mit den Link-Listen, die schon Underline haben. */
.content-footer :global(.markdown a) {
text-decoration: underline;
text-underline-offset: 2px;
}
.content-footer :global(a:hover) {
color: var(--color-stein-0);
}
.content-footer :global(h3) {
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(.prose) {
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' },
],
},
};
+95 -70
View File
@@ -64,6 +64,11 @@
} else {
searchOpen = false;
menuOpen = true;
// Sektion der aktuellen Seite direkt aufgeklappt zeigen.
openSubmenu =
links.find(
(l) => l.children && l.children.length > 0 && isActiveTree(l, $page.url.pathname),
)?.label ?? null;
}
}
@@ -111,7 +116,7 @@
<header
id="horizontal-navigation"
class="relative sticky top-0 z-20 border-b border-white/30 shadow-2xl backdrop-blur-md bg-wald-50"
class="relative sticky top-0 z-20 border-b border-white/30 shadow-2xl bg-wald-50"
>
<span class="gradient-blob-bg-inner" aria-hidden="true"></span>
<div class="relative flex items-center justify-between container-custom py-2">
@@ -122,11 +127,11 @@
>
{#if logoSvgHtml}
<span
class="logo-svg h-10 w-auto inline-flex items-center text-inherit [&_svg]:block [&_svg]:h-full [&_svg]:w-auto [&_svg]:max-h-10"
class="logo-svg h-8 lg:h-10 w-auto inline-flex items-center text-inherit [&_svg]:block [&_svg]:h-full [&_svg]:w-auto [&_svg]:max-h-8 lg:[&_svg]:max-h-10"
aria-hidden="true">{@html logoSvgHtml}</span
>
{:else if logoUrl && logoUrl.trim() !== ""}
<img src={logoUrl} alt="" class="h-10 w-auto object-contain" />
<img src={logoUrl} alt="" class="h-8 lg:h-10 w-auto object-contain" />
{:else}
<span>{t(T.site_name_fallback)}</span>
{/if}
@@ -140,26 +145,26 @@
{#each links as link}
{@const active = isActiveTree(link, $page.url.pathname)}
{#if link.children && link.children.length > 0}
<div class="relative group">
<div class="relative group inline-flex items-center">
{#if link.href && link.href !== "#"}
<a
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-expanded="false"
>
{link.label}
<Icon icon="mdi:chevron-down" class="size-3" aria-hidden="true" />
<Icon icon="lucide:chevron-down" class="size-3 shrink-0" aria-hidden="true" />
</a>
{:else}
<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-expanded="false"
>
{link.label}
<Icon icon="mdi:chevron-down" class="size-3" aria-hidden="true" />
<Icon icon="lucide:chevron-down" class="size-3 shrink-0" aria-hidden="true" />
</button>
{/if}
<div
@@ -186,57 +191,63 @@
{:else}
<a
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}
>
{link.label}
</a>
{/if}
{/each}
<div class="w-1 h-4"></div>
{#each socialLinks as social}
{@const isInternal = social.href.startsWith("/")}
<a
href={social.href}
target={isInternal ? undefined : "_blank"}
rel={isInternal ? undefined : "noopener noreferrer"}
class="p-1.5 -m-1.5 rounded-xs transition-colors flex items-center justify-center"
aria-label={social.label || t(T.header_social_aria)}
title={social.label || t(T.header_social_aria)}
<div class="ml-2 flex items-center gap-1.5">
{#each socialLinks as social}
{@const isInternal = social.href.startsWith("/")}
{@const sActive = isInternal && isActiveLink(social.href, $page.url.pathname)}
<a
href={social.href}
target={isInternal ? undefined : "_blank"}
rel={isInternal ? undefined : "noopener noreferrer"}
class="rounded-full border border-wald-700/15 bg-wald-700/8 px-2.5 py-1 transition-opacity hover:opacity-70 inline-flex items-center gap-1.5 {sActive ? 'font-bold' : ''}"
aria-current={sActive ? "page" : undefined}
aria-label={social.label || t(T.header_social_aria)}
title={social.label || t(T.header_social_aria)}
>
<Icon icon={social.icon} class="text-sm" aria-hidden="true" />
{#if social.label}
<span class="text-[.7rem] leading-none">{social.label}</span>
{/if}
</a>
{/each}
<button
type="button"
class="rounded-full border border-wald-700/15 bg-wald-700/8 px-2.5 py-1 transition-opacity hover:opacity-70 inline-flex items-center justify-center"
aria-label={searchOpen
? t(T.header_search_close)
: t(T.header_search_open)}
title={searchOpen
? t(T.header_search_close)
: t(T.header_search_open)}
aria-expanded={searchOpen}
aria-controls="search-panel"
onclick={() => (searchOpen = !searchOpen)}
>
<Icon icon={social.icon} aria-hidden="true" />
</a>
{/each}
<button
type="button"
class="p-2 -mr-2 rounded-xs transition-colors flex items-center justify-center"
aria-label={searchOpen
? t(T.header_search_close)
: t(T.header_search_open)}
title={searchOpen
? t(T.header_search_close)
: t(T.header_search_open)}
aria-expanded={searchOpen}
aria-controls="search-panel"
onclick={() => (searchOpen = !searchOpen)}
>
{#if searchOpen}
<Icon
icon="mdi:close"
class="text-error!"
aria-hidden="true"
/>
{:else}
<Icon icon="mdi:magnify" aria-hidden="true" />
{/if}
</button>
{#if searchOpen}
<Icon
icon="lucide:x"
class="text-sm text-error!"
aria-hidden="true"
/>
{:else}
<Icon icon="lucide:search" class="text-sm" aria-hidden="true" />
{/if}
</button>
</div>
</nav>
<!-- Mobile: Such- und Burger-Buttons (unter lg) -->
<div class="lg:hidden flex items-center gap-1">
<button
type="button"
class="p-2 rounded-xs transition-colors flex items-center justify-center"
class="rounded-full border border-wald-700/15 bg-wald-700/8 px-2.5 py-1 transition-opacity hover:opacity-70 inline-flex items-center justify-center"
aria-label={searchOpen
? t(T.header_search_close)
: t(T.header_search_open)}
@@ -246,21 +257,21 @@
>
{#if searchOpen}
<Icon
icon="mdi:close"
class="text-2xl text-error!"
icon="lucide:x"
class="text-sm text-error!"
aria-hidden="true"
/>
{:else}
<Icon
icon="mdi:magnify"
class="text-2xl"
icon="lucide:search"
class="text-sm"
aria-hidden="true"
/>
{/if}
</button>
<button
type="button"
class="p-2 -mr-2 hover:bg-stein-0/10 rounded-xs transition-colors flex items-center justify-center"
class="-mr-1 inline-flex items-center gap-1.5 rounded-full border border-wald-600/40 bg-wald-600/25 px-2.5 py-1 text-wald-800 transition-opacity hover:opacity-70"
aria-label={menuOpen
? t(T.header_menu_close)
: t(T.header_menu_open)}
@@ -274,14 +285,11 @@
: t(T.header_menu_open)}</span
>
{#if menuOpen}
<Icon
icon="mdi:close"
class="text-2xl"
aria-hidden="true"
/>
<Icon icon="lucide:x" class="text-sm" aria-hidden="true" />
{:else}
<Icon icon="mdi:menu" class="text-2xl" aria-hidden="true" />
<Icon icon="lucide:menu" class="text-sm" aria-hidden="true" />
{/if}
<span class="text-[.7rem] leading-none" aria-hidden="true">{menuOpen ? "Schließen" : "Menü"}</span>
</button>
</div>
</div>
@@ -292,16 +300,26 @@
{#if menuOpen}
<div
id="mobile-nav"
class="lg:hidden border-t border-stein-0/10 bg-stein-900/98 backdrop-blur overflow-hidden"
class="lg:hidden absolute left-0 right-0 top-full z-30 shadow-2xl border-t border-stein-0/10 bg-stein-900/90 backdrop-blur-lg max-h-[calc(100dvh-3.5rem)] overflow-y-auto overscroll-contain"
role="dialog"
aria-label="Navigation"
in:slide={{ duration: 200 }}
out:slide={{ duration: 150 }}
>
<nav
class="container-custom py-4 flex flex-col gap-1"
class="container-custom pt-4 pb-[calc(4rem+env(safe-area-inset-bottom))] flex flex-col gap-1"
aria-label={t(T.header_nav_aria)}
>
<div class="grid grid-cols-2 gap-2.5 pb-4 mb-2 border-b border-stein-0/10">
<a href="/mitmachen/" class="inline-flex items-center justify-center gap-1.5 rounded-full border border-wald-400/40 px-3 py-2 text-sm font-medium text-wald-200 transition-colors hover:bg-stein-0/10" onclick={closeMenu}>
<Icon icon="lucide:heart-handshake" class="size-4 shrink-0" aria-hidden="true" />
Mitmachen
</a>
<a href="/termin-melden" class="inline-flex items-center justify-center gap-1.5 rounded-full border border-gelb-400/40 px-3 py-2 text-sm font-medium text-gelb-300 transition-colors hover:bg-stein-0/10" onclick={closeMenu}>
<Icon icon="lucide:calendar-plus" class="size-4 shrink-0" aria-hidden="true" />
Termin melden
</a>
</div>
{#each links as link}
{@const active = isActiveTree(link, $page.url.pathname)}
{#if link.children && link.children.length > 0}
@@ -309,12 +327,12 @@
<div>
<button
type="button"
class="flex w-full items-center justify-between py-4 px-3 rounded-xs transition-colors text-base text-stein-100 hover:bg-stein-0/10 border-l-4 {active ? 'border-wald-400 font-bold text-wald-300' : 'border-transparent'}"
class="flex w-full items-center justify-between py-2.5 px-3 rounded-xs transition-colors text-sm text-stein-100 hover:bg-stein-0/10 border-l-4 {active ? 'border-wald-400 text-wald-300' : 'border-transparent'}"
aria-expanded={expanded}
onclick={() => toggleSubmenu(link.label)}
>
<span>{link.label}</span>
<Icon icon={expanded ? "mdi:chevron-up" : "mdi:chevron-down"} class="size-4" aria-hidden="true" />
<Icon icon={expanded ? "lucide:chevron-up" : "lucide:chevron-down"} class="size-4" aria-hidden="true" />
</button>
{#if expanded}
<div class="ml-6 border-l border-stein-0/10" in:slide={{ duration: 150 }} out:slide={{ duration: 100 }}>
@@ -322,7 +340,7 @@
{@const pActive = isActiveLink(link.href, $page.url.pathname)}
<a
href={link.href}
class="block py-2 px-3 text-sm {pActive ? 'font-bold text-wald-300' : 'text-stein-200 hover:text-stein-0'}"
class="block py-2 px-3 text-sm {pActive ? 'text-wald-300' : 'text-stein-200 hover:text-stein-0'}"
aria-current={pActive ? "page" : undefined}
onclick={closeMenu}
>
@@ -333,7 +351,7 @@
{@const cActive = isActiveLink(child.href, $page.url.pathname)}
<a
href={child.href}
class="block py-2 px-3 text-sm {cActive ? 'font-bold text-wald-300' : 'text-stein-200 hover:text-stein-0'}"
class="block py-2 px-3 text-sm {cActive ? 'text-wald-300' : 'text-stein-200 hover:text-stein-0'}"
aria-current={cActive ? "page" : undefined}
onclick={closeMenu}
>
@@ -346,8 +364,8 @@
{:else}
<a
href={link.href}
class="block py-4 px-3 rounded-xs transition-colors text-base {active
? 'font-bold text-wald-300 bg-wald-700/20 border-l-4 border-wald-400'
class="block py-2.5 px-3 rounded-xs transition-colors text-sm {active
? 'text-wald-300 bg-wald-700/20 border-l-4 border-wald-400'
: 'text-stein-100 hover:text-stein-0 hover:bg-stein-0/10 border-l-4 border-transparent'}"
aria-current={active ? "page" : undefined}
onclick={closeMenu}
@@ -357,27 +375,34 @@
{/if}
{/each}
{#if socialLinks.length > 0}
<div
class="flex flex-wrap gap-2 pt-2 mt-2 border-t border-stein-0/10"
>
<div class="flex flex-col gap-1 pt-2 mt-2 border-t border-stein-0/10">
{#each socialLinks as social}
{@const isInternal = social.href.startsWith("/")}
{@const sActive =
isInternal &&
isActiveLink(social.href, $page.url.pathname)}
<a
href={social.href}
target={isInternal ? undefined : "_blank"}
rel={isInternal
? undefined
: "noopener noreferrer"}
class="inline-flex p-2 text-stein-100 hover:text-stein-0 hover:bg-stein-0/10 rounded-xs transition-colors"
class="flex items-center gap-3 py-2.5 px-3 rounded-xs transition-colors text-sm border-l-4 {sActive
? 'text-wald-300 bg-wald-700/20 border-wald-400'
: 'text-stein-100 hover:text-stein-0 hover:bg-stein-0/10 border-transparent'}"
aria-current={sActive ? "page" : undefined}
aria-label={social.label ||
t(T.header_social_aria)}
onclick={closeMenu}
>
<Icon
icon={social.icon}
class="text-[1.5rem]"
class="text-sm"
aria-hidden="true"
/>
{#if social.label}
<span>{social.label}</span>
{/if}
</a>
{/each}
</div>
+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">
import { onMount, onDestroy } from "svelte";
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
import IconButton from "$lib/components/IconButton.svelte";
let {
src,
@@ -33,12 +32,5 @@
class="max-w-full max-h-full object-contain rounded shadow-2xl cursor-default"
onclick={(e) => e.stopPropagation()}
/>
<button
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="mdi:close" class="size-5" aria-hidden="true" />
</button>
<IconButton icon="lucide:x" label="Schließen" variant="ghost" size="md" onclick={onclose} class="absolute top-4 right-4" />
</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 },
};
+60
View File
@@ -0,0 +1,60 @@
<!--
Reusable disclosure box for short collapsible info notes.
- `title` — always-visible summary line
- `body` — HTML rendered inside the panel ({@html})
- `variant` — colour palette without callers passing raw Tailwind:
stein → neutral data-disclaimer tone
amber → soft warning, used for uncertainty-flagged hints
- `open` — initial state; default closed
-->
<script lang="ts">
import Icon from "@iconify/svelte";
type Variant = "stein" | "amber";
let {
title,
body = "",
variant = "stein",
open = false,
iconName = "lucide:info",
}: {
title: string;
body?: string;
variant?: Variant;
open?: boolean;
iconName?: string;
} = $props();
const palettes = {
stein: {
container: "border-stein-200 bg-stein-50",
summary: "text-stein-700 hover:bg-stein-100",
icon: "text-stein-500",
body: "border-stein-200 text-stein-600",
},
amber: {
container: "border-amber-300 bg-amber-100/70",
summary: "text-amber-800 hover:bg-amber-200/70",
icon: "text-amber-700",
body: "border-amber-200 text-stein-700",
},
} as const;
const p = $derived(palettes[variant]);
</script>
<details class="info-accordion rounded-md border text-xs {p.container}" {open}>
<summary class="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 font-medium leading-snug list-none {p.summary}">
<Icon icon={iconName} class="size-3.5 shrink-0 {p.icon}" />
<span class="flex-1">{title}</span>
<Icon icon="lucide:chevron-down" class="info-accordion-chevron size-3.5 shrink-0 transition-transform {p.icon}" />
</summary>
<div class="markdown prose prose-sm prose-zinc max-w-none border-t px-3 py-2 leading-relaxed {p.body}">{@html body}</div>
</details>
<style>
.info-accordion[open] :global(.info-accordion-chevron) {
transform: rotate(180deg);
}
</style>
+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 } };
+10 -6
View File
@@ -11,16 +11,20 @@
totalPages = 1,
basePath = "/posts",
activeTag = null,
sort = "newest",
}: {
currentPage: number;
totalPages: number;
basePath?: string;
activeTag?: string | null;
sort?: "newest" | "oldest";
} = $props();
const query = $derived(sort === "oldest" ? "?sort=oldest" : "");
function tagHref(tagSlug: string | null): string {
if (!tagSlug) return `${basePath}/`;
return `${basePath}/tag/${encodeURIComponent(tagSlug)}/`;
if (!tagSlug) return `${basePath}/${query}`;
return `${basePath}/tag/${encodeURIComponent(tagSlug)}/${query}`;
}
function pageHref(page: number): string {
@@ -28,7 +32,7 @@
const tagPart = activeTag
? `/tag/${encodeURIComponent(activeTag)}`
: "";
return `${basePath}${tagPart}/page/${page}/`;
return `${basePath}${tagPart}/page/${page}/${query}`;
}
const showPagination = $derived(totalPages > 1);
@@ -46,7 +50,7 @@
});
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 =
"text-stein-700 border-stein-200 bg-white hover:bg-stein-50 hover:border-wald-300";
const activeColors =
@@ -60,7 +64,7 @@
<div class="flex items-center">
{#if hasPrev}
<a href={pageHref(prevPage)} class="{baseShape} {navShape} {inactiveColors}" aria-label={t(T.pagination_prev)}>
<Icon icon="mdi:chevron-left" class="size-4" aria-hidden="true" />
<Icon icon="lucide:chevron-left" class="size-4" aria-hidden="true" />
<span class="hidden sm:inline">{t(T.pagination_prev)}</span>
</a>
{/if}
@@ -76,7 +80,7 @@
{#if hasNext}
<a href={pageHref(nextPage)} class="{baseShape} {navShape} {inactiveColors}" aria-label={t(T.pagination_next)}>
<span class="hidden sm:inline">{t(T.pagination_next)}</span>
<Icon icon="mdi:chevron-right" class="size-4" aria-hidden="true" />
<Icon icon="lucide:chevron-right" class="size-4" aria-hidden="true" />
</a>
{/if}
</div>
+133 -5
View File
@@ -1,10 +1,13 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { onMount, onDestroy } from "svelte";
import "$lib/iconify-offline";
import { env } from "$env/dynamic/public";
import { useTranslate, T } from "$lib/translations";
import QrButton from "$lib/components/QrButton.svelte";
import SocialImageModal from "$lib/components/SocialImageModal.svelte";
let {
url,
title,
@@ -12,6 +15,7 @@
commentPageId = null,
commentEnvironment,
commentTargetId = "comments-section",
socialSlug = null,
class: cls = "",
}: {
/** Absolute URL of the page being shared. */
@@ -26,9 +30,22 @@
commentEnvironment?: string;
/** DOM id of the comment accordion (used by the count-badge click). */
commentTargetId?: string;
/** Wenn gesetzt: zeigt Social-Bild-Button, der Modal öffnet. */
socialSlug?: string | null;
class?: string;
} = $props();
let socialModalOpen = $state(false);
// Direkt-Link-Support: ?social=1 öffnet das Social-Bild-Modal automatisch
onMount(() => {
if (!socialSlug) return;
const params = new URLSearchParams(window.location.search);
if (params.get("social") === "1") {
socialModalOpen = true;
}
});
const t = useTranslate();
// ── Comment count fetch (only when commentPageId is set) ────────────────
@@ -139,6 +156,73 @@
function print() {
window.print();
}
// ── Vorlesen (Web Speech API) ───────────────────────────────────────────
// Button erscheint nur, wenn der Browser Sprachausgabe unterstützt.
let canSpeak = $state(false);
let speaking = $state(false);
let paused = $state(false);
$effect(() => {
canSpeak = typeof window !== "undefined" && "speechSynthesis" in window;
});
function getArticleText(): string {
const art = document.querySelector("article");
if (!art) return "";
const clone = art.cloneNode(true) as HTMLElement;
clone
.querySelectorAll("[hidden], .sr-only, script, style, nav, figure figcaption")
.forEach((n) => n.remove());
return (clone.textContent ?? "").replace(/\s+/g, " ").trim();
}
function speak() {
const synth = window.speechSynthesis;
synth.cancel();
const text = getArticleText();
if (!text) return;
// In Sätze zerlegen — manche Engines schneiden lange Utterances ab.
const chunks = text.match(/[^.!?]+[.!?]*(?:\s|$)/g) ?? [text];
const voice =
synth.getVoices().find((v) => v.lang?.toLowerCase().startsWith("de")) ?? null;
chunks.forEach((c, i) => {
const u = new SpeechSynthesisUtterance(c.trim());
u.lang = "de-DE";
if (voice) u.voice = voice;
if (i === chunks.length - 1)
u.onend = () => {
speaking = false;
paused = false;
};
synth.speak(u);
});
speaking = true;
paused = false;
}
function toggleSpeak() {
const synth = window.speechSynthesis;
if (!speaking) {
speak();
} else if (paused) {
synth.resume();
paused = false;
} else {
synth.pause();
paused = true;
}
}
function stopSpeak() {
window.speechSynthesis.cancel();
speaking = false;
paused = false;
}
onDestroy(() => {
if (typeof window !== "undefined" && "speechSynthesis" in window)
window.speechSynthesis.cancel();
});
</script>
<!--
@@ -157,7 +241,7 @@
title={t(T.comments_jump_to)}
class="no-underline! inline-flex items-center gap-1 rounded-full border border-stein-200 bg-white px-2.5 py-1 text-xs font-medium text-stein-700! hover:bg-stein-50 hover:border-stein-300 transition-colors"
>
<Icon icon="mdi:comment-outline" class="size-3.5" />
<Icon icon="lucide:message-circle" class="size-3.5" />
<span class="tabular-nums">{commentCount ?? "…"}</span>
</a>
{/if}
@@ -172,11 +256,33 @@
title={t(T.post_action_share)}
>
<Icon
icon={shared ? "mdi:check" : "mdi:share-variant-outline"}
icon={shared ? "lucide:check" : "lucide:share-2"}
class="size-3.5"
/>
</button>
{/if}
{#if canSpeak}
<button
type="button"
onclick={toggleSpeak}
class="inline-flex items-center px-2.5 py-1 text-stein-600 hover:bg-stein-50 hover:text-stein-900 transition-colors {speaking && !paused ? 'text-wald-700!' : ''}"
aria-label={!speaking ? "Vorlesen" : paused ? "Weiterlesen" : "Pause"}
title={!speaking ? "Vorlesen" : paused ? "Weiterlesen" : "Pause"}
>
<Icon icon={!speaking ? "lucide:volume-2" : paused ? "lucide:play" : "lucide:pause"} class="size-3.5" />
</button>
{#if speaking}
<button
type="button"
onclick={stopSpeak}
class="inline-flex items-center px-2.5 py-1 text-stein-600 hover:bg-stein-50 hover:text-stein-900 transition-colors"
aria-label="Vorlesen stoppen"
title="Vorlesen stoppen"
>
<Icon icon="lucide:square" class="size-3.5" />
</button>
{/if}
{/if}
<button
type="button"
onclick={copyLink}
@@ -184,7 +290,7 @@
aria-label={copied ? t(T.post_action_copied) : t(T.post_action_copy)}
title={copied ? t(T.post_action_copied) : t(T.post_action_copy)}
>
<Icon icon={copied ? "mdi:check" : "mdi:link-variant"} class="size-3.5" />
<Icon icon={copied ? "lucide:check" : "lucide:link"} class="size-3.5" />
</button>
<button
type="button"
@@ -193,8 +299,19 @@
aria-label={t(T.post_action_print)}
title={t(T.post_action_print)}
>
<Icon icon="mdi:printer-outline" class="size-3.5" />
<Icon icon="lucide:printer" class="size-3.5" />
</button>
{#if socialSlug}
<button
type="button"
onclick={() => (socialModalOpen = true)}
class="inline-flex items-center px-2.5 py-1 text-stein-600 hover:bg-stein-50 hover:text-stein-900 transition-colors"
aria-label="Social-Bild Vorschau"
title="Social-Bild Vorschau (für Instagram, Facebook, WhatsApp)"
>
<Icon icon="lucide:image" class="size-3.5" />
</button>
{/if}
<QrButton
value={url}
label={title}
@@ -203,12 +320,23 @@
/>
</div>
{#if socialSlug}
<SocialImageModal
open={socialModalOpen}
title={title}
slug={socialSlug}
endpointBase="/api/social/post"
downloadPrefix="beitrag"
onclose={() => (socialModalOpen = false)}
/>
{/if}
{#if readingTimeMinutes != null && readingTimeMinutes > 0}
<span
class="inline-flex items-center gap-1 rounded-full border border-stein-200 bg-white px-2.5 py-1 text-xs text-stein-500"
title={t(T.post_action_reading_time, { minutes: readingTimeMinutes })}
>
<Icon icon="mdi:clock-outline" class="size-3.5" />
<Icon icon="lucide:clock" class="size-3.5" />
<span class="tabular-nums"
>{t(T.post_action_reading_time, { minutes: readingTimeMinutes })}</span
>
+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 RustyImage from "./RustyImage.svelte";
import Card from "./Card.svelte";
import Badge from "./Badge.svelte";
import { getPostEventInfo } from "$lib/blog-utils";
import { useTranslate, T } from "$lib/translations";
import { marked } from "$lib/markdown-safe";
@@ -50,6 +51,16 @@
const isPastEvent = $derived(
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>
<Card
@@ -90,23 +101,37 @@
{/if}
{#if isPastEvent}
<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">
Vergangen
</span>
<Badge variant="solid" color="slate" uppercase class="shadow-sm">Vergangen</Badge>
</div>
{/if}
{#if commentCount != null}
<div class="absolute top-2 left-2">
<div class="absolute top-2 left-2 flex flex-col items-start gap-1">
{#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
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 })}
title={t(T.comments_count_aria, { count: commentCount })}
>
<Icon icon="mdi:comment-outline" class="size-3" />
{commentCount}
<Badge
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>
</div>
{/if}
{/if}
</div>
</div>
<div class="flex-1 flex flex-col justify-start p-4">
<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 = {};
+1 -1
View File
@@ -24,7 +24,7 @@
onclick={() => (open = true)}
class={cls}
>
<Icon icon="mdi:qrcode" class="size-[1em]" />
<Icon icon="lucide:qr-code" class="size-[1em]" />
</button>
<QrModal value={open ? value : null} {label} {sublabel} onclose={() => (open = false)} />
+9 -7
View File
@@ -3,6 +3,7 @@
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
import QRCode from "qrcode";
import Button from "$lib/ui/Button.svelte";
let {
value,
@@ -60,7 +61,7 @@
onclick={onclose}
>
<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()}
role="dialog"
aria-modal="true"
@@ -71,7 +72,7 @@
class="absolute right-3 top-3 text-stein-400 hover:text-stein-700"
aria-label="Schließen"
>
<Icon icon="mdi:close" class="size-5" />
<Icon icon="lucide:x" class="size-5" />
</button>
{#if label}
<p class="mb-1 text-sm font-medium text-stein-900">{label}</p>
@@ -87,15 +88,16 @@
<div class="flex h-48 items-center justify-center text-xs text-stein-300">Generiere…</div>
{/if}
<p class="mt-3 text-center text-[0.65rem] text-stein-400">QR-Code scannen</p>
<button
type="button"
<Button
variant="outline"
size="sm"
onclick={downloadPng}
disabled={!qrPngUrl}
class="btn-outline btn-sm mt-3 w-full justify-center"
class="mt-3 w-full"
>
<Icon icon="mdi:download" class="size-3.5" />
<Icon icon="lucide:download" class="size-3.5" />
Als PNG herunterladen
</button>
</Button>
</div>
</div>
{/if}
+117 -47
View File
@@ -1,8 +1,10 @@
<script lang="ts">
import { slide } from "svelte/transition";
import { tick } from "svelte";
import { goto } from "$app/navigation";
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
import SearchField from "$lib/ui/SearchField.svelte";
type SearchHit = {
type: "page" | "post";
@@ -25,18 +27,72 @@
let loadError = $state<string | null>(null);
let inputEl = $state<HTMLInputElement | null>(null);
const MAX_RESULTS = 40;
const normalizedQuery = $derived(query.trim().toLowerCase());
const tokens = $derived(normalizedQuery.split(/\s+/).filter((t) => t.length > 0));
// All tokens must match (AND); score by field weight so title hits rank first.
const results = $derived.by<SearchHit[]>(() => {
if (!allHits || normalizedQuery.length < 2) return [];
const q = normalizedQuery;
return allHits
.filter((h) => {
const hay = `${h.title} ${h.subtitle} ${h.excerpt} ${h.slug}`.toLowerCase();
return hay.includes(q);
})
.slice(0, 40);
const scored: { hit: SearchHit; score: number }[] = [];
for (const h of allHits) {
const title = h.title.toLowerCase();
const subtitle = h.subtitle.toLowerCase();
const excerpt = h.excerpt.toLowerCase();
const slug = h.slug.toLowerCase();
let score = 0;
let allMatch = true;
for (const tok of tokens) {
const inTitle = title.includes(tok);
const inSub = subtitle.includes(tok);
const inExcerpt = excerpt.includes(tok);
const inSlug = slug.includes(tok);
if (!(inTitle || inSub || inExcerpt || inSlug)) {
allMatch = false;
break;
}
if (inTitle) score += title.startsWith(tok) ? 12 : 8;
if (inSub) score += 4;
if (inExcerpt) score += 2;
if (inSlug) score += 1;
}
if (allMatch) scored.push({ hit: h, score });
}
scored.sort((a, b) => b.score - a.score);
return scored.slice(0, MAX_RESULTS).map((s) => s.hit);
});
let activeIndex = $state(-1);
let itemEls = $state<(HTMLAnchorElement | null)[]>([]);
// Reset cursor whenever the result set changes.
$effect(() => {
void results;
activeIndex = -1;
});
// Keep the keyboard-selected item visible.
$effect(() => {
if (activeIndex >= 0) itemEls[activeIndex]?.scrollIntoView({ block: "nearest" });
});
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function highlight(text: string): string {
const safe = escapeHtml(text);
if (tokens.length === 0) return safe;
const pattern = tokens
.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
.join("|");
return safe.replace(
new RegExp(`(${pattern})`, "gi"),
'<mark class="bg-wald-400/30 text-wald-100 rounded-xs px-0.5">$1</mark>',
);
}
async function loadIndex() {
if (allHits || loading) return;
loading = true;
@@ -70,7 +126,26 @@
void loadIndex();
tick().then(() => inputEl?.focus());
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") closeSearch();
if (e.key === "Escape") {
closeSearch();
return;
}
const n = results.length;
if (n === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
activeIndex = (activeIndex + 1) % n;
} else if (e.key === "ArrowUp") {
e.preventDefault();
activeIndex = (activeIndex - 1 + n) % n;
} else if (e.key === "Enter" && activeIndex >= 0) {
e.preventDefault();
const href = results[activeIndex]?.href;
if (href) {
closeSearch();
void goto(href);
}
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
@@ -80,79 +155,74 @@
{#if open}
<div
id="search-panel"
class="border-t border-white/30 bg-white/95 backdrop-blur overflow-hidden"
class="absolute left-0 right-0 top-full z-30 shadow-2xl border-t border-stein-0/10 bg-stein-900/90 backdrop-blur-lg max-h-[calc(100dvh-3.5rem)] overflow-y-auto overscroll-contain"
role="region"
aria-label="Suche"
in:slide={{ duration: 200 }}
out:slide={{ duration: 150 }}
>
<div class="container-custom py-4 overflow-auto max-h-[calc(100vh-120px)]">
<label class="relative block max-w-xl mx-auto">
<span class="sr-only">Suche</span>
<span class="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-stein-500">
<Icon icon="mdi:magnify" class="h-5 w-5" />
</span>
<input
bind:this={inputEl}
type="search"
bind:value={query}
placeholder="Seiten und Beiträge durchsuchen…"
class="w-full rounded-xs border border-stein-300 bg-white py-3 pl-10 pr-10 text-sm focus:border-stein-500 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-500 hover:bg-stein-100 hover:text-stein-900"
>
<Icon icon="mdi:close" class="h-4 w-4" />
</button>
{/if}
</label>
<div class="container-custom pt-4 pb-[calc(2rem+env(safe-area-inset-bottom))]">
<SearchField
bind:value={query}
bind:inputEl
tone="dark"
placeholder="Seiten und Beiträge durchsuchen…"
class="max-w-xl mx-auto"
/>
<div class="max-w-xl mx-auto mt-4 text-sm">
<p class="sr-only" role="status" aria-live="polite">
{#if normalizedQuery.length >= 2 && !loading && !loadError}
{results.length === 0 ? "Keine Treffer." : `${results.length} Treffer.`}
{/if}
</p>
{#if loading}
<p class="text-stein-500 text-center py-2">Lade Suchindex…</p>
<p class="text-stein-300 text-center py-2">Lade Suchindex…</p>
{:else if loadError}
<p class="text-fire-600 text-center py-2">Suche nicht verfügbar: {loadError}</p>
<p class="text-fire-400 text-center py-2">Suche nicht verfügbar: {loadError}</p>
{:else if normalizedQuery.length < 2}
<p class="text-stein-500 text-center py-2">Mindestens 2 Zeichen eingeben.</p>
<p class="text-stein-300 text-center py-2">Mindestens 2 Zeichen eingeben.</p>
{:else if results.length === 0}
<p class="text-stein-500 text-center py-2">Keine Treffer.</p>
<p class="text-stein-300 text-center py-2">Keine Treffer.</p>
{:else}
<ul class="divide-y divide-stein-200">
{#each results as hit}
<p class="text-stein-400 text-xs mb-2">
{results.length === MAX_RESULTS ? `${MAX_RESULTS}+ Treffer` : `${results.length} Treffer`} · ↑↓ zum Wählen, ⏎ zum Öffnen
</p>
<ul class="divide-y divide-stein-0/10">
{#each results as hit, i}
<li>
<a
bind:this={itemEls[i]}
href={hit.href}
onclick={closeSearch}
class="flex items-start gap-3 py-3 hover:bg-stein-50 rounded-xs px-2 -mx-2 no-underline"
onmouseenter={() => (activeIndex = i)}
aria-current={i === activeIndex ? "true" : undefined}
class="flex items-start gap-3 py-3 rounded-xs px-2 -mx-2 no-underline {i === activeIndex ? 'bg-stein-0/10' : 'hover:bg-stein-0/10'}"
>
{#if hit.image}
<img
src={hit.image}
alt=""
class="w-20 h-12 object-cover shrink-0 rounded-xs border border-stein-200"
class="w-20 h-12 object-cover shrink-0 rounded-xs border border-stein-0/10"
loading="lazy"
/>
{:else}
<span class="w-20 h-12 shrink-0 bg-stein-100 rounded-xs border border-stein-200 flex items-center justify-center text-stein-400">
<Icon icon={hit.type === "post" ? "mdi:file-document-outline" : "mdi:file-outline"} class="h-5 w-5" />
<span class="w-20 h-12 shrink-0 bg-stein-0/10 rounded-xs border border-stein-0/10 flex items-center justify-center text-stein-400">
<Icon icon={hit.type === "post" ? "lucide:file-text" : "lucide:file"} class="h-5 w-5" />
</span>
{/if}
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 text-[.65rem] uppercase tracking-wide text-stein-500">
<div class="flex items-center gap-2 text-[.65rem] uppercase tracking-wide text-stein-300">
<span>{hit.type === "post" ? "Beitrag" : "Seite"}</span>
{#if hit.isEvent && hit.eventDate}
<span class="text-wald-700">{formatDate(hit.eventDate)}</span>
<span class="text-wald-300">{formatDate(hit.eventDate)}</span>
{:else if hit.created}
<span>{formatDate(hit.created)}</span>
{/if}
</div>
<div class="font-medium text-stein-900 truncate">{hit.title}</div>
<div class="font-medium text-stein-0 truncate">{@html highlight(hit.title)}</div>
{#if hit.excerpt}
<div class="text-xs text-stein-600 line-clamp-2">{hit.excerpt}</div>
<div class="text-xs text-stein-300 line-clamp-2">{@html highlight(hit.excerpt)}</div>
{/if}
</div>
</a>
@@ -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>
+223
View File
@@ -0,0 +1,223 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
import Button from "$lib/ui/Button.svelte";
let {
open,
title,
slug,
endpointBase,
downloadPrefix = "social",
onclose,
}: {
open: boolean;
title: string;
slug: string;
/** "/api/social/calendar" oder "/api/social/post" */
endpointBase: string;
/** Prefix für Download-Dateinamen, z.B. "termin" oder "beitrag" */
downloadPrefix?: string;
onclose: () => void;
} = $props();
let theme = $state<"wald" | "transparent-onlight" | "transparent-ondark">("wald");
let linkCopied = $state(false);
/** Direktlink, der dieses Modal erneut öffnet — abhängig von endpointBase. */
const directLink = $derived.by(() => {
if (typeof window === "undefined") return "";
const origin = window.location.origin;
if (endpointBase.includes("/calendar")) {
return `${origin}/kalender/?social=${encodeURIComponent(slug)}`;
}
if (endpointBase.includes("/post")) {
return `${origin}/post/${encodeURIComponent(slug)}?social=1`;
}
return `${origin}/`;
});
async function copyDirectLink() {
try {
await navigator.clipboard.writeText(directLink);
linkCopied = true;
setTimeout(() => (linkCopied = false), 2000);
} catch {
linkCopied = false;
}
}
// Loading-State pro Format. Skeleton wird angezeigt bis das jeweilige
// Bild fertig geladen ist. Bei Theme-Wechsel resetten.
let loadedOg = $state(false);
let loadedSq = $state(false);
let loadedStory = $state(false);
$effect(() => {
// Reset bei Theme-Änderung (neuer Request → neuer Loading-Zustand)
void theme;
loadedOg = false;
loadedSq = false;
loadedStory = false;
});
const themeQ = $derived(theme === "wald" ? "" : `&theme=${theme}`);
const ogUrl = $derived(`${endpointBase}/${slug}?format=og${themeQ}`);
const sqUrl = $derived(`${endpointBase}/${slug}?format=square${themeQ}`);
const storyUrl = $derived(`${endpointBase}/${slug}?format=story${themeQ}`);
const previewBg = $derived(
theme === "wald"
? "bg-stein-50"
: theme === "transparent-onlight"
? "bg-[repeating-linear-gradient(45deg,#f5f5f4,#f5f5f4_10px,#e7e5e4_10px,#e7e5e4_20px)]"
: "bg-[repeating-linear-gradient(45deg,#292524,#292524_10px,#1c1917_10px,#1c1917_20px)]",
);
</script>
{#if open}
<div
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 p-4"
role="dialog"
aria-modal="true"
aria-label="Social-Bild-Vorschau"
onclick={onclose}
onkeydown={(e) => { if (e.key === 'Escape') onclose(); }}
tabindex="-1"
>
<div
class="relative max-h-[92vh] w-full max-w-5xl overflow-y-auto rounded-lg bg-white p-5 shadow-2xl"
onclick={(e) => e.stopPropagation()}
role="presentation"
>
<div class="mb-4 flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="text-xs uppercase tracking-wider text-stein-500">Social-Bild</p>
<p class="truncate text-sm font-semibold text-stein-800">{title}</p>
</div>
<div class="flex shrink-0 items-center gap-1">
<button
type="button"
onclick={copyDirectLink}
class="inline-flex items-center gap-1 rounded-md border border-stein-300 bg-white px-2 py-1 text-[11px] font-medium text-stein-700 hover:bg-stein-50"
title="Direktlink zu dieser Vorschau kopieren"
aria-label="Direktlink kopieren"
>
<Icon icon={linkCopied ? "lucide:check" : "lucide:link"} class="size-3.5" />
{linkCopied ? "Kopiert" : "Direktlink"}
</button>
<button
type="button"
onclick={onclose}
class="text-stein-400 hover:text-stein-700"
aria-label="Schließen"
>
<Icon icon="lucide:x" class="size-5" />
</button>
</div>
</div>
<div class="mb-4 rounded-lg border border-stein-200 bg-stein-50 p-3">
<p class="mb-2 text-[11px] font-medium uppercase tracking-wider text-stein-500">Hintergrund</p>
<div class="flex flex-wrap gap-2">
<button
type="button"
onclick={() => (theme = "wald")}
class="flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition
{theme === 'wald' ? 'border-wald-500 bg-wald-100 text-wald-800' : 'border-stein-300 bg-white text-stein-700 hover:bg-stein-50'}"
>
<span class="size-3 rounded-full bg-gradient-to-br from-wald-900 to-wald-600"></span>
Wald (Standard)
</button>
<button
type="button"
onclick={() => (theme = "transparent-onlight")}
class="flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition
{theme === 'transparent-onlight' ? 'border-wald-500 bg-wald-100 text-wald-800' : 'border-stein-300 bg-white text-stein-700 hover:bg-stein-50'}"
title="Transparenter Hintergrund mit dunklem Text"
>
<span class="size-3 rounded-full border border-stein-300 bg-[repeating-linear-gradient(45deg,#fff,#fff_2px,#e5e5e5_2px,#e5e5e5_4px)]"></span>
Transparent · helle Hintergründe
</button>
<button
type="button"
onclick={() => (theme = "transparent-ondark")}
class="flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition
{theme === 'transparent-ondark' ? 'border-wald-500 bg-wald-100 text-wald-800' : 'border-stein-300 bg-white text-stein-700 hover:bg-stein-50'}"
title="Transparenter Hintergrund mit hellem Text"
>
<span class="size-3 rounded-full border border-stein-300 bg-[repeating-linear-gradient(45deg,#1c1917,#1c1917_2px,#44403c_2px,#44403c_4px)]"></span>
Transparent · dunkle Hintergründe
</button>
</div>
{#if theme !== "wald"}
<p class="mt-2 text-[10px] text-stein-500">
Vorschau-Hintergrund zeigt nur, wie der Text aussieht — heruntergeladene PNG hat echten Alpha-Kanal.
</p>
{/if}
</div>
<div class="grid gap-4 sm:grid-cols-3">
<div class="flex flex-col gap-2">
<p class="text-xs font-medium text-stein-600">Quer · 1200 × 630</p>
<div class="relative w-full aspect-[1200/630] rounded-sm border border-stein-200 overflow-hidden {previewBg}">
{#if !loadedOg}
<div class="absolute inset-0 animate-pulse bg-gradient-to-br from-stein-200 via-stein-300 to-stein-200"></div>
{/if}
<img
src={ogUrl}
alt="Social-Bild Quer"
class="absolute inset-0 h-full w-full transition-opacity duration-200 {loadedOg ? 'opacity-100' : 'opacity-0'}"
loading="eager"
onload={() => (loadedOg = true)}
/>
</div>
<Button href={ogUrl} download={`${downloadPrefix}-${slug}-og-${theme}.png`} variant="outline" size="sm">
<Icon icon="lucide:download" class="size-3.5" />
Quer herunterladen
</Button>
</div>
<div class="flex flex-col gap-2">
<p class="text-xs font-medium text-stein-600">Quadrat · 1080 × 1080</p>
<div class="relative mx-auto w-full aspect-square rounded-sm border border-stein-200 overflow-hidden {previewBg}">
{#if !loadedSq}
<div class="absolute inset-0 animate-pulse bg-gradient-to-br from-stein-200 via-stein-300 to-stein-200"></div>
{/if}
<img
src={sqUrl}
alt="Social-Bild Quadrat"
class="absolute inset-0 h-full w-full transition-opacity duration-200 {loadedSq ? 'opacity-100' : 'opacity-0'}"
loading="eager"
onload={() => (loadedSq = true)}
/>
</div>
<Button href={sqUrl} download={`${downloadPrefix}-${slug}-square-${theme}.png`} variant="outline" size="sm">
<Icon icon="lucide:download" class="size-3.5" />
Quadrat herunterladen
</Button>
</div>
<div class="flex flex-col gap-2">
<p class="text-xs font-medium text-stein-600">Story · 1080 × 1920</p>
<div class="relative mx-auto h-[280px] w-[158px] rounded-sm border border-stein-200 overflow-hidden {previewBg}">
{#if !loadedStory}
<div class="absolute inset-0 animate-pulse bg-gradient-to-br from-stein-200 via-stein-300 to-stein-200"></div>
{/if}
<img
src={storyUrl}
alt="Social-Bild Story"
class="absolute inset-0 h-full w-full transition-opacity duration-200 {loadedStory ? 'opacity-100' : 'opacity-0'}"
loading="eager"
onload={() => (loadedStory = true)}
/>
</div>
<Button href={storyUrl} download={`${downloadPrefix}-${slug}-story-${theme}.png`} variant="outline" size="sm">
<Icon icon="lucide:download" class="size-3.5" />
Story herunterladen
</Button>
</div>
</div>
<p class="mt-4 text-[11px] text-stein-400">
Quer: Facebook/Twitter/Link-Preview. Quadrat: Instagram-Feed. Story: Instagram/WhatsApp Story.
</p>
</div>
</div>
{/if}
+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' },
};
+40 -33
View File
@@ -8,7 +8,7 @@
import type { FullwidthBannerEntry } from "$lib/cms";
interface ResponsiveBannerImage {
sources: { type: string; srcset: string }[];
sources: { type: string; srcset: string; media?: string }[];
fallback: string;
width: number;
height: number;
@@ -68,6 +68,13 @@
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(
responsive != null && responsive.fallback.trim().length > 0,
);
@@ -79,18 +86,9 @@
const showBanner = $derived(hasImage || hasText || hasPageTitle);
const altText = $derived(banner?.headline ?? headline ?? "");
let imgEl = $state<HTMLImageElement | null>(null);
let imgLoaded = $state(false);
const currentSrc = $derived(responsive?.fallback ?? resolvedImages[0] ?? "");
$effect(() => {
// reset on src change, then probe cache
void currentSrc;
imgLoaded = false;
queueMicrotask(() => {
if (imgEl?.complete && imgEl.naturalWidth > 0) imgLoaded = true;
});
});
// Kein JS-gesteuertes Fade-in: `opacity-0` im SSR-HTML hält das Banner bis
// nach der Hydration unsichtbar und schob den LCP von ~3s auf ~8s. Das Bild
// malt jetzt progressiv über den LQIP-Background — sichtbar ab erstem Paint.
</script>
{#if showBanner}
@@ -103,23 +101,19 @@
{#if hasResponsive && responsive}
{#key responsive.fallback}
<picture class="block w-full max-w-[1920px] mx-auto">
{#each responsive.sources as s (s.type)}
<source type={s.type} srcset={s.srcset} sizes={sizes} />
{#each responsive.sources as s (s.media ? `${s.type}|${s.media}` : s.type)}
<source type={s.type} media={s.media} srcset={s.srcset} sizes={sizes} />
{/each}
<img
src={responsive.fallback}
alt={altText}
class="w-full object-cover max-h-[400px] lg:max-h-[600px] aspect-square sm:aspect-video lg:aspect-24/9 text-transparent transition-opacity duration-500"
class:opacity-0={!imgLoaded}
class:opacity-100={imgLoaded}
class="w-full object-cover max-h-[400px] lg:max-h-[600px] aspect-square sm:aspect-video lg:aspect-24/9 text-transparent"
width={responsive.width}
height={responsive.height}
style={imgStyle}
loading="eager"
fetchpriority="high"
decoding="async"
bind:this={imgEl}
onload={() => (imgLoaded = true)}
/>
</picture>
{/key}
@@ -128,28 +122,41 @@
<img
src={resolvedImages[0]}
alt={altText}
class="block w-full object-cover max-h-[400px] lg:max-h-[600px] max-w-[1920px] mx-auto aspect-square sm:aspect-video lg:aspect-24/9 text-transparent transition-opacity duration-500"
class:opacity-0={!imgLoaded}
class:opacity-100={imgLoaded}
class="block w-full object-cover max-h-[400px] lg:max-h-[600px] max-w-[1920px] mx-auto aspect-square sm:aspect-video lg:aspect-24/9 text-transparent"
width={1920}
height={600}
style={imgStyle}
loading="eager"
fetchpriority="high"
onload={() => (imgLoaded = true)}
/>
{/key}
{/if}
{#if hasPageTitle}
<div class="absolute inset-0 flex items-center justify-start ">
<div class="container-custom mx-auto px-6 relative">
{#if headlineHtml}
<h1 class="bg-fire-400/70 inline-block py-1 px-2 -ml-2 backdrop-blur">{@html headlineHtml}</h1>
{/if}
<div></div>
{#if subheadlineHtml}
<h2 class="bg-white/50 inline-block py-1 px-2 -ml-2 backdrop-blur font-light! text-xl!">{@html subheadlineHtml}</h2>
{/if}
<!-- Verlaufs-Scrim: links dunkel -> rechts transparent, hält Text lesbar ohne das Foto zuzukleistern -->
<div
class="absolute inset-0 bg-gradient-to-r from-black/80 via-black/55 to-transparent"
aria-hidden="true"
></div>
<div class="absolute inset-0 flex items-center justify-start">
<div class="container-custom mx-auto px-6">
<div class="max-w-xl text-white">
{#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>
{/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: () => {},
},
};
+40 -22
View File
@@ -3,6 +3,7 @@
import "$lib/iconify-offline";
import type { WindArea, TextFragment } from "$lib/windkarte";
import { useTranslate, T } from "$lib/translations";
import InfoAccordion from "$lib/components/InfoAccordion.svelte";
let {
area,
@@ -80,8 +81,6 @@
? [
`${area.gebiets_nr}${area.bezeichnung ? ` ${area.bezeichnung}` : ''}`,
area.flaeche_ha != null ? `${t(T.windkarte_label_flaeche)}: ${area.flaeche_ha.toFixed(1)} ha` : null,
area.anlagen_geplant != null ? `${t(T.windkarte_label_anlagen_geplant)}: ${area.anlagen_geplant}` : null,
area.hoehe_max_m != null ? `${t(T.windkarte_label_hoehe_max)}: ${area.hoehe_max_m} m` : null,
]
.filter(Boolean)
.join('\n')
@@ -111,7 +110,7 @@
class="shrink-0 rounded-md p-1.5 text-stein-400 hover:bg-stein-100 hover:text-stein-700 transition-colors"
aria-label={t(T.windkarte_panel_close)}
>
<Icon icon="mdi:close" class="size-5" />
<Icon icon="lucide:x" class="size-5" />
</button>
</div>
@@ -121,34 +120,53 @@
<div class="flex flex-col divide-y divide-stein-100 border border-stein-100 rounded-lg overflow-hidden text-sm">
{#if area.flaeche_ha != null}
<div class="flex items-center justify-between px-3 py-1.5">
<span class="text-xs text-stein-500">{t(T.windkarte_label_flaeche)}</span>
<span class="flex items-center gap-1.5 text-xs text-stein-500">
<Icon icon="lucide:ruler" class="size-3.5 text-stein-400" />
{t(T.windkarte_label_flaeche)}
</span>
<span class="font-semibold text-stein-800">{area.flaeche_ha.toFixed(1)} ha</span>
</div>
{/if}
{#if area.anlagen_geplant != null}
<div class="flex items-center justify-between px-3 py-1.5">
<span class="text-xs text-stein-500">{t(T.windkarte_label_anlagen_geplant)}</span>
<span class="font-semibold text-stein-800">{area.anlagen_geplant}</span>
</div>
{/if}
{#if area.hoehe_max_m != null}
<div class="flex items-center justify-between px-3 py-1.5">
<span class="text-xs text-stein-500">{t(T.windkarte_label_hoehe_max)}</span>
<span class="font-semibold text-stein-800">{area.hoehe_max_m} m</span>
<span class="flex items-center gap-1.5 text-xs text-stein-500">
<Icon icon="lucide:fan" class="size-3.5 text-stein-400" />
{t(T.windkarte_label_anlagen_geplant)}
</span>
<span class="flex items-center gap-1.5 font-semibold text-stein-800">
{area.anlagen_geplant}
{#if area.anlagen_geplant_hinweis}
<Icon icon="lucide:info" class="size-3.5 text-amber-700" />
{/if}
</span>
</div>
{/if}
{#if area.investor}
<div class="flex items-center justify-between px-3 py-1.5">
<span class="text-xs text-stein-500">{t(T.windkarte_label_investor)}</span>
<span class="flex items-center gap-1.5 text-xs text-stein-500">
<Icon icon="lucide:building-2" class="size-3.5 text-stein-400" />
{t(T.windkarte_label_investor)}
</span>
<span class="font-semibold text-stein-800">{area.investor}</span>
</div>
{/if}
</div>
{#if area.anlagen_geplant_hinweis}
<InfoAccordion
title={t(T.windkarte_label_anlagen_hinweis)}
body={area.anlagen_geplant_hinweis}
variant="amber"
/>
{/if}
<!-- Gemeinden -->
{#if area.gemeinden && area.gemeinden.length > 0}
<div>
<p class="mb-2 text-xs font-medium uppercase tracking-wide text-stein-500">{t(T.windkarte_label_gemeinden)}</p>
<p class="mb-2 flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-stein-500">
<Icon icon="lucide:map-pin" class="size-3.5 text-stein-400" />
{t(T.windkarte_label_gemeinden)}
</p>
<div class="flex flex-wrap gap-1.5">
{#each area.gemeinden as g}
<span class="rounded-full bg-himmel-100 px-2.5 py-0.5 text-xs font-medium text-himmel-800">{g}</span>
@@ -168,7 +186,7 @@
<!-- Stellungnahme-Kriterien -->
{#if loadingKriterien}
<div class="flex items-center gap-2 text-xs text-stein-400">
<Icon icon="mdi:loading" class="size-3.5 animate-spin" />
<Icon icon="lucide:loader-circle" class="size-3.5 animate-spin" />
<span>{t(T.windkarte_kriterien_loading)}</span>
</div>
{:else if kriterien.length > 0}
@@ -183,7 +201,7 @@
>
<span class="min-w-0 line-clamp-2">{k.title ?? k._slug}</span>
<Icon
icon={expandedSlug === k._slug ? "mdi:chevron-up" : "mdi:chevron-down"}
icon={expandedSlug === k._slug ? "lucide:chevron-up" : "lucide:chevron-down"}
class="size-3.5 shrink-0 mt-0.5 text-stein-400"
/>
</button>
@@ -204,7 +222,7 @@
href={area.stellungnahmeUrl}
class="flex items-center gap-2 rounded-lg bg-wald-700 px-4 py-3 text-sm font-semibold text-white no-underline hover:bg-wald-800 transition-colors"
>
<Icon icon="mdi:file-document-edit-outline" class="size-4 shrink-0" />
<Icon icon="lucide:file-pen" class="size-4 shrink-0" />
Einwendung erstellen
</a>
{/if}
@@ -215,7 +233,7 @@
href={stellungnahmeHref(area.stellungnahme)}
class="flex items-center gap-2 rounded-lg border border-wald-300 bg-wald-50 px-4 py-3 text-sm font-medium text-wald-700 hover:bg-wald-100 transition-colors"
>
<Icon icon="mdi:file-document-edit-outline" class="size-4 shrink-0" />
<Icon icon="lucide:file-pen" class="size-4 shrink-0" />
{t(T.windkarte_stellungnahme_link)}
</a>
{/if}
@@ -226,7 +244,7 @@
onclick={copyLink}
class="flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-stein-200 px-2 py-2 text-xs font-medium text-stein-600 hover:bg-stein-50 transition-colors whitespace-nowrap"
>
<Icon icon={copied ? "mdi:check" : "mdi:link-variant"} class="size-3.5 shrink-0" />
<Icon icon={copied ? "lucide:check" : "lucide:link"} class="size-3.5 shrink-0" />
{copied ? t(T.windkarte_action_copied) : t(T.windkarte_action_copy_link)}
</button>
{#if osmHref(center)}
@@ -236,7 +254,7 @@
rel="noopener noreferrer"
class="flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-stein-200 px-2 py-2 text-xs font-medium text-stein-600 hover:bg-stein-50 transition-colors whitespace-nowrap"
>
<Icon icon="mdi:map-outline" class="size-3.5 shrink-0" />
<Icon icon="lucide:map" class="size-3.5 shrink-0" />
{t(T.windkarte_action_osm)}
</a>
{/if}
@@ -252,7 +270,7 @@
title={`${t(T.windkarte_action_download_geojson)}\n${exportInfo}`}
class="flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-stein-200 px-2 py-2 text-xs font-medium text-stein-600 no-underline hover:bg-stein-50 transition-colors whitespace-nowrap"
>
<Icon icon="mdi:download" class="size-3.5 shrink-0" />
<Icon icon="lucide:download" class="size-3.5 shrink-0" />
GeoJSON
</a>
<a
@@ -261,7 +279,7 @@
title={`${t(T.windkarte_action_download_kml)}\n${exportInfo}`}
class="flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-stein-200 px-2 py-2 text-xs font-medium text-stein-600 no-underline hover:bg-stein-50 transition-colors whitespace-nowrap"
>
<Icon icon="mdi:download" class="size-3.5 shrink-0" />
<Icon icon="lucide:download" class="size-3.5 shrink-0" />
KML
</a>
</div>
+63 -28
View File
@@ -33,6 +33,7 @@
let _hidden: string[] = [];
$effect(() => {
_hidden = hiddenStatuses;
syncLabelsFn?.();
if (!geojsonLayer) return;
restyleAll(selectedSlug);
// Deselect if selected area's status was just hidden.
@@ -58,7 +59,8 @@
let bufferLayer: import("leaflet").LayerGroup | null = null;
let ring2kmLayer: import("leaflet").LayerGroup | null = null;
let ring5kmLayer: import("leaflet").LayerGroup | null = null;
let labelLayer: import("leaflet").LayerGroup | null = null;
let labelLayers: Record<string, import("leaflet").LayerGroup> = {};
let syncLabelsFn: (() => void) | null = null;
let selectedSlug = $state<string | null>(null);
let geojsonLayer: import("leaflet").GeoJSON | null = null;
let buffering = $state(false);
@@ -198,7 +200,13 @@
map = L.map(mapEl, { zoomControl: true, attributionControl: false }).setView([50.62, 10.42], 10);
L.control.attribution({ position: "bottomleft" }).addTo(map);
labelLayer = L.layerGroup(); // not added to map yet — shown only at zoom >= 13
// One label LayerGroup per status — lets syncLabels respect hiddenStatuses
labelLayers = {
rechtskraeftig: L.layerGroup(),
entwurf_2: L.layerGroup(),
entwurf_2_voraussichtlich: L.layerGroup(),
entwurf_3: L.layerGroup(),
};
onready?.({ reset: () => map!.setView([50.62, 10.42], 10) });
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
@@ -275,27 +283,37 @@
await selectFeature(L, feature, layer, area);
});
// Hover tooltip
// Hover tooltip — labels rendered bold via <strong>, an info glyph
// gets appended after the count when the editor supplied a hint
// string so users know there's more context in the side panel.
const nrLabel = area.gebiets_nr ?? p.name ?? "";
const title = area.bezeichnung
? `${nrLabel} ${area.bezeichnung}`
: nrLabel;
const lines: string[] = [];
if (area.flaeche_ha != null) lines.push(`${t(T.windkarte_label_flaeche)}: ${area.flaeche_ha.toFixed(1)} ha`);
if (area.anlagen_geplant != null) lines.push(`${t(T.windkarte_label_anlagen_geplant)}: ${area.anlagen_geplant}`);
if (area.hoehe_max_m != null) lines.push(`${t(T.windkarte_label_hoehe_max)}: ${area.hoehe_max_m} m`);
if (area.gemeinden && area.gemeinden.length > 0) {
lines.push(`${t(T.windkarte_label_gemeinden)}: ${area.gemeinden.slice(0, 3).join(', ')}${area.gemeinden.length > 3 ? '…' : ''}`);
}
const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const row = (label: string, value: string, suffix = "") =>
`<strong>${esc(label)}:</strong> ${esc(value)}${suffix}`;
const lines: string[] = [];
if (area.flaeche_ha != null) {
lines.push(row(t(T.windkarte_label_flaeche), `${area.flaeche_ha.toFixed(1)} ha`));
}
if (area.anlagen_geplant != null) {
const hint = area.anlagen_geplant_hinweis ? " *" : "";
lines.push(row(t(T.windkarte_label_anlagen_geplant), `${area.anlagen_geplant}`, hint));
}
if (area.gemeinden && area.gemeinden.length > 0) {
lines.push(row(t(T.windkarte_label_gemeinden), area.gemeinden.join(', ')));
}
const tooltipHtml =
`<div class="font-semibold">${esc(title)}</div>` +
(lines.length ? `<div class="mt-0.5 text-xs opacity-90">${lines.map(esc).join('<br/>')}</div>` : '');
`<div class="text-sm font-bold pb-1 mb-1 border-b border-stein-200/60">${esc(title)}</div>` +
(lines.length ? `<div class="text-xs opacity-90 leading-relaxed">${lines.join('<br/>')}</div>` : '');
layer.bindTooltip(tooltipHtml, { sticky: true, className: "windkarte-tooltip" });
// Permanent label via divIcon marker at polygon center
const nr = area.gebiets_nr ?? p.name;
if (nr && labelLayer) {
const featStatus = (feature?.properties?.status ?? "rechtskraeftig") as string;
const targetGroup = labelLayers[featStatus] ?? labelLayers.rechtskraeftig;
if (nr && targetGroup) {
const center = (layer as import("leaflet").Polygon).getBounds().getCenter();
L.marker(center, {
icon: L.divIcon({
@@ -306,29 +324,35 @@
}),
interactive: false,
keyboard: false,
}).addTo(labelLayer);
}).addTo(targetGroup);
}
},
}).addTo(map);
restyleAll(selectedSlug);
// Labels: show from zoom 13, scale font proportionally with zoom
// Labels: show from zoom 13, scale font proportionally with zoom.
// Per-status LayerGroup so hidden statuses also hide their labels.
const syncLabels = () => {
if (!labelLayer || !map) return;
if (!map) return;
const z = map.getZoom();
if (z >= 13) {
if (!map.hasLayer(labelLayer)) map.addLayer(labelLayer);
const fontSize = Math.max(8, Math.min(26, Math.round(9 * Math.pow(2, z - 13))));
labelLayer.eachLayer((l) => {
const el = (l as import("leaflet").Marker).getElement();
const span = el?.querySelector<HTMLElement>(".windkarte-label");
if (span) span.style.fontSize = fontSize + "px";
});
} else {
if (map.hasLayer(labelLayer)) map.removeLayer(labelLayer);
const show = z >= 13;
const fontSize = Math.max(8, Math.min(26, Math.round(9 * Math.pow(2, z - 13))));
for (const [status, layer] of Object.entries(labelLayers)) {
const visible = show && !_hidden.includes(status);
if (visible) {
if (!map.hasLayer(layer)) map.addLayer(layer);
layer.eachLayer((l) => {
const el = (l as import("leaflet").Marker).getElement();
const span = el?.querySelector<HTMLElement>(".windkarte-label");
if (span) span.style.fontSize = fontSize + "px";
});
} else {
if (map.hasLayer(layer)) map.removeLayer(layer);
}
}
};
syncLabelsFn = syncLabels;
map.on("zoomend", syncLabels);
syncLabels();
@@ -370,13 +394,24 @@
border-radius: 6px;
box-shadow: 0 1px 4px rgba(0,0,0,0.15);
color: #333735;
font-size: 12px;
font-size: 13px;
font-weight: 500;
padding: 4px 8px;
padding: 8px 12px;
min-width: 220px;
max-width: 360px;
white-space: normal !important;
line-height: 1.45;
}
:global(.windkarte-tooltip::before) {
display: none;
}
/* Auf Touch-Geräten Tooltip ausblenden: Touch öffnet direkt das
Detail-Panel, der Tooltip bricht sonst links aus dem Karten-Bereich. */
@media (hover: none) {
:global(.windkarte-tooltip) {
display: none !important;
}
}
:global(.windkarte-label-wrap) {
background: none !important;
border: none !important;
@@ -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 { downloadVCard, downloadVCardAll, buildVCardString } from "$lib/vcard";
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();
@@ -121,24 +124,21 @@
<!-- Search + Filter bar -->
<div class="no-print mb-4 flex flex-col gap-2">
<div class="flex gap-2">
<div class="relative max-w-sm flex-1">
<Icon icon="mdi:magnify" class="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-stein-400" />
<input
type="search"
placeholder="Suchen…"
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>
<SearchField
bind:value={search}
placeholder="Suchen…"
ariaLabel="Kontakte durchsuchen"
class="max-w-sm flex-1"
/>
<!-- 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
type="button"
onclick={() => { viewMode = "alpha"; activeLetter = null; }}
class="px-2 py-1 transition-colors {viewMode === 'alpha' ? 'bg-wald-500 text-white' : 'bg-white text-stein-600 hover:bg-stein-50'}"
title="Alphabetisch"
>
<Icon icon="mdi:sort-alphabetical-ascending" class="size-4" />
<Icon icon="lucide:arrow-down-a-z" class="size-4" />
</button>
<button
type="button"
@@ -146,7 +146,7 @@
class="px-2 py-1 transition-colors {viewMode === 'org' ? 'bg-wald-500 text-white' : 'bg-white text-stein-600 hover:bg-stein-50'}"
title="Nach Organisation"
>
<Icon icon="mdi:account-group-outline" class="size-4" />
<Icon icon="lucide:users" class="size-4" />
</button>
</div>
</div>
@@ -196,7 +196,7 @@
{#if filtered().length === 0}
<p class="text-sm text-stein-400">Keine Kontakte gefunden.</p>
{: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"}
{#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">
@@ -219,14 +219,15 @@
</div>
<div class="mt-2 flex items-center justify-between">
<button
type="button"
<Button
variant="outline"
size="sm"
onclick={() => downloadVCardAll(filtered())}
class="no-print btn-outline btn-sm"
class="no-print"
>
<Icon icon="mdi:download" class="size-3.5" />
<Icon icon="lucide:download" class="size-3.5" />
Alle exportieren (.vcf)
</button>
</Button>
<p class="text-xs text-stein-400">{filtered().length} Kontakt{filtered().length !== 1 ? "e" : ""}</p>
</div>
{/if}
@@ -239,14 +240,10 @@
{#if contact.role || (contact.organisations ?? []).some((o) => typeof o === "object" && o.name)}
<div class="mt-0.5 flex flex-nowrap gap-1 overflow-hidden">
{#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">
{contact.role}
</span>
<Badge>{contact.role}</Badge>
{/if}
{#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">
{org.name}
</span>
<Badge color="green">{org.name}</Badge>
{/each}
</div>
{/if}
@@ -266,13 +263,13 @@
onclick={() => downloadVCard(contact)}
class="text-stein-300 transition-colors hover:text-wald-600"
>
<Icon icon="mdi:card-account-details-outline" class="size-4" />
<Icon icon="lucide:id-card" class="size-4" />
</button>
</div>
{#if contact.note}
<div class="flex items-center gap-1.5 text-xs text-stein-400">
<Icon icon="mdi:map-marker-outline" class="size-3.5 shrink-0" />
<Icon icon="lucide:map-pin" class="size-3.5 shrink-0" />
<span class="min-w-0 truncate">{contact.note}</span>
</div>
{/if}
@@ -281,7 +278,7 @@
<div class="flex flex-col gap-1">
{#if contact.phone}
<div class="flex items-center gap-1.5">
<Icon icon="mdi:phone-outline" class="size-3.5 shrink-0 text-stein-400" />
<Icon icon="lucide:phone" class="size-3.5 shrink-0 text-stein-400" />
<span class="min-w-0 truncate text-xs text-stein-600">{contact.phone}</span>
<button
type="button"
@@ -289,20 +286,20 @@
onclick={() => navigator.clipboard.writeText(contact.phone ?? "")}
class="no-print shrink-0 text-stein-300 transition-colors hover:text-wald-600"
>
<Icon icon="mdi:content-copy" class="size-3.5" />
<Icon icon="lucide:copy" class="size-3.5" />
</button>
<a
href="tel:{contact.phone.replace(/\s/g, '')}"
title="Anrufen"
class="no-print shrink-0 text-stein-300 no-underline transition-colors hover:text-wald-600"
>
<Icon icon="mdi:arrow-top-right" class="size-3.5" />
<Icon icon="lucide:arrow-up-right" class="size-3.5" />
</a>
</div>
{/if}
{#if contact.email}
<div class="flex items-center gap-1.5">
<Icon icon="mdi:email-outline" class="size-3.5 shrink-0 text-stein-400" />
<Icon icon="lucide:mail" class="size-3.5 shrink-0 text-stein-400" />
<span class="min-w-0 truncate text-xs text-stein-600">{contact.email}</span>
<button
type="button"
@@ -310,14 +307,14 @@
onclick={() => navigator.clipboard.writeText(contact.email ?? "")}
class="no-print shrink-0 text-stein-300 transition-colors hover:text-wald-600"
>
<Icon icon="mdi:content-copy" class="size-3.5" />
<Icon icon="lucide:copy" class="size-3.5" />
</button>
<a
href="mailto:{contact.email}"
title="E-Mail schreiben"
class="no-print shrink-0 text-stein-300 no-underline transition-colors hover:text-wald-600"
>
<Icon icon="mdi:arrow-top-right" class="size-3.5" />
<Icon icon="lucide:arrow-up-right" class="size-3.5" />
</a>
</div>
{/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 },
};
File diff suppressed because it is too large Load Diff
@@ -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">
import { getBlockLayoutClasses } from "$lib/block-layout";
import SectionHeader from "$lib/components/SectionHeader.svelte";
import type { CalendarBlockData, CalendarItemData } from "$lib/block-types";
import { t as tStatic, T } from "$lib/translations";
import type { Translations } from "$lib/translations";
@@ -41,7 +42,7 @@
return d.toLocaleDateString("de-DE", {
weekday: "short",
day: "2-digit",
month: "short",
month: "long",
});
}
function fmtTime(d: Date): string {
@@ -51,6 +52,18 @@
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 lt = block.linkTo;
if (!lt) return null;
@@ -62,50 +75,58 @@
</script>
<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-type="calendar"
data-block-variant="compact"
data-block-slug={block._slug}
>
<header class="flex items-center gap-2 border-b border-stein-100 px-3 py-2">
<Icon icon="mdi:calendar-month-outline" class="size-8 shrink-0 text-stein-900" aria-hidden="true" />
<h2 class="text-sm font-semibold leading-none text-stein-800">
{block.title ?? t(T.calendar_next_events)}
</h2>
</header>
<div class="mb-4">
<SectionHeader
number={block.number}
kicker={block.kicker}
title={block.title ?? t(T.calendar_next_events)}
tag="h3"
actionLabel={linkHref() ? t(T.calendar_all_upcoming) : undefined}
actionHref={linkHref() ?? undefined}
/>
</div>
{#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)}
</p>
{: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)}
{@const timeStr = fmtTime(start)}
{@const loc = locationText(it)}
{@const title = splitTitle(it.title ?? t(T.calendar_untitled))}
{@const base = linkHref()}
{@const itemHref = base && it._slug ? `${base}#event-${it._slug}` : null}
<li>
<svelte:element
this={itemHref ? "a" : "div"}
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">
<span class="text-[0.6rem] font-semibold uppercase leading-none">
<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 tracking-wide">
{start.toLocaleDateString("de-DE", { month: "short" }).replace(".", "")}
</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()}
</span>
</div>
<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="mt-0.5 text-[0.7rem] text-stein-500">
{fmtDate(start)}{timeStr ? ` · ${timeStr}` : ""}
{#if it.location}
<span class="ml-1">· {typeof it.location === "string" ? it.location : (it.location as { text?: string })?.text ?? ""}</span>
{/if}
<p class="text-sm font-semibold leading-snug text-stein-900">
{title.main}{#if title.paren}{" "}<span class="font-normal text-stein-400">{title.paren}</span>{/if}
</p>
<p class="mt-1 flex items-center gap-1.5 text-xs text-stein-500">
<Icon icon="lucide:clock" class="size-3.5 shrink-0" aria-hidden="true" />
<span class="min-w-0 truncate">
{fmtDate(start)}{timeStr ? ` · ${timeStr}` : ""}{loc ? ` · ${loc}` : ""}
</span>
</p>
</div>
</svelte:element>
@@ -113,14 +134,4 @@
{/each}
</ul>
{/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="mdi:arrow-right" class="size-3.5" aria-hidden="true" />
</a>
{/if}
</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">
import { getBlockLayoutClasses } from "$lib/block-layout";
import type { DeadlineBannerBlockData } from "$lib/block-types";
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
let { block }: { block: DeadlineBannerBlockData } = $props();
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
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 = {
title?: string;
@@ -102,56 +113,37 @@
const sentence = $derived.by(() => {
const parts: string[] = [];
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(" ");
});
const hasContent = $derived(!!(sentence || countdownStr));
</script>
{#if hasContent && !dismissed}
{#if hasContent}
<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-slug={block._slug}
role="note"
style="font-size: 0.7rem;"
>
<div
class="gradient-blob-bg bg-gray-50 py-2.5"
>
<div class="container-custom flex items-center gap-x-6">
<p class="my-0! flex-1 text-sm/6 text-gray-900">
{sentence}{#if countdownStr}{sentence ? " · " : ""}<span
class="font-semibold">{countdownStr}</span
>{/if}{#if href}
{" "}<a
{href}
target={newTab ? "_blank" : undefined}
rel={newTab ? "noopener noreferrer" : undefined}
class="font-semibold whitespace-nowrap text-gray-900 hover:text-gray-700"
>
{ctaLabel}&nbsp;<Icon
icon="mdi: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="mdi:close"
class="size-5 text-gray-900"
aria-hidden="true"
/>
</button>
</div>
</div>
<div class="container-custom flex flex-wrap items-center justify-center gap-x-3 gap-y-1.5 py-2 text-center">
{#if sentence}
<span>{sentence}</span>
{/if}
{#if countdownStr}
<span class="whitespace-nowrap rounded-full bg-black/20 px-2 py-0.5 font-bold">{countdownStr}</span>
{/if}
{#if href}
<a
{href}
target={newTab ? "_blank" : undefined}
rel={newTab ? "noopener noreferrer" : undefined}
class="whitespace-nowrap font-semibold underline underline-offset-2 hover:no-underline"
>{ctaLabel}</a>
{/if}
</div>
</div>
{/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,63 @@
<script lang="ts">
/**
* Datums-Kachel für Kalender-Einträge.
*
* - Einzeltermin: Wochentag · großer Tag · Monat
* - Mehrtägig: Wochentag-Range · Tag-Range · Monat
* (gleiche Höhe, Box wächst nur in der Breite)
*
* Bewusst feste rem-Schriftgrößen + `size-*` (mobil etwas kleiner als
* Desktop). Keine fluid/calc-Skalierung → kein Overflow, identisch
* berechenbar auf allen Geräten.
*/
let {
start,
end = null,
multiDay = false,
}: { start: Date; end?: Date | null; multiDay?: boolean } = $props();
const isRange = $derived(multiDay && !!end);
const weekday = (d: Date) =>
d.toLocaleDateString("de-DE", { weekday: "short" }).replace(/\.$/, "");
const month = (d: Date) =>
d.toLocaleDateString("de-DE", { month: "short" });
// YYYY-MM-DD aus lokalen Feldern (kein UTC-Shift) für <time datetime>.
const isoDate = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const fullDate = (d: Date) =>
d.toLocaleDateString("de-DE", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
});
const ariaLabel = $derived(
isRange && end
? `${fullDate(start)} bis ${fullDate(end)}`
: fullDate(start),
);
</script>
<time
datetime={isoDate(start)}
aria-label={ariaLabel}
class="shrink-0 select-none aspect-square size-18 rounded-sm rounded-br-4xl bg-wald-200 text-wald-500 flex justify-center items-center shadow"
>
<div class="text-center font-black leading-normal">
<div class="text-[10px] uppercase tracking-wider">
{#if isRange && end}{weekday(start)}{weekday(end)}{:else}{weekday(
start,
)}{/if}
</div>
<div class="text-[20px] leading-5">
{#if isRange && end}{start.getDate()}{end.getDate()}{:else}{start.getDate()}{/if}
</div>
<div class="text-[10px] uppercase tracking-wider">
{month(start)}
</div>
</div>
</time>
@@ -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 "$lib/iconify-offline";
import { getBlockLayoutClasses } from "$lib/block-layout";
import SectionHeader from "$lib/components/SectionHeader.svelte";
import type { FilesBlockData } from "$lib/block-types";
import {
extractCmsFileField,
@@ -11,6 +12,9 @@
} from "$lib/rusty-file";
let { block }: { block: FilesBlockData } = $props();
const actionRef = $derived(
block.action && typeof block.action === "object" ? block.action : null,
);
const layoutClasses = $derived(
block.layout ? getBlockLayoutClasses(block.layout) : "col-span-12 mb-4",
@@ -21,66 +25,9 @@
label: string;
description?: string;
sizeLabel: string | null;
ext: string | null;
iconName: string;
extLabel: string;
};
function iconForExt(ext: string | null): string {
switch (ext) {
case "pdf":
return "mdi:file-pdf-box";
case "doc":
case "docx":
case "rtf":
case "odt":
return "mdi:file-word-box";
case "xls":
case "xlsx":
case "ods":
case "csv":
case "tsv":
return "mdi:file-excel-box";
case "ppt":
case "pptx":
return "mdi:file-powerpoint-box";
case "zip":
case "tar":
case "gz":
case "7z":
return "mdi:folder-zip";
case "jpg":
case "jpeg":
case "png":
case "webp":
case "gif":
case "svg":
return "mdi:file-image";
case "txt":
case "md":
return "mdi:file-document-outline";
case "json":
return "mdi:code-json";
case "xml":
case "html":
case "htm":
return "mdi:xml";
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 "mdi:console";
default:
return "mdi:file-outline";
}
}
const resolved = $derived(
((block.items ?? []) as unknown[])
.map((raw): Resolved | null => {
@@ -98,8 +45,7 @@
label,
description: field.description,
sizeLabel: formatFileSize(field.size),
ext,
iconName: iconForExt(ext),
extLabel: ext ? ext.slice(0, 4).toUpperCase() : "FILE",
};
})
.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}>
{#if block.headline}
<h3 class="mb-3 text-lg font-semibold">{block.headline}</h3>
{/if}
{#if block.description}
<div class="mb-3">
<SectionHeader
number={block.number}
kicker={block.kicker}
title={block.headline}
subtitle={block.description}
tag="h3"
actionLabel={actionRef?.linkName}
actionHref={actionRef?.url}
/>
</div>
{:else if block.description}
<p class="mb-3 text-sm text-stein-700">{block.description}</p>
{/if}
{#if resolved.length === 0}
<p class="text-sm text-stein-500">Keine Dateien verfügbar.</p>
{: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}
<li class="list-none! ml-0">
<a
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"
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 items-center gap-1.5 min-w-0">
<span class="text-sm text-stein-900 group-hover:underline truncate">{f.label}</span>
<span class="flex items-baseline gap-1.5 min-w-0">
<span class="text-sm text-stein-900 truncate">{f.label}</span>
{#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}
</span>
{#if f.description}
<span class="text-xs text-stein-400">{f.description}</span>
{/if}
</span>
<Icon
icon="lucide:download"
class="size-5 shrink-0 text-stein-400 group-hover:text-stein-600 transition-colors"
aria-hidden="true"
/>
</a>
</li>
{/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">
import { getBlockLayoutClasses } from "$lib/block-layout";
import SectionHeader from "$lib/components/SectionHeader.svelte";
import type { HeadlineBlockData } from "$lib/block-types";
let { block }: { block: HeadlineBlockData } = $props();
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(
block.align === "center"
? "text-center"
@@ -13,13 +24,28 @@
: "text-left",
);
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>
<div class={layoutClasses} data-block="Headline" data-block-type="headline" data-block-slug={block._slug}>
<svelte:element
this={Tag}
class={alignClass}
>
{block.text ?? ""}
</svelte:element>
{#if useSection}
<SectionHeader
number={block.number}
kicker={block.kicker}
title={block.text ?? ""}
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>
@@ -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.',
},
},
};
+1 -1
View File
@@ -52,7 +52,7 @@
onkeydown={(e) => e.key === "Enter" && activateMap()}
>
<Icon
icon="mdi:cursor-default-click"
icon="lucide:mouse-pointer-click"
width="2rem"
height="2rem"
class="block text-white"
@@ -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 },
},
};

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