Compare commits
No commits in common. "cf29fa45b31b94a5dc26f3299795636142f3ff47" and "96daccd92371e072e7b6628688445426d1e6cf5a" have entirely different histories.
cf29fa45b3
...
96daccd923
32 changed files with 226 additions and 55880 deletions
|
|
@ -77,4 +77,4 @@ all:
|
||||||
envs_dir: /opt/church-website/envs
|
envs_dir: /opt/church-website/envs
|
||||||
scripts_dir: /opt/church-website/scripts
|
scripts_dir: /opt/church-website/scripts
|
||||||
repo_url: "{{ vault_repo_url }}"
|
repo_url: "{{ vault_repo_url }}"
|
||||||
repo_branch: chemnitz
|
repo_branch: staging
|
||||||
|
|
|
||||||
|
|
@ -3,34 +3,21 @@ import { fetchBlogPosts } from '@/fetch/blog'
|
||||||
import { BlogExcerpt } from '@/components/BlogExcerpt/BlogExcerpt'
|
import { BlogExcerpt } from '@/components/BlogExcerpt/BlogExcerpt'
|
||||||
import { Section } from '@/components/Section/Section'
|
import { Section } from '@/components/Section/Section'
|
||||||
import { Container } from '@/components/Container/Container'
|
import { Container } from '@/components/Container/Container'
|
||||||
import { NextPrevButtons } from '@/components/NextPrevButtons/NextPrevButtons'
|
|
||||||
import { getPhoto } from '@/utils/dto/gallery'
|
import { getPhoto } from '@/utils/dto/gallery'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
const buildHref = (page: number): string =>
|
export default async function Page() {
|
||||||
page > 1 ? `/blog?page=${page}` : '/blog'
|
const blogs = await fetchBlogPosts(false);
|
||||||
|
|
||||||
export default async function Page({
|
|
||||||
searchParams,
|
|
||||||
}: {
|
|
||||||
searchParams: Promise<{ page?: string }>
|
|
||||||
}) {
|
|
||||||
const query = await searchParams
|
|
||||||
|
|
||||||
const parsedPage = parseInt(query.page ?? '1', 10)
|
|
||||||
const page = Number.isFinite(parsedPage) && parsedPage > 0 ? parsedPage : 1
|
|
||||||
|
|
||||||
const blogs = await fetchBlogPosts(false, page)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={'Aktuelle Nachrichten'}
|
title={"Aktuelle Nachrichten"}
|
||||||
description={'Im Blog der Pfarrei finden Sie aktuelle Nachrichten und erfahren alles über das Gemeindeleben, kommende Veranstaltungen und besondere Gottesdienste.'}
|
description={"Im Blog der Pfarrei finden Sie aktuelle Nachrichten und erfahren alles über das Gemeindeleben, kommende Veranstaltungen und besondere Gottesdienste."}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Section padding={'small'}>
|
<Section padding={"small"}>
|
||||||
<Container>
|
<Container>
|
||||||
{
|
{
|
||||||
blogs?.docs.map(blog =>
|
blogs?.docs.map(blog =>
|
||||||
|
|
@ -44,23 +31,6 @@ export default async function Page({
|
||||||
}
|
}
|
||||||
</Container>
|
</Container>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{(blogs?.hasPrevPage || blogs?.hasNextPage) && (
|
|
||||||
<Section padding={'small'}>
|
|
||||||
<NextPrevButtons
|
|
||||||
prev={
|
|
||||||
blogs.hasPrevPage && blogs.prevPage
|
|
||||||
? { href: buildHref(blogs.prevPage), text: 'Vorige Seite' }
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
next={
|
|
||||||
blogs.hasNextPage && blogs.nextPage
|
|
||||||
? { href: buildHref(blogs.nextPage), text: 'Nächste Seite' }
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Section>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -50,7 +50,7 @@ export default async function WorshipPage({searchParams}: {
|
||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={"Gottesdienste"}
|
title={"Gottesdienste"}
|
||||||
description={"Feiern Sie mit uns den Glauben! Auf dieser Seite finden Sie alle Termine, Details und besondere Highlights unserer Gottesdienste im Überblick. Seien Sie herzlich willkommen! Für genauere Informationen klicken Sie auf den Gottesdiensttermin!"}
|
description={"Erleben Sie unsere Heilige Messe und feiern Sie mit uns! Auf dieser Seite finden Sie alle Termine, Details und besondere Highlights unserer Gottesdienste im Überblick. Seien Sie herzlich willkommen!"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Section padding={"small"} paddingBottom={"large"}>
|
<Section padding={"small"} paddingBottom={"large"}>
|
||||||
|
|
|
||||||
|
|
@ -38,19 +38,6 @@ export const Blog: CollectionConfig = {
|
||||||
de: 'Titel',
|
de: 'Titel',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'pinned',
|
|
||||||
type: 'checkbox',
|
|
||||||
required: true,
|
|
||||||
defaultValue: false,
|
|
||||||
label: {
|
|
||||||
de: 'Angepinnt',
|
|
||||||
},
|
|
||||||
admin: {
|
|
||||||
position: 'sidebar',
|
|
||||||
description: 'Angepinnte Beiträge werden im Blog und auf der Startseite zuerst angezeigt.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
type: 'tabs',
|
type: 'tabs',
|
||||||
tabs: [
|
tabs: [
|
||||||
|
|
@ -130,10 +117,8 @@ export const Blog: CollectionConfig = {
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
defaultSort: ['-pinned', '-createdAt'],
|
|
||||||
admin: {
|
admin: {
|
||||||
useAsTitle: 'title',
|
useAsTitle: 'title',
|
||||||
defaultColumns: ['title', 'pinned', 'updatedAt'],
|
|
||||||
hidden: hide,
|
hidden: hide,
|
||||||
livePreview: {
|
livePreview: {
|
||||||
url: ({ data }) => `/api/draft?url=/blog/${data.id}`,
|
url: ({ data }) => `/api/draft?url=/blog/${data.id}`,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { CollectionConfig } from 'payload'
|
import { CollectionConfig } from 'payload'
|
||||||
import { isAdminOrEmployee } from '@/collections/access/admin'
|
import { isAdminOrEmployee } from '@/collections/access/admin'
|
||||||
import { canMutateOccurrenceOfAssignedEvent } from '@/collections/access/assigned'
|
|
||||||
|
|
||||||
export const EventOccurrences: CollectionConfig = {
|
export const EventOccurrences: CollectionConfig = {
|
||||||
slug: 'eventOccurrence',
|
slug: 'eventOccurrence',
|
||||||
|
|
@ -86,9 +85,7 @@ export const EventOccurrences: CollectionConfig = {
|
||||||
access: {
|
access: {
|
||||||
read: () => true,
|
read: () => true,
|
||||||
create: isAdminOrEmployee(),
|
create: isAdminOrEmployee(),
|
||||||
// Whoever may edit the parent event may cancel/uncancel its occurrences
|
update: isAdminOrEmployee(),
|
||||||
// (used by the "Nächste Termine" table on the event edit view).
|
|
||||||
update: canMutateOccurrenceOfAssignedEvent(),
|
|
||||||
delete: isAdminOrEmployee(),
|
delete: isAdminOrEmployee(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,7 @@
|
||||||
import { CollectionConfig } from 'payload'
|
import { CollectionConfig } from 'payload'
|
||||||
|
import { Group, User } from '@/payload-types'
|
||||||
|
import { fetchEventById } from '@/fetch/events'
|
||||||
import { isPublishedPublic } from '@/collections/access/public'
|
import { isPublishedPublic } from '@/collections/access/public'
|
||||||
import {
|
|
||||||
canCreateAssignedEvent,
|
|
||||||
canMutateAssignedEvent,
|
|
||||||
defaultToOnlyAssigned,
|
|
||||||
filterEventsToAssigned,
|
|
||||||
getAssignedIds,
|
|
||||||
hasAssignedMatch,
|
|
||||||
hideUnlessAssignedAny,
|
|
||||||
toIdArray,
|
|
||||||
unassignedAdditions,
|
|
||||||
} from '@/collections/access/assigned'
|
|
||||||
import { regenerateOccurrencesForEvent } from '@/jobs/generateEventOccurrences'
|
import { regenerateOccurrencesForEvent } from '@/jobs/generateEventOccurrences'
|
||||||
|
|
||||||
export const Events: CollectionConfig = {
|
export const Events: CollectionConfig = {
|
||||||
|
|
@ -131,17 +122,17 @@ export const Events: CollectionConfig = {
|
||||||
label: {
|
label: {
|
||||||
de: 'Gemeinde',
|
de: 'Gemeinde',
|
||||||
},
|
},
|
||||||
defaultValue: defaultToOnlyAssigned('parishes'),
|
validate: (value, options) => {
|
||||||
// A Gläubiger may only ADD their own parishes — unless they are
|
let user = options.req.user
|
||||||
// assigned to a group; group members may attach any parish.
|
|
||||||
// Connections already on the doc may always be kept.
|
if (!user) {
|
||||||
validate: (value: unknown, options: any) => {
|
return 'You are not allowed to do this'
|
||||||
const user = options.req?.user
|
|
||||||
if (!user || user.roles !== 'user') return true
|
|
||||||
if (getAssignedIds(user, 'groups').length > 0) return true
|
|
||||||
if (unassignedAdditions(user, 'parishes', value, options.previousValue).length > 0) {
|
|
||||||
return 'Sie können nur Gemeinden hinzufügen, die Ihnen zugewiesen sind.'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (user.roles === 'user' && value && value.length > 0) {
|
||||||
|
return 'Sie sind nur erlaubt Veranstaltungen für Gruppen zu erstellen.'
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -153,40 +144,38 @@ export const Events: CollectionConfig = {
|
||||||
label: {
|
label: {
|
||||||
de: 'Gruppe',
|
de: 'Gruppe',
|
||||||
},
|
},
|
||||||
defaultValue: defaultToOnlyAssigned('groups'),
|
access: {
|
||||||
// Combined rule for parish AND group (validated once, here):
|
update: ({req: { user}, data}) => {
|
||||||
// a Gläubiger's event needs at least one connection, and at
|
if(user && (user.roles == "admin" || user.roles =="employee")) {
|
||||||
// least one of them must be an own assignment. Additional
|
return true
|
||||||
// foreign connections are allowed.
|
}
|
||||||
validate: (value: unknown, options: any) => {
|
|
||||||
const user = options.req?.user
|
|
||||||
|
|
||||||
// No user = local API (jobs, scripts); unauthenticated REST
|
if(hasGroup(user, data)) {
|
||||||
// writes are already blocked by the collection access.
|
return true
|
||||||
if (!user || user.roles !== 'user') {
|
}
|
||||||
return true
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
validate: (value, options: { req: { user: any } }) => {
|
||||||
|
let user = options.req.user
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return 'You are not allowed to do this'
|
||||||
}
|
}
|
||||||
|
|
||||||
const groupIds = toIdArray(value)
|
if (user.roles === 'user') {
|
||||||
const parishIds = toIdArray(options.data?.parish)
|
if(!Array.isArray(value) || value.length === 0) {
|
||||||
|
return 'Sie müssen die Veranstaltung verknüpfen mit ihrer Gruppe.'
|
||||||
|
}
|
||||||
|
|
||||||
// Parish-assigned users may attach any group; otherwise only own groups may be added.
|
if(!Array.isArray(user.groups) || user.groups.length === 0) {
|
||||||
if (
|
return "Sie sind kein Mitglied einer Gruppe, und können deswegen keine Veranstaltung erstellen."
|
||||||
getAssignedIds(user, 'parishes').length === 0 &&
|
}
|
||||||
unassignedAdditions(user, 'groups', value, options.previousValue).length > 0
|
|
||||||
) {
|
|
||||||
return 'Sie können nur Gruppen hinzufügen, die Ihnen zugewiesen sind.'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (groupIds.length === 0 && parishIds.length === 0) {
|
if(!value.every(id => user.groups.includes(id))) {
|
||||||
return 'Sie müssen die Veranstaltung mit mindestens einer Gruppe oder Gemeinde verknüpfen.'
|
return "Sie sind nur berechtigt Veranstaltungen für ihrer Gruppe zu erstellen"
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
!hasAssignedMatch(user, groupIds, 'groups') &&
|
|
||||||
!hasAssignedMatch(user, parishIds, 'parishes')
|
|
||||||
) {
|
|
||||||
return 'Mindestens eine der ausgewählten Gruppen oder Gemeinden muss Ihnen zugewiesen sein.'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|
@ -380,8 +369,6 @@ export const Events: CollectionConfig = {
|
||||||
],
|
],
|
||||||
admin: {
|
admin: {
|
||||||
useAsTitle: 'title',
|
useAsTitle: 'title',
|
||||||
hidden: hideUnlessAssignedAny(['groups', 'parishes']),
|
|
||||||
baseFilter: filterEventsToAssigned(),
|
|
||||||
livePreview: {
|
livePreview: {
|
||||||
url: ({ data }) => `/api/draft?url=/veranstaltungen/${data.id}`,
|
url: ({ data }) => `/api/draft?url=/veranstaltungen/${data.id}`,
|
||||||
},
|
},
|
||||||
|
|
@ -393,9 +380,26 @@ export const Events: CollectionConfig = {
|
||||||
},
|
},
|
||||||
access: {
|
access: {
|
||||||
read: isPublishedPublic(),
|
read: isPublishedPublic(),
|
||||||
create: canCreateAssignedEvent(),
|
// admins and employees can delete, others only if they are member of the group
|
||||||
update: canMutateAssignedEvent(),
|
delete: async ({ req: { user }, id }) => {
|
||||||
delete: canMutateAssignedEvent(),
|
if (!user) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if(user.roles === 'admin' || user.roles === 'employee')
|
||||||
|
return true
|
||||||
|
|
||||||
|
if(typeof id !== 'string') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = await fetchEventById(id)
|
||||||
|
if (hasGroup(user, event)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
},
|
||||||
},
|
},
|
||||||
hooks: {
|
hooks: {
|
||||||
afterChange: [
|
afterChange: [
|
||||||
|
|
@ -446,3 +450,29 @@ export const Events: CollectionConfig = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if we have
|
||||||
|
* - a user
|
||||||
|
* - data with groups
|
||||||
|
* - the user is member of one of the groups
|
||||||
|
*
|
||||||
|
* @param user
|
||||||
|
* @param data
|
||||||
|
*/
|
||||||
|
const hasGroup = (user: null | User , data: Partial<any> | undefined) => {
|
||||||
|
return user
|
||||||
|
&& user.roles === 'user'
|
||||||
|
&& data
|
||||||
|
&& Array.isArray(data.group)
|
||||||
|
&& data.group.length > 0
|
||||||
|
&& data.group.some((group: string | Group) => {
|
||||||
|
if (!Array.isArray(user.groups)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof group === "string")
|
||||||
|
return user.groups.includes(group)
|
||||||
|
else
|
||||||
|
return user.groups.includes(group.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { CollectionConfig } from 'payload'
|
import { CollectionConfig } from 'payload'
|
||||||
import { isAdminOrEmployee } from '@/collections/access/admin'
|
import { isAdminOrEmployee } from '@/collections/access/admin'
|
||||||
import { filterToAssigned, hideUnlessAssigned, isStaffOrAssigned } from '@/collections/access/assigned'
|
|
||||||
import { ParagraphBlock } from '@/collections/blocks/Paragraph'
|
import { ParagraphBlock } from '@/collections/blocks/Paragraph'
|
||||||
import { GalleryBlock } from '@/collections/blocks/Gallery'
|
import { GalleryBlock } from '@/collections/blocks/Gallery'
|
||||||
import { ContactformBlock } from '@/collections/blocks/Contactform'
|
import { ContactformBlock } from '@/collections/blocks/Contactform'
|
||||||
|
|
@ -106,8 +105,6 @@ export const Groups: CollectionConfig = {
|
||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
useAsTitle: 'name',
|
useAsTitle: 'name',
|
||||||
hidden: hideUnlessAssigned('groups'),
|
|
||||||
baseFilter: filterToAssigned('groups'),
|
|
||||||
livePreview: {
|
livePreview: {
|
||||||
url: ({ data }) => `/api/draft?url=/gruppe/${data.slug}`,
|
url: ({ data }) => `/api/draft?url=/gruppe/${data.slug}`,
|
||||||
},
|
},
|
||||||
|
|
@ -120,7 +117,24 @@ export const Groups: CollectionConfig = {
|
||||||
access: {
|
access: {
|
||||||
read: isPublishedPublic(),
|
read: isPublishedPublic(),
|
||||||
create: isAdminOrEmployee(),
|
create: isAdminOrEmployee(),
|
||||||
update: isStaffOrAssigned('groups'),
|
update: ({ req, id }) => {
|
||||||
|
if (!req.user) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
req.user.roles === 'user' &&
|
||||||
|
id &&
|
||||||
|
req.user.groups?.find(
|
||||||
|
(group) =>
|
||||||
|
group === id || (typeof group === 'object' && group.id === id),
|
||||||
|
) === undefined
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
delete: isAdminOrEmployee(),
|
delete: isAdminOrEmployee(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { CollectionConfig } from 'payload'
|
import { CollectionConfig } from 'payload'
|
||||||
import { revalidateTag } from 'next/cache'
|
import { revalidateTag } from 'next/cache'
|
||||||
import { isAdminOrEmployee } from '@/collections/access/admin'
|
import { hide, isAdminOrEmployee } from '@/collections/access/admin'
|
||||||
import { filterToAssigned, hideUnlessAssigned, isStaffOrAssigned } from '@/collections/access/assigned'
|
|
||||||
import { ParagraphBlock } from '@/collections/blocks/Paragraph'
|
import { ParagraphBlock } from '@/collections/blocks/Paragraph'
|
||||||
import { DocumentBlock } from '@/collections/blocks/Document'
|
import { DocumentBlock } from '@/collections/blocks/Document'
|
||||||
import { ContactformBlock } from '@/collections/blocks/Contactform'
|
import { ContactformBlock } from '@/collections/blocks/Contactform'
|
||||||
|
|
@ -120,8 +119,7 @@ export const Pages: CollectionConfig = {
|
||||||
],
|
],
|
||||||
admin: {
|
admin: {
|
||||||
useAsTitle: 'title',
|
useAsTitle: 'title',
|
||||||
hidden: hideUnlessAssigned('pages'),
|
hidden: hide,
|
||||||
baseFilter: filterToAssigned('pages'),
|
|
||||||
livePreview: {
|
livePreview: {
|
||||||
url: ({ data }) => `/api/draft?url=/${data.slug}`,
|
url: ({ data }) => `/api/draft?url=/${data.slug}`,
|
||||||
},
|
},
|
||||||
|
|
@ -134,7 +132,7 @@ export const Pages: CollectionConfig = {
|
||||||
access: {
|
access: {
|
||||||
read: isPublishedPublic(),
|
read: isPublishedPublic(),
|
||||||
create: isAdminOrEmployee(),
|
create: isAdminOrEmployee(),
|
||||||
update: isStaffOrAssigned('pages'),
|
update: isAdminOrEmployee(),
|
||||||
delete: isAdminOrEmployee(),
|
delete: isAdminOrEmployee(),
|
||||||
},
|
},
|
||||||
hooks: {
|
hooks: {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { CollectionConfig } from 'payload'
|
import { CollectionConfig } from 'payload'
|
||||||
import { isAdmin } from '@/collections/access/admin'
|
import { hide, isAdmin, isAdminOrEmployee } from '@/collections/access/admin'
|
||||||
import { filterToAssigned, hideUnlessAssigned, isStaffOrAssigned } from '@/collections/access/assigned'
|
|
||||||
import { ParagraphBlock } from '@/collections/blocks/Paragraph'
|
import { ParagraphBlock } from '@/collections/blocks/Paragraph'
|
||||||
import { DocumentBlock } from '@/collections/blocks/Document'
|
import { DocumentBlock } from '@/collections/blocks/Document'
|
||||||
import { DonationBlock } from '@/collections/blocks/Donation'
|
import { DonationBlock } from '@/collections/blocks/Donation'
|
||||||
|
|
@ -182,8 +181,7 @@ export const Parish: CollectionConfig = {
|
||||||
],
|
],
|
||||||
admin: {
|
admin: {
|
||||||
useAsTitle: 'name',
|
useAsTitle: 'name',
|
||||||
hidden: hideUnlessAssigned('parishes'),
|
hidden: hide,
|
||||||
baseFilter: filterToAssigned('parishes'),
|
|
||||||
livePreview: {
|
livePreview: {
|
||||||
url: ({ data }) => `/api/draft?url=/gemeinde/${data.slug}`,
|
url: ({ data }) => `/api/draft?url=/gemeinde/${data.slug}`,
|
||||||
},
|
},
|
||||||
|
|
@ -196,7 +194,7 @@ export const Parish: CollectionConfig = {
|
||||||
access: {
|
access: {
|
||||||
read: isPublishedPublic(),
|
read: isPublishedPublic(),
|
||||||
create: isAdmin(),
|
create: isAdmin(),
|
||||||
update: isStaffOrAssigned('parishes'),
|
update: isAdminOrEmployee(),
|
||||||
delete: isAdmin(),
|
delete: isAdmin(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,33 +63,13 @@ export const Users: CollectionConfig = {
|
||||||
{
|
{
|
||||||
name: 'groups',
|
name: 'groups',
|
||||||
label: {
|
label: {
|
||||||
de: 'Gruppen (Bearbeitungsrechte)',
|
de: 'Mitgliedschaft',
|
||||||
},
|
},
|
||||||
type: 'relationship',
|
type: 'relationship',
|
||||||
relationTo: 'group',
|
relationTo: 'group',
|
||||||
hasMany: true,
|
hasMany: true,
|
||||||
maxDepth: 0,
|
maxDepth: 0,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'parishes',
|
|
||||||
label: {
|
|
||||||
de: 'Gemeinden (Bearbeitungsrechte)',
|
|
||||||
},
|
|
||||||
type: 'relationship',
|
|
||||||
relationTo: 'parish',
|
|
||||||
hasMany: true,
|
|
||||||
maxDepth: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'pages',
|
|
||||||
label: {
|
|
||||||
de: 'Seiten (Bearbeitungsrechte)',
|
|
||||||
},
|
|
||||||
type: 'relationship',
|
|
||||||
relationTo: 'pages',
|
|
||||||
hasMany: true,
|
|
||||||
maxDepth: 0,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
access: {
|
access: {
|
||||||
read: isAdminOrEmployee(),
|
read: isAdminOrEmployee(),
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,5 @@
|
||||||
import { CollectionConfig } from 'payload'
|
import { CollectionConfig } from 'payload'
|
||||||
import {
|
import { hide, isAdminOrEmployee } from '@/collections/access/admin'
|
||||||
canCreateAssignedWorship,
|
|
||||||
canMutateAssignedWorship,
|
|
||||||
filterWorshipToAssigned,
|
|
||||||
hideUnlessAssigned,
|
|
||||||
resolveAssignedChurchIds,
|
|
||||||
} from '@/collections/access/assigned'
|
|
||||||
|
|
||||||
export const Worship: CollectionConfig = {
|
export const Worship: CollectionConfig = {
|
||||||
slug: 'worship',
|
slug: 'worship',
|
||||||
|
|
@ -41,17 +35,6 @@ export const Worship: CollectionConfig = {
|
||||||
type: 'relationship',
|
type: 'relationship',
|
||||||
relationTo: 'church',
|
relationTo: 'church',
|
||||||
required: true,
|
required: true,
|
||||||
// The update access Where only constrains which docs may be targeted;
|
|
||||||
// this keeps a Gläubiger from moving a worship doc to a foreign church.
|
|
||||||
validate: async (value: unknown, options: any) => {
|
|
||||||
const user = options.req?.user
|
|
||||||
if (!user || user.roles !== 'user') return true
|
|
||||||
const churchIds = await resolveAssignedChurchIds(options.req, user)
|
|
||||||
if (!value || !churchIds.includes(String(value))) {
|
|
||||||
return 'Sie sind nur berechtigt, Gottesdienste für Kirchen Ihrer zugewiesenen Gemeinden zu verwalten.'
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'type',
|
name: 'type',
|
||||||
|
|
@ -142,13 +125,12 @@ export const Worship: CollectionConfig = {
|
||||||
admin: {
|
admin: {
|
||||||
defaultColumns: ["date", 'location', 'type', 'celebrant'],
|
defaultColumns: ["date", 'location', 'type', 'celebrant'],
|
||||||
listSearchableFields: ['date', 'location'],
|
listSearchableFields: ['date', 'location'],
|
||||||
hidden: hideUnlessAssigned('parishes'),
|
hidden: hide
|
||||||
baseFilter: filterWorshipToAssigned(),
|
|
||||||
},
|
},
|
||||||
access: {
|
access: {
|
||||||
read: () => true,
|
read: () => true,
|
||||||
create: canCreateAssignedWorship(),
|
create: isAdminOrEmployee(),
|
||||||
update: canMutateAssignedWorship(),
|
update: isAdminOrEmployee(),
|
||||||
delete: canMutateAssignedWorship(),
|
delete: isAdminOrEmployee(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,233 +0,0 @@
|
||||||
import type { Access, BaseFilter, ClientUser, PayloadRequest, Where } from 'payload'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Assignment fields on the Users collection: each holds the ids of the
|
|
||||||
* documents a Gläubiger (role 'user') is allowed to edit.
|
|
||||||
*/
|
|
||||||
export type AssignmentField = 'groups' | 'parishes' | 'pages'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize a user's assignment field to an array of id strings.
|
|
||||||
* The fields use maxDepth: 0 so values are plain ids, but handle
|
|
||||||
* populated objects defensively.
|
|
||||||
*/
|
|
||||||
export const getAssignedIds = (user: unknown, field: AssignmentField): string[] => {
|
|
||||||
const value = (user as { [K in AssignmentField]?: unknown } | null | undefined)?.[field]
|
|
||||||
return toIdArray(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Normalize any relationship value (single or hasMany, id or populated doc) to id strings. */
|
|
||||||
export const toIdArray = (value: unknown): string[] => {
|
|
||||||
if (value == null) return []
|
|
||||||
const arr = Array.isArray(value) ? value : [value]
|
|
||||||
return arr.map((v) =>
|
|
||||||
typeof v === 'object' && v !== null ? String((v as { id: unknown }).id) : String(v),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isStaff = (user: { roles?: unknown } | null | undefined): boolean =>
|
|
||||||
user?.roles === 'admin' || user?.roles === 'employee'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update access: admin/employee always; role 'user' only for documents
|
|
||||||
* they are assigned to. The collection-level probe (no id) is granted
|
|
||||||
* only if the user has at least one assignment.
|
|
||||||
*/
|
|
||||||
export const isStaffOrAssigned =
|
|
||||||
(field: AssignmentField): Access =>
|
|
||||||
({ req: { user }, id }) => {
|
|
||||||
if (!user) return false
|
|
||||||
if (user.roles === 'admin' || user.roles === 'employee') return true
|
|
||||||
if (user.roles !== 'user') return false
|
|
||||||
const ids = getAssignedIds(user, field)
|
|
||||||
if (ids.length === 0) return false
|
|
||||||
if (id === undefined) return true
|
|
||||||
return ids.includes(String(id))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Admin base filter: staff see everything (null = unfiltered); role 'user'
|
|
||||||
* sees only assigned documents. An empty id list matches nothing.
|
|
||||||
*/
|
|
||||||
export const filterToAssigned =
|
|
||||||
(field: AssignmentField): BaseFilter =>
|
|
||||||
({ req: { user } }) => {
|
|
||||||
if (!user || user.roles !== 'user') return null
|
|
||||||
return { id: { in: getAssignedIds(user, field) } }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* admin.hidden: hide the collection from role 'user' unless they have at
|
|
||||||
* least one assignment; never hidden for staff.
|
|
||||||
*/
|
|
||||||
export const hideUnlessAssigned =
|
|
||||||
(field: AssignmentField) =>
|
|
||||||
({ user }: { user: ClientUser }): boolean => {
|
|
||||||
if (!user || user.roles !== 'user') return false
|
|
||||||
return getAssignedIds(user, field).length === 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* admin.hidden: hide the collection from role 'user' unless they have at
|
|
||||||
* least one assignment in ANY of the given fields.
|
|
||||||
*/
|
|
||||||
export const hideUnlessAssignedAny =
|
|
||||||
(fields: AssignmentField[]) =>
|
|
||||||
({ user }: { user: ClientUser }): boolean => {
|
|
||||||
if (!user || user.roles !== 'user') return false
|
|
||||||
return fields.every((field) => getAssignedIds(user, field).length === 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True if at least one of the given ids is in the user's assignment field. */
|
|
||||||
export const hasAssignedMatch = (user: unknown, ids: unknown, field: AssignmentField): boolean => {
|
|
||||||
const assigned = getAssignedIds(user, field)
|
|
||||||
return toIdArray(ids).some((id) => assigned.includes(id))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* defaultValue for hasMany relationship fields: when a Gläubiger has exactly
|
|
||||||
* one assignment, pre-fill it on new documents. Staff get no pre-fill.
|
|
||||||
*/
|
|
||||||
export const defaultToOnlyAssigned =
|
|
||||||
(field: AssignmentField) =>
|
|
||||||
({ user }: { user: unknown }): string[] | undefined => {
|
|
||||||
if ((user as { roles?: unknown } | null)?.roles !== 'user') return undefined
|
|
||||||
const ids = getAssignedIds(user, field)
|
|
||||||
return ids.length === 1 ? ids : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ids in `value` that are neither carried over from `previousValue` nor among
|
|
||||||
* the user's own assignments — i.e. foreign connections the user is trying to
|
|
||||||
* ADD. Existing foreign connections (set by staff) are not flagged.
|
|
||||||
*/
|
|
||||||
export const unassignedAdditions = (
|
|
||||||
user: unknown,
|
|
||||||
field: AssignmentField,
|
|
||||||
value: unknown,
|
|
||||||
previousValue: unknown,
|
|
||||||
): string[] => {
|
|
||||||
const assigned = getAssignedIds(user, field)
|
|
||||||
const previous = toIdArray(previousValue)
|
|
||||||
return toIdArray(value).filter((id) => !previous.includes(id) && !assigned.includes(id))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Worship: a worship doc has no parish field — only `location` (church).
|
|
||||||
// A Gläubiger's scope is the union of churches of their assigned parishes.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Resolve the user's assigned parishes to the union of their church ids. */
|
|
||||||
export const resolveAssignedChurchIds = async (
|
|
||||||
req: PayloadRequest,
|
|
||||||
user: unknown,
|
|
||||||
): Promise<string[]> => {
|
|
||||||
const parishIds = getAssignedIds(user, 'parishes')
|
|
||||||
if (parishIds.length === 0) return []
|
|
||||||
const { docs } = await req.payload.find({
|
|
||||||
collection: 'parish',
|
|
||||||
where: { id: { in: parishIds } },
|
|
||||||
depth: 0,
|
|
||||||
pagination: false,
|
|
||||||
select: { churches: true },
|
|
||||||
req,
|
|
||||||
})
|
|
||||||
return [...new Set(docs.flatMap((doc) => toIdArray((doc as { churches?: unknown }).churches)))]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Worship create access. The admin UI probes create permission without data;
|
|
||||||
* the actual create always carries data, where `location` is enforced (and
|
|
||||||
* additionally validated on the field for a German error message).
|
|
||||||
*/
|
|
||||||
export const canCreateAssignedWorship = (): Access =>
|
|
||||||
async ({ req, data }) => {
|
|
||||||
const user = req.user
|
|
||||||
if (!user) return false
|
|
||||||
if (isStaff(user)) return true
|
|
||||||
if (user.roles !== 'user') return false
|
|
||||||
const churchIds = await resolveAssignedChurchIds(req, user)
|
|
||||||
if (churchIds.length === 0) return false
|
|
||||||
if (data?.location === undefined) return true
|
|
||||||
return churchIds.includes(String(data.location))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Worship update/delete access: staff always; role 'user' only within their churches. */
|
|
||||||
export const canMutateAssignedWorship = (): Access =>
|
|
||||||
async ({ req }) => {
|
|
||||||
const user = req.user
|
|
||||||
if (!user) return false
|
|
||||||
if (isStaff(user)) return true
|
|
||||||
if (user.roles !== 'user') return false
|
|
||||||
const churchIds = await resolveAssignedChurchIds(req, user)
|
|
||||||
if (churchIds.length === 0) return false
|
|
||||||
return { location: { in: churchIds } } as Where
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Worship admin list filter: staff unfiltered; role 'user' only their churches. */
|
|
||||||
export const filterWorshipToAssigned = (): BaseFilter =>
|
|
||||||
async ({ req }) => {
|
|
||||||
const user = req.user
|
|
||||||
if (!user || user.roles !== 'user') return null
|
|
||||||
return { location: { in: await resolveAssignedChurchIds(req, user) } }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Events: connected to groups AND/OR parishes; one own match grants access.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Where matching events with at least one assigned group OR parish.
|
|
||||||
* `prefix: 'event.'` targets the parent event from the occurrence collection.
|
|
||||||
* Returns false when the user has no assignments at all.
|
|
||||||
*/
|
|
||||||
export const eventMatchWhere = (user: unknown, prefix = ''): false | Where => {
|
|
||||||
const groupIds = getAssignedIds(user, 'groups')
|
|
||||||
const parishIds = getAssignedIds(user, 'parishes')
|
|
||||||
const or: Where[] = []
|
|
||||||
if (groupIds.length > 0) or.push({ [`${prefix}group`]: { in: groupIds } })
|
|
||||||
if (parishIds.length > 0) or.push({ [`${prefix}parish`]: { in: parishIds } })
|
|
||||||
if (or.length === 0) return false
|
|
||||||
return { or }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Event create access: staff always; role 'user' with at least one group or
|
|
||||||
* parish assignment. Which connections the new event must carry is enforced
|
|
||||||
* by the combined validate on the `group` field.
|
|
||||||
*/
|
|
||||||
export const canCreateAssignedEvent = (): Access =>
|
|
||||||
({ req: { user } }) => {
|
|
||||||
if (!user) return false
|
|
||||||
if (isStaff(user)) return true
|
|
||||||
if (user.roles !== 'user') return false
|
|
||||||
return (
|
|
||||||
getAssignedIds(user, 'groups').length > 0 || getAssignedIds(user, 'parishes').length > 0
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Event update/delete access: staff always; role 'user' via one-match Where. */
|
|
||||||
export const canMutateAssignedEvent = (): Access =>
|
|
||||||
({ req: { user } }) => {
|
|
||||||
if (!user) return false
|
|
||||||
if (isStaff(user)) return true
|
|
||||||
if (user.roles !== 'user') return false
|
|
||||||
return eventMatchWhere(user)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Events admin list filter: staff unfiltered; role 'user' one-match filter. */
|
|
||||||
export const filterEventsToAssigned = (): BaseFilter =>
|
|
||||||
({ req: { user } }) => {
|
|
||||||
if (!user || user.roles !== 'user') return null
|
|
||||||
const where = eventMatchWhere(user)
|
|
||||||
return where === false ? { id: { in: [] } } : where
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Occurrence update access: whoever may edit the parent event may toggle its occurrences. */
|
|
||||||
export const canMutateOccurrenceOfAssignedEvent = (): Access =>
|
|
||||||
({ req: { user } }) => {
|
|
||||||
if (!user) return false
|
|
||||||
if (isStaff(user)) return true
|
|
||||||
if (user.roles !== 'user') return false
|
|
||||||
return eventMatchWhere(user, 'event.')
|
|
||||||
}
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 634px;
|
height: 634px;
|
||||||
background-color: $shade1;
|
background-color: $shade1;
|
||||||
opacity: 1;
|
opacity: 0.7;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
bottom: 20px;
|
bottom: 20px;
|
||||||
left: 30px;
|
left: 30px;
|
||||||
}
|
}
|
||||||
|
|
@ -23,6 +24,7 @@
|
||||||
color: $white;
|
color: $white;
|
||||||
font-family: var(--header-font);
|
font-family: var(--header-font);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
bottom: 50px;
|
bottom: 50px;
|
||||||
right: 0;
|
right: 0;
|
||||||
width: 50vw;
|
width: 50vw;
|
||||||
|
|
|
||||||
|
|
@ -29,17 +29,6 @@ export const EventWithEnd: Story = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MultiDayEvent: Story = {
|
|
||||||
args: {
|
|
||||||
date: '2026-07-24T18:00:00+02:00',
|
|
||||||
end: '2026-07-26T14:00:00+02:00',
|
|
||||||
title: 'Gemeindewochenende',
|
|
||||||
href: 'https://www.link_to_event.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',
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { useEffect, useMemo, useState } from 'react'
|
||||||
import styles from "./styles.module.scss"
|
import styles from "./styles.module.scss"
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { formatEventDate } from '@/utils/formatEventDate'
|
|
||||||
|
|
||||||
export type EventRowProps = {
|
export type EventRowProps = {
|
||||||
/** datetime 8601 format */
|
/** datetime 8601 format */
|
||||||
|
|
@ -42,6 +41,8 @@ const shortMonth = (date: string) => {
|
||||||
return months[month - 1];
|
return months[month - 1];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const EventRow = ({date, end, 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]);
|
||||||
|
|
@ -79,7 +80,10 @@ export const EventRow = ({date, end, title, location, cancelled, href, color = "
|
||||||
<span className={classNames({ [styles.cancelled]: cancelled })}>
|
<span className={classNames({ [styles.cancelled]: cancelled })}>
|
||||||
{ showDate &&
|
{ showDate &&
|
||||||
<>
|
<>
|
||||||
{formatEventDate(dateObj, endObj, dayFormat)}
|
{dateObj.toLocaleDateString("de-DE", { weekday: "long" })}
|
||||||
|
{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 />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,3 @@ export const Default: Story = {
|
||||||
'Wie die drei Weisen aus dem Morgenland wollen wir uns immer wieder neu auf den Weg machen.',
|
'Wie die drei Weisen aus dem Morgenland wollen wir uns immer wieder neu auf den Weg machen.',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Default2: Story = {
|
|
||||||
args: {
|
|
||||||
text: 'Herzlich willkommen auf der Homepage der Pfarrei Heilige Mutter Teresa. Als katholische Christen bringen wir uns in ökumenischer Verbundenheit als kreative Minderheit in Chemnitz und Umgebung ein.',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
line-height: 168%;
|
line-height: 168%;
|
||||||
font-size: 36px;
|
font-size: 36px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
text-wrap: balance;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 576px) {
|
@media screen and (max-width: 576px) {
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,8 @@ import config from '@/payload.config'
|
||||||
* Fetches blog posts based on given criteria.
|
* Fetches blog posts based on given criteria.
|
||||||
*
|
*
|
||||||
* @param {boolean} displayOnFrontpage - Indicates whether to display posts on the front page.
|
* @param {boolean} displayOnFrontpage - Indicates whether to display posts on the front page.
|
||||||
* @param {number} page - Page number for pagination.
|
|
||||||
*/
|
*/
|
||||||
export const fetchBlogPosts = async (displayOnFrontpage: boolean, page: number = 1) => {
|
export const fetchBlogPosts = async (displayOnFrontpage: boolean) => {
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
today.setHours(23, 59)
|
today.setHours(23, 59)
|
||||||
|
|
||||||
|
|
@ -60,17 +59,15 @@ export const fetchBlogPosts = async (displayOnFrontpage: boolean, page: number =
|
||||||
const payload = await getPayload({ config })
|
const payload = await getPayload({ config })
|
||||||
return payload.find({
|
return payload.find({
|
||||||
collection: 'blog',
|
collection: 'blog',
|
||||||
sort: ['-pinned', '-createdAt'],
|
sort: '-date',
|
||||||
select: {
|
select: {
|
||||||
title: true,
|
title: true,
|
||||||
date: true,
|
date: true,
|
||||||
photo: true,
|
photo: true,
|
||||||
pinned: true,
|
|
||||||
content: displayOnFrontpage ? undefined : true,
|
content: displayOnFrontpage ? undefined : true,
|
||||||
},
|
},
|
||||||
where: query,
|
where: query,
|
||||||
limit: 18,
|
limit: 18,
|
||||||
page,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,15 +43,7 @@ const getUpcomingOccurrences = unstable_cache(
|
||||||
|
|
||||||
const query: any = {
|
const query: any = {
|
||||||
and: [
|
and: [
|
||||||
// Overlap semantics: an occurrence stays "upcoming" while it is still
|
{ date: { greater_than_equal: fromDate } },
|
||||||
// running, not only before it starts. endDateTime is null for
|
|
||||||
// occurrences without an end; those fall back to the start date.
|
|
||||||
{
|
|
||||||
or: [
|
|
||||||
{ date: { greater_than_equal: fromDate } },
|
|
||||||
{ endDateTime: { greater_than_equal: fromDate } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ 'event._status': { equals: 'published' } },
|
{ 'event._status': { equals: 'published' } },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,87 @@
|
||||||
import { getPayload } from 'payload'
|
import { getPayload, PaginatedDocs } from 'payload'
|
||||||
import config from '@/payload.config'
|
import config from '@/payload.config'
|
||||||
import { Event } from '@/payload-types'
|
import { Event } from '@/payload-types'
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
parishId?: string
|
||||||
|
groupId?: string
|
||||||
|
limit?: number
|
||||||
|
page?: number
|
||||||
|
fromDate?: Date
|
||||||
|
toDate?: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a list of events
|
||||||
|
*/
|
||||||
|
export async function fetchEvents(
|
||||||
|
args?: Args,
|
||||||
|
): Promise<PaginatedDocs<Event>> {
|
||||||
|
const {
|
||||||
|
parishId,
|
||||||
|
groupId,
|
||||||
|
limit = 30,
|
||||||
|
page = 0,
|
||||||
|
fromDate = new Date(),
|
||||||
|
toDate,
|
||||||
|
} = args || {}
|
||||||
|
|
||||||
|
const query: any = {
|
||||||
|
and: [
|
||||||
|
{
|
||||||
|
'_status': {
|
||||||
|
equals: 'published',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: {
|
||||||
|
greater_than_equal: fromDate.toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toDate) {
|
||||||
|
query.and.push({
|
||||||
|
date: {
|
||||||
|
less_than: toDate.toISOString(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parishId) {
|
||||||
|
query.and.push({
|
||||||
|
parish: {
|
||||||
|
equals: parishId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupId) {
|
||||||
|
query.and.push({
|
||||||
|
group: {
|
||||||
|
equals: groupId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await getPayload({ config })
|
||||||
|
return payload.find({
|
||||||
|
collection: 'event',
|
||||||
|
sort: 'date',
|
||||||
|
where: query,
|
||||||
|
select: {
|
||||||
|
location: true,
|
||||||
|
date: true,
|
||||||
|
title: true,
|
||||||
|
cancelled: true,
|
||||||
|
},
|
||||||
|
depth: 1,
|
||||||
|
limit,
|
||||||
|
page,
|
||||||
|
}) as Promise<PaginatedDocs<Event>>
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch a single event by ID
|
* Fetch a single event by ID
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,41 +0,0 @@
|
||||||
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-07-19T09:00:32.028Z';
|
|
||||||
ALTER TABLE "calendar" ALTER COLUMN "date" SET DEFAULT '2026-07-19T09:00:32.325Z';
|
|
||||||
ALTER TABLE "blog_blocks_contactform" ALTER COLUMN "email" SET DEFAULT 'chemnitz@pfarrei-bddmei.de';
|
|
||||||
ALTER TABLE "_blog_v_blocks_contactform" ALTER COLUMN "email" SET DEFAULT 'chemnitz@pfarrei-bddmei.de';
|
|
||||||
ALTER TABLE "classifieds" ALTER COLUMN "until" SET DEFAULT '2026-08-15T09:00:32.384Z';
|
|
||||||
ALTER TABLE "group_blocks_contactform" ALTER COLUMN "email" SET DEFAULT 'chemnitz@pfarrei-bddmei.de';
|
|
||||||
ALTER TABLE "_group_v_blocks_contactform" ALTER COLUMN "email" SET DEFAULT 'chemnitz@pfarrei-bddmei.de';
|
|
||||||
ALTER TABLE "pages_blocks_contactform" ALTER COLUMN "email" SET DEFAULT 'chemnitz@pfarrei-bddmei.de';
|
|
||||||
ALTER TABLE "_pages_v_blocks_contactform" ALTER COLUMN "email" SET DEFAULT 'chemnitz@pfarrei-bddmei.de';
|
|
||||||
ALTER TABLE "users_rels" ADD COLUMN "parish_id" uuid;
|
|
||||||
ALTER TABLE "users_rels" ADD COLUMN "pages_id" uuid;
|
|
||||||
ALTER TABLE "users_rels" ADD CONSTRAINT "users_rels_parish_fk" FOREIGN KEY ("parish_id") REFERENCES "public"."parish"("id") ON DELETE cascade ON UPDATE no action;
|
|
||||||
ALTER TABLE "users_rels" ADD CONSTRAINT "users_rels_pages_fk" FOREIGN KEY ("pages_id") REFERENCES "public"."pages"("id") ON DELETE cascade ON UPDATE no action;
|
|
||||||
CREATE INDEX "users_rels_parish_id_idx" ON "users_rels" USING btree ("parish_id");
|
|
||||||
CREATE INDEX "users_rels_pages_id_idx" ON "users_rels" USING btree ("pages_id");`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
|
||||||
await db.execute(sql`
|
|
||||||
ALTER TABLE "users_rels" DROP CONSTRAINT "users_rels_parish_fk";
|
|
||||||
|
|
||||||
ALTER TABLE "users_rels" DROP CONSTRAINT "users_rels_pages_fk";
|
|
||||||
|
|
||||||
DROP INDEX "users_rels_parish_id_idx";
|
|
||||||
DROP INDEX "users_rels_pages_id_idx";
|
|
||||||
ALTER TABLE "announcement" ALTER COLUMN "date" SET DEFAULT '2026-06-14T08:49:19.434Z';
|
|
||||||
ALTER TABLE "calendar" ALTER COLUMN "date" SET DEFAULT '2026-06-14T08:49:19.726Z';
|
|
||||||
ALTER TABLE "blog_blocks_contactform" ALTER COLUMN "email" SET DEFAULT 'kontakt@mutter-teresa-chemnitz.de';
|
|
||||||
ALTER TABLE "_blog_v_blocks_contactform" ALTER COLUMN "email" SET DEFAULT 'kontakt@mutter-teresa-chemnitz.de';
|
|
||||||
ALTER TABLE "classifieds" ALTER COLUMN "until" SET DEFAULT '2026-07-11T08:49:19.782Z';
|
|
||||||
ALTER TABLE "group_blocks_contactform" ALTER COLUMN "email" SET DEFAULT 'kontakt@mutter-teresa-chemnitz.de';
|
|
||||||
ALTER TABLE "_group_v_blocks_contactform" ALTER COLUMN "email" SET DEFAULT 'kontakt@mutter-teresa-chemnitz.de';
|
|
||||||
ALTER TABLE "pages_blocks_contactform" ALTER COLUMN "email" SET DEFAULT 'kontakt@mutter-teresa-chemnitz.de';
|
|
||||||
ALTER TABLE "_pages_v_blocks_contactform" ALTER COLUMN "email" SET DEFAULT 'kontakt@mutter-teresa-chemnitz.de';
|
|
||||||
ALTER TABLE "users_rels" DROP COLUMN "parish_id";
|
|
||||||
ALTER TABLE "users_rels" DROP COLUMN "pages_id";`)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,19 +0,0 @@
|
||||||
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-07-19T11:32:04.857Z';
|
|
||||||
ALTER TABLE "calendar" ALTER COLUMN "date" SET DEFAULT '2026-07-19T11:32:05.134Z';
|
|
||||||
ALTER TABLE "classifieds" ALTER COLUMN "until" SET DEFAULT '2026-08-15T11:32:05.193Z';
|
|
||||||
ALTER TABLE "blog" ADD COLUMN "pinned" boolean DEFAULT false;
|
|
||||||
ALTER TABLE "_blog_v" ADD COLUMN "version_pinned" boolean DEFAULT false;`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
|
||||||
await db.execute(sql`
|
|
||||||
ALTER TABLE "announcement" ALTER COLUMN "date" SET DEFAULT '2026-07-19T09:00:32.028Z';
|
|
||||||
ALTER TABLE "calendar" ALTER COLUMN "date" SET DEFAULT '2026-07-19T09:00:32.325Z';
|
|
||||||
ALTER TABLE "classifieds" ALTER COLUMN "until" SET DEFAULT '2026-08-15T09:00:32.384Z';
|
|
||||||
ALTER TABLE "blog" DROP COLUMN "pinned";
|
|
||||||
ALTER TABLE "_blog_v" DROP COLUMN "version_pinned";`)
|
|
||||||
}
|
|
||||||
|
|
@ -51,8 +51,6 @@ import * as migration_20260605_130843_add_worship_language_other from './2026060
|
||||||
import * as migration_20260609_113259_parish_gallery_caption from './20260609_113259_parish_gallery_caption';
|
import * as migration_20260609_113259_parish_gallery_caption from './20260609_113259_parish_gallery_caption';
|
||||||
import * as migration_20260611_072650_add_contact_person_to_group from './20260611_072650_add_contact_person_to_group';
|
import * as migration_20260611_072650_add_contact_person_to_group from './20260611_072650_add_contact_person_to_group';
|
||||||
import * as migration_20260611_084920_add_image_with_text_block from './20260611_084920_add_image_with_text_block';
|
import * as migration_20260611_084920_add_image_with_text_block from './20260611_084920_add_image_with_text_block';
|
||||||
import * as migration_20260716_090032_add_user_parish_page_assignments from './20260716_090032_add_user_parish_page_assignments';
|
|
||||||
import * as migration_20260716_113205_add_blog_pinned from './20260716_113205_add_blog_pinned';
|
|
||||||
|
|
||||||
export const migrations = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
|
|
@ -318,16 +316,6 @@ export const migrations = [
|
||||||
{
|
{
|
||||||
up: migration_20260611_084920_add_image_with_text_block.up,
|
up: migration_20260611_084920_add_image_with_text_block.up,
|
||||||
down: migration_20260611_084920_add_image_with_text_block.down,
|
down: migration_20260611_084920_add_image_with_text_block.down,
|
||||||
name: '20260611_084920_add_image_with_text_block',
|
name: '20260611_084920_add_image_with_text_block'
|
||||||
},
|
|
||||||
{
|
|
||||||
up: migration_20260716_090032_add_user_parish_page_assignments.up,
|
|
||||||
down: migration_20260716_090032_add_user_parish_page_assignments.down,
|
|
||||||
name: '20260716_090032_add_user_parish_page_assignments',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
up: migration_20260716_113205_add_blog_pinned.up,
|
|
||||||
down: migration_20260716_113205_add_blog_pinned.down,
|
|
||||||
name: '20260716_113205_add_blog_pinned'
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -124,10 +124,3 @@ export const WithOccurrences: Story = {
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WithOccurrencesAndGroup: Story = {
|
|
||||||
args: {
|
|
||||||
...WithOccurrences.args,
|
|
||||||
group: 'some_group',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { Container } from '@/components/Container/Container'
|
||||||
import { Col } from '@/components/Flex/Col'
|
import { Col } from '@/components/Flex/Col'
|
||||||
import { Pill } from '@/components/Pill/Pill'
|
import { Pill } from '@/components/Pill/Pill'
|
||||||
import { useDate } from '@/hooks/useCompactDate'
|
import { useDate } from '@/hooks/useCompactDate'
|
||||||
import { formatEventDate } from '@/utils/formatEventDate'
|
import { readableDateTime } from '@/utils/readableDate'
|
||||||
import { TextDiv } from '@/components/Text/TextDiv'
|
import { TextDiv } from '@/components/Text/TextDiv'
|
||||||
import { Button } from '@/components/Button/Button'
|
import { Button } from '@/components/Button/Button'
|
||||||
import Image, { StaticImageData } from 'next/image'
|
import Image, { StaticImageData } from 'next/image'
|
||||||
|
|
@ -72,7 +72,7 @@ export function EventPage(
|
||||||
}: EventProps
|
}: EventProps
|
||||||
) {
|
) {
|
||||||
const published = useDate(createdAt)
|
const published = useDate(createdAt)
|
||||||
const readableDate = formatEventDate(new Date(date), endDateTime ? new Date(endDateTime) : undefined, "long")
|
const readableDate = readableDateTime(date, endDateTime)
|
||||||
const where = locationString(location);
|
const where = locationString(location);
|
||||||
const contactPersonPhoto = typeof contact === "object" ? getPhoto("thumbnail", contact.photo) : undefined;
|
const contactPersonPhoto = typeof contact === "object" ? getPhoto("thumbnail", contact.photo) : undefined;
|
||||||
const isRecurring = recurrenceType && recurrenceType !== 'none'
|
const isRecurring = recurrenceType && recurrenceType !== 'none'
|
||||||
|
|
@ -93,13 +93,12 @@ export function EventPage(
|
||||||
|
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<div className={styles.headerText}>
|
<div className={styles.headerText}>
|
||||||
|
<p>
|
||||||
<div className={styles.published}>
|
|
||||||
Publiziert am {published}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{shortDescription}
|
{shortDescription}
|
||||||
</div>
|
</p>
|
||||||
|
<p className={styles.published}>
|
||||||
|
Publiziert am {published}
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className={styles.pills}>
|
<div className={styles.pills}>
|
||||||
{ isRecurring &&
|
{ isRecurring &&
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.pills {
|
.pills {
|
||||||
margin-top: 10px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1068,10 +1068,6 @@ export interface Blog {
|
||||||
id: string;
|
id: string;
|
||||||
photo?: (string | null) | Media;
|
photo?: (string | null) | Media;
|
||||||
title: string;
|
title: string;
|
||||||
/**
|
|
||||||
* Angepinnte Beiträge werden im Blog und auf der Startseite zuerst angezeigt.
|
|
||||||
*/
|
|
||||||
pinned: boolean;
|
|
||||||
content: {
|
content: {
|
||||||
excerpt: string;
|
excerpt: string;
|
||||||
content: (
|
content: (
|
||||||
|
|
@ -1360,8 +1356,6 @@ export interface User {
|
||||||
name: string;
|
name: string;
|
||||||
roles: 'user' | 'employee' | 'admin';
|
roles: 'user' | 'employee' | 'admin';
|
||||||
groups?: (string | Group)[] | null;
|
groups?: (string | Group)[] | null;
|
||||||
parishes?: (string | Parish)[] | null;
|
|
||||||
pages?: (string | Page)[] | null;
|
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
|
@ -1381,7 +1375,7 @@ export interface User {
|
||||||
password?: string | null;
|
password?: string | null;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Automatisch erzeugte Suchergebnisse. Sie werden von der Website-Suche verwendet und aktualisieren sich selbst, sobald Inhalte erstellt oder geändert werden.
|
* This is a collection of automatically created search results. These results are used by the global site search and will be updated automatically as documents in the CMS are created or updated.
|
||||||
*
|
*
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "search".
|
* via the `definition` "search".
|
||||||
|
|
@ -1890,7 +1884,6 @@ export interface CalendarSelect<T extends boolean = true> {
|
||||||
export interface BlogSelect<T extends boolean = true> {
|
export interface BlogSelect<T extends boolean = true> {
|
||||||
photo?: T;
|
photo?: T;
|
||||||
title?: T;
|
title?: T;
|
||||||
pinned?: T;
|
|
||||||
content?:
|
content?:
|
||||||
| T
|
| T
|
||||||
| {
|
| {
|
||||||
|
|
@ -2614,8 +2607,6 @@ export interface UsersSelect<T extends boolean = true> {
|
||||||
name?: T;
|
name?: T;
|
||||||
roles?: T;
|
roles?: T;
|
||||||
groups?: T;
|
groups?: T;
|
||||||
parishes?: T;
|
|
||||||
pages?: T;
|
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
email?: T;
|
email?: T;
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,6 @@ import { siteConfig } from '@/config/site'
|
||||||
import { generateRecurringMassesTask } from '@/jobs/generateRecurringMasses'
|
import { generateRecurringMassesTask } from '@/jobs/generateRecurringMasses'
|
||||||
import { generateEventOccurrencesTask } from '@/jobs/generateEventOccurrences'
|
import { generateEventOccurrencesTask } from '@/jobs/generateEventOccurrences'
|
||||||
import { searchPlugin } from '@payloadcms/plugin-search'
|
import { searchPlugin } from '@payloadcms/plugin-search'
|
||||||
import { hide } from '@/collections/access/admin'
|
|
||||||
|
|
||||||
const filename = fileURLToPath(import.meta.url)
|
const filename = fileURLToPath(import.meta.url)
|
||||||
const dirname = path.dirname(filename)
|
const dirname = path.dirname(filename)
|
||||||
|
|
@ -227,20 +226,6 @@ export default buildConfig({
|
||||||
// read each referenced document's `slug` to build URLs like
|
// read each referenced document's `slug` to build URLs like
|
||||||
// /gemeinde/[slug] and /gruppe/[slug].
|
// /gemeinde/[slug] and /gruppe/[slug].
|
||||||
searchOverrides: {
|
searchOverrides: {
|
||||||
labels: {
|
|
||||||
singular: {
|
|
||||||
de: 'Suchergebnis',
|
|
||||||
},
|
|
||||||
plural: {
|
|
||||||
de: 'Suchergebnisse',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
admin: {
|
|
||||||
hidden: hide,
|
|
||||||
description: {
|
|
||||||
de: 'Automatisch erzeugte Suchergebnisse. Sie werden von der Website-Suche verwendet und aktualisieren sich selbst, sobald Inhalte erstellt oder geändert werden.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
fields: ({ defaultFields }) =>
|
fields: ({ defaultFields }) =>
|
||||||
defaultFields.map((field) =>
|
defaultFields.map((field) =>
|
||||||
'name' in field && field.name === 'doc' && field.type === 'relationship'
|
'name' in field && field.name === 'doc' && field.type === 'relationship'
|
||||||
|
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
import { describe, expect, test } from 'vitest'
|
|
||||||
import { formatEventDate } from './formatEventDate'
|
|
||||||
|
|
||||||
// Fri 24.07.2026 18:00 Berlin time
|
|
||||||
const start = new Date('2026-07-24T18:00:00+02:00')
|
|
||||||
const endSameDay = new Date('2026-07-24T20:00:00+02:00')
|
|
||||||
const endNextDay = new Date('2026-07-25T01:00:00+02:00')
|
|
||||||
const endSunday = new Date('2026-07-26T14:00:00+02:00')
|
|
||||||
|
|
||||||
describe('format "long"', () => {
|
|
||||||
test('start only', () => {
|
|
||||||
expect(formatEventDate(start)).toBe(`Freitag 24.07.26, 18:00 Uhr`)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('same-day end renders as time range', () => {
|
|
||||||
expect(formatEventDate(start, endSameDay)).toBe(`Freitag 24.07.26, 18:00 - 20:00 Uhr`)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('multi-day end renders both dates', () => {
|
|
||||||
expect(formatEventDate(start, endSunday)).toBe(
|
|
||||||
`Fr. 24.07. 18:00 Uhr - So. 26.07. 14:00 Uhr`,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('end after midnight counts as multi-day', () => {
|
|
||||||
expect(formatEventDate(start, endNextDay)).toBe(
|
|
||||||
`Fr. 24.07. 18:00 Uhr - Sa. 25.07. 01:00 Uhr`,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('format "short"', () => {
|
|
||||||
test('start only', () => {
|
|
||||||
expect(formatEventDate(start, undefined, 'short')).toBe(`Freitag, 18:00 Uhr`)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('same-day end renders as time range without date', () => {
|
|
||||||
expect(formatEventDate(start, endSameDay, 'short')).toBe(`Freitag, 18:00 - 20:00 Uhr`)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('multi-day end renders compact weekday range', () => {
|
|
||||||
expect(formatEventDate(start, endSunday, 'short')).toBe(`Fr 18:00 - So 14:00 Uhr`)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('timezone handling', () => {
|
|
||||||
test('calendar day is compared in Europe/Berlin, not UTC', () => {
|
|
||||||
const lateStart = new Date('2026-07-24T22:30:00Z')
|
|
||||||
const earlyEnd = new Date('2026-07-25T01:00:00Z')
|
|
||||||
expect(formatEventDate(lateStart, earlyEnd)).toBe(`Samstag 25.07.26, 00:30 - 03:00 Uhr`)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
/**
|
|
||||||
* Format an event's date and time range in a user-friendly way.
|
|
||||||
*
|
|
||||||
* format "long" (default):
|
|
||||||
* - same day: "Freitag 24.07.26, 18:00 - 20:00 Uhr"
|
|
||||||
* - multi-day: "Fr. 24.07. 18:00 Uhr - So. 26.07. 14:00 Uhr"
|
|
||||||
*
|
|
||||||
* format "short" (narrow screens):
|
|
||||||
* - same day: "Freitag, 18:00 - 20:00 Uhr"
|
|
||||||
* - multi-day: "Fr 18:00 - So 14:00 Uhr"
|
|
||||||
*/
|
|
||||||
export const formatEventDate = (start: Date, end?: Date, format: "long" | "short" = "long"): string => {
|
|
||||||
const time = (d: Date) => d.toLocaleTimeString("de-DE", { timeStyle: "short", timeZone: "Europe/Berlin" })
|
|
||||||
const calendarDay = (d: Date) => d.toLocaleDateString("de-DE", { timeZone: "Europe/Berlin" })
|
|
||||||
const weekday = (d: Date, length: "long" | "short") =>
|
|
||||||
d.toLocaleDateString("de-DE", { weekday: length, timeZone: "Europe/Berlin" })
|
|
||||||
const isMultiDay = end && calendarDay(start) !== calendarDay(end)
|
|
||||||
|
|
||||||
switch (format) {
|
|
||||||
case "short": {
|
|
||||||
if (end && isMultiDay) {
|
|
||||||
return `${weekday(start, "short")} ${time(start)} - ${weekday(end, "short")} ${time(end)} Uhr`
|
|
||||||
}
|
|
||||||
const endTime = end ? ` - ${time(end)}` : ""
|
|
||||||
return `${weekday(start, "long")}, ${time(start)}${endTime} Uhr`
|
|
||||||
}
|
|
||||||
|
|
||||||
case "long": {
|
|
||||||
if (end && isMultiDay) {
|
|
||||||
// "Fr. 24.07." — assembled manually because Intl puts a comma between weekday and date
|
|
||||||
const dayDate = (d: Date) =>
|
|
||||||
`${weekday(d, "short")}. ${d.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", timeZone: "Europe/Berlin" })}`
|
|
||||||
return `${dayDate(start)} ${time(start)} Uhr - ${dayDate(end)} ${time(end)} Uhr`
|
|
||||||
}
|
|
||||||
const endTime = end ? ` - ${time(end)}` : ""
|
|
||||||
return `${weekday(start, "long")} ${start.toLocaleDateString("de-DE", { dateStyle: "short", timeZone: "Europe/Berlin" })}, ${time(start)}${endTime} Uhr`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue