From c0fe32f9f536d7f343281e7239b99343f6f48c80 Mon Sep 17 00:00:00 2001 From: Benno Tielen Date: Thu, 23 Apr 2026 17:43:38 +0200 Subject: [PATCH] feature: chemnitz map --- .../ChemnitzMap/ChemnitzMap.stories.tsx | 40 ++++++ src/components/ChemnitzMap/ChemnitzMap.tsx | 92 +++++++++++++ src/components/ChemnitzMap/chemnitzMapSvg.ts | 2 + src/components/ChemnitzMap/chemnitz_map.svg | 1 + src/components/ChemnitzMap/styles.module.scss | 45 +++++++ .../CollapsibleMapWithText.stories.tsx | 38 ++++++ .../CollapsibleMapWithText.tsx | 123 ++++++++++++++++++ .../CollapsibleMapWithText/styles.module.scss | 90 +++++++++++++ 8 files changed, 431 insertions(+) create mode 100644 src/components/ChemnitzMap/ChemnitzMap.stories.tsx create mode 100644 src/components/ChemnitzMap/ChemnitzMap.tsx create mode 100644 src/components/ChemnitzMap/chemnitzMapSvg.ts create mode 100644 src/components/ChemnitzMap/chemnitz_map.svg create mode 100644 src/components/ChemnitzMap/styles.module.scss create mode 100644 src/compositions/CollapsibleMapWithText/CollapsibleMapWithText.stories.tsx create mode 100644 src/compositions/CollapsibleMapWithText/CollapsibleMapWithText.tsx create mode 100644 src/compositions/CollapsibleMapWithText/styles.module.scss diff --git a/src/components/ChemnitzMap/ChemnitzMap.stories.tsx b/src/components/ChemnitzMap/ChemnitzMap.stories.tsx new file mode 100644 index 0000000..fa40b4f --- /dev/null +++ b/src/components/ChemnitzMap/ChemnitzMap.stories.tsx @@ -0,0 +1,40 @@ +import { Meta, StoryObj } from '@storybook/nextjs-vite' +import { ChemnitzMap } from './ChemnitzMap' + +const meta: Meta = { + component: ChemnitzMap, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} + +type Story = StoryObj +export default meta + +export const Default: Story = {} + +export const WithClickHandler: Story = { + args: { + onChurchClick: (church) => { + // eslint-disable-next-line no-alert + alert(`Clicked: ${church.replace(/_/g, ' ')}`) + }, + }, +} + +export const CustomUrls: Story = { + args: { + churchUrls: { + 'St._Marien': '/custom/marien', + 'St._Joseph': '/custom/joseph', + }, + onChurchClick: (church) => { + // eslint-disable-next-line no-console + console.log('Would navigate for', church) + }, + }, +} diff --git a/src/components/ChemnitzMap/ChemnitzMap.tsx b/src/components/ChemnitzMap/ChemnitzMap.tsx new file mode 100644 index 0000000..4f338de --- /dev/null +++ b/src/components/ChemnitzMap/ChemnitzMap.tsx @@ -0,0 +1,92 @@ +'use client' + +import { useEffect, useRef } from 'react' +import { useRouter } from 'next/navigation' +import classNames from 'classnames' +import styles from './styles.module.scss' +import { chemnitzMapSvg } from './chemnitzMapSvg' + +export type ChemnitzMapChurch = + | 'St._Antonius_Frankenberg' + | 'St._Marien' + | 'St._Joseph' + | 'St._Johannes_Nepomuk' + | 'St._Antonius' + | 'St._Franziskus' + | 'Maria_Hilfe_der_Chrsiten' + +const DEFAULT_CHURCH_URLS: Record = { + 'St._Antonius_Frankenberg': '/gemeinde/st-antonius-frankenberg', + 'St._Marien': '/gemeinde/st-marien', + 'St._Joseph': '/gemeinde/st-joseph', + 'St._Johannes_Nepomuk': '/gemeinde/st-johannes-nepomuk', + 'St._Antonius': '/gemeinde/st-antonius', + 'St._Franziskus': '/gemeinde/st-franziskus', + 'Maria_Hilfe_der_Chrsiten': '/gemeinde/maria-hilfe-der-christen', +} + +const CHURCH_IDS = Object.keys(DEFAULT_CHURCH_URLS) as ChemnitzMapChurch[] + +export type ChemnitzMapProps = { + churchUrls?: Partial> + onChurchClick?: (church: ChemnitzMapChurch) => void + fill?: boolean +} + +export const ChemnitzMap = ({ churchUrls, onChurchClick, fill = false }: ChemnitzMapProps) => { + const router = useRouter() + const rootRef = useRef(null) + + useEffect(() => { + const root = rootRef.current + if (!root) return + + const urls = { ...DEFAULT_CHURCH_URLS, ...churchUrls } + const cleanups: Array<() => void> = [] + + for (const id of CHURCH_IDS) { + const group = root.querySelector(`g[id="${id}"]`) + if (!group) continue + + group.classList.add(styles.church) + group.setAttribute('role', 'link') + group.setAttribute('tabindex', '0') + group.setAttribute('aria-label', id.replace(/_/g, ' ')) + + const activate = () => { + if (onChurchClick) { + onChurchClick(id) + return + } + const href = urls[id] + if (href) router.push(href) + } + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + activate() + } + } + + group.addEventListener('click', activate) + group.addEventListener('keydown', onKeyDown) + cleanups.push(() => { + group.removeEventListener('click', activate) + group.removeEventListener('keydown', onKeyDown) + }) + } + + return () => cleanups.forEach((fn) => fn()) + }, [router, churchUrls, onChurchClick]) + + return ( +
+ ) +} + +export default ChemnitzMap diff --git a/src/components/ChemnitzMap/chemnitzMapSvg.ts b/src/components/ChemnitzMap/chemnitzMapSvg.ts new file mode 100644 index 0000000..c5798dd --- /dev/null +++ b/src/components/ChemnitzMap/chemnitzMapSvg.ts @@ -0,0 +1,2 @@ +// Generated from chemnitz_map.svg — do not edit by hand. +export const chemnitzMapSvg = ``; diff --git a/src/components/ChemnitzMap/chemnitz_map.svg b/src/components/ChemnitzMap/chemnitz_map.svg new file mode 100644 index 0000000..2d9c598 --- /dev/null +++ b/src/components/ChemnitzMap/chemnitz_map.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/ChemnitzMap/styles.module.scss b/src/components/ChemnitzMap/styles.module.scss new file mode 100644 index 0000000..8380449 --- /dev/null +++ b/src/components/ChemnitzMap/styles.module.scss @@ -0,0 +1,45 @@ +.map { + width: 100%; + + :global(svg) { + width: 100%; + height: auto; + display: block; + } +} + +.fill { + height: 100%; + + :global(svg) { + width: 100%; + height: 100%; + } +} + +.church { + cursor: pointer; + transform-box: fill-box; + transform-origin: center; + transition: transform 0.3s ease; + + :global(path.cls-1) { + transition: fill 0.3s ease; + } + + &:hover { + transform: scale(1.15); + + :global(path.cls-1) { + fill: #0f6898; + } + } + + &:focus-visible { + outline: none; + + :global(path.cls-1) { + fill: #0f6898; + } + } +} diff --git a/src/compositions/CollapsibleMapWithText/CollapsibleMapWithText.stories.tsx b/src/compositions/CollapsibleMapWithText/CollapsibleMapWithText.stories.tsx new file mode 100644 index 0000000..69f2db3 --- /dev/null +++ b/src/compositions/CollapsibleMapWithText/CollapsibleMapWithText.stories.tsx @@ -0,0 +1,38 @@ +import { Meta, StoryObj } from '@storybook/nextjs-vite' +import { CollapsibleMapWithText } from './CollapsibleMapWithText' +import { Title } from '@/components/Title/Title' +import { P } from '@/components/Text/Paragraph' + +const meta: Meta = { + component: CollapsibleMapWithText, +} + +type Story = StoryObj +export default meta + +export const OurParishes: Story = { + args: { + backgroundColor: 'soft', + schema: 'base', + title: 'Unsere Gemeinden', + text: + 'Unsere Pfarrei umfasst sieben Kirchen in und um Chemnitz. Klicken Sie auf eine Kirche, um mehr über die jeweilige Gemeinde zu erfahren – von Gottesdienstzeiten über Gruppen und Kreise bis hin zu Kontaktmöglichkeiten.\n' + + '\n' + + 'Jede Gemeinde hat ihren eigenen Charakter und ihre eigene Geschichte, gemeinsam bilden sie ein lebendiges katholisches Netzwerk in der Region.', + onChurchClick: (church) => { + // eslint-disable-next-line no-alert + alert(`Klick: ${church.replace(/_/g, ' ')}`) + }, + content: ( + <> + + <P width={'3/4'}> + Die Pfarrei wurde gegründet, um die pastorale Arbeit der umliegenden Kirchgemeinden + zu bündeln und gleichzeitig den lokalen Charakter jeder einzelnen Gemeinde zu + bewahren. Auf der Karte sehen Sie die geografische Verteilung – von St. Marien im + Stadtzentrum bis St. Antonius in Frankenberg. + </P> + </> + ), + }, +} diff --git a/src/compositions/CollapsibleMapWithText/CollapsibleMapWithText.tsx b/src/compositions/CollapsibleMapWithText/CollapsibleMapWithText.tsx new file mode 100644 index 0000000..fce3a66 --- /dev/null +++ b/src/compositions/CollapsibleMapWithText/CollapsibleMapWithText.tsx @@ -0,0 +1,123 @@ +'use client' + +import { BackgroundColor, Section } from '@/components/Section/Section' +import { Title } from '@/components/Title/Title' +import { Container } from '@/components/Container/Container' +import { TextDiv } from '@/components/Text/TextDiv' +import { Row } from '@/components/Flex/Row' +import { CollapsibleArrow } from '@/components/CollapsibleArrow/CollapsibleArrow' +import { ChemnitzMap, ChemnitzMapProps } from '@/components/ChemnitzMap/ChemnitzMap' +import React, { useEffect, useRef, useState } from 'react' +import classNames from 'classnames' +import styles from './styles.module.scss' + +type CollapsibleMapWithTextProps = { + backgroundColor?: BackgroundColor + title: string + text: string + schema?: 'base' | 'contrast' + content: React.ReactNode + churchUrls?: ChemnitzMapProps['churchUrls'] + onChurchClick?: ChemnitzMapProps['onChurchClick'] +} + +type MoreInformationProps = { + isCollapsed: boolean + onClick: () => void +} + +const MoreInformation = ({ isCollapsed, onClick }: MoreInformationProps) => { + const [direction, setDirection] = useState<'UP' | 'DOWN'>(isCollapsed ? 'DOWN' : 'UP') + + const toggleDirection = () => { + setDirection(direction === 'UP' ? 'DOWN' : 'UP') + } + + const handleClick = () => { + toggleDirection() + onClick() + } + + return ( + <button + onClick={handleClick} + className={styles.more} + onMouseEnter={toggleDirection} + onMouseLeave={toggleDirection} + > + Mehr erfahren <CollapsibleArrow direction={direction} /> + </button> + ) +} + +export const CollapsibleMapWithText = ({ + backgroundColor, + title, + text, + schema = 'base', + content, + churchUrls, + onChurchClick, +}: CollapsibleMapWithTextProps) => { + const ref = useRef<HTMLDivElement>(null) + const ref2 = useRef<HTMLDivElement>(null) + const [contentHeight, setContentHeight] = useState(0) + const [isCollapsed, setIsCollapsed] = useState(true) + + const collapse = () => { + setIsCollapsed(true) + ref.current?.scrollIntoView({ behavior: 'smooth' }) + } + + useEffect(() => { + if (ref2.current) { + setContentHeight(ref2.current.scrollHeight) + } + }, [ref2]) + + return ( + <div ref={ref} className={styles.root}> + <Section backgroundColor={backgroundColor}> + <Container> + <Row alignItems={'center'}> + <div className={classNames(styles.col, styles.imageCol)} /> + <div className={styles.col}> + <Title title={title} size={'lg'} color={schema} /> + + <TextDiv text={text} /> + + <div className={styles.right}> + <MoreInformation + isCollapsed={isCollapsed} + onClick={() => setIsCollapsed(!isCollapsed)} + /> + </div> + </div> + </Row> + </Container> + </Section> + + <div className={styles.mapCorner}> + <ChemnitzMap churchUrls={churchUrls} onChurchClick={onChurchClick} /> + </div> + + <div + ref={ref2} + className={styles.content} + style={{ maxHeight: isCollapsed ? undefined : contentHeight }} + > + <Section + backgroundColor={backgroundColor} + padding={'small'} + paddingBottom={'large'} + > + <Container>{content}</Container> + + <div className={styles.endButton}> + <CollapsibleArrow direction={'UP'} onClick={collapse} /> + </div> + </Section> + </div> + </div> + ) +} diff --git a/src/compositions/CollapsibleMapWithText/styles.module.scss b/src/compositions/CollapsibleMapWithText/styles.module.scss new file mode 100644 index 0000000..6895506 --- /dev/null +++ b/src/compositions/CollapsibleMapWithText/styles.module.scss @@ -0,0 +1,90 @@ +@import "template.scss"; + +.root { + position: relative; +} + +.mapCorner { + position: absolute; + top: 0; + left: 0; + width: 100%; + z-index: 0; + pointer-events: none; + opacity: 0.5; + + :global(svg) { + pointer-events: auto; + } +} + +.right { + margin-top: 40px; + text-align: right; + z-index: 3; +} + +.image { + border-radius: 13px; + transition: opacity 3s; + width: 100%; + height: 100%; +} + +.col { + width: calc(50% - 40px); +} + +.imageCol { + /* Empty column — space reserved for the map positioned in the upper-left corner. */ +} + +.more { + font-size: 18px; + color: $base-color; + cursor: pointer; + font-weight: bold; + display: inline-flex; + gap: 10px; + border: 0; + background-color: inherit; + align-items: center; +} + +.content { + transition: max-height 500ms ease-in; + max-height: 0; + overflow: hidden; +} + +.endButton { + position: relative; + top: 75px; + text-align: center; +} + +.endButton svg { + cursor: pointer; +} + +@media screen and (max-width: 750px) { + .imageCol { + display: none; + } + + .col { + width: 100%; + } + + .mapCorner { + position: static; + width: 70vw; + margin: 0 auto 40px; + } +} + +@media screen and (max-width: 576px) { + .endButton { + top: 20px; + } +}