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.
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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 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} />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hover:hover {
|
.contact a:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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" })}</> : "" }
|
||||||
|
Uhr
|
||||||
<br />
|
<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 { 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" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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";
|
@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
|
|
@ -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: '',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
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 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}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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_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'
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
| {
|
| {
|
||||||
|
|
|
||||||
|
|
@ -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
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 = {
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue