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. Bleiben Sie auf dem Laufenden! Hier finden Sie die aktuelle Ausgabe mit allen Berichten und Terminen aus der Gemeinde.
<br/><br/> <br/><br/>
</P> </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> </Col>
</Row> </Row>
</Container> </Container>

View file

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

View file

@ -79,6 +79,7 @@ export default async function EventsPage({searchParams}: {
<EventRow <EventRow
key={e.id} key={e.id}
date={e.date} date={e.date}
end={e.end}
title={e.title} title={e.title}
href={e.href} href={e.href}
location={e.location} 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 { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ParagraphFeatureClient as ParagraphFeatureClient_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 { 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 { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { AlignFeatureClient as AlignFeatureClient_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' 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#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, "@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_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#UnorderedListFeatureClient": UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, "@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, "@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,

View file

@ -61,6 +61,8 @@ export const Churches: CollectionConfig = {
{ label: 'Heilige Messe', value: 'MASS' }, { label: 'Heilige Messe', value: 'MASS' },
{ label: 'Familien Messe', value: 'FAMILY' }, { label: 'Familien Messe', value: 'FAMILY' },
{ label: 'Wort-Gottes-Feier', value: 'WORD' }, { 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', name: 'cancelled',
type: 'checkbox', type: 'checkbox',

View file

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

View file

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

View file

@ -20,6 +20,13 @@ export const GalleryBlock: Block = {
relationTo: 'media', relationTo: 'media',
required: true required: true
}, },
{
name: 'caption',
label: {
de: 'Bildunterschrift'
},
type: 'text'
},
], ],
minRows: 3, minRows: 3,
maxRows: 12 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 styles from "./styles.module.scss"
import { ContactPerson } from '@/payload-types' import { ContactPerson } from '@/payload-types'
import Image, { StaticImageData } from 'next/image' import Image, { StaticImageData } from 'next/image'
import { SecureEmail } from '@/components/SecureEmail/SecureEmail'
import { extractEmailPartsEncoded } from '@/utils/dto/email'
type ContactPerson2Props = { type ContactPerson2Props = {
contact: null | string | undefined | ContactPerson contact: null | string | undefined | ContactPerson
@ -12,6 +14,8 @@ export const ContactPerson2 = ({contact, photo}: ContactPerson2Props) => {
return "Unbekannt" return "Unbekannt"
} }
const { user, domain } = contact.email ? extractEmailPartsEncoded(contact.email) : { user: '', domain: '' }
return ( return (
<div className={styles.contact}> <div className={styles.contact}>
{ photo && { photo &&
@ -24,9 +28,9 @@ export const ContactPerson2 = ({contact, photo}: ContactPerson2Props) => {
} }
<div> <div>
{contact.name} {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; color: inherit;
} }
.hover:hover { .contact a:hover {
text-decoration: underline; text-decoration: underline;
} }

View file

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

View file

@ -14,27 +14,50 @@
border-radius: 50%; border-radius: 50%;
} }
.photoPlaceholder {
width: 200px;
height: 200px;
border-radius: 50%;
background-color: #efefef;
}
.role { .role {
font-size: 25px; font-size: 25px;
color: $base-color; color: $base-color;
margin: 0; margin: 0;
} }
.info { .phoneEmail {
display: flex; display: flex;
flex-direction: column; gap: 15px;
gap: 4px;
} }
.name { .name {
font-weight: bold; font-weight: bold;
} }
.link { .phoneEmail a {
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
&:hover { &:hover {
text-decoration: underline; 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 = { export const EventInMarch: Story = {
args: { args: {
date: '2024-03-24T15:00:00+01:00', date: '2024-03-24T15:00:00+01:00',

View file

@ -8,6 +8,7 @@ import Link from 'next/link'
export type EventRowProps = { export type EventRowProps = {
/** datetime 8601 format */ /** datetime 8601 format */
date: string, date: string,
end?: string,
showDate?: boolean showDate?: boolean
title: string, title: string,
href?: 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 day = useMemo(() => date.substring(8, 10), [date]);
const dateObj = useMemo(() => new Date(date), [date]); const dateObj = useMemo(() => new Date(date), [date]);
const endObj = useMemo(() => end ? new Date(end) : undefined, [end]);
const month = useMemo(() => shortMonth(date), [date]); const month = useMemo(() => shortMonth(date), [date]);
const [dayFormat, setDayFormat] = useState<"long" | "short">("long") const [dayFormat, setDayFormat] = useState<"long" | "short">("long")
useEffect(() => { useEffect(() => {
@ -79,7 +81,9 @@ export const EventRow = ({date, title, location, cancelled, href, color = "base"
{ showDate && { showDate &&
<> <>
{dateObj.toLocaleDateString("de-DE", { weekday: "long" })} {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 /> <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 { Meta, StoryObj } from '@storybook/nextjs-vite'
import { Gallery } from './Gallery' import { Gallery, GalleryItem } from './Gallery'
import chris from "./../../assets/christophorus.jpeg" import chris from "./../../assets/christophorus.jpeg"
import forest from "./../../assets/forest.jpeg"
import map from "./../../assets/map.jpg"
const meta: Meta<typeof Gallery> = { const meta: Meta<typeof Gallery> = {
component: Gallery, component: Gallery,
@ -9,27 +11,52 @@ const meta: Meta<typeof Gallery> = {
type Story = StoryObj<typeof Gallery>; type Story = StoryObj<typeof Gallery>;
export default meta 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 = { export const Default: Story = {
args: { args: {
items: [ items: [
{ { id: "1", thumbnail: chris, image: chris, alt: "Heiliger Christophorus" },
id: "1", remote("2", 1015, 600, 400, "Fluss im Tal"),
thumbnail: chris, { id: "3", thumbnail: forest, image: forest, alt: "Wald" },
image: chris, remote("4", 1018, 500, 500, "Berglandschaft"),
alt: "hallo" { id: "5", thumbnail: map, image: map, alt: "Karte der Pfarrei" },
}, remote("6", 1039, 700, 400, "Wasserfall"),
{ remote("7", 164, 400, 600, "Stillleben"),
id: "2", ],
thumbnail: chris,
image: chris,
alt: "hallo"
},
{
id: "3",
thumbnail: chris,
image: chris,
alt: "hallo"
}
]
}, },
} }
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 styles from './styles.module.scss'
import Image, { StaticImageData } from 'next/image' import Image, { StaticImageData } from 'next/image'
import { useCallback, useEffect, useRef, useState } from 'react' import {
import { AutoScroll } from '@/components/Gallery/AutoScroll' CSSProperties,
import { Fullscreen } from '@/components/Gallery/Fullscreen' MouseEvent as ReactMouseEvent,
Ref,
TouchEvent as ReactTouchEvent,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
type ImageType = { type ImageType =
src: string, | {
width: number, src: string
height: number width: number
} | StaticImageData height: number
}
| StaticImageData
export type GalleryItem = { export type GalleryItem = {
id: string; id: string
thumbnail: ImageType ; thumbnail: ImageType
image: ImageType; image: ImageType
alt: string; alt: string
} caption?: string
type GalleryItemProps = {
thumbnail: ImageType,
alt: string,
onClick?: () => void
} }
type GalleryProps = { type GalleryProps = {
items: GalleryItem[]; items: GalleryItem[]
} }
const GalleryItem = ({ thumbnail, alt, onClick }: GalleryItemProps) => { /** Horizontal gap between thumbnails — keep in sync with `$gap` in styles.module.scss. */
return ( const GAP = 30
<Image /** Autoscroll speed in pixels per second. */
onClick={onClick} const SPEED = 30
className={styles.item} /** Pointer travel (px) past which a press counts as a drag, not a click. */
src={thumbnail.src} const DRAG_THRESHOLD = 5
height={thumbnail.height} /** Minimum swipe distance (px) to trigger navigation / close in the lightbox. */
width={thumbnail.width} const SWIPE_THRESHOLD = 50
alt={alt} /** Fade duration (ms) — keep in sync with the animations in styles.module.scss. */
unoptimized={true} const FADE_MS = 100
/>
)
}
export const Gallery = ({ items }: GalleryProps) => { export const Gallery = ({ items }: GalleryProps) => {
const [displayFullscreen, setDisplayFullscreen] = useState(false) const [lightboxIndex, setLightboxIndex] = useState<number | null>(null)
const [idx, setIdx] = useState(0) // Render a second copy of the strip only once we know it should loop (client, motion-ok, overflowing).
const [isScrolling, setIsScrolling] = useState(true) const [loop, setLoop] = useState(false)
const displayImage = useCallback((n: number) => {
setIdx(n);
setDisplayFullscreen(true);
setIsScrolling(false)
}, [setDisplayFullscreen, setIdx, setIsScrolling]);
const next = useCallback(() => { const wrapperRef = useRef<HTMLDivElement>(null)
setIdx((idx + 1) % items.length) const firstGroupRef = useRef<HTMLDivElement>(null)
}, [idx, setIdx, items]) 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) { // Keep the autoscroll loop paused while the lightbox is open.
return null; 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 ( return (
<> <>
<AutoScroll <div
isScrolling={isScrolling} className={styles.wrapper}
onTouch={() => setIsScrolling(false)} ref={wrapperRef}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
onFocus={onFocus}
onBlur={onBlur}
> >
<div className={styles.container}> <div className={styles.track}>
{items.map((item, index) => {renderGroup(0, firstGroupRef)}
<GalleryItem {loop && renderGroup(1, undefined, true)}
onClick={() => displayImage(index)}
key={item.id}
thumbnail={item.thumbnail}
alt={item.alt}
/>)}
</div> </div>
</AutoScroll> </div>
<Fullscreen {lightboxIndex !== null && (
display={displayFullscreen} <Lightbox
image={image} item={items[lightboxIndex]}
closeClicked={() => setDisplayFullscreen(false)} index={lightboxIndex}
nextClicked={next} 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"; @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; display: flex;
gap: 30px; gap: $gap;
flex-wrap: nowrap;
justify-content: center; justify-content: center;
min-width: 100%;
width: max-content;
}
.group {
display: flex;
gap: $gap;
flex: none;
} }
.item { .item {
cursor: pointer; 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', id: '7',
date: '2024-08-23T11:00:00.000Z', date: '2024-08-23T11:00:00.000Z',
location: 'St. Marien', location: 'St. Marien',
type: 'MASS', type: 'LANGUAGE',
cancelled: false, cancelled: false,
updatedAt: '', updatedAt: '',
createdAt: '', createdAt: '',
@ -186,7 +186,7 @@ export const SevenChurches: Story = {
id: '4', id: '4',
date: '2024-08-25T17:00:00.000Z', date: '2024-08-25T17:00:00.000Z',
location: 'St. Christopherus', location: 'St. Christopherus',
type: 'FAMILY', type: 'LANGUAGE',
cancelled: false, cancelled: false,
updatedAt: '', updatedAt: '',
createdAt: '', createdAt: '',

View file

@ -39,6 +39,24 @@ export const Default: Story = {
updatedAt: '', updatedAt: '',
createdAt: '', 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', id: '1',
date: '2024-08-26T15:00:00.000Z', 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 = { export const Cancelled: Story = {
args: { args: {
id: '1', id: '1',

View file

@ -4,6 +4,7 @@ import styles from './styles.module.scss'
import Image from 'next/image' import Image from 'next/image'
import family from './family.svg' import family from './family.svg'
import bible from './bible.svg' import bible from './bible.svg'
import language from './language.svg'
import { useState } from 'react' import { useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import classNames from 'classnames' import classNames from 'classnames'
@ -12,7 +13,7 @@ import { useTime } from '@/hooks/useTime'
export type MassTableRowProps = { export type MassTableRowProps = {
id: string id: string
date: string date: string
type: 'MASS' | 'FAMILY' | 'WORD' type: 'MASS' | 'FAMILY' | 'WORD' | 'LANGUAGE' | 'OTHER'
cancelled: boolean cancelled: boolean
} }
@ -50,6 +51,10 @@ export const MassTableRow = ({
{type === 'WORD' && ( {type === 'WORD' && (
<Image src={bible} width={18} height={18} alt={'Wortgottesfeier'} /> <Image src={bible} width={18} height={18} alt={'Wortgottesfeier'} />
)} )}
{type === 'LANGUAGE' && (
<Image src={language} width={18} height={18} alt={'Fremdsprache'} />
)}
</div> </div>
</div> </div>
</Link> </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 type { JSXConvertersFunction } from '@payloadcms/richtext-lexical/react'
import { LinkJSXConverter } from '@payloadcms/richtext-lexical/react'
import type { SerializedLinkNode } from '@payloadcms/richtext-lexical' import type { SerializedLinkNode } from '@payloadcms/richtext-lexical'
import Image from 'next/image'
import { Button } from '@/components/Button/Button' 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'] & { type LinkFields = SerializedLinkNode['fields'] & {
appearance?: 'link' | 'button' appearance?: 'link' | 'button'
} }
// Custom JSX converters passed to <RichText /> wherever rich text is rendered. // Maps an internal Payload document to its public URL based on collection slug.
// Only the `link` converter is overridden — every other node keeps Payload's const internalDocToHref = ({ linkNode }: { linkNode: SerializedLinkNode }): string => {
// default rendering via the spread of `defaultConverters`. 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 }) => ({ export const jsxConverters: JSXConvertersFunction = ({ defaultConverters }) => ({
...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) => { link: (args) => {
const { node, nodesToJSX } = args const { node, nodesToJSX } = args
const fields = node.fields as LinkFields const fields = node.fields as LinkFields
// Normal link → delegate to Payload's built-in link converter. // Normal link → delegate to the internalDocToHref-aware link converter.
// The default converter from the package is a function, but the type
// also allows a static ReactNode, so we narrow before calling.
if (fields?.appearance !== 'button') { if (fields?.appearance !== 'button') {
const defaultLink = defaultConverters.link const linkConverter = linkConverters.link
return typeof defaultLink === 'function' ? defaultLink(args) : defaultLink return typeof linkConverter === 'function' ? linkConverter(args) : linkConverter
} }
// Button link → resolve href the same way Payload's default converter does: // Button link → resolve href using internalDocToHref for internal links.
// internal links point at the related doc's slug, custom links use `url`.
const href = const href =
fields.linkType === 'internal' && typeof fields.doc?.value === 'object' fields.linkType === 'internal'
? `/${(fields.doc.value as { slug?: string }).slug ?? ''}` ? internalDocToHref({ linkNode: node })
: (fields.url ?? '#') : (fields.url ?? '#')
// Render the existing Button component. Schema and size are intentionally
// hardcoded — editors only choose link vs. button, not the styling.
return ( return (
<Button <Button
href={href} href={href}

View file

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

View file

@ -25,6 +25,7 @@ import { EventsBlock } from '@/compositions/Blocks/EventsBlock'
import { ImageCardsBlock } from '@/compositions/Blocks/ImageCardsBlock' import { ImageCardsBlock } from '@/compositions/Blocks/ImageCardsBlock'
import { ClassifiedsFromApi } from '@/components/Classifieds/ClassifiedsFromApi' import { ClassifiedsFromApi } from '@/components/Classifieds/ClassifiedsFromApi'
import { NextPrevButtons } from '@/components/NextPrevButtons/NextPrevButtons' import { NextPrevButtons } from '@/components/NextPrevButtons/NextPrevButtons'
import { GroupEvents } from '@/compositions/GroupEvents/GroupEvents'
type BlocksProps = { type BlocksProps = {
content: Blog['content']['content'] | NonNullable<Page['content']> content: Blog['content']['content'] | NonNullable<Page['content']>
@ -52,7 +53,12 @@ export function Blocks({ content }: BlocksProps) {
return ( return (
<Container key={item.id}> <Container key={item.id}>
<Section padding={'medium'}> <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> </Section>
</Container> </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') { if (item.blockType === 'imageCards') {
return <ImageCardsBlock key={item.id} items={item.items} /> return <ImageCardsBlock key={item.id} items={item.items} />
} }

View file

@ -39,7 +39,14 @@ export function ParishBlocks({ content }: BlocksProps) {
return ( return (
<Container key={item.id}> <Container key={item.id}>
<Section padding={"medium"}> <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> </Section>
</Container> </Container>
) )

View file

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

View file

@ -1,15 +1,9 @@
import { getPayload } from 'payload' import { getPayload } from 'payload'
import config from '@/payload.config' import config from '@/payload.config'
import { Magazine } from '@/payload-types' import { Magazine } from '@/payload-types'
import { PaginatedDocs } from 'payload'
/** export const fetchLastMagazine = async (): Promise<Magazine | undefined> => {
* 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
> => {
const payload = await getPayload({ config }) const payload = await getPayload({ config })
const result = await payload.find({ const result = await payload.find({
collection: 'magazine', collection: 'magazine',
@ -18,3 +12,13 @@ export const fetchLastMagazine = async (): Promise<
}) })
return result.docs[0] 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) { switch (type) {
case 'FAMILY': case 'FAMILY':
return 'Familien Messe' return 'Familien Messe'
case 'WORD': case 'WORD':
return 'Wort-Gottes-Feier' return 'Wort-Gottes-Feier'
case 'LANGUAGE':
return 'Fremdsprache'
case 'OTHER':
return 'Andere'
case 'MASS': case 'MASS':
default: default:
return 'Heilige Messe' return 'Heilige Messe'

View file

@ -42,6 +42,7 @@ type GenerateEventOccurrencesOutput = {
type EventLike = { type EventLike = {
id: string id: string
date?: string | null date?: string | null
endDateTime?: string | null
recurrenceType?: EventRecurrenceType | null recurrenceType?: EventRecurrenceType | null
recurrenceRules?: RecurrenceRule[] | null recurrenceRules?: RecurrenceRule[] | null
endDate?: string | null endDate?: string | null
@ -113,6 +114,17 @@ export const regenerateOccurrencesForEvent = async ({
return { created, deleted, skipped: skipped + 1 } 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' const recurrenceType = event.recurrenceType ?? 'none'
if (recurrenceType === 'none') { if (recurrenceType === 'none') {
@ -124,6 +136,7 @@ export const regenerateOccurrencesForEvent = async ({
data: { data: {
event: event.id, event: event.id,
date: eventDate.toISOString(), date: eventDate.toISOString(),
endDateTime: endFor(eventDate),
cancelled: cancelledDates.has(eventDate.toISOString()), cancelled: cancelledDates.has(eventDate.toISOString()),
generated: true, generated: true,
}, },
@ -144,6 +157,7 @@ export const regenerateOccurrencesForEvent = async ({
data: { data: {
event: event.id, event: event.id,
date: iso, date: iso,
endDateTime: endFor(date),
cancelled: cancelledDates.has(iso), cancelled: cancelledDates.has(iso),
generated: true, generated: true,
}, },

View file

@ -103,7 +103,7 @@ export const generateRecurringMassesTask: TaskConfig<{
for (const rawEntry of schedule) { for (const rawEntry of schedule) {
const entry = rawEntry as ScheduleEntry & { const entry = rawEntry as ScheduleEntry & {
time?: string | Date | null time?: string | Date | null
type?: 'MASS' | 'FAMILY' | 'WORD' type?: 'MASS' | 'FAMILY' | 'WORD' | 'LANGUAGE' | 'OTHER'
defaultCelebrant?: string | null defaultCelebrant?: string | null
defaultTitle?: string | null defaultTitle?: string | null
defaultDescription?: 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_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_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_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 = [ export const migrations = [
{ {
@ -274,6 +278,26 @@ export const migrations = [
{ {
up: migration_20260504_070356_next_prev_buttons.up, up: migration_20260504_070356_next_prev_buttons.up,
down: migration_20260504_070356_next_prev_buttons.down, 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 = { type UpcomingOccurrence = {
id: string id: string
date: string date: string
end?: string
title: string title: string
href: string href: string
location: string location: string
@ -204,6 +205,7 @@ export function EventPage(
<EventRow <EventRow
key={o.id} key={o.id}
date={o.date} date={o.date}
end={o.end}
title={o.title} title={o.title}
href={o.href} href={o.href}
location={o.location} location={o.location}

View file

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

View file

@ -10,7 +10,7 @@ import {
AlignFeature, AlignFeature,
UnorderedListFeature, UnorderedListFeature,
LinkFeature, LinkFeature,
FixedToolbarFeature, FixedToolbarFeature, UploadFeature,
} from '@payloadcms/richtext-lexical' } from '@payloadcms/richtext-lexical'
import path from 'path' import path from 'path'
import { buildConfig } from 'payload' import { buildConfig } from 'payload'
@ -142,6 +142,10 @@ export default buildConfig({
HeadingFeature({ enabledHeadingSizes: ["h3","h4","h5"]}), HeadingFeature({ enabledHeadingSizes: ["h3","h4","h5"]}),
AlignFeature(), AlignFeature(),
UnorderedListFeature(), UnorderedListFeature(),
UploadFeature({
enabledCollections: ['media']
}
),
LinkFeature({ LinkFeature({
fields: ({ defaultFields }) => [ fields: ({ defaultFields }) => [
...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 = { type EventRowItem = {
id: string id: string
date: string date: string
end?: string
title: string title: string
href: string href: string
location: string location: string
@ -51,13 +52,14 @@ export const transformOccurrences = (
occurrences: EventOccurrence[], occurrences: EventOccurrence[],
): EventRowItem[] => { ): EventRowItem[] => {
return occurrences return occurrences
.map((o) => { .map((o): EventRowItem | undefined => {
const event = typeof o.event === 'object' ? o.event : undefined const event = typeof o.event === 'object' ? o.event : undefined
if (!event) return undefined if (!event) return undefined
return { return {
id: o.id, id: o.id,
title: event.title, title: event.title,
date: o.date, date: o.date,
end: o.endDateTime ?? undefined,
href: `/veranstaltungen/${event.id}/${o.id}`, href: `/veranstaltungen/${event.id}/${o.id}`,
location: typeof event.location === 'object' ? event.location.name : 'Unbekannt', location: typeof event.location === 'object' ? event.location.name : 'Unbekannt',
cancelled: Boolean(event.cancelled || o.cancelled), cancelled: Boolean(event.cancelled || o.cancelled),
@ -75,6 +77,7 @@ export const transformEvents = (events: Event[]): EventRowItem[] => {
id: e.id, id: e.id,
title: e.title, title: e.title,
date: e.date, date: e.date,
end: e.endDateTime ?? undefined,
href: `/veranstaltungen/${e.id}`, href: `/veranstaltungen/${e.id}`,
location: typeof e.location === 'object' ? e.location.name : 'Unbekannt', location: typeof e.location === 'object' ? e.location.name : 'Unbekannt',
cancelled: e.cancelled, cancelled: e.cancelled,

View file

@ -4,6 +4,7 @@ import { StaticImageData } from 'next/image'
type Items = { type Items = {
photo: string | Media; photo: string | Media;
caption?: string | null;
id?: string | null; id?: string | null;
}[]; }[];
@ -20,7 +21,8 @@ export const transformGallery = (items: Items) => {
alt: item.photo.alt, alt: item.photo.alt,
id: item.id, id: item.id,
thumbnail, 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 * 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 = { const type = {
'MASS': 'Eucharistiefeier', 'MASS': 'Eucharistiefeier',
'FAMILY': 'Familienmesse', 'FAMILY': 'Familienmesse',
'WORD': 'Wort-Gottes-Feier', 'WORD': 'Wort-Gottes-Feier',
'LANGUAGE': 'Eucharistiefeier in Fremdsprache',
'OTHER': 'Gottesdienst',
} }
return type[category] return type[category] ?? 'Gottesdienst'
} }
/** /**