Compare commits

..

12 commits

Author SHA1 Message Date
Benno Tielen
a12e460448 feature: different masses
Some checks failed
Deploy / deploy (push) Has been cancelled
2026-06-05 15:50:00 +02:00
Benno Tielen
d1c05bfeb8 feature: upgraded gallery 2026-06-05 14:49:54 +02:00
Benno Tielen
57a9a1c966 fix: build 2026-06-05 13:43:23 +02:00
Benno Tielen
1ac42e51db feature: display end times 2026-06-05 13:43:17 +02:00
Benno Tielen
5ee3d48434 feature: group events and end time 2026-06-05 13:19:27 +02:00
Benno Tielen
15a4db17c8 feature: image in richtext 2026-06-05 11:37:33 +02:00
Benno Tielen
14056701d0 fix: display empty circle when no photo is provided 2026-06-05 10:25:13 +02:00
Benno Tielen
a85c0e609a fix: internal link 2026-06-05 10:21:11 +02:00
Benno Tielen
776bc6ff25 fix: document in new tab 2026-06-05 09:47:25 +02:00
Benno Tielen
6b960aef19 feature: add magazine archive page 2026-06-03 13:37:51 +02:00
Benno Tielen
da56be94a3 feature: obfuscate email addresses for improved security 2026-06-03 11:44:01 +02:00
Benno Tielen
f175afaf19 feature: roles pdf 2026-06-02 16:23:35 +02:00
59 changed files with 105381 additions and 1478 deletions

BIN
rollen-berechtigungen.pdf Normal file

Binary file not shown.

View file

@ -0,0 +1,83 @@
import { PageHeader } from '@/compositions/PageHeader/PageHeader'
import { Container } from '@/components/Container/Container'
import { Section } from '@/components/Section/Section'
import { NextPrevButtons } from '@/components/NextPrevButtons/NextPrevButtons'
import { fetchMagazines } from '@/fetch/magazine'
import { siteConfig } from '@/config/site'
import { Media } from '@/payload-types'
import Image from 'next/image'
import styles from './styles.module.scss'
export const dynamic = 'force-dynamic'
type Props = {
searchParams: Promise<{ page?: string }>
}
export default async function MagazinArchivPage({ searchParams }: Props) {
const { page: pageParam } = await searchParams
const page = Math.max(1, parseInt(pageParam ?? '1', 10) || 1)
const result = await fetchMagazines(page)
return (
<>
<PageHeader
title={`${siteConfig.magazineName} Archiv`}
description={'Alle bisherigen Ausgaben unseres Pfarrei-Magazins auf einen Blick.'}
/>
<Section padding={'small'}>
<Container>
<div className={styles.grid}>
{result.docs.map((magazine) => {
const cover = typeof magazine.cover === 'object' ? (magazine.cover as Media) : null
const pdfUrl =
typeof magazine.document === 'object' ? magazine.document.url ?? null : null
if (!cover?.url || !pdfUrl) return null
return (
<a
key={magazine.id}
href={pdfUrl}
target="_blank"
rel="noopener noreferrer"
className={styles.item}
>
<Image
src={cover.url}
width={cover.width ?? 300}
height={cover.height ?? 400}
alt={`${siteConfig.magazineName}`}
unoptimized
className={styles.cover}
/>
</a>
)
})}
</div>
{result.docs.length === 0 && <p>Keine Ausgaben gefunden.</p>}
</Container>
</Section>
{(result.hasPrevPage || result.hasNextPage) && (
<Section padding={'small'}>
<NextPrevButtons
prev={
result.hasPrevPage && result.prevPage
? { href: `/pfarrei/magazin/archiv?page=${result.prevPage}`, text: 'Neuere Ausgaben' }
: undefined
}
next={
result.hasNextPage && result.nextPage
? { href: `/pfarrei/magazin/archiv?page=${result.nextPage}`, text: 'Ältere Ausgaben' }
: undefined
}
/>
</Section>
)}
</>
)
}

View file

@ -0,0 +1,23 @@
@import 'template.scss';
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 24px;
}
.item {
display: block;
cursor: pointer;
&:hover .cover {
transform: scale(1.03);
}
}
.cover {
width: 100%;
height: auto;
border: 8px solid $light-grey;
transition: transform 0.2s ease;
}

View file

@ -48,7 +48,8 @@ export default async function MagazinePage() {
Bleiben Sie auf dem Laufenden! Hier finden Sie die aktuelle Ausgabe mit allen Berichten und Terminen aus der Gemeinde.
<br/><br/>
</P>
<Button size={"lg"} target={"_blank"} href={magazine_url}>{siteConfig.magazineName} herunterladen</Button>
<Button size={"md"} schema={"shade"} href={`/pfarrei/magazin/archiv`}>Archiv</Button>
<Button size={"md"} target={"_blank"} href={magazine_url}>{siteConfig.magazineName} herunterladen</Button>
</Col>
</Row>
</Container>

View file

@ -114,6 +114,7 @@ export default async function EventsOverviewPage({
<EventRow
key={e.id}
date={e.date}
end={e.end}
title={e.title}
href={e.href}
location={e.location}

View file

@ -79,6 +79,7 @@ export default async function EventsPage({searchParams}: {
<EventRow
key={e.id}
date={e.date}
end={e.end}
title={e.title}
href={e.href}
location={e.location}

View file

@ -6,6 +6,7 @@ import { FixedToolbarFeatureClient as FixedToolbarFeatureClient_e70f5e05f09f93e0
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
@ -29,6 +30,7 @@ export const importMap = {
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UploadFeatureClient": UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,

View file

@ -61,6 +61,8 @@ export const Churches: CollectionConfig = {
{ label: 'Heilige Messe', value: 'MASS' },
{ label: 'Familien Messe', value: 'FAMILY' },
{ label: 'Wort-Gottes-Feier', value: 'WORD' },
{ label: 'Fremdsprache', value: 'LANGUAGE' },
{ label: 'Andere', value: 'OTHER' },
],
},
{

View file

@ -38,6 +38,21 @@ export const EventOccurrences: CollectionConfig = {
},
},
},
{
name: 'endDateTime',
type: 'date',
index: true,
label: {
de: 'Enduhrzeit',
},
admin: {
date: {
pickerAppearance: 'dayAndTime',
timeIntervals: 15,
timeFormat: 'HH:mm',
},
},
},
{
name: 'cancelled',
type: 'checkbox',

View file

@ -20,6 +20,7 @@ import { CollapsibleImageWithTextBlock } from '@/collections/blocks/CollapsibleI
import { CollapsibleMapWithTextBlock } from '@/collections/blocks/CollapsibleMapWithText'
import { CollapsiblesBlock } from '@/collections/blocks/Collapsibles'
import { EventsBlock } from '@/collections/blocks/Events'
import { GroupEventsBlock } from '@/collections/blocks/GroupEvents'
import { PublicationAndNewsletterBlock } from '@/collections/blocks/PublicationAndNewsletter'
import { ContactPersonBlock } from '@/collections/blocks/ContactPersonBlock'
import { ImageCardsBlock } from '@/collections/blocks/ImageCards'
@ -106,6 +107,7 @@ export const Pages: CollectionConfig = {
CollapsiblesBlock,
MassTimesBlock,
EventsBlock,
GroupEventsBlock,
ContactPersonBlock,
ImageCardsBlock,
ClassifiedsBlock,

View file

@ -44,7 +44,7 @@ export const Worship: CollectionConfig = {
type: 'radio',
options: [
{
label: 'Heilige Messe',
label: 'Eucharistiefeier',
value: 'MASS',
},
{
@ -55,6 +55,14 @@ export const Worship: CollectionConfig = {
label: 'Wort-Gottes-Feier',
value: 'WORD',
},
{
label: 'Fremdsprache',
value: 'LANGUAGE',
},
{
label: 'Andere',
value: 'OTHER',
},
],
required: true,
},

View file

@ -20,6 +20,13 @@ export const GalleryBlock: Block = {
relationTo: 'media',
required: true
},
{
name: 'caption',
label: {
de: 'Bildunterschrift'
},
type: 'text'
},
],
minRows: 3,
maxRows: 12

View file

@ -0,0 +1,24 @@
import { Block } from 'payload'
export const GroupEventsBlock: Block = {
slug: 'groupEvents',
labels: {
singular: {
de: 'Gruppen-Veranstaltungen',
},
plural: {
de: 'Gruppen-Veranstaltungen',
},
},
fields: [
{
name: 'group',
type: 'relationship',
relationTo: 'group',
required: true,
label: {
de: 'Gruppe',
},
},
],
}

View file

@ -1,6 +1,8 @@
import styles from "./styles.module.scss"
import { ContactPerson } from '@/payload-types'
import Image, { StaticImageData } from 'next/image'
import { SecureEmail } from '@/components/SecureEmail/SecureEmail'
import { extractEmailPartsEncoded } from '@/utils/dto/email'
type ContactPerson2Props = {
contact: null | string | undefined | ContactPerson
@ -12,6 +14,8 @@ export const ContactPerson2 = ({contact, photo}: ContactPerson2Props) => {
return "Unbekannt"
}
const { user, domain } = contact.email ? extractEmailPartsEncoded(contact.email) : { user: '', domain: '' }
return (
<div className={styles.contact}>
{ photo &&
@ -24,9 +28,9 @@ export const ContactPerson2 = ({contact, photo}: ContactPerson2Props) => {
}
<div>
{contact.name}
{contact.email &&
{user && domain &&
<>
<br/> <a href={`mailto:${contact.email}`} className={styles.hover}>{contact.email}</a>
<br/> <SecureEmail user={user} domain={domain} />
</>
}
{

View file

@ -17,7 +17,7 @@
color: inherit;
}
.hover:hover {
.contact a:hover {
text-decoration: underline;
}

View file

@ -1,6 +1,8 @@
import styles from './styles.module.scss'
import { ContactPerson } from '@/payload-types'
import Image, { StaticImageData } from 'next/image'
import { SecureEmail } from '@/components/SecureEmail/SecureEmail'
import { extractEmailPartsEncoded } from '@/utils/dto/email'
type ContactPersonCardProps = {
contact: null | string | undefined | ContactPerson
@ -19,10 +21,17 @@ export const ContactPersonCard = ({
return null
}
let user, domain = null;
if (contact.email) {
let data = extractEmailPartsEncoded(contact.email)
user = data.user;
domain = data.domain;
}
return (
<div className={styles.card}>
<div>
{photo && (
{photo ? (
<Image
className={styles.photo}
src={photo}
@ -31,6 +40,8 @@ export const ContactPersonCard = ({
height={200}
unoptimized={true}
/>
) : (
<div className={styles.photoPlaceholder} />
)}
</div>
@ -38,19 +49,20 @@ export const ContactPersonCard = ({
{contact.role && (
<h3 className={styles.role}>{contact.role}</h3>
)}
<div className={styles.info}>
<span className={styles.name}>{contact.name}</span>
{contact.email && (
<a href={`mailto:${contact.email}`} className={styles.link}>
{contact.email}
</a>
<div className={styles.name}>{contact.name}</div>
<div className={styles.phoneEmail}>
{user && domain && (
<span> <SecureEmail user={user} domain={domain} /></span>
)}
{contact.telephone && (
<a href={`tel:${contact.telephone}`} className={styles.link}>
<span>
📞 <a href={`tel:${contact.telephone}`}>
{contact.telephone}
</a>
</span>
)}
</div>
</div>
</div>
</div>

View file

@ -14,27 +14,50 @@
border-radius: 50%;
}
.photoPlaceholder {
width: 200px;
height: 200px;
border-radius: 50%;
background-color: #efefef;
}
.role {
font-size: 25px;
color: $base-color;
margin: 0;
}
.info {
.phoneEmail {
display: flex;
flex-direction: column;
gap: 4px;
gap: 15px;
}
.name {
font-weight: bold;
}
.link {
.phoneEmail a {
text-decoration: none;
color: inherit;
&:hover {
text-decoration: underline;
}
}
@media screen and (max-width: 576px) {
.role {
text-align: center;
}
.card {
flex-direction: column;
align-items: center;
gap: 10px;
}
.phoneEmail {
flex-direction: column;
gap: 0
}
}

View file

@ -18,6 +18,17 @@ export const EventInJanuary: Story = {
},
}
export const EventWithEnd: Story = {
args: {
date: '2024-01-06T15:00:00+01:00',
end: '2024-01-06T17:00:00+01:00',
title: 'Herz Jesu Feier',
href: 'https://www.herzJesuFeier.com',
location: "St. Clara",
cancelled: false,
},
}
export const EventInMarch: Story = {
args: {
date: '2024-03-24T15:00:00+01:00',

View file

@ -8,6 +8,7 @@ import Link from 'next/link'
export type EventRowProps = {
/** datetime 8601 format */
date: string,
end?: string,
showDate?: boolean
title: string,
href?: string,
@ -42,9 +43,10 @@ const shortMonth = (date: string) => {
export const EventRow = ({date, title, location, cancelled, href, color = "base", showDate = true}: EventRowProps) => {
export const EventRow = ({date, end, title, location, cancelled, href, color = "base", showDate = true}: EventRowProps) => {
const day = useMemo(() => date.substring(8, 10), [date]);
const dateObj = useMemo(() => new Date(date), [date]);
const endObj = useMemo(() => end ? new Date(end) : undefined, [end]);
const month = useMemo(() => shortMonth(date), [date]);
const [dayFormat, setDayFormat] = useState<"long" | "short">("long")
useEffect(() => {
@ -79,7 +81,9 @@ export const EventRow = ({date, title, location, cancelled, href, color = "base"
{ showDate &&
<>
{dateObj.toLocaleDateString("de-DE", { weekday: "long" })}
{dayFormat === "long" && " " + dateObj.toLocaleDateString("de-DE", { dateStyle: "medium" })}, {dateObj.toLocaleTimeString("de-DE", { timeStyle: "short", timeZone: "Europe/Berlin" })} Uhr
{dayFormat === "long" && " " + dateObj.toLocaleDateString("de-DE", { dateStyle: "short" })}, {dateObj.toLocaleTimeString("de-DE", { timeStyle: "short", timeZone: "Europe/Berlin" })}
{ endObj ? <> - {endObj.toLocaleTimeString("de-DE", { timeStyle: "short", timeZone: "Europe/Berlin" })}</> : "" }
&nbsp;Uhr
<br />
</>
}

View file

@ -1,67 +0,0 @@
import styles from "./autoscroll.module.scss"
import { useEffect, useRef, useState } from 'react'
type AutoScrollProps = {
isScrolling: boolean
onTouch?: () => void
children: JSX.Element
}
/**
* This component autoscroll the content from left to right
* automatically
*
*/
export const AutoScroll = ({children, isScrolling, onTouch}: AutoScrollProps) => {
const [step, setStep] = useState(0)
const [direction, setDirection] = useState<'left' | 'right'>('right')
const wrapper = useRef<HTMLDivElement>(null)
useEffect(() => {
if(wrapper && wrapper.current) {
if (isScrolling) {
const scrollSpeed = 30; // 30px per second
const toScroll = wrapper.current.scrollWidth - window.innerWidth;
// nothing to scroll
if (toScroll <= 0) {
return;
}
const steps = toScroll/scrollSpeed * 1000
const t = setTimeout(() => {
let left = toScroll * step/steps
if(wrapper.current) {
wrapper.current.scrollTo({
left: left,
})
}
if(step >= steps) {
setDirection("left")
}
if(step <= 0) {
setDirection("right")
}
setStep(direction === "right" ? step + 1 : step - 1)
return () => clearTimeout(t);
}, 1);
}
}}, [wrapper, step, setStep, direction, setDirection, isScrolling])
return (
<div
className={styles.wrapper}
ref={wrapper}
onTouchStart={onTouch}
>
{children}
</div>
)
}

View file

@ -1,31 +0,0 @@
import styles from './fullscreen.module.scss'
import Image, { StaticImageData } from 'next/image'
type FullScreenProps = {
display: boolean
image: StaticImageData
alt?: string
closeClicked: () => void
nextClicked: () => void
}
export const Fullscreen = ({display, image, closeClicked, alt, nextClicked}: FullScreenProps) => {
if(display)
return (
<div className={styles.display}>
<div
className={styles.close}
onClick={closeClicked}
>x</div>
<Image
onClick={nextClicked}
className={styles.displayImage}
height={image.height}
width={image.width}
src={image.src}
alt={alt || ""}
unoptimized={true}
/>
</div>
)
}

View file

@ -1,6 +1,8 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite'
import { Gallery } from './Gallery'
import { Gallery, GalleryItem } from './Gallery'
import chris from "./../../assets/christophorus.jpeg"
import forest from "./../../assets/forest.jpeg"
import map from "./../../assets/map.jpg"
const meta: Meta<typeof Gallery> = {
component: Gallery,
@ -9,27 +11,52 @@ const meta: Meta<typeof Gallery> = {
type Story = StoryObj<typeof Gallery>;
export default meta
// Remote image with explicit dimensions (varied aspect ratios) so the strip
// shows genuinely different items. Picsum serves a stable image per `id`.
const remote = (
id: string,
picId: number,
width: number,
height: number,
alt: string,
caption?: string,
): GalleryItem => ({
id,
thumbnail: { src: `https://picsum.photos/id/${picId}/${width}/${height}`, width, height },
image: { src: `https://picsum.photos/id/${picId}/${width * 2}/${height * 2}`, width: width * 2, height: height * 2 },
alt,
caption,
})
export const Default: Story = {
args: {
items: [
{
id: "1",
thumbnail: chris,
image: chris,
alt: "hallo"
},
{
id: "2",
thumbnail: chris,
image: chris,
alt: "hallo"
},
{
id: "3",
thumbnail: chris,
image: chris,
alt: "hallo"
}
]
{ id: "1", thumbnail: chris, image: chris, alt: "Heiliger Christophorus" },
remote("2", 1015, 600, 400, "Fluss im Tal"),
{ id: "3", thumbnail: forest, image: forest, alt: "Wald" },
remote("4", 1018, 500, 500, "Berglandschaft"),
{ id: "5", thumbnail: map, image: map, alt: "Karte der Pfarrei" },
remote("6", 1039, 700, 400, "Wasserfall"),
remote("7", 164, 400, 600, "Stillleben"),
],
},
}
}
export const WithCaptions: Story = {
args: {
items: [
{ id: "1", thumbnail: chris, image: chris, alt: "Heiliger Christophorus", caption: "Fenster des Heiligen Christophorus, Hauptschiff" },
{ id: "2", thumbnail: forest, image: forest, alt: "Wald", caption: "Spaziergang durch den Pfarrwald" },
remote("3", 1043, 600, 400, "Innenraum", "Blick auf die Orgelempore"),
{ id: "4", thumbnail: map, image: map, alt: "Karte", caption: "Lage der Gemeinde" },
],
},
}
export const SingleItem: Story = {
args: {
items: [
{ id: "1", thumbnail: chris, image: chris, alt: "Heiliger Christophorus", caption: "Einzelnes Bild ohne Autoscroll" },
],
},
}

View file

@ -2,92 +2,398 @@
import styles from './styles.module.scss'
import Image, { StaticImageData } from 'next/image'
import { useCallback, useEffect, useRef, useState } from 'react'
import { AutoScroll } from '@/components/Gallery/AutoScroll'
import { Fullscreen } from '@/components/Gallery/Fullscreen'
import {
CSSProperties,
MouseEvent as ReactMouseEvent,
Ref,
TouchEvent as ReactTouchEvent,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
type ImageType = {
src: string,
width: number,
height: number
} | StaticImageData
type ImageType =
| {
src: string
width: number
height: number
}
| StaticImageData
export type GalleryItem = {
id: string;
thumbnail: ImageType ;
image: ImageType;
alt: string;
}
type GalleryItemProps = {
thumbnail: ImageType,
alt: string,
onClick?: () => void
id: string
thumbnail: ImageType
image: ImageType
alt: string
caption?: string
}
type GalleryProps = {
items: GalleryItem[];
items: GalleryItem[]
}
const GalleryItem = ({ thumbnail, alt, onClick }: GalleryItemProps) => {
return (
<Image
onClick={onClick}
className={styles.item}
src={thumbnail.src}
height={thumbnail.height}
width={thumbnail.width}
alt={alt}
unoptimized={true}
/>
)
}
/** Horizontal gap between thumbnails — keep in sync with `$gap` in styles.module.scss. */
const GAP = 30
/** Autoscroll speed in pixels per second. */
const SPEED = 30
/** Pointer travel (px) past which a press counts as a drag, not a click. */
const DRAG_THRESHOLD = 5
/** Minimum swipe distance (px) to trigger navigation / close in the lightbox. */
const SWIPE_THRESHOLD = 50
/** Fade duration (ms) — keep in sync with the animations in styles.module.scss. */
const FADE_MS = 100
export const Gallery = ({ items }: GalleryProps) => {
const [displayFullscreen, setDisplayFullscreen] = useState(false)
const [idx, setIdx] = useState(0)
const [isScrolling, setIsScrolling] = useState(true)
const displayImage = useCallback((n: number) => {
setIdx(n);
setDisplayFullscreen(true);
setIsScrolling(false)
}, [setDisplayFullscreen, setIdx, setIsScrolling]);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null)
// Render a second copy of the strip only once we know it should loop (client, motion-ok, overflowing).
const [loop, setLoop] = useState(false)
const next = useCallback(() => {
setIdx((idx + 1) % items.length)
}, [idx, setIdx, items])
const wrapperRef = useRef<HTMLDivElement>(null)
const firstGroupRef = useRef<HTMLDivElement>(null)
const pausedRef = useRef(false)
const lightboxOpenRef = useRef(false)
const dragRef = useRef({ active: false, startX: 0, scrollStart: 0, moved: false })
const close = useCallback(() => setLightboxIndex(null), [])
const showNext = useCallback(
() => setLightboxIndex((i) => (i === null ? i : (i + 1) % items.length)),
[items.length],
)
const showPrev = useCallback(
() => setLightboxIndex((i) => (i === null ? i : (i - 1 + items.length) % items.length)),
[items.length],
)
if(items.length == 0) {
return null;
// Keep the autoscroll loop paused while the lightbox is open.
useEffect(() => {
lightboxOpenRef.current = lightboxIndex !== null
}, [lightboxIndex])
// Decide whether the strip should autoscroll/loop at all.
useEffect(() => {
const wrapper = wrapperRef.current
if (!wrapper || items.length < 2) return
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
if (prefersReduced) return
if (wrapper.scrollWidth <= wrapper.clientWidth) return
setLoop(true)
}, [items.length])
// Continuous, seamless autoscroll via requestAnimationFrame on the duplicated strip.
useEffect(() => {
if (!loop) return
const wrapper = wrapperRef.current
const firstGroup = firstGroupRef.current
if (!wrapper || !firstGroup) return
let raf = 0
let last: number | null = null
const step = (now: number) => {
if (last !== null && !pausedRef.current && !lightboxOpenRef.current) {
const dt = (now - last) / 1000
wrapper.scrollLeft += SPEED * dt
const period = firstGroup.offsetWidth + GAP
if (period > 0 && wrapper.scrollLeft >= period) {
wrapper.scrollLeft -= period
}
}
last = now
raf = requestAnimationFrame(step)
}
raf = requestAnimationFrame(step)
return () => cancelAnimationFrame(raf)
}, [loop])
// --- Pointer drag-to-scroll (mouse). Touch uses native overflow scrolling. ---
const onMouseEnter = useCallback(() => {
pausedRef.current = true
}, [])
const onMouseLeave = useCallback(() => {
pausedRef.current = false
dragRef.current.active = false
}, [])
const onMouseDown = useCallback((e: ReactMouseEvent) => {
const wrapper = wrapperRef.current
if (!wrapper) return
dragRef.current = {
active: true,
startX: e.clientX,
scrollStart: wrapper.scrollLeft,
moved: false,
}
}, [])
const onMouseMove = useCallback((e: ReactMouseEvent) => {
const drag = dragRef.current
const wrapper = wrapperRef.current
if (!drag.active || !wrapper) return
const dx = e.clientX - drag.startX
if (Math.abs(dx) > DRAG_THRESHOLD) drag.moved = true
wrapper.scrollLeft = drag.scrollStart - dx
}, [])
const onMouseUp = useCallback(() => {
dragRef.current.active = false
}, [])
// Touch: pause autoscroll while a finger is down; native scrolling moves the strip.
const onTouchStart = useCallback(() => {
pausedRef.current = true
}, [])
const onTouchEnd = useCallback(() => {
pausedRef.current = false
}, [])
// Keyboard: pause autoscroll while a thumbnail is focused so it can be activated (WCAG 2.2.2).
const onFocus = useCallback(() => {
pausedRef.current = true
}, [])
const onBlur = useCallback(() => {
pausedRef.current = false
}, [])
const handleThumbClick = useCallback((index: number) => {
// Ignore the click that ends a drag.
if (dragRef.current.moved) return
setLightboxIndex(index)
}, [])
if (items.length === 0) {
return null
}
const { image, alt } = items[idx]
const renderGroup = (copy: number, ref?: Ref<HTMLDivElement>, hidden = false) => (
<div className={styles.group} ref={ref} aria-hidden={hidden || undefined}>
{items.map((item, index) => (
<Image
key={`${item.id}-${copy}`}
className={styles.item}
src={item.thumbnail.src}
width={item.thumbnail.width}
height={item.thumbnail.height}
alt={item.alt}
unoptimized
draggable={false}
tabIndex={hidden ? -1 : 0}
role="button"
aria-haspopup="dialog"
onClick={() => handleThumbClick(index)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleThumbClick(index)
}
}}
/>
))}
</div>
)
return (
<>
<AutoScroll
isScrolling={isScrolling}
onTouch={() => setIsScrolling(false)}
<div
className={styles.wrapper}
ref={wrapperRef}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
onFocus={onFocus}
onBlur={onBlur}
>
<div className={styles.container}>
{items.map((item, index) =>
<GalleryItem
onClick={() => displayImage(index)}
key={item.id}
thumbnail={item.thumbnail}
alt={item.alt}
/>)}
<div className={styles.track}>
{renderGroup(0, firstGroupRef)}
{loop && renderGroup(1, undefined, true)}
</div>
</AutoScroll>
</div>
<Fullscreen
display={displayFullscreen}
image={image}
closeClicked={() => setDisplayFullscreen(false)}
nextClicked={next}
{lightboxIndex !== null && (
<Lightbox
item={items[lightboxIndex]}
index={lightboxIndex}
total={items.length}
hasMultiple={items.length > 1}
onClose={close}
onNext={showNext}
onPrev={showPrev}
/>
)}
</>
)
}
type LightboxProps = {
item: GalleryItem
index: number
total: number
hasMultiple: boolean
onClose: () => void
onNext: () => void
onPrev: () => void
}
// Visually hide content while keeping it available to assistive technology.
const srOnly: CSSProperties = {
position: 'absolute',
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: 'hidden',
clipPath: 'inset(50%)',
whiteSpace: 'nowrap',
border: 0,
}
const Lightbox = ({ item, index, total, hasMultiple, onClose, onNext, onPrev }: LightboxProps) => {
const dialogRef = useRef<HTMLDivElement>(null)
const touchRef = useRef({ x: 0, y: 0 })
const closeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
// Play the exit fade before the parent unmounts us.
const [closing, setClosing] = useState(false)
// Fade the whole lightbox out, then actually close once the animation ends.
const requestClose = useCallback(() => {
setClosing((alreadyClosing) => {
if (!alreadyClosing) closeTimer.current = setTimeout(onClose, FADE_MS)
return true
})
}, [onClose])
// Lock body scroll and restore focus on close.
useEffect(() => {
const previouslyFocused = document.activeElement as HTMLElement | null
const previousOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
dialogRef.current?.focus()
return () => {
if (closeTimer.current) clearTimeout(closeTimer.current)
document.body.style.overflow = previousOverflow
previouslyFocused?.focus?.()
}
}, [])
// Keyboard navigation + focus trap (keep Tab within the modal — WCAG 2.4.3).
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
requestClose()
} else if (e.key === 'ArrowRight') {
onNext()
} else if (e.key === 'ArrowLeft') {
onPrev()
} else if (e.key === 'Tab') {
const dialog = dialogRef.current
if (!dialog) return
const focusables = Array.from(dialog.querySelectorAll<HTMLElement>('button'))
if (focusables.length === 0) {
e.preventDefault()
return
}
const first = focusables[0]
const last = focusables[focusables.length - 1]
const active = document.activeElement
if (e.shiftKey && (active === first || active === dialog)) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && active === last) {
e.preventDefault()
first.focus()
}
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [requestClose, onNext, onPrev])
const onTouchStart = (e: ReactTouchEvent) => {
const t = e.changedTouches[0]
touchRef.current = { x: t.clientX, y: t.clientY }
}
const onTouchEnd = (e: ReactTouchEvent) => {
const t = e.changedTouches[0]
const dx = t.clientX - touchRef.current.x
const dy = t.clientY - touchRef.current.y
if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > SWIPE_THRESHOLD) {
if (dx < 0) onNext()
else onPrev()
} else if (dy > SWIPE_THRESHOLD && Math.abs(dy) > Math.abs(dx)) {
requestClose()
}
}
const stop = (e: ReactMouseEvent) => e.stopPropagation()
return (
<div
className={`${styles.display} ${closing ? styles.closing : ''}`}
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label="Bildergalerie"
tabIndex={-1}
onClick={requestClose}
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
>
<div style={srOnly} aria-live="polite">
{`Bild ${index + 1} von ${total}: ${item.alt}`}
</div>
<button
type="button"
className={styles.close}
aria-label="Schließen"
onClick={(e) => {
stop(e)
requestClose()
}}
>
×
</button>
{hasMultiple && (
<button
type="button"
className={`${styles.nav} ${styles.prev}`}
aria-label="Vorheriges Bild"
onClick={(e) => {
stop(e)
onPrev()
}}
/>
)}
<Image
className={styles.displayImage}
src={item.image.src}
width={item.image.width}
height={item.image.height}
alt={item.alt}
unoptimized
onClick={stop}
/>
</>
{hasMultiple && (
<button
type="button"
className={`${styles.nav} ${styles.next}`}
aria-label="Nächstes Bild"
onClick={(e) => {
stop(e)
onNext()
}}
/>
)}
{item.caption && (
<div className={styles.captionBar} onClick={stop}>
{item.caption}
</div>
)}
</div>
)
}
}

View file

@ -1,4 +0,0 @@
.wrapper {
overflow: scroll;
scrollbar-width: none;
}

View file

@ -1,42 +0,0 @@
@import "template.scss";
.display {
position: fixed;
top: 0;
left: 0;
z-index: 99;
width: 100vw;
height: 100vh;
background-color: $overlay;
backdrop-filter: blur(8px);
display: flex;
justify-content: center;
align-items: center;
}
.displayImage {
display: block;
max-height: 90vh;
max-width: 90vw;
object-fit: contain;
}
.close {
color: $white;
font-weight: bold;
position: fixed;
top: 35px;
right: 35px;
background-color: $base-color;
border-radius: 50%;
width: 50px;
height: 50px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
}
.close:hover {
background-color: $shade1;
}

View file

@ -1,12 +1,176 @@
@import "template.scss";
.container {
$gap: 30px;
/* ---- Thumbnail strip ---- */
.wrapper {
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
-ms-overflow-style: none;
cursor: grab;
&::-webkit-scrollbar {
display: none;
}
&:active {
cursor: grabbing;
}
}
.track {
display: flex;
gap: 30px;
flex-wrap: nowrap;
gap: $gap;
justify-content: center;
min-width: 100%;
width: max-content;
}
.group {
display: flex;
gap: $gap;
flex: none;
}
.item {
cursor: pointer;
}
flex: none;
user-select: none;
-webkit-user-drag: none;
&:focus-visible {
outline: 3px solid $base-color;
outline-offset: 3px;
}
}
/* ---- Fullscreen lightbox ---- */
@keyframes lightboxFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes lightboxFadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
.display {
position: fixed;
top: 0;
left: 0;
z-index: 99;
width: 100vw;
height: 100vh;
background-color: $overlay;
backdrop-filter: blur(8px);
display: flex;
justify-content: center;
align-items: center;
outline: none;
animation: lightboxFadeIn 200ms ease;
}
.closing {
animation: lightboxFadeOut 200ms ease forwards;
}
.displayImage {
display: block;
max-height: 90vh;
max-width: 90vw;
object-fit: contain;
user-select: none;
}
.close,
.nav {
color: $white;
font-weight: bold;
position: fixed;
background-color: $base-color;
border: none;
border-radius: 50%;
width: 50px;
height: 50px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
font-size: 24px;
line-height: 1;
opacity: 0.6;
&:hover,
&:focus-visible {
background-color: $shade1;
opacity: 1;
}
&:focus-visible {
outline: 3px solid $white;
outline-offset: 2px;
}
}
.close {
top: 35px;
right: 35px;
}
.nav {
top: 50%;
transform: translateY(-50%);
font-size: 0;
// Chevron drawn with CSS borders so it stays perfectly centred (no font metrics).
&::before {
content: "";
display: block;
width: 9px;
height: 9px;
border: solid $white;
border-width: 0;
}
}
.prev {
left: 35px;
&::before {
border-width: 0 0 3px 3px;
transform: rotate(45deg);
margin-left: 3px; // optical centering of the rotated chevron
}
}
.next {
right: 35px;
&::before {
border-width: 3px 3px 0 0;
transform: rotate(45deg);
margin-right: 3px; // optical centering of the rotated chevron
}
}
.captionBar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 24px 16px;
color: $white;
text-align: center;
font-size: 16px;
background: linear-gradient(to top, rgba(0, 0, 0, 0.45), rgba(0, 0, 0, 0.0));
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9);
}
@media (prefers-reduced-motion: reduce) {
.display,
.closing {
animation: none;
}
}

File diff suppressed because it is too large Load diff

View file

@ -85,7 +85,7 @@ export const Default: Story = {
id: '7',
date: '2024-08-23T11:00:00.000Z',
location: 'St. Marien',
type: 'MASS',
type: 'LANGUAGE',
cancelled: false,
updatedAt: '',
createdAt: '',
@ -186,7 +186,7 @@ export const SevenChurches: Story = {
id: '4',
date: '2024-08-25T17:00:00.000Z',
location: 'St. Christopherus',
type: 'FAMILY',
type: 'LANGUAGE',
cancelled: false,
updatedAt: '',
createdAt: '',

View file

@ -39,6 +39,24 @@ export const Default: Story = {
updatedAt: '',
createdAt: '',
},
{
id: '1',
date: '2024-08-25T11:00:00.000Z',
location: 'St. Christopherus',
type: 'LANGUAGE',
cancelled: false,
updatedAt: '',
createdAt: '',
},
{
id: '1',
date: '2024-08-25T19:00:00.000Z',
location: 'St. Christopherus',
type: 'OTHER',
cancelled: false,
updatedAt: '',
createdAt: '',
},
{
id: '1',
date: '2024-08-26T15:00:00.000Z',

View file

@ -35,6 +35,24 @@ export const LiturgyOfTheWord: Story = {
},
}
export const ForeignLanguage: Story = {
args: {
id: '1',
date: '2024-08-23T15:00:00.000Z',
type: 'LANGUAGE',
cancelled: false,
},
}
export const Other: Story = {
args: {
id: '1',
date: '2024-08-23T15:00:00.000Z',
type: 'OTHER',
cancelled: false,
},
}
export const Cancelled: Story = {
args: {
id: '1',

View file

@ -4,6 +4,7 @@ import styles from './styles.module.scss'
import Image from 'next/image'
import family from './family.svg'
import bible from './bible.svg'
import language from './language.svg'
import { useState } from 'react'
import Link from 'next/link'
import classNames from 'classnames'
@ -12,7 +13,7 @@ import { useTime } from '@/hooks/useTime'
export type MassTableRowProps = {
id: string
date: string
type: 'MASS' | 'FAMILY' | 'WORD'
type: 'MASS' | 'FAMILY' | 'WORD' | 'LANGUAGE' | 'OTHER'
cancelled: boolean
}
@ -50,6 +51,10 @@ export const MassTableRow = ({
{type === 'WORD' && (
<Image src={bible} width={18} height={18} alt={'Wortgottesfeier'} />
)}
{type === 'LANGUAGE' && (
<Image src={language} width={18} height={18} alt={'Fremdsprache'} />
)}
</div>
</div>
</Link>

View file

@ -0,0 +1,24 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg fill="#ffffff" width="201px" height="201px" viewBox="0 0 512.00 512.00" xmlns="http://www.w3.org/2000/svg" stroke="#ffffff" stroke-width="13.312000000000001">
<g id="SVGRepo_bgCarrier" stroke-width="0">
<rect x="0" y="0" width="512.00" height="512.00" rx="256" fill="#728F8D" strokewidth="0"/>
</g>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
<g id="SVGRepo_iconCarrier">
<title>ionicons-v5-l</title>
<path d="M478.33,433.6l-90-218a22,22,0,0,0-40.67,0l-90,218a22,22,0,1,0,40.67,16.79L316.66,406H419.33l18.33,44.39A22,22,0,0,0,458,464a22,22,0,0,0,20.32-30.4ZM334.83,362,368,281.65,401.17,362Z"/>
<path d="M267.84,342.92a22,22,0,0,0-4.89-30.7c-.2-.15-15-11.13-36.49-34.73,39.65-53.68,62.11-114.75,71.27-143.49H330a22,22,0,0,0,0-44H214V70a22,22,0,0,0-44,0V90H54a22,22,0,0,0,0,44H251.25c-9.52,26.95-27.05,69.5-53.79,108.36-31.41-41.68-43.08-68.65-43.17-68.87a22,22,0,0,0-40.58,17c.58,1.38,14.55,34.23,52.86,83.93.92,1.19,1.83,2.35,2.74,3.51-39.24,44.35-77.74,71.86-93.85,80.74a22,22,0,1,0,21.07,38.63c2.16-1.18,48.6-26.89,101.63-85.59,22.52,24.08,38,35.44,38.93,36.1a22,22,0,0,0,30.75-4.9Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,23 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite'
import { SecureEmail } from './SecureEmail'
const meta: Meta<typeof SecureEmail> = {
component: SecureEmail,
}
type Story = StoryObj<typeof SecureEmail>;
export default meta
export const Default: Story = {
args: {
user: btoa('pastoor'),
domain: btoa('parochie.nl'),
},
}
export const WithLabel: Story = {
args: {
...Default.args,
label: 'Send email'
}
}

View file

@ -0,0 +1,59 @@
'use client';
import { useRef } from 'react';
interface SecureEmailProps {
/**
* base64-encoded email, e.g. btoa('pastoor')
*/
user: string;
/**
* base64-encoded domain, e.g. btoa('parochie.nl')
*/
domain: string;
label?: string;
}
/**
* Generates a secure email link that requires user interaction to reveal the actual "mailto:" address,
* preventing the address from being easily scraped by bots.
*
* @param {Object} props - The properties for the SecureEmail component.
* @param {string} props.user - The Base64-encoded user portion of the email address.
* @param {string} props.domain - The Base64-encoded domain portion of the email address.
* @param {string} [props.label='E-Mail schreiben'] - The label text displayed for the email link.
* @return {JSX.Element} A React component that renders an email link.
*/
export function SecureEmail({ user, domain, label = 'E-Mail schreiben' }: SecureEmailProps) {
const ref = useRef<HTMLAnchorElement>(null);
// Decode and write the real mailto: into the href, imperatively (not via state),
// so it's in place synchronously before the browser navigates.
const reveal = () => {
const el = ref.current;
if (!el || el.href.startsWith('mailto:')) return;
el.href = `mailto:${atob(user)}@${atob(domain)}`;
};
// Fallback: if a click somehow lands before reveal fired (some touch flows),
// assemble the address then and navigate manually.
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
if (!ref.current?.href.startsWith('mailto:')) {
e.preventDefault();
reveal();
window.location.href = ref.current!.href;
}
};
return (
<a
ref={ref}
href="#"
onMouseEnter={reveal}
onFocus={reveal}
onTouchStart={reveal}
onClick={handleClick}>
{label}
</a>
);
}

View file

@ -1,41 +1,79 @@
import type { JSXConvertersFunction } from '@payloadcms/richtext-lexical/react'
import { LinkJSXConverter } from '@payloadcms/richtext-lexical/react'
import type { SerializedLinkNode } from '@payloadcms/richtext-lexical'
import Image from 'next/image'
import { Button } from '@/components/Button/Button'
import { getPhoto } from '@/utils/dto/gallery'
// Lexical link nodes carry their editable metadata (url, newTab, linkType, ...)
// in `fields`. We extend that shape with the custom `appearance` select that is
// added to LinkFeature in `payload.config.ts`, so editors can mark a link as a
// call-to-action button.
type LinkFields = SerializedLinkNode['fields'] & {
appearance?: 'link' | 'button'
}
// Custom JSX converters passed to <RichText /> wherever rich text is rendered.
// Only the `link` converter is overridden — every other node keeps Payload's
// default rendering via the spread of `defaultConverters`.
// Maps an internal Payload document to its public URL based on collection slug.
const internalDocToHref = ({ linkNode }: { linkNode: SerializedLinkNode }): string => {
const { doc } = linkNode.fields
if (!doc?.value || typeof doc.value !== 'object') return '#'
const document = doc.value as Record<string, unknown>
switch (doc.relationTo) {
case 'group':
return `/gruppe/${document.slug ?? document.id}`
case 'event':
return `/veranstaltungen/${document.id}`
case 'worship':
return `/gottesdienst/${document.id}`
case 'pages':
return `/${document.slug ?? ''}`
case 'parish':
return `/gemeinde/${document.slug ?? document.id}`
case 'blog':
return `/blog/${document.id}`
default:
return '#'
}
}
const linkConverters = LinkJSXConverter({ internalDocToHref })
export const jsxConverters: JSXConvertersFunction = ({ defaultConverters }) => ({
...defaultConverters,
...linkConverters,
// Render uploaded media at the "gallery" size (500px tall, variable width).
upload: ({ node }) => {
if (node.relationTo !== 'media' || typeof node.value !== 'object') {
return null
}
const photo = getPhoto('gallery', node.value)
if (!photo) return null
return (
<Image
src={photo.src}
width={photo.width}
height={photo.height}
alt={node.value.alt}
unoptimized={true}
/>
)
},
link: (args) => {
const { node, nodesToJSX } = args
const fields = node.fields as LinkFields
// Normal link → delegate to Payload's built-in link converter.
// The default converter from the package is a function, but the type
// also allows a static ReactNode, so we narrow before calling.
// Normal link → delegate to the internalDocToHref-aware link converter.
if (fields?.appearance !== 'button') {
const defaultLink = defaultConverters.link
return typeof defaultLink === 'function' ? defaultLink(args) : defaultLink
const linkConverter = linkConverters.link
return typeof linkConverter === 'function' ? linkConverter(args) : linkConverter
}
// Button link → resolve href the same way Payload's default converter does:
// internal links point at the related doc's slug, custom links use `url`.
// Button link → resolve href using internalDocToHref for internal links.
const href =
fields.linkType === 'internal' && typeof fields.doc?.value === 'object'
? `/${(fields.doc.value as { slug?: string }).slug ?? ''}`
fields.linkType === 'internal'
? internalDocToHref({ linkNode: node })
: (fields.url ?? '#')
// Render the existing Button component. Schema and size are intentionally
// hardcoded — editors only choose link vs. button, not the styling.
return (
<Button
href={href}

View file

@ -12,6 +12,11 @@
color: $base-color;
}
.container img {
max-width: 100%;
height: auto;
}
.container h3 {
font-size: 33px;
font-weight: 700;

View file

@ -25,6 +25,7 @@ import { EventsBlock } from '@/compositions/Blocks/EventsBlock'
import { ImageCardsBlock } from '@/compositions/Blocks/ImageCardsBlock'
import { ClassifiedsFromApi } from '@/components/Classifieds/ClassifiedsFromApi'
import { NextPrevButtons } from '@/components/NextPrevButtons/NextPrevButtons'
import { GroupEvents } from '@/compositions/GroupEvents/GroupEvents'
type BlocksProps = {
content: Blog['content']['content'] | NonNullable<Page['content']>
@ -52,7 +53,12 @@ export function Blocks({ content }: BlocksProps) {
return (
<Container key={item.id}>
<Section padding={'medium'}>
<Button size={'lg'} href={item.file.url || 'notfound'} schema={'contrast'}>{item.button}</Button>
<Button
size={'lg'}
href={item.file.url || 'notfound'}
schema={'contrast'}
target={'_blank'}
>{item.button}</Button>
</Section>
</Container>
)
@ -272,6 +278,18 @@ export function Blocks({ content }: BlocksProps) {
)
}
if (item.blockType === 'groupEvents') {
const groupId =
typeof item.group === 'object' ? item.group.id : item.group
return (
<Section key={item.id} padding={'small'}>
<Container>
<GroupEvents id={groupId} />
</Container>
</Section>
)
}
if (item.blockType === 'imageCards') {
return <ImageCardsBlock key={item.id} items={item.items} />
}

View file

@ -39,7 +39,14 @@ export function ParishBlocks({ content }: BlocksProps) {
return (
<Container key={item.id}>
<Section padding={"medium"}>
<Button size={"lg"} href={item.file.url || "notfound"} schema={"contrast"}>{item.button}</Button>
<Button
size={"lg"}
href={item.file.url || "notfound"}
schema={"contrast"}
target={"_blank"}
>
{item.button}
</Button>
</Section>
</Container>
)

View file

@ -33,6 +33,7 @@ export const Events = ({ events, n, schema = 'base' }: EventsProps) => {
<EventRow
key={event.href}
date={event.date}
end={event.end}
title={event.title}
href={event.href}
location={event.location}

View file

@ -1,15 +1,9 @@
import { getPayload } from 'payload'
import config from '@/payload.config'
import { Magazine } from '@/payload-types'
import { PaginatedDocs } from 'payload'
/**
* Asynchronously fetches the last magazine entry.
*
* @returns {Promise<Magazine | undefined>} The last magazine entry if successful, or undefined.
*/
export const fetchLastMagazine = async (): Promise<
Magazine | undefined
> => {
export const fetchLastMagazine = async (): Promise<Magazine | undefined> => {
const payload = await getPayload({ config })
const result = await payload.find({
collection: 'magazine',
@ -18,3 +12,13 @@ export const fetchLastMagazine = async (): Promise<
})
return result.docs[0]
}
export const fetchMagazines = async (page = 1, limit = 12): Promise<PaginatedDocs<Magazine>> => {
const payload = await getPayload({ config })
return payload.find({
collection: 'magazine',
sort: '-date',
limit,
page,
})
}

View file

@ -1,9 +1,13 @@
export const useMassType = (type: 'MASS' | 'FAMILY' | 'WORD') => {
export const useMassType = (type: 'MASS' | 'FAMILY' | 'WORD' | 'LANGUAGE' | 'OTHER') => {
switch (type) {
case 'FAMILY':
return 'Familien Messe'
case 'WORD':
return 'Wort-Gottes-Feier'
case 'LANGUAGE':
return 'Fremdsprache'
case 'OTHER':
return 'Andere'
case 'MASS':
default:
return 'Heilige Messe'

View file

@ -42,6 +42,7 @@ type GenerateEventOccurrencesOutput = {
type EventLike = {
id: string
date?: string | null
endDateTime?: string | null
recurrenceType?: EventRecurrenceType | null
recurrenceRules?: RecurrenceRule[] | null
endDate?: string | null
@ -113,6 +114,17 @@ export const regenerateOccurrencesForEvent = async ({
return { created, deleted, skipped: skipped + 1 }
}
// Derive each occurrence's end from the event's duration (end - start),
// re-anchored to the occurrence's own start. A missing or non-positive
// duration yields no end on the occurrences.
const eventEnd = event.endDateTime ? new Date(event.endDateTime) : null
const durationMs =
eventEnd && !Number.isNaN(eventEnd.getTime()) && eventEnd.getTime() > eventDate.getTime()
? eventEnd.getTime() - eventDate.getTime()
: null
const endFor = (start: Date): string | null =>
durationMs === null ? null : new Date(start.getTime() + durationMs).toISOString()
const recurrenceType = event.recurrenceType ?? 'none'
if (recurrenceType === 'none') {
@ -124,6 +136,7 @@ export const regenerateOccurrencesForEvent = async ({
data: {
event: event.id,
date: eventDate.toISOString(),
endDateTime: endFor(eventDate),
cancelled: cancelledDates.has(eventDate.toISOString()),
generated: true,
},
@ -144,6 +157,7 @@ export const regenerateOccurrencesForEvent = async ({
data: {
event: event.id,
date: iso,
endDateTime: endFor(date),
cancelled: cancelledDates.has(iso),
generated: true,
},

View file

@ -103,7 +103,7 @@ export const generateRecurringMassesTask: TaskConfig<{
for (const rawEntry of schedule) {
const entry = rawEntry as ScheduleEntry & {
time?: string | Date | null
type?: 'MASS' | 'FAMILY' | 'WORD'
type?: 'MASS' | 'FAMILY' | 'WORD' | 'LANGUAGE' | 'OTHER'
defaultCelebrant?: string | null
defaultTitle?: string | null
defaultDescription?: string | null

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,50 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TABLE "pages_blocks_group_events" (
"_order" integer NOT NULL,
"_parent_id" uuid NOT NULL,
"_path" text NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"group_id" uuid,
"block_name" varchar
);
CREATE TABLE "_pages_v_blocks_group_events" (
"_order" integer NOT NULL,
"_parent_id" uuid NOT NULL,
"_path" text NOT NULL,
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"group_id" uuid,
"_uuid" varchar,
"block_name" varchar
);
ALTER TABLE "announcement" ALTER COLUMN "date" SET DEFAULT '2026-06-07T09:51:11.853Z';
ALTER TABLE "calendar" ALTER COLUMN "date" SET DEFAULT '2026-06-07T09:51:12.181Z';
ALTER TABLE "classifieds" ALTER COLUMN "until" SET DEFAULT '2026-07-05T09:51:12.265Z';
ALTER TABLE "pages_blocks_group_events" ADD CONSTRAINT "pages_blocks_group_events_group_id_group_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."group"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "pages_blocks_group_events" ADD CONSTRAINT "pages_blocks_group_events_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."pages"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "_pages_v_blocks_group_events" ADD CONSTRAINT "_pages_v_blocks_group_events_group_id_group_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."group"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "_pages_v_blocks_group_events" ADD CONSTRAINT "_pages_v_blocks_group_events_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."_pages_v"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "pages_blocks_group_events_order_idx" ON "pages_blocks_group_events" USING btree ("_order");
CREATE INDEX "pages_blocks_group_events_parent_id_idx" ON "pages_blocks_group_events" USING btree ("_parent_id");
CREATE INDEX "pages_blocks_group_events_path_idx" ON "pages_blocks_group_events" USING btree ("_path");
CREATE INDEX "pages_blocks_group_events_group_idx" ON "pages_blocks_group_events" USING btree ("group_id");
CREATE INDEX "_pages_v_blocks_group_events_order_idx" ON "_pages_v_blocks_group_events" USING btree ("_order");
CREATE INDEX "_pages_v_blocks_group_events_parent_id_idx" ON "_pages_v_blocks_group_events" USING btree ("_parent_id");
CREATE INDEX "_pages_v_blocks_group_events_path_idx" ON "_pages_v_blocks_group_events" USING btree ("_path");
CREATE INDEX "_pages_v_blocks_group_events_group_idx" ON "_pages_v_blocks_group_events" USING btree ("group_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "pages_blocks_group_events" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "_pages_v_blocks_group_events" DISABLE ROW LEVEL SECURITY;
DROP TABLE "pages_blocks_group_events" CASCADE;
DROP TABLE "_pages_v_blocks_group_events" CASCADE;
ALTER TABLE "announcement" ALTER COLUMN "date" SET DEFAULT '2026-05-10T07:03:55.605Z';
ALTER TABLE "calendar" ALTER COLUMN "date" SET DEFAULT '2026-05-10T07:03:55.910Z';
ALTER TABLE "classifieds" ALTER COLUMN "until" SET DEFAULT '2026-06-03T07:03:55.993Z';`)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,19 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "announcement" ALTER COLUMN "date" SET DEFAULT '2026-06-07T11:31:06.259Z';
ALTER TABLE "calendar" ALTER COLUMN "date" SET DEFAULT '2026-06-07T11:31:06.641Z';
ALTER TABLE "classifieds" ALTER COLUMN "until" SET DEFAULT '2026-07-05T11:31:06.720Z';
ALTER TABLE "event_occurrence" ADD COLUMN "end_date_time" timestamp(3) with time zone;
CREATE INDEX "event_occurrence_end_date_time_idx" ON "event_occurrence" USING btree ("end_date_time");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
DROP INDEX "event_occurrence_end_date_time_idx";
ALTER TABLE "announcement" ALTER COLUMN "date" SET DEFAULT '2026-06-07T09:51:11.853Z';
ALTER TABLE "calendar" ALTER COLUMN "date" SET DEFAULT '2026-06-07T09:51:12.181Z';
ALTER TABLE "classifieds" ALTER COLUMN "until" SET DEFAULT '2026-07-05T09:51:12.265Z';
ALTER TABLE "event_occurrence" DROP COLUMN "end_date_time";`)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,27 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "announcement" ALTER COLUMN "date" SET DEFAULT '2026-06-07T12:41:03.305Z';
ALTER TABLE "calendar" ALTER COLUMN "date" SET DEFAULT '2026-06-07T12:41:03.609Z';
ALTER TABLE "classifieds" ALTER COLUMN "until" SET DEFAULT '2026-07-05T12:41:03.673Z';
ALTER TABLE "blog_blocks_gallery_items" ADD COLUMN "caption" varchar;
ALTER TABLE "_blog_v_blocks_gallery_items" ADD COLUMN "caption" varchar;
ALTER TABLE "group_blocks_gallery_items" ADD COLUMN "caption" varchar;
ALTER TABLE "_group_v_blocks_gallery_items" ADD COLUMN "caption" varchar;
ALTER TABLE "pages_blocks_gallery_items" ADD COLUMN "caption" varchar;
ALTER TABLE "_pages_v_blocks_gallery_items" ADD COLUMN "caption" varchar;`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "announcement" ALTER COLUMN "date" SET DEFAULT '2026-06-07T11:31:06.259Z';
ALTER TABLE "calendar" ALTER COLUMN "date" SET DEFAULT '2026-06-07T11:31:06.641Z';
ALTER TABLE "classifieds" ALTER COLUMN "until" SET DEFAULT '2026-07-05T11:31:06.720Z';
ALTER TABLE "blog_blocks_gallery_items" DROP COLUMN "caption";
ALTER TABLE "_blog_v_blocks_gallery_items" DROP COLUMN "caption";
ALTER TABLE "group_blocks_gallery_items" DROP COLUMN "caption";
ALTER TABLE "_group_v_blocks_gallery_items" DROP COLUMN "caption";
ALTER TABLE "pages_blocks_gallery_items" DROP COLUMN "caption";
ALTER TABLE "_pages_v_blocks_gallery_items" DROP COLUMN "caption";`)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,27 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TYPE "public"."enum_church_recurring_schedule_type" ADD VALUE 'LANGUAGE';
ALTER TYPE "public"."enum_church_recurring_schedule_type" ADD VALUE 'OTHER';
ALTER TYPE "public"."enum_worship_type" ADD VALUE 'LANGUAGE';
ALTER TYPE "public"."enum_worship_type" ADD VALUE 'OTHER';
ALTER TABLE "announcement" ALTER COLUMN "date" SET DEFAULT '2026-06-07T13:08:42.418Z';
ALTER TABLE "calendar" ALTER COLUMN "date" SET DEFAULT '2026-06-07T13:08:42.705Z';
ALTER TABLE "classifieds" ALTER COLUMN "until" SET DEFAULT '2026-07-05T13:08:42.759Z';`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "church_recurring_schedule" ALTER COLUMN "type" SET DATA TYPE text;
DROP TYPE "public"."enum_church_recurring_schedule_type";
CREATE TYPE "public"."enum_church_recurring_schedule_type" AS ENUM('MASS', 'FAMILY', 'WORD');
ALTER TABLE "church_recurring_schedule" ALTER COLUMN "type" SET DATA TYPE "public"."enum_church_recurring_schedule_type" USING "type"::"public"."enum_church_recurring_schedule_type";
ALTER TABLE "worship" ALTER COLUMN "type" SET DATA TYPE text;
DROP TYPE "public"."enum_worship_type";
CREATE TYPE "public"."enum_worship_type" AS ENUM('MASS', 'FAMILY', 'WORD');
ALTER TABLE "worship" ALTER COLUMN "type" SET DATA TYPE "public"."enum_worship_type" USING "type"::"public"."enum_worship_type";
ALTER TABLE "announcement" ALTER COLUMN "date" SET DEFAULT '2026-06-07T12:41:03.305Z';
ALTER TABLE "calendar" ALTER COLUMN "date" SET DEFAULT '2026-06-07T12:41:03.609Z';
ALTER TABLE "classifieds" ALTER COLUMN "until" SET DEFAULT '2026-07-05T12:41:03.673Z';`)
}

View file

@ -44,6 +44,10 @@ import * as migration_20260423_115311 from './20260423_115311';
import * as migration_20260423_131226_add_event_end_date_time from './20260423_131226_add_event_end_date_time';
import * as migration_20260429_121105_collapsible_map_with_text from './20260429_121105_collapsible_map_with_text';
import * as migration_20260504_070356_next_prev_buttons from './20260504_070356_next_prev_buttons';
import * as migration_20260605_095112 from './20260605_095112';
import * as migration_20260605_113107_occurrence_end_time from './20260605_113107_occurrence_end_time';
import * as migration_20260605_124103_gallery_caption from './20260605_124103_gallery_caption';
import * as migration_20260605_130843_add_worship_language_other from './20260605_130843_add_worship_language_other';
export const migrations = [
{
@ -274,6 +278,26 @@ export const migrations = [
{
up: migration_20260504_070356_next_prev_buttons.up,
down: migration_20260504_070356_next_prev_buttons.down,
name: '20260504_070356_next_prev_buttons'
name: '20260504_070356_next_prev_buttons',
},
{
up: migration_20260605_095112.up,
down: migration_20260605_095112.down,
name: '20260605_095112',
},
{
up: migration_20260605_113107_occurrence_end_time.up,
down: migration_20260605_113107_occurrence_end_time.down,
name: '20260605_113107_occurrence_end_time',
},
{
up: migration_20260605_124103_gallery_caption.up,
down: migration_20260605_124103_gallery_caption.down,
name: '20260605_124103_gallery_caption',
},
{
up: migration_20260605_130843_add_worship_language_other.up,
down: migration_20260605_130843_add_worship_language_other.down,
name: '20260605_130843_add_worship_language_other'
},
];

View file

@ -21,6 +21,7 @@ import classNames from 'classnames'
type UpcomingOccurrence = {
id: string
date: string
end?: string
title: string
href: string
location: string
@ -204,6 +205,7 @@ export function EventPage(
<EventRow
key={o.id}
date={o.date}
end={o.end}
title={o.title}
href={o.href}
location={o.location}

View file

@ -337,7 +337,7 @@ export interface Church {
*/
recurringSchedule?:
| {
type: 'MASS' | 'FAMILY' | 'WORD';
type: 'MASS' | 'FAMILY' | 'WORD' | 'LANGUAGE' | 'OTHER';
frequency: 'weekly' | 'biweekly' | 'monthlyByWeekday';
day: 'monday' | 'tuesday' | 'wednesday' | 'thursday' | 'friday' | 'saturday' | 'sunday';
time: string;
@ -505,6 +505,7 @@ export interface Page {
| {
items: {
photo: string | Media;
caption?: string | null;
id?: string | null;
}[];
id?: string | null;
@ -675,6 +676,12 @@ export interface Page {
blockName?: string | null;
blockType: 'events';
}
| {
group: string | Group;
id?: string | null;
blockName?: string | null;
blockType: 'groupEvents';
}
| {
contact: string | ContactPerson;
id?: string | null;
@ -728,20 +735,6 @@ export interface Page {
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "contactPerson".
*/
export interface ContactPerson {
id: string;
photo?: (string | null) | Media;
name: string;
role?: string | null;
email?: string | null;
telephone?: string | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "group".
@ -806,6 +799,7 @@ export interface Group {
| {
items: {
photo: string | Media;
caption?: string | null;
id?: string | null;
}[];
id?: string | null;
@ -911,6 +905,20 @@ export interface Group {
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "contactPerson".
*/
export interface ContactPerson {
id: string;
photo?: (string | null) | Media;
name: string;
role?: string | null;
email?: string | null;
telephone?: string | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "worship".
@ -919,7 +927,7 @@ export interface Worship {
id: string;
date: string;
location: string | Church;
type: 'MASS' | 'FAMILY' | 'WORD';
type: 'MASS' | 'FAMILY' | 'WORD' | 'LANGUAGE' | 'OTHER';
title?: string | null;
cancelled: boolean;
liturgicalDay?: string | null;
@ -1025,6 +1033,7 @@ export interface Blog {
| {
items: {
photo: string | Media;
caption?: string | null;
id?: string | null;
}[];
id?: string | null;
@ -1174,6 +1183,7 @@ export interface EventOccurrence {
id: string;
event: string | Event;
date: string;
endDateTime?: string | null;
cancelled: boolean;
generated?: boolean | null;
updatedAt: string;
@ -1824,6 +1834,7 @@ export interface BlogSelect<T extends boolean = true> {
| T
| {
photo?: T;
caption?: T;
id?: T;
};
id?: T;
@ -1926,6 +1937,7 @@ export interface EventSelect<T extends boolean = true> {
export interface EventOccurrenceSelect<T extends boolean = true> {
event?: T;
date?: T;
endDateTime?: T;
cancelled?: T;
generated?: T;
updatedAt?: T;
@ -2007,6 +2019,7 @@ export interface GroupSelect<T extends boolean = true> {
| T
| {
photo?: T;
caption?: T;
id?: T;
};
id?: T;
@ -2166,6 +2179,7 @@ export interface PagesSelect<T extends boolean = true> {
| T
| {
photo?: T;
caption?: T;
id?: T;
};
id?: T;
@ -2294,6 +2308,13 @@ export interface PagesSelect<T extends boolean = true> {
id?: T;
blockName?: T;
};
groupEvents?:
| T
| {
group?: T;
id?: T;
blockName?: T;
};
contactPersonBlock?:
| T
| {

View file

@ -10,7 +10,7 @@ import {
AlignFeature,
UnorderedListFeature,
LinkFeature,
FixedToolbarFeature,
FixedToolbarFeature, UploadFeature,
} from '@payloadcms/richtext-lexical'
import path from 'path'
import { buildConfig } from 'payload'
@ -142,6 +142,10 @@ export default buildConfig({
HeadingFeature({ enabledHeadingSizes: ["h3","h4","h5"]}),
AlignFeature(),
UnorderedListFeature(),
UploadFeature({
enabledCollections: ['media']
}
),
LinkFeature({
fields: ({ defaultFields }) => [
...defaultFields,

18
src/utils/dto/email.ts Normal file
View file

@ -0,0 +1,18 @@
/**
* Extracts and encodes the user and domain parts of an email address.
*
* This function splits the given email string into two parts: the user (local-part) and the domain.
* Each part is then Base64 encoded and returned as an object.
*
* Used for obfuscating email addresses to prevent direct exposure.
*
* @param {string} email - The email address to be processed.
* @returns {{ user: string, domain: string }} An object containing the Base64 encoded user and domain parts of the email.
*/
export const extractEmailPartsEncoded = (email: string): { user: string, domain: string } => {
const [user, domain] = email.split('@');
return {
user: btoa(user),
domain: btoa(domain)
};
}

View file

@ -41,6 +41,7 @@ export const eventToPageProps = (
type EventRowItem = {
id: string
date: string
end?: string
title: string
href: string
location: string
@ -51,13 +52,14 @@ export const transformOccurrences = (
occurrences: EventOccurrence[],
): EventRowItem[] => {
return occurrences
.map((o) => {
.map((o): EventRowItem | undefined => {
const event = typeof o.event === 'object' ? o.event : undefined
if (!event) return undefined
return {
id: o.id,
title: event.title,
date: o.date,
end: o.endDateTime ?? undefined,
href: `/veranstaltungen/${event.id}/${o.id}`,
location: typeof event.location === 'object' ? event.location.name : 'Unbekannt',
cancelled: Boolean(event.cancelled || o.cancelled),
@ -75,6 +77,7 @@ export const transformEvents = (events: Event[]): EventRowItem[] => {
id: e.id,
title: e.title,
date: e.date,
end: e.endDateTime ?? undefined,
href: `/veranstaltungen/${e.id}`,
location: typeof e.location === 'object' ? e.location.name : 'Unbekannt',
cancelled: e.cancelled,

View file

@ -4,6 +4,7 @@ import { StaticImageData } from 'next/image'
type Items = {
photo: string | Media;
caption?: string | null;
id?: string | null;
}[];
@ -20,7 +21,8 @@ export const transformGallery = (items: Items) => {
alt: item.photo.alt,
id: item.id,
thumbnail,
image
image,
caption: item.caption ?? undefined
})
}
}

View file

@ -4,14 +4,16 @@ import { EventRowProps } from '@/components/EventRow/EventRow'
/**
* Transform the worship category to a readable string
*/
export const transformCategory = (category: "MASS" | "FAMILY" | "WORD"): string => {
export const transformCategory = (category: "MASS" | "FAMILY" | "WORD" | "LANGUAGE" | "OTHER"): string => {
const type = {
'MASS': 'Eucharistiefeier',
'FAMILY': 'Familienmesse',
'WORD': 'Wort-Gottes-Feier',
'LANGUAGE': 'Eucharistiefeier in Fremdsprache',
'OTHER': 'Gottesdienst',
}
return type[category]
return type[category] ?? 'Gottesdienst'
}
/**