Compare commits

...

142 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
Peter Meier 894263e5c4 fix: OrganisationsMap erst bei Klick interaktiv, kein Page-Scroll-Hijack
Deploy / verify (push) Successful in 1m1s
Deploy / deploy (push) Successful in 1m25s
Initial Drag/Zoom deaktiviert. Overlay 'Karte aktivieren' freigibt
Interaktion. Marker-Klick aktiviert automatisch mit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 13:17:15 +02:00
Peter Meier aebe5f4854 fix: kleinere Map-Höhe auf Mobile (320px statt 400px+)
Deploy / verify (push) Successful in 58s
Deploy / deploy (push) Successful in 1m8s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 13:06:53 +02:00
Peter Meier fc81d973a5 feat: Ansprechpartner im OrganisationsMap Detail-Panel
Deploy / verify (push) Successful in 1m2s
Deploy / deploy (push) Successful in 1m11s
Marker-Klick zeigt jetzt zusätzlich Name, Rolle, Email und Telefon
der Kontaktpersonen pro Organisation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 13:02:47 +02:00
Peter Meier c9ba88ab80 feat: OrganisationsMapBlock — Karte der Bürgerinitiativen
Deploy / verify (push) Successful in 1m5s
Deploy / deploy (push) Successful in 1m13s
Neuer Block-Typ `organisations_map`: Leaflet-Karte mit CircleMarkern für alle
Organisationen die ein location.lat/lng haben. Klick auf Marker öffnet
Detail-Panel (Desktop: rechte Sidebar / Mobile: Bottom Sheet) mit Name,
Standort, Beschreibung und Website-Link.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 12:03:26 +02:00
Peter Meier 88b2df0c05 feat: location-Feld an Organisation, Header-Tooltips
Deploy / verify (push) Successful in 1m0s
Deploy / deploy (push) Successful in 1m13s
- block-types: OrganisationEntry.location { text, lat, lng }
- Header: title-Attribut auf Social-Icons + Suche-Button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 11:54:13 +02:00
Peter Meier 6c774c4913 refactor: SearchableTextBlock Redesign, Ort-Badge Truncate
Deploy / verify (push) Successful in 59s
Deploy / deploy (push) Successful in 1m12s
- SearchableTextBlock: Farbverlauf-Header entfernt, Tag/Tags durch .chip ersetzt,
  Suche auf Adressbuch-Style ausgerichtet, Kopier-Buttons auf Icon-only reduziert,
  border-l-4 Akzent entfernt
- EventBadges: whitespace-nowrap entfernt, truncate max-w-[16rem] + title für lange Ortstexte
- PostCard: min-w-0 auf Badges-Container für korrekte Truncate-Propagation
- iconify-mdi-subset: qrcode + download Icons ergänzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 11:25:34 +02:00
Peter Meier 982a7a3b73 feat: QR-Code, vCard-Export, Org-Gruppenansicht im Adressbuch
Deploy / verify (push) Successful in 1m10s
Deploy / deploy (push) Successful in 1m45s
- vcard.ts: shared vCard-Generator (downloadVCard, downloadVCardAll, buildVCardString)
- QrModal.svelte + QrButton.svelte: wiederverwendbare QR-Komponenten mit PNG-Download
- AdressbuchBlock: Org-Gruppenansicht (Toggle), QR + vCard pro Kontakt,
  Buchstaben-Trennzeilen, Such-Highlight, "Alle exportieren", Print-Styles
- ContactCard: vCard-Download-Button
- PostActions: QR-Button für Seiten-URL
- qrcode npm-Paket hinzugefügt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 11:15:57 +02:00
Peter Meier 4642b32271 refactor: Adressbuch-Kontaktinfo als Text mit Icon-Button statt vollem Button
Deploy / verify (push) Successful in 59s
Deploy / deploy (push) Successful in 1m12s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 10:54:44 +02:00
Peter Meier df1895bb91 refactor: einheitliche .chip-Klasse für Filter-Chips, .btn-outline.btn-sm für Adressbuch-Actions
Deploy / verify (push) Successful in 1m6s
Deploy / deploy (push) Successful in 1m15s
- app.css: neue .chip Utility (rounded-xs, border, text-[11px])
- AdressbuchBlock: Org-/Letter-Filter auf .chip, Action-Buttons auf .btn-outline.btn-sm
- CalendarBlock: Tag-Filter auf .chip

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 10:36:26 +02:00
Peter Meier 9433705709 fix: Header-Adressbuch-Icon auf mdi:import-contacts
Deploy / verify (push) Successful in 56s
Deploy / deploy (push) Successful in 1m10s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 10:21:35 +02:00
Peter Meier 31f1c8586c feat: WhatsApp/YouTube-Links auf Organisation
Deploy / verify (push) Successful in 1m2s
Deploy / deploy (push) Successful in 1m15s
Neue Felder whatsapp/youtube (string) auf OrganisationEntry.
OrganisationsCard zeigt Icon-Buttons wenn gesetzt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 10:20:41 +02:00
Peter Meier 3c4b847c23 feat: Adressbuch-Icon in Header-Navigation, WhatsApp/YouTube entfernt
Deploy / verify (push) Successful in 1m6s
Deploy / deploy (push) Successful in 1m8s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 10:14:31 +02:00
Peter Meier 80ee877ae0 feat: render subheadlines as inline markdown
Deploy / verify (push) Successful in 58s
Deploy / deploy (push) Successful in 1m8s
Subheadlines in page headers and post cards support inline markdown
(bold, italic, links) via marked.parseInline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 10:11:07 +02:00
Peter Meier 363d4edb76 feat: Adressbuch-Block mit Suche, Org- und Buchstabenfilter
Deploy / verify (push) Successful in 1m14s
Deploy / deploy (push) Successful in 1m18s
- AdressbuchBlock: listet alle Kontakte alphabetisch mit Suche,
  Org-Filter-Chips und A–Z-Buchstabenfilter; /adressbuch-Seite
- ContactCard: neues Design passend zu OrganisationsCard (Badges, Links)
- ContactsBlock: Karussell mit snap-scroll, volle Breite
- block-types, blog-utils, cms: AdressbuchBlockData, resolveAdressbuchBlocks,
  getContacts; BlockRenderer registriert adressbuch
- Kontakt-Seite: Link auf /adressbuch statt eingebettetem Block

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 10:08:54 +02:00
Peter Meier 7bd62b75b0 feat(contacts): ContactCard + ContactsBlock-Komponente, BlockRenderer registriert
Deploy / verify (push) Successful in 1m8s
Deploy / deploy (push) Successful in 1m13s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 08:49:23 +02:00
Peter Meier 1827d9772a feat(stellungnahme): Grüner Output-Hintergrund, KI-Anleitung, Aktualisieren-Button entfernt
Deploy / verify (push) Successful in 59s
Deploy / deploy (push) Successful in 1m35s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 15:26:36 +02:00
Peter Meier 5d2988fa1b feat(page): Kommentar-Badge oben mit Scroll-to-Comments
Deploy / verify (push) Successful in 1m0s
Deploy / deploy (push) Successful in 1m22s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 09:33:22 +02:00
Peter Meier df4380409e feat(comments): Auto-open accordion wenn Kommentare vorhanden, Pages-Support
Deploy / verify (push) Successful in 1m1s
Deploy / deploy (push) Successful in 1m11s
- Post: Kommentar-Count server-seitig laden, Accordion offen wenn count > 0
- Page-Route: optionale Kommentare via commentsEnabled-Flag (Schema: default false)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 09:22:03 +02:00
Peter Meier 6546f7d0a3 feat(stellungnahme): KI-Prompt, editierbarer Text, Stepper-Füllstand, DnD-Reihenfolge
Deploy / verify (push) Successful in 1m3s
Deploy / deploy (push) Successful in 1m17s
- KI-Prompt kopieren mit Ton/Haltung-Slidern und persönlichem Hinweis
- Output-Textarea editierbar (field-sizing: content, auto-height)
- Stepper zeigt Füllstand: Haken (komplett), gelb (leer), grau (unbesucht)
- Weiter-Buttons mit Tooltips bei leerem Schritt
- Auto-Generate beim Betreten von Step 4, live bei Eingabe-Änderungen
- Drag & Drop Umsortierung der Argumente in Step 4
- Step in localStorage persistiert (HMR-Fix)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 22:13:11 +02:00
Peter Meier f2379c0e28 feat(stellungnahme): Tag-Pill-Filter, flache Gruppen, Sticky-Footer, Auto-Save, Auto-Focus
Deploy / verify (push) Successful in 1m1s
Deploy / deploy (push) Successful in 1m9s
Filter:
- Tag-Pill-Reihe in Step 2 oberhalb der Suche. Multi-Select, OR-Logik
  zwischen Pills, AND mit Text-Suche. Pills aus allen Nicht-Fetch-Tags
  der deduplizierten Fragmente, alphabetisch sortiert. "Filter
  zurücksetzen" erscheint bei ≥1 aktivem Pill. Empty-State erklärt
  was nicht matcht (Text/Pills/beides).

Akkordeons raus (Variante B):
- Gruppen-Header bleiben als sichtbare Trenner (Label + Anzahl + "Alle"-
  Toggle), aber Click-to-Expand-Mechanik weg — Items immer offen.
  expandedGroups + toggleGroup als dead code entfernt.

Bedienkomfort:
- Sticky Mobile-Footer für alle 5 Step-Nav-Rows: bottom-2 mit
  weißem BG + Shadow + Backdrop-Blur. Auf Desktop unverändert (sm:static).
- Auto-Save-Indikator "✓ Gespeichert" rechts oben, blitzt 2s nach
  jeder Mutation. Pure UI — wir speichern eh schon in localStorage.
- Auto-Focus per Svelte-Action beim Step-Wechsel: Step 2 → Such-Input,
  Step 3 → Betroffenheit-Textarea, Step 4 → Name-Input.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 08:49:20 +02:00
Peter Meier 77fee9b1c2 feat(stellungnahme): Step-0 als Cards, Step-3 ohne Mindest-Auswahl, Steckbrief default offen
Deploy / verify (push) Successful in 54s
Deploy / deploy (push) Successful in 1m3s
Step 0 (Gebietskontext):
- Gebiets-Nummer (W-37) deutlich größer: text-4xl tracking-tight
- Detail-Felder als Cards: rounded-lg border-wald-200 bg-white/70 p-3 shadow-sm
- Icons (Gemeinden, Fläche, Anlagen geplant, Max. Höhe, Investor, Status)
  oben links als eigenes Element statt inline; size-5 text-wald-600
- Labels: text-sm sm:text-base font-semibold text-wald-800 whitespace-nowrap
  (kleiner auf Mobile damit "Anlagen geplant" in der Card bleibt)
- Werte: text-stein-600 (grau) mit mt-1 Abstand
- Steckbrief-<details> default open

Step 2 (Allgemeine Argumente):
- "Weiter"-Button nicht mehr disabled bei 0 Auswahl — Mindest-Auswahl
  weggefallen, Anzahl nur im Label wenn >0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 11:43:01 +02:00
Peter Meier 3f39b5b1a8 fix(stellungnahme): Mini-Map mobile square + Gebiet zentriert (fitBoundsPadding-Prop)
Deploy / verify (push) Successful in 1m5s
Deploy / deploy (push) Successful in 1m12s
2026-05-30 11:25:36 +02:00
Peter Meier bd67f0c568 feat(nav): navgroup-Dropdowns im Header + Fix Overflow-Clipping
Deploy / verify (push) Successful in 1m3s
Deploy / deploy (push) Successful in 1m12s
- layout.server.ts: NavLink um optional children?: NavLink[] erweitert.
  navHeader-Resolve von 'links' auf 'all' (navgroup-sub-links werden mit
  aufgelöst). Resolver-Loop erkennt navgroup-Entries (_type==='navgroup'
  oder Shape label+links) und baut NavLink mit Children + Parent-URL.
- Header.svelte: Desktop-Dropdown mit Hover/Focus-Open + ARIA (haspopup,
  expanded, role=menu/menuitem). Mobile: collapsible Submenu via
  openSubmenu-State, slide-animiert; "– Übersicht"-Link wenn Parent
  selbst eine URL hat. isActiveTree(): Parent gilt aktiv wenn ein Child
  aktiv ist.
- Overflow-Fix: .gradient-blob-bg setzt overflow:hidden → clipte den
  Desktop-Dropdown. Neue Variante .gradient-blob-bg-inner als absoluter
  Inner-Layer (eigenes overflow-hidden), Header selbst bleibt
  overflow-visible. Bestehende .gradient-blob-bg-Nutzungen (z.B.
  DeadlineBannerBlock) unverändert.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 08:50:51 +02:00
Peter Meier c58f418c1a perf(cms-client+ssr): O(1)-Slug-Lookup, Sparse-Pages, höhere TTLs, Cache-Control, Parallel-Resolver
Deploy / verify (push) Successful in 58s
Deploy / deploy (push) Successful in 1m12s
- getPageBySlug: neuer Filter-Lookup-Pfad (?slug=X&_fields=_slug) als
  primärer Fallback — O(1) statt komplette Page-Liste laden. Vollständige
  Liste nur noch als Last-Resort-Safety-Net. Skaliert egal wieviele Pages.
- getPages: Sparse-Projektion via _fields (8 Felder statt Voll-Resolve-
  Tree) → ~90 % kleinere Payload pro Call.
- CACHE_TTL hoch (Webhooks purgen eh): page/page_config/navigation/footer/
  fullwidth_banner/translation/openapi → 1h; tag/text_fragment → 30min;
  post → 10min. Spart Cold-Loads + CMS-Roundtrips.
- [...slug]/+page.server.ts: Cache-Control public, s-maxage=60,
  stale-while-revalidate=600 für öffentliche Pages (Caddy/Browser cachen).
  Preview-Branch bleibt private/no-store.
- Resolver parallelisiert: Calendar/Deadline/StellingnahmeGenerator
  starten sofort parallel zu PostOverview, nicht erst danach.
- Icon-Subset um clock-remove-outline ergänzt (Frist-abgelaufen-Banner).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 00:09:26 +02:00
Peter Meier f05ee6b9d7 fix(cms): getPages paginiert alle Seiten (Slug-Fallback fand Seiten jenseits per_page=50 nicht → 404)
Deploy / verify (push) Successful in 1m4s
Deploy / deploy (push) Successful in 1m15s
2026-05-29 23:28:35 +02:00
Peter Meier 940b9beb7f feat(windkarte): stellungnahmeUrl-Feld + Einwendung-erstellen-Button im Detail-Panel
Deploy / verify (push) Successful in 1m7s
Deploy / deploy (push) Successful in 1m19s
2026-05-29 09:14:36 +02:00
Peter Meier 0be73adc51 feat(stellungnahme): a11y, Suche, Frist-Status, Steckbrief & Map mobil
Deploy / verify (push) Successful in 57s
Deploy / deploy (push) Successful in 1m38s
- Titel klickbar als <label> (Tastatur/Screenreader), aria-live auf Ausgabe
- Neustart mit Rückfrage, mailto-Längen-Guard (>1900 Zeichen)
- Frist-abgelaufen-Banner statt kommentarlos verschwinden
- Suchfeld filtert allgemeine Argumente live
- <details>-Chevron dreht beim Aufklappen
- Steckbrief einklappbar + scrollbar, Single-Newlines als Hard-Break
- Mini-Map responsive Höhe (h-56 mobil → h-80 desktop)
- Ausgabe-Buttons text-sm/text-[11px], mobil umbrechend; Mail-Link no-underline
- gebietsBez-Deklaration vor Verwendung gezogen (svelte-check Fehler)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 14:41:43 +02:00
Peter Meier 5d0a634359 fix(stellungnahme): Mail-Link no-underline, Ausgabe-Buttons kleiner
Deploy / verify (push) Failing after 47s
Deploy / deploy (push) Has been skipped
2026-05-28 14:27:10 +02:00
Peter Meier e80d9eef7e feat(stellungnahme): E-Mail-Button immer zeigen (leerer Empfänger = an sich selbst)
Deploy / verify (push) Failing after 51s
Deploy / deploy (push) Has been skipped
2026-05-28 14:25:21 +02:00
Peter Meier a872a015d9 fix(stellungnahme): Ausgabe-Buttons mobil umbrechen statt overflowen
Deploy / verify (push) Failing after 47s
Deploy / deploy (push) Has been skipped
2026-05-28 14:23:47 +02:00
Peter Meier 8194d8639d feat(stellungnahme): Einleitung/Abschluss via CMS-Felder pflegbar
Deploy / verify (push) Failing after 46s
Deploy / deploy (push) Has been skipped
2026-05-28 13:35:31 +02:00
Peter Meier 4e46e82770 feat(stellungnahme): Button subtiles Wiggle-Rotate statt bounce
Deploy / verify (push) Failing after 50s
Deploy / deploy (push) Has been skipped
2026-05-28 13:32:08 +02:00
Peter Meier 2bae9b3e5d fix(stellungnahme): Button animate-bounce statt ping
Deploy / verify (push) Failing after 47s
Deploy / deploy (push) Has been skipped
2026-05-28 13:31:07 +02:00
Peter Meier 16bb8af12c feat(stellungnahme): Text-erstellen-Button pulsiert (animate-ping)
Deploy / verify (push) Failing after 47s
Deploy / deploy (push) Has been skipped
2026-05-28 13:29:44 +02:00
Peter Meier 6bc6781b3c fix(stellungnahme): Zurück-Button in Step 5 links
Deploy / verify (push) Failing after 44s
Deploy / deploy (push) Has been skipped
2026-05-28 13:29:01 +02:00
Peter Meier d45e691171 feat(stellungnahme): Notizfeld pro Argument + editierbare Einleitung/Abschluss
Deploy / verify (push) Failing after 50s
Deploy / deploy (push) Has been skipped
- Notizfeld an jedem Argument (expanded) → erscheint im Output als "Anmerkung:"
- Einleitung und Abschluss in Step 5 via <details> editierbar
- Beide mit Zurücksetzen-Button auf Default
- Alles in localStorage persistiert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 13:27:07 +02:00
Peter Meier e239c85961 feat(markdown): span[style] in Markdown erlauben, alles andere HTML strippen
Deploy / verify (push) Successful in 1m4s
Deploy / deploy (push) Successful in 1m21s
2026-05-28 13:14:58 +02:00
Peter Meier 28170a8549 fix(stellungnahme): Disclaimer prominenter mit Rahmen und Großbuchstaben
Deploy / verify (push) Successful in 52s
Deploy / deploy (push) Successful in 1m5s
2026-05-27 16:37:54 +02:00
Peter Meier 2d1967ec38 feat(stellungnahme): Entwurfs-Disclaimer im generierten Dokument
Deploy / verify (push) Successful in 53s
Deploy / deploy (push) Successful in 1m37s
2026-05-27 16:36:53 +02:00
Peter Meier 8f260044e6 fix(stellungnahme): bulletPoints statt Prosatext im generierten Dokument
Deploy / verify (push) Successful in 57s
Deploy / deploy (push) Successful in 1m36s
2026-05-27 16:34:43 +02:00
Peter Meier 5f5bd06a78 feat(stellungnahme): E-Mail, Name in Grußformel, Datum-Fix, UX-Polish
Deploy / verify (push) Successful in 57s
Deploy / deploy (push) Successful in 1m9s
- E-Mail-Button: mailto-Link wenn recipientEmail im Block gesetzt
- Name in Grußformel: "Mit freundlichen Grüßen\n\nMax Mustermann"
- Datum-Fix: heute in buildText() berechnet, nicht beim Render
- Disabled-Hint: roter Hinweis wenn Name fehlt
- "Alle ab" klappt Gruppe ein (Symmetrie zu "Alle" öffnet)
- Schritt X von Y unter Step-Indikator
- recipientEmail-Feld in StellingnahmeGeneratorBlockData

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 16:32:25 +02:00
Peter Meier ff383b8b3b fix(stellungnahme): mode const nicht in resetAll zuweisen
Deploy / verify (push) Successful in 55s
Deploy / deploy (push) Successful in 1m5s
2026-05-27 16:02:53 +02:00
Peter Meier cfb74096d1 feat(stellungnahme): Modus-Auswahl entfernen, immer Fertigtext
Deploy / verify (push) Failing after 45s
Deploy / deploy (push) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 16:00:39 +02:00
Peter Meier 96dd88247d feat(stellungnahme): UX-Verbesserungen, localStorage, Mini-Map, Druck
Deploy / verify (push) Successful in 55s
Deploy / deploy (push) Successful in 1m39s
- localStorage: Formularstand + Ausgabe-Historie (max 5) persistieren
- Fortsetzen-Banner: gespeicherte Sitzung erkannt → Sprung zu letztem Schritt
- Neustart-Button: alles inkl. localStorage leeren
- Frist-Banner: deadline-Feld in StellingnahmeGeneratorBlockData, Ampelfarben
- Step 1 überspringen wenn keine ortskonkreten Argumente vorhanden
- Step-Indikator: goTo() statt step= (scrollt jetzt korrekt)
- "Alle wählen" öffnet Gruppe automatisch
- Download als .txt neben Kopieren/Drucken
- <pre> → <div> für konsistentes Schriftbild im Output
- Mini-Map für Vorranggebiet in Step 0 (tap-to-activate, fitBoundsMaxZoom=13)
- WindkarteMap: fitBoundsMaxZoom-Prop ergänzt
- Druckausgabe: 13pt Serif, 2cm Ränder, kein Markdown im Fertigtext

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 15:57:27 +02:00
Peter Meier 6d0861bb0a feat(stellungnahme): Einwendungs-Assistent für Windvorranggebiete
Deploy / verify (push) Successful in 59s
Deploy / deploy (push) Successful in 2m37s
5-Schritt-Wizard: Gebietskontext → Argumente wählen → Ausgabeform →
persönliche Betroffenheit → Absender + Ausgabe. Alle Eingaben bleiben
lokal im Browser (kein Server-Transfer). Fertigtext- und Leitfaden-Modus.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 14:41:00 +02:00
Peter Meier d781b3140e feat(calendar-compact): polish + global not-prose opt-out support
Deploy / verify (push) Successful in 1m4s
Deploy / deploy (push) Successful in 1m9s
CalendarCompact:
- denser layout (smaller padding/font/badge), header icon size-8
- icon stein-900 (black), subtle wald gradient background
- rounded-sm to match design tokens (was rounded-lg)
- list items link to linkTo + "#event-<slug>" anchor; <svelte:element>
  falls back to div when no linkTo is set
- not-prose on root so global content margins/list/link rules don't
  apply to the card subtree

blog-utils.resolveCalendarBlocks:
- when items array is empty, fall back to the full calendar_item pool
  (both default and compact variant get this) — saves editors from
  re-listing items on every page.

app.css: add `:where(:not(.not-prose *))` to .content p/h1-h4,
main a (link colour + underline), main ul/ol so subtrees marked
not-prose stop inheriting the global prose-like defaults.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 09:16:21 +02:00
Peter Meier b7bcdd76ee feat(calendar): compact variant for next-events teaser
Deploy / verify (push) Successful in 1m8s
Deploy / deploy (push) Successful in 1m13s
Calendar block now supports variant: "default" | "compact".
- default = full calendar (unchanged)
- compact = next N events (default 3) + link to a target page

New CalendarCompactBlock.svelte: small card with month/day badge,
title, date/time/location line, "Alle kommenden Termine →" footer
link resolved from the schema's linkTo reference. Filters to future
events (12h tolerance for running ones).

BlockRenderer dispatches by block.variant inside the calendar case.
Schema (cms_content/_types/core/calendar.json5) adds variant + linkTo
+ limit fields.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 08:49:08 +02:00
Peter Meier 055f001174 feat(page): inline topImage field, fallback to topFullwidthBanner
Deploy / verify (push) Successful in 1m3s
Deploy / deploy (push) Successful in 1m4s
Reads page.topImage first (new direct image field on the page schema);
falls back to legacy topFullwidthBanner.image during transition. Same
ResponsiveImage build, social-image transform and warm-up as before.
Banner-object still passed through so existing rendering keeps working
when headline/subheadline/variant are wanted; pure-image migration
needs no further code changes.

Schema + 21 pages migrated CMS-side in the same change set.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 15:14:41 +02:00
Peter Meier c329658992 feat(page): use topFullwidthBanner as og:image source
Deploy / verify (push) Successful in 55s
Deploy / deploy (push) Successful in 1m4s
Pages now expose `socialImage` from the topFullwidthBanner field —
same 1200×630 cover transform with focal-aware crop that posts already
use. Layout meta-tag pipeline picks it up automatically; pages without
a banner still fall back to the global default social image.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 15:06:48 +02:00
Peter Meier 6a1069f908 fix(form): shrink sm inputs to text-xs, align consent checkbox top
Deploy / verify (push) Successful in 54s
Deploy / deploy (push) Successful in 1m3s
- TextInput/Textarea size="sm": text-xs, tighter padding (h-9, px-2.5
  py-1.5) so placeholder/value match label scale.
- Checkbox: new size prop (sm = h-4 w-4 + text-xs leading-snug).
  Switched container to flex/items-start so multi-line labels align
  with checkbox top instead of vertically centering and pushing the
  box halfway down the paragraph.
- ContactForm: consent checkbox uses size="sm"; privacy note + error
  hint at text-[0.7rem] leading-snug to match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 14:33:34 +02:00
Peter Meier f7a547874b fix(css): drop aggressive word-break in link content
Deploy / verify (push) Successful in 58s
Deploy / deploy (push) Successful in 1m6s
Global rule `main a, .markdown a { word-break: break-all }` broke
German words mid-character whenever the anchor wrapped non-URL content
(e.g. PostCard wraps title/excerpt in <a href>). Visible in PostOverview
as "schützt" → "sc / hützt", "überall" → "über / all".

Replaced with overflow-wrap: anywhere — still wraps long URLs softly,
but only when nothing else fits.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 14:26:52 +02:00
Peter Meier 81c82c33e6 perf: review-fixes (N+1, waterfalls, sanitize-cache, lazy strommix)
Deploy / verify (push) Successful in 56s
Deploy / deploy (push) Successful in 1m3s
- blog-utils.resolvePostOverviewBlocks: parallel getPostBySlug via
  Promise.all + .catch(null) instead of sequential await loop.
- blog-utils.resolveCalendarBlocks: same fix for calendar items.
- [...slug] page load: SearchableText/Calendar/DeadlineBanner now run
  parallel after tagsMap (was strict waterfall).
- sanitize-blocks: LRU cache keyed on raw html input — sanitize-html is
  ~ms/KB, the same content repeats across pages and cache hits.
- cms.getNormalizedTranslations: WeakMap-memoised the per-request
  Object.entries normalize loop on top of the existing 5-min bundle TTL.
- cms.cached: opt-in dev cache via DEV_CACHE_CMS=1 (default still off).
- rusty-image.ensureTransformedImage: was async-wrapping a sync URL
  builder; made sync, removed awaits at call sites — saves microtasks.
- BlockRenderer: lazy-load StrommixBlock (~220 KB) via dynamic import
  in the template — pages without the live-strommix widget no longer
  ship its bundle.

Also: enabled Caddy zstd+gzip on windwiderstand.de vhost (server-side
edit; verified `content-encoding: zstd` on response).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 12:30:15 +02:00
Peter Meier c00eb26689 feat: wire CMS redirects plugin into hooks.server.ts
Deploy / verify (push) Successful in 51s
Deploy / deploy (push) Successful in 1m16s
New $lib/redirects.server.ts: lazy-loads GET /api/redirects, exact-match
index with trailing-slash normalisation, 60s TTL with background refresh
(cold start blocks once, subsequent requests get stale cache instantly).

