feature: readability and ux

This commit is contained in:
Benno Tielen 2026-04-18 10:08:57 +02:00
parent 20b0c0a768
commit f489ac9d9b
13 changed files with 379 additions and 126 deletions

View file

@ -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

View file

@ -4,9 +4,8 @@ import { Blocks } from '@/compositions/Blocks/Blocks'
import { Metadata } from 'next'
import { AdminMenu } from '@/components/AdminMenu/AdminMenu'
import { Section } from '@/components/Section/Section'
import { isAuthenticated } from '@/utils/auth'
import { getRequestAuth } from '@/utils/auth'
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave/RefreshRouteOnSave'
import { draftMode } from 'next/headers'
type Props = {
params: Promise<{ slug?: string[] }>
@ -26,9 +25,8 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
export default async function DynamicPage({ params }: Props) {
const slug = (await params).slug
const { isEnabled: isDraft } = await draftMode()
const { authenticated, isDraft } = await getRequestAuth()
const page = await fetchPageBySlug(slug?.join('/') || "", isDraft)
const authenticated = await isAuthenticated()
if (!page) {
notFound()

View file

@ -9,27 +9,19 @@ import { HR } from '@/components/HorizontalRule/HorizontalRule'
import Image from 'next/image'
import styles from './styles.module.scss'
import { Blocks } from '@/compositions/Blocks/Blocks'
import { isAuthenticated } from '@/utils/auth'
import { canView, getRequestAuth } from '@/utils/auth'
import { AdminMenu } from '@/components/AdminMenu/AdminMenu'
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave/RefreshRouteOnSave'
import { draftMode } from 'next/headers'
import { fetchBlog } from '@/fetch/blog'
export default async function BlogPage({ params }: { params: Promise<{id: string}>}){
const id = (await params).id;
const { isEnabled: isDraft } = await draftMode()
const { authenticated, isDraft } = await getRequestAuth()
const data = await fetchBlog(id, isDraft) as Blog;
const url = typeof data.photo === 'object' && data.photo?.sizes?.banner?.url;
const authenticated = await isAuthenticated();
if(!data) {
notFound();
}
if(!authenticated && data._status !== "published") {
notFound();
}
if (!canView(data, authenticated)) notFound()
// determine if some margin at the bottom should be added
const length = data.content.content.length;

View file

@ -6,25 +6,17 @@ import { fetchParish } from '@/fetch/parish'
import { fetchLastAnnouncement } from '@/fetch/announcement'
import { getPhoto, transformGallery } from '@/utils/dto/gallery'
import { fetchLastCalendar } from '@/fetch/calendar'
import { isAuthenticated } from '@/utils/auth'
import { canView, getRequestAuth } from '@/utils/auth'
import { AdminMenu } from '@/components/AdminMenu/AdminMenu'
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave/RefreshRouteOnSave'
import { draftMode } from 'next/headers'
export default async function ParishPage ({ params }: { params: Promise<{slug: string}>}) {
const slug = (await params).slug;
const { isEnabled: isDraft } = await draftMode()
const { authenticated, isDraft } = await getRequestAuth()
const parish = await fetchParish(slug, isDraft);
const authenticated = await isAuthenticated();
if(!parish) {
notFound();
}
if(!authenticated && parish._status !== "published") {
notFound();
}
if (!canView(parish, authenticated)) notFound()
const {
id,

View file

@ -11,28 +11,19 @@ import { Col } from '@/components/Flex/Col'
import { Row } from '@/components/Flex/Row'
import { Blocks } from '@/compositions/Blocks/Blocks'
import { getPhoto } from '@/utils/dto/gallery'
import { isAuthenticated } from '@/utils/auth'
import { canView, getRequestAuth } from '@/utils/auth'
import { AdminMenu } from '@/components/AdminMenu/AdminMenu'
import { GroupEvents } from '@/compositions/GroupEvents/GroupEvents'
import { RichText } from '@/components/Text/RichText'
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave/RefreshRouteOnSave'
import { draftMode } from 'next/headers'
export default async function GroupPage({ params }: { params: Promise<{slug: string}>}) {
const slug = (await params).slug
const { isEnabled: isDraft } = await draftMode()
const { authenticated, isDraft } = await getRequestAuth()
const group = await fetchGroup(slug, isDraft)
if(!group) {
notFound();
}
const authenticated = await isAuthenticated();
if(!authenticated && group._status !== "published") {
notFound();
}
if (!canView(group, authenticated)) notFound()
const {id, shortDescription, photo, name, content, text } = group
const media = getPhoto("tablet", photo)

View file

@ -1,14 +1,13 @@
import { notFound } from 'next/navigation'
import { draftMode } from 'next/headers'
import { EventPage } from '@/pageComponents/Event/Event'
import { getPhoto } from '@/utils/dto/gallery'
import { isAuthenticated } from '@/utils/auth'
import { canView, getRequestAuth } from '@/utils/auth'
import {
fetchOccurrenceById,
fetchUpcomingOccurrences,
} from '@/fetch/eventOccurrences'
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave/RefreshRouteOnSave'
import { transformOccurrences } from '@/utils/dto/events'
import { eventToPageProps, transformOccurrences } from '@/utils/dto/events'
export default async function Page({
params,
@ -16,22 +15,15 @@ export default async function Page({
params: Promise<{ eventId: string; occurrenceId: string }>
}) {
const { eventId, occurrenceId } = await params
const { isEnabled: isDraft } = await draftMode()
const { authenticated, isDraft } = await getRequestAuth()
const occurrence = await fetchOccurrenceById(occurrenceId, isDraft)
if (!occurrence) notFound()
const event = typeof occurrence.event === 'object' ? occurrence.event : undefined
if (!event) notFound()
if (!canView(event, authenticated)) 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 upcomingRaw = await fetchUpcomingOccurrences({
@ -45,19 +37,7 @@ export default async function Page({
<>
{isDraft && <RefreshRouteOnSave />}
<EventPage
id={event.id}
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}
{...eventToPageProps(event, occurrence)}
photo={photo}
isAuthenticated={authenticated}
upcomingOccurrences={upcomingOccurrences}

View file

@ -1,79 +1,31 @@
import { notFound, redirect } from 'next/navigation'
import { draftMode } from 'next/headers'
import { notFound } from 'next/navigation'
import { EventPage } from '@/pageComponents/Event/Event'
import { getPhoto } from '@/utils/dto/gallery'
import { isAuthenticated } from '@/utils/auth'
import { canView, getRequestAuth } from '@/utils/auth'
import { fetchEventById } from '@/fetch/events'
import { fetchUpcomingOccurrences } from '@/fetch/eventOccurrences'
import { fetchUpcomingOrPastOccurrences } from '@/fetch/eventOccurrences'
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave/RefreshRouteOnSave'
import { getPayload } from 'payload'
import config from '@/payload.config'
import { eventToPageProps, transformOccurrences } from '@/utils/dto/events'
/**
* 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 }> }) {
const { eventId } = await params
const { isEnabled: isDraft } = await draftMode()
const authenticated = await isAuthenticated()
const { authenticated, isDraft } = await getRequestAuth()
const event = await fetchEventById(eventId, isDraft)
if (!event) notFound()
if (!authenticated && event._status !== 'published') notFound()
if (!canView(event, authenticated)) notFound()
// Try next upcoming first, then most recent past as a fallback.
const upcoming = await fetchUpcomingOccurrences({ eventId, limit: 1 })
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 occurrences = await fetchUpcomingOrPastOccurrences({ eventId, limit: 5 })
const upcomingOccurrences = transformOccurrences(occurrences.docs)
const photo = getPhoto('tablet', event.photo)
return (
<>
{isDraft && <RefreshRouteOnSave />}
<EventPage
id={event.id}
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}
{...eventToPageProps(event)}
photo={photo}
isAuthenticated={authenticated}
upcomingOccurrences={upcomingOccurrences}
/>
</>
)

View file

@ -12,6 +12,7 @@ import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1e
import { UnderlineFeatureClient as UnderlineFeatureClient_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 { NextOccurrencesTable as NextOccurrencesTable_5b561f1308e1f6674a6813c6174350fc } from '@/admin/components/NextOccurrencesTable/NextOccurrencesTable'
import { LinkToDoc as LinkToDoc_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'
@ -33,6 +34,7 @@ export const importMap = {
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_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#ReindexButton": ReindexButton_aead06e4cbf6b2620c5c51c9ab283634,
"/components/Logo/Logo#default": default_9bcae99938dc292be0063ce32055e14c,

View file

@ -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: {
afterChange: [
async ({ doc, req }) => {
async ({ doc, previousDoc, req }) => {
try {
await regenerateOccurrencesForEvent({
event: doc,
payload: req.payload,
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) {
req.payload.logger.error(
{ err, eventId: doc.id },

View file

@ -60,6 +60,66 @@ export async function fetchUpcomingOccurrences(
}) 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.
* Returns undefined if not found or if the id is malformed.

View file

@ -67,6 +67,29 @@ export const regenerateOccurrencesForEvent = async ({
}): Promise<GenerateEventOccurrencesOutput> => {
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({
collection: 'eventOccurrence',
where: {
@ -100,7 +123,7 @@ export const regenerateOccurrencesForEvent = async ({
data: {
event: event.id,
date: eventDate.toISOString(),
cancelled: false,
cancelled: cancelledDates.has(eventDate.toISOString()),
generated: true,
},
req,
@ -120,12 +143,13 @@ export const regenerateOccurrencesForEvent = async ({
const cursor = new Date(eventDate)
while (cursor.getTime() <= effectiveEnd.getTime()) {
if (cursor.getTime() >= now.getTime()) {
const iso = cursor.toISOString()
await payload.create({
collection: 'eventOccurrence',
data: {
event: event.id,
date: cursor.toISOString(),
cancelled: false,
date: iso,
cancelled: cancelledDates.has(iso),
generated: true,
},
req,

View file

@ -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
@ -16,3 +16,32 @@ export const isAuthenticated = async (): Promise<boolean> => {
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'
}

View file

@ -1,4 +1,37 @@
import type { ComponentProps } from 'react'
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 = {
id: string