Compare commits

...

232 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
Peter Meier 94a8feeb67 feat(contact): full validation, CMS-translated strings, smaller UI
Deploy / verify (push) Failing after 47s
Deploy / deploy (push) Has been skipped
- contact-form-validation.ts: shared client+server validator returning
  {key, params} so messages can be translated via the CMS bundle.
- ContactFormComponent: 3 honeypots, subject field, GDPR consent
  checkbox, character counter, inline per-field errors after submit,
  uses useTranslate() everywhere.
- /api/contact: origin allowlist, multiple honeypot check, client-age
  gate, server-side re-validation, forwards X-Forwarded-For + UA to
  CMS forms plugin with consent timestamp in payload.
- TextInput/Textarea: new size="sm" variant (rounded-sm, shadow-sm,
  h-10, smaller padding/text). Default "md" unchanged.
- translations.ts: 37 new contact_* keys.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 11:28:23 +02:00
Peter Meier 8575572183 refactor: consolidate block dispatch into BlockRenderer
ContentRows now delegates to BlockRenderer instead of maintaining its
own parallel if-else chain. Single source of truth for all block types.
Adds internal_component support (ContactFormComponent via /api/contact).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 11:12:49 +02:00
Peter Meier 9e05fbb2ba feat: add internal_component block type with contact form
InternalComponentBlock dispatches on `component` field value.
ContactFormComponent submits via /api/contact (SvelteKit proxy →
CMS /api/forms/kontakt/submit) with honeypot + client_age_ms.
BlockRenderer and block-types.ts updated accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 11:01:47 +02:00
Peter Meier 50d68a17ca fix(windkarte): Kriterien-Accordion text-xs, Titel 2-zeilig
Deploy / verify (push) Successful in 54s
Deploy / deploy (push) Successful in 1m5s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:52:03 +02:00
Peter Meier 8e43169b23 feat(windkarte): Stellungnahme-Kriterien per Windvorranggebiet
Deploy / verify (push) Successful in 56s
Deploy / deploy (push) Successful in 1m38s
Jedes Windvorranggebiet kann jetzt 1–n text_fragment-Einträge als
Stellungnahme-Kriterien referenzieren. Das Panel lädt diese lazy beim
Öffnen und zeigt sie als aufklappbare Accordion-Sektion.

Außerdem: alle hardcodierten Strings im Karten-Block (Panel, Map,
Block) durch t()-Aufrufe ersetzt; 7 neue Translation-Keys ergänzt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:22:35 +02:00
Peter Meier a3f545d727 feat(windkarte): add per-area geo export + dynamic favicon
Deploy / verify (push) Successful in 1m1s
Deploy / deploy (push) Successful in 1m8s
- /api/vorranggebiete.geojson + .kml endpoints (filter via ?id=W-XX)
- Download-Buttons in WindAreaPanel pro selektiertes Gebiet
- Hover-Tooltip auf Polygon zeigt Fläche, geplante Anlagen, Höhe, Gemeinden
- Favicon + apple-touch-icon dynamisch aus data.logoUrl (CMS-Logo)
- alte windrad-PNGs in static/favicons/ entfernt
- WindkarteBlock list-fetch: _limit statt limit (RustyCMS-Param)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:14:58 +02:00
Peter Meier 61d2093d0b seo: use banner PNG as default OG image, skip SVG logos for social
Deploy / verify (push) Successful in 55s
Deploy / deploy (push) Successful in 1m16s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:06:38 +02:00
Peter Meier 12e0d4e3b8 ui: carousel swipe, org peek, deadline banner fixes
Deploy / verify (push) Successful in 53s
Deploy / deploy (push) Successful in 1m3s
- Carousel: touch swipe support (40px threshold)
- OrganisationsBlock: 1.5 cards on mobile to hint at more content
- DeadlineBanner: left-aligned text, shrink-0 close button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 14:46:23 +02:00
Peter Meier 9d58c92e94 ui: card-surface utility, rounded corners, calendar polish, banner layout
Deploy / verify (push) Successful in 1m1s
Deploy / deploy (push) Successful in 1m9s
- Add .card-surface utility (@layer components): rounded-sm border shadow-sm
- Card.svelte, CalendarBlock, SearchableTextBlock, BlogOverview use card-surface
- OrganisationsBlock: logo h-12 rounded-sm, description style cleanup, pt-1 for hover clip fix
- DeadlineBanner: container-custom width constraint, text left-aligned
- WindAreaPanel stats: card-surface
- CalendarBlock: rounded-sm day headers, past days opacity-20 + diagonal strike
- Add .day-past pseudo-element utility for diagonal line

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 14:32:42 +02:00
Peter Meier f08e3bb790 windkarte: scroll map into view when URL hash selects an area
Deploy / verify (push) Successful in 54s
Deploy / deploy (push) Successful in 1m4s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 09:45:46 +02:00
Peter Meier cd362fb811 windkarte: Rechtskräftig default hidden, remove Entwurf-2-voraussichtlich, 2km/5km rings on by default
Deploy / verify (push) Successful in 58s
Deploy / deploy (push) Successful in 1m6s
- Hide status badge for rechtskraeftig in WindAreaPanel
- Default hiddenStatuses=["rechtskraeftig"] so polygons start hidden
- Fix: call restyleAll after geojsonLayer init so initial hidden state applies
- Default hiddenRings=[] so 2km+5km buffer rings show on selection
- Remove entwurf_2_voraussichtlich from legend
- Lower maxZoom 12→11 for more context on feature select

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 09:26:19 +02:00
Peter Meier 4a72142555 WindAreaPanel: compact key-value rows instead of cards
Label left, value right, divided rows. No more padded bg cards.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 09:18:38 +02:00
Peter Meier 823adde1fa WindAreaPanel: stats single column layout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 09:18:06 +02:00
Peter Meier f55f409158 WindAreaPanel: render notizen as HTML with prose styles
Was output as escaped plain text. Field contains HTML from the CMS editor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 09:16:46 +02:00
Peter Meier 250865893e FilesBlock: icon size-4 → size-5
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 08:46:52 +02:00
Peter Meier 07b6317b94 FilesBlock: mt-0.5 on icon for top alignment
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 08:46:38 +02:00
Peter Meier 07f17e880e FilesBlock: top-align icon with text
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 08:45:48 +02:00
Peter Meier baa4b9aae4 FilesBlock: allow description to wrap
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 08:45:17 +02:00
Peter Meier 61e01c127f FilesBlock: size as inline badge after filename
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 08:44:46 +02:00
Peter Meier 887a6bda3e FilesBlock: remove default link underline
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 08:43:57 +02:00
Peter Meier 774ac893ec FilesBlock: description on second line below filename
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 08:43:10 +02:00
Peter Meier 1284be9c42 FilesBlock: size label smaller + no-wrap
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 08:42:32 +02:00
Peter Meier 325f95487f FilesBlock: move size under icon, drop redundant ext label
Icon already communicates file type. Size now sits below the icon
in a fixed-width left column. Ext text removed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 08:41:40 +02:00
Peter Meier 1825b7d9fe FilesBlock: compact single-row layout
Reduce padding, smaller icon, filename + ext/size on one line.
Description hidden on mobile, truncated on desktop.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 08:39:09 +02:00
Peter Meier 4b9214dbaf Fix FilesBlock: external URLs bypass /cms-files proxy
getCmsFileUrl() returned /cms-files?src=... for all URLs including
external ones. resolveCmsSource() rejects foreign hosts (SSRF guard),
so external file links 404'd. External http(s) URLs now returned directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 08:36:43 +02:00
Peter Meier 44b991f7c1 Improve social media OG/Twitter meta for posts
Deploy / verify (push) Successful in 51s
Deploy / deploy (push) Successful in 1m2s
- Fix bug: post page was overriding og:image with 150x200 thumbnail
- og:type = "article" for posts (was always "website")
- article:published_time, article:modified_time, article:author, article:tag
- og:image:alt + twitter:image:alt (post headline)
- twitter:label1/data1 with reading time
- Remove thumbnail fallback from socialImage (falls back to site logo instead)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 15:35:32 +02:00
Peter Meier d7a84aff0d SearchableTextBlock: animated accordion, tag counts, empty state, focus-visible
- Replace <details> with Svelte-controlled accordion + slide transition (180ms)
- Close all items on search/tag filter change
- Live tag counts per filter showing matches for current search query
- Visual empty state with reset button when no results
- focus-visible ring on all interactive elements (was focus: — shows on click too)
- Tag.svelte: remove ring-0, add focus-visible:ring-2
- Card.svelte: add focus-visible ring to linked cards

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 15:31:38 +02:00
Peter Meier fa50617d3c Fix Tooltip overflow-hidden clipping via portal action
Portal the tooltip into document.body so overflow:hidden ancestors
can't clip it. Fixed positioning + getBoundingClientRect() for placement.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 15:25:18 +02:00
Peter Meier cbf0e4aa51 Fix Tooltip clipping in overflow-hidden containers — use fixed positioning
Switches from absolute (clipped by ancestor overflow:hidden) to fixed
with viewport-relative coords from getBoundingClientRect().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 15:22:27 +02:00
Peter Meier 35a34eb300 Fix SearchableTextBlock h2 color — override global heading CSS with \!text-white
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 15:21:03 +02:00
Peter Meier 1f430f01a5 Fix SearchableTextBlock header text contrast — white/80 on dark green
Deploy / verify (push) Successful in 53s
Deploy / deploy (push) Successful in 1m1s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 15:19:08 +02:00
Peter Meier 5b4d59175b Redesign SearchableTextBlock UI — stronger visual hierarchy
Deploy / verify (push) Successful in 56s
Deploy / deploy (push) Successful in 1m6s
Green gradient header, white card with shadow, left-border accent on open
accordion items, hover-visible copy button in summary row, lighter dividers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 15:18:07 +02:00
Peter Meier 4fc541acf9 perf: parallel block resolution, immutable asset caching, fewer favicons
Deploy / verify (push) Successful in 57s
Deploy / deploy (push) Successful in 59s
- +page.server.ts: resolveContentImages/Calendar/Deadline/SearchableText
  now run in parallel via Promise.all (after resolvePostOverviewBlocks)
- +page.server.ts: banner widths 5→3 [640,1024,1536]
- hooks.server.ts: /_app/immutable/** gets Cache-Control: max-age=31536000
- +layout.svelte: favicon tags 18→4 (ico, 32px png, 192px, apple-180px)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 15:00:07 +02:00
Peter Meier 19f3948f48 feat(carousel): 2 cols tablet, 3 cols desktop; equal-height cards
Deploy / verify (push) Successful in 54s
Deploy / deploy (push) Successful in 1m4s
- PostOverviewBlock carousel: md:2 cols, lg:3 cols via snap-scroll
- PostCard: add class prop for external styling
- Carousel cards: h-full for equal height

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 14:54:36 +02:00
Peter Meier 41dfbb2930 feat: add carousel design to PostOverview; extract shared Carousel component
Deploy / verify (push) Successful in 57s
Deploy / deploy (push) Successful in 1m5s
- New Carousel.svelte: index-fade, arrows, dots, optional auto-rotate
- QuoteCarouselBlock refactored to use Carousel
- PostOverviewBlock: carousel design variant added
- CMS schema: "carousel" added to post_overview design enum

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 14:27:31 +02:00
Peter Meier a1dcedd855 fix: WindkarteBlock col-span + data-block attrs
Deploy / verify (push) Successful in 52s
Deploy / deploy (push) Successful in 1m24s
Block fehlte layoutClasses (col-span) und data-block/type/slug attrs.
Layout-Feld zu WindMapBlockData ergänzt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 18:14:56 +02:00
Peter Meier 3cb728d677 fix: add wind_map block to ContentRows
Deploy / verify (push) Successful in 1m4s
Deploy / deploy (push) Successful in 1m40s
ContentRows.svelte fehlte wind_map — Block wurde als "Unbekannter Block" gerendert.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 17:59:06 +02:00
Peter Meier 0b6174eb53 chore: update iconify subset (map-outline swap)
Deploy / verify (push) Successful in 51s
Deploy / deploy (push) Successful in 59s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 15:31:42 +02:00
Peter Meier b4581e4d4f feat(windkarte): tap-to-activate overlay for mobile scroll fix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 15:31:23 +02:00
Peter Meier ad72c44d98 fix(windkarte): 2km/5km-Ringe selektion-basiert statt permanent
Deploy / verify (push) Successful in 54s
Deploy / deploy (push) Successful in 1m1s
Ringe erscheinen nur bei ausgewähltem Gebiet (wie 200m/600m/1km),
Toggle steuert Sichtbarkeit. Permanente Allgebiets-Berechnung entfernt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 15:24:14 +02:00
Peter Meier f9b7057ba4 fix(windkarte): maxZoom bei Selektion von 14 auf 12 reduziert
Deploy / verify (push) Successful in 51s
Deploy / deploy (push) Successful in 1m1s
2026-05-13 15:21:11 +02:00
Peter Meier cd67ff8dc8 feat(windkarte): permanente 2km/5km-Ringe togglebar in Legende
Deploy / verify (push) Successful in 55s
Deploy / deploy (push) Successful in 1m4s
Zeigt 2km (indigo) und 5km (lila) Pufferringe für alle Gebiete gleichzeitig,
unabhängig von Selektion. Default versteckt, Klick auf Legendenitem ein-/ausblenden.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 15:19:44 +02:00
Peter Meier 0f6a58fed4 feat(windkarte): Legend-Toggle — Status ein-/ausblendbar per Klick
Deploy / verify (push) Successful in 54s
Deploy / deploy (push) Successful in 1m1s
Klick auf Legendenitem blendet den Status aus (opacity 0, kein Klick,
kein Hover). Panel schließt sich wenn der Status des gewählten Gebiets
ausgeblendet wird. Visuell: opacity + line-through auf versteckten Items.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 15:09:14 +02:00
Peter Meier 7d4f52ba02 feat(windkarte): neuer Status entwurf_2_voraussichtlich (orange)
Deploy / verify (push) Successful in 56s
Deploy / deploy (push) Successful in 1m3s
Farbe #d4752a. Legende, Typ-Union, Übersetzungen ergänzt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 14:17:15 +02:00
Peter Meier ca742345f3 feat(windkarte): geometry eingebettet in wind_area CMS-Einträge
Deploy / verify (push) Successful in 49s
Deploy / deploy (push) Successful in 1m8s
GeoJSON-Geometrie direkt in CMS-Einträgen gespeichert — kein separates
GeoJSON-File mehr. WindkarteMap baut FeatureCollection aus areas[].geometry.
WindArea-Typ bekommt geometry-Feld. geojsonUrl-Prop entfernt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 11:34:59 +02:00
Peter Meier ee56a457d3 feat(windkarte): alle UI-Strings auf t(T.windkarte_*) umgestellt
Deploy / verify (push) Successful in 51s
Deploy / deploy (push) Successful in 59s
Keine hardcodierten Texte mehr — alle Strings über CMS translation_bundle
(app.json5). Keys in translations.ts registriert.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 11:09:09 +02:00
Peter Meier 91a09fd3c4 fix(windkarte): Interne "nicht verknüpft"-Meldung aus Panel entfernt
Deploy / verify (push) Successful in 50s
Deploy / deploy (push) Successful in 59s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 11:03:59 +02:00
Peter Meier fece9c7cfc fix(windkarte): Mobile — Map höher, Panel kürzer (52% statt 72%)
Deploy / verify (push) Successful in 52s
Deploy / deploy (push) Successful in 59s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 10:28:07 +02:00
Peter Meier 0cbf48f1fb fix(windkarte): Panel z-index über Leaflet-Controls (1001 > 1000)
Deploy / verify (push) Successful in 53s
Deploy / deploy (push) Successful in 1m0s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 10:26:23 +02:00
Peter Meier 0e28a7e334 feat(windkarte): Karten-UX Verbesserungen
Deploy / verify (push) Successful in 54s
Deploy / deploy (push) Successful in 1m7s
- Polygon-Labels ab Zoom 13 mit zoom-proportionaler Schriftgröße
- Hover-Highlight, Deselect per Hintergrundklick
- Mobile Bottom-Sheet / Desktop Side-Panel (CSS-only)
- Share-Link via URL-Hash (#W-10), auto-select on load
- Share-Button + OSM-Link im Panel
- Reset-Button auf Karte
- Buffer-Spinner während Turf-Berechnung
- Leaflet-Attribution nach links (kollidierte mit Panel)
- fitBounds mit asymmetrischem Padding + maxZoom 14

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 10:24:56 +02:00
Peter Meier 6fef86fc6b fix(markdown): use _slug instead of removed name field for data attribute
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 09:41:25 +02:00
Peter Meier c66899fa9b feat(windkarte): slugPrefix enforcement, name→gebiets_nr, name field removed
Deploy / verify (push) Successful in 48s
Deploy / deploy (push) Successful in 59s
- RustyCMS: SchemaDefinition.slug_prefix + auto-prepend in create_entry
- wind_area/wind_map schemas: slugPrefix config, name field removed
- wind_area entries: name→gebiets_nr (faktische Gebietsnummer, GeoJSON-Join-Key)
- WindkarteMap/WindAreaPanel: Join + Anzeige auf gebiets_nr

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 09:35:28 +02:00
Peter Meier 067e847524 feat(windkarte): wind_map referenziert wind_area explizit
Deploy / verify (push) Successful in 49s
Deploy / deploy (push) Successful in 1m1s
areas-Feld im Schema + Block-Logik: referenzierte Slugs werden einzeln
gefetcht. Leer = alle published areas als Fallback. Ermöglicht verschiedene
Karten-Blöcke mit unterschiedlichen Gebiet-Subsets.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 09:25:00 +02:00
Peter Meier 39d7d70c8b feat(windkarte): Leaflet-Block für Windvorranggebiete SWT
Deploy / verify (push) Successful in 58s
Deploy / deploy (push) Successful in 1m18s
Neuer CMS-Block `wind_map` — platzierbar auf jeder Seite per Row-Content.
Fetcht wind_area-Einträge + GeoJSON-Asset client-side, rendert Leaflet-Karte
(CartoDB Light) mit farbcodierten Polygonen nach Planungsstatus. Klick auf
Gebiet öffnet Slide-in-Panel mit Kenndaten, Gemeinden, Stellungnahme-Link
und Puffer-Ringen (200/600/1000 m via Turf). Erweiterbar für 2. Entwurf 2026.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 09:20:32 +02:00
Peter Meier cdc2a79950 refactor(strommix): route EC live snapshot through CMS, keep direct fallback
Deploy / verify (push) Successful in 52s
Deploy / deploy (push) Successful in 54s
Energy-Charts /public_power now fetched via strommix_history_de CMS collection
(which uses ${time:day_start|iso}/${time:now|iso} in its source URL). When the
CMS returns null (old binary without env_subst support), falls back to a direct
EC call so charts keep working during the transition.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 08:17:41 +02:00
Peter Meier 81e419aa4d fix(strommix): show sparkline without history backend
Deploy / verify (push) Successful in 55s
Deploy / deploy (push) Successful in 57s
The strommix_history_de collection source URL 404s (Energy-Charts needs
start/end params), so the CMS history backend stays empty and todayHourly
was always null — making the Wind & Solar sparkline never appear.

Extend fetchEnergyChartsPublicPower to start at midnight (was: -2h) and
return the raw series alongside the snapshot values. Build todayHourly
from it when the history backend has no items. No extra API call.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 07:58:34 +02:00
Peter Meier 10fb2a3ef6 feat(calendar): markdown descriptions, grid & header polish
Deploy / verify (push) Successful in 49s
Deploy / deploy (push) Successful in 50s
- description field rendered as Markdown (marked.parse + prose)
- month label left/larger, nav arrows grouped right
- weekday headers colored (himmel/stein)
- day cells weekend/weekday coloring
- remove pl-[4.75rem] indent from accordion body

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 20:53:51 +02:00
Peter Meier 7ea23531e9 feat(calendar): grid color polish, month header layout, direct event links
Deploy / verify (push) Successful in 51s
Deploy / deploy (push) Successful in 53s
- month label left + larger, prev/next arrows grouped right
- weekday headers colored (himmel/stein by weekday/weekend)
- day cells colored by weekday/weekend + event state
- event anchor IDs, onDestroy SSR fix, ImageModal component
- copy link button, action buttons always show text

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 20:43:21 +02:00
Peter Meier 23faef550e feat(calendar): image/attachment per event, direct links, copy link, ImageModal component
Deploy / verify (push) Successful in 58s
Deploy / deploy (push) Successful in 53s
- calendar_item schema: flyer → image (type: image), attachment (assetUrl)
- CalendarItemData: image + attachment fields
- event anchor IDs (#event-{slug}) with onMount hash scroll + auto-open
- share button uses direct calendar anchor URL
- copy link button (mdi:link-variant) copies anchor URL to clipboard
- ImageModal extracted as reusable component
- action buttons always show text (no expand-on-hover)
- onDestroy window guard (SSR fix)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 20:02:30 +02:00
Peter Meier 6fc2c0a345 feat(calendar): hover tooltip, light theme grid, UX polish
Deploy / verify (push) Successful in 53s
Deploy / deploy (push) Successful in 55s
- Hover tooltip on event cells (pointer:fine only, position:fixed bypasses overflow-hidden)
  — shows title + right-aligned time, max 3 events, "+N weitere"
- Grid background changed to himmel-100 (light), all text tokens updated to dark
- Today cell: wald-200 green background + wald-400 outline
- Empty day cells: subtle bg-himmel-50/40 tint
- Event badge larger on desktop (sm:min-w-4.5 sm:h-4.5 sm:text-[10px])
- Weekday headers: font-semibold text-himmel-800
- Calendar takes full width inside widget (removed max-w-2xl cap)
- Web Share API per event (navigator.share / clipboard fallback, Kopiert\! toast)
- Subscribe bar: webcal:// on Apple, direct ICS download on other platforms

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 17:23:41 +02:00
Peter Meier 6fb2592eae feat(calendar): add feed subscription, bulk export, share & Google Cal
Deploy / verify (push) Successful in 1m0s
Deploy / deploy (push) Successful in 1m3s
- /api/kalender.ics SSR route: ICS feed of all calendar items (webcal-subscribable, Cache 1h)
- buildICSFeed() / downloadICSFeed() for multi-event ICS export
- googleCalUrl() deep-link pre-fills Google Calendar create form
- Web Share API per event (navigator.share on mobile, clipboard fallback on desktop)
  — "Kopiert\!" toast per event for 2s after clipboard write
- Actions bar (subscribe + bulk export) only rendered after onMount
  — Apple platforms: webcal:// link opens Calendar.app natively
  — All others: direct ICS download via <a download>

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 16:50:56 +02:00
Peter Meier 7fa52bb629 fix(calendar): remove hard max-w cap on location text
Deploy / verify (push) Successful in 52s
Deploy / deploy (push) Successful in 56s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:08:17 +02:00
Peter Meier 4dd17b7ac4 fix(docker): add healthcheck and stop grace period
Deploy / verify (push) Successful in 57s
Deploy / deploy (push) Successful in 56s
Watchtower waits for container to be healthy before stopping the old
one. Grace period allows in-flight requests to drain on shutdown.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 14:06:46 +02:00
Peter Meier ec9930762d fix(TopBanner): align text-only page title with content container
Deploy / verify (push) Successful in 1m1s
Deploy / deploy (push) Successful in 58s
Replace `container mx-auto px-6` with `container-custom` so the
headline/subheadline block (no image case) matches the main content
padding and max-width.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 13:11:10 +02:00
Peter Meier ef163fb336 feat(layout): add global announcement banner above nav
Deploy / verify (push) Successful in 1m12s
Deploy / deploy (push) Successful in 1m6s
Adds AnnouncementBanner component fed by a new top_banner CMS entry
(site-announcement). Extended top_banner schema and TS types with
optional link + linkLabel fields. Rendered inline-markdown text.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 21:56:42 +02:00
Peter Meier 8db68e073c refactor(calendar): stack layout, wider grid, fixed cell height
Deploy / verify (push) Successful in 53s
Deploy / deploy (push) Successful in 57s
- Calendar widget stays stacked (calendar above events list)
- Grid widens to max-w-2xl with fixed h-10 cells (no aspect-square height growth)
- Gap between cells: 0.5 → 1.5
- Button system extracted to shared btn-* utility classes in app.css
- Replaced Google Calendar link + Google Maps with OSM
- Removed google icon from iconify subset

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 09:56:40 +02:00
Peter Meier b491e3767a Revert "fix(calendar): stack layout, cap block at 1000px, restore events scroll"
Deploy / verify (push) Successful in 51s
Deploy / deploy (push) Successful in 51s
This reverts commit bf6ca475db.
2026-05-08 08:18:05 +02:00
Peter Meier bf6ca475db fix(calendar): stack layout, cap block at 1000px, restore events scroll
Deploy / verify (push) Successful in 53s
Deploy / deploy (push) Successful in 53s
The 2-col grid forced the events list to share the calendar's square
height which cropped event details. Stack calendar over events instead,
cap the whole block at 1000px (centered), constrain the calendar grid to
480px so day cells don't grow oversized, and re-add a 600px max-height
with internal scroll on the events list so the page stays compact and
sticky day-group headers regain their scroll context.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 08:16:57 +02:00
Peter Meier ce4c4570d6 feat(header): keep internal social-nav links in same tab
Deploy / verify (push) Successful in 55s
Deploy / deploy (push) Successful in 57s
Social-link entries with internal URLs (starting with /) used to open in a
new tab via target=_blank, which is wrong for internal navigation like the
new /kalender entry.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 07:53:43 +02:00
Peter Meier 892afc2d9f refactor(post-events): read event data from resolved calendarItem
Deploy / verify (push) Successful in 47s
Deploy / deploy (push) Successful in 53s
Replace direct post.isEvent/eventDate/eventLocation reads with
getPostEventInfo() helper that derives info from the resolved
calendarItem reference. Loaders now resolve "calendarItem" and request
that field instead of legacy flat properties.

Also regenerate iconify mdi subset (map-marker-outline, download,
google, calendar-search, refresh).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 16:08:16 +02:00
Peter Meier fc6c59bc10 fix(deadline-banner): count calendar days, not 24h windows
Deploy / verify (push) Successful in 49s
Deploy / deploy (push) Successful in 53s
daysLeft used Math.ceil(ms/24h) which counted partial 24h windows:
30h before a midnight event tomorrow showed "Noch 2 Tage" instead
of "Morgen". Compare midnight-to-midnight day-keys instead, same
approach as CalendarBlock relativeDays().

Also adds data-block="DeadlineBanner" identifier.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 15:45:22 +02:00
Peter Meier a1c59b549d feat(calendar): future-events accordion, ICS/Google export, multi-day
Right panel now shows all upcoming events as a card-style accordion
(date block + title + location/time chips visible, body collapsed).
Day-grouped with sticky headers + relative countdown.

- ICS download per event (RFC-5545, client-side blob)
- Google Calendar render-template link per event
- Maps link via location field (new schema field)
- Multi-day events via terminEnde (new schema field): grid spans,
  range "Mai / 14–16 / 2026" date block, expanded eventsByDay map
- Tag filter chips (new schema field) above calendar
- Past events <details> at list end, expanded if no future
- Live status badge: "läuft gerade" / "beginnt in 45 min"
  (60s tick interval)
- Today marker on calendar grid (outline + sr-only label)
- Auto-scroll next event into panel viewport on mount
- "Im Kalender anzeigen" jumps grid to event month
- Urgency-colored left border per event: now/today/tomorrow/week/
  later in fire/erde/himmel/stein
- Zebra striping + 4px separator between day-groups
- ARIA: aria-current=date, aria-pressed on day/tag buttons
- Print CSS: details auto-open, day-group break-inside: avoid
- Timezone fix: dayKey uses local Y/M/D not ISO string slice
- Empty-title fallback to "Termin"
- Side-by-side layout from md+ (was always stacked)

Extends CalendarItemData with terminEnde, location, tags
inline until OpenAPI regen catches up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 15:45:16 +02:00
Peter Meier 4790d571fd feat(strommix): hero variant, 24h sparkline, trend, polishing
- New "hero" variant between compact and full: pct + status +
  sparkline + trend, no sub-cards or accordion
- 24h Wind+Solar sparkline below hero pct, accent-colored
- Trend arrow vs previous hour with delta percentage points
- Polling pauses on tab hidden, refetches on visibility return
  if last fetch >5min old
- Skeleton loader replaces "Lade…" text (matches final layout)
- Retry button on error state (silent vs explicit fetch)
- Stale-flag now prominent red badge in header bar
- Stand-time wrapped in <time datetime title> for full-date hover
- loadMw=0 falls back to last hourly slot instead of red error
- Negative-price banner replaces standalone price card (dedup)
- Production breakdown grid: clean auto/1fr label-value alignment
- Print CSS: details auto-open, chevrons hidden
- All German strings moved to translation keys
- STROMMIX_DEFAULT_THRESHOLDS const as single source for buckets

Includes data-block="LiveStrommix" attribute for the new
identifier scheme + new RenewableSparkline.svelte component.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 15:44:57 +02:00
Peter Meier 5a86ff701e feat(blocks): data-block PascalCase identifier on every CMS block
Adds data-block="PascalName" alongside existing data-block-type
snake_case so blocks can be located via [data-block="Calendar"] etc.
in DevTools. Snake-case attribute kept for backward compat.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 15:44:40 +02:00
Peter Meier b633ed2d2b fix(rusty-image): preserve animated GIFs through markdown image processing
Deploy / verify (push) Successful in 51s
Deploy / deploy (push) Successful in 54s
RustyCMS /api/transform decodes only the first frame of animated formats,
which silently kills GIF animation when markdown HTML images get rewritten
to /api/transform URLs. Skip the transform for `.gif` URLs so the original
asset URL flows through and animation survives.

Also picks up an arrow-right entry in the iconify mdi subset generated
during a UI build.
2026-05-07 13:06:37 +02:00
Peter Meier 88514b065a feat(strommix): history-driven panels (residual load, dunkelflaute, neg-price, flow balance)
Aggregator route /api/strommix now reads three RustyCMS history-mode
collections in parallel — strommix_history_de, price_de_history,
crossborder_history_de — and projects derived metrics on top of the raw
upstream snapshots stored there:

- todayHourly: per-slot Last + Wind+Solar for the residual-load chart
- negPriceHoursYtd: count of YTD slots where DE-LU day-ahead < 0
- crossborderTodayGwh / netCrossborderGwhYtd / crossborderByCountryGwhYtd:
  GW × slot-hours integrated into a per-country GWh balance
- lastDunkelflaute / worstDunkelflauten: contiguous runs where
  (wind+solar)/load drops below 10 % for at least 1 h, top 5 by duration

All four are null-graceful — collections that the CMS hasn't backfilled
yet leave the corresponding panel hidden instead of breaking the widget.

Five new components in lib/components/blocks/strommix/:
- ResidualLoadChart.svelte — SVG line chart, no chart-lib dep
- CapacityFactorBars.svelte — live vs installed Wind/Wind-Off/Solar
- DunkelflauteCounter.svelte — last phase card + top-5 list
- NegPriceCounter.svelte — YTD hours + manual EinsMan compensation
- FlowBalanceTodayYtd.svelte — today + YTD saldo with per-country breakdown

Block schema additions (block-types.ts + strommix-default.json5 in
cms_content): installed_*_gw / installed_as_of for the capacity-factor
bars (BNetzA Marktstammdatenregister snapshot, manually maintained), and
einsman_costs_ytd_eur_mio / einsman_costs_as_of for the NegPrice panel
(BNetzA quarterly report figures, no API).
2026-05-07 13:06:30 +02:00
Peter Meier c4a09f03f4 chore: sync lockfile with @fontsource-variable/aleo, drop yarn.lock
Deploy / verify (push) Successful in 52s
Deploy / deploy (push) Successful in 1m9s
Lora -> Aleo dependency change in 4419742 left package-lock.json out of
sync, breaking `npm ci` in CI. Regenerated via `npm install --package-
lock-only`. Removed yarn.lock that came back with the same commit;
single source of truth is package-lock.json.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 16:22:09 +02:00
Peter Meier 44197429d6 feat(ui): deadline banner with gradient blob bg, swap Lora→Aleo
Deploy / verify (push) Failing after 17s
Deploy / deploy (push) Has been skipped
- DeadlineBannerBlock: redesign nach Tailwind-UI-Vorlage (gray-50, blurry blobs, dismiss button)
- new .gradient-blob-bg utility (radial-gradient blobs via ::before/::after)
- apply blob bg to Header
- @theme: register --font-sans (Inter) + --font-serif (Aleo)
- replace @fontsource-variable/lora with /aleo for blockquote font
- Header: simplify active nav-link styling
- QuoteBlock: formatting + drop borderClass

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 16:17:08 +02:00
Peter Meier 3f62473c32 fix(embed): skip site chrome on /widget routes via root layout
Deploy / verify (push) Successful in 50s
Deploy / deploy (push) Successful in 52s
(embed)/+layout@.svelte only resets intermediate layouts — root
+layout.svelte still wrapped every widget page with Header, Footer,
TopBanner, Breadcrumbs, Skip-Link, OG/JSON-LD/Analytics.

Root layout now derives isEmbed from $page.route.id (matches /(embed))
and renders just {@render children()} in that branch. svelte:head is
gated too: embed mode emits only the favicon, leaving the page free to
set its own <title> and noindex meta.

+layout.server.ts short-circuits the bootstrap batch (navigation,
footer, page_config, logo) for embed routes — saves one CMS roundtrip
+ logo transform per widget request, and avoids double-counting in
Umami/pm86 analytics when the widget is iframed elsewhere.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 10:04:01 +02:00
Peter Meier 63a9721841 feat(embed): standalone widget route /widget/<collection>/<slug>
Deploy / verify (push) Successful in 51s
Deploy / deploy (push) Successful in 53s
Renders a single CMS entry as a block in isolation — no header, footer, or
preview banner. Lets us share a deep link to a single widget (e.g. the live
strommix gauge) for embedding or quick previews without exposing the rest of
the page.

- New BlockRenderer.svelte: switch over `_type` shared with ContentRows.
- Generic `getEntryBySlug(collection, slug, options)` in cms.ts so any
  collection (live_strommix, deadline_banner, image, ...) is fetchable.
- Route group `(embed)/+layout@.svelte` resets the layout chain so no
  Header/Footer/TopBanner inherit. `x-robots-tag: noindex`.
- Optional `?preview=<token>` for drafts. Cache: short s-maxage for live,
  no-store for preview.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 09:37:21 +02:00
Peter Meier 1f3fbe7fa7 fix(footer): scope lead-paragraph rule to <article> only
Deploy / verify (push) Successful in 46s
Deploy / deploy (push) Successful in 58s
Lead-paragraph rule matched footer MarkdownBlocks (copyright line)
and made it 1.25rem. Restrict to article descendants so footer +
sidebar Markdown stay at their natural size.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 01:12:05 +02:00
Peter Meier b7093b703a style(design): consistency sweep + UX upgrades
Deploy / verify (push) Successful in 49s
Deploy / deploy (push) Successful in 1m0s
- Palette: gray/zinc/slate/neutral → stein, red → fire, amber → erde,
  sky → himmel, green → wald
- Footer: replaced raw rgb with stein tokens
- Radius: unified to rounded-xs across cards, buttons, inputs, pagination
- Buttons: .btn-primary/.btn-secondary now font-medium; added secondary
- Card hover: -translate-y-0.5 + shadow + wald-300 border
- Header active link: wald border-bottom + wald-700 text
- Mobile nav: bigger touch targets + wald active accent
- Pagination: prev/next text labels + wald-500 active state with
  separated shape/color classes to avoid Tailwind conflicts
- Lead paragraph: scoped .markdown > p:first-child for top-level
  MarkdownBlock only via [data-block-type="markdown"]
- Section dividers: subtle wald-200 gradient between content rows
- Global focus-visible ring (wald-500)
- Inline hex → palette tokens (Badge amber, Tag custom-color contrast)
- Font weights snapped to design system (300/400/500/600/700)
- transition-all → transition-colors where only color changes
- Removed em-dashes from user-visible templates

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 01:06:22 +02:00
Peter Meier 3ded972642 feat(strommix): variant system + wald palette + UX polish
Deploy / verify (push) Successful in 52s
Deploy / deploy (push) Successful in 52s
Add full/compact variants, prominent share, source badge, big
negative-price banner with conditional disclaimer, cards-style
context row, fade-in animation, and remove em-dashes from
visible strings.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 00:43:18 +02:00
Peter Meier 080e69dcac fix(strommix): bypass CMS for Energy-Charts production — upstream now needs explicit start/end
Deploy / verify (push) Successful in 48s
Deploy / deploy (push) Successful in 49s
Energy-Charts' /public_power endpoint started rejecting requests without
`start` + `end` query params ("HTTP 404: no content available"). The
CMS-cached `strommix_de` external schema cannot pass dynamic time
windows (external-collections v1 has no runtime-vars), so the per-field
fallback chain was effectively dead — when SMARD's load filter lagged
12+ h, both sources returned null and the widget surfaced "Lastwert
nicht verfügbar" with all zeros.

Aggregator now fetches /public_power directly with a 2-hour window,
picks the latest non-null per series, and feeds those values into the
existing per-field fallback. CMS strommix_de schema becomes obsolete
for the widget but stays available for admin debugging.

Same shape as the previous CMS payload, so the fallback merge is
unchanged. Picks `Wind onshore`, `Wind offshore`, `Solar`, `Biomass`,
`Hydro Run-of-River`, `Fossil brown coal / lignite`, `Fossil hard
coal`, `Fossil gas`, `Nuclear`, `Load` from production_types.
2026-05-05 00:09:25 +02:00
Peter Meier 0d13e85639 fix(strommix): discard SMARD snapshot when aligned timestamp >90 min stale
Deploy / verify (push) Successful in 48s
Deploy / deploy (push) Successful in 49s
When SMARD's load filter (id 410) lags 12+ hours, the aligned-min
strategy in fetchSmardSnapshot produces a snapshot stamped with that
12-hour-old timestamp — and the widget surfaces it as the live "Stand
HH:MM" header. With Energy-Charts already returning fresher numbers
through the CMS path, falling back entirely is the right move.

Threshold is 90 min: tight enough to catch obvious lag, loose enough
to keep normal 30–60 min SMARD delay as the canonical source.
2026-05-05 00:06:11 +02:00
Peter Meier 9ebca1c237 fix(strommix): full i18n + drop hardcoded grid constraints
Deploy / verify (push) Successful in 48s
Deploy / deploy (push) Successful in 52s
Two issues from the audit:

1. Hardcoded `lg:col-span-8 lg:col-start-3` collided with the layout
   coming from `getBlockLayoutClasses(block.layout)`. Tailwind picked
   `lg:col-start-3` from the hardcoded class (no opposing
   `lg:col-start-1` in the layout output) so the widget started at
   column 3 with span 12 → overflowed past the 12-col grid on
   desktop, looking like it was "hanging right". Drop the hardcoded
   constraints; the layout config from the CMS controls width
   (mobile/tablet/desktop = 12 by default = full width).
2. Roughly 25 German strings were inline. Extracted them to
   translation keys (T.strommix_*, registered in translations.ts) and
   read via `useTranslate()` like ImageGalleryBlock /
   PostOverviewBlock / YoutubeVideoBlock. Includes field labels for
   the breakdown disclosure, source attribution variants, status
   bucket markers, error / loading / fallback messages, and the
   conventional formula text shown under the "Konventionell jetzt"
   number.

Default DE strings live in cms_content/windwiderstand/translation_bundle/
app.json5 (separate CMS push).
2026-05-05 00:02:27 +02:00
Peter Meier 2d85681252 fix(strommix): per-field fallback to Energy-Charts when SMARD value missing
Deploy / verify (push) Successful in 46s
Deploy / deploy (push) Successful in 48s
The widget rendered "Keine gültigen Live-Werte" the moment SMARD's
load filter (id 410) lagged behind the production filters by even one
slot — the aggregator coalesced "filter unavailable" with "filter
returned 0" and the percent calculation refused to divide by zero.

Three changes:

1. SmardSnapshot.values now uses `number | null` so callers can tell
   "filter genuinely missing" apart from "filter reported zero" (e.g.
   solar at night, decommissioned nuclear).
2. Aggregator pulls Energy-Charts (CMS strommix_de) in parallel and
   uses it as a per-field fallback for any SMARD null. New
   `dataSource: "smard+energy-charts"` flag travels in the response so
   the attribution stays honest. Pure SMARD when everything resolves;
   pure Energy-Charts when SMARD is entirely down; the hybrid label
   for the common case where only load drops out.
3. Widget renders the production breakdown + context row + price row
   even when the percentage is unavailable, with a clear "Lastwert
   von SMARD gerade nicht verfügbar" hint instead of going blank.
2026-05-04 23:55:57 +02:00
Peter Meier 96cd9e9c57 fix(strommix): SMARD as primary source — accuracy parity with audit
Deploy / verify (push) Successful in 45s
Deploy / deploy (push) Successful in 49s
Audit at 22:15 MESZ flagged the conventional sum (lignite + hard coal
+ gas + nuclear) as 19.1 GW in the widget vs 15.65 GW in reality. The
breakdown showed the gap was Energy-Charts' `Fossil gas` (6.9 GW)
which bundles natural gas + GuD + similar, while SMARD's `Erdgas`
filter 4069 (3.8 GW) tracks just natural gas the way the widget label
implies. SMARD is the canonical German grid feed; switching the
primary source resolves the discrepancy directly.

- New `lib/smard.ts` fetches the 11 relevant filter IDs in parallel:
  conventional (1223/4070/4069/1224), wind (4067/1225), solar (1226),
  hydro (1228), biomass (4066), other-conventional + pumped storage
  (1227/4071, surfaced separately), load (410). Two-step protocol
  (index.json → archive.json) handled inside; output is MW (MWh × 4)
  aligned to one common slot timestamp so every value on screen comes
  from the same point in time.
- Aggregator route uses SMARD for production + load, Energy-Charts
  only for cross-border saldo + day-ahead prices (where SMARD has no
  convenient single-call endpoint). `dataSource: "smard" |
  "energy-charts"` flag travels in the response so the widget can
  attribute the numbers and fall back gracefully when SMARD is down.
- StrommixBlock gets a "Werte prüfen" disclosure that lists every
  component (wind onshore + offshore, solar, the four conventional
  buckets, biomass, hydro, load) with its MW value and the slot
  timestamp + data source. Fact-check users can verify the addition
  themselves now without asking.

Verified live: SMARD @ 21:00 UTC = lignite 8484 + hard coal 3480 +
gas 3811 + nuclear 0 = 15.78 GW conventional. Matches the user's
ground-truth 15.65 GW within sampling noise.
2026-05-04 23:47:52 +02:00
Peter Meier f4e4abd978 fix(strommix): pick price at "now" + surface cross-border timestamp
Deploy / verify (push) Successful in 46s
Deploy / deploy (push) Successful in 49s
Audit against ground truth at 22:15 lokal exposed three real bugs:

1. Day-ahead price was wrong (widget 125, real 153 €/MWh). The CMS'
   `last_non_null` transform always picks the last cell of the 96-slot
   array — typically a slot in the future, not the slot covering "now".
   Bypass the CMS for prices: aggregator fetches energy-charts /price
   directly, walks unix_seconds, and picks the largest start ≤ now.
   `priceMeasuredAtUnix` is surfaced so the widget can show the slot
   timestamp as a sanity check.

2. Cross-border saldo (cbpf) typically lags 1.5–2 h. Widget labelled
   it as "jetzt" even when the upstream's last datapoint was 90 min
   old. Now the cross-border timestamp travels through the aggregator
   and the widget renders "Stand 20:30 Uhr · verzögert" when the gap
   to the production snapshot exceeds 30 min.

3. Bucket label "Dunkelflaute" misuses the term — the formal
   definition needs ≥24 h history, not a single threshold crossing.
   Updated the strommix-default content entry to use "Sehr niedrige
   Einspeisung" / "Very low feed-in" instead, and explained the
   reasoning in a comment so editors don't re-introduce the misnomer.

Aggregator response shape gained `crossborderMeasuredAtUnix` +
`priceMeasuredAtUnix`; the widget renders both as small timestamps
under their values, plus a "verzögert" hint when cross-border data is
older than the 30-min tolerance window.

Cache TTLs on the price external collections lowered from 30 min to
15 min so the admin-side cache flips at the same cadence as the
day-ahead slots (since 30.09.2025 prices come in 15-min buckets).
2026-05-04 23:35:13 +02:00
Peter Meier 8ddfd026bc feat(blocks): live_strommix widget for energy-charts data
Deploy / verify (push) Successful in 47s
Deploy / deploy (push) Successful in 53s
Editor-controlled live-strommix card that surfaces what share of
electricity demand is currently covered by weather-dependent
renewables (Wind+Solar / Load), plus the conventional generation that
fills the gap, the cross-border saldo, and the day-ahead price (DE/FR
side-by-side). Status bucket + label + argument come from the CMS so
editors can iterate copy without a deploy.

- LiveStrommixBlockData type next to the other block-types.
- New `/api/strommix` aggregator route bundles four CMS external
  collections (strommix_de, crossborder_de, price_de, price_fr) into a
  single payload, computes the renewable + conventional sums and the
  cross-border saldo (Σ flow_xx_gw, sign convention: + = export from
  DE), surfaces stale-on-error flags from the CMS response headers.
  Cache-control 5min so a single node only hits energy-charts once
  per window.
- StrommixBlock.svelte renders the card: percentage, bucket label,
  bucket argument (markdown), context row (conventional + saldo with
  ↑/↓ arrow), price row with optional FR comparison + negative-price
  badge, footer with disclaimer + "Wie wird das berechnet?" link +
  stale-data hint when the CMS served fallback. Re-polls every 5min
  client-side; bucket thresholds + labels + arguments come from the
  block instance so multiple placements can carry different copy.
- ContentRows.svelte dispatcher branch on `_type === "live_strommix"`.

Wired against `cms_content/_types/core/live_strommix.json5` (added in
the rustycms repo today). content_layout.row{1,2,3}Content already
allow the new block-type via the same commit on the CMS side.
2026-05-04 22:57:40 +02:00
274 changed files with 25517 additions and 2743 deletions
+7
View File
@@ -89,6 +89,13 @@ jobs:
IMAGE_CACHE_DIR=/data/image-cache IMAGE_CACHE_DIR=/data/image-cache
PUBLIC_UMAMI_SCRIPT_URL=https://cloud.umami.is/script.js PUBLIC_UMAMI_SCRIPT_URL=https://cloud.umami.is/script.js
PUBLIC_UMAMI_WEBSITE_ID=${{ secrets.PUBLIC_UMAMI_WEBSITE_ID }} 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 ENVEOF
- name: Pull and restart container - name: Pull and restart container
+3
View File
@@ -19,3 +19,6 @@ dist/
# Logs # Logs
*.log *.log
npm-debug.log* npm-debug.log*
# Storybook
storybook-static/
@@ -0,0 +1,14 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import TranslationProvider from '$lib/components/TranslationProvider.svelte';
import strings from '$lib/components/blocks/_fixtures/translations.json';
// Storybook reicht die Story als `children`-Snippet rein. Wir setzen den
// Translation-Context (echte deutsche Strings aus dem CMS-Bundle), damit
// Blocks lesbare Texte statt roher Keys zeigen.
let { children }: { children: Snippet } = $props();
</script>
<TranslationProvider translations={strings}>
{@render children()}
</TranslationProvider>
+40
View File
@@ -0,0 +1,40 @@
import type { StorybookConfig } from '@storybook/sveltekit';
import tailwindcss from '@tailwindcss/vite';
const config: StorybookConfig = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|ts|svelte)'],
addons: [
'@storybook/addon-svelte-csf',
'@storybook/addon-a11y',
'@storybook/addon-docs',
],
framework: '@storybook/sveltekit',
// MSW-Service-Worker liegt in static/ (via `npx msw init static/`). Storybook
// muss static/ ausliefern, damit /mockServiceWorker.js erreichbar ist.
staticDirs: ['../static'],
// Tailwind v4 läuft als Vite-Plugin. Das sveltekit-Framework bringt nur die
// Svelte/SvelteKit-Integration mit ($app/$env/$lib-Mocks), nicht Tailwind —
// darum hier explizit dazuhängen, sonst greifen keine Utility-Klassen/Tokens.
viteFinal: async (viteConfig) => {
viteConfig.plugins = viteConfig.plugins ?? [];
viteConfig.plugins.push(tailwindcss());
// Same-origin-Routen, die im echten Frontend SvelteKit-Server-Endpoints
// sind (in Storybook nicht existieren), auf die Live-Site proxien. Damit
// rendern RustyImage-Bilder (/cms-images, /cms-files) und die Live-Daten-
// Blocks (/api/strommix, /api/stellungnahme) mit echten Daten — ohne
// Fixtures. Deterministische Offline-Mocks stattdessen via MSW (siehe
// .storybook/README oder Kommentar unten).
const PROXY_TARGET = 'https://windwiderstand.de';
viteConfig.server = viteConfig.server ?? {};
viteConfig.server.proxy = {
...(viteConfig.server.proxy ?? {}),
'/cms-images': { target: PROXY_TARGET, changeOrigin: true },
'/cms-files': { target: PROXY_TARGET, changeOrigin: true },
'/api/strommix': { target: PROXY_TARGET, changeOrigin: true },
'/api/stellungnahme': { target: PROXY_TARGET, changeOrigin: true },
};
return viteConfig;
},
};
export default config;
+13
View File
@@ -0,0 +1,13 @@
<!--
SvelteKit liest $env/dynamic/public im Browser aus globalThis.__sveltekit_dev.env.
Dieses Global setzt sonst nur der echte SvelteKit-Dev-Server. In Storybook hier
vorab setzen, sonst crasht jede Komponente mit `env.PUBLIC_*` (Windkarte,
Stellingnahme, …) mit "__sveltekit_dev is undefined". Läuft vor dem Story-Bundle.
-->
<script>
window.__sveltekit_dev = window.__sveltekit_dev || { env: {} };
window.__sveltekit_dev.env = Object.assign(
{ PUBLIC_CMS_URL: 'https://cms.pm86.de' },
window.__sveltekit_dev.env,
);
</script>
+54
View File
@@ -0,0 +1,54 @@
import type { Preview } from '@storybook/sveltekit';
import { initialize, mswLoader } from 'msw-storybook-addon';
import WithTranslations from './decorators/WithTranslations.svelte';
// Design-System-Styles: Fonts, Tailwind, Farbtokens (wald/erde/…). Zieht die
// echte app.css des Frontends → Stories sehen aus wie live.
import '../src/app.css';
// MSW starten. `onUnhandledRequest: 'bypass'` → Requests ohne Handler laufen
// normal durch (z.B. der Vite-Proxy für /cms-images). Nur explizit gemockte
// Endpoints werden abgefangen → Blocks mit fetch entwickeln sich deterministisch
// offline, per Story via `parameters.msw.handlers`.
initialize({ onUnhandledRequest: 'bypass' });
// Default-Mock für den SvelteKit-page-Store/-State ($app/stores + $app/state).
// Ohne das crasht jede Komponente, die $page.url liest (Header, PostActions, …).
// Einzelne Stories können via parameters.sveltekit_experimental überschreiben.
const mockPage = {
url: new URL('http://localhost:6006/'),
params: {},
route: { id: null },
status: 200,
error: null,
data: {},
form: null,
state: {},
};
const preview: Preview = {
loaders: [mswLoader],
// Alle Stories in den Translation-Context wrappen → echte Texte statt Keys.
decorators: [() => WithTranslations],
parameters: {
sveltekit_experimental: {
stores: { page: mockPage },
state: { page: mockPage },
},
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
// Frontend rendert auf hellem Grund; Storybook-Canvas dazu passend.
backgrounds: {
default: 'weiss',
values: [
{ name: 'weiss', value: '#ffffff' },
{ name: 'stein', value: '#f5f5f4' },
],
},
},
};
export default preview;
+3
View File
@@ -35,4 +35,7 @@ USER svelte
EXPOSE 3001 EXPOSE 3001
ENV PORT=3001 ENV PORT=3001
HEALTHCHECK --interval=5s --timeout=3s --start-period=15s --retries=3 \
CMD wget -qO- http://127.0.0.1:3001/ > /dev/null || exit 1
CMD ["node", "build"] CMD ["node", "build"]
+1
View File
@@ -10,6 +10,7 @@ services:
- image-cache:/data/image-cache - image-cache:/data/image-cache
networks: networks:
- web - web
stop_grace_period: 30s
volumes: volumes:
image-cache: image-cache:
+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`.
+3402 -19
View File
File diff suppressed because it is too large Load Diff
+21 -2
View File
@@ -17,18 +17,30 @@
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",
"prepare": "svelte-kit sync || true" "prepare": "svelte-kit sync || true",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
}, },
"devDependencies": { "devDependencies": {
"@iconify-json/lucide": "^1.2.114",
"@iconify-json/mdi": "^1.2.3", "@iconify-json/mdi": "^1.2.3",
"@storybook/addon-a11y": "^10.5.0",
"@storybook/addon-docs": "^10.5.0",
"@storybook/addon-svelte-csf": "^5.1.2",
"@storybook/sveltekit": "^10.5.0",
"@sveltejs/adapter-node": "^5.2.12", "@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/kit": "^2.21.4", "@sveltejs/kit": "^2.65.0",
"@sveltejs/vite-plugin-svelte": "^5.0.3", "@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@types/geojson": "^7946.0.16",
"@types/node": "^22.10.0", "@types/node": "^22.10.0",
"@types/qrcode": "^1.5.6",
"@types/sanitize-html": "^2.13.0", "@types/sanitize-html": "^2.13.0",
"@vitest/coverage-v8": "^2.1.9", "@vitest/coverage-v8": "^2.1.9",
"msw": "^2.15.0",
"msw-storybook-addon": "^2.0.7",
"openapi-typescript": "^7.4.0", "openapi-typescript": "^7.4.0",
"storybook": "^10.5.0",
"svelte": "^5.28.2", "svelte": "^5.28.2",
"svelte-check": "^4.1.4", "svelte-check": "^4.1.4",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
@@ -41,10 +53,17 @@
"@fontsource-variable/rubik": "^5.2.8", "@fontsource-variable/rubik": "^5.2.8",
"@fontsource/inter": "^5.2.8", "@fontsource/inter": "^5.2.8",
"@iconify/svelte": "^5.2.1", "@iconify/svelte": "^5.2.1",
"@resvg/resvg-js": "^2.6.2",
"@turf/buffer": "^7.3.5",
"@types/leaflet": "^1.9.21",
"blurhash": "^2.0.5", "blurhash": "^2.0.5",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"leaflet": "^1.9.4",
"marked": "^17.0.2", "marked": "^17.0.2",
"qrcode": "^1.5.4",
"sanitize-html": "^2.13.1", "sanitize-html": "^2.13.1",
"satori": "^0.26.0",
"satori-html": "^0.3.2",
"sharp": "^0.34.5" "sharp": "^0.34.5"
}, },
"type": "module" "type": "module"
+82 -45
View File
@@ -19,8 +19,6 @@ import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..'); const ROOT = path.resolve(__dirname, '..');
const SRC_DIR = path.join(ROOT, 'src'); 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) * 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. * pick icons that appear blank — better than shipping the full collection.
*/ */
const EXTRA_ICONS = [ const EXTRA_ICONS = [
// PostActions: dynamic share/copy state // Brand/Logo-Icons ohne Lucide-Pendant (Editors picken sie als social.icon)
'share-variant-outline', 'google',
'link-variant', 'whatsapp',
// Search: file-type fallback 'mastodon',
'file-outline', 'telegram',
// Common social icons editors pick (Header.svelte: social.icon) // 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', 'facebook',
'youtube',
'map',
'video',
'instagram', 'instagram',
'twitter', 'twitter',
'youtube',
'linkedin', 'linkedin',
'mastodon',
'github', 'github',
'email',
'email-outline',
'phone', 'phone',
'whatsapp', // PostActions / Links
'telegram', 'share-2',
// Common file-type icons (FilesBlock: f.iconName) 'link',
'file-pdf-box', 'external-link',
'file-word-box', 'arrow-up-right',
'file-excel-box', // 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-image',
'file-video', 'file-video',
'file-music', 'file-music',
// Tag/link/banner fallbacks (LinkListBlock, Tags, DeadlineBannerBlock) 'file-down',
'link', 'presentation',
'tag-outline', // Tag/flag/info/format fallbacks
'flag-outline', 'tag',
'information-outline', '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) { function walk(dir) {
const out = []; const out = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
@@ -76,27 +104,28 @@ function walk(dir) {
return out; return out;
} }
function scanStatic() { function scanStatic(prefix) {
const pattern = new RegExp(`icon="${prefix}:([a-z0-9-]+)"`, 'g');
const found = new Set(); const found = new Set();
for (const file of walk(SRC_DIR)) { for (const file of walk(SRC_DIR)) {
const content = fs.readFileSync(file, 'utf8'); const content = fs.readFileSync(file, 'utf8');
let m; let m;
while ((m = STATIC_PATTERN.exec(content)) !== null) { while ((m = pattern.exec(content)) !== null) {
found.add(m[1]); found.add(m[1]);
} }
} }
return found; return found;
} }
function main() { /** Build a tree-shaken subset for one Iconify collection. */
if (!fs.existsSync(ICONS_JSON)) { function buildSubset({ prefix, pkg, outFile, extra }) {
console.error( const iconsJson = path.join(ROOT, 'node_modules', '@iconify-json', pkg, 'icons.json');
`[generate-icons] missing ${ICONS_JSON} — run \`npm install\` first.`, if (!fs.existsSync(iconsJson)) {
); console.error(`[generate-icons] missing ${iconsJson} — run \`npm install\` first.`);
process.exit(1); process.exit(1);
} }
const collection = JSON.parse(fs.readFileSync(ICONS_JSON, 'utf8')); const collection = JSON.parse(fs.readFileSync(iconsJson, 'utf8'));
const wanted = new Set([...scanStatic(), ...EXTRA_ICONS]); const wanted = new Set([...scanStatic(prefix), ...extra]);
const icons = {}; const icons = {};
const aliases = {}; const aliases = {};
@@ -123,22 +152,30 @@ function main() {
icons, icons,
...(Object.keys(aliases).length ? { aliases } : {}), ...(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 sizeAfter = fs.statSync(outFile).size;
const sizeBefore = fs.statSync(ICONS_JSON).size;
const sizeAfter = fs.statSync(OUT_FILE).size;
console.log( console.log(
`[generate-icons] ${Object.keys(icons).length} icons + ${Object.keys(aliases).length} aliases → ${path.relative(ROOT, OUT_FILE)}`, `[generate-icons] ${prefix}: ${Object.keys(icons).length} icons + ${Object.keys(aliases).length} aliases → ${path.relative(ROOT, outFile)} (${(sizeAfter / 1024).toFixed(1)} KB)`,
);
console.log(
`[generate-icons] ${(sizeBefore / 1024 / 1024).toFixed(2)} MB → ${(sizeAfter / 1024).toFixed(1)} KB (${((1 - sizeAfter / sizeBefore) * 100).toFixed(1)}% smaller)`,
); );
if (missing.length) { if (missing.length) {
console.warn( console.warn(`[generate-icons] ${prefix}: ${missing.length} not found: ${missing.join(', ')}`);
`[generate-icons] ${missing.length} icon(s) not found in MDI: ${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(); 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');
+467 -318
View File
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -3,7 +3,15 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <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% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">
+28 -19
View File
@@ -1,9 +1,10 @@
import { getTranslationBundleBySlug } from '$lib/cms'; import { getNormalizedTranslations } from '$lib/cms';
import { isCmsAvailable } from '$lib/cms-health.server'; import { isCmsAvailable } from '$lib/cms-health.server';
import { maintenanceResponse } from '$lib/maintenance.server'; import { maintenanceResponse } from '$lib/maintenance.server';
import { TRANSLATION_BUNDLE_SLUG } from '$lib/constants'; import { TRANSLATION_BUNDLE_SLUG } from '$lib/constants';
import { env as envPublic } from '$env/dynamic/public'; import { env as envPublic } from '$env/dynamic/public';
import { logWarn } from '$lib/log.server'; import { logWarn } from '$lib/log.server';
import { lookupRedirect } from '$lib/redirects.server';
import type { Handle } from '@sveltejs/kit'; import type { Handle } from '@sveltejs/kit';
/** /**
@@ -62,6 +63,24 @@ export const handle: Handle = async ({ event, resolve }) => {
return maintenanceResponse(200); 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 // Legacy-Pfade (/blog, /p) → /posts, /post. Vor Health-Check, weil
// SvelteKit-Routing/CMS-Calls dafür unnötig. // SvelteKit-Routing/CMS-Calls dafür unnötig.
const redirectTo = legacyRedirectTarget(event.url.pathname); const redirectTo = legacyRedirectTarget(event.url.pathname);
@@ -78,23 +97,10 @@ export const handle: Handle = async ({ event, resolve }) => {
} }
try { try {
const bundle = await getTranslationBundleBySlug(TRANSLATION_BUNDLE_SLUG, { locale: 'de' }); event.locals.translations = await getNormalizedTranslations(
if (bundle?.strings && typeof bundle.strings === 'object') { TRANSLATION_BUNDLE_SLUG,
const normalized: Record<string, string> = {}; { locale: 'de' },
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 = {};
}
} catch (err) { } catch (err) {
event.locals.translations = {}; event.locals.translations = {};
logWarn('hooks.translations', err); logWarn('hooks.translations', err);
@@ -115,7 +121,10 @@ export const handle: Handle = async ({ event, resolve }) => {
event.url.searchParams.has('preview_draft'); event.url.searchParams.has('preview_draft');
const isPreviewDraft = event.url.searchParams.has('preview_draft'); const isPreviewDraft = event.url.searchParams.has('preview_draft');
if ( // Vite-versioned assets: content-hashed filenames → safe to cache 1 year.
if (event.url.pathname.startsWith('/_app/immutable/')) {
response.headers.set('cache-control', 'public, max-age=31536000, immutable');
} else if (
event.request.method === 'GET' && event.request.method === 'GET' &&
isCacheablePath(event.url.pathname) && isCacheablePath(event.url.pathname) &&
!isPreview && !isPreview &&
+44 -1
View File
@@ -46,12 +46,36 @@ export type BlockLayout = {
| "10" | "10"
| "11" | "11"
| "12"; | "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. */ /** Abstand nach unten (rem): 0, 0.5, 1, 1.5, 2. */
spaceBottom?: 0 | 0.5 | 1 | 1.5 | 2; spaceBottom?: 0 | 0.5 | 1 | 1.5 | 2;
/** Abstand nach oben (rem): 0, 0.5, 1, 1.5, 2, 3, 4. */
spaceTop?: 0 | 0.5 | 1 | 1.5 | 2 | 3 | 4;
/** true = Block fullwidth aus dem Grid ausbrechen (viewport-breit). */ /** true = Block fullwidth aus dem Grid ausbrechen (viewport-breit). */
breakout?: boolean; breakout?: boolean;
}; };
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> = { const COL_SPAN: Record<string, string> = {
"1": "col-span-1", "1": "col-span-1",
"2": "col-span-2", "2": "col-span-2",
@@ -105,6 +129,16 @@ const SPACE_BOTTOM: Record<number, string> = {
2: "mb-8", // 2rem 2: "mb-8", // 2rem
}; };
const SPACE_TOP: Record<number, string> = {
0: "mt-0",
0.5: "mt-2", // 0.5rem
1: "mt-4", // 1rem
1.5: "mt-6", // 1.5rem
2: "mt-8", // 2rem
3: "mt-12", // 3rem
4: "mt-16", // 4rem
};
/** /**
* Gibt Tailwind-Klassen für das Block-Layout zurück (12-Spalten-Grid + Abstand). * Gibt Tailwind-Klassen für das Block-Layout zurück (12-Spalten-Grid + Abstand).
* Parent muss grid grid-cols-12 haben. * Parent muss grid grid-cols-12 haben.
@@ -126,18 +160,27 @@ export function getBlockLayoutClasses(
: Number(layout.spaceBottom); : Number(layout.spaceBottom);
const spaceClass = SPACE_BOTTOM[sb as keyof typeof SPACE_BOTTOM] ?? "mb-4"; const spaceClass = SPACE_BOTTOM[sb as keyof typeof SPACE_BOTTOM] ?? "mb-4";
const st =
typeof layout.spaceTop === "number" ? layout.spaceTop : Number(layout.spaceTop);
const spaceTopClass = SPACE_TOP[st as keyof typeof SPACE_TOP] ?? "";
if (breakout) { if (breakout) {
return "w-full"; return [spaceTopClass, "w-full"].filter(Boolean).join(" ");
} }
const mobile = String(layout.mobile ?? "12"); const mobile = String(layout.mobile ?? "12");
const tablet = layout.tablet != null ? String(layout.tablet) : undefined; const tablet = layout.tablet != null ? String(layout.tablet) : undefined;
const desktop = layout.desktop != null ? String(layout.desktop) : 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[] = [ const parts: string[] = [
COL_SPAN[mobile] ?? "col-span-12", COL_SPAN[mobile] ?? "col-span-12",
tablet ? (MD_COL_SPAN[tablet] ?? "") : "", tablet ? (MD_COL_SPAN[tablet] ?? "") : "",
desktop ? (LG_COL_SPAN[desktop] ?? "") : "", desktop ? (LG_COL_SPAN[desktop] ?? "") : "",
tabletStart ? (MD_COL_START[tabletStart] ?? "") : "",
desktopStart ? (LG_COL_START[desktopStart] ?? "") : "",
spaceTopClass,
spaceClass, spaceClass,
]; ];
return parts.filter(Boolean).join(" "); return parts.filter(Boolean).join(" ");
+289 -5
View File
@@ -45,6 +45,12 @@ export interface HeadlineBlockData {
_slug?: string; _slug?: string;
tag?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; tag?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
text?: string; text?: string;
/** SectionHeader: Label über dem Titel (uppercase). Gesetzt → Headline rendert als Sektionskopf. */
kicker?: string;
/** SectionHeader: Index vor dem Kicker, z.B. "03". */
number?: string;
/** SectionHeader: CTA-Link (aufgelöst: url, linkName). */
action?: string | { url?: string; linkName?: string; newTab?: boolean };
align?: "left" | "center" | "right"; align?: "left" | "center" | "right";
layout?: BlockLayout; layout?: BlockLayout;
} }
@@ -121,6 +127,12 @@ export interface ImageGalleryBlockData {
description?: string; description?: string;
/** Optional: oberhalb des Galerie-Modals als Header. */ /** Optional: oberhalb des Galerie-Modals als Header. */
title?: string; title?: string;
/** SectionHeader: Label über dem Titel (uppercase). */
kicker?: string;
/** SectionHeader: Index vor dem Kicker, z.B. "03". */
number?: string;
/** SectionHeader: CTA-Link (aufgelöst: url, linkName). */
action?: string | { url?: string; linkName?: string; newTab?: boolean };
/** "standard" = Carousel (Default), "grid" = Thumbnail-Grid mit Modal. */ /** "standard" = Carousel (Default), "grid" = Thumbnail-Grid mit Modal. */
variant?: "standard" | "grid"; variant?: "standard" | "grid";
images?: ImageGalleryImage[]; images?: ImageGalleryImage[];
@@ -144,6 +156,12 @@ export interface FilesBlockData {
_type?: "files"; _type?: "files";
_slug?: string; _slug?: string;
headline?: string; headline?: string;
/** SectionHeader: Label über dem Titel (uppercase). */
kicker?: string;
/** SectionHeader: Index vor dem Kicker, z.B. "03". */
number?: string;
/** SectionHeader: CTA-Link (aufgelöst: url, linkName). */
action?: string | { url?: string; linkName?: string; newTab?: boolean };
description?: string; description?: string;
items?: Array<string | FilesBlockItem>; items?: Array<string | FilesBlockItem>;
forceDownload?: boolean; forceDownload?: boolean;
@@ -174,6 +192,12 @@ export interface YoutubeVideoGalleryBlockData {
_slug?: string; _slug?: string;
title?: string; title?: string;
subtitle?: string; subtitle?: string;
/** SectionHeader: Label über dem Titel (uppercase). */
kicker?: string;
/** SectionHeader: Index vor dem Kicker, z.B. "03". */
number?: string;
/** SectionHeader: CTA-Link (aufgelöst: url, linkName). */
action?: string | { url?: string; linkName?: string; newTab?: boolean };
description?: string; description?: string;
variant?: "carousel" | "grid"; variant?: "carousel" | "grid";
videos?: YoutubeVideoBlockData[]; videos?: YoutubeVideoBlockData[];
@@ -216,6 +240,8 @@ export interface LinkListBlockData {
_slug?: string; _slug?: string;
headline?: string; headline?: string;
links?: Array<string | { url?: string; linkName?: string; newTab?: boolean }>; links?: Array<string | { url?: string; linkName?: string; newTab?: boolean }>;
/** Spaltenlayout: "auto" = mehrspaltig bei >4 Links, "single" = immer untereinander. */
columns?: "auto" | "single";
layout?: BlockLayout; layout?: BlockLayout;
} }
@@ -224,6 +250,12 @@ export interface PostOverviewBlockData {
_type?: "post_overview"; _type?: "post_overview";
_slug?: string; _slug?: string;
headline?: string; headline?: string;
/** SectionHeader: kleines Label über dem Titel (uppercase). */
kicker?: string;
/** SectionHeader: optionaler Index vor dem Kicker, z.B. "03". */
number?: string;
/** SectionHeader: CTA-Link (aufgelöst aus link-Collection: url, linkName). */
action?: string | { url?: string; linkName?: string; newTab?: boolean };
/** Einleitungstext (Markdown). */ /** Einleitungstext (Markdown). */
text?: string; text?: string;
/** true = alle Posts laden; false = posts (Slugs/Objekte) aus dem Block nutzen. */ /** true = alle Posts laden; false = posts (Slugs/Objekte) aus dem Block nutzen. */
@@ -236,8 +268,8 @@ export interface PostOverviewBlockData {
excludeTag?: string[]; excludeTag?: string[];
/** Max. Anzahl Einträge. */ /** Max. Anzahl Einträge. */
numberItems?: number; numberItems?: number;
/** "list" | "cards". */ /** "list" | "cards" | "carousel" | "featured" (1. Post groß links, Rest kompakt rechts). */
design?: "list" | "cards"; design?: "list" | "cards" | "carousel" | "featured";
layout?: BlockLayout; layout?: BlockLayout;
/** Nach resolvePostOverviewBlocks: geladene/gefilterte Posts (PostEntry[]). */ /** Nach resolvePostOverviewBlocks: geladene/gefilterte Posts (PostEntry[]). */
postsResolved?: unknown[]; postsResolved?: unknown[];
@@ -284,6 +316,12 @@ export interface SearchableTextBlockData {
_slug?: string; _slug?: string;
/** Titel der Suchkomponente. */ /** Titel der Suchkomponente. */
title?: string; title?: string;
/** SectionHeader: Label über dem Titel (uppercase). */
kicker?: string;
/** SectionHeader: Index vor dem Kicker, z.B. "03". */
number?: string;
/** SectionHeader: CTA-Link (aufgelöst: url, linkName). */
action?: string | { url?: string; linkName?: string; newTab?: boolean };
/** Einleitung (Markdown). */ /** Einleitung (Markdown). */
description?: string; description?: string;
/** Slugs oder aufgelöste text_fragment-Einträge. */ /** Slugs oder aufgelöste text_fragment-Einträge. */
@@ -313,6 +351,10 @@ export interface OrganisationEntry {
description?: string; description?: string;
link?: string | { url?: string; linkName?: string; newTab?: boolean }; link?: string | { url?: string; linkName?: string; newTab?: boolean };
badge?: string | { label?: string; color?: string }; badge?: string | { label?: string; color?: string };
whatsapp?: string;
youtube?: string;
location?: { text?: string; lat?: number; lng?: number };
contacts?: (string | ContactEntry)[];
} }
/** Organisations-Block (_type: "organisations"). */ /** Organisations-Block (_type: "organisations"). */
@@ -320,9 +362,15 @@ export interface OrganisationsBlockData {
_type?: "organisations"; _type?: "organisations";
_slug?: string; _slug?: string;
headline?: string; headline?: string;
/** SectionHeader: Label über dem Titel (uppercase). */
kicker?: string;
/** SectionHeader: Index vor dem Kicker, z.B. "03". */
number?: string;
/** SectionHeader: CTA-Link (aufgelöst: url, linkName). */
action?: string | { url?: string; linkName?: string; newTab?: boolean };
description?: string; description?: string;
/** default = Karten-Grid; compact = engere Karten, Logo links; carousel = horizontaler Slider (3 Desktop / 2 Mobile) */ /** default = Karten-Grid; compact = engere Karten, Logo links; carousel = horizontaler Slider (3 Desktop / 2 Mobile) */
presentation?: "default" | "compact" | "carousel"; presentation?: "default" | "compact" | "carousel" | "grid";
addOrganisationTitle?: string; addOrganisationTitle?: string;
addOrganisationMarkdown?: string; addOrganisationMarkdown?: string;
/** Referenz auf link-Collection (aufgelöst: url, linkName, newTab) */ /** Referenz auf link-Collection (aufgelöst: url, linkName, newTab) */
@@ -331,8 +379,125 @@ export interface OrganisationsBlockData {
layout?: BlockLayout; layout?: BlockLayout;
} }
/** Kalender-Item (calendar_item) aus OpenAPI; terminZeit = ISO date-time. */ export interface OrganisationsMapBlockData {
export type CalendarItemData = components["schemas"]["calendar_item"]; _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`. */
export interface LiveStrommixBlockData {
_type?: "live_strommix";
_slug?: string;
/** `full` = card with argument + breakdown disclosure + history accordion.
* `hero` = pct + status + sparkline + trend, no sub-cards or accordion.
* `compact` = single horizontal strip for sidebar / homepage placement.
* Default: `full`. */
variant?: "full" | "hero" | "compact";
intro_headline?: string;
intro_text?: string;
threshold_dunkelflaute?: number;
threshold_niedrig?: number;
threshold_mittel?: number;
label_dunkelflaute?: string;
label_niedrig?: string;
label_mittel?: string;
label_hoch?: string;
argument_dunkelflaute?: string;
argument_niedrig?: string;
argument_mittel?: string;
argument_hoch?: string;
enable_france_comparison?: boolean;
disclaimer?: string;
/** Day-ahead price (€/MWh) below which `disclaimer` is rendered.
* Default 10 — disclaimer about EEG-Vergütung at negative prices is
* irrelevant at 130 €/MWh and gets hidden. Set very high (e.g. 9999)
* to always show. */
disclaimer_threshold_eur_mwh?: number;
/** Reference (resolved: { _slug, _type, ...page-fields } or just slug string). */
explanation_page?: string | { _slug?: string; title?: string };
// ---------------------------------------------------------------------
// Stammdaten (BNetzA Marktstammdatenregister, manuell gepflegt). Treiben
// den Live-vs-Installiert-Auslastungs-Bereich. Stand quartalsweise
// aktualisieren — die Werte bewegen sich um 13 GW pro Quartal.
// ---------------------------------------------------------------------
installed_wind_onshore_gw?: number;
installed_wind_offshore_gw?: number;
installed_solar_gw?: number;
/** Free-form Stand-Marker für die Stammdaten, z. B. "2025-Q4". */
installed_as_of?: string;
/** Optional: Hand-gepflegte EinsMan-Entschädigungssumme YTD (Mio €).
* BNetzA liefert das nur quartalsweise per PDF. Wenn nicht gesetzt,
* zeigt das Widget den Counter ohne Eurosumme. */
einsman_costs_ytd_eur_mio?: number;
einsman_costs_as_of?: string;
layout?: BlockLayout;
}
/** Kalender-Item (calendar_item) aus OpenAPI; terminZeit = ISO date-time.
* Lokale Erweiterungen sind im RustyCMS-Schema ergänzt und werden bis zur
* OpenAPI-Regen hier inline mitgeführt. */
export type CalendarItemData = components["schemas"]["calendar_item"] & {
/** Optionales Endedatum für Mehrtagesveranstaltungen (ISO date-time). */
terminEnde?: string;
/** Optionaler Veranstaltungsort (string oder {text, lat, lng}). */
location?: string | { text?: string; lat?: number; lng?: number };
/** Optionale Tags zur Filterung. */
tags?: string[];
/** Optionales Veranstaltungsbild (aufgelöstes image-Feld oder Slug). */
image?:
| string
| {
_type?: "img";
_slug?: string;
src?: string;
description?: string;
file?: { url?: string };
};
/** Optionaler Dateianhang (z.B. PDF-Flyer). */
attachment?: {
src?: string;
label?: string;
};
};
/** Kalender-Block (_type: "calendar"). items nach Resolve: CalendarItemData[]. Basis aus OpenAPI (calendar). */ /** Kalender-Block (_type: "calendar"). items nach Resolve: CalendarItemData[]. Basis aus OpenAPI (calendar). */
export type CalendarBlockData = Omit< export type CalendarBlockData = Omit<
@@ -342,7 +507,126 @@ export type CalendarBlockData = Omit<
_type?: "calendar"; _type?: "calendar";
/** Optionaler Titel über dem Kalender. */ /** Optionaler Titel über dem Kalender. */
title?: string; title?: string;
/** SectionHeader: Label über dem Titel (uppercase). */
kicker?: string;
/** SectionHeader: Index vor dem Kicker, z.B. "03". */
number?: string;
/** SectionHeader: CTA-Link (aufgelöst: url, linkName). */
action?: string | { url?: string; linkName?: string; newTab?: boolean };
/** Slugs oder aufgelöste calendar_item-Einträge. */ /** Slugs oder aufgelöste calendar_item-Einträge. */
items?: (string | CalendarItemData)[]; items?: (string | CalendarItemData)[];
layout?: BlockLayout; layout?: BlockLayout;
/** 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" | "form-mitmachen" | "form-newsletter" | "form-newsletter-inline";
export interface InternalComponentBlockData {
_type?: "internal_component";
_slug?: string;
component?: InternalComponentId;
layout?: BlockLayout;
}
export interface WindMapBlockData {
_type?: "wind_map";
_slug?: string;
name?: string;
title?: string;
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;
}
+268 -97
View File
@@ -4,10 +4,15 @@ import {
getPostBySlug, getPostBySlug,
getTags, getTags,
getTextFragmentBySlug, getTextFragmentBySlug,
getTextFragmentsByTag,
getEntryBySlug,
getStellungnahmeConfig,
getCalendarBySlug, getCalendarBySlug,
getCalendarItemBySlug, getCalendarItemBySlug,
getContacts,
getCalendarItems, getCalendarItems,
} from "./cms"; } from "./cms";
import { STELLUNGNAHME_CONFIG_SLUG } from "./constants";
import type { RowContentLayout } from "./block-types"; import type { RowContentLayout } from "./block-types";
import type { CalendarItemData } from "./block-types"; import type { CalendarItemData } from "./block-types";
import { ensureTransformedImage, extractCmsImageField, type CmsImageField } from "./rusty-image"; import { ensureTransformedImage, extractCmsImageField, type CmsImageField } from "./rusty-image";
@@ -121,6 +126,44 @@ export function filterPostsByTag(
}); });
} }
export type PostEventLocation = {
text?: string;
lat?: number;
lng?: number;
};
export type PostEventInfo = {
isEvent: boolean;
eventDate: string | null;
eventLocation: PostEventLocation | null;
};
/**
* Liest event-Daten aus dem aufgelösten calendarItem-Reference des Posts.
* Voraussetzung: post mit resolve=["all"] oder explizit "calendarItem" geladen,
* sodass `post.calendarItem` als Objekt mit terminZeit/location vorliegt.
*/
export function getPostEventInfo(
post: PostEntry | null | undefined,
): PostEventInfo {
const ci = (post as { calendarItem?: unknown } | null | undefined)?.calendarItem;
if (!ci || typeof ci !== "object") {
return { isEvent: false, eventDate: null, eventLocation: null };
}
const obj = ci as {
terminZeit?: string;
location?: PostEventLocation;
};
if (!obj.terminZeit) {
return { isEvent: false, eventDate: null, eventLocation: null };
}
return {
isEvent: true,
eventDate: obj.terminZeit,
eventLocation: obj.location ?? null,
};
}
/** URL aus post.postImage (RustyCMS img mit src, oder legacy file.url). */ /** URL aus post.postImage (RustyCMS img mit src, oder legacy file.url). */
export function getPostImageUrl( export function getPostImageUrl(
postImage: PostEntry["postImage"], postImage: PostEntry["postImage"],
@@ -200,34 +243,37 @@ export type PostSearchIndexEntry = {
eventDate: string | null; eventDate: string | null;
}; };
type PostWithEventIndex = PostEntry & { type PostWithImageIndex = PostEntry & {
isEvent?: boolean;
eventDate?: string;
_resolvedImageUrl?: string; _resolvedImageUrl?: string;
}; };
export function buildPostSearchIndex(posts: PostEntry[]): PostSearchIndexEntry[] { export function buildPostSearchIndex(posts: PostEntry[]): PostSearchIndexEntry[] {
return posts.map((p) => ({ return posts.map((p) => {
slug: p.slug ?? p._slug ?? "", const ev = getPostEventInfo(p);
headline: p.headline ?? "", return {
excerpt: p.excerpt ?? "", slug: p.slug ?? p._slug ?? "",
subheadline: p.subheadline ?? "", headline: p.headline ?? "",
tags: (p.postTag ?? []) excerpt: p.excerpt ?? "",
.map((t) => subheadline: p.subheadline ?? "",
typeof t === "string" tags: (p.postTag ?? [])
? t .map((t) =>
: (t as { name?: string; _slug?: string }).name ?? typeof t === "string"
(t as { _slug?: string })._slug ?? ? t
"", : (t as { name?: string; _slug?: string }).name ??
) (t as { _slug?: string })._slug ??
.filter(Boolean), "",
image: (p as PostWithEventIndex)._resolvedImageUrl ?? null, )
created: p.created ?? null, .filter(Boolean),
isEvent: Boolean((p as PostWithEventIndex).isEvent), image: (p as PostWithImageIndex)._resolvedImageUrl ?? null,
eventDate: (p as PostWithEventIndex).eventDate ?? null, created: p.created ?? null,
})); isEvent: ev.isEvent,
eventDate: ev.eventDate,
};
});
} }
export type PostSortOrder = "newest" | "oldest";
export type LoadPostsResult = { export type LoadPostsResult = {
posts: PostEntry[]; posts: PostEntry[];
tags: Awaited<ReturnType<typeof getTags>>; tags: Awaited<ReturnType<typeof getTags>>;
@@ -235,6 +281,7 @@ export type LoadPostsResult = {
currentPage: number; currentPage: number;
totalPages: number; totalPages: number;
totalPosts: number; totalPosts: number;
sort: PostSortOrder;
upcomingEvents: PostEntry[]; upcomingEvents: PostEntry[];
searchIndex: PostSearchIndexEntry[]; searchIndex: PostSearchIndexEntry[];
tagName: string | null; tagName: string | null;
@@ -245,9 +292,11 @@ export type LoadPostsResult = {
export async function loadPostsList(opts: { export async function loadPostsList(opts: {
tagSlug?: string | null; tagSlug?: string | null;
page?: number; page?: number;
sort?: PostSortOrder;
}): Promise<LoadPostsResult> { }): Promise<LoadPostsResult> {
const tagSlug = opts.tagSlug ?? null; const tagSlug = opts.tagSlug ?? null;
const requestedPage = Math.max(1, opts.page ?? 1); const requestedPage = Math.max(1, opts.page ?? 1);
const sort: PostSortOrder = opts.sort === "oldest" ? "oldest" : "newest";
const perPage = getPostsPerPage(); const perPage = getPostsPerPage();
let posts: PostEntry[] = []; let posts: PostEntry[] = [];
@@ -260,7 +309,7 @@ export async function loadPostsList(opts: {
// (post → postImage → img-Sidecar; post → postTag → name/icon/color). // (post → postImage → img-Sidecar; post → postTag → name/icon/color).
[posts, tags] = await Promise.all([ [posts, tags] = await Promise.all([
getPosts({ getPosts({
resolve: ["postImage", "postTag"], resolve: ["postImage", "postTag", "calendarItem"],
depth: 2, depth: 2,
fields: [ fields: [
"_slug", "_slug",
@@ -270,8 +319,7 @@ export async function loadPostsList(opts: {
"subheadline", "subheadline",
"excerpt", "excerpt",
"created", "created",
"isEvent", "calendarItem",
"eventDate",
"hideFromListing", "hideFromListing",
"postImage.*", "postImage.*",
"postTag.name", "postTag.name",
@@ -288,33 +336,32 @@ export async function loadPostsList(opts: {
for (const post of posts) { for (const post of posts) {
const field = getPostImageField(post.postImage); const field = getPostImageField(post.postImage);
if (field) { if (field) {
try { const url = ensureTransformedImage(field.url, {
const url = await ensureTransformedImage(field.url, { width: 400,
width: 400, height: 267,
height: 267, fit: "cover",
fit: "cover", format: "webp",
format: "webp", focalX: field.focal?.x,
focalX: field.focal?.x, focalY: field.focal?.y,
focalY: field.focal?.y, });
}); const p = post as PostEntry & {
const p = post as PostEntry & { _resolvedImageUrl?: string;
_resolvedImageUrl?: string; _rawImageUrl?: string;
_rawImageUrl?: string; _imageFocal?: { x: number; y: number } | null;
_imageFocal?: { x: number; y: number } | null; };
}; p._resolvedImageUrl = url;
p._resolvedImageUrl = url; p._rawImageUrl = field.url;
p._rawImageUrl = field.url; p._imageFocal = field.focal ?? null;
p._imageFocal = field.focal ?? null;
} catch {
/* image optional */
}
} }
} }
} catch (e) { } catch (e) {
cmsError = e instanceof Error ? e.message : String(e); cmsError = e instanceof Error ? e.message : String(e);
} }
const filtered = filterPostsByTag(posts, tagSlug); const filteredByTag = filterPostsByTag(posts, tagSlug);
// posts sind bereits neueste-zuerst sortiert; „oldest" = umgekehrte Reihenfolge.
const filtered =
sort === "oldest" ? [...filteredByTag].reverse() : filteredByTag;
const totalPages = getTotalPages(filtered.length, perPage); const totalPages = getTotalPages(filtered.length, perPage);
const pageNum = Math.min(requestedPage, totalPages); const pageNum = Math.min(requestedPage, totalPages);
const pagePosts = paginate(filtered, pageNum, perPage); const pagePosts = paginate(filtered, pageNum, perPage);
@@ -333,6 +380,7 @@ export async function loadPostsList(opts: {
currentPage: pageNum, currentPage: pageNum,
totalPages, totalPages,
totalPosts: filtered.length, totalPosts: filtered.length,
sort,
upcomingEvents, upcomingEvents,
searchIndex, searchIndex,
tagName, tagName,
@@ -342,16 +390,18 @@ export async function loadPostsList(opts: {
export function selectUpcomingEvents(posts: PostEntry[], max = 4): PostEntry[] { export function selectUpcomingEvents(posts: PostEntry[], max = 4): PostEntry[] {
const now = Date.now(); const now = Date.now();
return (posts as PostWithEventIndex[]) type Scored = { post: PostEntry; ts: number };
.filter( const scored: Scored[] = [];
(p) => p.isEvent && p.eventDate && new Date(p.eventDate).getTime() >= now, for (const p of posts) {
) const ev = getPostEventInfo(p);
.sort( if (!ev.isEvent || !ev.eventDate) continue;
(a, b) => const ts = new Date(ev.eventDate).getTime();
new Date(a.eventDate ?? 0).getTime() - if (ts >= now) scored.push({ post: p, ts });
new Date(b.eventDate ?? 0).getTime(), }
) return scored
.slice(0, max) as PostEntry[]; .sort((a, b) => a.ts - b.ts)
.slice(0, max)
.map((s) => s.post);
} }
const POST_BASE = "/post"; const POST_BASE = "/post";
@@ -472,15 +522,14 @@ export async function resolvePostOverviewBlocks(
.filter(Boolean); .filter(Boolean);
const limit = const limit =
typeof item.numberItems === "number" ? item.numberItems : 9999; typeof item.numberItems === "number" ? item.numberItems : 9999;
const resolved: PostEntry[] = []; const settled = await Promise.all(
for (const slug of slugs.slice(0, limit)) { slugs.slice(0, limit).map((slug) =>
const post = await getPostBySlug(slug, { getPostBySlug(slug, { locale: "de", resolve: ["all"] }).catch(
locale: "de", () => null,
resolve: ["all"], ),
}); ),
if (post) resolved.push(post); );
} item.postsResolved = settled.filter((p): p is PostEntry => !!p);
item.postsResolved = resolved;
} }
} }
if (Array.isArray(item.postsResolved)) { if (Array.isArray(item.postsResolved)) {
@@ -491,26 +540,22 @@ export async function resolvePostOverviewBlocks(
for (const post of item.postsResolved as PostEntry[]) { for (const post of item.postsResolved as PostEntry[]) {
const field = getPostImageField(post.postImage); const field = getPostImageField(post.postImage);
if (field) { if (field) {
try { const url = ensureTransformedImage(field.url, {
const url = await ensureTransformedImage(field.url, { width: 400,
width: 400, height: 267,
height: 267, fit: "cover",
fit: "cover", format: "webp",
format: "webp", focalX: field.focal?.x,
focalX: field.focal?.x, focalY: field.focal?.y,
focalY: field.focal?.y, });
}); const p = post as PostEntry & {
const p = post as PostEntry & { _resolvedImageUrl?: string;
_resolvedImageUrl?: string; _rawImageUrl?: string;
_rawImageUrl?: string; _imageFocal?: { x: number; y: number } | null;
_imageFocal?: { x: number; y: number } | null; };
}; p._resolvedImageUrl = url;
p._resolvedImageUrl = url; p._rawImageUrl = field.url;
p._rawImageUrl = field.url; p._imageFocal = field.focal ?? null;
p._imageFocal = field.focal ?? null;
} catch {
// Bild optional
}
} }
} }
} }
@@ -562,19 +607,35 @@ export async function resolveCalendarBlocks(
resolve: "items", resolve: "items",
}); });
const raw = calendar?.items ?? rawItems; const raw = calendar?.items ?? rawItems;
const resolved: CalendarItemData[] = []; // Leere items-Liste → alle calendar_items aus der Collection (Pool).
for (const x of raw) { // Greift sowohl für default-Variante als auch compact-Anteaser.
if (typeof x === "object" && x !== null && "terminZeit" in x && "title" in x) { if (raw.length === 0) {
resolved.push(x as CalendarItemData); const pool = (await getCalendarItems({ locale: "de" })) as unknown as CalendarItemData[];
} else { (item as { items?: CalendarItemData[] }).items = pool;
const islug = typeof x === "string" ? x : (x as { _slug?: string })?._slug; continue;
if (islug) {
const entry = await getCalendarItemBySlug(islug, { locale: "de" });
if (entry) resolved.push(entry);
}
}
} }
(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,
);
} }
} }
} }
@@ -713,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;
}
}
}
+194
View File
@@ -0,0 +1,194 @@
/**
* Helpers for "send calendar entry to my own calendar" flows.
*
* - `buildICS()` produces a single-event RFC-5545 string suitable for
* download. Apple Calendar, Outlook and Google all import it.
* - `googleCalUrl()` returns a deep link to Google Calendar's event-
* create form pre-filled with the event details.
* - `mapsUrl()` returns a Google Maps search URL for an address string.
*
* All client-only — no Node APIs touched. Safe in SSR contexts (the
* functions just return strings; the actual download is triggered by
* the caller via `URL.createObjectURL`).
*/
export type CalendarItemICS = {
title: string;
start: Date;
/** Optional end date. If omitted, default duration is 1 hour. */
end?: Date | null;
description?: string;
location?: string;
url?: string;
/** Stable identifier; falls back to start-timestamp + title hash. */
uid?: string;
};
/** Folds long lines per RFC-5545 §3.1 (75 octets max). Most clients
* tolerate longer lines but Outlook is strict — better safe. */
function foldLine(line: string): string {
if (line.length <= 75) return line;
const out: string[] = [];
let i = 0;
while (i < line.length) {
const chunk = line.slice(i, i + (i === 0 ? 75 : 74));
out.push((i === 0 ? "" : " ") + chunk);
i += i === 0 ? 75 : 74;
}
return out.join("\r\n");
}
/** Escape per RFC-5545 §3.3.11 — backslash, comma, semicolon, newline. */
function escapeText(s: string): string {
return s
.replace(/\\/g, "\\\\")
.replace(/\n/g, "\\n")
.replace(/,/g, "\\,")
.replace(/;/g, "\\;");
}
/** UTC timestamp formatted as `YYYYMMDDTHHMMSSZ`. */
function fmtICSDate(d: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return (
d.getUTCFullYear().toString() +
pad(d.getUTCMonth() + 1) +
pad(d.getUTCDate()) +
"T" +
pad(d.getUTCHours()) +
pad(d.getUTCMinutes()) +
pad(d.getUTCSeconds()) +
"Z"
);
}
function deterministicUid(title: string, start: Date): string {
let hash = 0;
for (let i = 0; i < title.length; i++) {
hash = (hash * 31 + title.charCodeAt(i)) | 0;
}
return `${start.getTime()}-${Math.abs(hash).toString(36)}@windwiderstand.de`;
}
export function buildICS(item: CalendarItemICS): string {
const start = item.start;
const end =
item.end ??
new Date(start.getTime() + 60 * 60 * 1000); // 1h default
const uid = item.uid ?? deterministicUid(item.title, start);
const now = new Date();
const lines: string[] = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//windwiderstand.de//calendar//DE",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
"BEGIN:VEVENT",
`UID:${uid}`,
`DTSTAMP:${fmtICSDate(now)}`,
`DTSTART:${fmtICSDate(start)}`,
`DTEND:${fmtICSDate(end)}`,
foldLine(`SUMMARY:${escapeText(item.title)}`),
];
if (item.description) {
lines.push(foldLine(`DESCRIPTION:${escapeText(item.description)}`));
}
if (item.location) {
lines.push(foldLine(`LOCATION:${escapeText(item.location)}`));
}
if (item.url) {
lines.push(foldLine(`URL:${item.url}`));
}
lines.push("END:VEVENT", "END:VCALENDAR");
return lines.join("\r\n");
}
/** Trigger a browser download of an ICS file. Caller should pass a
* filesystem-friendly filename; we still strip path separators
* defensively. No-op on the server (`window` undefined). */
export function downloadICS(item: CalendarItemICS, filename: string): void {
if (typeof window === "undefined") return;
const safeName = filename.replace(/[/\\:*?"<>|]/g, "_") || "event.ics";
const ics = buildICS(item);
const blob = new Blob([ics], { type: "text/calendar;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = safeName.endsWith(".ics") ? safeName : `${safeName}.ics`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/** Builds a VCALENDAR with multiple VEVENTs — for feed/bulk export. */
export function buildICSFeed(items: CalendarItemICS[], calName = "Windwiderstand Termine"): string {
const now = new Date();
const lines: string[] = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//windwiderstand.de//calendar//DE",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
foldLine(`X-WR-CALNAME:${escapeText(calName)}`),
"X-WR-TIMEZONE:Europe/Berlin",
"REFRESH-INTERVAL;VALUE=DURATION:PT1H",
"X-PUBLISHED-TTL:PT1H",
];
for (const item of items) {
const start = item.start;
const end = item.end ?? new Date(start.getTime() + 60 * 60 * 1000);
const uid = item.uid ?? deterministicUid(item.title, start);
lines.push(
"BEGIN:VEVENT",
`UID:${uid}`,
`DTSTAMP:${fmtICSDate(now)}`,
`DTSTART:${fmtICSDate(start)}`,
`DTEND:${fmtICSDate(end)}`,
foldLine(`SUMMARY:${escapeText(item.title)}`),
);
if (item.description) lines.push(foldLine(`DESCRIPTION:${escapeText(item.description)}`));
if (item.location) lines.push(foldLine(`LOCATION:${escapeText(item.location)}`));
if (item.url) lines.push(foldLine(`URL:${item.url}`));
lines.push("END:VEVENT");
}
lines.push("END:VCALENDAR");
return lines.join("\r\n");
}
/** Triggers a browser download of a multi-event ICS file. */
export function downloadICSFeed(items: CalendarItemICS[], filename: string): void {
if (typeof window === "undefined") return;
const safeName = filename.replace(/[/\\:*?"<>|]/g, "_") || "termine.ics";
const ics = buildICSFeed(items);
const blob = new Blob([ics], { type: "text/calendar;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = safeName.endsWith(".ics") ? safeName : `${safeName}.ics`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/** Deep-link to Google Calendar's pre-filled create form. */
export function googleCalUrl(item: CalendarItemICS): string {
const fmt = (d: Date) =>
d.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
const end = item.end ?? new Date(item.start.getTime() + 60 * 60 * 1000);
const params = new URLSearchParams({
action: "TEMPLATE",
text: item.title,
dates: `${fmt(item.start)}/${fmt(end)}`,
});
if (item.description) params.set("details", item.description);
if (item.location) params.set("location", item.location);
return `https://calendar.google.com/calendar/render?${params}`;
}
/** OpenStreetMap search URL for an address or place name. */
export function mapsUrl(location: string): string {
return `https://www.openstreetmap.org/search?query=${encodeURIComponent(location)}`;
}
+12 -4
View File
@@ -4024,7 +4024,7 @@ export interface components {
* @default false * @default false
*/ */
external: boolean; 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; icon?: string;
/** @description Internal key. Not used in app. */ /** @description Internal key. Not used in app. */
internal?: string; internal?: string;
@@ -4065,7 +4065,7 @@ export interface components {
* @default false * @default false
*/ */
external: boolean; 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; icon?: string;
/** @description Internal key. Not used in app. */ /** @description Internal key. Not used in app. */
internal?: string; internal?: string;
@@ -5400,7 +5400,7 @@ export interface components {
readonly _slug?: string; readonly _slug?: string;
/** @description Optional pill color (CSS, e.g. #2d5a27 or rgb()). Admin: color picker + text field. */ /** @description Optional pill color (CSS, e.g. #2d5a27 or rgb()). Admin: color picker + text field. */
color?: string; 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; icon?: string;
/** @description Tag name (e.g. politik, termin). Must be unique. */ /** @description Tag name (e.g. politik, termin). Must be unique. */
name: string; name: string;
@@ -5410,7 +5410,7 @@ export interface components {
_slug: string; _slug: string;
/** @description Optional pill color (CSS, e.g. #2d5a27 or rgb()). Admin: color picker + text field. */ /** @description Optional pill color (CSS, e.g. #2d5a27 or rgb()). Admin: color picker + text field. */
color?: string; 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; icon?: string;
/** @description Tag name (e.g. politik, termin). Must be unique. */ /** @description Tag name (e.g. politik, termin). Must be unique. */
name: string; name: string;
@@ -5446,6 +5446,10 @@ export interface components {
id: string; id: string;
/** @description Banner text (e.g. notice at top of page) */ /** @description Banner text (e.g. notice at top of page) */
text: string; text: string;
/** @description Optional URL the banner links to */
link?: string;
/** @description CTA label for the link */
linkLabel?: string;
}; };
top_banner_input: { top_banner_input: {
/** @description URL slug (used as filename) */ /** @description URL slug (used as filename) */
@@ -5454,6 +5458,10 @@ export interface components {
id: string; id: string;
/** @description Banner text (e.g. notice at top of page) */ /** @description Banner text (e.g. notice at top of page) */
text: string; text: string;
/** @description Optional URL the banner links to */
link?: string;
/** @description CTA label for the link */
linkLabel?: string;
}; };
translation_bundle: { translation_bundle: {
/** @description Entry identifier (filename) */ /** @description Entry identifier (filename) */
+255 -25
View File
@@ -62,20 +62,22 @@ let openApiCache: OpenApiSpec | null = null;
* Override via PUBLIC_CMS_CACHE_TTL_MS_<COLLECTION> falls nötig. * Override via PUBLIC_CMS_CACHE_TTL_MS_<COLLECTION> falls nötig.
*/ */
const CACHE_TTL: Record<string, number> = { const CACHE_TTL: Record<string, number> = {
page: 5 * 60_000, // Webhooks purgen bei Mutation → höhere TTL ist unkritisch und spart
page_config: 10 * 60_000, // Cold-Loads + CMS-Roundtrips.
navigation: 10 * 60_000, page: 60 * 60_000, // 1h (war 5min)
footer: 10 * 60_000, page_config: 60 * 60_000, // 1h
post: 60_000, navigation: 60 * 60_000, // 1h
tag: 5 * 60_000, footer: 60 * 60_000, // 1h
text_fragment: 5 * 60_000, post: 10 * 60_000, // 10min (war 1min) — Posts ändern öfter
fullwidth_banner: 5 * 60_000, tag: 30 * 60_000, // 30min
calendar: 60_000, text_fragment: 30 * 60_000, // 30min
calendar_item: 60_000, fullwidth_banner: 60 * 60_000, // 1h
translation: 5 * 60_000, calendar: 5 * 60_000, // 5min — wenig Mutationen, aber zeitsensitiv
translation_bundle: 5 * 60_000, calendar_item: 5 * 60_000, // 5min
openapi: 10 * 60_000, translation: 60 * 60_000, // 1h
default: 60_000, translation_bundle: 60 * 60_000, // 1h
openapi: 60 * 60_000, // 1h
default: 5 * 60_000,
}; };
type CacheEntry<T> = { value: T; expires: number }; 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>". * 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, collection: string,
key: string, key: string,
fn: () => Promise<T>, fn: () => Promise<T>,
): 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); const hit = cache.get(key);
if (hit && hit.expires > Date.now()) return hit.value as T; if (hit && hit.expires > Date.now()) return hit.value as T;
const pending = inflight.get(key) as Promise<T> | undefined; const pending = inflight.get(key) as Promise<T> | undefined;
@@ -117,7 +124,9 @@ async function cached<T>(
export function invalidateCollection(collection: string): number { export function invalidateCollection(collection: string): number {
let n = 0; let n = 0;
for (const k of cache.keys()) { 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); cache.delete(k);
n++; n++;
} }
@@ -160,14 +169,35 @@ export async function fetchOpenApi(): Promise<OpenApiSpec> {
export async function getPages(): Promise<PageEntry[]> { export async function getPages(): Promise<PageEntry[]> {
return cached("page", "page:list:", async () => { return cached("page", "page:list:", async () => {
const base = getBaseUrl(); const base = getBaseUrl();
const res = await cmsFetch(`${base}/api/content/page`); // Alle Seiten holen — CMS-Default-per_page ist 50, die Site hat aber mehr.
if (!res.ok) { // Ohne vollständige Liste scheitert der Slug→_slug-Fallback in
throw new Error( // getPageBySlug für Seiten jenseits der ersten Page → 404.
`RustyCMS list page failed: ${res.status}. Is the CMS running at ${base}?`, 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}`,
); );
} if (!res.ok) {
const data = (await res.json()) as PageListResponse; throw new Error(
return data.items ?? []; `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(); 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). * Ein Page-Eintrag nach Slug (GET /api/content/page/:slug).
*/ */
@@ -354,6 +416,18 @@ export async function getPageBySlug(
res = await trySlug("/" + normSlug); res = await trySlug("/" + normSlug);
} }
if (res.status === 404) { 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 items = await getPages();
const match = items.find( const match = items.find(
(p) => (p) =>
@@ -625,6 +699,42 @@ export async function getPostBySlug(
return cached("post", key, fetchFn); return cached("post", key, fetchFn);
} }
/**
* Generic single-entry fetch by `(collection, slug)`. Used by the embed/widget
* route that renders a single block standalone.
*
* Resolves nested references via `_resolve=all` by default so block components
* see the same data shape as inside a page row.
*/
export async function getEntryBySlug<T = Record<string, unknown>>(
collection: string,
slug: string,
options?: ContentGetOptions,
): Promise<T | null> {
const resolveKey = options?.resolve?.join(",") ?? "all";
const fieldsKey = options?.fields?.join(",") ?? "";
const depthKey = options?.depth ?? "";
const key = `entry:${collection}:${slug}:${options?.locale ?? ""}:${resolveKey}:${depthKey}:${fieldsKey}`;
const fetchFn = async (): Promise<T | null> => {
const base = getBaseUrl();
const u = new URL(`${base}/api/content/${encodeURIComponent(collection)}/${encodeURIComponent(slug)}`);
if (options?.locale) u.searchParams.set("_locale", options.locale);
u.searchParams.set("_resolve", options?.resolve?.length ? options.resolve.join(",") : "all");
if (typeof options?.depth === "number") u.searchParams.set("_depth", String(options.depth));
if (options?.fields?.length) u.searchParams.set("_fields", options.fields.join(","));
if (options?.preview) u.searchParams.set("preview", options.preview);
if (options?.previewDraft) u.searchParams.set("preview_draft", options.previewDraft);
const res = await cmsFetch(u.toString());
if (res.status === 404) return null;
if (!res.ok) {
throw new Error(`RustyCMS getEntry failed: ${res.status} for ${collection}/${slug}`);
}
return (await res.json()) as T;
};
if (options?.preview || options?.previewDraft) return fetchFn();
return cached(collection, key, fetchFn);
}
/** Für Post-URLs wird slug bevorzugt (nicht _slug). Führender Slash wird ignoriert. */ /** Für Post-URLs wird slug bevorzugt (nicht _slug). Führender Slash wird ignoriert. */
export async function getPostSlugs(): Promise<string[]> { export async function getPostSlugs(): Promise<string[]> {
const items = await getPosts(); const items = await getPosts();
@@ -710,7 +820,58 @@ 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). */ /** Fullwidth-Banner (z. B. für Top-Banner mit Bild). */
export type TopBannerEntry = components["schemas"]["top_banner"];
/** Top-Banner anhand Slug (GET /api/content/top_banner/:slug). */
export async function getTopBannerBySlug(
slug: string,
options?: { locale?: string },
): Promise<TopBannerEntry | null> {
const key = `top_banner:slug:${slug}:${options?.locale ?? ""}`;
return cached("top_banner", key, async () => {
const base = getBaseUrl();
const url = new URL(
`${base}/api/content/top_banner/${encodeURIComponent(slug)}`,
);
if (options?.locale) url.searchParams.set("_locale", options.locale);
const res = await cmsFetch(url.toString());
if (res.status === 404) return null;
if (!res.ok) {
throw new Error(
`RustyCMS get top_banner failed: ${res.status} for slug "${slug}".`,
);
}
return (await res.json()) as TopBannerEntry;
});
}
export type FullwidthBannerEntry = components["schemas"]["fullwidth_banner"]; export type FullwidthBannerEntry = components["schemas"]["fullwidth_banner"];
/** Fullwidth-Banner anhand Slug (GET /api/content/fullwidth_banner/:slug). */ /** Fullwidth-Banner anhand Slug (GET /api/content/fullwidth_banner/:slug). */
@@ -786,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 keystring 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. */ /** Kalender-Eintrag (calendar) aus OpenAPI-Spec. */
export type CalendarEntry = components["schemas"]["calendar"]; export type CalendarEntry = components["schemas"]["calendar"];
@@ -879,3 +1092,20 @@ export async function getCommentCounts(
return {}; 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>
+2 -2
View File
@@ -32,9 +32,9 @@
} }
</script> </script>
<details {id} {open} ontoggle={onToggle} class="pt-4 border-t border-zinc-200 group {cls}"> <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"> <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} {label}
</summary> </summary>
<div class="mt-4"> <div class="mt-4">
@@ -0,0 +1,42 @@
<script lang="ts">
import type { TopBannerEntry } from '$lib/cms';
import { marked } from '$lib/markdown-safe';
let { banner }: { banner: TopBannerEntry } = $props();
const textHtml = $derived(banner?.text ? marked.parseInline(banner.text) as string : '');
</script>
{#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 class="banner-text">{@html textHtml}</span>
{#if banner.link}
<a
href={banner.link}
class="shrink-0 font-semibold underline underline-offset-2 hover:no-underline"
>
{banner.linkLabel ?? 'Mehr erfahren'}
</a>
{/if}
</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"> <script lang="ts">
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
type Variant = "subtle" | "solid";
let { let {
children, children,
color, color,
variant = "subtle",
uppercase = false,
class: className = "", class: className = "",
}: { children?: Snippet; color?: string; class?: string } = $props(); }: {
children?: Snippet;
color?: string;
/** subtle = getönt mit Border (Default) · solid = gefüllt, weißer Text. */
variant?: Variant;
uppercase?: boolean;
class?: string;
} = $props();
function colorClasses(c?: string): string { // Die einzige Badge-Farbquelle. Deckt alle CMS-badge.color-Werte ab
// (green/blue/amber/orange/purple/teal/slate) + Fallback. Marken-Töne
// (wald/himmel) wo möglich, sonst Tailwind-Palette. KEINE Eigenimplementierungen
// woanders — immer <Badge>.
function subtleClasses(c?: string): string {
switch (c) { switch (c) {
case "green": return "bg-wald-50 text-wald-700 border border-wald-200"; case "green": return "bg-wald-50 text-wald-700 border-wald-200";
case "blue": return "bg-himmel-50 text-himmel-700 border border-himmel-200"; case "blue": return "bg-himmel-50 text-himmel-700 border-himmel-200";
case "amber": return "bg-[#fdf8ec] text-[#a6780a] border border-[#f0d98a]"; case "amber": return "bg-amber-50 text-amber-700 border-amber-200";
default: return "bg-stein-100 text-stein-600 border border-stein-200"; case "orange": return "bg-orange-50 text-orange-700 border-orange-200";
case "purple": return "bg-purple-50 text-purple-700 border-purple-200";
case "teal": return "bg-teal-50 text-teal-700 border-teal-200";
case "slate": return "bg-slate-100 text-slate-700 border-slate-300";
default: return "bg-stein-100 text-stein-600 border-stein-200";
} }
} }
// Gefüllte Variante (weißer Text) — für Kategorie-/Status-Pills auf Bildern etc.
function solidClasses(c?: string): string {
switch (c) {
case "green": return "bg-wald-600 text-white border-transparent";
case "blue": return "bg-himmel-700 text-white border-transparent";
case "amber": return "bg-amber-500 text-white border-transparent";
case "orange": return "bg-orange-500 text-white border-transparent";
case "purple": return "bg-purple-600 text-white border-transparent";
case "teal": return "bg-teal-600 text-white border-transparent";
case "fire": return "bg-fire-700 text-white border-transparent";
case "white": return "bg-white/90 text-stein-800 border-transparent";
case "slate":
default: return "bg-stein-800 text-white border-transparent";
}
}
const colorClasses = $derived(
variant === "solid" ? solidClasses(color) : subtleClasses(color),
);
</script> </script>
<span class="shrink-0 rounded-full px-1.5 py-0 text-[0.65rem] font-medium {colorClasses(color)} {className}"> <span
class="shrink-0 whitespace-nowrap rounded-full border px-1.5 py-0 text-[0.65rem] font-medium {uppercase
? 'uppercase tracking-wide'
: ''} {colorClasses} {className}"
>
{@render children?.()} {@render children?.()}
</span> </span>
+137
View File
@@ -0,0 +1,137 @@
<script lang="ts">
import MarkdownBlock from "./blocks/MarkdownBlock.svelte";
import HeadlineBlock from "./blocks/HeadlineBlock.svelte";
import HtmlBlock from "./blocks/HtmlBlock.svelte";
import IframeBlock from "./blocks/IframeBlock.svelte";
import ImageBlock from "./blocks/ImageBlock.svelte";
import ImageGalleryBlock from "./blocks/ImageGalleryBlock.svelte";
import FilesBlock from "./blocks/FilesBlock.svelte";
import YoutubeVideoBlock from "./blocks/YoutubeVideoBlock.svelte";
import YoutubeVideoGalleryBlock from "./blocks/YoutubeVideoGalleryBlock.svelte";
import QuoteBlock from "./blocks/QuoteBlock.svelte";
import QuoteCarouselBlock from "./blocks/QuoteCarouselBlock.svelte";
import LinkListBlock from "./blocks/LinkListBlock.svelte";
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 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,
IframeBlockData,
ImageBlockData,
ImageGalleryBlockData,
FilesBlockData,
YoutubeVideoBlockData,
YoutubeVideoGalleryBlockData,
QuoteBlockData,
QuoteCarouselBlockData,
LinkListBlockData,
PostOverviewBlockData,
SearchableTextBlockData,
CalendarBlockData,
OrganisationsBlockData,
ContactsBlockData,
OpnFormBlockData,
InternalComponentBlockData,
DeadlineBannerBlockData,
LiveStrommixBlockData,
WindMapBlockData,
StellingnahmeGeneratorBlockData,
OrganisationsMapBlockData,
SectionGridBlockData,
ContactFormBlockData,
} from "$lib/block-types";
let {
block,
translations = {},
}: { block: ResolvedBlock; translations?: Translations | null } = $props();
const type = $derived((block._type as string) ?? "unknown");
</script>
{#if type === "markdown"}
<MarkdownBlock block={block as MarkdownBlockData} />
{:else if type === "headline"}
<HeadlineBlock block={block as HeadlineBlockData} />
{:else if type === "html"}
<HtmlBlock block={block as HtmlBlockData} />
{:else if type === "iframe"}
<IframeBlock block={block as IframeBlockData} translations={translations} />
{:else if type === "image"}
<ImageBlock block={block as ImageBlockData} />
{:else if type === "image_gallery"}
<ImageGalleryBlock block={block as ImageGalleryBlockData} />
{:else if type === "files"}
<FilesBlock block={block as FilesBlockData} />
{:else if type === "youtube_video"}
<YoutubeVideoBlock block={block as YoutubeVideoBlockData} />
{:else if type === "youtube_video_gallery"}
<YoutubeVideoGalleryBlock block={block as YoutubeVideoGalleryBlockData} />
{:else if type === "quote"}
<QuoteBlock block={block as QuoteBlockData} />
{:else if type === "quote_carousel"}
<QuoteCarouselBlock block={block as QuoteCarouselBlockData} />
{:else if type === "link_list"}
<LinkListBlock block={block as LinkListBlockData} />
{:else if type === "post_overview"}
<PostOverviewBlock block={block as PostOverviewBlockData} />
{:else if type === "searchable_text"}
<SearchableTextBlock block={block as SearchableTextBlockData} translations={translations} />
{:else if type === "calendar"}
{#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"}
<InternalComponentBlock block={block as InternalComponentBlockData} />
{:else if type === "deadline_banner"}
<DeadlineBannerBlock block={block as DeadlineBannerBlockData} />
{:else if type === "live_strommix"}
<!-- 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>
</div>
{/if}
+298 -77
View File
@@ -5,8 +5,23 @@
import EventBadges from "./EventBadges.svelte"; import EventBadges from "./EventBadges.svelte";
import Tag from "./Tag.svelte"; import Tag from "./Tag.svelte";
import Pagination from "./Pagination.svelte"; import Pagination from "./Pagination.svelte";
import Card from "./Card.svelte";
import Badge from "./Badge.svelte";
import PostMeta from "./PostMeta.svelte";
import RustyImage from "./RustyImage.svelte";
import SearchField from "$lib/ui/SearchField.svelte";
import NewsletterInlineFormComponent from "./internal/NewsletterInlineFormComponent.svelte";
import type { PostEntry } from "$lib/cms"; import type { PostEntry } from "$lib/cms";
import { postHref } from "$lib/blog-utils"; import { postHref, getPostEventInfo } from "$lib/blog-utils";
import type { PostSortOrder } from "$lib/blog-utils";
import { marked } from "$lib/markdown-safe";
import { goto } from "$app/navigation";
type PostWithImage = PostEntry & {
_resolvedImageUrl?: string;
_rawImageUrl?: string;
_imageFocal?: { x: number; y: number } | null;
};
import { t as tStatic, T } from "$lib/translations"; import { t as tStatic, T } from "$lib/translations";
import type { Translations } from "$lib/translations"; import type { Translations } from "$lib/translations";
import { import {
@@ -34,8 +49,6 @@
eventDate: string | null; eventDate: string | null;
}; };
type EventPost = PostEntry & { eventDate?: string; isEvent?: boolean };
let { let {
posts = [], posts = [],
tags = [], tags = [],
@@ -47,6 +60,7 @@
translations = {}, translations = {},
upcomingEvents = [], upcomingEvents = [],
searchIndex = null, searchIndex = null,
sort = "newest",
}: { }: {
posts: PostEntry[]; posts: PostEntry[];
tags: TagEntry[]; tags: TagEntry[];
@@ -56,12 +70,81 @@
totalPosts?: number; totalPosts?: number;
basePath?: string; basePath?: string;
translations?: Translations | null; translations?: Translations | null;
upcomingEvents?: EventPost[]; upcomingEvents?: PostEntry[];
searchIndex?: SearchIndexEntry[] | null; searchIndex?: SearchIndexEntry[] | null;
sort?: PostSortOrder;
} = $props(); } = $props();
// Sortierung umschalten: Query-Param `sort`, immer zurück auf Seite 1
// (bei umgekehrter Reihenfolge stimmen alte Seitenzahlen nicht mehr).
function setSort(order: PostSortOrder) {
if (order === sort) return;
const base = activeTag
? `${basePath}/tag/${encodeURIComponent(activeTag)}/`
: `${basePath}/`;
goto(order === "oldest" ? `${base}?sort=oldest` : base);
}
let query = $state(""); let query = $state("");
let year = $state<string>(""); let year = $state<string>("");
// Nur Tags anzeigen, die auch in mindestens einem Beitrag vorkommen
// (searchIndex = alle listbaren Posts, tags als Namen). Aktiver Tag bleibt
// immer sichtbar. Ohne searchIndex nichts filtern.
const usedTagNames = $derived(
new Set((searchIndex ?? []).flatMap((e) => e.tags)),
);
const isTagUsed = (t: TagEntry) =>
!searchIndex ||
usedTagNames.has(t.name) ||
(t._slug ?? "") === activeTag;
// Org-/BI-Tags (slug `tag-org-*`, name „Org · …") in eigene Sektion trennen.
const isOrgTag = (t: TagEntry) => (t._slug ?? "").startsWith("tag-org-");
const orgTags = $derived(tags.filter((t) => isOrgTag(t) && isTagUsed(t)));
const topicTags = $derived(
tags.filter((t) => !isOrgTag(t) && isTagUsed(t)),
);
const orgLabel = (name: string) => name.replace(/^Org\s*·\s*/, "");
const activeIsOrg = $derived(
!!activeTag && orgTags.some((t) => (t._slug ?? "") === activeTag),
);
// Lange Themen-Liste einklappen (erste N + „mehr"). Der aktive Tag wird
// auch im eingeklappten Zustand immer angezeigt.
const INITIAL_TAGS = 12;
let showAllTags = $state(false);
const visibleTags = $derived.by(() => {
if (showAllTags) return topicTags;
const head = topicTags.slice(0, INITIAL_TAGS);
if (activeTag && !head.some((t) => (t._slug ?? "") === activeTag)) {
const act = topicTags.find((t) => (t._slug ?? "") === activeTag);
if (act) return [...head, act];
}
return head;
});
let showAllOrgs = $state(false);
const INITIAL_ORGS = 10;
const visibleOrgs = $derived.by(() => {
if (showAllOrgs) return orgTags;
const head = orgTags.slice(0, INITIAL_ORGS);
if (activeTag && !head.some((t) => (t._slug ?? "") === activeTag)) {
const act = orgTags.find((t) => (t._slug ?? "") === activeTag);
if (act) return [...head, act];
}
return head;
});
// Featured-Hero: neuester Beitrag groß, nur auf Seite 1 der ungefilterten
// Übersicht. Danach die restlichen Posts im Grid.
const showFeatured = $derived(
currentPage === 1 && !activeTag && sort === "newest" && posts.length > 1,
);
const featuredPost = $derived(showFeatured ? posts[0] : null);
const gridPosts = $derived(showFeatured ? posts.slice(1) : posts);
const featuredEvent = $derived(
featuredPost ? getPostEventInfo(featuredPost) : null,
);
const normalizedQuery = $derived(query.trim().toLowerCase()); const normalizedQuery = $derived(query.trim().toLowerCase());
const isFiltering = $derived(normalizedQuery.length >= 2 || year !== ""); const isFiltering = $derived(normalizedQuery.length >= 2 || year !== "");
@@ -130,41 +213,23 @@
<div class="blog-overview"> <div class="blog-overview">
{#if searchIndex} {#if searchIndex}
<div class="mb-4 flex flex-wrap items-center gap-2"> <div class="mb-4 flex flex-wrap items-center gap-2">
<label class="relative block w-full sm:max-w-sm"> <SearchField
<span class="sr-only">{t(T.blog_search_label)}</span> bind:value={query}
<span placeholder={t(T.blog_search_placeholder)}
class="pointer-events-none absolute top-1/2 left-2 -translate-y-1/2 text-zinc-500" ariaLabel={t(T.blog_search_label)}
> class="w-full sm:max-w-sm"
<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-sm border border-zinc-300 bg-white py-2 pr-9 pl-8 text-sm focus:border-zinc-500 focus:outline-none"
/>
{#if query}
<button
type="button"
onclick={() => (query = "")}
class="absolute top-1/2 right-1.5 -translate-y-1/2 rounded-sm p-1 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900"
aria-label={t(T.blog_search_clear)}
>
<Icon icon="mdi:close" class="h-4 w-4" />
</button>
{/if}
</label>
{#if availableYears.length > 0} {#if availableYears.length > 0}
<label class="relative inline-flex items-center"> <label class="relative inline-flex items-center">
<span class="sr-only">{t(T.blog_year_label)}</span> <span class="sr-only">{t(T.blog_year_label)}</span>
<span <span
class="pointer-events-none absolute top-1/2 left-2 -translate-y-1/2 text-zinc-500" 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> </span>
<select <select
bind:value={year} bind:value={year}
class="appearance-none rounded-sm border border-zinc-300 bg-white py-2 pr-7 pl-8 text-sm focus:border-zinc-500 focus:outline-none" class="appearance-none rounded-md border border-stein-300 bg-white py-2 pr-7 pl-8 text-sm focus:border-wald-500 focus:outline-none focus:ring-2 focus:ring-wald-200"
> >
<option value="">{t(T.blog_year_all)}</option> <option value="">{t(T.blog_year_all)}</option>
{#each availableYears as y} {#each availableYears as y}
@@ -172,9 +237,9 @@
{/each} {/each}
</select> </select>
<span <span
class="pointer-events-none absolute top-1/2 right-1.5 -translate-y-1/2 text-zinc-500" 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> </span>
</label> </label>
{/if} {/if}
@@ -182,37 +247,36 @@
{/if} {/if}
{#if !isFiltering && upcomingEvents.length > 0} {#if !isFiltering && upcomingEvents.length > 0}
<section <Card variant="callout" class="not-prose mb-6">
class="not-prose mb-6 rounded-sm border border-zinc-200 bg-zinc-50 p-4"
>
<h2 <h2
class="mb-3 flex items-center gap-2 text-sm font-semibold tracking-wide text-zinc-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)} {t(T.blog_upcoming_events)}
</h2> </h2>
<ul class="not-prose list-none space-y-2 pl-0"> <ul class="not-prose list-none space-y-2 pl-0">
{#each upcomingEvents as ev} {#each upcomingEvents as ev}
{@const evInfo = getPostEventInfo(ev)}
<li> <li>
<a <a
href={postHref(ev)} href={postHref(ev)}
class="group flex flex-wrap items-center gap-2 no-underline" class="group flex flex-wrap items-center gap-2 no-underline"
> >
<EventBadges eventDate={ev.eventDate} /> <EventBadges eventDate={evInfo.eventDate} />
<span <span
class="text-sm text-zinc-900 group-hover:text-zinc-950" class="text-sm text-stein-900 group-hover:text-stein-900"
>{ev.headline}</span >{ev.headline}</span
> >
</a> </a>
</li> </li>
{/each} {/each}
</ul> </ul>
</section> </Card>
{/if} {/if}
{#if isFiltering} {#if isFiltering}
<div <div
class="mb-3 flex flex-wrap items-center gap-2 text-xs text-zinc-600" class="mb-3 flex flex-wrap items-center gap-2 text-xs text-stein-600"
> >
<span> <span>
{t(T.blog_results_count, { count: filteredResults.length })} {t(T.blog_results_count, { count: filteredResults.length })}
@@ -225,7 +289,7 @@
query = ""; query = "";
year = ""; year = "";
}} }}
class="text-zinc-500 underline hover:text-zinc-900" class="text-stein-500 underline hover:text-stein-900"
>{t(T.blog_filter_reset)}</button >{t(T.blog_filter_reset)}</button
> >
</div> </div>
@@ -234,13 +298,14 @@
class="py-2 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3" class="py-2 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
> >
{#each filteredResults as hit} {#each filteredResults as hit}
<a <Card
variant="tile"
layout="post"
href={searchResultHref(hit.slug)} href={searchResultHref(hit.slug)}
class="flex flex-col overflow-hidden border border-gray-200 bg-white text-slate-950 no-underline transition-all"
> >
{#if hit.image} {#if hit.image}
<div <div
class="aspect-3/2 w-full overflow-hidden bg-gray-100" class="aspect-3/2 w-full overflow-hidden bg-stein-100"
> >
<img <img
src={hit.image} src={hit.image}
@@ -255,23 +320,23 @@
{hit.headline} {hit.headline}
</h3> </h3>
{#if hit.excerpt} {#if hit.excerpt}
<p class="text-sm text-zinc-700 line-clamp-3"> <p class="text-sm text-stein-700 line-clamp-3">
{hit.excerpt} {hit.excerpt}
</p> </p>
{/if} {/if}
</div> </div>
</a> </Card>
{/each} {/each}
</div> </div>
{:else} {:else}
<div <div
class="rounded-sm border border-zinc-200 bg-white p-6 text-center text-sm text-zinc-600" class="rounded-lg border border-stein-200 bg-white p-6 text-center text-sm text-stein-600"
> >
{t(T.blog_no_results)} {t(T.blog_no_results)}
</div> </div>
{/if} {/if}
{:else} {:else}
{#if tags.length > 0} {#if topicTags.length > 0}
<div class="mb-2"> <div class="mb-2">
<div <div
class="flex flex-wrap gap-2" class="flex flex-wrap gap-2"
@@ -284,7 +349,7 @@
variant={activeTag ? "blue" : "green"} variant={activeTag ? "blue" : "green"}
active={!activeTag} active={!activeTag}
/> />
{#each tags as tag} {#each visibleTags as tag}
{@const slug = tag._slug ?? ""} {@const slug = tag._slug ?? ""}
<Tag <Tag
label={tag.name} label={tag.name}
@@ -297,14 +362,164 @@
: tag.color?.trim() || null} : tag.color?.trim() || null}
/> />
{/each} {/each}
{#if topicTags.length > INITIAL_TAGS}
<Tag
label={showAllTags
? "weniger"
: `+${topicTags.length - INITIAL_TAGS} weitere`}
variant="white"
icon={showAllTags ? "lucide:chevron-up" : "lucide:chevron-down"}
onclick={() => (showAllTags = !showAllTags)}
/>
{/if}
</div> </div>
</div> </div>
{/if} {/if}
<Pagination {currentPage} {totalPages} {basePath} {activeTag} /> {#if orgTags.length > 0}
<div class="mb-4 mt-5">
<div
class="mb-1.5 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-stein-500"
>
<Icon icon="lucide:users" class="size-3.5" />
Bürgerinitiativen &amp; Organisationen
</div>
<div
class="flex flex-wrap gap-2"
role="navigation"
aria-label="Nach Organisation filtern"
>
{#each visibleOrgs as tag}
{@const slug = tag._slug ?? ""}
<Tag
label={orgLabel(tag.name)}
href={tagHref(slug)}
variant={activeTag === slug ? "inactive" : "white"}
active={activeTag === slug}
icon={tag.icon?.trim() || "lucide:flag"}
/>
{/each}
{#if orgTags.length > INITIAL_ORGS && !activeIsOrg}
<Tag
label={showAllOrgs
? "weniger"
: `+${orgTags.length - INITIAL_ORGS} weitere`}
variant="white"
icon={showAllOrgs ? "lucide:chevron-up" : "lucide:chevron-down"}
onclick={() => (showAllOrgs = !showAllOrgs)}
/>
{/if}
</div>
</div>
{/if}
<div class="mt-5 mb-4 flex flex-wrap items-center gap-2">
<Pagination {currentPage} {totalPages} {basePath} {activeTag} {sort} />
<div class="grow"></div>
<div
class="inline-flex overflow-hidden rounded-full border border-stein-200 bg-white text-xs"
role="group"
aria-label="Sortierung"
>
<button
type="button"
onclick={() => setSort("newest")}
aria-pressed={sort === "newest"}
class="px-3 py-1.5 font-medium transition-colors {sort === 'newest'
? 'bg-wald-600 text-white'
: 'text-stein-600 hover:bg-stein-50'}"
>
Neueste
</button>
<button
type="button"
onclick={() => setSort("oldest")}
aria-pressed={sort === "oldest"}
class="px-3 py-1.5 font-medium transition-colors {sort === 'oldest'
? 'bg-wald-600 text-white'
: 'text-stein-600 hover:bg-stein-50'}"
>
Älteste
</button>
</div>
</div>
{#if featuredPost}
{@const fp = featuredPost as PostWithImage}
<a
href={postHref(fp)}
class="not-prose group mb-6 grid overflow-hidden rounded-lg border border-stein-200 bg-white no-underline transition-[transform,box-shadow,border-color] duration-200 hover:-translate-y-0.5 hover:border-wald-300 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-500 focus-visible:ring-offset-2 md:grid-cols-2"
>
<div class="relative aspect-video w-full overflow-hidden bg-stein-100 md:aspect-auto">
{#if fp._rawImageUrl}
<RustyImage
src={fp._rawImageUrl}
focal={fp._imageFocal ?? null}
width={720}
aspect="16/9"
alt={fp.headline ?? ""}
sizes="(min-width: 768px) 50vw, 100vw"
class="h-full w-full object-cover"
/>
{:else if fp._resolvedImageUrl}
<img
src={fp._resolvedImageUrl}
alt={fp.headline ?? ""}
class="h-full w-full object-cover"
/>
{/if}
<Badge
variant="solid"
color="green"
uppercase
class="absolute left-3 top-3 inline-flex items-center gap-1 shadow-sm"
>
<Icon icon="lucide:star" class="size-3" />
Neuester Beitrag
</Badge>
{#if featuredEvent?.eventDate}
<div class="absolute bottom-3 left-3 right-3 min-w-0">
<EventBadges
eventDate={featuredEvent.eventDate}
locationText={featuredEvent.eventLocation?.text ?? null}
/>
</div>
{/if}
</div>
<div class="flex flex-col justify-center p-5 md:p-6">
<PostMeta
date={fp.created ?? null}
tags={fp.postTag ?? []}
tagBase={`${basePath}/tag`}
/>
<h2 class="mb-2 mt-1 text-xl font-semibold leading-tight text-stein-900 md:text-2xl">
{fp.headline ?? fp.linkName ?? fp.slug ?? fp._slug}
</h2>
{#if fp.subheadline}
<div class="mb-2 text-sm text-stein-700">
{@html marked.parseInline(fp.subheadline)}
</div>
{/if}
{#if fp.excerpt}
<p class="line-clamp-3 text-sm font-light text-stein-600">
{fp.excerpt}
</p>
{/if}
<span
class="mt-3 inline-flex items-center gap-1 text-sm font-medium text-wald-700 group-hover:gap-1.5"
>
Weiterlesen
<Icon
icon="lucide:arrow-right"
class="size-4 transition-transform group-hover:translate-x-0.5"
/>
</span>
</div>
</a>
{/if}
<div class="py-2 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div class="py-2 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each posts as post} {#each gridPosts as post}
<PostCard <PostCard
{post} {post}
href={postHref(post)} href={postHref(post)}
@@ -315,36 +530,42 @@
{/each} {/each}
</div> </div>
<div class="flex flex-wrap items-center gap-2"> {#if currentPage === 1 && !activeTag}
<Pagination {currentPage} {totalPages} {basePath} {activeTag} /> <div
<div class="grow"></div> class="not-prose my-6 rounded-lg bg-stein-900 p-5 sm:p-6 [&_h3]:!text-stein-0"
<div class="bg-white rounded-md font-thin"> >
<div class="inline-flex text-xs p-2"> <NewsletterInlineFormComponent />
{#if totalCount > 0}
{t(T.blog_count, {
visible: posts.length,
total: totalCount,
current: currentPage,
totalPages,
})}
{:else}
{t(T.blog_count, {
visible: 0,
total: 0,
current: currentPage,
totalPages,
})}
{/if}
</div>
</div> </div>
{/if}
<div class="flex flex-wrap items-center gap-2">
<Pagination {currentPage} {totalPages} {basePath} {activeTag} {sort} />
<div class="grow"></div>
<Badge>
{#if totalCount > 0}
{t(T.blog_count, {
visible: posts.length,
total: totalCount,
current: currentPage,
totalPages,
})}
{:else}
{t(T.blog_count, {
visible: 0,
total: 0,
current: currentPage,
totalPages,
})}
{/if}
</Badge>
</div> </div>
<div class="mt-4 text-xs text-zinc-600"> <div class="mt-5">
<a <a
href="/posts/rss.xml" href="/posts/rss.xml"
class="inline-flex items-center gap-1 hover:underline" class="inline-flex items-center gap-2 rounded-full border border-stein-200 bg-white px-3.5 py-2 text-sm font-medium text-stein-700 no-underline transition-colors hover:border-wald-300 hover:bg-stein-50 hover:text-wald-700"
> >
<Icon icon="mdi:rss" class="h-3.5 w-3.5" /> <Icon icon="lucide:rss" class="size-4 text-wald-600" />
{t(T.blog_rss_subscribe)} {t(T.blog_rss_subscribe)}
</a> </a>
</div> </div>
+24
View File
@@ -0,0 +1,24 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import Card from './Card.svelte';
const { Story } = defineMeta({
title: 'Molecules/Card',
component: Card,
tags: ['autodocs'],
});
</script>
<Story name="Tile">
<div class="p-4">
<h3 class="font-semibold">Karten-Titel</h3>
<p class="text-sm text-stein-600">Inhalt der Kachel.</p>
</div>
</Story>
<Story name="AlsLink" args={{ href: '#', variant: 'tile' }}>
<div class="p-4">
<h3 class="font-semibold">Klickbare Karte</h3>
<p class="text-sm text-stein-600">Ganze Karte ist ein Link.</p>
</div>
</Story>
+6 -9
View File
@@ -7,7 +7,7 @@
* tile + post: PostCard Shell ohne Padding, Medien bündig, Text mit p-4 im Kind. * tile + post: PostCard Shell ohne Padding, Medien bündig, Text mit p-4 im Kind.
* callout: CTA; layout wird ignoriert. * callout: CTA; layout wird ignoriert.
*/ */
const cardSurface = cva("relative min-w-0 overflow-hidden", { const cardSurface = cva("relative min-w-0 overflow-hidden rounded-lg", {
variants: { variants: {
variant: { variant: {
tile: "", tile: "",
@@ -24,20 +24,17 @@
{ {
variant: "tile", variant: "tile",
layout: "stack", layout: "stack",
class: class: "card-surface flex flex-col gap-2 p-4 text-stein-900",
"flex flex-col gap-2 border border-gray-200 bg-white p-4 text-slate-950",
}, },
{ {
variant: "tile", variant: "tile",
layout: "inline", layout: "inline",
class: class: "card-surface flex flex-row items-start gap-3 p-4 text-stein-900",
"flex flex-row items-start gap-3 border border-gray-200 bg-white p-4 text-slate-950",
}, },
{ {
variant: "tile", variant: "tile",
layout: "post", layout: "post",
class: class: "card-surface flex flex-col gap-0 p-0 text-stein-900",
"flex flex-col gap-0 border border-gray-200 bg-white p-0 text-slate-950",
}, },
], ],
defaultVariants: { variant: "tile", layout: "stack" }, defaultVariants: { variant: "tile", layout: "stack" },
@@ -46,8 +43,8 @@
const cardAsLink = cva("", { const cardAsLink = cva("", {
variants: { variants: {
variant: { variant: {
callout: "cursor-pointer transition-all no-underline text-inherit", callout: "cursor-pointer transition-colors no-underline text-inherit hover:bg-wald-100",
tile: "transition-all no-underline hover:border-gray-300", tile: "card-hover-lift no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-500 focus-visible:ring-offset-2",
}, },
}, },
defaultVariants: { variant: "tile" }, defaultVariants: { variant: "tile" },
@@ -0,0 +1,33 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import Carousel from './Carousel.svelte';
const { Story } = defineMeta({
title: 'Organisms/Carousel',
component: Carousel,
tags: ['autodocs'],
});
const colors = ['wald', 'erde', 'himmel', 'fire', 'stein'];
</script>
<!-- Carousel bekommt die Anzahl + eine `item`-Snippet, die pro Index rendert. -->
<Story name="Standard" asChild>
<Carousel count={5} ariaLabel="Demo-Karussell">
{#snippet item(i)}
<div class="flex h-40 items-center justify-center rounded-sm bg-{colors[i]}-100 text-xl font-semibold">
Slide {i + 1}
</div>
{/snippet}
</Carousel>
</Story>
<Story name="AutoRotate" asChild>
<Carousel count={3} autoRotateMs={2000} ariaLabel="Auto-Karussell">
{#snippet item(i)}
<div class="flex h-40 items-center justify-center rounded-sm bg-wald-100 text-xl font-semibold">
Auto {i + 1}
</div>
{/snippet}
</Carousel>
</Story>
+106
View File
@@ -0,0 +1,106 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import type { Snippet } from "svelte";
import IconButton from "$lib/components/IconButton.svelte";
let {
count,
item,
ariaLabel = "Karussell",
autoRotateMs = 0,
showArrows = true,
}: {
count: number;
item: Snippet<[index: number]>;
ariaLabel?: string;
autoRotateMs?: number;
showArrows?: boolean;
} = $props();
let index = $state(0);
let paused = $state(false);
let timer: ReturnType<typeof setInterval> | null = null;
let touchStartX = 0;
function onTouchStart(e: TouchEvent) {
touchStartX = e.touches[0].clientX;
}
function onTouchEnd(e: TouchEvent) {
const dx = e.changedTouches[0].clientX - touchStartX;
if (Math.abs(dx) < 40) return;
if (dx < 0) next(); else prev();
}
function prev() {
if (count > 0) index = (index - 1 + count) % count;
}
function next() {
if (count > 0) index = (index + 1) % count;
}
function startTimer() {
stopTimer();
if (!autoRotateMs || paused || count < 2) return;
timer = setInterval(() => { index = (index + 1) % count; }, autoRotateMs);
}
function stopTimer() {
if (timer) { clearInterval(timer); timer = null; }
}
$effect(() => {
void autoRotateMs; void paused; void count;
startTimer();
return stopTimer;
});
onMount(startTimer);
onDestroy(stopTimer);
</script>
<div
role="region"
aria-roledescription="carousel"
aria-label={ariaLabel}
onmouseenter={() => (paused = true)}
onmouseleave={() => (paused = false)}
>
<div class="flex items-center gap-1.5 sm:gap-2">
{#if showArrows && count > 1}
<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}>
{#each { length: count } as _, i}
<div
class="col-start-1 row-start-1 transition-opacity duration-300 {i === index
? 'opacity-100'
: 'pointer-events-none opacity-0'}"
aria-hidden={i !== index}
>
{@render item(i)}
</div>
{/each}
</div>
{#if showArrows && count > 1}
<IconButton icon="lucide:chevron-right" label="Nächster Eintrag" variant="surface" size="sm" onclick={next} />
{/if}
</div>
{#if count > 1}
<div class="mt-3 flex justify-center gap-1.5" role="tablist">
{#each { length: count } as _, i}
<button
type="button"
role="tab"
aria-selected={i === index}
aria-label={`Eintrag ${i + 1}`}
onclick={() => (index = i)}
class="size-2 rounded-full transition-colors {i === index
? 'bg-wald-700'
: 'bg-stein-300 hover:bg-stein-400'}"
></button>
{/each}
</div>
{/if}
</div>
+67 -65
View File
@@ -3,6 +3,9 @@
import "$lib/iconify-offline"; import "$lib/iconify-offline";
import { env } from "$env/dynamic/public"; import { env } from "$env/dynamic/public";
import { useTranslate, T } from "$lib/translations"; import { useTranslate, T } from "$lib/translations";
import Button from "$lib/ui/Button.svelte";
import TextInput from "$lib/ui/TextInput.svelte";
import Badge from "$lib/components/Badge.svelte";
let { let {
pageId, pageId,
@@ -305,42 +308,39 @@
bind:value={editBody} bind:value={editBody}
use:autosize use:autosize
onkeydown={submitOnCmdEnter} onkeydown={submitOnCmdEnter}
class="w-full p-2 border border-zinc-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-zinc-400" class="w-full p-2 border border-stein-300 rounded-xs text-sm focus:outline-none focus:ring-2 focus:ring-stein-400"
rows="2" rows="2"
maxlength={bodyMax} maxlength={bodyMax}
></textarea> ></textarea>
<div class="mt-1 flex items-center gap-3 text-xs"> <div class="mt-1 flex items-center gap-3 text-xs">
<button <Button type="submit" variant="primary" size="sm" label={t(T.comments_save)} />
type="submit"
class="rounded bg-zinc-800 px-3 py-1 font-medium text-white hover:bg-zinc-700"
>{t(T.comments_save)}</button>
<button <button
type="button" type="button"
onclick={cancelEdit} onclick={cancelEdit}
class="text-zinc-500 hover:text-zinc-900" class="text-stein-500 hover:text-stein-900"
>{t(T.comments_cancel)}</button> >{t(T.comments_cancel)}</button>
<span class="ml-auto text-zinc-400 tabular-nums">{editBody.length} / {bodyMax}</span> <span class="ml-auto text-stein-400 tabular-nums">{editBody.length} / {bodyMax}</span>
</div> </div>
{#if editError}<p class="text-red-700 text-xs mt-1">{editError}</p>{/if} {#if editError}<p class="text-fire-700 text-xs mt-1">{editError}</p>{/if}
</form> </form>
{:else} {:else}
<p class="mt-1 whitespace-pre-wrap text-sm leading-relaxed text-zinc-800">{c.body}</p> <p class="mt-1 whitespace-pre-wrap text-sm leading-relaxed text-stein-800">{c.body}</p>
{#if c.can_edit} {#if c.can_edit}
<div class="mt-1 flex gap-3 opacity-60 group-hover:opacity-100 transition-opacity"> <div class="mt-1 flex gap-3 opacity-60 group-hover:opacity-100 transition-opacity">
<button <button
type="button" type="button"
onclick={() => startEdit(c)} onclick={() => startEdit(c)}
class="text-xs text-zinc-600 hover:text-zinc-900 inline-flex items-center gap-1" 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)} {t(T.comments_edit)}
</button> </button>
<button <button
type="button" type="button"
onclick={() => deleteComment(c.id)} onclick={() => deleteComment(c.id)}
class="text-xs text-zinc-600 hover:text-red-700 inline-flex items-center gap-1" 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)} {t(T.comments_delete)}
</button> </button>
</div> </div>
@@ -350,23 +350,23 @@
{#snippet meta(c: PublicComment)} {#snippet meta(c: PublicComment)}
<div class="flex flex-wrap items-baseline gap-2 text-sm"> <div class="flex flex-wrap items-baseline gap-2 text-sm">
<strong class="font-medium text-zinc-900">{c.author}</strong> <strong class="font-medium text-stein-900">{c.author}</strong>
{#if c.can_edit} {#if c.can_edit}
<span class="text-[10px] uppercase tracking-wide text-zinc-400">{t(T.comments_self_marker)}</span> <span class="text-[10px] uppercase tracking-wide text-stein-400">{t(T.comments_self_marker)}</span>
{/if} {/if}
<time <time
class="text-zinc-500 text-xs" class="text-stein-500 text-xs"
datetime={c.created_at} datetime={c.created_at}
title={fmtAbs(c.created_at)} title={fmtAbs(c.created_at)}
>{fmtRel(c.created_at)}</time> >{fmtRel(c.created_at)}</time>
{#if c.updated_at} {#if c.updated_at}
<span class="text-zinc-400 text-xs" title={fmtAbs(c.updated_at)}>{t(T.comments_edited)}</span> <span class="text-stein-400 text-xs" title={fmtAbs(c.updated_at)}>{t(T.comments_edited)}</span>
{/if} {/if}
{#if c.status === "pending"} {#if c.status === "pending"}
<span class="ml-auto inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-800"> <Badge color="amber" uppercase class="ml-auto inline-flex items-center gap-1">
<Icon icon="mdi:clock-outline" class="size-3" /> <Icon icon="lucide:clock" class="size-3" />
{t(T.comments_pending_short)} {t(T.comments_pending_short)}
</span> </Badge>
{/if} {/if}
</div> </div>
{/snippet} {/snippet}
@@ -376,33 +376,33 @@
<ul class="space-y-4 list-none p-0" aria-busy="true" aria-label={t(T.comments_loading)}> <ul class="space-y-4 list-none p-0" aria-busy="true" aria-label={t(T.comments_loading)}>
{#each Array(2) as _, i (i)} {#each Array(2) as _, i (i)}
<li class="flex gap-3 list-none!"> <li class="flex gap-3 list-none!">
<div class="size-9 shrink-0 rounded-full bg-zinc-200 animate-pulse"></div> <div class="size-9 shrink-0 rounded-full bg-stein-200 animate-pulse"></div>
<div class="flex-1 space-y-2"> <div class="flex-1 space-y-2">
<div class="h-3 w-32 bg-zinc-200 rounded animate-pulse"></div> <div class="h-3 w-32 bg-stein-200 rounded-xs animate-pulse"></div>
<div class="h-3 w-full bg-zinc-200 rounded animate-pulse"></div> <div class="h-3 w-full bg-stein-200 rounded-xs animate-pulse"></div>
<div class="h-3 w-3/4 bg-zinc-200 rounded animate-pulse"></div> <div class="h-3 w-3/4 bg-stein-200 rounded-xs animate-pulse"></div>
</div> </div>
</li> </li>
{/each} {/each}
</ul> </ul>
{:else if loadError} {:else if loadError}
<div class="flex items-start gap-2 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-800"> <div class="flex items-start gap-2 rounded-lg border border-fire-200 bg-fire-50 p-3 text-sm text-fire-800">
<Icon icon="mdi:alert-circle-outline" class="size-5 shrink-0 mt-0.5" /> <Icon icon="lucide:circle-alert" class="size-5 shrink-0 mt-0.5" />
<div class="flex-1"> <div class="flex-1">
<p class="font-medium">{t(T.comments_load_error_title)}</p> <p class="font-medium">{t(T.comments_load_error_title)}</p>
<p class="mt-1 text-xs text-red-700">{loadError}</p> <p class="mt-1 text-xs text-fire-700">{loadError}</p>
</div> </div>
<button <button
type="button" type="button"
onclick={loadComments} onclick={loadComments}
class="text-xs text-red-800 underline hover:no-underline" class="text-xs text-fire-800 underline hover:no-underline"
>{t(T.comments_retry)}</button> >{t(T.comments_retry)}</button>
</div> </div>
{:else} {:else}
{#if topLevel.length === 0} {#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"> <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="mdi:comment-text-outline" class="size-10 text-zinc-300 shrink-0" /> <Icon icon="lucide:message-square-text" class="size-5 text-stein-300 shrink-0" />
<p class="text-sm text-zinc-500">{t(T.comments_empty)}</p> <p class="text-sm font-semibold text-stein-300">{t(T.comments_empty)}</p>
</div> </div>
{:else} {:else}
<ul class="space-y-5 mb-8 list-none p-0"> <ul class="space-y-5 mb-8 list-none p-0">
@@ -410,7 +410,7 @@
{@const replies = repliesOf(c.id)} {@const replies = repliesOf(c.id)}
<li <li
class="group flex gap-3 list-none! {c.status === 'pending' class="group flex gap-3 list-none! {c.status === 'pending'
? 'rounded-md border border-amber-200 bg-amber-50/50 p-3' ? 'rounded-lg border border-erde-200 bg-erde-50/50 p-3'
: ''}" : ''}"
> >
{@render avatar(c.author)} {@render avatar(c.author)}
@@ -422,9 +422,9 @@
<button <button
type="button" type="button"
onclick={() => (replyTo = replyTo === c.id ? null : c.id)} onclick={() => (replyTo = replyTo === c.id ? null : c.id)}
class="text-xs text-zinc-600 hover:text-zinc-900 inline-flex items-center gap-1" 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 {replyTo === c.id
? t(T.comments_cancel) ? t(T.comments_cancel)
: replies.length === 0 : replies.length === 0
@@ -437,13 +437,12 @@
{#if replyTo === c.id} {#if replyTo === c.id}
<form onsubmit={(e) => onReply(e, c.id)} class="mt-3 space-y-2"> <form onsubmit={(e) => onReply(e, c.id)} class="mt-3 space-y-2">
<input <TextInput
type="text"
bind:value={replyAuthor} bind:value={replyAuthor}
placeholder={t(T.comments_name_placeholder)} placeholder={t(T.comments_name_placeholder)}
label=""
required required
maxlength="80" size="sm"
class="w-full p-2 border border-zinc-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-zinc-400"
/> />
<textarea <textarea
bind:value={replyBody} bind:value={replyBody}
@@ -453,26 +452,29 @@
required required
rows="2" rows="2"
maxlength={bodyMax} maxlength={bodyMax}
class="w-full p-2 border border-zinc-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-zinc-400" class="w-full p-2 border border-stein-300 rounded-xs text-sm focus:outline-none focus:ring-2 focus:ring-stein-400"
></textarea> ></textarea>
{#if replyError}<p class="text-red-700 text-xs">{replyError}</p>{/if} {#if replyError}<p class="text-fire-700 text-xs">{replyError}</p>{/if}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button <Button
type="submit" type="submit"
disabled={submitting} variant="primary"
class="px-3 py-1.5 bg-zinc-800 text-white rounded text-sm font-medium hover:bg-zinc-700 disabled:opacity-50" size="sm"
>{t(T.comments_reply_send)}</button> loading={submitting}
<span class="ml-auto text-xs text-zinc-400 tabular-nums">{replyBody.length} / {bodyMax}</span> 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> </div>
</form> </form>
{/if} {/if}
{#if replies.length > 0} {#if replies.length > 0}
<ul class="mt-4 space-y-4 list-none p-0 border-l border-zinc-200 pl-4"> <ul class="mt-4 space-y-4 list-none p-0 border-l border-stein-200 pl-4">
{#each replies as r (r.id)} {#each replies as r (r.id)}
<li <li
class="group flex gap-3 list-none! {r.status === 'pending' class="group flex gap-3 list-none! {r.status === 'pending'
? 'rounded-md border border-amber-200 bg-amber-50/50 p-2' ? 'rounded-lg border border-erde-200 bg-erde-50/50 p-2'
: ''}" : ''}"
> >
{@render avatar(r.author, "sm")} {@render avatar(r.author, "sm")}
@@ -490,15 +492,14 @@
</ul> </ul>
{/if} {/if}
<form onsubmit={onSubmit} class="border-t border-zinc-200 pt-5 space-y-3"> <form onsubmit={onSubmit} class="border-t border-stein-200 pt-5 space-y-3">
<h3 class="font-medium text-sm text-zinc-900">{t(T.comments_form_title)}</h3> <h3 class="font-medium text-sm text-stein-900">{t(T.comments_form_title)}</h3>
<input <TextInput
type="text"
bind:value={author} bind:value={author}
placeholder={t(T.comments_name_placeholder)} placeholder={t(T.comments_name_placeholder)}
label=""
required required
maxlength="80" size="sm"
class="w-full p-2 border border-zinc-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-zinc-400"
/> />
<textarea <textarea
bind:value={body} bind:value={body}
@@ -508,7 +509,7 @@
required required
rows="3" rows="3"
maxlength={bodyMax} maxlength={bodyMax}
class="w-full p-2 border border-zinc-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-zinc-400" class="w-full p-2 border border-stein-300 rounded-xs text-sm focus:outline-none focus:ring-2 focus:ring-stein-400"
></textarea> ></textarea>
<input <input
type="text" type="text"
@@ -519,26 +520,27 @@
class="absolute left-[-9999px]" class="absolute left-[-9999px]"
/> />
{#if submitError} {#if submitError}
<p class="text-red-700 text-xs">{submitError}</p> <p class="text-fire-700 text-xs">{submitError}</p>
{/if} {/if}
<div class="flex flex-wrap items-center gap-3 text-xs"> <div class="flex flex-wrap items-center gap-3 text-xs">
<button <Button
type="submit" type="submit"
disabled={submitting || !body.trim() || !author.trim()} variant="primary"
class="px-4 py-2 bg-zinc-800 text-white rounded text-sm font-medium hover:bg-zinc-700 disabled:opacity-50 disabled:cursor-not-allowed" loading={submitting}
> disabled={!body.trim() || !author.trim()}
{submitting ? t(T.comments_sending) : t(T.comments_send)} label={submitting ? t(T.comments_sending) : t(T.comments_send)}
</button> class="text-sm"
/>
{#if requireApproval} {#if requireApproval}
<span class="text-zinc-500 inline-flex items-center gap-1"> <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)} {t(T.comments_approval_hint)}
</span> </span>
{/if} {/if}
<span class="ml-auto text-zinc-400 tabular-nums">{body.length} / {bodyMax}</span> <span class="ml-auto text-stein-400 tabular-nums">{body.length} / {bodyMax}</span>
</div> </div>
{#if totalPending > 0} {#if totalPending > 0}
<p class="text-xs text-zinc-500 italic"> <p class="text-xs text-stein-500 italic">
{totalPending === 1 {totalPending === 1
? t(T.comments_pending_summary_one) ? t(T.comments_pending_summary_one)
: t(T.comments_pending_summary_other, { count: totalPending })} : t(T.comments_pending_summary_other, { count: totalPending })}
+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}
+8 -89
View File
@@ -1,47 +1,9 @@
<script lang="ts"> <script lang="ts">
import MarkdownBlock from "./blocks/MarkdownBlock.svelte"; import BlockRenderer from "./BlockRenderer.svelte";
import HeadlineBlock from "./blocks/HeadlineBlock.svelte";
import HtmlBlock from "./blocks/HtmlBlock.svelte";
import IframeBlock from "./blocks/IframeBlock.svelte";
import ImageBlock from "./blocks/ImageBlock.svelte";
import ImageGalleryBlock from "./blocks/ImageGalleryBlock.svelte";
import FilesBlock from "./blocks/FilesBlock.svelte";
import YoutubeVideoBlock from "./blocks/YoutubeVideoBlock.svelte";
import YoutubeVideoGalleryBlock from "./blocks/YoutubeVideoGalleryBlock.svelte";
import QuoteBlock from "./blocks/QuoteBlock.svelte";
import QuoteCarouselBlock from "./blocks/QuoteCarouselBlock.svelte";
import LinkListBlock from "./blocks/LinkListBlock.svelte";
import PostOverviewBlock from "./blocks/PostOverviewBlock.svelte";
import SearchableTextBlock from "./blocks/SearchableTextBlock.svelte";
import CalendarBlock from "./blocks/CalendarBlock.svelte";
import OrganisationsBlock from "./blocks/OrganisationsBlock.svelte";
import OpnFormBlock from "./blocks/OpnFormBlock.svelte";
import DeadlineBannerBlock from "./blocks/DeadlineBannerBlock.svelte";
import { isBlockLayoutBreakout, getSpaceBottomClass } from "$lib/block-layout"; import { isBlockLayoutBreakout, getSpaceBottomClass } from "$lib/block-layout";
import type { BlockLayout } from "$lib/block-layout"; import type { BlockLayout } from "$lib/block-layout";
import type { Translations } from "$lib/translations"; import type { Translations } from "$lib/translations";
import type { import type { RowContentLayout, ResolvedBlock } from "$lib/block-types";
RowContentLayout,
ResolvedBlock,
MarkdownBlockData,
HeadlineBlockData,
HtmlBlockData,
IframeBlockData,
ImageBlockData,
ImageGalleryBlockData,
FilesBlockData,
YoutubeVideoBlockData,
YoutubeVideoGalleryBlockData,
QuoteBlockData,
QuoteCarouselBlockData,
LinkListBlockData,
PostOverviewBlockData,
SearchableTextBlockData,
CalendarBlockData,
OrganisationsBlockData,
OpnFormBlockData,
DeadlineBannerBlockData,
} from "$lib/block-types";
let { layout, class: className = "", translations = {} }: { layout: RowContentLayout; class?: string; translations?: Translations | null } = $props(); let { layout, class: className = "", translations = {} }: { layout: RowContentLayout; class?: string; translations?: Translations | null } = $props();
@@ -103,68 +65,25 @@
> >
{#each row.content as item} {#each row.content as item}
{#if isResolvedBlock(item)} {#if isResolvedBlock(item)}
{#snippet blockContent()}
{#if blockType(item) === "markdown"}
<MarkdownBlock block={item as MarkdownBlockData} />
{:else if blockType(item) === "headline"}
<HeadlineBlock block={item as HeadlineBlockData} />
{:else if blockType(item) === "html"}
<HtmlBlock block={item as HtmlBlockData} />
{:else if blockType(item) === "iframe"}
<IframeBlock block={item as IframeBlockData} translations={translations} />
{:else if blockType(item) === "image"}
<ImageBlock block={item as ImageBlockData} />
{:else if blockType(item) === "image_gallery"}
<ImageGalleryBlock block={item as ImageGalleryBlockData} />
{:else if blockType(item) === "files"}
<FilesBlock block={item as FilesBlockData} />
{:else if blockType(item) === "youtube_video"}
<YoutubeVideoBlock block={item as YoutubeVideoBlockData} />
{:else if blockType(item) === "youtube_video_gallery"}
<YoutubeVideoGalleryBlock block={item as YoutubeVideoGalleryBlockData} />
{:else if blockType(item) === "quote"}
<QuoteBlock block={item as QuoteBlockData} />
{:else if blockType(item) === "quote_carousel"}
<QuoteCarouselBlock block={item as QuoteCarouselBlockData} />
{:else if blockType(item) === "link_list"}
<LinkListBlock block={item as LinkListBlockData} />
{:else if blockType(item) === "post_overview"}
<PostOverviewBlock block={item as PostOverviewBlockData} />
{:else if blockType(item) === "searchable_text"}
<SearchableTextBlock block={item as SearchableTextBlockData} translations={translations} />
{:else if blockType(item) === "calendar"}
<CalendarBlock block={item as CalendarBlockData} translations={translations} />
{:else if blockType(item) === "organisations"}
<OrganisationsBlock block={item as OrganisationsBlockData} />
{:else if blockType(item) === "opnform"}
<OpnFormBlock block={item as OpnFormBlockData} />
{:else if blockType(item) === "deadline_banner"}
<DeadlineBannerBlock block={item as DeadlineBannerBlockData} />
{:else}
<div class="col-span-12 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
Unbekannter Block: <code>{blockType(item)}</code>
</div>
{/if}
{/snippet}
{#if isBlockLayoutBreakout((item as { layout?: BlockLayout }).layout)} {#if isBlockLayoutBreakout((item as { layout?: BlockLayout }).layout)}
{@const rowBlockType = blockType(item)} {@const type = blockType(item)}
<div class="col-span-12"> <div class="col-span-12">
<div <div
class="component-breakout--{rowBlockType} {getSpaceBottomClass((item as { layout?: BlockLayout }).layout)} w-screen relative left-1/2 -translate-x-1/2 max-w-none {rowBlockType === 'quote' class="component-breakout--{type} {getSpaceBottomClass((item as { layout?: BlockLayout }).layout)} w-screen relative left-1/2 -translate-x-1/2 max-w-none {type === 'quote'
? 'bg-wald-50' ? 'bg-wald-50'
: '[background:var(--color-container-breakout)]'}" : '[background:var(--color-container-breakout)]'}"
> >
{#if rowBlockType === 'deadline_banner'} {#if type === 'deadline_banner'}
{@render blockContent()} <BlockRenderer block={item} {translations} />
{:else} {:else}
<div class="container-custom py-8"> <div class="container-custom py-8">
{@render blockContent()} <BlockRenderer block={item} {translations} />
</div> </div>
{/if} {/if}
</div> </div>
</div> </div>
{:else} {:else}
{@render blockContent()} <BlockRenderer block={item} {translations} />
{/if} {/if}
{/if} {/if}
{/each} {/each}
+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"> <div class="flex flex-wrap gap-2">
{#if dateLabel} {#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]"> <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} {dateLabel}
</span> </span>
{/if} {/if}
{#if locationText} {#if locationText}
{#if locationHref} {#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-neutral-800 text-neutral-50! px-3 py-1 text-[.65rem] shadow-sm hover:bg-neutral-700 transition-colors no-underline!"> <a href={locationHref} target={locationHref.startsWith('#') ? undefined : '_blank'} rel={locationHref.startsWith('#') ? undefined : 'noopener noreferrer'} title={locationText} class="inline-flex min-w-0 items-center gap-1.5 rounded-full bg-stein-800 text-stein-50! px-3 py-1 text-[.65rem] shadow-sm hover:bg-stein-700 transition-colors no-underline!">
<Icon icon="mdi:map-marker" class="size-3.5 shrink-0 opacity-90" aria-hidden="true" /> <Icon icon="lucide:map-pin" class="size-3.5 shrink-0 opacity-90" aria-hidden="true" />
{locationText} <span class="truncate max-w-[16rem]">{locationText}</span>
</a> </a>
{:else} {:else}
<span class="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full bg-neutral-800 text-neutral-50 px-3 py-1 text-[.65rem] font-medium shadow-sm no-underline! [text-decoration:none]"> <span title={locationText} class="inline-flex min-w-0 items-center gap-1.5 rounded-full bg-stein-800 text-stein-50 px-3 py-1 text-[.65rem] font-medium shadow-sm no-underline! [text-decoration:none]">
<Icon icon="mdi:map-marker" class="size-3.5 shrink-0 opacity-90" aria-hidden="true" /> <Icon icon="lucide:map-pin" class="size-3.5 shrink-0 opacity-90" aria-hidden="true" />
{locationText} <span class="truncate max-w-[16rem]">{locationText}</span>
</span> </span>
{/if} {/if}
{/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',
},
};
+4 -4
View File
@@ -44,8 +44,8 @@
onclick={() => { overlayVisible = false; }} onclick={() => { overlayVisible = false; }}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') overlayVisible = false; }} 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-zinc-700 shadow"> <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)} {t(translations, T.post_map_activate)}
</div> </div>
</div> </div>
@@ -58,7 +58,7 @@
rel="noopener noreferrer" 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" 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)} {t(translations, T.post_map_open_osm)}
</a> </a>
{/if} {/if}
@@ -70,7 +70,7 @@
rel="noopener noreferrer" 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" 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)} {locationText ?? t(translations, T.post_map_open_osm)}
</a> </a>
{/if} {/if}
+78 -23
View File
@@ -8,12 +8,26 @@
id?: string; id?: string;
} }
let { footer = null, translations = {} }: { footer?: FooterEntry | null; translations?: Translations | null } = $props(); let {
footer = null,
translations = {},
logoSvgHtml = null,
logoUrl = null,
siteName = "",
}: {
footer?: FooterEntry | null;
translations?: Translations | null;
logoSvgHtml?: string | null;
logoUrl?: string | null;
siteName?: string;
} = $props();
function t(key: string) { function t(key: string) {
return tStatic(translations ?? null, key); return tStatic(translations ?? null, key);
} }
const year = new Date().getFullYear();
const hasRowContent = $derived( const hasRowContent = $derived(
!!footer && !!footer &&
((footer.row1Content?.length ?? 0) > 0 || ((footer.row1Content?.length ?? 0) > 0 ||
@@ -24,21 +38,37 @@
{#if footer} {#if footer}
<footer <footer
class="mt-auto bg-zinc-950 text-white py-10" class="mt-auto bg-stein-900 text-stein-0 py-10"
data-footer-id={footer.id} data-footer-id={footer.id}
> >
<div class="container-custom"> <div class="container-custom">
{#if hasRowContent} {#if hasRowContent}
<div class="content-footer text-sm text-zinc-200"> <div class="content-footer text-sm text-stein-200">
<ContentRows layout={footer} translations={translations} /> <ContentRows layout={footer} translations={translations} />
</div> </div>
{:else} {/if}
<p class="text-sm text-white/80 text-center">
<!-- Bottom-Bar: Brand links, Copyright/Lizenz rechts (immer, unabhängig vom CMS-Content). -->
<div
class="mt-8 flex flex-col gap-4 border-t border-stein-700 pt-6 text-xs text-stein-400 md:flex-row md:items-center md:justify-between"
>
<div class="flex items-center gap-2.5 text-stein-0">
{#if logoSvgHtml}
<span
class="footer-logo inline-flex h-6 w-auto items-center [&_svg]:block [&_svg]:h-full [&_svg]:w-auto"
aria-hidden="true">{@html logoSvgHtml}</span
>
{:else if logoUrl && logoUrl.trim() !== ""}
<img src={logoUrl} alt="" class="h-6 w-auto object-contain" />
{/if}
<span class="font-bold">{siteName}</span>
</div>
<p class="md:text-right">
{t(T.footer_copyright) !== T.footer_copyright {t(T.footer_copyright) !== T.footer_copyright
? t(T.footer_copyright) ? t(T.footer_copyright)
: `© ${new Date().getFullYear()} Windwiderstand`} : `Copyright © ${year} · Inhalte frei nutzbar mit Quellenangabe „windwiderstand.de“ · CC BY 4.0`}
</p> </p>
{/if} </div>
</div> </div>
</footer> </footer>
{/if} {/if}
@@ -47,33 +77,58 @@
.content-footer :global(.content) { .content-footer :global(.content) {
margin: 0; margin: 0;
} }
.content-footer :global(.content-row) { /* Footer nutzt jetzt das normale 12-Spalten-Grid (grid grid-cols-12 aus
display: flex; ContentRows) + per-Block layout (mobile/tablet/desktop + desktopStart).
flex-wrap: wrap; Kein Flex-Override mehr — Spaltigkeit kommt aus dem CMS-layout. */
gap: 2rem;
align-items: flex-start;
}
.content-footer :global(.content-row > *) {
flex: 1 1 200px;
grid-column: auto !important;
min-width: 0;
}
.content-footer :global(.content-row + .content-row) { .content-footer :global(.content-row + .content-row) {
margin-top: 2rem; margin-top: 2rem;
padding-top: 1.5rem; padding-top: 1.5rem;
border-top: 1px solid rgb(63 63 70); 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) { .content-footer :global(a) {
color: rgb(228 228 231); 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) { .content-footer :global(a:hover) {
color: rgb(255 255 255); color: var(--color-stein-0);
} }
.content-footer :global(h3) { .content-footer :global(h3) {
color: rgb(250 250 250); color: var(--color-stein-0);
}
/* Link-Listen-Überschriften ("Seiten", "Kontakt & Community") als kleine
Label-Kappen — überschreibt die unlayered h3-Defaults (daher !important). */
.content-footer :global(.link-list h3) {
font-size: 0.75rem !important;
font-weight: 600 !important;
line-height: 1.2 !important;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-stein-400) !important;
margin-bottom: 0.9rem;
}
/* Footer-Navigationslinks ohne Unterstrich (Markdown-Links bleiben unterstrichen). */
.content-footer :global(.link-list a) {
text-decoration: none !important;
} }
.content-footer :global(.markdown), .content-footer :global(.markdown),
.content-footer :global(.prose) { .content-footer :global(.prose) {
color: rgb(212 212 216); color: var(--color-stein-200);
} }
</style> </style>
+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' },
],
},
};
+207 -94
View File
@@ -19,6 +19,12 @@
interface NavLink { interface NavLink {
href: string; href: string;
label: 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 { interface SocialLink {
@@ -46,6 +52,11 @@
} }
let menuOpen = $state(false); let menuOpen = $state(false);
let searchOpen = $state(false); let searchOpen = $state(false);
let openSubmenu = $state<string | null>(null);
function toggleSubmenu(label: string) {
openSubmenu = openSubmenu === label ? null : label;
}
function toggleMenu() { function toggleMenu() {
if (menuOpen) { if (menuOpen) {
@@ -53,6 +64,11 @@
} else { } else {
searchOpen = false; searchOpen = false;
menuOpen = true; 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 <header
id="horizontal-navigation" id="horizontal-navigation"
class="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 <a
href="/" href="/"
aria-label={t(T.site_name_fallback)} aria-label={t(T.site_name_fallback)}
@@ -110,11 +127,11 @@
> >
{#if logoSvgHtml} {#if logoSvgHtml}
<span <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 aria-hidden="true">{@html logoSvgHtml}</span
> >
{:else if logoUrl && logoUrl.trim() !== ""} {: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} {:else}
<span>{t(T.site_name_fallback)}</span> <span>{t(T.site_name_fallback)}</span>
{/if} {/if}
@@ -126,63 +143,111 @@
aria-label={t(T.header_nav_aria)} aria-label={t(T.header_nav_aria)}
> >
{#each links as link} {#each links as link}
<a {@const active = isActiveTree(link, $page.url.pathname)}
href={link.href} {#if link.children && link.children.length > 0}
class="hover:animate-pulse transition-all text-[.7rem]" <div class="relative group inline-flex items-center">
class:font-bold={isActiveLink( {#if link.href && link.href !== "#"}
link.href, <a
$page.url.pathname, href={link.href}
)} class="text-[.7rem] leading-none py-1 inline-flex items-center gap-0.5 {active ? 'font-bold' : ''}"
class:underline={isActiveLink( aria-haspopup="menu"
link.href, aria-expanded="false"
$page.url.pathname, >
)} {link.label}
aria-current={isActiveLink(link.href, $page.url.pathname) <Icon icon="lucide:chevron-down" class="size-3 shrink-0" aria-hidden="true" />
? "page" </a>
: undefined} {:else}
> <button
{link.label} type="button"
</a> class="text-[.7rem] leading-none py-1 inline-flex items-center gap-0.5 {active ? 'font-bold' : ''}"
{/each} aria-haspopup="menu"
<div class="w-1 h-4"></div> aria-expanded="false"
{#each socialLinks as social} >
<a {link.label}
href={social.href} <Icon icon="lucide:chevron-down" class="size-3 shrink-0" aria-hidden="true" />
target="_blank" </button>
rel="noopener noreferrer" {/if}
class="p-1.5 -m-1.5 rounded-md transition-colors flex items-center justify-center" <div
aria-label={social.label || t(T.header_social_aria)} 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"
<Icon icon={social.icon} aria-hidden="true" /> >
</a> <ul class="bg-stein-0 shadow-lg rounded border border-stein-200 py-1 text-stein-800">
{/each} {#each link.children as child}
<button {@const cActive = isActiveLink(child.href, $page.url.pathname)}
type="button" <li role="none">
class="p-2 -mr-2 rounded-md transition-colors flex items-center justify-center" <a
aria-label={searchOpen role="menuitem"
? t(T.header_search_close) href={child.href}
: t(T.header_search_open)} class="block px-3 py-1.5 text-xs hover:bg-wald-50 {cActive ? 'font-bold text-wald-700' : ''}"
aria-expanded={searchOpen} aria-current={cActive ? "page" : undefined}
aria-controls="search-panel" >
onclick={() => (searchOpen = !searchOpen)} {child.label}
> </a>
{#if searchOpen} </li>
<Icon {/each}
icon="mdi:close" </ul>
class="text-error!" </div>
aria-hidden="true" </div>
/>
{:else} {: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} {/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> </nav>
<!-- Mobile: Such- und Burger-Buttons (unter lg) --> <!-- Mobile: Such- und Burger-Buttons (unter lg) -->
<div class="lg:hidden flex items-center gap-1"> <div class="lg:hidden flex items-center gap-1">
<button <button
type="button" type="button"
class="p-2 rounded-md 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 aria-label={searchOpen
? t(T.header_search_close) ? t(T.header_search_close)
: t(T.header_search_open)} : t(T.header_search_open)}
@@ -192,21 +257,21 @@
> >
{#if searchOpen} {#if searchOpen}
<Icon <Icon
icon="mdi:close" icon="lucide:x"
class="text-2xl text-error!" class="text-sm text-error!"
aria-hidden="true" aria-hidden="true"
/> />
{:else} {:else}
<Icon <Icon
icon="mdi:magnify" icon="lucide:search"
class="text-2xl" class="text-sm"
aria-hidden="true" aria-hidden="true"
/> />
{/if} {/if}
</button> </button>
<button <button
type="button" type="button"
class="p-2 -mr-2 hover:bg-stein-0/10 rounded-md 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 aria-label={menuOpen
? t(T.header_menu_close) ? t(T.header_menu_close)
: t(T.header_menu_open)} : t(T.header_menu_open)}
@@ -220,14 +285,11 @@
: t(T.header_menu_open)}</span : t(T.header_menu_open)}</span
> >
{#if menuOpen} {#if menuOpen}
<Icon <Icon icon="lucide:x" class="text-sm" aria-hidden="true" />
icon="mdi:close"
class="text-2xl"
aria-hidden="true"
/>
{:else} {:else}
<Icon icon="mdi:menu" class="text-2xl" aria-hidden="true" /> <Icon icon="lucide:menu" class="text-sm" aria-hidden="true" />
{/if} {/if}
<span class="text-[.7rem] leading-none" aria-hidden="true">{menuOpen ? "Schließen" : "Menü"}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -238,58 +300,109 @@
{#if menuOpen} {#if menuOpen}
<div <div
id="mobile-nav" 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" role="dialog"
aria-label="Navigation" aria-label="Navigation"
in:slide={{ duration: 200 }} in:slide={{ duration: 200 }}
out:slide={{ duration: 150 }} out:slide={{ duration: 150 }}
> >
<nav <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)} aria-label={t(T.header_nav_aria)}
> >
{#each links as link} <div class="grid grid-cols-2 gap-2.5 pb-4 mb-2 border-b border-stein-0/10">
<a <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}>
href={link.href} <Icon icon="lucide:heart-handshake" class="size-4 shrink-0" aria-hidden="true" />
class="block py-3 px-2 text-stein-100 hover:text-stein-0 hover:bg-stein-0/10 rounded-md transition-colors text-sm" Mitmachen
class:font-bold={isActiveLink(
link.href,
$page.url.pathname,
)}
class:underline={isActiveLink(
link.href,
$page.url.pathname,
)}
aria-current={isActiveLink(
link.href,
$page.url.pathname,
)
? "page"
: undefined}
onclick={closeMenu}
>
{link.label}
</a> </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} {/each}
{#if socialLinks.length > 0} {#if socialLinks.length > 0}
<div <div class="flex flex-col gap-1 pt-2 mt-2 border-t border-stein-0/10">
class="flex flex-wrap gap-2 pt-2 mt-2 border-t border-stein-0/10"
>
{#each socialLinks as social} {#each socialLinks as social}
{@const isInternal = social.href.startsWith("/")}
{@const sActive =
isInternal &&
isActiveLink(social.href, $page.url.pathname)}
<a <a
href={social.href} href={social.href}
target="_blank" target={isInternal ? undefined : "_blank"}
rel="noopener noreferrer" rel={isInternal
class="inline-flex p-2 text-stein-100 hover:text-stein-0 hover:bg-stein-0/10 rounded-md transition-colors" ? undefined
: "noopener noreferrer"}
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 || aria-label={social.label ||
t(T.header_social_aria)} t(T.header_social_aria)}
onclick={closeMenu} onclick={closeMenu}
> >
<Icon <Icon
icon={social.icon} icon={social.icon}
class="text-[1.5rem]" class="text-sm"
aria-hidden="true" aria-hidden="true"
/> />
{#if social.label}
<span>{social.label}</span>
{/if}
</a> </a>
{/each} {/each}
</div> </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}
+36
View File
@@ -0,0 +1,36 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import IconButton from "$lib/components/IconButton.svelte";
let {
src,
alt = "",
onclose,
}: { src: string; alt?: string; onclose: () => void } = $props();
function onKeydown(e: KeyboardEvent) {
if (e.key === "Escape") onclose();
}
onMount(() => window.addEventListener("keydown", onKeydown));
onDestroy(() => {
if (typeof window !== "undefined")
window.removeEventListener("keydown", onKeydown);
});
</script>
<div
class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 p-4 cursor-zoom-out"
onclick={onclose}
role="dialog"
aria-modal="true"
aria-label={alt || "Bild Vollansicht"}
>
<img
{src}
{alt}
class="max-w-full max-h-full object-contain rounded shadow-2xl cursor-default"
onclick={(e) => e.stopPropagation()}
/>
<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 } };
+25 -13
View File
@@ -11,16 +11,20 @@
totalPages = 1, totalPages = 1,
basePath = "/posts", basePath = "/posts",
activeTag = null, activeTag = null,
sort = "newest",
}: { }: {
currentPage: number; currentPage: number;
totalPages: number; totalPages: number;
basePath?: string; basePath?: string;
activeTag?: string | null; activeTag?: string | null;
sort?: "newest" | "oldest";
} = $props(); } = $props();
const query = $derived(sort === "oldest" ? "?sort=oldest" : "");
function tagHref(tagSlug: string | null): string { function tagHref(tagSlug: string | null): string {
if (!tagSlug) return `${basePath}/`; if (!tagSlug) return `${basePath}/${query}`;
return `${basePath}/tag/${encodeURIComponent(tagSlug)}/`; return `${basePath}/tag/${encodeURIComponent(tagSlug)}/${query}`;
} }
function pageHref(page: number): string { function pageHref(page: number): string {
@@ -28,7 +32,7 @@
const tagPart = activeTag const tagPart = activeTag
? `/tag/${encodeURIComponent(activeTag)}` ? `/tag/${encodeURIComponent(activeTag)}`
: ""; : "";
return `${basePath}${tagPart}/page/${page}/`; return `${basePath}${tagPart}/page/${page}/${query}`;
} }
const showPagination = $derived(totalPages > 1); const showPagination = $derived(totalPages > 1);
@@ -45,32 +49,40 @@
return Array.from({ length: end - start + 1 }, (_, i) => start + i); return Array.from({ length: end - start + 1 }, (_, i) => start + i);
}); });
const paginationLinkClass = const baseShape =
" bg-linear-to-b from-stein-50 to-stein-100 rounded-full h-6 w-6 mx-[2px] flex justify-center items-center text-stein-950 no-underline text-xs font-medium border border-stein-200 shadow-md"; "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 =
"text-white border-wald-500 bg-wald-500 hover:bg-wald-600 hover:border-wald-600 pointer-events-none";
const numberShape = "min-w-8 px-2";
const navShape = "px-3 gap-1";
</script> </script>
{#if showPagination} {#if showPagination}
<div class="inline-flex py-4"> <nav class="inline-flex py-4 items-center" aria-label="Seitennavigation">
<div class="flex"> <div class="flex items-center">
{#if hasPrev} {#if hasPrev}
<a href={pageHref(prevPage)} class={paginationLinkClass} aria-label={t(T.pagination_prev)}> <a href={pageHref(prevPage)} class="{baseShape} {navShape} {inactiveColors}" aria-label={t(T.pagination_prev)}>
<Icon icon="mdi:chevron-left" class="text-xs" 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> </a>
{/if} {/if}
{#each pageNumbers as num} {#each pageNumbers as num}
<a <a
href={pageHref(num)} href={pageHref(num)}
class="{paginationLinkClass} {num === currentPage ? 'opacity-50 shadow-none' : ''}" class="{baseShape} {numberShape} {num === currentPage ? activeColors : inactiveColors}"
aria-current={num === currentPage ? 'page' : undefined} aria-current={num === currentPage ? 'page' : undefined}
> >
{num} {num}
</a> </a>
{/each} {/each}
{#if hasNext} {#if hasNext}
<a href={pageHref(nextPage)} class={paginationLinkClass} aria-label={t(T.pagination_next)}> <a href={pageHref(nextPage)} class="{baseShape} {navShape} {inactiveColors}" aria-label={t(T.pagination_next)}>
<Icon icon="mdi:chevron-right" class="text-xs" aria-hidden="true" /> <span class="hidden sm:inline">{t(T.pagination_next)}</span>
<Icon icon="lucide:chevron-right" class="size-4" aria-hidden="true" />
</a> </a>
{/if} {/if}
</div> </div>
</div> </nav>
{/if} {/if}
+146 -11
View File
@@ -1,8 +1,12 @@
<script lang="ts"> <script lang="ts">
import Icon from "@iconify/svelte"; import Icon from "@iconify/svelte";
import { onMount, onDestroy } from "svelte";
import "$lib/iconify-offline"; import "$lib/iconify-offline";
import { env } from "$env/dynamic/public"; import { env } from "$env/dynamic/public";
import { useTranslate, T } from "$lib/translations"; import { useTranslate, T } from "$lib/translations";
import QrButton from "$lib/components/QrButton.svelte";
import SocialImageModal from "$lib/components/SocialImageModal.svelte";
let { let {
url, url,
@@ -11,6 +15,7 @@
commentPageId = null, commentPageId = null,
commentEnvironment, commentEnvironment,
commentTargetId = "comments-section", commentTargetId = "comments-section",
socialSlug = null,
class: cls = "", class: cls = "",
}: { }: {
/** Absolute URL of the page being shared. */ /** Absolute URL of the page being shared. */
@@ -25,9 +30,22 @@
commentEnvironment?: string; commentEnvironment?: string;
/** DOM id of the comment accordion (used by the count-badge click). */ /** DOM id of the comment accordion (used by the count-badge click). */
commentTargetId?: string; commentTargetId?: string;
/** Wenn gesetzt: zeigt Social-Bild-Button, der Modal öffnet. */
socialSlug?: string | null;
class?: string; class?: string;
} = $props(); } = $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(); const t = useTranslate();
// ── Comment count fetch (only when commentPageId is set) ──────────────── // ── Comment count fetch (only when commentPageId is set) ────────────────
@@ -138,6 +156,73 @@
function print() { function print() {
window.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> </script>
<!-- <!--
@@ -154,54 +239,104 @@
onclick={onCommentClick} onclick={onCommentClick}
aria-label={t(T.comments_jump_to)} aria-label={t(T.comments_jump_to)}
title={t(T.comments_jump_to)} title={t(T.comments_jump_to)}
class="no-underline! inline-flex items-center gap-1 rounded-full border border-zinc-200 bg-white px-2.5 py-1 text-xs font-medium text-zinc-700! hover:bg-zinc-50 hover:border-zinc-300 transition-colors" 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> <span class="tabular-nums">{commentCount ?? "…"}</span>
</a> </a>
{/if} {/if}
<div class="inline-flex items-center rounded-full border border-zinc-200 bg-white overflow-hidden divide-x divide-zinc-200 print:hidden"> <div class="inline-flex items-center rounded-full border border-stein-200 bg-white overflow-hidden divide-x divide-stein-200 print:hidden">
{#if canShare} {#if canShare}
<button <button
type="button" type="button"
onclick={share} onclick={share}
class="inline-flex items-center px-2.5 py-1 text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900 transition-colors" 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={t(T.post_action_share)} aria-label={t(T.post_action_share)}
title={t(T.post_action_share)} title={t(T.post_action_share)}
> >
<Icon <Icon
icon={shared ? "mdi:check" : "mdi:share-variant-outline"} icon={shared ? "lucide:check" : "lucide:share-2"}
class="size-3.5" class="size-3.5"
/> />
</button> </button>
{/if} {/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 <button
type="button" type="button"
onclick={copyLink} onclick={copyLink}
class="inline-flex items-center px-2.5 py-1 text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900 transition-colors" 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={copied ? t(T.post_action_copied) : t(T.post_action_copy)} 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)} 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>
<button <button
type="button" type="button"
onclick={print} onclick={print}
class="inline-flex items-center px-2.5 py-1 text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900 transition-colors" 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={t(T.post_action_print)} aria-label={t(T.post_action_print)}
title={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> </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> </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} {#if readingTimeMinutes != null && readingTimeMinutes > 0}
<span <span
class="inline-flex items-center gap-1 rounded-full border border-zinc-200 bg-white px-2.5 py-1 text-xs text-zinc-500" 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 })} 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" <span class="tabular-nums"
>{t(T.post_action_reading_time, { minutes: readingTimeMinutes })}</span >{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 },
};
+48 -25
View File
@@ -6,7 +6,10 @@
import EventBadges from "./EventBadges.svelte"; import EventBadges from "./EventBadges.svelte";
import RustyImage from "./RustyImage.svelte"; import RustyImage from "./RustyImage.svelte";
import Card from "./Card.svelte"; import Card from "./Card.svelte";
import Badge from "./Badge.svelte";
import { getPostEventInfo } from "$lib/blog-utils";
import { useTranslate, T } from "$lib/translations"; import { useTranslate, T } from "$lib/translations";
import { marked } from "$lib/markdown-safe";
const t = useTranslate(); const t = useTranslate();
@@ -21,27 +24,23 @@
href, href,
tagBase = "/posts/tag", tagBase = "/posts/tag",
commentCount = null, commentCount = null,
class: className = "",
}: { }: {
post: PostWithImage; post: PostWithImage;
href: string; href: string;
tagBase?: string; tagBase?: string;
/** Approved comment count. `null` = unknown/loading, hide badge. `0` = also hide. */ /** Approved comment count. `null` = unknown/loading, hide badge. `0` = also hide. */
commentCount?: number | null; commentCount?: number | null;
class?: string;
} = $props(); } = $props();
const rawImg = $derived(post._rawImageUrl); const rawImg = $derived(post._rawImageUrl);
const focal = $derived(post._imageFocal ?? null); const focal = $derived(post._imageFocal ?? null);
const fallbackImg = $derived(post._resolvedImageUrl); const fallbackImg = $derived(post._resolvedImageUrl);
type PostWithEvent = PostWithImage & { const eventInfo = $derived(getPostEventInfo(post));
isEvent?: boolean; const eventDate = $derived(eventInfo.eventDate);
eventDate?: string; const locationText = $derived(eventInfo.eventLocation?.text ?? null);
eventLocation?: { text?: string };
};
const p = $derived(post as PostWithEvent);
const eventDate = $derived(p.isEvent && p.eventDate ? p.eventDate : null);
const locationText = $derived(p.eventLocation?.text ?? null);
const tagSlugs = $derived( const tagSlugs = $derived(
(post.postTag ?? []).map((t) => (post.postTag ?? []).map((t) =>
@@ -50,16 +49,26 @@
); );
const hasTerminTag = $derived(tagSlugs.includes("tag-termin")); const hasTerminTag = $derived(tagSlugs.includes("tag-termin"));
const isPastEvent = $derived( const isPastEvent = $derived(
hasTerminTag && !!p.eventDate && new Date(p.eventDate).getTime() < Date.now(), hasTerminTag && !!eventDate && new Date(eventDate).getTime() < Date.now(),
); );
// „Neu"-Badge: Beitrag jünger als 7 Tage (nach Erstellung). Nicht bei
// vergangenen Terminen.
const NEW_MS = 7 * 24 * 60 * 60 * 1000;
const isNew = $derived.by(() => {
if (isPastEvent || !post.created) return false;
const ts = new Date(post.created).getTime();
if (Number.isNaN(ts)) return false;
return Date.now() - ts < NEW_MS;
});
</script> </script>
<Card <Card
{href} {href}
layout="post" layout="post"
class={isPastEvent ? "opacity-70 grayscale" : ""} class="{isPastEvent ? 'opacity-70 grayscale' : ''} {className}"
> >
<div class="relative aspect-3/2 w-full overflow-hidden bg-gray-100"> <div class="relative aspect-3/2 w-full overflow-hidden bg-stein-100">
{#if rawImg} {#if rawImg}
<RustyImage <RustyImage
src={rawImg} src={rawImg}
@@ -79,36 +88,50 @@
loading="lazy" loading="lazy"
/> />
{:else} {:else}
<div class="w-full h-full flex items-center justify-center text-gray-300"> <div class="w-full h-full flex items-center justify-center text-stein-300">
<svg xmlns="http://www.w3.org/2000/svg" class="size-12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"> <svg xmlns="http://www.w3.org/2000/svg" class="size-12" 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"/> <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> </svg>
</div> </div>
{/if} {/if}
{#if eventDate} {#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} /> <EventBadges {eventDate} {locationText} />
</div> </div>
{/if} {/if}
{#if isPastEvent} {#if isPastEvent}
<div class="absolute top-2 right-2"> <div class="absolute top-2 right-2">
<span class="inline-flex items-center gap-1 rounded-full bg-neutral-800/90 px-2.5 py-1 text-[.65rem] font-semibold uppercase tracking-wide text-white shadow-sm"> <Badge variant="solid" color="slate" uppercase class="shadow-sm">Vergangen</Badge>
Vergangen
</span>
</div> </div>
{/if} {/if}
{#if commentCount != null} <div class="absolute top-2 left-2 flex flex-col items-start gap-1">
<div class="absolute top-2 left-2"> {#if isNew}
<Badge
variant="solid"
color="green"
uppercase
class="inline-flex items-center gap-1 shadow-sm"
>
<Icon icon="lucide:sparkles" class="size-3" />
Neu
</Badge>
{/if}
{#if commentCount != null}
<span <span
class="inline-flex items-center gap-1 rounded-full bg-white/90 px-2 py-1 text-[.65rem] font-semibold tabular-nums text-zinc-700 shadow-sm"
aria-label={t(T.comments_count_aria, { count: commentCount })} aria-label={t(T.comments_count_aria, { count: commentCount })}
title={t(T.comments_count_aria, { count: commentCount })} title={t(T.comments_count_aria, { count: commentCount })}
> >
<Icon icon="mdi:comment-outline" class="size-3" /> <Badge
{commentCount} variant="solid"
color="white"
class="inline-flex items-center gap-1 tabular-nums shadow-sm"
>
<Icon icon="lucide:message-circle" class="size-3" />
{commentCount}
</Badge>
</span> </span>
</div> {/if}
{/if} </div>
</div> </div>
<div class="flex-1 flex flex-col justify-start p-4"> <div class="flex-1 flex flex-col justify-start p-4">
<PostMeta <PostMeta
@@ -122,7 +145,7 @@
</h5> </h5>
{#if post.subheadline} {#if post.subheadline}
<div class="text-xs line-clamp-2 mb-1"> <div class="text-xs line-clamp-2 mb-1">
{post.subheadline} {@html marked.parseInline(post.subheadline)}
</div> </div>
{/if} {/if}
{#if post.excerpt} {#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"> <script lang="ts">
import { slide } from "svelte/transition"; import { slide } from "svelte/transition";
import { tick } from "svelte"; import { tick } from "svelte";
import { goto } from "$app/navigation";
import Icon from "@iconify/svelte"; import Icon from "@iconify/svelte";
import "$lib/iconify-offline"; import "$lib/iconify-offline";
import SearchField from "$lib/ui/SearchField.svelte";
type SearchHit = { type SearchHit = {
type: "page" | "post"; type: "page" | "post";
@@ -25,18 +27,72 @@
let loadError = $state<string | null>(null); let loadError = $state<string | null>(null);
let inputEl = $state<HTMLInputElement | null>(null); let inputEl = $state<HTMLInputElement | null>(null);
const MAX_RESULTS = 40;
const normalizedQuery = $derived(query.trim().toLowerCase()); 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[]>(() => { const results = $derived.by<SearchHit[]>(() => {
if (!allHits || normalizedQuery.length < 2) return []; if (!allHits || normalizedQuery.length < 2) return [];
const q = normalizedQuery; const scored: { hit: SearchHit; score: number }[] = [];
return allHits for (const h of allHits) {
.filter((h) => { const title = h.title.toLowerCase();
const hay = `${h.title} ${h.subtitle} ${h.excerpt} ${h.slug}`.toLowerCase(); const subtitle = h.subtitle.toLowerCase();
return hay.includes(q); const excerpt = h.excerpt.toLowerCase();
}) const slug = h.slug.toLowerCase();
.slice(0, 40); 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() { async function loadIndex() {
if (allHits || loading) return; if (allHits || loading) return;
loading = true; loading = true;
@@ -70,7 +126,26 @@
void loadIndex(); void loadIndex();
tick().then(() => inputEl?.focus()); tick().then(() => inputEl?.focus());
const onKey = (e: KeyboardEvent) => { 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); window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey);
@@ -80,79 +155,74 @@
{#if open} {#if open}
<div <div
id="search-panel" 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" role="region"
aria-label="Suche" aria-label="Suche"
in:slide={{ duration: 200 }} in:slide={{ duration: 200 }}
out:slide={{ duration: 150 }} out:slide={{ duration: 150 }}
> >
<div class="container-custom py-4 overflow-auto max-h-[calc(100vh-120px)]"> <div class="container-custom pt-4 pb-[calc(2rem+env(safe-area-inset-bottom))]">
<label class="relative block max-w-xl mx-auto"> <SearchField
<span class="sr-only">Suche</span> bind:value={query}
<span class="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-zinc-500"> bind:inputEl
<Icon icon="mdi:magnify" class="h-5 w-5" /> tone="dark"
</span> placeholder="Seiten und Beiträge durchsuchen…"
<input class="max-w-xl mx-auto"
bind:this={inputEl} />
type="search"
bind:value={query}
placeholder="Seiten und Beiträge durchsuchen…"
class="w-full rounded-sm border border-zinc-300 bg-white py-3 pl-10 pr-10 text-sm focus:border-zinc-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-sm p-1 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900"
>
<Icon icon="mdi:close" class="h-4 w-4" />
</button>
{/if}
</label>
<div class="max-w-xl mx-auto mt-4 text-sm"> <div class="max-w-xl mx-auto mt-4 text-sm">
<p class="sr-only" role="status" aria-live="polite">
{#if normalizedQuery.length >= 2 && !loading && !loadError}
{results.length === 0 ? "Keine Treffer." : `${results.length} Treffer.`}
{/if}
</p>
{#if loading} {#if loading}
<p class="text-zinc-500 text-center py-2">Lade Suchindex…</p> <p class="text-stein-300 text-center py-2">Lade Suchindex…</p>
{:else if loadError} {:else if loadError}
<p class="text-red-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} {:else if normalizedQuery.length < 2}
<p class="text-zinc-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} {:else if results.length === 0}
<p class="text-zinc-500 text-center py-2">Keine Treffer.</p> <p class="text-stein-300 text-center py-2">Keine Treffer.</p>
{:else} {:else}
<ul class="divide-y divide-zinc-200"> <p class="text-stein-400 text-xs mb-2">
{#each results as hit} {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> <li>
<a <a
bind:this={itemEls[i]}
href={hit.href} href={hit.href}
onclick={closeSearch} onclick={closeSearch}
class="flex items-start gap-3 py-3 hover:bg-zinc-50 rounded-sm 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} {#if hit.image}
<img <img
src={hit.image} src={hit.image}
alt="" alt=""
class="w-20 h-12 object-cover shrink-0 rounded-sm border border-zinc-200" class="w-20 h-12 object-cover shrink-0 rounded-xs border border-stein-0/10"
loading="lazy" loading="lazy"
/> />
{:else} {:else}
<span class="w-20 h-12 shrink-0 bg-zinc-100 rounded-sm border border-zinc-200 flex items-center justify-center text-zinc-400"> <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" ? "mdi:file-document-outline" : "mdi:file-outline"} class="h-5 w-5" /> <Icon icon={hit.type === "post" ? "lucide:file-text" : "lucide:file"} class="h-5 w-5" />
</span> </span>
{/if} {/if}
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex items-center gap-2 text-[.65rem] uppercase tracking-wide text-zinc-500"> <div class="flex items-center gap-2 text-[.65rem] uppercase tracking-wide text-stein-300">
<span>{hit.type === "post" ? "Beitrag" : "Seite"}</span> <span>{hit.type === "post" ? "Beitrag" : "Seite"}</span>
{#if hit.isEvent && hit.eventDate} {#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} {:else if hit.created}
<span>{formatDate(hit.created)}</span> <span>{formatDate(hit.created)}</span>
{/if} {/if}
</div> </div>
<div class="font-medium text-zinc-900 truncate">{hit.title}</div> <div class="font-medium text-stein-0 truncate">{@html highlight(hit.title)}</div>
{#if hit.excerpt} {#if hit.excerpt}
<div class="text-xs text-zinc-600 line-clamp-2">{hit.excerpt}</div> <div class="text-xs text-stein-300 line-clamp-2">{@html highlight(hit.excerpt)}</div>
{/if} {/if}
</div> </div>
</a> </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' } };
+3 -3
View File
@@ -54,7 +54,7 @@
} }
const baseClass = const baseClass =
"inline-flex shrink-0 items-center gap-1 whitespace-nowrap border-0 py-1 px-2.5 text-[.6rem] rounded-full transition-[opacity,filter] no-underline shadow-none outline-none ring-0"; "inline-flex shrink-0 items-center gap-1 whitespace-nowrap border-0 py-1 px-2.5 text-[.6rem] rounded-full transition-[opacity,filter] no-underline shadow-none outline-none focus-visible:ring-2 focus-visible:ring-wald-500 focus-visible:ring-offset-1";
const variantClass = $derived.by(() => { const variantClass = $derived.by(() => {
if (customColor?.trim()) { if (customColor?.trim()) {
return ""; return "";
@@ -62,7 +62,7 @@
if (variant === "white") if (variant === "white")
return "bg-stein-100 text-stein-800"; return "bg-stein-100 text-stein-800";
if (variant === "green") return "bg-wald-100 text-wald-800"; if (variant === "green") return "bg-wald-100 text-wald-800";
if (variant === "blue") return "bg-sky-100/90 text-sky-900"; if (variant === "blue") return "bg-himmel-100/90 text-himmel-900";
if (variant === "inactive") return "bg-stein-200 text-stein-500"; if (variant === "inactive") return "bg-stein-200 text-stein-500";
if (variant === "date") return "bg-wald-50 text-wald-800"; if (variant === "date") return "bg-wald-50 text-wald-800";
return "bg-stein-200 text-stein-500"; return "bg-stein-200 text-stein-500";
@@ -71,7 +71,7 @@
const c = customColor?.trim(); const c = customColor?.trim();
if (!c) return ""; if (!c) return "";
const light = isLightBackground(c); const light = isLightBackground(c);
const fg = light ? "#111827" : "#fafafa"; const fg = light ? "var(--color-stein-900)" : "var(--color-stein-50)";
return `background-color: ${c}; color: ${fg};`; return `background-color: ${c}; color: ${fg};`;
}); });
const interactiveClass = $derived.by(() => const interactiveClass = $derived.by(() =>
+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' },
};
+45 -38
View File
@@ -8,7 +8,7 @@
import type { FullwidthBannerEntry } from "$lib/cms"; import type { FullwidthBannerEntry } from "$lib/cms";
interface ResponsiveBannerImage { interface ResponsiveBannerImage {
sources: { type: string; srcset: string }[]; sources: { type: string; srcset: string; media?: string }[];
fallback: string; fallback: string;
width: number; width: number;
height: number; height: number;
@@ -68,6 +68,13 @@
subheadline?.trim() ? (marked.parseInline(subheadline) as string) : "", subheadline?.trim() ? (marked.parseInline(subheadline) as string) : "",
); );
const eyebrowLabel = "Windwiderstand Thüringen";
// Eyebrow überall — nur ausblenden, wenn er die Headline dupliziert (Home).
const showEyebrow = $derived(
(headline?.replace(/<[^>]*>/g, "").trim().toLowerCase() ?? "") !==
eyebrowLabel.toLowerCase(),
);
const hasResponsive = $derived( const hasResponsive = $derived(
responsive != null && responsive.fallback.trim().length > 0, responsive != null && responsive.fallback.trim().length > 0,
); );
@@ -79,47 +86,34 @@
const showBanner = $derived(hasImage || hasText || hasPageTitle); const showBanner = $derived(hasImage || hasText || hasPageTitle);
const altText = $derived(banner?.headline ?? headline ?? ""); const altText = $derived(banner?.headline ?? headline ?? "");
let imgEl = $state<HTMLImageElement | null>(null); // Kein JS-gesteuertes Fade-in: `opacity-0` im SSR-HTML hält das Banner bis
let imgLoaded = $state(false); // 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.
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;
});
});
</script> </script>
{#if showBanner} {#if showBanner}
<div class="w-full"> <div class="w-full">
{#if hasImage} {#if hasImage}
<div <div
class="w-full overflow-hidden relative flex justify-center items-center shadow-lg bg-zinc-200" class="w-full overflow-hidden relative flex justify-center items-center shadow-lg bg-stein-200"
style={wrapperStyle} style={wrapperStyle}
> >
{#if hasResponsive && responsive} {#if hasResponsive && responsive}
{#key responsive.fallback} {#key responsive.fallback}
<picture class="block w-full max-w-[1920px] mx-auto"> <picture class="block w-full max-w-[1920px] mx-auto">
{#each responsive.sources as s (s.type)} {#each responsive.sources as s (s.media ? `${s.type}|${s.media}` : s.type)}
<source type={s.type} srcset={s.srcset} sizes={sizes} /> <source type={s.type} media={s.media} srcset={s.srcset} sizes={sizes} />
{/each} {/each}
<img <img
src={responsive.fallback} src={responsive.fallback}
alt={altText} 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="w-full object-cover max-h-[400px] lg:max-h-[600px] aspect-square sm:aspect-video lg:aspect-24/9 text-transparent"
class:opacity-0={!imgLoaded}
class:opacity-100={imgLoaded}
width={responsive.width} width={responsive.width}
height={responsive.height} height={responsive.height}
style={imgStyle} style={imgStyle}
loading="eager" loading="eager"
fetchpriority="high" fetchpriority="high"
decoding="async" decoding="async"
bind:this={imgEl}
onload={() => (imgLoaded = true)}
/> />
</picture> </picture>
{/key} {/key}
@@ -128,44 +122,57 @@
<img <img
src={resolvedImages[0]} src={resolvedImages[0]}
alt={altText} 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="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"
class:opacity-0={!imgLoaded}
class:opacity-100={imgLoaded}
width={1920} width={1920}
height={600} height={600}
style={imgStyle} style={imgStyle}
loading="eager" loading="eager"
fetchpriority="high" fetchpriority="high"
onload={() => (imgLoaded = true)}
/> />
{/key} {/key}
{/if} {/if}
{#if hasPageTitle} {#if hasPageTitle}
<div class="absolute inset-0 flex items-center justify-start "> <!-- Verlaufs-Scrim: links dunkel -> rechts transparent, hält Text lesbar ohne das Foto zuzukleistern -->
<div class="container-custom mx-auto px-6 relative"> <div
{#if headlineHtml} class="absolute inset-0 bg-gradient-to-r from-black/80 via-black/55 to-transparent"
<h1 class="bg-red-400/70 inline-block py-1 px-2 -ml-2 backdrop-blur">{@html headlineHtml}</h1> aria-hidden="true"
{/if} ></div>
<div></div> <div class="absolute inset-0 flex items-center justify-start">
{#if subheadlineHtml} <div class="container-custom mx-auto px-6">
<h2 class="bg-white/50 inline-block py-1 px-2 -ml-2 backdrop-blur font-light! text-xl!">{@html subheadlineHtml}</h2> <div class="max-w-xl text-white">
{/if} {#if showEyebrow}
<span class="mb-3 inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.08em] text-wald-200">
<span class="h-px w-6 bg-wald-400"></span>
{eyebrowLabel}
</span>
{/if}
{#if headlineHtml}
<h1 class="text-4xl font-bold leading-[1.05] tracking-tight text-white! drop-shadow-sm lg:text-5xl">
{@html headlineHtml}
</h1>
{/if}
{#if subheadlineHtml}
<h2 class="mt-4 text-xl! font-normal! leading-snug text-stein-100! lg:text-2xl!">
{@html subheadlineHtml}
</h2>
{/if}
</div>
</div> </div>
</div> </div>
{/if} {/if}
</div> </div>
{:else if hasPageTitle} {:else if hasPageTitle}
<div class="border-b border-zinc-200 container mx-auto px-6 py-4"> <div class="border-b border-stein-200 container-custom py-4">
{#if headlineHtml} {#if headlineHtml}
<h1 class="text-2xl font-bold text-zinc-900">{@html headlineHtml}</h1> <h1 class="text-2xl font-bold text-stein-900">{@html headlineHtml}</h1>
{/if} {/if}
{#if subheadlineHtml} {#if subheadlineHtml}
<h2 class="text-lg text-zinc-600 mt-1">{@html subheadlineHtml}</h2> <h2 class="text-lg text-stein-600 mt-1">{@html subheadlineHtml}</h2>
{/if} {/if}
</div> </div>
{/if} {/if}
{#if hasText} {#if hasText}
<div class="w-full bg-green-700 text-white text-center py-2 px-4"> <div class="w-full bg-wald-700 text-white text-center py-2 px-4">
<div class="container mx-auto"> <div class="container mx-auto">
<div class="text-sm font-medium">{banner!.text}</div> <div class="text-sm font-medium">{banner!.text}</div>
</div> </div>
@@ -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: () => {},
},
};
+289
View File
@@ -0,0 +1,289 @@
<script lang="ts">
import Icon from "@iconify/svelte";
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,
center,
cmsBase,
onclose,
}: {
area: WindArea | null;
center: [number, number] | null;
cmsBase: string;
onclose: () => void;
} = $props();
const t = useTranslate();
const STATUS_KEY: Record<string, string> = {
rechtskraeftig: T.windkarte_status_rechtskraeftig,
entwurf_2: T.windkarte_status_entwurf_2,
entwurf_3: T.windkarte_status_entwurf_3,
};
const STATUS_CLASS: Record<string, string> = {
rechtskraeftig: "bg-wald-100 text-wald-800",
entwurf_2: "bg-erde-100 text-erde-800",
entwurf_3: "bg-himmel-100 text-himmel-800",
};
function stellungnahmeHref(s: WindArea["stellungnahme"]): string | null {
if (!s) return null;
if (typeof s === "string") return `/post/${s}`;
if (s._slug) return `/post/${s._slug}`;
return null;
}
let kriterien = $state<TextFragment[]>([]);
let loadingKriterien = $state(false);
let expandedSlug = $state<string | null>(null);
$effect(() => {
const slugs = (area?.stellungnahme_kriterien ?? [])
.map((k) => (typeof k === "string" ? k : k._slug))
.filter(Boolean);
kriterien = [];
expandedSlug = null;
if (slugs.length === 0) return;
loadingKriterien = true;
Promise.all(
slugs.map((slug) =>
fetch(`${cmsBase}/api/content/text_fragment/${slug}`)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
),
).then((results) => {
kriterien = results.filter(Boolean) as TextFragment[];
loadingKriterien = false;
});
});
let copied = $state(false);
async function copyLink() {
await navigator.clipboard.writeText(location.href);
copied = true;
setTimeout(() => (copied = false), 2000);
}
function osmHref(c: [number, number] | null): string | null {
if (!c) return null;
return `https://www.openstreetmap.org/?mlat=${c[0].toFixed(5)}&mlon=${c[1].toFixed(5)}#map=14/${c[0].toFixed(5)}/${c[1].toFixed(5)}`;
}
const exportInfo = $derived(
area
? [
`${area.gebiets_nr}${area.bezeichnung ? ` ${area.bezeichnung}` : ''}`,
area.flaeche_ha != null ? `${t(T.windkarte_label_flaeche)}: ${area.flaeche_ha.toFixed(1)} ha` : null,
]
.filter(Boolean)
.join('\n')
: ''
);
</script>
{#if area}
<div class="pointer-events-auto flex h-full flex-col overflow-y-auto bg-stein-0 shadow-xl">
<!-- Header -->
<div class="flex items-start justify-between gap-3 border-b border-stein-200 px-5 py-4">
<div class="min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-xl font-semibold text-stein-800">{area.gebiets_nr}</span>
{#if area.status && area.status !== 'rechtskraeftig'}
<span class="rounded-full px-2 py-0.5 text-xs font-medium {STATUS_CLASS[area.status] ?? 'bg-stein-100 text-stein-600'}">
{t(STATUS_KEY[area.status] ?? area.status)}
</span>
{/if}
</div>
{#if area.bezeichnung}
<p class="mt-0.5 text-sm text-stein-500">{area.bezeichnung}</p>
{/if}
</div>
<button
onclick={onclose}
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="lucide:x" class="size-5" />
</button>
</div>
<!-- Body -->
<div class="flex-1 space-y-5 px-5 py-4 text-sm">
<!-- Kenndaten -->
<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="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="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="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 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>
{/each}
</div>
</div>
{/if}
<!-- Notizen -->
{#if area.notizen}
<div>
<p class="mb-1.5 text-xs font-medium uppercase tracking-wide text-stein-500">{t(T.windkarte_label_notizen)}</p>
<div class="text-stein-700 leading-relaxed markdown prose prose-sm prose-zinc max-w-none">{@html area.notizen}</div>
</div>
{/if}
<!-- Stellungnahme-Kriterien -->
{#if loadingKriterien}
<div class="flex items-center gap-2 text-xs text-stein-400">
<Icon icon="lucide:loader-circle" class="size-3.5 animate-spin" />
<span>{t(T.windkarte_kriterien_loading)}</span>
</div>
{:else if kriterien.length > 0}
<div>
<p class="mb-2 text-xs font-medium uppercase tracking-wide text-stein-500">{t(T.windkarte_label_kriterien)}</p>
<div class="divide-y divide-stein-100 rounded-lg border border-stein-100 overflow-hidden">
{#each kriterien as k (k._slug)}
<div>
<button
onclick={() => { expandedSlug = expandedSlug === k._slug ? null : k._slug; }}
class="flex w-full items-start justify-between gap-2 px-3 py-2 text-left text-xs font-medium text-stein-700 hover:bg-stein-50 transition-colors"
>
<span class="min-w-0 line-clamp-2">{k.title ?? k._slug}</span>
<Icon
icon={expandedSlug === k._slug ? "lucide:chevron-up" : "lucide:chevron-down"}
class="size-3.5 shrink-0 mt-0.5 text-stein-400"
/>
</button>
{#if expandedSlug === k._slug && k.text}
<div class="px-3 pb-3 pt-1 text-xs text-stein-700 leading-relaxed markdown prose prose-xs prose-zinc max-w-none border-t border-stein-100">
{@html k.text}
</div>
{/if}
</div>
{/each}
</div>
</div>
{/if}
<!-- 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="lucide:file-pen" class="size-4 shrink-0" />
{t(T.windkarte_stellungnahme_link)}
</a>
{/if}
<!-- Actions -->
<div class="flex gap-2 pt-1">
<button
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 ? "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)}
<a
href={osmHref(center)}
target="_blank"
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="lucide:map" class="size-3.5 shrink-0" />
{t(T.windkarte_action_osm)}
</a>
{/if}
</div>
<!-- Export-Downloads -->
<div>
<p class="mb-2 text-xs font-medium uppercase tracking-wide text-stein-500">{t(T.windkarte_label_geodaten_export, { nr: area.gebiets_nr })}</p>
<div class="flex gap-2">
<a
href="/api/vorranggebiete.geojson?id={encodeURIComponent(area.gebiets_nr)}"
download
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="lucide:download" class="size-3.5 shrink-0" />
GeoJSON
</a>
<a
href="/api/vorranggebiete.kml?id={encodeURIComponent(area.gebiets_nr)}"
download
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="lucide:download" class="size-3.5 shrink-0" />
KML
</a>
</div>
</div>
</div>
</div>
{/if}
+436
View File
@@ -0,0 +1,436 @@
<script lang="ts">
import "leaflet/dist/leaflet.css";
import { onMount, onDestroy } from "svelte";
import type { WindArea } from "$lib/windkarte";
import { useTranslate, T } from "$lib/translations";
let {
areas,
onselect,
onready = null,
initialGebietsNr = null,
hiddenStatuses = [],
hiddenRings = [],
fitBoundsMaxZoom = 11,
fitBoundsPadding = null,
}: {
areas: WindArea[];
onselect: (area: WindArea | null, center: [number, number] | null) => void;
onready?: ((ctrl: { reset: () => void }) => void) | null;
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();
// Mirror prop into plain variable so Leaflet event handlers read current value.
let _hidden: string[] = [];
$effect(() => {
_hidden = hiddenStatuses;
syncLabelsFn?.();
if (!geojsonLayer) return;
restyleAll(selectedSlug);
// Deselect if selected area's status was just hidden.
if (selectedSlug) {
geojsonLayer.eachLayer((l) => {
const f = (l as import("leaflet").Path & { feature?: GeoJSON.Feature }).feature;
if (f?.properties?.name === selectedSlug) {
const s = f.properties?.status ?? "rechtskraeftig";
if (_hidden.includes(s)) {
selectedSlug = null;
onselect(null, null);
bufferLayer?.clearLayers();
if (ring2kmLayer && map?.hasLayer(ring2kmLayer)) map.removeLayer(ring2kmLayer);
if (ring5kmLayer && map?.hasLayer(ring5kmLayer)) map.removeLayer(ring5kmLayer);
}
}
});
}
});
let mapEl: HTMLDivElement;
let map: import("leaflet").Map | null = null;
let bufferLayer: import("leaflet").LayerGroup | null = null;
let ring2kmLayer: import("leaflet").LayerGroup | null = null;
let ring5kmLayer: 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);
let _hiddenRings: string[] = [];
$effect(() => {
_hiddenRings = hiddenRings;
if (!map) return;
for (const [key, layer] of [["2km", ring2kmLayer], ["5km", ring5kmLayer]] as [string, import("leaflet").LayerGroup | null][]) {
if (!layer) continue;
if (_hiddenRings.includes(key)) {
if (map.hasLayer(layer)) map.removeLayer(layer);
} else {
if (!map.hasLayer(layer)) map.addLayer(layer);
}
}
});
const COLORS: Record<string, { fill: string; stroke: string }> = {
rechtskraeftig: { fill: "#2d7a45", stroke: "#1a4d28" },
entwurf_2: { fill: "#b08a52", stroke: "#6e5530" },
entwurf_2_voraussichtlich:{ fill: "#d4752a", stroke: "#8a4a18" },
entwurf_3: { fill: "#436e85", stroke: "#264150" },
};
function style(status: string, selected: boolean, hovered = false) {
const c = COLORS[status] ?? COLORS.rechtskraeftig;
return {
color: c.stroke,
fillColor: c.fill,
weight: selected ? 2.5 : 1.5,
fillOpacity: selected ? 0.55 : hovered ? 0.42 : 0.25,
opacity: 1,
};
}
function restyleAll(sel: string | null) {
geojsonLayer?.eachLayer((l) => {
const f = (l as import("leaflet").Path & { feature?: GeoJSON.Feature }).feature;
if (!f) return;
const s = f.properties?.status ?? "rechtskraeftig";
if (_hidden.includes(s)) {
(l as import("leaflet").Path).setStyle({ opacity: 0, fillOpacity: 0, weight: 0 });
} else {
(l as import("leaflet").Path).setStyle(style(s, f.properties?.name === sel));
}
});
}
async function drawBuffer(L: typeof import("leaflet"), feature: GeoJSON.Feature) {
buffering = true;
const { buffer } = await import("@turf/buffer");
if (bufferLayer) bufferLayer.clearLayers();
else bufferLayer = L.layerGroup().addTo(map!);
for (const { m, color, dash } of [
{ m: 200, color: "#e35651", dash: "4 4" },
{ m: 600, color: "#b08a52", dash: "6 3" },
{ m: 1000, color: "#436e85", dash: "8 4" },
]) {
const buffered = buffer(feature, m / 1000, { units: "kilometers" });
if (!buffered) continue;
L.geoJSON(buffered, {
style: { color, fillColor: color, fillOpacity: 0.05, weight: 1.5, dashArray: dash },
}).addTo(bufferLayer!);
}
// 2km / 5km — always compute on selection, show/hide controlled by hiddenRings toggle
if (!ring2kmLayer) ring2kmLayer = L.layerGroup();
else ring2kmLayer.clearLayers();
if (!ring5kmLayer) ring5kmLayer = L.layerGroup();
else ring5kmLayer.clearLayers();
const b2 = buffer(feature, 2, { units: "kilometers" });
if (b2) L.geoJSON(b2, { style: { color: "#5e6fa3", fillColor: "#5e6fa3", fillOpacity: 0.05, weight: 1.5, dashArray: "10 5" } }).addTo(ring2kmLayer);
const b5 = buffer(feature, 5, { units: "kilometers" });
if (b5) L.geoJSON(b5, { style: { color: "#8a567a", fillColor: "#8a567a", fillOpacity: 0.05, weight: 1.5, dashArray: "14 6" } }).addTo(ring5kmLayer);
if (!_hiddenRings.includes("2km") && !map!.hasLayer(ring2kmLayer)) map!.addLayer(ring2kmLayer);
if (!_hiddenRings.includes("5km") && !map!.hasLayer(ring5kmLayer)) map!.addLayer(ring5kmLayer);
buffering = false;
}
async function selectFeature(
L: typeof import("leaflet"),
feature: GeoJSON.Feature,
layer: import("leaflet").Layer,
cmsArea: WindArea | null,
zoom = true
) {
const name = feature.properties?.name;
const prev = selectedSlug;
selectedSlug = name ?? null;
restyleAll(selectedSlug);
const bounds = (layer as import("leaflet").Polygon).getBounds();
const c = bounds.getCenter();
const center: [number, number] = [c.lat, c.lng];
if (cmsArea) {
onselect(cmsArea, center);
await drawBuffer(L, feature);
} else {
onselect(null, null);
bufferLayer?.clearLayers();
if (ring2kmLayer && map!.hasLayer(ring2kmLayer)) map!.removeLayer(ring2kmLayer);
if (ring5kmLayer && map!.hasLayer(ring5kmLayer)) map!.removeLayer(ring5kmLayer);
}
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(), {
...pad,
maxZoom: fitBoundsMaxZoom,
});
}
}
onMount(async () => {
const L = await import("leaflet");
const iconUrl = new URL("leaflet/dist/images/marker-icon.png", import.meta.url).href;
const icon2xUrl = new URL("leaflet/dist/images/marker-icon-2x.png", import.meta.url).href;
const shadowUrl = new URL("leaflet/dist/images/marker-shadow.png", import.meta.url).href;
L.Icon.Default.mergeOptions({ iconUrl, iconRetinaUrl: icon2xUrl, shadowUrl });
map = L.map(mapEl, { zoomControl: true, attributionControl: false }).setView([50.62, 10.42], 10);
L.control.attribution({ position: "bottomleft" }).addTo(map);
// 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", {
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>-Mitwirkende | Geodaten: © GDI-Th, dl-de/by-2-0',
maxZoom: 19,
}).addTo(map);
const byNr = Object.fromEntries(areas.map((a) => [a.gebiets_nr, a]));
// Deselect on background map click
map.on("click", () => {
if (!selectedSlug) return;
selectedSlug = null;
restyleAll(null);
onselect(null, null);
bufferLayer?.clearLayers();
if (ring2kmLayer && map!.hasLayer(ring2kmLayer)) map!.removeLayer(ring2kmLayer);
if (ring5kmLayer && map!.hasLayer(ring5kmLayer)) map!.removeLayer(ring5kmLayer);
});
try {
// Build FeatureCollection directly from CMS areas (geometry embedded)
const geo: GeoJSON.FeatureCollection = {
type: "FeatureCollection",
features: areas
.filter((a) => a.geometry)
.map((a) => ({
type: "Feature" as const,
properties: {
name: a.gebiets_nr,
bezeichnung: a.bezeichnung,
status: a.status ?? "rechtskraeftig",
},
geometry: a.geometry!,
})),
};
const layerMap = new Map<string, { feature: GeoJSON.Feature; layer: import("leaflet").Layer }>();
geojsonLayer = L.geoJSON(geo, {
style: (f) => {
const s = f?.properties?.status ?? "rechtskraeftig";
return style(s, false);
},
onEachFeature: (feature, layer) => {
const p = feature.properties ?? {};
const area = byNr[p.name] ?? { _slug: "", gebiets_nr: p.name ?? "", status: p.status, bezeichnung: p.bezeichnung };
feature.properties.cms = area;
const name = feature.properties?.name ?? "";
layerMap.set(name, { feature, layer });
// Hover highlight
layer.on("mouseover", () => {
const s = feature.properties?.status ?? "rechtskraeftig";
if (_hidden.includes(s)) return;
if (feature.properties?.name !== selectedSlug) {
(layer as import("leaflet").Path).setStyle(style(s, false, true));
}
});
layer.on("mouseout", () => {
const s = feature.properties?.status ?? "rechtskraeftig";
if (_hidden.includes(s)) return;
if (feature.properties?.name !== selectedSlug) {
(layer as import("leaflet").Path).setStyle(style(s, false, false));
}
});
layer.on("click", async (e) => {
const s = feature.properties?.status ?? "rechtskraeftig";
if (_hidden.includes(s)) return;
L.DomEvent.stopPropagation(e);
await selectFeature(L, feature, layer, area);
});
// 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 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="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;
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({
html: `<span class="windkarte-label">${nr}</span>`,
className: "windkarte-label-wrap",
iconSize: [0, 0],
iconAnchor: [0, 0],
}),
interactive: false,
keyboard: false,
}).addTo(targetGroup);
}
},
}).addTo(map);
restyleAll(selectedSlug);
// Labels: show from zoom 13, scale font proportionally with zoom.
// Per-status LayerGroup so hidden statuses also hide their labels.
const syncLabels = () => {
if (!map) return;
const z = map.getZoom();
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();
// Auto-select from hash
if (initialGebietsNr) {
const entry = layerMap.get(initialGebietsNr);
if (entry) {
await selectFeature(L, entry.feature, entry.layer, byNr[initialGebietsNr] ?? null);
}
}
} catch (e) {
console.error("Karte laden fehlgeschlagen:", e);
}
});
onDestroy(() => {
map?.remove();
map = null;
});
</script>
<div bind:this={mapEl} class="h-full w-full"></div>
{#if buffering}
<div class="pointer-events-none absolute inset-0 flex items-center justify-center z-[600]">
<div class="rounded-full bg-white/90 p-2.5 shadow-md">
<svg class="size-5 animate-spin text-stein-500" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4l3-3-3-3v4a8 8 0 00-8 8h4z"/>
</svg>
</div>
</div>
{/if}
<style>
:global(.windkarte-tooltip) {
background: white;
border: 1px solid #d5d8d6;
border-radius: 6px;
box-shadow: 0 1px 4px rgba(0,0,0,0.15);
color: #333735;
font-size: 13px;
font-weight: 500;
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;
box-shadow: none !important;
}
:global(.windkarte-label) {
background: rgba(255, 255, 255, 0.6);
opacity: 0.75;
border-radius: 3px;
box-shadow: 0 1px 2px rgba(0,0,0,0.12);
color: #1a1a1a;
display: inline-block;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.02em;
padding: 1px 4px;
pointer-events: none;
position: absolute;
transform: translate(-50%, -50%);
white-space: nowrap;
}
</style>
@@ -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,149 +1,149 @@
<script lang="ts"> <script lang="ts">
import { getBlockLayoutClasses } from "$lib/block-layout"; import { getBlockLayoutClasses } from "$lib/block-layout";
import type { DeadlineBannerBlockData } from "$lib/block-types"; import type { DeadlineBannerBlockData } from "$lib/block-types";
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
let { block }: { block: DeadlineBannerBlockData } = $props(); let { block }: { block: DeadlineBannerBlockData } = $props();
const layoutClasses = $derived(getBlockLayoutClasses(block.layout)); const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const variant = $derived(block.variant ?? "accent"); const showCountdown = $derived(block.showCountdown !== false);
const showCountdown = $derived(block.showCountdown !== false);
type ResolvedItem = { // Look wie AnnouncementBanner (kompakte, zentrierte Leiste) — nur farbig.
title?: string; // "urgent" = rot, damit die Frist wirkt.
terminZeit?: string; const barClass = $derived.by(() => {
description?: string; switch (block.variant) {
link?: DeadlineBannerBlockData["link"]; case "accent":
}; return "bg-himmel-600";
case "info":
const autoItem = $derived.by((): ResolvedItem | null => { return "bg-wald-600";
if (block.mode !== "auto") return null; case "urgent":
const now = Date.now(); return "bg-fire-600";
const items = (block.items ?? []) default:
.filter( return "bg-stein-700";
(x): x is ResolvedItem => }
typeof x === "object" && x !== null && "terminZeit" in x,
)
.map((x): { x: ResolvedItem; ts: number } => ({ x, ts: Date.parse(x.terminZeit ?? "") }))
.filter(({ ts }) => !isNaN(ts) && ts >= now)
.sort((a, b) => a.ts - b.ts);
return items[0]?.x ?? null;
});
const text = $derived(
block.mode === "auto" ? autoItem?.title ?? "" : block.text ?? "",
);
const dateIso = $derived(
block.mode === "auto" ? autoItem?.terminZeit ?? "" : block.date ?? "",
);
const linkValue = $derived(
block.mode === "auto" ? autoItem?.link : block.link,
);
const dateObj = $derived.by(() => {
if (!dateIso) return null;
const d = new Date(dateIso);
return isNaN(d.getTime()) ? null : d;
});
const dateStr = $derived.by(() => {
if (!dateObj) return "";
return dateObj.toLocaleDateString("de-DE", {
day: "numeric",
month: "long",
}); });
});
const daysLeft = $derived.by(() => { type ResolvedItem = {
if (!dateObj) return null; title?: string;
const ms = dateObj.getTime() - Date.now(); terminZeit?: string;
if (ms < 0) return null; description?: string;
return Math.ceil(ms / (1000 * 60 * 60 * 24)); link?: DeadlineBannerBlockData["link"];
}); };
const countdownStr = $derived.by(() => { const autoItem = $derived.by((): ResolvedItem | null => {
if (!showCountdown || daysLeft == null) return ""; if (block.mode !== "auto") return null;
if (daysLeft === 0) return "Heute"; const now = Date.now();
if (daysLeft === 1) return "Morgen"; const items = (block.items ?? [])
return `Noch ${daysLeft} Tage`; .filter(
}); (x): x is ResolvedItem =>
typeof x === "object" && x !== null && "terminZeit" in x,
)
.map((x): { x: ResolvedItem; ts: number } => ({
x,
ts: Date.parse(x.terminZeit ?? ""),
}))
.filter(({ ts }) => !isNaN(ts) && ts >= now)
.sort((a, b) => a.ts - b.ts);
return items[0]?.x ?? null;
});
function getHref(link: DeadlineBannerBlockData["link"]): string { const text = $derived(
if (typeof link === "string" && link) return link; block.mode === "auto" ? (autoItem?.title ?? "") : (block.text ?? ""),
if (link && typeof link === "object" && link.url) return link.url; );
return ""; const dateIso = $derived(
} block.mode === "auto"
const href = $derived(getHref(linkValue)); ? (autoItem?.terminZeit ?? "")
const newTab = $derived( : (block.date ?? ""),
linkValue && typeof linkValue === "object" ? !!linkValue.newTab : false, );
); const linkValue = $derived(
block.mode === "auto" ? autoItem?.link : block.link,
);
const variantClasses = $derived( const dateObj = $derived.by(() => {
variant === "urgent" if (!dateIso) return null;
? "bg-amber-100 text-amber-900" const d = new Date(dateIso);
: variant === "info" return isNaN(d.getTime()) ? null : d;
? "bg-sky-100 text-sky-900" });
: "bg-wald-500 text-wald-50",
); const dateStr = $derived.by(() => {
const iconName = $derived( if (!dateObj) return "";
variant === "urgent" ? "mdi:alert-circle" : "mdi:calendar-clock", return dateObj.toLocaleDateString("de-DE", {
); day: "numeric",
const hasContent = $derived(!!(text || dateStr || countdownStr)); month: "long",
});
});
/** Kalendertage zwischen heute und Ziel — über Mitternacht-zu-
* Mitternacht-Diff, nicht roh per ms. Sonst zählt z. B. 30 h vor
* Termin als „2 Tage" obwohl der Termin morgen ist. */
const daysLeft = $derived.by(() => {
if (!dateObj) return null;
const today = new Date();
today.setHours(0, 0, 0, 0);
const target = new Date(dateObj);
target.setHours(0, 0, 0, 0);
const diffDays = Math.round(
(target.getTime() - today.getTime()) / (1000 * 60 * 60 * 24),
);
if (diffDays < 0) return null;
return diffDays;
});
const countdownStr = $derived.by(() => {
if (!showCountdown || daysLeft == null) return "";
if (daysLeft === 0) return "Heute";
if (daysLeft === 1) return "Morgen";
return `Noch ${daysLeft} Tage`;
});
function getHref(link: DeadlineBannerBlockData["link"]): string {
if (typeof link === "string" && link) return link;
if (link && typeof link === "object" && link.url) return link.url;
return "";
}
const href = $derived(getHref(linkValue));
const newTab = $derived(
linkValue && typeof linkValue === "object" ? !!linkValue.newTab : false,
);
const ctaLabel = $derived(
linkValue && typeof linkValue === "object" && linkValue.linkName
? linkValue.linkName
: "Mehr erfahren",
);
const sentence = $derived.by(() => {
const parts: string[] = [];
if (text) parts.push(text);
// 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> </script>
{#if hasContent} {#if hasContent}
<div <div
class="deadline-banner {layoutClasses}" class="deadline-banner {layoutClasses} w-full {barClass} text-white print:hidden"
data-block-type="deadline_banner" data-block="DeadlineBanner" data-block-type="deadline_banner"
data-block-slug={block._slug} data-block-slug={block._slug}
> role="note"
{#snippet inner()} style="font-size: 0.7rem;"
<div class="container-custom flex flex-wrap sm:flex-nowrap items-center gap-x-2 gap-y-1 px-4 py-2 text-xs sm:text-sm"> >
{#if dateStr} <div class="container-custom flex flex-wrap items-center justify-center gap-x-3 gap-y-1.5 py-2 text-center">
<span class="inline-flex items-center rounded-md bg-white/50 text-wald-900 px-2 py-0.5 text-sm sm:text-base font-bold whitespace-nowrap shrink-0"> {#if sentence}
<span class="inline-flex items-center gap-1.5"> <span>{sentence}</span>
<Icon icon={iconName} class="size-4 shrink-0" aria-hidden="true" />
{dateStr}
</span>
{#if countdownStr}
<span
class="ml-0 inline-flex min-w-0 max-w-0 scale-[0.96] items-center overflow-hidden whitespace-nowrap rounded-md border border-transparent py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide opacity-0 transition-[max-width,margin,opacity,transform,padding,border-color] duration-200 ease-out group-hover:ml-2 group-hover:max-w-48 group-hover:border-wald-900 group-hover:px-2 group-hover:opacity-100 group-hover:scale-100 group-focus-within:ml-2 group-focus-within:max-w-48 group-focus-within:border-wald-900 group-focus-within:px-2 group-focus-within:opacity-100 group-focus-within:scale-100"
>
{countdownStr}
</span>
{/if} {/if}
</span>
{:else}
<span class="inline-flex items-center gap-0 shrink-0">
<Icon icon={iconName} class="size-4 shrink-0" aria-hidden="true" />
{#if countdownStr} {#if countdownStr}
<span <span class="whitespace-nowrap rounded-full bg-black/20 px-2 py-0.5 font-bold">{countdownStr}</span>
class="ml-0 inline-flex min-w-0 max-w-0 shrink-0 scale-[0.96] items-center overflow-hidden whitespace-nowrap rounded-full px-0 py-0.5 text-xs font-semibold uppercase tracking-wide opacity-0 transition-[max-width,margin,opacity,transform,padding] duration-200 ease-out group-hover:ml-2 group-hover:max-w-48 group-hover:px-2 group-hover:opacity-100 group-hover:scale-100 group-focus-within:ml-2 group-focus-within:max-w-48 group-focus-within:px-2 group-focus-within:opacity-100 group-focus-within:scale-100"
>
{countdownStr}
</span>
{/if} {/if}
</span> {#if href}
{/if} <a
{#if text} {href}
<span class="min-w-0 wrap-break-word line-clamp-2 sm:line-clamp-1 font-semibold text-shadow-sm">{text}</span> target={newTab ? "_blank" : undefined}
{/if} rel={newTab ? "noopener noreferrer" : undefined}
</div> class="whitespace-nowrap font-semibold underline underline-offset-2 hover:no-underline"
{/snippet} >{ctaLabel}</a>
{#if href} {/if}
<a </div>
href={href} </div>
target={newTab ? "_blank" : undefined}
rel={newTab ? "noopener noreferrer" : undefined}
class="group block no-underline text-inherit hover:no-underline transition-all hover:brightness-95 {variantClasses}"
>
{@render inner()}
</a>
{:else}
<div class="group {variantClasses}">
{@render inner()}
</div>
{/if}
</div>
{/if} {/if}
@@ -0,0 +1,25 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import EventDateBox from './EventDateBox.svelte';
// Kleiner Datums-Kasten (Tag/Monat), genutzt von Kalender-Karten. Kein CMS-Block
// → als Molecule geführt. Nimmt echte Date-Objekte.
const meta = {
title: 'Molecules/EventDateBox',
component: EventDateBox,
tags: ['autodocs'],
} satisfies Meta<typeof EventDateBox>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Eintagestermin: Story = {
args: { start: new Date('2026-08-15T18:00:00') },
};
export const Mehrtagestermin: Story = {
args: {
start: new Date('2026-08-15T10:00:00'),
end: new Date('2026-08-17T16:00:00'),
multiDay: true,
},
};
@@ -0,0 +1,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',
},
],
},
},
};
+41 -78
View File
@@ -2,6 +2,7 @@
import Icon from "@iconify/svelte"; import Icon from "@iconify/svelte";
import "$lib/iconify-offline"; import "$lib/iconify-offline";
import { getBlockLayoutClasses } from "$lib/block-layout"; import { getBlockLayoutClasses } from "$lib/block-layout";
import SectionHeader from "$lib/components/SectionHeader.svelte";
import type { FilesBlockData } from "$lib/block-types"; import type { FilesBlockData } from "$lib/block-types";
import { import {
extractCmsFileField, extractCmsFileField,
@@ -11,6 +12,9 @@
} from "$lib/rusty-file"; } from "$lib/rusty-file";
let { block }: { block: FilesBlockData } = $props(); let { block }: { block: FilesBlockData } = $props();
const actionRef = $derived(
block.action && typeof block.action === "object" ? block.action : null,
);
const layoutClasses = $derived( const layoutClasses = $derived(
block.layout ? getBlockLayoutClasses(block.layout) : "col-span-12 mb-4", block.layout ? getBlockLayoutClasses(block.layout) : "col-span-12 mb-4",
@@ -21,66 +25,9 @@
label: string; label: string;
description?: string; description?: string;
sizeLabel: string | null; sizeLabel: string | null;
ext: string | null; extLabel: string;
iconName: string;
}; };
function iconForExt(ext: string | null): string {
switch (ext) {
case "pdf":
return "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( const resolved = $derived(
((block.items ?? []) as unknown[]) ((block.items ?? []) as unknown[])
.map((raw): Resolved | null => { .map((raw): Resolved | null => {
@@ -98,45 +45,61 @@
label, label,
description: field.description, description: field.description,
sizeLabel: formatFileSize(field.size), sizeLabel: formatFileSize(field.size),
ext, extLabel: ext ? ext.slice(0, 4).toUpperCase() : "FILE",
iconName: iconForExt(ext),
}; };
}) })
.filter((r): r is Resolved => r !== null), .filter((r): r is Resolved => r !== null),
); );
</script> </script>
<div class="files-block {layoutClasses}" data-block-type="files" data-block-slug={block._slug}> <div class="files-block {layoutClasses}" data-block="Files" data-block-type="files" data-block-slug={block._slug}>
{#if block.headline} {#if block.headline}
<h3 class="mb-3 text-lg font-semibold">{block.headline}</h3> <div class="mb-3">
{/if} <SectionHeader
{#if block.description} number={block.number}
<p class="mb-3 text-sm text-zinc-700">{block.description}</p> 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}
{#if resolved.length === 0} {#if resolved.length === 0}
<p class="text-sm text-zinc-500">Keine Dateien verfügbar.</p> <p class="text-sm text-stein-500">Keine Dateien verfügbar.</p>
{:else} {:else}
<ul class="flex flex-col gap-2 list-none! p-0 m-0! ml-0"> <ul class="flex flex-col gap-3 list-none! p-0 m-0! ml-0">
{#each resolved as f} {#each resolved as f}
<li class="list-none! ml-0"> <li class="list-none! ml-0">
<a <a
href={f.href} href={f.href}
class="group flex items-start gap-3 rounded-md border border-zinc-200 bg-white px-3 py-2 hover:border-zinc-400 hover:bg-zinc-50 transition-colors" class="group card-hover-lift flex items-center gap-3 rounded-xl border border-stein-200 bg-white px-3.5 py-2.5 no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-500 focus-visible:ring-offset-2"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<Icon icon={f.iconName} class="size-6 shrink-0 text-zinc-600" aria-hidden="true" /> <span
<span class="flex flex-col min-w-0"> class="shrink-0 size-9 flex items-center justify-center rounded-lg bg-fire-50 text-[10px] font-bold tracking-wide text-fire-700"
<span class="font-medium text-zinc-900 group-hover:underline truncate">{f.label}</span> >{f.extLabel}</span
{#if f.description} >
<span class="text-sm text-zinc-600">{f.description}</span> <span class="flex-1 min-w-0 flex flex-col">
{/if} <span class="flex items-baseline gap-1.5 min-w-0">
<span class="text-xs text-zinc-500"> <span class="text-sm text-stein-900 truncate">{f.label}</span>
{#if f.ext}<span class="uppercase">{f.ext}</span>{/if} {#if f.sizeLabel}
{#if f.ext && f.sizeLabel} · {/if} <span class="shrink-0 text-xs text-stein-400 tabular-nums">({f.sizeLabel})</span>
{#if f.sizeLabel}{f.sizeLabel}{/if} {/if}
</span> </span>
{#if f.description}
<span class="text-xs text-stein-400">{f.description}</span>
{/if}
</span> </span>
<Icon
icon="lucide:download"
class="size-5 shrink-0 text-stein-400 group-hover:text-stein-600 transition-colors"
aria-hidden="true"
/>
</a> </a>
</li> </li>
{/each} {/each}
@@ -0,0 +1,50 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import HeadlineBlock from './HeadlineBlock.svelte';
// CMS-Block in Isolation. `block` = die aufgelöste Block-Datenstruktur, wie sie
// der BlockRenderer von der API bekommt (siehe HeadlineBlockData in block-types.ts).
const meta = {
title: 'CMS-Blocks/Headline',
component: HeadlineBlock,
tags: ['autodocs'],
argTypes: {
block: { control: 'object' },
},
} satisfies Meta<typeof HeadlineBlock>;
export default meta;
type Story = StoryObj<typeof meta>;
export const H2: Story = {
args: {
block: { tag: 'h2', text: 'Windwiderstand im Steigerwald', align: 'left' },
},
};
export const Zentriert: Story = {
args: {
block: { tag: 'h1', text: 'Wald statt Windkraft', align: 'center' },
},
};
export const MitUmbruch: Story = {
args: {
block: {
tag: 'h2',
text: 'Erste Zeile\nZweite Zeile',
align: 'left',
},
},
};
// Sobald kicker/action gesetzt → Headline rendert als SectionHeader.
export const AlsSektionskopf: Story = {
args: {
block: {
tag: 'h2',
text: 'Wir sind keine Lobby,\nkeine Partei, keine NGO.',
kicker: 'Über uns',
action: { url: '/about', linkName: 'Mehr über uns' },
},
},
};

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