hooks.server.ts: before legacy-redirect fallback, runs lookupRedirect for
GET requests. Editor-defined rules win over hardcoded /blog→/posts etc.
External targets (http://…) drop query; internal targets keep ?search.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 11:51:45 +02:00
Peter Meier 0aa3898e5b fix(ui): make Button.children optional in props type
Deploy / verify (push) Successful in 53s
Deploy / deploy (push) Successful in 1m7s
svelte-check (strict) flagged ContactFormComponent for not passing
children to <Button>. The component already renders label as fallback;
typed children as optional Snippet so callers don't need to pass it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 11:47:20 +02:00
Peter Meier bcf1502093 fix(contact): drop Origin header when forwarding to CMS
Deploy / verify (push) Failing after 43s
Deploy / deploy (push) Has been skipped
SvelteKit already validates Origin at /api/contact; forwarding the
client Origin upstream caused the prod CMS to reject local dev
submissions (allowlist is prod-only). CMS forms plugin accepts
no-Origin requests when REQUIRE_ORIGIN=false.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 11:44:06 +02:00
Peter Meier d03afe780e feat(contact): add Cloudflare Turnstile captcha
Deploy / verify (push) Failing after 44s
Deploy / deploy (push) Has been skipped
Client renders explicit Turnstile widget (flexible, light theme) when
PUBLIC_TURNSTILE_SITE_KEY is set. Token sent with form payload, server
verifies via siteverify endpoint before forwarding to CMS. Skipped
entirely when no site key configured (dev fallback). New i18n keys:
contact_captcha_pending, contact_server_error_captcha. Deploy workflow
forwards PUBLIC_TURNSTILE_SITE_KEY + TURNSTILE_SECRET.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 11:37:09 +02:00
217 changed files with 18200 additions and 2014 deletions
+7
View File
@@ -89,6 +89,13 @@ jobs:
IMAGE_CACHE_DIR=/data/image-cache
PUBLIC_UMAMI_SCRIPT_URL=https://cloud.umami.is/script.js
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`.
+3243 -22
View File
File diff suppressed because it is too large Load Diff
+19 -3
View File
@@ -17,18 +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",
@@ -37,17 +49,21 @@
"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",
"class-variance-authority": "^0.7.1",
"leaflet": "^1.9.4",
"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 -45
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,40 +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',
// 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',
'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 })) {
@@ -76,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 = {};
@@ -123,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');
+115 -87
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;
}
}
@@ -304,92 +325,80 @@ h4 {
}
}
/* Content-Links: Himmel (Design System Links) */
main a:not(.no-underline):not([class*="btn"]) {
/* Content-Links: Himmel (Design System Links).
* `:where(:not(.not-prose *))` schließt jeden Subtree mit `.not-prose`
* aus — Card-Links etc. behalten ihre eigene Farbe + kein Underline. */
main a:not(.no-underline):not([class*="btn"]):where(:not(.not-prose *)) {
color: var(--color-link);
text-decoration: underline;
}
main a:not(.no-underline):not([class*="btn"]):hover {
main a:not(.no-underline):not([class*="btn"]):where(:not(.not-prose *)):hover {
color: var(--color-himmel-600);
}
/* Lange URLs umbrechen, damit sie nicht über den Bildschirmrand ragen */
/* Lange URLs/Links umbrechen ohne mitten im Wort zu trennen.
* `overflow-wrap: anywhere` bricht nur wenn nötig (lange URL), während das
* frühere `word-break: break-all` jedes Wort hackte — sichtbar bei
* Card-Links wie PostCard, wo der ganze Card-Text in einem `<a>` liegt. */
main a,
.markdown a {
overflow-wrap: break-word;
word-break: break-all;
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;
}
main ul:not(.not-prose) {
main ul:not(.not-prose):where(:not(.not-prose *)) {
margin-left: 1em;
margin-bottom: 1em;
}
main ul:not(.not-prose) li {
main ul:not(.not-prose):where(:not(.not-prose *)) li {
list-style: disc;
}
main ol:not(.not-prose) {
main ol:not(.not-prose):where(:not(.not-prose *)) {
margin-left: 1.1em;
margin-bottom: 1em;
}
main ol:not(.not-prose) li {
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;
}
/* .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. */
/* 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;
/* 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-full border px-2 py-0.5 text-[11px] font-medium transition-colors;
}
/* Dekorativer Hintergrund mit zwei verschwommenen Verlaufs-Blobs (Wald → Erde).
@@ -400,7 +409,9 @@ main ol:not(.not-prose) li {
}
.gradient-blob-bg::before,
.gradient-blob-bg::after {
.gradient-blob-bg::after,
.gradient-blob-bg-inner::before,
.gradient-blob-bg-inner::after {
content: "";
position: absolute;
top: 50%;
@@ -419,14 +430,28 @@ main ol:not(.not-prose) li {
pointer-events: none;
}
.gradient-blob-bg::before {
.gradient-blob-bg::before,
.gradient-blob-bg-inner::before {
left: max(-7rem, calc(50% - 52rem));
}
.gradient-blob-bg::after {
.gradient-blob-bg::after,
.gradient-blob-bg-inner::after {
left: max(45rem, calc(50% + 8rem));
}
/* Variante für Container die SELBST nicht clippen sollen (z.B. Header mit
absolut positioniertem Dropdown): Blobs leben in einem inneren absoluten
Layer, der overflow-hidden hat, der Parent bleibt overflow-visible. */
.gradient-blob-bg-inner {
position: absolute;
inset: 0;
overflow: hidden;
isolation: isolate;
pointer-events: none;
z-index: 0;
}
/* Globaler Focus-Ring wald-Akzent für Tastatur-Nutzer (a11y + Polish) */
*:focus-visible {
outline: 2px solid var(--color-wald-500);
@@ -442,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;
}
@@ -471,15 +496,17 @@ article [data-block-type="markdown"] .markdown > p:first-child {
max-width: 50%;
}
/* Content-Blöcke */
.content p {
/* Content-Blöcke. `:where(:not(.not-prose *))` lässt jeden Subtree mit
* .not-prose komplett ausserhalb dieser Margin-Defaults — Components, die
* eigene Abstände setzen, müssen nur `not-prose` auf den Root packen. */
.content p:where(:not(.not-prose *)) {
@apply mb-4;
}
.content h1,
.content h2,
.content h3,
.content h4 {
.content h1:where(:not(.not-prose *)),
.content h2:where(:not(.not-prose *)),
.content h3:where(:not(.not-prose *)),
.content h4:where(:not(.not-prose *)) {
@apply mb-2;
}
@@ -604,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">
+24 -18
View File
@@ -1,9 +1,10 @@
import { getTranslationBundleBySlug } from '$lib/cms';
import { getNormalizedTranslations } from '$lib/cms';
import { isCmsAvailable } from '$lib/cms-health.server';
import { maintenanceResponse } from '$lib/maintenance.server';
import { TRANSLATION_BUNDLE_SLUG } from '$lib/constants';
import { env as envPublic } from '$env/dynamic/public';
import { logWarn } from '$lib/log.server';
import { lookupRedirect } from '$lib/redirects.server';
import type { Handle } from '@sveltejs/kit';
/**
@@ -62,6 +63,24 @@ export const handle: Handle = async ({ event, resolve }) => {
return maintenanceResponse(200);
}
// CMS-verwaltete Redirects (Admin UI → /admin/plugins/redirects).
// Editor-Regeln gewinnen gegen hardcoded Legacy-Patterns; nur GET, sonst
// brechen POST-Forms beim Pfadwechsel.
if (event.request.method === 'GET') {
const cmsRedirect = await lookupRedirect(event.url.pathname);
if (cmsRedirect) {
const search = event.url.search;
const dest =
cmsRedirect.to.startsWith('http') || !search
? cmsRedirect.to
: `${cmsRedirect.to}${search}`;
return new Response(null, {
status: cmsRedirect.status,
headers: { location: dest },
});
}
}
// Legacy-Pfade (/blog, /p) → /posts, /post. Vor Health-Check, weil
// SvelteKit-Routing/CMS-Calls dafür unnötig.
const redirectTo = legacyRedirectTarget(event.url.pathname);
@@ -78,23 +97,10 @@ export const handle: Handle = async ({ event, resolve }) => {
}
try {
const bundle = await getTranslationBundleBySlug(TRANSLATION_BUNDLE_SLUG, { locale: 'de' });
if (bundle?.strings && typeof bundle.strings === 'object') {
const normalized: Record<string, string> = {};
for (const [key, val] of Object.entries(bundle.strings)) {
const v = val as unknown;
const str =
typeof v === 'string'
? v
: typeof v === 'object' && v !== null && 'value' in v
? (v as { value?: unknown }).value
: (v as { text?: unknown })?.text ?? (v as { label?: unknown })?.label;
if (typeof str === 'string') normalized[key] = str;
}
event.locals.translations = normalized;
} else {
event.locals.translations = {};
}
event.locals.translations = await getNormalizedTranslations(
TRANSLATION_BUNDLE_SLUG,
{ locale: 'de' },
);
} catch (err) {
event.locals.translations = {};
logWarn('hooks.translations', err);
+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(" ");
+192 -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. */
@@ -313,6 +351,10 @@ export interface OrganisationEntry {
description?: string;
link?: string | { url?: string; linkName?: string; newTab?: boolean };
badge?: string | { label?: string; color?: string };
whatsapp?: string;
youtube?: string;
location?: { text?: string; lat?: number; lng?: number };
contacts?: (string | ContactEntry)[];
}
/** Organisations-Block (_type: "organisations"). */
@@ -320,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) */
@@ -331,6 +379,48 @@ export interface OrganisationsBlockData {
layout?: BlockLayout;
}
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;
}
export interface ContactEntry {
_type?: "contact";
_slug?: string;
name?: string;
role?: string;
organisations?: (string | { _slug?: string; name?: string })[];
email?: string;
phone?: string;
note?: string;
}
export interface ContactsBlockData {
_type?: "contacts_block";
_slug?: string;
headline?: string;
contacts?: (string | ContactEntry)[];
layout?: BlockLayout;
}
export interface AdressbuchBlockData {
_type?: "adressbuch";
_slug?: string;
excludeContacts?: (string | ContactEntry)[];
layout?: BlockLayout;
/** Server-side injected — not in CMS schema */
resolvedContacts?: ContactEntry[];
}
/** Live-Strommix-Widget (_type: "live_strommix"). Editorial-Konfig + Platzierungs-Instanz.
* Fetched live data from /api/strommix (aggregator) and renders status bucket
* derived from `(wind_onshore + wind_offshore + solar) / load * 100`. */
@@ -417,13 +507,25 @@ 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;
/** Variante: "default" voller Kalender, "compact" Anteaser. */
variant?: "default" | "compact";
/** Compact: Ziel-Page-Referenz hinter dem "Alle Termine"-Link. */
linkTo?: string | { _slug?: string;[key: string]: unknown };
/** Compact: Anzahl Termine (default 3). */
limit?: number;
};
/** 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";
@@ -440,5 +542,91 @@ 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;
}
import type { TextFragment, WindArea } from "./windkarte";
/** Stellungnahme-Generator (_type: "stellungnahme_generator").
* Platzierbare Instanz, rendert Einwendungs-Assistenten für ein Vorranggebiet. */
export interface StellingnahmeGeneratorBlockData {
_type?: "stellungnahme_generator";
_slug?: string;
id?: string;
/** Referenz auf wind_area; nach Resolve vollständiges WindArea-Objekt inkl.
* aufgelöster stellungnahme_kriterien (TextFragment[]). */
windArea?: string | (WindArea & {
stellungnahme_kriterien?: TextFragment[];
});
headline?: string;
intro?: string;
/** Referenz auf tag; nach Resolve: { _slug, name }. */
allgemeineArgumenteTag?: string | { _slug: string; name?: string };
/** Vom Resolver befüllt: allgemeine (gebietsunabhängige) Fragmente via Tag. */
allgemeineArgumente?: TextFragment[];
/** Einleitungssatz; {gebiet} wird ersetzt. */
defaultOpeningText?: string;
/** Abschlussforderung vor "Mit freundlichen Grüßen". */
defaultClosingText?: string;
/** ISO-Datum: Einwendungsfrist-Ende (z.B. "2026-07-04"). */
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;
}
+193 -62
View File
@@ -4,10 +4,15 @@ import {
getPostBySlug,
getTags,
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";
@@ -267,6 +272,8 @@ export function buildPostSearchIndex(posts: PostEntry[]): PostSearchIndexEntry[]
});
}
export type PostSortOrder = "newest" | "oldest";
export type LoadPostsResult = {
posts: PostEntry[];
tags: Awaited<ReturnType<typeof getTags>>;
@@ -274,6 +281,7 @@ export type LoadPostsResult = {
currentPage: number;
totalPages: number;
totalPosts: number;
sort: PostSortOrder;
upcomingEvents: PostEntry[];
searchIndex: PostSearchIndexEntry[];
tagName: string | null;
@@ -284,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[] = [];
@@ -326,33 +336,32 @@ export async function loadPostsList(opts: {
for (const post of posts) {
const field = getPostImageField(post.postImage);
if (field) {
try {
const url = await ensureTransformedImage(field.url, {
width: 400,
height: 267,
fit: "cover",
format: "webp",
focalX: field.focal?.x,
focalY: field.focal?.y,
});
const p = post as PostEntry & {
_resolvedImageUrl?: string;
_rawImageUrl?: string;
_imageFocal?: { x: number; y: number } | null;
};
p._resolvedImageUrl = url;
p._rawImageUrl = field.url;
p._imageFocal = field.focal ?? null;
} catch {
/* image optional */
}
const url = ensureTransformedImage(field.url, {
width: 400,
height: 267,
fit: "cover",
format: "webp",
focalX: field.focal?.x,
focalY: field.focal?.y,
});
const p = post as PostEntry & {
_resolvedImageUrl?: string;
_rawImageUrl?: string;
_imageFocal?: { x: number; y: number } | null;
};
p._resolvedImageUrl = url;
p._rawImageUrl = field.url;
p._imageFocal = field.focal ?? null;
}
}
} catch (e) {
cmsError = e instanceof Error ? e.message : String(e);
}
const filtered = filterPostsByTag(posts, tagSlug);
const filteredByTag = filterPostsByTag(posts, tagSlug);
// posts sind bereits neueste-zuerst sortiert; „oldest" = umgekehrte Reihenfolge.
const filtered =
sort === "oldest" ? [...filteredByTag].reverse() : filteredByTag;
const totalPages = getTotalPages(filtered.length, perPage);
const pageNum = Math.min(requestedPage, totalPages);
const pagePosts = paginate(filtered, pageNum, perPage);
@@ -371,6 +380,7 @@ export async function loadPostsList(opts: {
currentPage: pageNum,
totalPages,
totalPosts: filtered.length,
sort,
upcomingEvents,
searchIndex,
tagName,
@@ -512,15 +522,14 @@ export async function resolvePostOverviewBlocks(
.filter(Boolean);
const limit =
typeof item.numberItems === "number" ? item.numberItems : 9999;
const resolved: PostEntry[] = [];
for (const slug of slugs.slice(0, limit)) {
const post = await getPostBySlug(slug, {
locale: "de",
resolve: ["all"],
});
if (post) resolved.push(post);
}
item.postsResolved = resolved;
const settled = await Promise.all(
slugs.slice(0, limit).map((slug) =>
getPostBySlug(slug, { locale: "de", resolve: ["all"] }).catch(
() => null,
),
),
);
item.postsResolved = settled.filter((p): p is PostEntry => !!p);
}
}
if (Array.isArray(item.postsResolved)) {
@@ -531,26 +540,22 @@ export async function resolvePostOverviewBlocks(
for (const post of item.postsResolved as PostEntry[]) {
const field = getPostImageField(post.postImage);
if (field) {
try {
const url = await ensureTransformedImage(field.url, {
width: 400,
height: 267,
fit: "cover",
format: "webp",
focalX: field.focal?.x,
focalY: field.focal?.y,
});
const p = post as PostEntry & {
_resolvedImageUrl?: string;
_rawImageUrl?: string;
_imageFocal?: { x: number; y: number } | null;
};
p._resolvedImageUrl = url;
p._rawImageUrl = field.url;
p._imageFocal = field.focal ?? null;
} catch {
// Bild optional
}
const url = ensureTransformedImage(field.url, {
width: 400,
height: 267,
fit: "cover",
format: "webp",
focalX: field.focal?.x,
focalY: field.focal?.y,
});
const p = post as PostEntry & {
_resolvedImageUrl?: string;
_rawImageUrl?: string;
_imageFocal?: { x: number; y: number } | null;
};
p._resolvedImageUrl = url;
p._rawImageUrl = field.url;
p._imageFocal = field.focal ?? null;
}
}
}
@@ -602,19 +607,35 @@ export async function resolveCalendarBlocks(
resolve: "items",
});
const raw = calendar?.items ?? rawItems;
const resolved: CalendarItemData[] = [];
for (const x of raw) {
if (typeof x === "object" && x !== null && "terminZeit" in x && "title" in x) {
resolved.push(x as CalendarItemData);
} else {
const islug = typeof x === "string" ? x : (x as { _slug?: string })?._slug;
if (islug) {
const entry = await getCalendarItemBySlug(islug, { locale: "de" });
if (entry) resolved.push(entry);
}
}
// Leere items-Liste → alle calendar_items aus der Collection (Pool).
// Greift sowohl für default-Variante als auch compact-Anteaser.
if (raw.length === 0) {
const pool = (await getCalendarItems({ locale: "de" })) as unknown as CalendarItemData[];
(item as { items?: CalendarItemData[] }).items = pool;
continue;
}
(item as { items?: CalendarItemData[] }).items = resolved;
const settled = await Promise.all(
raw.map(async (x): Promise<CalendarItemData | null> => {
if (
typeof x === "object" &&
x !== null &&
"terminZeit" in x &&
"title" in x
) {
return x as CalendarItemData;
}
const islug =
typeof x === "string" ? x : (x as { _slug?: string })?._slug;
if (!islug) return null;
const entry = await getCalendarItemBySlug(islug, { locale: "de" }).catch(
() => null,
);
return entry ?? null;
}),
);
(item as { items?: CalendarItemData[] }).items = settled.filter(
(e): e is CalendarItemData => !!e,
);
}
}
}
@@ -753,3 +774,113 @@ export async function resolveSearchableTextBlocks(
}
}
}
function isStellingnahmeGeneratorBlock(item: unknown): boolean {
return (item as { _type?: string })?._type === "stellungnahme_generator";
}
/** 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,
): Promise<void> {
if (!layout) return;
const rows = [
layout.row1Content ?? [],
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 =
typeof windAreaRef === "string"
? windAreaRef
: (windAreaRef as { _slug?: string })?._slug ?? "";
if (windAreaSlug) {
const windArea = await getEntryBySlug("wind_area", windAreaSlug, {
locale: "de",
resolve: ["stellungnahme_kriterien"],
depth: 2,
});
if (windArea) {
const kriterienRaw =
(windArea as { stellungnahme_kriterien?: unknown[] })
.stellungnahme_kriterien ?? [];
// Fragmente einzeln laden um bulletPoints zu bekommen (nicht im Basis-Resolve)
const kriterien = await Promise.all(
kriterienRaw.map(async (k) => {
const slug =
typeof k === "string"
? k
: (k as { _slug?: string })?._slug ?? "";
if (!slug) return null;
return getTextFragmentBySlug(slug, { locale: "de" });
}),
);
(windArea as Record<string, unknown>).stellungnahme_kriterien =
kriterien.filter(Boolean);
block.windArea = windArea;
}
}
// Resolve allgemeineArgumente via Tag
const tagRef = block.allgemeineArgumenteTag;
const tagSlug =
typeof tagRef === "string"
? tagRef
: (tagRef as { _slug?: string })?._slug ?? "";
if (tagSlug) {
block.allgemeineArgumente = await getTextFragmentsByTag(tagSlug, {
locale: "de",
});
}
}
}
}
export async function resolveAdressbuchBlocks(
layout: RowContentLayout | null | undefined,
): Promise<void> {
if (!layout) return;
const rows = [layout.row1Content ?? [], layout.row2Content ?? [], layout.row3Content ?? []];
let contacts: unknown[] | null = null;
for (const content of rows) {
if (!Array.isArray(content)) continue;
for (const item of content) {
if (typeof item !== "object" || item === null) continue;
if ((item as { _type?: string })._type !== "adressbuch") continue;
if (contacts === null) contacts = await getContacts();
const block = item as Record<string, unknown>;
const exclude = (block.excludeContacts as unknown[]) ?? [];
const excludeSlugs = new Set(
exclude.map((e) =>
typeof e === "string" ? e : (e as { _slug?: string })?._slug ?? ""
).filter(Boolean)
);
block.resolvedContacts = excludeSlugs.size
? contacts.filter((c) => !excludeSlugs.has((c as { _slug?: string })._slug ?? ""))
: contacts;
}
}
}
+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;
+194 -25
View File
@@ -62,20 +62,22 @@ let openApiCache: OpenApiSpec | null = null;
* Override via PUBLIC_CMS_CACHE_TTL_MS_<COLLECTION> falls nötig.
*/
const CACHE_TTL: Record<string, number> = {
page: 5 * 60_000,
page_config: 10 * 60_000,
navigation: 10 * 60_000,
footer: 10 * 60_000,
post: 60_000,
tag: 5 * 60_000,
text_fragment: 5 * 60_000,
fullwidth_banner: 5 * 60_000,
calendar: 60_000,
calendar_item: 60_000,
translation: 5 * 60_000,
translation_bundle: 5 * 60_000,
openapi: 10 * 60_000,
default: 60_000,
// Webhooks purgen bei Mutation → höhere TTL ist unkritisch und spart
// Cold-Loads + CMS-Roundtrips.
page: 60 * 60_000, // 1h (war 5min)
page_config: 60 * 60_000, // 1h
navigation: 60 * 60_000, // 1h
footer: 60 * 60_000, // 1h
post: 10 * 60_000, // 10min (war 1min) — Posts ändern öfter
tag: 30 * 60_000, // 30min
text_fragment: 30 * 60_000, // 30min
fullwidth_banner: 60 * 60_000, // 1h
calendar: 5 * 60_000, // 5min — wenig Mutationen, aber zeitsensitiv
calendar_item: 5 * 60_000, // 5min
translation: 60 * 60_000, // 1h
translation_bundle: 60 * 60_000, // 1h
openapi: 60 * 60_000, // 1h
default: 5 * 60_000,
};
type CacheEntry<T> = { value: T; expires: number };
@@ -88,14 +90,19 @@ 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>,
): Promise<T> {
if (import.meta.env.DEV) return fn();
// Dev: Cache aus by default (sofortige Reflektion neuer CMS-Daten).
// Mit DEV_CACHE_CMS=1 anschalten — sinnvoll wenn lokal gegen Prod-CMS
// entwickelt wird, sonst werden Netzwerk-Roundtrips bei jedem Request fällig.
if (import.meta.env.DEV && process.env.DEV_CACHE_CMS !== "1") return fn();
const hit = cache.get(key);
if (hit && hit.expires > Date.now()) return hit.value as T;
const pending = inflight.get(key) as Promise<T> | undefined;
@@ -117,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++;
}
@@ -160,14 +169,35 @@ export async function fetchOpenApi(): Promise<OpenApiSpec> {
export async function getPages(): Promise<PageEntry[]> {
return cached("page", "page:list:", async () => {
const base = getBaseUrl();
const res = await cmsFetch(`${base}/api/content/page`);
if (!res.ok) {
throw new Error(
`RustyCMS list page failed: ${res.status}. Is the CMS running at ${base}?`,
// Alle Seiten holen — CMS-Default-per_page ist 50, die Site hat aber mehr.
// Ohne vollständige Liste scheitert der Slug→_slug-Fallback in
// getPageBySlug für Seiten jenseits der ersten Page → 404.
const PER_PAGE = 200;
// Sparse-Projektion: nur die Felder, die Caller (sitemap, search-index,
// root-page, getPageBySlug-Fallback) tatsächlich verwenden. Spart 8095 %
// Payload-Größe und vermeidet komplette Resolve-Trees.
const FIELDS =
"_slug,_updated,_created,slug,linkName,name,headline,subheadline";
const all: PageEntry[] = [];
let page = 1;
let totalPages = 1;
do {
const res = await cmsFetch(
`${base}/api/content/page?_per_page=${PER_PAGE}&_page=${page}&_fields=${FIELDS}`,
);
}
const data = (await res.json()) as PageListResponse;
return data.items ?? [];
if (!res.ok) {
throw new Error(
`RustyCMS list page failed: ${res.status}. Is the CMS running at ${base}?`,
);
}
const data = (await res.json()) as PageListResponse & {
total_pages?: number;
};
all.push(...(data.items ?? []));
totalPages = data.total_pages ?? 1;
page += 1;
} while (page <= totalPages);
return all;
});
}
@@ -322,6 +352,38 @@ function normalizePageSlug(s: string | undefined): string {
return (s ?? "").replace(/^\/+|\/+$/g, "").trim();
}
/**
* Schlanker Lookup: gibt zu einem URL-Slug ("/regionalplan/w36" oder
* "regionalplan/w36") den internen `_slug` zurück, indem das CMS gezielt nach
* dem `slug`-Feld gefiltert wird. Nur `_slug` wird projiziert → kleine Payload
* unabhängig von Collection-Größe. Probiert beide Slug-Schreibweisen.
*/
async function findPageSlugByField(
normSlug: string,
base: string,
): Promise<string | null> {
const candidates = [normSlug, "/" + normSlug];
for (const candidate of candidates) {
const u = new URL(`${base}/api/content/page`);
u.searchParams.set("slug", candidate);
u.searchParams.set("_fields", "_slug");
u.searchParams.set("_per_page", "2");
let r: Response;
try {
r = await cmsFetch(u.toString());
} catch {
continue;
}
if (!r.ok) continue;
const data = (await r.json().catch(() => null)) as
| { items?: Array<{ _slug?: string }> }
| null;
const hit = data?.items?.[0]?._slug;
if (hit) return hit;
}
return null;
}
/**
* Ein Page-Eintrag nach Slug (GET /api/content/page/:slug).
*/
@@ -354,6 +416,18 @@ export async function getPageBySlug(
res = await trySlug("/" + normSlug);
}
if (res.status === 404) {
// O(1)-Lookup via slug-Feld-Filter (statt komplette Page-Liste zu holen).
// Skaliert egal wie viele Pages existieren. Beide Slug-Schreibweisen
// probieren — Filter macht exakten String-Vergleich.
const matchSlug = await findPageSlugByField(normSlug, base);
if (matchSlug) {
res = await trySlug(matchSlug);
}
}
if (res.status === 404) {
// Last-resort: vollständige Liste durchsuchen (Safety-Net für Edge-Cases
// — z.B. Slug-Formen die der Exakt-Filter verfehlt aber post-Normalisierung
// matchen würden). Sollte praktisch nie greifen.
const items = await getPages();
const match = items.find(
(p) =>
@@ -746,6 +820,32 @@ export async function getTextFragmentBySlug(
});
}
/** Alle Text-Fragmente, die einen bestimmten Tag (Slug) tragen. */
export async function getTextFragmentsByTag(
tagSlug: string,
options?: { locale?: string },
): Promise<TextFragmentEntry[]> {
const key = `text_fragment:tag:${tagSlug}:${options?.locale ?? ""}`;
return cached("text_fragment", key, async () => {
const base = getBaseUrl();
const url = new URL(`${base}/api/content/text_fragment`);
url.searchParams.set("_per_page", "500");
url.searchParams.set("_resolve", "tags");
if (options?.locale) url.searchParams.set("_locale", options.locale);
const res = await cmsFetch(url.toString());
if (!res.ok) return [];
const data = (await res.json()) as { items?: TextFragmentEntry[] };
const items = data.items ?? [];
return items.filter((f) => {
const tags = (f as unknown as { tags?: unknown[] }).tags ?? [];
return tags.some((t) => {
if (typeof t === "string") return t === tagSlug;
return (t as { _slug?: string })?._slug === tagSlug;
});
});
});
}
/** Fullwidth-Banner (z. B. für Top-Banner mit Bild). */
export type TopBannerEntry = components["schemas"]["top_banner"];
@@ -847,6 +947,58 @@ 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
* once per bundle reload (every 5 min) instead of every request.
*/
const normalizedTranslations = new WeakMap<object, Record<string, string>>();
export async function getNormalizedTranslations(
slug: string,
options?: { locale?: string },
): Promise<Record<string, string>> {
const bundle = await getTranslationBundleBySlug(slug, options);
const strings = bundle?.strings;
if (!strings || typeof strings !== "object") return {};
const cached = normalizedTranslations.get(strings as object);
if (cached) return cached;
const out: Record<string, string> = {};
for (const [key, val] of Object.entries(strings as Record<string, unknown>)) {
const str =
typeof val === "string"
? val
: typeof val === "object" && val !== null && "value" in val
? (val as { value?: unknown }).value
: (val as { text?: unknown })?.text ??
(val as { label?: unknown })?.label;
if (typeof str === "string") out[key] = str;
}
normalizedTranslations.set(strings as object, out);
return out;
}
/** Kalender-Eintrag (calendar) aus OpenAPI-Spec. */
export type CalendarEntry = components["schemas"]["calendar"];
@@ -940,3 +1092,20 @@ export async function getCommentCounts(
return {};
}
}
/** Alle Kontakte (GET /api/content/contact), alphabetisch nach Name. */
export async function getContacts(): Promise<Record<string, unknown>[]> {
return cached("contact", "contact:list:all", async () => {
const base = getBaseUrl();
const url = new URL(`${base}/api/content/contact`);
url.searchParams.set("_per_page", "1000");
url.searchParams.set("_resolve", "all");
url.searchParams.set("_depth", "2");
url.searchParams.set("_sort", "name");
url.searchParams.set("_order", "asc");
const res = await cmsFetch(url.toString());
if (!res.ok) return [];
const data = (await res.json()) as { items?: Record<string, unknown>[] };
return data.items ?? [];
});
}
@@ -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>
+37 -3
View File
@@ -14,15 +14,21 @@
import PostOverviewBlock from "./blocks/PostOverviewBlock.svelte";
import SearchableTextBlock from "./blocks/SearchableTextBlock.svelte";
import CalendarBlock from "./blocks/CalendarBlock.svelte";
import CalendarCompactBlock from "./blocks/CalendarCompactBlock.svelte";
import OrganisationsBlock from "./blocks/OrganisationsBlock.svelte";
import ContactsBlock from "./blocks/ContactsBlock.svelte";
import OpnFormBlock from "./blocks/OpnFormBlock.svelte";
import InternalComponentBlock from "./blocks/InternalComponentBlock.svelte";
import DeadlineBannerBlock from "./blocks/DeadlineBannerBlock.svelte";
import StrommixBlock from "./blocks/StrommixBlock.svelte";
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,
AdressbuchBlockData,
MarkdownBlockData,
HeadlineBlockData,
HtmlBlockData,
@@ -39,11 +45,16 @@
SearchableTextBlockData,
CalendarBlockData,
OrganisationsBlockData,
ContactsBlockData,
OpnFormBlockData,
InternalComponentBlockData,
DeadlineBannerBlockData,
LiveStrommixBlockData,
WindMapBlockData,
StellingnahmeGeneratorBlockData,
OrganisationsMapBlockData,
SectionGridBlockData,
ContactFormBlockData,
} from "$lib/block-types";
let {
@@ -83,9 +94,15 @@
{:else if type === "searchable_text"}
<SearchableTextBlock block={block as SearchableTextBlockData} translations={translations} />
{:else if type === "calendar"}
<CalendarBlock block={block as CalendarBlockData} translations={translations} />
{#if (block as CalendarBlockData).variant === "compact"}
<CalendarCompactBlock block={block as CalendarBlockData} translations={translations} />
{:else}
<CalendarBlock block={block as CalendarBlockData} translations={translations} />
{/if}
{:else if type === "organisations"}
<OrganisationsBlock block={block as OrganisationsBlockData} />
{:else if type === "contacts_block"}
<ContactsBlock block={block as ContactsBlockData} />
{:else if type === "opnform"}
<OpnFormBlock block={block as OpnFormBlockData} />
{:else if type === "internal_component"}
@@ -93,9 +110,26 @@
{:else if type === "deadline_banner"}
<DeadlineBannerBlock block={block as DeadlineBannerBlockData} />
{:else if type === "live_strommix"}
<StrommixBlock block={block as LiveStrommixBlockData} />
<!-- Lazy: ~220 KB Block-Bundle nur laden wenn Seite das Widget tatsächlich hat -->
{#await import("./blocks/StrommixBlock.svelte") then m}
{@const Comp = m.default}
<Comp block={block as LiveStrommixBlockData} />
{/await}
{:else if type === "wind_map"}
<WindkarteBlock block={block as WindMapBlockData} />
{:else if type === "adressbuch"}
<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}
<Comp block={block as StellingnahmeGeneratorBlockData} />
{/await}
{:else}
<div class="rounded border border-erde-200 bg-erde-50 px-3 py-2 text-sm text-erde-800">
Unbekannter Block: <code>{type}</code>
+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' } },
};
+66
View File
@@ -0,0 +1,66 @@
<script lang="ts">
import Icon from "@iconify/svelte";
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();
const orgNames = $derived(
(contact.organisations ?? [])
.filter((o): o is { _slug?: string; name?: string } => typeof o === "object" && o !== null)
.map((o) => o.name)
.filter(Boolean) as string[]
);
</script>
<div
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>
{#if contact.role || orgNames.length > 0}
<div class="flex flex-wrap gap-1">
{#if contact.role}
<Badge>{contact.role}</Badge>
{/if}
{#each orgNames as org}
<Badge color="green">{org}</Badge>
{/each}
</div>
{/if}
</div>
<!-- vCard download -->
<button
type="button"
title="Kontakt speichern (.vcf)"
onclick={() => downloadVCard(contact)}
class="absolute top-3 right-3 text-stein-300 transition-colors hover:text-wald-600"
>
<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="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="lucide:mail" class="size-3.5 shrink-0 text-stein-400" />
<span class="truncate">{contact.email}</span>
</a>
{/if}
</div>
{/if}
{#if contact.note}
<p class="text-[11px] text-stein-400">{contact.note}</p>
{/if}
</div>
+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' },
};
+7 -7
View File
@@ -28,20 +28,20 @@
<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'} class="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap 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" />
{locationText}
<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="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 class="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap 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" />
{locationText}
<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="lucide:map-pin" class="size-3.5 shrink-0 opacity-90" aria-hidden="true" />
<span class="truncate max-w-[16rem]">{locationText}</span>
</span>
{/if}
{/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' },
],
},
};
+202 -77
View File
@@ -19,6 +19,12 @@
interface NavLink {
href: string;
label: string;
children?: NavLink[];
}
function isActiveTree(link: NavLink, pathname: string): boolean {
if (isActiveLink(link.href, pathname)) return true;
return (link.children ?? []).some((c) => isActiveLink(c.href, pathname));
}
interface SocialLink {
@@ -46,6 +52,11 @@
}
let menuOpen = $state(false);
let searchOpen = $state(false);
let openSubmenu = $state<string | null>(null);
function toggleSubmenu(label: string) {
openSubmenu = openSubmenu === label ? null : label;
}
function toggleMenu() {
if (menuOpen) {
@@ -53,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;
}
}
@@ -100,9 +116,10 @@
<header
id="horizontal-navigation"
class="gradient-blob-bg 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"
>
<div class="flex items-center justify-between container-custom py-2">
<span class="gradient-blob-bg-inner" aria-hidden="true"></span>
<div class="relative flex items-center justify-between container-custom py-2">
<a
href="/"
aria-label={t(T.site_name_fallback)}
@@ -110,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}
@@ -126,55 +143,111 @@
aria-label={t(T.header_nav_aria)}
>
{#each links as link}
{@const active = isActiveLink(link.href, $page.url.pathname)}
<a
href={link.href}
class="text-[.7rem] py-1 {active ? 'font-bold ' : ''}"
aria-current={active ? "page" : undefined}
>
{link.label}
</a>
{/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)}
>
<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)}
aria-expanded={searchOpen}
aria-controls="search-panel"
onclick={() => (searchOpen = !searchOpen)}
>
{#if searchOpen}
<Icon
icon="mdi:close"
class="text-error!"
aria-hidden="true"
/>
{@const active = isActiveTree(link, $page.url.pathname)}
{#if link.children && link.children.length > 0}
<div class="relative group inline-flex items-center">
{#if link.href && link.href !== "#"}
<a
href={link.href}
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="lucide:chevron-down" class="size-3 shrink-0" aria-hidden="true" />
</a>
{:else}
<button
type="button"
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="lucide:chevron-down" class="size-3 shrink-0" aria-hidden="true" />
</button>
{/if}
<div
role="menu"
class="absolute left-0 top-full pt-1 min-w-[10rem] opacity-0 invisible translate-y-1 transition-all group-hover:opacity-100 group-hover:visible group-hover:translate-y-0 group-focus-within:opacity-100 group-focus-within:visible group-focus-within:translate-y-0 z-50"
>
<ul class="bg-stein-0 shadow-lg rounded border border-stein-200 py-1 text-stein-800">
{#each link.children as child}
{@const cActive = isActiveLink(child.href, $page.url.pathname)}
<li role="none">
<a
role="menuitem"
href={child.href}
class="block px-3 py-1.5 text-xs hover:bg-wald-50 {cActive ? 'font-bold text-wald-700' : ''}"
aria-current={cActive ? "page" : undefined}
>
{child.label}
</a>
</li>
{/each}
</ul>
</div>
</div>
{:else}
<Icon icon="mdi:magnify" aria-hidden="true" />
<a
href={link.href}
class="text-[.7rem] leading-none py-1 inline-flex items-center {active ? 'font-bold ' : ''}"
aria-current={active ? "page" : undefined}
>
{link.label}
</a>
{/if}
</button>
{/each}
<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)}
>
{#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)}
@@ -184,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)}
@@ -212,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>
@@ -230,54 +300,109 @@
{#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)}
>
{#each links as link}
{@const active = isActiveLink(
link.href,
$page.url.pathname,
)}
<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'
: 'text-stein-100 hover:text-stein-0 hover:bg-stein-0/10 border-l-4 border-transparent'}"
aria-current={active ? "page" : undefined}
onclick={closeMenu}
>
{link.label}
<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}
{@const expanded = openSubmenu === link.label}
<div>
<button
type="button"
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 ? "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 }}>
{#if link.href && link.href !== "#"}
{@const pActive = isActiveLink(link.href, $page.url.pathname)}
<a
href={link.href}
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}
>
{link.label} Übersicht
</a>
{/if}
{#each link.children as child}
{@const cActive = isActiveLink(child.href, $page.url.pathname)}
<a
href={child.href}
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}
>
{child.label}
</a>
{/each}
</div>
{/if}
</div>
{:else}
<a
href={link.href}
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}
>
{link.label}
</a>
{/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>
+140 -5
View File
@@ -1,8 +1,12 @@
<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,
@@ -11,6 +15,7 @@
commentPageId = null,
commentEnvironment,
commentTargetId = "comments-section",
socialSlug = null,
class: cls = "",
}: {
/** Absolute URL of the page being shared. */
@@ -25,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) ────────────────
@@ -138,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>
<!--
@@ -156,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}
@@ -171,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}
@@ -183,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"
@@ -192,16 +299,44 @@
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}
sublabel="Link teilen"
class="inline-flex items-center px-2.5 py-1 text-base text-stein-600 hover:bg-stein-50 hover:text-stein-900 transition-colors"
/>
</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 },
};
+38 -12
View File
@@ -6,8 +6,10 @@
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";
const t = useTranslate();
@@ -49,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
@@ -83,29 +95,43 @@
</div>
{/if}
{#if eventDate}
<div class="absolute bottom-2 left-2 right-2">
<div class="absolute bottom-2 left-2 right-2 min-w-0">
<EventBadges {eventDate} {locationText} />
</div>
{/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
@@ -119,7 +145,7 @@
</h5>
{#if post.subheadline}
<div class="text-xs line-clamp-2 mb-1">
{post.subheadline}
{@html marked.parseInline(post.subheadline)}
</div>
{/if}
{#if post.excerpt}
@@ -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 = {};
+30
View File
@@ -0,0 +1,30 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
import QrModal from "./QrModal.svelte";
let {
value,
label = "",
sublabel = "",
class: cls = "",
}: {
value: string;
label?: string;
sublabel?: string;
class?: string;
} = $props();
let open = $state(false);
</script>
<button
type="button"
title="QR-Code anzeigen"
onclick={() => (open = true)}
class={cls}
>
<Icon icon="lucide:qr-code" class="size-[1em]" />
</button>
<QrModal value={open ? value : null} {label} {sublabel} onclose={() => (open = false)} />
+103
View File
@@ -0,0 +1,103 @@
<script lang="ts">
import { onMount } from "svelte";
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
import QRCode from "qrcode";
import Button from "$lib/ui/Button.svelte";
let {
value,
label = "",
sublabel = "",
onclose,
}: {
value: string | null;
label?: string;
sublabel?: string;
onclose: () => void;
} = $props();
let qrSvg = $state("");
let qrPngUrl = $state("");
$effect(() => {
if (!value) { qrSvg = ""; qrPngUrl = ""; return; }
QRCode.toString(value, {
type: "svg",
errorCorrectionLevel: "M",
margin: 2,
color: { dark: "#1c1917", light: "#ffffff" },
}).then((svg) => { qrSvg = svg; });
QRCode.toDataURL(value, {
errorCorrectionLevel: "M",
margin: 2,
width: 512,
color: { dark: "#1c1917", light: "#ffffff" },
}).then((url) => { qrPngUrl = url; });
});
function downloadPng() {
if (!qrPngUrl) return;
const a = document.createElement("a");
a.href = qrPngUrl;
a.download = `${(label || "qrcode").toLowerCase().replace(/\s+/g, "-")}.png`;
a.click();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape") onclose();
}
onMount(() => {
window.addEventListener("keydown", handleKeydown);
return () => window.removeEventListener("keydown", handleKeydown);
});
</script>
{#if value}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
class="not-prose fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onclick={onclose}
>
<div
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"
>
<button
type="button"
onclick={onclose}
class="absolute right-3 top-3 text-stein-400 hover:text-stein-700"
aria-label="Schließen"
>
<Icon icon="lucide:x" class="size-5" />
</button>
{#if label}
<p class="mb-1 text-sm font-medium text-stein-900">{label}</p>
{/if}
{#if sublabel}
<p class="mb-4 text-xs text-stein-400">{sublabel}</p>
{:else}
<div class="mb-4"></div>
{/if}
{#if qrSvg}
{@html qrSvg}
{:else}
<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
variant="outline"
size="sm"
onclick={downloadPng}
disabled={!qrPngUrl}
class="mt-3 w-full"
>
<Icon icon="lucide:download" class="size-3.5" />
Als PNG herunterladen
</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: () => {},
},
};
+51 -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>
@@ -198,13 +216,24 @@
</div>
{/if}
<!-- Stellungnahme -->
<!-- Stellungnahme-Generator (interne URL) -->
{#if area.stellungnahmeUrl}
<a
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="lucide:file-pen" class="size-4 shrink-0" />
Einwendung erstellen
</a>
{/if}
<!-- Stellungnahme-Vorlage (Post) -->
{#if stellungnahmeHref(area.stellungnahme)}
<a
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}
@@ -215,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)}
@@ -225,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}
@@ -241,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
@@ -250,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>
+82 -31
View File
@@ -11,6 +11,8 @@
initialGebietsNr = null,
hiddenStatuses = [],
hiddenRings = [],
fitBoundsMaxZoom = 11,
fitBoundsPadding = null,
}: {
areas: WindArea[];
onselect: (area: WindArea | null, center: [number, number] | null) => void;
@@ -18,6 +20,11 @@
initialGebietsNr?: string | null;
hiddenStatuses?: string[];
hiddenRings?: string[];
fitBoundsMaxZoom?: number;
/** Wenn gesetzt: symmetrisches [x,y]-Padding für fitBounds (z.B. Mini-Map
* ohne Panel). Default null = panel-aware Padding (großer Bottom-/Side-
* Bereich für Detail-Drawer in der vollwertigen Karte). */
fitBoundsPadding?: [number, number] | null;
} = $props();
const t = useTranslate();
@@ -26,6 +33,7 @@
let _hidden: string[] = [];
$effect(() => {
_hidden = hiddenStatuses;
syncLabelsFn?.();
if (!geojsonLayer) return;
restyleAll(selectedSlug);
// Deselect if selected area's status was just hidden.
@@ -51,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);
@@ -164,10 +173,19 @@
if (zoom && prev !== name) {
const desktop = window.innerWidth >= 640;
const pad: { paddingTopLeft: [number, number]; paddingBottomRight: [number, number] } =
fitBoundsPadding
? {
paddingTopLeft: fitBoundsPadding,
paddingBottomRight: fitBoundsPadding,
}
: {
paddingTopLeft: [40, 40],
paddingBottomRight: [desktop ? 320 : 40, desktop ? 40 : 300],
};
map!.fitBounds((layer as import("leaflet").Polygon).getBounds(), {
paddingTopLeft: [40, 40],
paddingBottomRight: [desktop ? 320 : 40, desktop ? 40 : 300],
maxZoom: 11,
...pad,
maxZoom: fitBoundsMaxZoom,
});
}
}
@@ -182,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", {
@@ -259,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({
@@ -290,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();
@@ -354,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' },
],
},
},
};
@@ -0,0 +1,330 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
import { getBlockLayoutClasses } from "$lib/block-layout";
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();
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const allContacts = $derived((block.resolvedContacts ?? []) as ContactEntry[]);
const allOrgs = $derived(() => {
const map = new Map<string, string>();
for (const c of allContacts) {
for (const o of c.organisations ?? []) {
if (typeof o === "object" && o !== null && o._slug && o.name) {
map.set(o._slug, o.name);
}
}
}
return [...map.entries()].sort((a, b) => a[1].localeCompare(b[1], "de"));
});
let search = $state("");
let activeOrg = $state<string | null>(null);
let activeLetter = $state<string | null>(null);
let viewMode = $state<"alpha" | "org">("alpha");
const afterOrgFilter = $derived(() =>
allContacts.filter((c) => {
if (!activeOrg) return true;
return (c.organisations ?? []).some(
(o) => (typeof o === "object" ? o._slug : o) === activeOrg
);
})
);
const availableLetters = $derived(() => {
const set = new Set<string>();
for (const c of afterOrgFilter()) {
const first = (c.name ?? "").trim()[0];
if (first) set.add(first.toUpperCase());
}
return set;
});
const filtered = $derived(() => {
const q = search.toLowerCase().trim();
return afterOrgFilter().filter((c) => {
if (activeLetter) {
const first = (c.name ?? "").trim()[0]?.toUpperCase();
if (first !== activeLetter) return false;
}
if (!q) return true;
return (
(c.name ?? "").toLowerCase().includes(q) ||
(c.role ?? "").toLowerCase().includes(q) ||
(c.email ?? "").toLowerCase().includes(q) ||
(c.organisations ?? []).some((o) =>
typeof o === "object" ? (o.name ?? "").toLowerCase().includes(q) : false
)
);
});
});
// Alphabetisch gruppiert
const alphaGrouped = $derived(() => {
const map = new Map<string, ContactEntry[]>();
for (const c of filtered()) {
const letter = (c.name ?? "").trim()[0]?.toUpperCase() ?? "#";
if (!map.has(letter)) map.set(letter, []);
map.get(letter)!.push(c);
}
return [...map.entries()];
});
// Nach Organisation gruppiert
const orgGrouped = $derived(() => {
const groups = new Map<string, { name: string; contacts: ContactEntry[] }>();
const noOrg: ContactEntry[] = [];
for (const c of filtered()) {
const orgs = (c.organisations ?? []).filter(
(o): o is { _slug?: string; name?: string } => typeof o === "object" && !!o._slug && !!o.name
);
if (orgs.length === 0) {
noOrg.push(c);
} else {
for (const o of orgs) {
const slug = o._slug!;
if (!groups.has(slug)) groups.set(slug, { name: o.name!, contacts: [] });
groups.get(slug)!.contacts.push(c);
}
}
}
const result = [...groups.values()].sort((a, b) => a.name.localeCompare(b.name, "de"));
if (noOrg.length > 0) result.push({ name: "Weitere", contacts: noOrg });
return result;
});
function escHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function highlight(text: string, q: string): string {
if (!q.trim()) return escHtml(text);
const safe = q.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return text
.split(new RegExp(`(${safe})`, "gi"))
.map((p, i) =>
i % 2 === 1
? `<mark class="bg-wald-100 text-wald-900 rounded-xs px-px">${escHtml(p)}</mark>`
: escHtml(p)
)
.join("");
}
</script>
<div class="{layoutClasses} not-prose" data-block-type="adressbuch">
<!-- Search + Filter bar -->
<div class="no-print mb-4 flex flex-col gap-2">
<div class="flex gap-2">
<SearchField
bind:value={search}
placeholder="Suchen…"
ariaLabel="Kontakte durchsuchen"
class="max-w-sm flex-1"
/>
<!-- View toggle -->
<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="lucide:arrow-down-a-z" class="size-4" />
</button>
<button
type="button"
onclick={() => { viewMode = "org"; activeLetter = null; }}
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="lucide:users" class="size-4" />
</button>
</div>
</div>
<div class="flex flex-wrap gap-1">
<button
onclick={() => (activeOrg = null)}
class="chip {activeOrg === null
? 'border-wald-400 bg-wald-500 text-white'
: 'border-stein-300 text-stein-700 hover:bg-stein-100'}"
>
Alle
</button>
{#each allOrgs() as [slug, name]}
<button
onclick={() => (activeOrg = activeOrg === slug ? null : slug)}
class="chip {activeOrg === slug
? 'border-wald-400 bg-wald-500 text-white'
: 'border-stein-300 text-stein-700 hover:bg-stein-100'}"
>
{name}
</button>
{/each}
</div>
{#if viewMode === "alpha"}
<div class="flex flex-wrap gap-0.5">
{#each "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("") as letter}
{@const has = availableLetters().has(letter)}
<button
onclick={() => (activeLetter = activeLetter === letter ? null : letter)}
disabled={!has}
class="chip w-6 justify-center border-transparent {activeLetter === letter
? 'bg-wald-500 text-white'
: has
? 'text-stein-700 hover:bg-stein-100'
: 'cursor-default text-stein-300'}"
>
{letter}
</button>
{/each}
</div>
{/if}
</div>
<!-- Contact list -->
{#if filtered().length === 0}
<p class="text-sm text-stein-400">Keine Kontakte gefunden.</p>
{:else}
<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">
{letter}
</div>
{#each contacts as contact (contact._slug)}
{@render contactRow(contact)}
{/each}
{/each}
{:else}
{#each orgGrouped() as group}
<div class="sticky top-0 z-10 border-b border-stein-100 bg-stein-50 px-4 py-1.5 text-xs font-semibold text-stein-600">
{group.name}
</div>
{#each group.contacts as contact (contact._slug)}
{@render contactRow(contact)}
{/each}
{/each}
{/if}
</div>
<div class="mt-2 flex items-center justify-between">
<Button
variant="outline"
size="sm"
onclick={() => downloadVCardAll(filtered())}
class="no-print"
>
<Icon icon="lucide:download" class="size-3.5" />
Alle exportieren (.vcf)
</Button>
<p class="text-xs text-stein-400">{filtered().length} Kontakt{filtered().length !== 1 ? "e" : ""}</p>
</div>
{/if}
</div>
{#snippet contactRow(contact: ContactEntry)}
<div class="relative flex flex-col gap-2 px-4 py-3">
<div class="min-w-0 pr-14">
<p class="text-sm font-medium text-stein-900">{@html highlight(contact.name ?? "", search.trim())}</p>
{#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}
<Badge>{contact.role}</Badge>
{/if}
{#each (contact.organisations ?? []).filter((o): o is { _slug?: string; name?: string } => typeof o === "object" && !!o.name) as org}
<Badge color="green">{org.name}</Badge>
{/each}
</div>
{/if}
</div>
<!-- Actions top-right -->
<div class="no-print absolute top-3 right-4 flex gap-2 text-base">
<QrButton
value={buildVCardString(contact)}
label={contact.name}
sublabel={contact.role}
class="text-stein-300 transition-colors hover:text-wald-600"
/>
<button
type="button"
title="Kontakt speichern (.vcf)"
onclick={() => downloadVCard(contact)}
class="text-stein-300 transition-colors hover:text-wald-600"
>
<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="lucide:map-pin" class="size-3.5 shrink-0" />
<span class="min-w-0 truncate">{contact.note}</span>
</div>
{/if}
{#if contact.phone || contact.email}
<div class="flex flex-col gap-1">
{#if contact.phone}
<div class="flex items-center gap-1.5">
<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"
title="Nummer kopieren"
onclick={() => navigator.clipboard.writeText(contact.phone ?? "")}
class="no-print shrink-0 text-stein-300 transition-colors hover:text-wald-600"
>
<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="lucide:arrow-up-right" class="size-3.5" />
</a>
</div>
{/if}
{#if contact.email}
<div class="flex items-center gap-1.5">
<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"
title="E-Mail kopieren"
onclick={() => navigator.clipboard.writeText(contact.email ?? "")}
class="no-print shrink-0 text-stein-300 transition-colors hover:text-wald-600"
>
<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="lucide:arrow-up-right" class="size-3.5" />
</a>
</div>
{/if}
</div>
{/if}
</div>
{/snippet}
<style>
@media print {
.no-print { display: none !important; }
}
</style>
@@ -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,
},
};
@@ -0,0 +1,137 @@
<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";
import "$lib/iconify-offline";
import Icon from "@iconify/svelte";
let {
block,
translations = {},
}: { block: CalendarBlockData; translations?: Translations | null } = $props();
function t(key: string, replacements?: Record<string, string | number>) {
return tStatic(translations ?? null, key, replacements);
}
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const limit = $derived(typeof block.limit === "number" && block.limit > 0 ? block.limit : 3);
/** Compact-Variante zeigt nur aufgelöste Items (Strings = unresolved Slugs → skip). */
const allItems = $derived(
(block.items ?? []).filter(
(x): x is CalendarItemData => typeof x === "object" && x !== null && "terminZeit" in x,
),
);
/** Nur zukünftige Termine, aufsteigend sortiert, auf Limit gekürzt. */
const upcoming = $derived(
allItems
.map((it) => ({ it, start: it.terminZeit ? new Date(it.terminZeit) : null }))
.filter((x): x is { it: CalendarItemData; start: Date } => {
if (!x.start || isNaN(x.start.getTime())) return false;
return x.start.getTime() >= Date.now() - 12 * 3600 * 1000; // 12h Toleranz
})
.sort((a, b) => a.start.getTime() - b.start.getTime())
.slice(0, limit),
);
function fmtDate(d: Date): string {
return d.toLocaleDateString("de-DE", {
weekday: "short",
day: "2-digit",
month: "long",
});
}
function fmtTime(d: Date): string {
const h = d.getHours();
const m = d.getMinutes();
if (h === 0 && m === 0) return "";
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;
const slug = typeof lt === "string" ? lt : (lt._slug ?? "");
if (!slug) return null;
const clean = slug.replace(/^\/+|\/+$/g, "");
return clean ? `/${clean}/` : "/";
});
</script>
<div
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}
>
<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="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 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-3 px-4 py-3 no-underline {itemHref ? 'transition-colors hover:bg-stein-50' : ''}"
>
<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="mt-0.5 text-lg font-bold tabular-nums leading-none">
{start.getDate()}
</span>
</div>
<div class="min-w-0 flex-1">
<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>
</li>
{/each}
</ul>
{/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,30 @@
<script lang="ts">
import ContactCard from "$lib/components/ContactCard.svelte";
import { getBlockLayoutClasses } from "$lib/block-layout";
import type { ContactsBlockData, ContactEntry } from "$lib/block-types";
let { block }: { block: ContactsBlockData } = $props();
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const contacts = $derived(
(block.contacts ?? []).filter((c): c is ContactEntry => typeof c === "object" && c !== null)
);
</script>
{#if contacts.length > 0}
<div class="{layoutClasses}" data-block="ContactsBlock" data-block-type="contacts_block">
{#if block.headline}
<h3 class="mb-3 text-sm font-semibold text-stein-700">{block.headline}</h3>
{/if}
<div
class="flex snap-x snap-mandatory gap-3 overflow-x-auto px-px pt-1 pb-2 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{#each contacts as contact}
<div class="w-[calc(100%-2rem)] min-w-[220px] max-w-[320px] shrink-0 snap-start sm:w-60 lg:w-72">
<ContactCard {contact} />
</div>
{/each}
</div>
</div>
{/if}
@@ -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"

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