Compare commits
12 commits
edaf940748
...
a12e460448
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a12e460448 | ||
|
|
d1c05bfeb8 | ||
|
|
57a9a1c966 | ||
|
|
1ac42e51db | ||
|
|
5ee3d48434 | ||
|
|
15a4db17c8 | ||
|
|
14056701d0 | ||
|
|
a85c0e609a | ||
|
|
776bc6ff25 | ||
|
|
6b960aef19 | ||
|
|
da56be94a3 | ||
|
|
f175afaf19 |
59 changed files with 105381 additions and 1478 deletions
BIN
rollen-berechtigungen.pdf
Normal file
BIN
rollen-berechtigungen.pdf
Normal file
Binary file not shown.
83
src/app/(home)/pfarrei/magazin/archiv/page.tsx
Normal file
83
src/app/(home)/pfarrei/magazin/archiv/page.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
23
src/app/(home)/pfarrei/magazin/archiv/styles.module.scss
Normal file
23
src/app/(home)/pfarrei/magazin/archiv/styles.module.scss
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -20,6 +20,13 @@ export const GalleryBlock: Block = {
|
|||
relationTo: 'media',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'caption',
|
||||
label: {
|
||||
de: 'Bildunterschrift'
|
||||
},
|
||||
type: 'text'
|
||||
},
|
||||
],
|
||||
minRows: 3,
|
||||
maxRows: 12
|
||||
|
|
|
|||
24
src/collections/blocks/GroupEvents.ts
Normal file
24
src/collections/blocks/GroupEvents.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -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} />
|
||||
</>
|
||||
}
|
||||
{
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
color: inherit;
|
||||
}
|
||||
|
||||
.hover:hover {
|
||||
.contact a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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" })}</> : "" }
|
||||
Uhr
|
||||
<br />
|
||||
</>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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" },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
.wrapper {
|
||||
overflow: scroll;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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: '',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
24
src/components/MassTable/language.svg
Normal file
24
src/components/MassTable/language.svg
Normal 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 |
23
src/components/SecureEmail/SecureEmail.stories.tsx
Normal file
23
src/components/SecureEmail/SecureEmail.stories.tsx
Normal 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'
|
||||
}
|
||||
}
|
||||
59
src/components/SecureEmail/SecureEmail.tsx
Normal file
59
src/components/SecureEmail/SecureEmail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@
|
|||
color: $base-color;
|
||||
}
|
||||
|
||||
.container img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.container h3 {
|
||||
font-size: 33px;
|
||||
font-weight: 700;
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
25980
src/migrations/20260605_095112.json
Normal file
25980
src/migrations/20260605_095112.json
Normal file
File diff suppressed because it is too large
Load diff
50
src/migrations/20260605_095112.ts
Normal file
50
src/migrations/20260605_095112.ts
Normal 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';`)
|
||||
}
|
||||
26001
src/migrations/20260605_113107_occurrence_end_time.json
Normal file
26001
src/migrations/20260605_113107_occurrence_end_time.json
Normal file
File diff suppressed because it is too large
Load diff
19
src/migrations/20260605_113107_occurrence_end_time.ts
Normal file
19
src/migrations/20260605_113107_occurrence_end_time.ts
Normal 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";`)
|
||||
}
|
||||
26037
src/migrations/20260605_124103_gallery_caption.json
Normal file
26037
src/migrations/20260605_124103_gallery_caption.json
Normal file
File diff suppressed because it is too large
Load diff
27
src/migrations/20260605_124103_gallery_caption.ts
Normal file
27
src/migrations/20260605_124103_gallery_caption.ts
Normal 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";`)
|
||||
}
|
||||
26041
src/migrations/20260605_130843_add_worship_language_other.json
Normal file
26041
src/migrations/20260605_130843_add_worship_language_other.json
Normal file
File diff suppressed because it is too large
Load diff
27
src/migrations/20260605_130843_add_worship_language_other.ts
Normal file
27
src/migrations/20260605_130843_add_worship_language_other.ts
Normal 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';`)
|
||||
}
|
||||
|
|
@ -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'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
| {
|
||||
|
|
|
|||
|
|
@ -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
18
src/utils/dto/email.ts
Normal 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)
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in a new issue