feature: readability and ux
This commit is contained in:
parent
20b0c0a768
commit
f489ac9d9b
13 changed files with 379 additions and 126 deletions
|
|
@ -0,0 +1,166 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { CheckboxInput, useDocumentInfo } from '@payloadcms/ui'
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
type Occurrence = {
|
||||||
|
id: string
|
||||||
|
date: string
|
||||||
|
cancelled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const LIMIT = 10
|
||||||
|
|
||||||
|
const formatDate = (iso: string): string =>
|
||||||
|
new Date(iso).toLocaleString('de-DE', {
|
||||||
|
weekday: 'short',
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const NextOccurrencesTable = () => {
|
||||||
|
const { id } = useDocumentInfo()
|
||||||
|
const [occurrences, setOccurrences] = useState<Occurrence[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [pendingIds, setPendingIds] = useState<Set<string>>(new Set())
|
||||||
|
const [errorIds, setErrorIds] = useState<Set<string>>(new Set())
|
||||||
|
const [fetchError, setFetchError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return
|
||||||
|
let cancelled = false
|
||||||
|
setLoading(true)
|
||||||
|
setFetchError(null)
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
'where[event][equals]': String(id),
|
||||||
|
'where[date][greater_than_equal]': new Date().toISOString(),
|
||||||
|
sort: 'date',
|
||||||
|
limit: String(LIMIT),
|
||||||
|
depth: '0',
|
||||||
|
})
|
||||||
|
|
||||||
|
fetch(`/api/eventOccurrence?${params.toString()}`, { credentials: 'include' })
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
return res.json()
|
||||||
|
})
|
||||||
|
.then((json: { docs: Occurrence[] }) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setOccurrences(json.docs)
|
||||||
|
})
|
||||||
|
.catch((err: Error) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setFetchError(err.message)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [id])
|
||||||
|
|
||||||
|
const toggle = useCallback(async (occ: Occurrence) => {
|
||||||
|
const next = !occ.cancelled
|
||||||
|
setOccurrences((prev) =>
|
||||||
|
prev.map((o) => (o.id === occ.id ? { ...o, cancelled: next } : o)),
|
||||||
|
)
|
||||||
|
setPendingIds((prev) => new Set(prev).add(occ.id))
|
||||||
|
setErrorIds((prev) => {
|
||||||
|
const s = new Set(prev)
|
||||||
|
s.delete(occ.id)
|
||||||
|
return s
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/eventOccurrence/${occ.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ cancelled: next }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
} catch {
|
||||||
|
setOccurrences((prev) =>
|
||||||
|
prev.map((o) => (o.id === occ.id ? { ...o, cancelled: occ.cancelled } : o)),
|
||||||
|
)
|
||||||
|
setErrorIds((prev) => new Set(prev).add(occ.id))
|
||||||
|
} finally {
|
||||||
|
setPendingIds((prev) => {
|
||||||
|
const s = new Set(prev)
|
||||||
|
s.delete(occ.id)
|
||||||
|
return s
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return (
|
||||||
|
<div className="field-description">
|
||||||
|
Veranstaltung erst speichern, um Termine zu verwalten.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="field-description">Wird geladen…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fetchError) {
|
||||||
|
return (
|
||||||
|
<div className="field-description" style={{ color: 'var(--theme-error-500)' }}>
|
||||||
|
Fehler beim Laden: {fetchError}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (occurrences.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="field-description">
|
||||||
|
Keine zukünftigen Termine vorhanden.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Datum</th>
|
||||||
|
<th>Abgesagt</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{occurrences.map((occ) => (
|
||||||
|
<tr key={occ.id}>
|
||||||
|
<td>{formatDate(occ.date)}</td>
|
||||||
|
<td>
|
||||||
|
<CheckboxInput
|
||||||
|
checked={occ.cancelled}
|
||||||
|
onToggle={() => toggle(occ)}
|
||||||
|
readOnly={pendingIds.has(occ.id)}
|
||||||
|
/>
|
||||||
|
{errorIds.has(occ.id) && (
|
||||||
|
<div
|
||||||
|
className="field-description"
|
||||||
|
style={{ color: 'var(--theme-error-500)' }}
|
||||||
|
>
|
||||||
|
Speichern fehlgeschlagen.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NextOccurrencesTable
|
||||||
|
|
@ -4,9 +4,8 @@ import { Blocks } from '@/compositions/Blocks/Blocks'
|
||||||
import { Metadata } from 'next'
|
import { Metadata } from 'next'
|
||||||
import { AdminMenu } from '@/components/AdminMenu/AdminMenu'
|
import { AdminMenu } from '@/components/AdminMenu/AdminMenu'
|
||||||
import { Section } from '@/components/Section/Section'
|
import { Section } from '@/components/Section/Section'
|
||||||
import { isAuthenticated } from '@/utils/auth'
|
import { getRequestAuth } from '@/utils/auth'
|
||||||
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave/RefreshRouteOnSave'
|
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave/RefreshRouteOnSave'
|
||||||
import { draftMode } from 'next/headers'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
params: Promise<{ slug?: string[] }>
|
params: Promise<{ slug?: string[] }>
|
||||||
|
|
@ -26,9 +25,8 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
|
|
||||||
export default async function DynamicPage({ params }: Props) {
|
export default async function DynamicPage({ params }: Props) {
|
||||||
const slug = (await params).slug
|
const slug = (await params).slug
|
||||||
const { isEnabled: isDraft } = await draftMode()
|
const { authenticated, isDraft } = await getRequestAuth()
|
||||||
const page = await fetchPageBySlug(slug?.join('/') || "", isDraft)
|
const page = await fetchPageBySlug(slug?.join('/') || "", isDraft)
|
||||||
const authenticated = await isAuthenticated()
|
|
||||||
|
|
||||||
if (!page) {
|
if (!page) {
|
||||||
notFound()
|
notFound()
|
||||||
|
|
|
||||||
|
|
@ -9,27 +9,19 @@ import { HR } from '@/components/HorizontalRule/HorizontalRule'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import styles from './styles.module.scss'
|
import styles from './styles.module.scss'
|
||||||
import { Blocks } from '@/compositions/Blocks/Blocks'
|
import { Blocks } from '@/compositions/Blocks/Blocks'
|
||||||
import { isAuthenticated } from '@/utils/auth'
|
import { canView, getRequestAuth } from '@/utils/auth'
|
||||||
import { AdminMenu } from '@/components/AdminMenu/AdminMenu'
|
import { AdminMenu } from '@/components/AdminMenu/AdminMenu'
|
||||||
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave/RefreshRouteOnSave'
|
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave/RefreshRouteOnSave'
|
||||||
import { draftMode } from 'next/headers'
|
|
||||||
import { fetchBlog } from '@/fetch/blog'
|
import { fetchBlog } from '@/fetch/blog'
|
||||||
|
|
||||||
export default async function BlogPage({ params }: { params: Promise<{id: string}>}){
|
export default async function BlogPage({ params }: { params: Promise<{id: string}>}){
|
||||||
|
|
||||||
const id = (await params).id;
|
const id = (await params).id;
|
||||||
const { isEnabled: isDraft } = await draftMode()
|
const { authenticated, isDraft } = await getRequestAuth()
|
||||||
const data = await fetchBlog(id, isDraft) as Blog;
|
const data = await fetchBlog(id, isDraft) as Blog;
|
||||||
const url = typeof data.photo === 'object' && data.photo?.sizes?.banner?.url;
|
const url = typeof data.photo === 'object' && data.photo?.sizes?.banner?.url;
|
||||||
const authenticated = await isAuthenticated();
|
|
||||||
|
|
||||||
if(!data) {
|
if (!canView(data, authenticated)) notFound()
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
if(!authenticated && data._status !== "published") {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
// determine if some margin at the bottom should be added
|
// determine if some margin at the bottom should be added
|
||||||
const length = data.content.content.length;
|
const length = data.content.content.length;
|
||||||
|
|
|
||||||
|
|
@ -6,25 +6,17 @@ import { fetchParish } from '@/fetch/parish'
|
||||||
import { fetchLastAnnouncement } from '@/fetch/announcement'
|
import { fetchLastAnnouncement } from '@/fetch/announcement'
|
||||||
import { getPhoto, transformGallery } from '@/utils/dto/gallery'
|
import { getPhoto, transformGallery } from '@/utils/dto/gallery'
|
||||||
import { fetchLastCalendar } from '@/fetch/calendar'
|
import { fetchLastCalendar } from '@/fetch/calendar'
|
||||||
import { isAuthenticated } from '@/utils/auth'
|
import { canView, getRequestAuth } from '@/utils/auth'
|
||||||
import { AdminMenu } from '@/components/AdminMenu/AdminMenu'
|
import { AdminMenu } from '@/components/AdminMenu/AdminMenu'
|
||||||
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave/RefreshRouteOnSave'
|
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave/RefreshRouteOnSave'
|
||||||
import { draftMode } from 'next/headers'
|
|
||||||
|
|
||||||
export default async function ParishPage ({ params }: { params: Promise<{slug: string}>}) {
|
export default async function ParishPage ({ params }: { params: Promise<{slug: string}>}) {
|
||||||
|
|
||||||
const slug = (await params).slug;
|
const slug = (await params).slug;
|
||||||
const { isEnabled: isDraft } = await draftMode()
|
const { authenticated, isDraft } = await getRequestAuth()
|
||||||
const parish = await fetchParish(slug, isDraft);
|
const parish = await fetchParish(slug, isDraft);
|
||||||
const authenticated = await isAuthenticated();
|
|
||||||
|
|
||||||
if(!parish) {
|
if (!canView(parish, authenticated)) notFound()
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
if(!authenticated && parish._status !== "published") {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
id,
|
id,
|
||||||
|
|
|
||||||
|
|
@ -11,28 +11,19 @@ import { Col } from '@/components/Flex/Col'
|
||||||
import { Row } from '@/components/Flex/Row'
|
import { Row } from '@/components/Flex/Row'
|
||||||
import { Blocks } from '@/compositions/Blocks/Blocks'
|
import { Blocks } from '@/compositions/Blocks/Blocks'
|
||||||
import { getPhoto } from '@/utils/dto/gallery'
|
import { getPhoto } from '@/utils/dto/gallery'
|
||||||
import { isAuthenticated } from '@/utils/auth'
|
import { canView, getRequestAuth } from '@/utils/auth'
|
||||||
import { AdminMenu } from '@/components/AdminMenu/AdminMenu'
|
import { AdminMenu } from '@/components/AdminMenu/AdminMenu'
|
||||||
import { GroupEvents } from '@/compositions/GroupEvents/GroupEvents'
|
import { GroupEvents } from '@/compositions/GroupEvents/GroupEvents'
|
||||||
import { RichText } from '@/components/Text/RichText'
|
import { RichText } from '@/components/Text/RichText'
|
||||||
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave/RefreshRouteOnSave'
|
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave/RefreshRouteOnSave'
|
||||||
import { draftMode } from 'next/headers'
|
|
||||||
|
|
||||||
export default async function GroupPage({ params }: { params: Promise<{slug: string}>}) {
|
export default async function GroupPage({ params }: { params: Promise<{slug: string}>}) {
|
||||||
|
|
||||||
const slug = (await params).slug
|
const slug = (await params).slug
|
||||||
const { isEnabled: isDraft } = await draftMode()
|
const { authenticated, isDraft } = await getRequestAuth()
|
||||||
const group = await fetchGroup(slug, isDraft)
|
const group = await fetchGroup(slug, isDraft)
|
||||||
|
|
||||||
if(!group) {
|
if (!canView(group, authenticated)) notFound()
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
const authenticated = await isAuthenticated();
|
|
||||||
|
|
||||||
if(!authenticated && group._status !== "published") {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
const {id, shortDescription, photo, name, content, text } = group
|
const {id, shortDescription, photo, name, content, text } = group
|
||||||
const media = getPhoto("tablet", photo)
|
const media = getPhoto("tablet", photo)
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,13 @@
|
||||||
import { notFound } from 'next/navigation'
|
import { notFound } from 'next/navigation'
|
||||||
import { draftMode } from 'next/headers'
|
|
||||||
import { EventPage } from '@/pageComponents/Event/Event'
|
import { EventPage } from '@/pageComponents/Event/Event'
|
||||||
import { getPhoto } from '@/utils/dto/gallery'
|
import { getPhoto } from '@/utils/dto/gallery'
|
||||||
import { isAuthenticated } from '@/utils/auth'
|
import { canView, getRequestAuth } from '@/utils/auth'
|
||||||
import {
|
import {
|
||||||
fetchOccurrenceById,
|
fetchOccurrenceById,
|
||||||
fetchUpcomingOccurrences,
|
fetchUpcomingOccurrences,
|
||||||
} from '@/fetch/eventOccurrences'
|
} from '@/fetch/eventOccurrences'
|
||||||
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave/RefreshRouteOnSave'
|
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave/RefreshRouteOnSave'
|
||||||
import { transformOccurrences } from '@/utils/dto/events'
|
import { eventToPageProps, transformOccurrences } from '@/utils/dto/events'
|
||||||
|
|
||||||
export default async function Page({
|
export default async function Page({
|
||||||
params,
|
params,
|
||||||
|
|
@ -16,22 +15,15 @@ export default async function Page({
|
||||||
params: Promise<{ eventId: string; occurrenceId: string }>
|
params: Promise<{ eventId: string; occurrenceId: string }>
|
||||||
}) {
|
}) {
|
||||||
const { eventId, occurrenceId } = await params
|
const { eventId, occurrenceId } = await params
|
||||||
const { isEnabled: isDraft } = await draftMode()
|
const { authenticated, isDraft } = await getRequestAuth()
|
||||||
|
|
||||||
const occurrence = await fetchOccurrenceById(occurrenceId, isDraft)
|
const occurrence = await fetchOccurrenceById(occurrenceId, isDraft)
|
||||||
if (!occurrence) notFound()
|
if (!occurrence) notFound()
|
||||||
|
|
||||||
const event = typeof occurrence.event === 'object' ? occurrence.event : undefined
|
const event = typeof occurrence.event === 'object' ? occurrence.event : undefined
|
||||||
if (!event) notFound()
|
if (!canView(event, authenticated)) notFound()
|
||||||
if (event.id !== eventId) notFound()
|
if (event.id !== eventId) notFound()
|
||||||
|
|
||||||
const authenticated = await isAuthenticated()
|
|
||||||
if (!authenticated && event._status !== 'published') notFound()
|
|
||||||
|
|
||||||
const group =
|
|
||||||
Array.isArray(event.group) && event.group.length > 0 && typeof event.group[0] === 'object'
|
|
||||||
? event.group[0].slug
|
|
||||||
: undefined
|
|
||||||
const photo = getPhoto('tablet', event.photo)
|
const photo = getPhoto('tablet', event.photo)
|
||||||
|
|
||||||
const upcomingRaw = await fetchUpcomingOccurrences({
|
const upcomingRaw = await fetchUpcomingOccurrences({
|
||||||
|
|
@ -45,19 +37,7 @@ export default async function Page({
|
||||||
<>
|
<>
|
||||||
{isDraft && <RefreshRouteOnSave />}
|
{isDraft && <RefreshRouteOnSave />}
|
||||||
<EventPage
|
<EventPage
|
||||||
id={event.id}
|
{...eventToPageProps(event, occurrence)}
|
||||||
title={event.title}
|
|
||||||
date={occurrence.date}
|
|
||||||
createdAt={event.createdAt}
|
|
||||||
cancelled={event.cancelled || occurrence.cancelled}
|
|
||||||
recurrenceType={event.recurrenceType}
|
|
||||||
location={event.location}
|
|
||||||
description={event.description}
|
|
||||||
shortDescription={event.shortDescription}
|
|
||||||
group={group}
|
|
||||||
contact={event.contact || undefined}
|
|
||||||
rsvpLink={event.rsvpLink || undefined}
|
|
||||||
flyer={typeof event.flyer === 'object' ? event.flyer || undefined : undefined}
|
|
||||||
photo={photo}
|
photo={photo}
|
||||||
isAuthenticated={authenticated}
|
isAuthenticated={authenticated}
|
||||||
upcomingOccurrences={upcomingOccurrences}
|
upcomingOccurrences={upcomingOccurrences}
|
||||||
|
|
|
||||||
|
|
@ -1,79 +1,31 @@
|
||||||
import { notFound, redirect } from 'next/navigation'
|
import { notFound } from 'next/navigation'
|
||||||
import { draftMode } from 'next/headers'
|
|
||||||
import { EventPage } from '@/pageComponents/Event/Event'
|
import { EventPage } from '@/pageComponents/Event/Event'
|
||||||
import { getPhoto } from '@/utils/dto/gallery'
|
import { getPhoto } from '@/utils/dto/gallery'
|
||||||
import { isAuthenticated } from '@/utils/auth'
|
import { canView, getRequestAuth } from '@/utils/auth'
|
||||||
import { fetchEventById } from '@/fetch/events'
|
import { fetchEventById } from '@/fetch/events'
|
||||||
import { fetchUpcomingOccurrences } from '@/fetch/eventOccurrences'
|
import { fetchUpcomingOrPastOccurrences } from '@/fetch/eventOccurrences'
|
||||||
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave/RefreshRouteOnSave'
|
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave/RefreshRouteOnSave'
|
||||||
import { getPayload } from 'payload'
|
import { eventToPageProps, transformOccurrences } from '@/utils/dto/events'
|
||||||
import config from '@/payload.config'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Entry route per event. Redirects to the current next-upcoming occurrence
|
|
||||||
* when one exists so shared/search URLs land on the canonical detail page.
|
|
||||||
* Falls back to an inline render when there are no occurrences yet (fresh
|
|
||||||
* event, draft preview) so live preview stays responsive.
|
|
||||||
*/
|
|
||||||
export default async function Page({ params }: { params: Promise<{ eventId: string }> }) {
|
export default async function Page({ params }: { params: Promise<{ eventId: string }> }) {
|
||||||
const { eventId } = await params
|
const { eventId } = await params
|
||||||
const { isEnabled: isDraft } = await draftMode()
|
const { authenticated, isDraft } = await getRequestAuth()
|
||||||
const authenticated = await isAuthenticated()
|
|
||||||
|
|
||||||
const event = await fetchEventById(eventId, isDraft)
|
const event = await fetchEventById(eventId, isDraft)
|
||||||
if (!event) notFound()
|
if (!canView(event, authenticated)) notFound()
|
||||||
if (!authenticated && event._status !== 'published') notFound()
|
|
||||||
|
|
||||||
// Try next upcoming first, then most recent past as a fallback.
|
const occurrences = await fetchUpcomingOrPastOccurrences({ eventId, limit: 5 })
|
||||||
const upcoming = await fetchUpcomingOccurrences({ eventId, limit: 1 })
|
const upcomingOccurrences = transformOccurrences(occurrences.docs)
|
||||||
if (upcoming.docs.length > 0) {
|
|
||||||
redirect(`/veranstaltungen/${eventId}/${upcoming.docs[0].id}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = await getPayload({ config })
|
|
||||||
const past = await payload.find({
|
|
||||||
collection: 'eventOccurrence',
|
|
||||||
where: {
|
|
||||||
and: [
|
|
||||||
{ event: { equals: eventId } },
|
|
||||||
{ date: { less_than: new Date().toISOString() } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
sort: '-date',
|
|
||||||
limit: 1,
|
|
||||||
depth: 0,
|
|
||||||
})
|
|
||||||
if (past.docs.length > 0) {
|
|
||||||
redirect(`/veranstaltungen/${eventId}/${past.docs[0].id}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// No occurrences yet (e.g. draft preview right after save). Render from
|
|
||||||
// the event's own date so editors still see their content.
|
|
||||||
const group =
|
|
||||||
Array.isArray(event.group) && event.group.length > 0 && typeof event.group[0] === 'object'
|
|
||||||
? event.group[0].slug
|
|
||||||
: undefined
|
|
||||||
const photo = getPhoto('tablet', event.photo)
|
const photo = getPhoto('tablet', event.photo)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isDraft && <RefreshRouteOnSave />}
|
{isDraft && <RefreshRouteOnSave />}
|
||||||
<EventPage
|
<EventPage
|
||||||
id={event.id}
|
{...eventToPageProps(event)}
|
||||||
title={event.title}
|
|
||||||
date={event.date}
|
|
||||||
createdAt={event.createdAt}
|
|
||||||
cancelled={event.cancelled}
|
|
||||||
recurrenceType={event.recurrenceType}
|
|
||||||
location={event.location}
|
|
||||||
description={event.description}
|
|
||||||
shortDescription={event.shortDescription}
|
|
||||||
group={group}
|
|
||||||
contact={event.contact || undefined}
|
|
||||||
rsvpLink={event.rsvpLink || undefined}
|
|
||||||
flyer={typeof event.flyer === 'object' ? event.flyer || undefined : undefined}
|
|
||||||
photo={photo}
|
photo={photo}
|
||||||
isAuthenticated={authenticated}
|
isAuthenticated={authenticated}
|
||||||
|
upcomingOccurrences={upcomingOccurrences}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1e
|
||||||
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { NextOccurrencesTable as NextOccurrencesTable_5b561f1308e1f6674a6813c6174350fc } from '@/admin/components/NextOccurrencesTable/NextOccurrencesTable'
|
||||||
import { LinkToDoc as LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634 } from '@payloadcms/plugin-search/client'
|
import { LinkToDoc as LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634 } from '@payloadcms/plugin-search/client'
|
||||||
import { ReindexButton as ReindexButton_aead06e4cbf6b2620c5c51c9ab283634 } from '@payloadcms/plugin-search/client'
|
import { ReindexButton as ReindexButton_aead06e4cbf6b2620c5c51c9ab283634 } from '@payloadcms/plugin-search/client'
|
||||||
import { default as default_9bcae99938dc292be0063ce32055e14c } from '../../../components/Logo/Logo'
|
import { default as default_9bcae99938dc292be0063ce32055e14c } from '../../../components/Logo/Logo'
|
||||||
|
|
@ -33,6 +34,7 @@ export const importMap = {
|
||||||
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@/admin/components/NextOccurrencesTable/NextOccurrencesTable#NextOccurrencesTable": NextOccurrencesTable_5b561f1308e1f6674a6813c6174350fc,
|
||||||
"@payloadcms/plugin-search/client#LinkToDoc": LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634,
|
"@payloadcms/plugin-search/client#LinkToDoc": LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634,
|
||||||
"@payloadcms/plugin-search/client#ReindexButton": ReindexButton_aead06e4cbf6b2620c5c51c9ab283634,
|
"@payloadcms/plugin-search/client#ReindexButton": ReindexButton_aead06e4cbf6b2620c5c51c9ab283634,
|
||||||
"/components/Logo/Logo#default": default_9bcae99938dc292be0063ce32055e14c,
|
"/components/Logo/Logo#default": default_9bcae99938dc292be0063ce32055e14c,
|
||||||
|
|
|
||||||
|
|
@ -226,6 +226,22 @@ export const Events: CollectionConfig = {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: { de: 'Nächste Termine' },
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'nextOccurrences',
|
||||||
|
type: 'ui',
|
||||||
|
admin: {
|
||||||
|
components: {
|
||||||
|
Field: {
|
||||||
|
path: '@/admin/components/NextOccurrencesTable/NextOccurrencesTable#NextOccurrencesTable',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -265,13 +281,31 @@ export const Events: CollectionConfig = {
|
||||||
},
|
},
|
||||||
hooks: {
|
hooks: {
|
||||||
afterChange: [
|
afterChange: [
|
||||||
async ({ doc, req }) => {
|
async ({ doc, previousDoc, req }) => {
|
||||||
try {
|
try {
|
||||||
await regenerateOccurrencesForEvent({
|
await regenerateOccurrencesForEvent({
|
||||||
event: doc,
|
event: doc,
|
||||||
payload: req.payload,
|
payload: req.payload,
|
||||||
req,
|
req,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Propagate event.cancelled to all future occurrences when it flips,
|
||||||
|
// so toggling the event-level flag acts as a master switch. Skipped
|
||||||
|
// when unchanged so unrelated saves don't overwrite per-occurrence
|
||||||
|
// cancellations set from the "Nächste Termine" tab.
|
||||||
|
if (doc.cancelled !== previousDoc?.cancelled) {
|
||||||
|
await req.payload.update({
|
||||||
|
collection: 'eventOccurrence',
|
||||||
|
where: {
|
||||||
|
and: [
|
||||||
|
{ event: { equals: doc.id } },
|
||||||
|
{ date: { greater_than_equal: new Date().toISOString() } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
data: { cancelled: doc.cancelled },
|
||||||
|
req,
|
||||||
|
})
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
req.payload.logger.error(
|
req.payload.logger.error(
|
||||||
{ err, eventId: doc.id },
|
{ err, eventId: doc.id },
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,66 @@ export async function fetchUpcomingOccurrences(
|
||||||
}) as Promise<PaginatedDocs<EventOccurrence>>
|
}) as Promise<PaginatedDocs<EventOccurrence>>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch past event occurrences (most recent first), joined to their parent
|
||||||
|
* event. Always filters to occurrences whose parent event is published.
|
||||||
|
*/
|
||||||
|
export async function fetchPastOccurrences(
|
||||||
|
args?: ListArgs,
|
||||||
|
): Promise<PaginatedDocs<EventOccurrence>> {
|
||||||
|
const {
|
||||||
|
parishId,
|
||||||
|
groupId,
|
||||||
|
eventId,
|
||||||
|
limit = 30,
|
||||||
|
page = 0,
|
||||||
|
fromDate,
|
||||||
|
toDate = new Date(),
|
||||||
|
} = args || {}
|
||||||
|
|
||||||
|
const query: any = {
|
||||||
|
and: [
|
||||||
|
{ date: { less_than: toDate.toISOString() } },
|
||||||
|
{ 'event._status': { equals: 'published' } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromDate) {
|
||||||
|
query.and.push({ date: { greater_than_equal: fromDate.toISOString() } })
|
||||||
|
}
|
||||||
|
if (eventId) {
|
||||||
|
query.and.push({ event: { equals: eventId } })
|
||||||
|
}
|
||||||
|
if (parishId) {
|
||||||
|
query.and.push({ 'event.parish': { equals: parishId } })
|
||||||
|
}
|
||||||
|
if (groupId) {
|
||||||
|
query.and.push({ 'event.group': { equals: groupId } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await getPayload({ config })
|
||||||
|
return payload.find({
|
||||||
|
collection: 'eventOccurrence',
|
||||||
|
sort: '-date',
|
||||||
|
where: query,
|
||||||
|
depth: 2,
|
||||||
|
limit,
|
||||||
|
page,
|
||||||
|
}) as Promise<PaginatedDocs<EventOccurrence>>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch upcoming occurrences, falling back to past occurrences if there
|
||||||
|
* are no upcoming ones.
|
||||||
|
*/
|
||||||
|
export async function fetchUpcomingOrPastOccurrences(
|
||||||
|
args?: ListArgs,
|
||||||
|
): Promise<PaginatedDocs<EventOccurrence>> {
|
||||||
|
const upcoming = await fetchUpcomingOccurrences(args)
|
||||||
|
if (upcoming.docs.length > 0) return upcoming
|
||||||
|
return fetchPastOccurrences(args)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch a single occurrence by id with its parent event populated.
|
* Fetch a single occurrence by id with its parent event populated.
|
||||||
* Returns undefined if not found or if the id is malformed.
|
* Returns undefined if not found or if the id is malformed.
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,29 @@ export const regenerateOccurrencesForEvent = async ({
|
||||||
}): Promise<GenerateEventOccurrencesOutput> => {
|
}): Promise<GenerateEventOccurrencesOutput> => {
|
||||||
const horizon = new Date(now.getTime() + weeksAhead * MS_PER_WEEK)
|
const horizon = new Date(now.getTime() + weeksAhead * MS_PER_WEEK)
|
||||||
|
|
||||||
|
// Snapshot cancelled dates so manual cancellations survive the wipe/regen.
|
||||||
|
// Matching by ISO date works because regen derives timestamps from the same
|
||||||
|
// event.date stepped by whole calendar days — shifts in event.date
|
||||||
|
// intentionally drop stale cancellations.
|
||||||
|
const existing = await payload.find({
|
||||||
|
collection: 'eventOccurrence',
|
||||||
|
where: {
|
||||||
|
and: [
|
||||||
|
{ event: { equals: event.id } },
|
||||||
|
{ date: { greater_than_equal: now.toISOString() } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
depth: 0,
|
||||||
|
limit: 1000,
|
||||||
|
pagination: false,
|
||||||
|
req,
|
||||||
|
})
|
||||||
|
const cancelledDates = new Set(
|
||||||
|
existing.docs
|
||||||
|
.filter((occ) => occ.cancelled === true && typeof occ.date === 'string')
|
||||||
|
.map((occ) => new Date(occ.date as string).toISOString()),
|
||||||
|
)
|
||||||
|
|
||||||
const wipe = await payload.delete({
|
const wipe = await payload.delete({
|
||||||
collection: 'eventOccurrence',
|
collection: 'eventOccurrence',
|
||||||
where: {
|
where: {
|
||||||
|
|
@ -100,7 +123,7 @@ export const regenerateOccurrencesForEvent = async ({
|
||||||
data: {
|
data: {
|
||||||
event: event.id,
|
event: event.id,
|
||||||
date: eventDate.toISOString(),
|
date: eventDate.toISOString(),
|
||||||
cancelled: false,
|
cancelled: cancelledDates.has(eventDate.toISOString()),
|
||||||
generated: true,
|
generated: true,
|
||||||
},
|
},
|
||||||
req,
|
req,
|
||||||
|
|
@ -120,12 +143,13 @@ export const regenerateOccurrencesForEvent = async ({
|
||||||
const cursor = new Date(eventDate)
|
const cursor = new Date(eventDate)
|
||||||
while (cursor.getTime() <= effectiveEnd.getTime()) {
|
while (cursor.getTime() <= effectiveEnd.getTime()) {
|
||||||
if (cursor.getTime() >= now.getTime()) {
|
if (cursor.getTime() >= now.getTime()) {
|
||||||
|
const iso = cursor.toISOString()
|
||||||
await payload.create({
|
await payload.create({
|
||||||
collection: 'eventOccurrence',
|
collection: 'eventOccurrence',
|
||||||
data: {
|
data: {
|
||||||
event: event.id,
|
event: event.id,
|
||||||
date: cursor.toISOString(),
|
date: iso,
|
||||||
cancelled: false,
|
cancelled: cancelledDates.has(iso),
|
||||||
generated: true,
|
generated: true,
|
||||||
},
|
},
|
||||||
req,
|
req,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { cookies } from 'next/headers'
|
import { cookies, draftMode } from 'next/headers'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the current user is (trying to be) authenticated by
|
* Check if the current user is (trying to be) authenticated by
|
||||||
|
|
@ -16,3 +16,32 @@ export const isAuthenticated = async (): Promise<boolean> => {
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the per-request auth context used by server pages that need both
|
||||||
|
* authentication state and Next's draft-mode flag.
|
||||||
|
*/
|
||||||
|
export const getRequestAuth = async (): Promise<{ authenticated: boolean; isDraft: boolean }> => {
|
||||||
|
const [authenticated, { isEnabled: isDraft }] = await Promise.all([
|
||||||
|
isAuthenticated(),
|
||||||
|
draftMode(),
|
||||||
|
])
|
||||||
|
return { authenticated, isDraft }
|
||||||
|
}
|
||||||
|
|
||||||
|
type Publishable = { _status?: ('draft' | 'published') | null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if the given entity can be viewed based on its status and authentication state.
|
||||||
|
*
|
||||||
|
* @param {T | null | undefined} entity - The entity to check, which may be null or undefined.
|
||||||
|
* @param {boolean} authenticated - Indicates whether the user is authenticated.
|
||||||
|
* @return {entity is T} Returns true if the entity can be viewed, otherwise false.
|
||||||
|
*/
|
||||||
|
export function canView<T extends Publishable>(
|
||||||
|
entity: T | null | undefined,
|
||||||
|
authenticated: boolean,
|
||||||
|
): entity is T {
|
||||||
|
if (!entity) return false
|
||||||
|
return authenticated || entity._status === 'published'
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,37 @@
|
||||||
|
import type { ComponentProps } from 'react'
|
||||||
import { Event, EventOccurrence } from '@/payload-types'
|
import { Event, EventOccurrence } from '@/payload-types'
|
||||||
|
import type { EventPage } from '@/pageComponents/Event/Event'
|
||||||
|
|
||||||
|
export const getEventGroupSlug = (event: Event): string | undefined => {
|
||||||
|
const firstGroup = event.group?.[0]
|
||||||
|
if (typeof firstGroup !== 'object' || firstGroup === null) return undefined
|
||||||
|
return firstGroup.slug ?? undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
type EventPageProps = ComponentProps<typeof EventPage>
|
||||||
|
type EventPagePropsAdapted = Omit<
|
||||||
|
EventPageProps,
|
||||||
|
'isAuthenticated' | 'upcomingOccurrences' | 'photo'
|
||||||
|
>
|
||||||
|
|
||||||
|
export const eventToPageProps = (
|
||||||
|
event: Event,
|
||||||
|
occurrence?: EventOccurrence,
|
||||||
|
): EventPagePropsAdapted => ({
|
||||||
|
id: event.id,
|
||||||
|
title: event.title,
|
||||||
|
date: occurrence?.date ?? event.date,
|
||||||
|
createdAt: event.createdAt,
|
||||||
|
cancelled: Boolean(event.cancelled || occurrence?.cancelled),
|
||||||
|
recurrenceType: event.recurrenceType,
|
||||||
|
location: event.location,
|
||||||
|
description: event.description,
|
||||||
|
shortDescription: event.shortDescription,
|
||||||
|
group: getEventGroupSlug(event),
|
||||||
|
contact: event.contact || undefined,
|
||||||
|
rsvpLink: event.rsvpLink || undefined,
|
||||||
|
flyer: typeof event.flyer === 'object' ? event.flyer || undefined : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
type EventRowItem = {
|
type EventRowItem = {
|
||||||
id: string
|
id: string
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue