Compare commits

..

No commits in common. "47fa6db41104e304e00144e38ca7571e953dcb9b" and "c4060dad0c0da0b3fbd7b32c3d8d6a4db4ae20fb" have entirely different histories.

163 changed files with 1181 additions and 150482 deletions

View file

@ -1,72 +0,0 @@
import type { StorybookConfig } from '@storybook/nextjs-vite';
import type { Plugin } from 'vite';
/**
* Vite plugin that provides an empty stub module for server-only packages
* (Payload CMS and its plugins) so they don't get bundled into
* the browser build and cause Node.js built-in errors.
*
* Uses a Proxy-based default export so any named or nested import
* resolves to a no-op function at runtime without needing to enumerate
* every export.
*/
function mockServerModules(): Plugin {
const serverPrefixes = ['payload', '@payloadcms/'];
const resolvedId = '\0mock-server-module';
return {
name: 'mock-server-modules',
enforce: 'pre',
resolveId(source) {
if (
serverPrefixes.some(
(prefix) => source === prefix || source.startsWith(prefix + '/'),
)
) {
return { id: resolvedId, syntheticNamedExports: true };
}
return null;
},
load(id) {
if (id === resolvedId) {
return `export default new Proxy({}, { get: (_, p) => p === '__esModule' ? true : () => {} });`;
}
return null;
},
};
}
const config: StorybookConfig = {
"stories": [
"../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"
],
"addons": [],
"framework": "@storybook/nextjs-vite",
async viteFinal(config) {
return {
...config,
plugins: [...(config.plugins || []), mockServerModules()],
optimizeDeps: {
...config.optimizeDeps,
exclude: [
...(config.optimizeDeps?.exclude || []),
'payload',
'@payloadcms/richtext-lexical',
'@payloadcms/db-postgres',
'@payloadcms/storage-gcs',
'@payloadcms/plugin-seo',
],
},
css: {
preprocessorOptions: {
scss: {
// This tells Sass to look in the root directory for imports
loadPaths: ["./"],
},
},
},
};
},
};
export default config;

View file

@ -1,20 +0,0 @@
<style>
:root {
--base-color: #016699;
--shade1: #67A3C2;
--shade2: #DDECF7;
--shade3: #eff6ff;
--contrast-color: #CE490F;
--contrast-shade1: #DA764B;
--border-radius: 13px;
--font-size-xl: 90px;
--font-size-lg: 60px;
--font-size-md: 33px;
--font-size-sm: 25px;
--font-size-lead: 36px;
--font-size-body: 20px;
--font-weight-extra-bold: 800;
--font-weight-bold: 700;
--font-weight-light: 300;
}
</style>

View file

@ -1,35 +0,0 @@
import type { Preview } from '@storybook/nextjs-vite'
import { defaultFont, headerFont } from '@/assets/fonts'
const preview: Preview = {
decorators: [
(Story) => {
return (
<div
className={defaultFont.className}
style={{
fontSize: 'var(--font-size-body)',
'--header-font': headerFont.style.fontFamily,
} as React.CSSProperties}
>
<Story />
</div>
)
},
],
parameters: {
nextjs: {
appDirectory: true,
},
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
}
export default preview

View file

@ -1,98 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Overview
Church website for the Heilige Drei Könige Catholic parish, built on **Next.js 15 (App Router) + React 19 + Payload CMS v3 + PostgreSQL/PostGIS**. The frontend is fully server-rendered via Next; content is authored in the Payload admin and rendered from typed collection blocks. The repo is a **multi-tenant template**: one codebase serves multiple parish sites, selected at build/run time via `NEXT_PUBLIC_SITE_ID`.
## Common commands
```bash
npm run dev # Next dev server (http://localhost:3000, admin at /admin)
npm run devsafe # Same but wipes .next first (use after Payload schema changes)
npm run build # Production build (output: 'standalone' for Docker)
npm start # Serve production build
npm run lint # Next/ESLint. Note: eslint.ignoreDuringBuilds is true in next.config
npm test # Vitest (run a single test: npx vitest run path/to/file.test.ts)
npm run storybook # Storybook on :6006 (see Storybook section for caveats)
# Payload
npm run payload migrate:create --name <name> # create a migration from current schema
npm run payload migrate # apply pending migrations
npm run generate:types # regenerate src/payload-types.ts
npm run payload jobs:run --cron "* * * * *" --queue default # run queued jobs locally
```
Node 22+ is required (`engines`). The `predev`/`prebuild` hooks run `scripts/copy-favicon.mjs` to copy the active site's `icon.ico` into `src/app/(home)/`.
## Architecture
### Multi-site configuration
- `sites/<siteId>/config.ts` exports a `SiteConfig` (name, colors, fonts, contact info). Current sites: `dreikoenige`, `chemnitz`.
- `src/config/site.ts` picks one based on `NEXT_PUBLIC_SITE_ID` and exposes `siteConfig`. Unknown IDs throw at module load.
- Each site also provides its own `Logo.tsx`, `logoSvg.ts`, and `icon.ico`. When adding theming or site-specific assets, go through `siteConfig` — don't hardcode.
### Next App Router layout
`src/app/` uses two route groups:
- **`(home)`** — public site. Notably:
- `[[...slug]]/page.tsx` is the catch-all that renders any CMS `Pages` document via the shared `<Blocks>` composition.
- Dedicated routes exist for domain entities: `gemeinde/` (churches), `gruppe/` (groups), `gottesdienst/` (worship), `veranstaltungen/` (events), `blog/`, `pfarrei/` (parish), `sakramente/`, `spenden/` (donations), `suche/` (search), `gebetsanliegen-des-papstes/` (pope's prayer intentions).
- `api/draft/` enables Payload draft mode for live preview.
- **`(payload)`** — Payload admin (`/admin`) and Payload's own API routes. Don't edit files under this group by hand; they're scaffolded by `@payloadcms/next`.
### Payload as the content layer
`src/payload.config.ts` is the single source of truth. Collections, globals, the Lexical editor config, the jobs queue, and plugins (GCS storage, search) all live there.
- **Collections** (`src/collections/*.ts`): `Parish`, `Churches`, `Worship`, `Events`, `Blog`, `Groups`, `Pages`, `Announcements`, `LiturgicalCalendar`, `PopesPrayerIntentions`, `Classifieds`, `ContactPerson`, `Locations`, `DonationForms`, `Prayers`, `Magazine`, `Highlight`, `Documents`, `Media`, `Users`.
- **Globals** (`src/globals/`): `Menu`, `Footer`, plus a `ValidateHref` helper.
- **Blocks** (`src/collections/blocks/*.ts`): reusable Lexical-adjacent block schemas referenced by `Pages.content`, `Blog.content`, etc. Each block has a matching renderer branch in `src/compositions/Blocks/Blocks.tsx`**adding a new block means updating both the schema and `Blocks.tsx`**.
- **Types**: `src/payload-types.ts` is generated — never edit by hand. Run `npm run generate:types` after schema changes. The TS path alias `@payload-config` resolves to the config file and `@/*` resolves to `src/*`.
- **DB**: Postgres adapter with `idType: 'uuid'` and `push: false` (migrations only, no dev-time schema push). PostGIS extension is required because `Locations` uses geo fields.
- **GraphQL is disabled**. Use the REST/local API.
- **Admin UI**: German only (`@payloadcms/translations/languages/de`); logo comes from the active site's `Logo.tsx` via `serverProps`.
### Data fetching pattern
Server components read via helpers in `src/fetch/` (e.g. `fetchPageBySlug`, `fetchBlogBySlug`, `fetchChurchBySlug`) rather than calling Payload directly from page files. These helpers accept a `draft` flag so draft mode works end-to-end. Authenticated previews use `isAuthenticated()` from `src/utils/auth.ts` plus the `<RefreshRouteOnSave>` client component.
### Rendering structure
- **`src/components/<Name>/`** — leaf/primitive UI (`Button`, `Title`, `Container`, …). Each folder usually contains the `.tsx`, a `styles.module.scss`, and a `.stories.tsx`.
- **`src/compositions/<Name>/`** — larger assemblies that combine components and sometimes fetch data (e.g. `Blocks`, `PageHeader`, `Footer`, `ContactSection`).
- **`src/pageComponents/<Entity>/`** — page-level components for specific routes (`Home`, `Parish`, `Event`, `Worship`, `Search`).
- **`src/utils/dto/`** — DTO mappers that normalize Payload shapes (e.g. `transformGallery`, `getPhoto`) before passing data into presentation components. When a block renderer needs data, prefer an existing DTO helper over inlining the reshape.
### Recurring masses / jobs queue
See the README for the full walkthrough. Key points for code changes:
- The task is `src/jobs/generateRecurringMasses.ts`; it's registered in `payload.config.ts` under `jobs.tasks`.
- `autoRun` only fires when `NODE_ENV === 'production'` (via `shouldAutoRun`). In dev, either trigger via the admin "Run now" button or the `npm run payload jobs:run` CLI command.
- Churches' `afterChange` hook queues a regeneration whenever the `recurringSchedule` field changes. The task only appends future occurrences — it will not overwrite existing `Worship` docs.
- `Worship` docs produced by the task are indistinguishable from hand-created ones; editors can still cancel/modify individual occurrences.
### Search
The `@payloadcms/plugin-search` plugin indexes `parish`, `pages`, `blog`, `event`, `group`. Two non-default tweaks in `payload.config.ts` worth knowing about:
1. `beforeSync` maps `originalDoc.name``searchDoc.title` because `Parish` and `Groups` don't have a `title` field.
2. `searchOverrides.fields` lifts the `doc.value` relationship's `maxDepth` to 1 so the results page (`/suche`) can read each referenced doc's `slug` to build URLs.
### Storage (GCS)
The `gcsStorage` plugin is registered unconditionally but `enabled: !!process.env.GOOGLE_BUCKET`. `alwaysInsertFields: true` keeps the collection schema (and therefore `payload-types.ts` / `importMap.js`) identical whether or not GCS is active — so you don't get spurious diffs switching between local-only dev and cloud dev. Public URLs are built manually via `generateFileURL` (`https://storage.googleapis.com/<bucket>/<prefix>/<filename>`).
### Styling
- SCSS Modules (`*.module.scss`) colocated with components.
- Root-level `_template.scss` and CSS variables from `siteConfig` drive theming.
- `next.config.mjs` pins `images.remotePatterns` to `storage.googleapis.com/<GOOGLE_BUCKET>/**`; local uploads are served from Next's default route.
### Storybook
`.storybook/main.ts` contains a custom **`mockServerModules` Vite plugin** that stubs out `payload` and every `@payloadcms/*` import with a Proxy-based no-op module. This is intentional — components that incidentally import Payload utilities won't blow up the browser build. If you add a component that genuinely needs server-only behavior in a story, refactor the story to pass data as props rather than trying to unmock.
### Migrations
Payload migrations live in `src/migrations/`. Every schema change should produce a new `<timestamp>_<name>.ts` + `.json` pair via `npm run payload migrate:create`. Production deploys must run `npm run payload migrate` against the prod DB (not yet wired into CD — see README todo).
## Path aliases
- `@/*``src/*`
- `@payload-config``src/payload.config.ts`
## Things to avoid
- Don't edit `src/payload-types.ts` — regenerate it.
- Don't edit files under `src/app/(payload)/admin/` or `src/app/(payload)/api/` — they're Payload-owned.
- Don't add a new block schema without also adding its renderer branch in `src/compositions/Blocks/Blocks.tsx` — the Pages route will silently drop unknown block types.
- Don't rely on `push: true` dev-mode schema sync; this project uses migrations exclusively.

69
LICENSE
View file

@ -1,69 +0,0 @@
Parish Website Template — Commercial License Agreement
Copyright (c) 2026 Benno Tielen <Benno@tielen.nl>. All rights reserved.
This software (the "Software") is licensed, not sold. By installing, copying,
or otherwise using the Software, the licensee ("Licensee") agrees to the terms
below. If Licensee does not agree, Licensee must not use the Software.
1. Grant of License
Subject to payment of the applicable license fee and compliance with this
agreement, the copyright holder ("Licensor") grants Licensee a non-exclusive,
non-transferable, perpetual license to:
(a) install and operate the Software on servers owned or controlled by
Licensee, for Licensee's own parish or organization, and
(b) modify the source code of the Software for Licensee's own operational
use.
Unless otherwise agreed in writing, this license covers a single production
deployment per purchased license. Non-production environments (staging,
development, backup) operated solely by Licensee in support of that production
deployment are included.
2. Restrictions
Licensee shall NOT, in whole or in part, whether modified or unmodified:
(a) redistribute, publish, sublicense, lease, rent, lend, or sell the
Software to any third party;
(b) use the Software to provide a hosted, managed, SaaS, white-label, or
similar service to any third party;
(c) remove, obscure, or alter any copyright, trademark, or license notice
contained in the Software;
(d) reverse engineer or decompile any non-source components of the Software
except to the extent such restriction is prohibited by applicable law.
3. Ownership
All right, title, and interest in the Software, including all intellectual
property rights, remain with Licensor. Modifications created by Licensee are
owned by Licensee, but are derivative works of the Software and are therefore
subject to the restrictions in Section 2.
4. Warranty Disclaimer
THE SOFTWARE IS PROVIDED "AS IS" AND "AS AVAILABLE", WITHOUT WARRANTY OF ANY
KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT.
5. Limitation of Liability
TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, LICENSOR SHALL NOT BE
LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE
DAMAGES, OR ANY LOSS OF PROFITS OR REVENUES. LICENSOR'S TOTAL AGGREGATE
LIABILITY UNDER THIS AGREEMENT SHALL NOT EXCEED THE LICENSE FEE PAID BY
LICENSEE FOR THE SOFTWARE.
6. Termination
This license terminates automatically if Licensee breaches any of its terms.
On termination, Licensee must cease all use of the Software and destroy all
copies in its possession or control. Sections 3, 4, 5, 7, and 8 survive
termination.
7. Governing Law and Jurisdiction
This agreement is governed by the laws of the Netherlands, without regard to
its conflict-of-laws rules. The competent courts of the Netherlands shall
have exclusive jurisdiction over any dispute arising out of or in connection
with this agreement.
8. Entire Agreement
This agreement constitutes the entire agreement between the parties with
respect to the Software and supersedes all prior or contemporaneous
understandings. Any amendment must be in writing and signed by Licensor.
For licensing inquiries, contact: Benno Tielen <Benno@tielen.nl>.

View file

@ -1,13 +1,11 @@
# Parish Website Template # Heilige Drei Könige Website
A white-label website template for Catholic parishes. One codebase powers multiple parish sites; the active site is selected at build/run time via the `NEXT_PUBLIC_SITE_ID` environment variable, and each site's branding (name, colors, fonts, logo, favicon, contact info) lives under `sites/<siteId>/`. The repository currently ships with `dreikoenige` (Heilige Drei Könige) and `chemnitz` as reference deployments. This repository contains the source code for the Heilige Drei Könige Catholic Church website, built with:
Built with: - Next.js
- React
- Next.js 15 (App Router)
- React 19
- Payload CMS v3 - Payload CMS v3
- PostgreSQL with PostGIS - PostgreSQL
## Quick start ## Quick start
@ -132,10 +130,6 @@ Open http://localhost:6006
Site-wide metadata (title, description, keywords, OpenGraph) is configured in `src/config/site.ts`. Update that file to change SEO defaults used in the root layout. Site-wide metadata (title, description, keywords, OpenGraph) is configured in `src/config/site.ts`. Update that file to change SEO defaults used in the root layout.
## License
This software is distributed under a commercial license, not as open source. Purchasing a license grants the right to deploy and modify the Software on servers you operate; redistribution, resale, and use as a hosted service for third parties are not permitted. See [`LICENSE`](./LICENSE) for the full terms, or contact Benno Tielen <Benno@tielen.nl> for licensing inquiries.
## A note on updating dependencies ## A note on updating dependencies
Payload CMS and Next.js are pinned to specific versions. From the Payload docs we currently have the following requirements (March 2026): Payload CMS and Next.js are pinned to specific versions. From the Payload docs we currently have the following requirements (March 2026):

View file

@ -169,38 +169,42 @@ docker restart postgres
## Deploy via Ansible (without CI/CD) ## Deploy via Ansible (without CI/CD)
Use these playbooks to deploy from your local machine — no Forgejo runner needed. Use the `deploy.yml` playbook to deploy from your local machine — no Forgejo runner or CI/CD pipeline needed. This is useful for hotfixes, CI outages, or production servers without Forgejo.
```bash ```bash
cd infra/ansible cd infra/ansible
# Deploy both environments (git pull once, then build+deploy each sequentially) # Deploy to test/staging VPS
ansible-playbook playbooks/deploy.yml --ask-vault-pass ansible-playbook playbooks/deploy.yml -i inventory/test.yml --ask-vault-pass
# Deploy staging only # Deploy to production
ansible-playbook playbooks/deploy-staging.yml --ask-vault-pass ansible-playbook playbooks/deploy.yml -i inventory/production.yml --ask-vault-pass
# Deploy test only
ansible-playbook playbooks/deploy-test.yml --ask-vault-pass
``` ```
**Steps executed per environment:** **What it does:**
1. Pull latest code from the configured branch (`staging`) 1. Pulls the latest code from the configured branch (`repo_branch` in inventory)
2. Build app Docker image (bakes in `NEXT_PUBLIC_SERVER_URL` and `NEXT_PUBLIC_SITE_ID`) 2. Runs `deploy.sh` for each environment (sequentially to save RAM), which:
3. Build migration image and run `npx payload migrate` - Builds the Docker app image with build-time env vars
4. Stop and remove the old container - Builds a migration image and runs `npx payload migrate`
5. Start the new container - Stops the old container, starts the new one
6. Fix upload volume permissions - Prunes old Docker images
7. Prune old Docker images
**Deploy a specific branch:** **Deploy a specific branch:**
```bash ```bash
ansible-playbook playbooks/deploy.yml --ask-vault-pass -e repo_branch=feature/my-branch ansible-playbook playbooks/deploy.yml -i inventory/test.yml --ask-vault-pass \
-e repo_branch=feature/my-branch
``` ```
> **Note:** The server must already be provisioned with `setup.yml` before deploying. The deploy playbooks only pull code and rebuild containers — they do not install Docker, Caddy, or PostgreSQL. **Deploy only one environment** (e.g., just staging):
```bash
ansible-playbook playbooks/deploy.yml -i inventory/test.yml --ask-vault-pass \
-e '{"app_environments": [{"name": "staging", "port": 3001}]}'
```
> **Note:** The server must already be provisioned with `setup.yml` before using `deploy.yml`. The deploy playbook only pulls code and rebuilds containers — it does not install Docker, Caddy, or PostgreSQL.
--- ---

View file

@ -1,20 +0,0 @@
---
- name: Deploy staging environment
hosts: all
become: true
tasks:
- name: Pull {{ repo_branch }} branch
ansible.builtin.git:
repo: "{{ repo_url }}"
dest: "{{ repo_dir }}"
version: "{{ repo_branch }}"
force: true
accept_hostkey: true
- name: Build and deploy staging
ansible.builtin.include_role:
name: app
tasks_from: deploy_env
vars:
env: "{{ app_environments | selectattr('name', 'equalto', 'staging') | first }}"

View file

@ -1,20 +0,0 @@
---
- name: Deploy test environment
hosts: all
become: true
tasks:
- name: Pull {{ repo_branch }} branch
ansible.builtin.git:
repo: "{{ repo_url }}"
dest: "{{ repo_dir }}"
version: "{{ repo_branch }}"
force: true
accept_hostkey: true
- name: Build and deploy test
ansible.builtin.include_role:
name: app
tasks_from: deploy_env
vars:
env: "{{ app_environments | selectattr('name', 'equalto', 'test') | first }}"

View file

@ -1,37 +1,19 @@
--- ---
- name: Pull latest code (shared for both environments) - name: Deploy app (rebuild + restart)
hosts: all hosts: all
become: true become: true
tasks: tasks:
- name: Pull {{ repo_branch }} branch - name: Pull latest code
ansible.builtin.git: ansible.builtin.git:
repo: "{{ repo_url }}" repo: "{{ repo_url }}"
dest: "{{ repo_dir }}" dest: "{{ repo_dir }}"
version: "{{ repo_branch }}" version: "{{ repo_branch }}"
force: true force: true
accept_hostkey: true
- name: Deploy staging environment - name: Deploy each environment
hosts: all ansible.builtin.shell: |
become: true {{ scripts_dir }}/deploy.sh {{ item.name }} {{ item.port }}
loop: "{{ app_environments }}"
tasks: loop_control:
- name: Build and deploy staging label: "{{ item.name }}"
ansible.builtin.include_role:
name: app
tasks_from: deploy_env
vars:
env: "{{ app_environments | selectattr('name', 'equalto', 'staging') | first }}"
- name: Deploy test environment
hosts: all
become: true
tasks:
- name: Build and deploy test
ansible.builtin.include_role:
name: app
tasks_from: deploy_env
vars:
env: "{{ app_environments | selectattr('name', 'equalto', 'test') | first }}"

View file

@ -1,7 +1,9 @@
--- ---
- name: Deploy each environment (sequentially to save RAM) - name: Deploy each environment (sequentially to save RAM)
ansible.builtin.include_tasks: deploy_env.yml ansible.builtin.shell: |
{{ scripts_dir }}/deploy.sh {{ item.name }} {{ item.port }}
loop: "{{ app_environments }}" loop: "{{ app_environments }}"
loop_control: loop_control:
loop_var: env label: "{{ item.name }}"
label: "{{ env.name }}" register: deploy_result
changed_when: true

View file

@ -1,98 +0,0 @@
---
- name: "{{ env.name }} | Build app image"
ansible.builtin.command:
argv:
- docker
- build
- --build-arg
- "NEXT_PUBLIC_SERVER_URL=https://{{ env.domain }}"
- --build-arg
- "NEXT_PUBLIC_SITE_ID={{ env.site_id }}"
- -t
- "church-website:{{ env.name }}"
- "{{ repo_dir }}"
changed_when: true
- name: "{{ env.name }} | Build migration image"
ansible.builtin.command:
argv:
- docker
- build
- --target
- builder
- --build-arg
- "NEXT_PUBLIC_SERVER_URL=https://{{ env.domain }}"
- --build-arg
- "NEXT_PUBLIC_SITE_ID={{ env.site_id }}"
- -t
- "church-website-migrate:{{ env.name }}"
- "{{ repo_dir }}"
changed_when: true
- name: "{{ env.name }} | Run database migrations"
ansible.builtin.command:
argv:
- docker
- run
- --rm
- --network
- "{{ docker_network }}"
- --env-file
- "{{ envs_dir }}/{{ env.name }}/.env"
- "church-website-migrate:{{ env.name }}"
- npx
- payload
- migrate
changed_when: true
- name: "{{ env.name }} | Stop old container"
ansible.builtin.command: "docker stop app-{{ env.name }}"
failed_when: false
changed_when: true
- name: "{{ env.name }} | Remove old container"
ansible.builtin.command: "docker rm app-{{ env.name }}"
failed_when: false
changed_when: true
- name: "{{ env.name }} | Start new container"
ansible.builtin.command:
argv:
- docker
- run
- -d
- --name
- "app-{{ env.name }}"
- --restart
- unless-stopped
- --network
- "{{ docker_network }}"
- --env-file
- "{{ envs_dir }}/{{ env.name }}/.env"
- -v
- "uploads-{{ env.name }}-media:/app/media"
- -v
- "uploads-{{ env.name }}-documents:/app/documents"
- -p
- "127.0.0.1:{{ env.port }}:3000"
- "church-website:{{ env.name }}"
changed_when: true
- name: "{{ env.name }} | Fix upload volume permissions"
ansible.builtin.command:
argv:
- docker
- exec
- -u
- "0"
- "app-{{ env.name }}"
- chown
- -R
- 1001:1001
- /app/media
- /app/documents
changed_when: true
- name: "{{ env.name }} | Prune old Docker images"
ansible.builtin.command: docker image prune -f
changed_when: true

View file

@ -4,6 +4,9 @@ import { withPayload } from '@payloadcms/next/withPayload'
const nextConfig = { const nextConfig = {
// Your Next.js config here // Your Next.js config here
output: 'standalone', output: 'standalone',
eslint: {
ignoreDuringBuilds: true,
},
images: { images: {
remotePatterns: [ remotePatterns: [
{ {

View file

@ -1,10 +1,8 @@
{ {
"name": "@bennotielen/parish-website", "name": "drei-koenige-v3",
"version": "1.0.0", "version": "1.0.0",
"description": "White-label parish website template (Next.js 15 + Payload CMS v3 + PostgreSQL/PostGIS).", "description": "A blank template to get started with Payload 3.0",
"license": "SEE LICENSE IN LICENSE", "license": "MIT",
"author": "Benno Tielen <Benno@tielen.nl>",
"private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"prebuild": "node --env-file-if-exists=.env scripts/copy-favicon.mjs", "prebuild": "node --env-file-if-exists=.env scripts/copy-favicon.mjs",

View file

@ -1,166 +0,0 @@
'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,8 +4,9 @@ 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 { getRequestAuth } from '@/utils/auth' import { isAuthenticated } 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[] }>
@ -25,8 +26,9 @@ 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 { authenticated, isDraft } = await getRequestAuth() const { isEnabled: isDraft } = await draftMode()
const page = await fetchPageBySlug(slug?.join('/') || "", isDraft) const page = await fetchPageBySlug(slug?.join('/') || "", isDraft)
const authenticated = await isAuthenticated()
if (!page) { if (!page) {
notFound() notFound()

View file

@ -9,19 +9,27 @@ 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 { canView, getRequestAuth } from '@/utils/auth' import { isAuthenticated } 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 { authenticated, isDraft } = await getRequestAuth() const { isEnabled: isDraft } = await draftMode()
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 (!canView(data, authenticated)) notFound() if(!data) {
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;

View file

@ -1,22 +1,30 @@
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import { Parish } from '@/pageComponents/Parish/Parish' import { Parish } from '@/pageComponents/Parish/Parish'
import { fetchUpcomingOccurrences } from '@/fetch/eventOccurrences' import { fetchEvents } from '@/fetch/events'
import { fetchWorship } from '@/fetch/worship' import { fetchWorship } from '@/fetch/worship'
import { fetchParish } from '@/fetch/parish' 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 { canView, getRequestAuth } from '@/utils/auth' import { isAuthenticated } 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 { authenticated, isDraft } = await getRequestAuth() const { isEnabled: isDraft } = await draftMode()
const parish = await fetchParish(slug, isDraft); const parish = await fetchParish(slug, isDraft);
const authenticated = await isAuthenticated();
if (!canView(parish, authenticated)) notFound() if(!parish) {
notFound();
}
if(!authenticated && parish._status !== "published") {
notFound();
}
const { const {
id, id,
@ -30,7 +38,7 @@ export default async function ParishPage ({ params }: { params: Promise<{slug: s
gallery, gallery,
content content
} = parish } = parish
const events = await fetchUpcomingOccurrences({ parishId: id }) const events = await fetchEvents({ parishId: id })
const churchIds = churches.map(c => typeof c === "string" ? c : c.id) const churchIds = churches.map(c => typeof c === "string" ? c : c.id)
const worship = await fetchWorship({ locations: churchIds }) const worship = await fetchWorship({ locations: churchIds })
const announcement = await fetchLastAnnouncement(id); const announcement = await fetchLastAnnouncement(id);

View file

@ -11,19 +11,28 @@ 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 { canView, getRequestAuth } from '@/utils/auth' import { isAuthenticated } 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 { authenticated, isDraft } = await getRequestAuth() const { isEnabled: isDraft } = await draftMode()
const group = await fetchGroup(slug, isDraft) const group = await fetchGroup(slug, isDraft)
if (!canView(group, authenticated)) notFound() if(!group) {
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)

View file

@ -1,7 +1,40 @@
import { fetchEvents } from '@/fetch/events'
import { fetchWorship } from '@/fetch/worship'
import { fetchBlogPosts } from '@/fetch/blog'
import { fetchHighlights } from '@/fetch/highlights'
import { Home } from '@/pageComponents/Home/Home' import { Home } from '@/pageComponents/Home/Home'
import moment from 'moment'
import { fetchLastAnnouncement, fetchLastAnnouncements } from '@/fetch/announcement'
import { perParish } from '@/utils/dto/perParish'
import { fetchLastCalendars } from '@/fetch/calendar'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
export default async function HomePage() { export default async function HomePage() {
return <Home />
const fromDate = moment().isoWeekday(1).hours(0).minutes(0);
const tillDate = moment().isoWeekday(7).hours(23).minutes(59);
const events = await fetchEvents()
const worship = await fetchWorship({
fromDate: fromDate.toDate(),
tillDate: tillDate.toDate(),
});
const blog = await fetchBlogPosts(true)
const highlights = await fetchHighlights()
const announcements = await fetchLastAnnouncements();
const announcementsLinks = announcements ? perParish(announcements) : [];
const calendars = await fetchLastCalendars();
const calendarsLinks = calendars ? perParish(calendars) : [];
return (
<Home
events={events?.docs || []}
worship={worship?.docs || []}
blog={blog?.docs || []}
highlights={highlights?.docs || []}
announcements={announcementsLinks}
calendars={calendarsLinks}
/>
)
} }

View file

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

View file

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

View file

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 145 KiB

View file

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

View file

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 118 KiB

View file

@ -1,47 +0,0 @@
import { notFound } from 'next/navigation'
import { EventPage } from '@/pageComponents/Event/Event'
import { getPhoto } from '@/utils/dto/gallery'
import { canView, getRequestAuth } from '@/utils/auth'
import {
fetchOccurrenceById,
fetchUpcomingOccurrences,
} from '@/fetch/eventOccurrences'
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave/RefreshRouteOnSave'
import { eventToPageProps, transformOccurrences } from '@/utils/dto/events'
export default async function Page({
params,
}: {
params: Promise<{ eventId: string; occurrenceId: string }>
}) {
const { eventId, occurrenceId } = await params
const { authenticated, isDraft } = await getRequestAuth()
const occurrence = await fetchOccurrenceById(occurrenceId, isDraft)
if (!occurrence) notFound()
const event = typeof occurrence.event === 'object' ? occurrence.event : undefined
if (!canView(event, authenticated)) notFound()
if (event.id !== eventId) notFound()
const photo = getPhoto('tablet', event.photo)
const upcomingRaw = await fetchUpcomingOccurrences({
eventId,
limit: 5,
fromDate: new Date(new Date(occurrence.date).getTime() + 1),
})
const upcomingOccurrences = transformOccurrences(upcomingRaw.docs)
return (
<>
{isDraft && <RefreshRouteOnSave />}
<EventPage
{...eventToPageProps(event, occurrence)}
photo={photo}
isAuthenticated={authenticated}
upcomingOccurrences={upcomingOccurrences}
/>
</>
)
}

View file

@ -1,32 +0,0 @@
import { notFound } from 'next/navigation'
import { EventPage } from '@/pageComponents/Event/Event'
import { getPhoto } from '@/utils/dto/gallery'
import { canView, getRequestAuth } from '@/utils/auth'
import { fetchEventById } from '@/fetch/events'
import { fetchUpcomingOrPastOccurrences } from '@/fetch/eventOccurrences'
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave/RefreshRouteOnSave'
import { eventToPageProps, transformOccurrences } from '@/utils/dto/events'
export default async function Page({ params }: { params: Promise<{ eventId: string }> }) {
const { eventId } = await params
const { authenticated, isDraft } = await getRequestAuth()
const event = await fetchEventById(eventId, isDraft)
if (!canView(event, authenticated)) notFound()
const occurrences = await fetchUpcomingOrPastOccurrences({ eventId, limit: 5 })
const upcomingOccurrences = transformOccurrences(occurrences.docs)
const photo = getPhoto('tablet', event.photo)
return (
<>
{isDraft && <RefreshRouteOnSave />}
<EventPage
{...eventToPageProps(event)}
photo={photo}
isAuthenticated={authenticated}
upcomingOccurrences={upcomingOccurrences}
/>
</>
)
}

View file

@ -0,0 +1,50 @@
import { notFound } from 'next/navigation'
import { EventPage } from '@/pageComponents/Event/Event'
import { getPhoto } from '@/utils/dto/gallery'
import { isAuthenticated } from '@/utils/auth'
import { fetchEventById } from '@/fetch/events'
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave/RefreshRouteOnSave'
import { draftMode } from 'next/headers'
export default async function Page({ params }: { params: Promise<{id: string}>}) {
const id = (await params).id;
const { isEnabled: isDraft } = await draftMode()
const event = await fetchEventById(id, isDraft)
if (!event) {
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);
return (
<>
{isDraft && <RefreshRouteOnSave />}
<EventPage
id={event.id}
title={event.title}
date={event.date}
createdAt={event.createdAt}
cancelled={event.cancelled}
isRecurring={event.isRecurring}
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}
isAuthenticated={authenticated}
/>
</>
)
}

View file

@ -1,11 +1,11 @@
import { fetchUpcomingOccurrences } from '@/fetch/eventOccurrences' import { fetchEvents } from '@/fetch/events'
import { PageHeader } from '@/compositions/PageHeader/PageHeader' import { PageHeader } from '@/compositions/PageHeader/PageHeader'
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 { Title } from '@/components/Title/Title' import { Title } from '@/components/Title/Title'
import { NextPrevButtons } from '@/components/NextPrevButtons/NextPrevButtons' import { NextPrevButtons } from '@/components/NextPrevButtons/NextPrevButtons'
import moment from 'moment' import moment from 'moment'
import { transformOccurrences } from '@/utils/dto/events' import { transformEvents } from '@/utils/dto/events'
import { weekNumber } from '@/utils/week' import { weekNumber } from '@/utils/week'
import { EventRow } from '@/components/EventRow/EventRow' import { EventRow } from '@/components/EventRow/EventRow'
import { fetchHighlightsBetweenDates } from '@/fetch/highlights' import { fetchHighlightsBetweenDates } from '@/fetch/highlights'
@ -38,7 +38,7 @@ export default async function EventsPage({searchParams}: {
const toDate = moment(week).add(1, 'week'); const toDate = moment(week).add(1, 'week');
const lastWeek = moment(week).subtract(1, 'week'); const lastWeek = moment(week).subtract(1, 'week');
const paginatedOccurrences = await fetchUpcomingOccurrences( const paginatedEvents = await fetchEvents(
{ {
limit: limit, limit: limit,
fromDate: fromDate.toDate(), fromDate: fromDate.toDate(),
@ -51,11 +51,11 @@ export default async function EventsPage({searchParams}: {
toDate.toDate(), toDate.toDate(),
))?.docs) || []; ))?.docs) || [];
if (!paginatedOccurrences) { if (!paginatedEvents) {
return <Error statusCode={503} message={"Veranstaltungen konnten nicht geladen werden."}/>; return <Error statusCode={503} message={"Veranstaltungen konnten nicht geladen werden."}/>;
} }
const events = transformOccurrences(paginatedOccurrences.docs) const events = transformEvents(paginatedEvents.docs)
return ( return (
<> <>

View file

@ -12,7 +12,6 @@ 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'
@ -34,7 +33,6 @@ 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,

View file

@ -1,76 +0,0 @@
import { CollectionConfig } from 'payload'
import { isAdminOrEmployee } from '@/collections/access/admin'
export const EventOccurrences: CollectionConfig = {
slug: 'eventOccurrence',
labels: {
singular: {
de: 'Veranstaltungs-Termin',
},
plural: {
de: 'Veranstaltungs-Termine',
},
},
fields: [
{
name: 'event',
type: 'relationship',
relationTo: 'event',
required: true,
index: true,
label: {
de: 'Veranstaltung',
},
},
{
name: 'date',
type: 'date',
required: true,
index: true,
label: {
de: 'Datum',
},
admin: {
date: {
pickerAppearance: 'dayAndTime',
timeIntervals: 15,
timeFormat: 'HH:mm',
},
},
},
{
name: 'cancelled',
type: 'checkbox',
required: true,
defaultValue: false,
label: {
de: 'Abgesagt',
},
admin: {
position: 'sidebar',
},
},
{
name: 'generated',
type: 'checkbox',
defaultValue: false,
label: {
de: 'Automatisch erzeugt',
},
admin: {
readOnly: true,
position: 'sidebar',
},
},
],
admin: {
defaultColumns: ['date', 'event', 'cancelled'],
hidden: false,
},
access: {
read: () => true,
create: isAdminOrEmployee(),
update: isAdminOrEmployee(),
delete: isAdminOrEmployee(),
},
}

View file

@ -2,7 +2,6 @@ import { CollectionConfig } from 'payload'
import { Group, User } from '@/payload-types' import { Group, User } from '@/payload-types'
import { fetchEventById } from '@/fetch/events' import { fetchEventById } from '@/fetch/events'
import { isPublishedPublic } from '@/collections/access/public' import { isPublishedPublic } from '@/collections/access/public'
import { regenerateOccurrencesForEvent } from '@/jobs/generateEventOccurrences'
export const Events: CollectionConfig = { export const Events: CollectionConfig = {
slug: 'event', slug: 'event',
@ -14,12 +13,6 @@ export const Events: CollectionConfig = {
de: 'Veranstaltungen', de: 'Veranstaltungen',
}, },
}, },
fields: [
{
type: 'tabs',
tabs: [
{
label: { de: 'Allgemein' },
fields: [ fields: [
{ {
name: 'title', name: 'title',
@ -53,44 +46,6 @@ export const Events: CollectionConfig = {
de: 'Location' de: 'Location'
}, },
}, },
{
name: 'shortDescription',
type: 'textarea',
required: true,
label: {
de: 'Kurzumschreibung (max. 200)',
},
maxLength: 200
},
{
name: 'description',
type: 'textarea',
required: true,
label: {
de: 'Einladung',
},
},
{
name: 'contact',
type: 'relationship',
relationTo: 'contactPerson',
label: {
de: "Ansprechperson"
}
},
{
name: 'rsvpLink',
type: 'text',
required: false,
label: {
de: "Anmeldelink"
}
},
],
},
{
label: { de: 'Zuordnung' },
fields: [
{ {
name: 'parish', name: 'parish',
type: 'relationship', type: 'relationship',
@ -158,11 +113,39 @@ export const Events: CollectionConfig = {
return true return true
}, },
}, },
], {
name: 'contact',
type: 'relationship',
relationTo: 'contactPerson',
label: {
de: "Ansprechperson"
}
}, },
{ {
label: { de: 'Medien' }, name: 'shortDescription',
fields: [ type: 'textarea',
required: true,
label: {
de: 'Kurzumschreibung (max. 200)',
},
maxLength: 200
},
{
name: 'description',
type: 'textarea',
required: true,
label: {
de: 'Einladung',
},
},
{
name: 'rsvpLink',
type: 'text',
required: false,
label: {
de: "Anmeldelink"
}
},
{ {
name: 'photo', name: 'photo',
label: { label: {
@ -179,42 +162,6 @@ export const Events: CollectionConfig = {
type: 'upload', type: 'upload',
relationTo: 'documents' relationTo: 'documents'
}, },
],
},
{
label: { de: 'Wiederholung' },
fields: [
{
name: 'recurrenceType',
type: 'select',
required: true,
defaultValue: 'none',
label: {
de: 'Wiederholung',
},
options: [
{ label: 'Einmalig', value: 'none' },
{ label: 'Wöchentlich', value: 'weekly' },
{ label: 'Alle 2 Wochen', value: 'biweekly' },
],
admin: {
description:
'Bei wiederkehrenden Veranstaltungen werden automatisch Termine für die nächsten Wochen erzeugt. Wochentag und Uhrzeit werden aus dem Datumsfeld übernommen.',
},
},
{
name: 'endDate',
type: 'date',
label: {
de: 'Enddatum',
},
admin: {
date: {
pickerAppearance: 'dayOnly',
},
description: 'Optional. Nach diesem Datum werden keine weiteren Termine erzeugt.',
},
},
{ {
name: 'cancelled', name: 'cancelled',
type: 'checkbox', type: 'checkbox',
@ -224,25 +171,14 @@ export const Events: CollectionConfig = {
}, },
defaultValue: false, defaultValue: false,
}, },
],
},
{ {
label: { de: 'Nächste Termine' }, name: 'isRecurring',
fields: [ type: 'checkbox',
{ required: true,
name: 'nextOccurrences', label: {
type: 'ui', de: 'Regelmäßig',
admin: {
components: {
Field: {
path: '@/admin/components/NextOccurrencesTable/NextOccurrencesTable#NextOccurrencesTable',
}, },
}, defaultValue: false,
},
},
],
},
],
}, },
], ],
admin: { admin: {
@ -279,53 +215,6 @@ export const Events: CollectionConfig = {
return false return false
}, },
}, },
hooks: {
afterChange: [
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 },
'Failed to regenerate event occurrences',
)
}
},
],
beforeDelete: [
async ({ id, req }) => {
// Cascade before the event row is removed so the FK from
// eventOccurrence.event (required) doesn't abort the transaction.
await req.payload.delete({
collection: 'eventOccurrence',
where: { event: { equals: id } },
req,
})
},
],
},
} }
/** /**
@ -354,3 +243,4 @@ const hasGroup = (user: null | User , data: Partial<any> | undefined) => {
return user.groups.includes(group.id) return user.groups.includes(group.id)
}) })
} }

View file

@ -9,8 +9,6 @@ import { ButtonBlock } from '@/collections/blocks/Button'
import { YoutubePlayerBlock } from '@/collections/blocks/YoutubePlayer' import { YoutubePlayerBlock } from '@/collections/blocks/YoutubePlayer'
import { isPublishedPublic } from '@/collections/access/public' import { isPublishedPublic } from '@/collections/access/public'
import { ImageCardsBlock } from '@/collections/blocks/ImageCards' import { ImageCardsBlock } from '@/collections/blocks/ImageCards'
import { CollapsiblesBlock } from '@/collections/blocks/Collapsibles'
import { TitleBlock } from '@/collections/blocks/Title'
export const Groups: CollectionConfig = { export const Groups: CollectionConfig = {
slug: 'group', slug: 'group',
@ -81,7 +79,6 @@ export const Groups: CollectionConfig = {
name: 'content', name: 'content',
type: 'blocks', type: 'blocks',
blocks: [ blocks: [
TitleBlock,
ParagraphBlock, ParagraphBlock,
GalleryBlock, GalleryBlock,
DocumentBlock, DocumentBlock,
@ -90,7 +87,6 @@ export const Groups: CollectionConfig = {
ContactformBlock, ContactformBlock,
ButtonBlock, ButtonBlock,
ImageCardsBlock, ImageCardsBlock,
CollapsiblesBlock,
] ]
} }
], ],

View file

@ -17,13 +17,11 @@ import { HorizontalRuleBlock } from '@/collections/blocks/HorizontalRule'
import { BlogSliderBlock } from '@/collections/blocks/BlogSlider' import { BlogSliderBlock } from '@/collections/blocks/BlogSlider'
import { MassTimesBlock } from '@/collections/blocks/MassTimes' import { MassTimesBlock } from '@/collections/blocks/MassTimes'
import { CollapsibleImageWithTextBlock } from '@/collections/blocks/CollapsibleImageWithText' import { CollapsibleImageWithTextBlock } from '@/collections/blocks/CollapsibleImageWithText'
import { CollapsiblesBlock } from '@/collections/blocks/Collapsibles'
import { EventsBlock } from '@/collections/blocks/Events' import { EventsBlock } from '@/collections/blocks/Events'
import { PublicationAndNewsletterBlock } from '@/collections/blocks/PublicationAndNewsletter' import { PublicationAndNewsletterBlock } from '@/collections/blocks/PublicationAndNewsletter'
import { ContactPersonBlock } from '@/collections/blocks/ContactPersonBlock' import { ContactPersonBlock } from '@/collections/blocks/ContactPersonBlock'
import { ImageCardsBlock } from '@/collections/blocks/ImageCards' import { ImageCardsBlock } from '@/collections/blocks/ImageCards'
import { ClassifiedsBlock } from '@/collections/blocks/Classifieds' import { ClassifiedsBlock } from '@/collections/blocks/Classifieds'
import { NextPrevButtonsBlock } from '@/collections/blocks/NextPrevButtons'
import { isPublishedPublic } from '@/collections/access/public' import { isPublishedPublic } from '@/collections/access/public'
export const Pages: CollectionConfig = { export const Pages: CollectionConfig = {
@ -101,13 +99,11 @@ export const Pages: CollectionConfig = {
HorizontalRuleBlock, HorizontalRuleBlock,
BlogSliderBlock, BlogSliderBlock,
CollapsibleImageWithTextBlock, CollapsibleImageWithTextBlock,
CollapsiblesBlock,
MassTimesBlock, MassTimesBlock,
EventsBlock, EventsBlock,
ContactPersonBlock, ContactPersonBlock,
ImageCardsBlock, ImageCardsBlock,
ClassifiedsBlock, ClassifiedsBlock,
NextPrevButtonsBlock,
], ],
}, },
], ],

View file

@ -7,7 +7,6 @@ import { YoutubePlayerBlock } from '@/collections/blocks/YoutubePlayer'
import { DonationAppeal } from '@/collections/blocks/DonationAppeal' import { DonationAppeal } from '@/collections/blocks/DonationAppeal'
import { ImageCardsBlock } from '@/collections/blocks/ImageCards' import { ImageCardsBlock } from '@/collections/blocks/ImageCards'
import { TitleBlock } from '@/collections/blocks/Title' import { TitleBlock } from '@/collections/blocks/Title'
import { CollapsiblesBlock } from '@/collections/blocks/Collapsibles'
import { isPublishedPublic } from '@/collections/access/public' import { isPublishedPublic } from '@/collections/access/public'
export const Parish: CollectionConfig = { export const Parish: CollectionConfig = {
@ -124,7 +123,6 @@ export const Parish: CollectionConfig = {
DonationAppeal, DonationAppeal,
ImageCardsBlock, ImageCardsBlock,
TitleBlock, TitleBlock,
CollapsiblesBlock,
] ]
}, },
{ {

View file

@ -1,47 +0,0 @@
import { Block } from 'payload'
export const CollapsiblesBlock: Block = {
slug: 'collapsibles',
labels: {
singular: {
de: 'Aufklappbare Liste',
},
plural: {
de: 'Aufklappbare Listen',
},
},
fields: [
{
name: 'items',
type: 'array',
required: true,
minRows: 1,
labels: {
singular: {
de: 'Eintrag',
},
plural: {
de: 'Einträge',
},
},
fields: [
{
name: 'title',
type: 'text',
required: true,
label: {
de: 'Titel',
},
},
{
name: 'content',
type: 'richText',
required: true,
label: {
de: 'Inhalt',
},
},
],
},
],
}

View file

@ -26,20 +26,5 @@ export const MassTimesBlock: Block = {
de: 'Untertitel', de: 'Untertitel',
}, },
}, },
{
name: 'churches',
label: {
de: 'Kirchengebäuden',
},
type: 'relationship',
relationTo: 'church',
hasMany: true,
admin: {
allowCreate: false,
description: {
de: 'Leer lassen, um alle Kirchen alphabetisch anzuzeigen.',
},
},
},
], ],
} }

View file

@ -1,64 +0,0 @@
import { Block } from 'payload'
import { validateHref } from '@/globals/ValidateHref'
export const NextPrevButtonsBlock: Block = {
slug: 'nextPrevButtons',
labels: {
singular: {
de: 'Zurück-/Weiter-Buttons',
},
plural: {
de: 'Zurück-/Weiter-Buttons',
},
},
fields: [
{
name: 'prev',
type: 'group',
label: {
de: 'Zurück',
},
fields: [
{
name: 'text',
type: 'text',
label: {
de: 'Text',
},
},
{
name: 'href',
type: 'text',
label: {
de: 'Zieladresse',
},
validate: validateHref,
},
],
},
{
name: 'next',
type: 'group',
label: {
de: 'Weiter',
},
fields: [
{
name: 'text',
type: 'text',
label: {
de: 'Text',
},
},
{
name: 'href',
type: 'text',
label: {
de: 'Zieladresse',
},
validate: validateHref,
},
],
},
],
}

View file

@ -1,32 +0,0 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite'
import { AdminMenu } from './AdminMenu'
const meta: Meta<typeof AdminMenu> = {
component: AdminMenu,
}
type Story = StoryObj<typeof AdminMenu>
export default meta
export const Authenticated: Story = {
args: {
collection: 'blog',
id: 'some_id',
isAuthenticated: true,
},
}
export const AuthenticatedWithoutId: Story = {
args: {
collection: 'blog',
isAuthenticated: true,
},
}
export const NotAuthenticated: Story = {
args: {
collection: 'blog',
id: 'some_id',
isAuthenticated: false,
},
}

View file

@ -1,4 +1,4 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite' import { Meta, StoryObj } from '@storybook/react'
import { Arrow } from './Arrow' import { Arrow } from './Arrow'
const meta: Meta<typeof Arrow> = { const meta: Meta<typeof Arrow> = {

View file

@ -1,4 +1,4 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite' import { Meta, StoryObj } from '@storybook/react'
import { Banner } from './Banner' import { Banner } from './Banner'
const meta: Meta<typeof Banner> = { const meta: Meta<typeof Banner> = {

View file

@ -1,4 +1,4 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite' import { Meta, StoryObj } from '@storybook/react'
import { BlogExcerpt } from './BlogExcerpt' import { BlogExcerpt } from './BlogExcerpt'
const meta: Meta<typeof BlogExcerpt> = { const meta: Meta<typeof BlogExcerpt> = {

View file

@ -1,4 +1,4 @@
import { Meta, StoryObj } from "@storybook/nextjs-vite" import { Meta, StoryObj } from "@storybook/react"
import { Button } from './Button' import { Button } from './Button'
const meta: Meta<typeof Button> = { const meta: Meta<typeof Button> = {

View file

@ -1,4 +1,4 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite' import { Meta, StoryObj } from '@storybook/react'
import { ChurchCard } from './ChurchCard' import { ChurchCard } from './ChurchCard'
const meta: Meta<typeof ChurchCard> = { const meta: Meta<typeof ChurchCard> = {

View file

@ -1,4 +1,4 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite' import { Meta, StoryObj } from '@storybook/react'
import { ChurchIcon } from './ChurchIcon' import { ChurchIcon } from './ChurchIcon'
const meta: Meta<typeof ChurchIcon> = { const meta: Meta<typeof ChurchIcon> = {

View file

@ -1,61 +0,0 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite'
import { SerializedEditorState } from 'lexical'
import { Ad } from './Ad'
const meta: Meta<typeof Ad> = {
component: Ad,
}
type Story = StoryObj<typeof Ad>
export default meta
const makeAdText = (text: string): SerializedEditorState =>
({
root: {
type: 'root',
format: '',
indent: 0,
version: 1,
direction: 'ltr',
children: [
{
type: 'paragraph',
format: '',
indent: 0,
version: 1,
direction: 'ltr',
textFormat: 0,
textStyle: '',
children: [
{
type: 'text',
format: 0,
mode: 'normal',
style: '',
text,
detail: 0,
version: 1,
},
],
},
],
},
}) as unknown as SerializedEditorState
export const Default: Story = {
args: {
text: makeAdText(
'Suche Helfer für unseren Gemeindegarten. Wir treffen uns jeden Samstag um 10 Uhr.',
),
contact: 'gemeinde@drei-koenige-berlin.de',
},
}
export const LongerText: Story = {
args: {
text: makeAdText(
'Biete Klavierunterricht für Anfänger und Fortgeschrittene. Unterricht im Pfarrheim oder bei Ihnen zuhause möglich. Freue mich auf Ihren Anruf oder Ihre E-Mail.',
),
contact: 'klavier@example.com',
},
}

View file

@ -1,75 +0,0 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite'
import { SerializedEditorState } from 'lexical'
import { Classifieds } from './Classifieds'
const meta: Meta<typeof Classifieds> = {
component: Classifieds,
}
type Story = StoryObj<typeof Classifieds>
export default meta
const makeAdText = (text: string): SerializedEditorState =>
({
root: {
type: 'root',
format: '',
indent: 0,
version: 1,
direction: 'ltr',
children: [
{
type: 'paragraph',
format: '',
indent: 0,
version: 1,
direction: 'ltr',
textFormat: 0,
textStyle: '',
children: [
{
type: 'text',
format: 0,
mode: 'normal',
style: '',
text,
detail: 0,
version: 1,
},
],
},
],
},
}) as unknown as SerializedEditorState
export const Default: Story = {
args: {
ads: [
{
id: '1',
text: makeAdText(
'Suche Helfer für unseren Gemeindegarten. Wir treffen uns jeden Samstag um 10 Uhr.',
),
email: 'gemeinde@drei-koenige-berlin.de',
},
{
id: '2',
text: makeAdText(
'Biete Klavierunterricht für Anfänger und Fortgeschrittene.',
),
email: 'klavier@example.com',
},
{
id: '3',
text: makeAdText('Verschenke Kinderfahrrad, gut erhalten.'),
email: 'fahrrad@example.com',
},
],
},
}
export const Empty: Story = {
args: {
ads: [],
},
}

View file

@ -1,66 +0,0 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite'
import { Collapsible } from './Collapsible'
const meta: Meta<typeof Collapsible> = {
component: Collapsible,
}
type Story = StoryObj<typeof Collapsible>
export default meta
export const Default: Story = {
args: {
title: 'Wann finden die Gottesdienste statt?',
children: 'Unsere Gottesdienste finden jeden Sonntag um 10:00 Uhr statt.',
},
}
export const OpenByDefault: Story = {
args: {
title: 'Wo kann ich parken?',
defaultOpen: true,
children:
'Direkt vor der Kirche stehen Parkplätze zur Verfügung. Weitere Parkplätze finden Sie in den umliegenden Straßen.',
},
}
export const LongContent: Story = {
args: {
title: 'Wie kann ich Mitglied werden?',
children: (
<>
<p>
Um Mitglied unserer Gemeinde zu werden, können Sie sich direkt an das
Pfarrbüro wenden. Dort erhalten Sie alle notwendigen Informationen und
Formulare.
</p>
<p>
Wir freuen uns über jedes neue Mitglied und laden Sie herzlich ein,
unsere Gemeinschaft kennenzulernen. Neben den regelmäßigen
Gottesdiensten gibt es zahlreiche Gruppen und Veranstaltungen, an
denen Sie teilnehmen können.
</p>
<p>
Bei Fragen stehen Ihnen unsere Mitarbeiter gerne telefonisch, per
E-Mail oder persönlich zur Verfügung.
</p>
</>
),
},
}
export const ListOfThree: Story = {
render: () => (
<div>
<Collapsible title="Wann finden die Gottesdienste statt?">
Unsere Gottesdienste finden jeden Sonntag um 10:00 Uhr statt.
</Collapsible>
<Collapsible title="Wo kann ich parken?">
Direkt vor der Kirche stehen Parkplätze zur Verfügung.
</Collapsible>
<Collapsible title="Wie erreiche ich das Pfarrbüro?">
Das Pfarrbüro ist montags bis freitags von 9:00 bis 12:00 Uhr geöffnet.
</Collapsible>
</div>
),
}

View file

@ -1,73 +0,0 @@
'use client'
import React, { useEffect, useId, useRef, useState } from 'react'
import classNames from 'classnames'
import { CollapsibleArrow } from '@/components/CollapsibleArrow/CollapsibleArrow'
import styles from './styles.module.scss'
type CollapsibleProps = {
title: string
children: React.ReactNode
defaultOpen?: boolean
id?: string
}
export const Collapsible = ({
title,
children,
defaultOpen = false,
id,
}: CollapsibleProps) => {
const reactId = useId()
const baseId = id ?? reactId
const headerId = `${baseId}-header`
const panelId = `${baseId}-panel`
const [isOpen, setIsOpen] = useState(defaultOpen)
const [contentHeight, setContentHeight] = useState(0)
const contentRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const measure = () => {
if (contentRef.current) {
setContentHeight(contentRef.current.scrollHeight)
}
}
measure()
window.addEventListener('resize', measure)
return () => window.removeEventListener('resize', measure)
}, [children])
const toggle = () => setIsOpen(v => !v)
return (
<div className={styles.collapsible}>
<button
type="button"
id={headerId}
className={styles.header}
aria-expanded={isOpen}
aria-controls={panelId}
onClick={toggle}
>
<span className={styles.title}>{title}</span>
<span aria-hidden="true" className={styles.arrow}>
<CollapsibleArrow direction={isOpen ? 'UP' : 'DOWN'} />
</span>
</button>
<div
id={panelId}
role="region"
aria-labelledby={headerId}
aria-hidden={!isOpen}
inert={!isOpen}
ref={contentRef}
className={classNames(styles.content, { [styles.open]: isOpen })}
style={{ maxHeight: isOpen ? contentHeight : 0 }}
>
<div className={styles.inner}>{children}</div>
</div>
</div>
)
}

View file

@ -1,59 +0,0 @@
@import 'template.scss';
.collapsible {
border-bottom: 1px solid $border-color-light;
}
.header {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 20px 0;
min-height: 44px;
background: transparent;
border: 0;
font-family: inherit;
color: $base-color;
cursor: pointer;
text-align: left;
-webkit-tap-highlight-color: transparent;
}
.header:focus {
outline: none;
}
.header:focus-visible {
outline: 2px solid $base-color;
outline-offset: 4px;
}
.title {
font-size: 22px;
font-weight: bold;
line-height: 1.4;
flex: 1 1 auto;
}
.arrow {
display: inline-flex;
flex: 0 0 auto;
}
.content {
max-height: 0;
overflow: hidden;
transition: max-height 400ms ease-in-out;
}
.inner {
padding: 0 0 20px;
}
@media (prefers-reduced-motion: reduce) {
.content {
transition: none;
}
}

View file

@ -1,4 +1,4 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite' import { Meta, StoryObj } from '@storybook/react'
import { CollapsibleArrow } from './CollapsibleArrow' import { CollapsibleArrow } from './CollapsibleArrow'
const meta: Meta<typeof CollapsibleArrow> = { const meta: Meta<typeof CollapsibleArrow> = {

View file

@ -1,4 +1,4 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite' import { Meta, StoryObj } from '@storybook/react'
import { ContactPerson } from './ContactPerson' import { ContactPerson } from './ContactPerson'
const meta: Meta<typeof ContactPerson> = { const meta: Meta<typeof ContactPerson> = {

View file

@ -1,4 +1,4 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite' import { Meta, StoryObj } from '@storybook/react'
import { ContactPersonList } from './ContactPersonList' import { ContactPersonList } from './ContactPersonList'
const meta: Meta<typeof ContactPersonList> = { const meta: Meta<typeof ContactPersonList> = {

View file

@ -1,4 +1,4 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite' import { Meta, StoryObj } from '@storybook/react'
import portrait from "./portrait.jpeg" import portrait from "./portrait.jpeg"
import { ContactPerson2 } from './ContactPerson2' import { ContactPerson2 } from './ContactPerson2'

View file

@ -2,7 +2,6 @@
display: flex; display: flex;
gap: 20px; gap: 20px;
align-items: center; align-items: center;
justify-content: center;
} }
.photo { .photo {

View file

@ -1,55 +0,0 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite'
import portrait from '../ContactPerson2/portrait.jpeg'
import { ContactPersonCard } from './ContactPersonCard'
const meta: Meta<typeof ContactPersonCard> = {
component: ContactPersonCard,
}
type Story = StoryObj<typeof ContactPersonCard>
export default meta
export const Default: Story = {
args: {
contact: {
id: 'some_id',
name: 'Pfr. M. Mustermann',
role: 'Pfarrer',
email: 'm.mustermann@drei-koenige-berlin.de',
telephone: '+49 30 1234567',
createdAt: '2021-03-01T00:00:00.000Z',
updatedAt: '2021-03-01T00:00:00.000Z',
},
},
}
export const WithPhoto: Story = {
args: {
...Default.args,
photo: portrait,
},
}
export const MinimalWithPhoto: Story = {
args: {
contact: {
id: 'some_id',
name: 'Pfr. M. Mustermann',
createdAt: '2021-03-01T00:00:00.000Z',
updatedAt: '2021-03-01T00:00:00.000Z',
},
photo: portrait,
},
}
export const NullContact: Story = {
args: {
contact: null,
},
}
export const StringContact: Story = {
args: {
contact: 'some_id_string',
},
}

View file

@ -1,4 +1,4 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite' import { Meta, StoryObj } from '@storybook/react'
import { Container } from './Container' import { Container } from './Container'
const meta: Meta<typeof Container> = { const meta: Meta<typeof Container> = {

View file

@ -1,4 +1,4 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite' import { Meta, StoryObj } from '@storybook/react'
import { Cross } from './Cross' import { Cross } from './Cross'
const meta: Meta<typeof Cross> = { const meta: Meta<typeof Cross> = {

View file

@ -1,4 +1,4 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite' import { Meta, StoryObj } from '@storybook/react'
import { DonationAppeal } from './DonationAppeal' import { DonationAppeal } from './DonationAppeal'
const meta: Meta<typeof DonationAppeal> = { const meta: Meta<typeof DonationAppeal> = {

View file

@ -1,11 +0,0 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite'
import { DonationForm } from './DonationForm'
const meta: Meta<typeof DonationForm> = {
component: DonationForm,
}
type Story = StoryObj<typeof DonationForm>
export default meta
export const Default: Story = {}

View file

@ -1,4 +1,4 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite' import { Meta, StoryObj } from '@storybook/react'
import { Dropdown } from './Dropdown' import { Dropdown } from './Dropdown'
const meta: Meta<typeof Dropdown> = { const meta: Meta<typeof Dropdown> = {

View file

@ -1,29 +0,0 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite'
import Error from './Error'
const meta: Meta<typeof Error> = {
component: Error,
}
type Story = StoryObj<typeof Error>
export default meta
export const NotFound: Story = {
args: {
statusCode: 404,
message: 'Die angeforderte Seite wurde nicht gefunden.',
},
}
export const ServerError: Story = {
args: {
statusCode: 500,
message: 'Interner Serverfehler.',
},
}
export const WithoutMessage: Story = {
args: {
statusCode: 403,
},
}

View file

@ -1,4 +1,4 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite' import { Meta, StoryObj } from '@storybook/react'
import { EventExcerpt, EventExcerptRow } from './EventExcerpt' import { EventExcerpt, EventExcerptRow } from './EventExcerpt'
import { TextDiv } from '@/components/Text/TextDiv' import { TextDiv } from '@/components/Text/TextDiv'

View file

@ -1,4 +1,4 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite' import { Meta, StoryObj } from '@storybook/react'
import { EventRow } from './EventRow' import { EventRow } from './EventRow'
const meta: Meta<typeof EventRow> = { const meta: Meta<typeof EventRow> = {

View file

@ -1,19 +0,0 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite'
import { Col } from './Col'
const meta: Meta<typeof Col> = {
component: Col,
}
type Story = StoryObj<typeof Col>
export default meta
export const Default: Story = {
args: {
children: <div>Spalteninhalt</div>,
},
}
export const Empty: Story = {
args: {},
}

View file

@ -1,46 +0,0 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite'
import { Row } from './Row'
const meta: Meta<typeof Row> = {
component: Row,
}
type Story = StoryObj<typeof Row>
export default meta
export const Default: Story = {
args: {
children: (
<>
<div>Erste Spalte</div>
<div>Zweite Spalte</div>
<div>Dritte Spalte</div>
</>
),
},
}
export const WithGap: Story = {
args: {
gap: 32,
children: (
<>
<div>Links</div>
<div>Rechts</div>
</>
),
},
}
export const CenterAligned: Story = {
args: {
alignItems: 'center',
gap: 16,
children: (
<>
<div style={{ height: 40 }}>Kurz</div>
<div style={{ height: 100 }}>Lang</div>
</>
),
},
}

View file

@ -53,7 +53,7 @@ export const AutoScroll = ({children, isScrolling, onTouch}: AutoScrollProps) =>
}, 1); }, 1);
} }
}}, [wrapper, step, setStep, direction, setDirection, isScrolling]) }}, [wrapper, step, setStep, direction, setDirection])
return ( return (
<div <div

View file

@ -1,4 +1,4 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite' import { Meta, StoryObj } from '@storybook/react'
import { Gallery } from './Gallery' import { Gallery } from './Gallery'
import chris from "./../../assets/christophorus.jpeg" import chris from "./../../assets/christophorus.jpeg"

View file

@ -1,4 +1,4 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite' import { Meta, StoryObj } from '@storybook/react'
import { HR } from './HorizontalRule' import { HR } from './HorizontalRule'
const meta: Meta<typeof HR> = { const meta: Meta<typeof HR> = {

View file

@ -1,4 +1,4 @@
import { Meta, StoryObj } from "@storybook/nextjs-vite" import { Meta, StoryObj } from "@storybook/react"
import { Image } from './Image' import { Image } from './Image'
const meta: Meta<typeof Image> = { const meta: Meta<typeof Image> = {

View file

@ -1,4 +1,4 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite' import { Meta, StoryObj } from '@storybook/react'
import { ImageCard } from './ImageCard' import { ImageCard } from './ImageCard'
const meta: Meta<typeof ImageCard> = { const meta: Meta<typeof ImageCard> = {

View file

@ -1,4 +1,4 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite' import { Meta, StoryObj } from '@storybook/react'
import { Input } from './Input' import { Input } from './Input'
const meta: Meta<typeof Input> = { const meta: Meta<typeof Input> = {

View file

@ -1,4 +1,4 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite' import { Meta, StoryObj } from '@storybook/react'
import { Logo } from './Logo' import { Logo } from './Logo'
const meta: Meta<typeof Logo> = { const meta: Meta<typeof Logo> = {

View file

@ -1,4 +1,4 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite' import { Meta, StoryObj } from '@storybook/react'
import { MainText } from './MainText' import { MainText } from './MainText'
const meta: Meta<typeof MainText> = { const meta: Meta<typeof MainText> = {

View file

@ -1,300 +0,0 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite'
import { MassGrid } from './MassGrid'
const meta: Meta<typeof MassGrid> = {
component: MassGrid,
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
locations: [
{
id: 'clara',
name: 'St. Clara',
masses: [
{
id: '1',
date: '2024-08-21T15:00:00.000Z',
location: 'St. Clara',
type: 'MASS',
cancelled: false,
updatedAt: '',
createdAt: '',
},
{
id: '2',
date: '2024-08-23T18:00:00.000Z',
location: 'St. Clara',
type: 'MASS',
cancelled: false,
updatedAt: '',
createdAt: '',
},
{
id: '3',
date: '2024-08-24T09:00:00.000Z',
location: 'St. Clara',
type: 'WORD',
cancelled: false,
updatedAt: '',
createdAt: '',
},
],
},
{
id: 'christopherus',
name: 'St. Christopherus',
masses: [
{
id: '4',
date: '2024-08-22T10:00:00.000Z',
location: 'St. Christopherus',
type: 'MASS',
cancelled: false,
updatedAt: '',
createdAt: '',
},
{
id: '5',
date: '2024-08-25T17:00:00.000Z',
location: 'St. Christopherus',
type: 'FAMILY',
cancelled: false,
updatedAt: '',
createdAt: '',
},
{
id: '6',
date: '2024-08-26T15:00:00.000Z',
location: 'St. Christopherus',
type: 'MASS',
cancelled: true,
updatedAt: '',
createdAt: '',
},
],
},
{
id: 'marien',
name: 'St. Marien',
masses: [
{
id: '7',
date: '2024-08-23T11:00:00.000Z',
location: 'St. Marien',
type: 'MASS',
cancelled: false,
updatedAt: '',
createdAt: '',
},
{
id: '8',
date: '2024-08-24T19:00:00.000Z',
location: 'St. Marien',
type: 'MASS',
cancelled: false,
updatedAt: '',
createdAt: '',
},
],
},
],
},
}
export const SingleLocation: Story = {
args: {
locations: [
{
id: 'clara',
name: 'St. Clara',
masses: [
{
id: '1',
date: '2024-08-21T15:00:00.000Z',
location: 'St. Clara',
type: 'MASS',
cancelled: false,
updatedAt: '',
createdAt: '',
},
{
id: '2',
date: '2024-08-23T18:00:00.000Z',
location: 'St. Clara',
type: 'MASS',
cancelled: false,
updatedAt: '',
createdAt: '',
},
],
},
],
},
}
export const Empty: Story = {
args: {
locations: [],
},
}
export const SevenChurches: Story = {
args: {
locations: [
{
id: 'clara',
name: 'St. Clara',
masses: [
{
id: '1',
date: '2024-08-21T15:00:00.000Z',
location: 'St. Clara',
type: 'MASS',
cancelled: false,
updatedAt: '',
createdAt: '',
},
{
id: '2',
date: '2024-08-24T09:00:00.000Z',
location: 'St. Clara',
type: 'WORD',
cancelled: false,
updatedAt: '',
createdAt: '',
},
],
},
{
id: 'christopherus',
name: 'St. Christopherus',
masses: [
{
id: '3',
date: '2024-08-22T10:00:00.000Z',
location: 'St. Christopherus',
type: 'MASS',
cancelled: false,
updatedAt: '',
createdAt: '',
},
{
id: '4',
date: '2024-08-25T17:00:00.000Z',
location: 'St. Christopherus',
type: 'FAMILY',
cancelled: false,
updatedAt: '',
createdAt: '',
},
],
},
{
id: 'marien',
name: 'St. Marien',
masses: [
{
id: '5',
date: '2024-08-23T11:00:00.000Z',
location: 'St. Marien',
type: 'MASS',
cancelled: false,
updatedAt: '',
createdAt: '',
},
],
},
{
id: 'johannes',
name: 'St. Johannes Nepomuk II',
masses: [
{
id: '6',
date: '2024-08-21T18:00:00.000Z',
location: 'St. Johannes Nepomuk II',
type: 'MASS',
cancelled: false,
updatedAt: '',
createdAt: '',
},
{
id: '7',
date: '2024-08-24T10:30:00.000Z',
location: 'St. Johannes Nepomuk II',
type: 'MASS',
cancelled: true,
updatedAt: '',
createdAt: '',
},
],
},
{
id: 'peter',
name: 'St. Peter',
masses: [
{
id: '8',
date: '2024-08-22T08:00:00.000Z',
location: 'St. Peter',
type: 'MASS',
cancelled: false,
updatedAt: '',
createdAt: '',
},
{
id: '9',
date: '2024-08-25T11:00:00.000Z',
location: 'St. Peter',
type: 'MASS',
cancelled: false,
updatedAt: '',
createdAt: '',
},
],
},
{
id: 'paul',
name: 'St. Paulus Of the holy Cross',
masses: [
{
id: '10',
date: '2024-08-23T17:00:00.000Z',
location: 'St. Paul',
type: 'WORD',
cancelled: false,
updatedAt: '',
createdAt: '',
},
],
},
{
id: 'elisabeth',
name: 'St. Elisabeth',
masses: [
{
id: '11',
date: '2024-08-24T19:00:00.000Z',
location: 'St. Elisabeth',
type: 'FAMILY',
cancelled: false,
updatedAt: '',
createdAt: '',
},
{
id: '12',
date: '2024-08-26T09:00:00.000Z',
location: 'St. Elisabeth',
type: 'MASS',
cancelled: false,
updatedAt: '',
createdAt: '',
},
],
},
],
},
}

View file

@ -1,29 +1,11 @@
import { Worship } from '@/payload-types'
import { MassTable } from '@/components/MassTable/MassTable'
import styles from './massgrid.module.scss' import styles from './massgrid.module.scss'
export type MassGridLocation = {
id: string
name: string
masses: Worship[]
}
type MassGridProps = { type MassGridProps = {
locations: MassGridLocation[] children: React.ReactNode
} }
export const MassGrid = ({ locations }: MassGridProps) => { export const MassGrid = ({ children }: MassGridProps) => {
return ( return <div className={styles.container}>
<div className={styles.container}> <div className={styles.grid}>{children}</div>
<div className={styles.grid}>
{locations.map((location) => (
<MassTable
key={location.id}
location={location.name}
masses={location.masses}
/>
))}
</div> </div>
</div>
)
} }

View file

@ -1,4 +1,4 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite' import { Meta, StoryObj } from '@storybook/react'
import { MassTable } from './MassTable' import { MassTable } from './MassTable'
const meta: Meta<typeof MassTable> = { const meta: Meta<typeof MassTable> = {

View file

@ -1,4 +1,4 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite' import { Meta, StoryObj } from '@storybook/react'
import { MassTableRow } from './MassTableRow' import { MassTableRow } from './MassTableRow'
const meta: Meta<typeof MassTableRow> = { const meta: Meta<typeof MassTableRow> = {

View file

@ -1,9 +1,9 @@
.grid { .grid {
display: flex; display: grid;
flex-wrap: wrap; grid-template-columns: repeat(auto-fit, 200px);
justify-content: center; justify-content: center;
column-gap: 30px; column-gap: 30px;
row-gap: 30px; row-gap: 15px;
} }
.container { .container {

View file

@ -18,12 +18,6 @@
.location { .location {
font-size: 22px; font-size: 22px;
font-weight: 700; font-weight: 700;
line-height: 1em;
height: 70px;
display: flex;
align-items: flex-end;
justify-content: center;
text-align: center;
} }
.cancelled { .cancelled {
@ -49,8 +43,8 @@
.table { .table {
width: 200px; width: 200px;
display: flex; display: grid;
flex-direction: column; grid-row: span 3;
align-items: center; grid-template-rows: subgrid;
gap: 10px; justify-items: center;
} }

View file

@ -1,4 +1,4 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite' import { Meta, StoryObj } from '@storybook/react'
import { MegaMenu } from './MegaMenu' import { MegaMenu } from './MegaMenu'
const meta: Meta<typeof MegaMenu> = { const meta: Meta<typeof MegaMenu> = {

View file

@ -1,4 +1,4 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite' import { Meta, StoryObj } from '@storybook/react'
import { Menu } from './Menu' import { Menu } from './Menu'
const meta: Meta<typeof Menu> = { const meta: Meta<typeof Menu> = {

View file

@ -1,4 +1,4 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite' import { Meta, StoryObj } from '@storybook/react'
import { NextPrevButtons } from './NextPrevButtons' import { NextPrevButtons } from './NextPrevButtons'
const meta: Meta<typeof NextPrevButtons> = { const meta: Meta<typeof NextPrevButtons> = {

View file

@ -1,4 +1,4 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite' import { Meta, StoryObj } from '@storybook/react'
import { Pill } from './Pill' import { Pill } from './Pill'
const meta: Meta<typeof Pill> = { const meta: Meta<typeof Pill> = {

View file

@ -1,4 +1,4 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite' import { Meta, StoryObj } from '@storybook/react'
import { PopupButton } from './PopupButton' import { PopupButton } from './PopupButton'
const meta: Meta<typeof PopupButton> = { const meta: Meta<typeof PopupButton> = {

View file

@ -1,26 +0,0 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite'
import { RandomPrayer } from './RandomPrayer'
const meta: Meta<typeof RandomPrayer> = {
component: RandomPrayer,
}
type Story = StoryObj<typeof RandomPrayer>
export default meta
export const Default: Story = {
args: {
prayers: [
'Vater unser im Himmel, geheiligt werde dein Name.',
'Gegrüßet seist du, Maria, voll der Gnade, der Herr ist mit dir.',
'Ehre sei dem Vater und dem Sohn und dem Heiligen Geist.',
'Herr, bleibe bei uns, denn es will Abend werden.',
],
},
}
export const SinglePrayer: Story = {
args: {
prayers: ['Komm, Heiliger Geist, erfülle die Herzen deiner Gläubigen.'],
},
}

View file

@ -1,27 +0,0 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite'
import { RawHTML } from './RawHTML'
const meta: Meta<typeof RawHTML> = {
component: RawHTML,
}
type Story = StoryObj<typeof RawHTML>
export default meta
export const Default: Story = {
args: {
html: '<h3>Willkommen</h3><p>Dies ist ein Absatz mit <strong>fettem</strong> und <em>kursivem</em> Text.</p>',
},
}
export const WithList: Story = {
args: {
html: '<h3>Gottesdienste</h3><ul><li>Sonntag 10:00 Uhr</li><li>Mittwoch 18:30 Uhr</li><li>Freitag 18:30 Uhr</li></ul>',
},
}
export const WithLink: Story = {
args: {
html: '<p>Besuchen Sie <a href="https://example.com">unsere Website</a> für weitere Informationen.</p>',
},
}

View file

@ -1,4 +1,4 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite' import { Meta, StoryObj } from '@storybook/react'
import { Section } from './Section' import { Section } from './Section'
const meta: Meta<typeof Section> = { const meta: Meta<typeof Section> = {

View file

@ -1,4 +1,4 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite' import { Meta, StoryObj } from '@storybook/react'
import { SideSlider } from './SideSlider' import { SideSlider } from './SideSlider'
const meta: Meta<typeof SideSlider> = { const meta: Meta<typeof SideSlider> = {

View file

@ -1,4 +1,4 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite' import { Meta, StoryObj } from '@storybook/react'
import { Testimony } from './Testimony' import { Testimony } from './Testimony'
const meta: Meta<typeof Testimony> = { const meta: Meta<typeof Testimony> = {

View file

@ -1,56 +0,0 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite'
import { SerializedEditorState } from 'lexical'
import { HTMLText } from './HTMLText'
const meta: Meta<typeof HTMLText> = {
component: HTMLText,
}
type Story = StoryObj<typeof HTMLText>
export default meta
const sampleState: SerializedEditorState = {
root: {
type: 'root',
format: '',
indent: 0,
version: 1,
direction: 'ltr',
children: [
{
type: 'paragraph',
format: '',
indent: 0,
version: 1,
direction: 'ltr',
textFormat: 0,
textStyle: '',
children: [
{
type: 'text',
format: 0,
mode: 'normal',
style: '',
text: 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat.',
detail: 0,
version: 1,
},
],
},
],
},
} as unknown as SerializedEditorState
export const ThreeFourth: Story = {
args: {
width: '3/4',
data: sampleState,
},
}
export const Half: Story = {
args: {
width: '1/2',
data: sampleState,
},
}

View file

@ -1,25 +0,0 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite'
import { P } from './Paragraph'
const meta: Meta<typeof P> = {
component: P,
}
type Story = StoryObj<typeof P>
export default meta
export const ThreeFourth: Story = {
args: {
width: '3/4',
children:
'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.',
},
}
export const Half: Story = {
args: {
width: '1/2',
children:
'At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.',
},
}

View file

@ -1,89 +0,0 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite'
import { SerializedEditorState } from 'lexical'
import { RichText } from './RichText'
const meta: Meta<typeof RichText> = {
component: RichText,
}
type Story = StoryObj<typeof RichText>
export default meta
const buildState = (
children: SerializedEditorState['root']['children'],
): SerializedEditorState =>
({
root: {
type: 'root',
format: '',
indent: 0,
version: 1,
direction: 'ltr',
children,
},
}) as SerializedEditorState
const paragraph = (
textNodes: {
text: string
format?: number
}[],
) => ({
type: 'paragraph',
format: '',
indent: 0,
version: 1,
direction: 'ltr',
textFormat: 0,
textStyle: '',
children: textNodes.map((n) => ({
type: 'text',
format: n.format ?? 0,
mode: 'normal',
style: '',
text: n.text,
detail: 0,
version: 1,
})),
})
export const SimpleParagraph: Story = {
args: {
data: buildState([
paragraph([
{ text: 'Willkommen bei der Pfarrei Heilige Drei Könige in Berlin.' },
]),
]),
},
}
export const Formatted: Story = {
args: {
data: buildState([
paragraph([
{ text: 'Dies ist ein ' },
{ text: 'fettgedruckter', format: 1 },
{ text: ' und ein ' },
{ text: 'kursiver', format: 2 },
{ text: ' Text.' },
]),
]),
},
}
export const MultipleParagraphs: Story = {
args: {
data: buildState([
paragraph([
{
text: 'Unsere Gottesdienste finden jeden Sonntag um 10:00 Uhr statt.',
},
]),
paragraph([
{
text: 'Herzlich willkommen sind alle, die mitfeiern und mitbeten möchten.',
},
]),
]),
},
}

Some files were not shown because too many files have changed in this diff Show more