feature: chemnitz map
This commit is contained in:
parent
3e836bb016
commit
c0fe32f9f5
8 changed files with 431 additions and 0 deletions
40
src/components/ChemnitzMap/ChemnitzMap.stories.tsx
Normal file
40
src/components/ChemnitzMap/ChemnitzMap.stories.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import { ChemnitzMap } from './ChemnitzMap'
|
||||
|
||||
const meta: Meta<typeof ChemnitzMap> = {
|
||||
component: ChemnitzMap,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ width: '100%', maxWidth: 1100, margin: '0 auto' }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
type Story = StoryObj<typeof ChemnitzMap>
|
||||
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)
|
||||
},
|
||||
},
|
||||
}
|
||||
92
src/components/ChemnitzMap/ChemnitzMap.tsx
Normal file
92
src/components/ChemnitzMap/ChemnitzMap.tsx
Normal file
|
|
@ -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<ChemnitzMapChurch, string> = {
|
||||
'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<Record<ChemnitzMapChurch, string>>
|
||||
onChurchClick?: (church: ChemnitzMapChurch) => void
|
||||
fill?: boolean
|
||||
}
|
||||
|
||||
export const ChemnitzMap = ({ churchUrls, onChurchClick, fill = false }: ChemnitzMapProps) => {
|
||||
const router = useRouter()
|
||||
const rootRef = useRef<HTMLDivElement>(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<SVGGElement>(`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 (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={classNames(styles.map, { [styles.fill]: fill })}
|
||||
dangerouslySetInnerHTML={{ __html: chemnitzMapSvg }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChemnitzMap
|
||||
2
src/components/ChemnitzMap/chemnitzMapSvg.ts
Normal file
2
src/components/ChemnitzMap/chemnitzMapSvg.ts
Normal file
File diff suppressed because one or more lines are too long
1
src/components/ChemnitzMap/chemnitz_map.svg
Normal file
1
src/components/ChemnitzMap/chemnitz_map.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 103 KiB |
45
src/components/ChemnitzMap/styles.module.scss
Normal file
45
src/components/ChemnitzMap/styles.module.scss
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<typeof CollapsibleMapWithText> = {
|
||||
component: CollapsibleMapWithText,
|
||||
}
|
||||
|
||||
type Story = StoryObj<typeof CollapsibleMapWithText>
|
||||
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: (
|
||||
<>
|
||||
<Title title={'Über unsere Pfarrei'} size={'md'} />
|
||||
<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>
|
||||
</>
|
||||
),
|
||||
},
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
90
src/compositions/CollapsibleMapWithText/styles.module.scss
Normal file
90
src/compositions/CollapsibleMapWithText/styles.module.scss
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue