Compare commits
14 commits
c4060dad0c
...
47fa6db411
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47fa6db411 | ||
|
|
fbcc46eafc | ||
|
|
f489ac9d9b | ||
|
|
20b0c0a768 | ||
|
|
542bb8c098 | ||
|
|
fcef53e7d0 | ||
|
|
b8ed9ea538 | ||
|
|
ed4ce47ee4 | ||
|
|
024cdad9dc | ||
|
|
130d5b89df | ||
|
|
3fab363e1f | ||
|
|
dba13d1d31 | ||
|
|
891da86f08 | ||
|
|
2efcb5f672 |
72
.storybook/main.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
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;
|
||||||
20
.storybook/preview-head.html
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<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>
|
||||||
35
.storybook/preview.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
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
|
||||||
98
CLAUDE.md
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
# 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
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
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>.
|
||||||
16
README.md
|
|
@ -1,11 +1,13 @@
|
||||||
# Heilige Drei Könige Website
|
# Parish Website Template
|
||||||
|
|
||||||
This repository contains the source code for the Heilige Drei Könige Catholic Church website, built with:
|
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.
|
||||||
|
|
||||||
- Next.js
|
Built with:
|
||||||
- React
|
|
||||||
|
- Next.js 15 (App Router)
|
||||||
|
- React 19
|
||||||
- Payload CMS v3
|
- Payload CMS v3
|
||||||
- PostgreSQL
|
- PostgreSQL with PostGIS
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
|
|
@ -130,6 +132,10 @@ 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):
|
||||||
|
|
|
||||||
|
|
@ -169,42 +169,38 @@ docker restart postgres
|
||||||
|
|
||||||
## Deploy via Ansible (without CI/CD)
|
## Deploy via Ansible (without CI/CD)
|
||||||
|
|
||||||
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.
|
Use these playbooks to deploy from your local machine — no Forgejo runner needed.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd infra/ansible
|
cd infra/ansible
|
||||||
|
|
||||||
# Deploy to test/staging VPS
|
# Deploy both environments (git pull once, then build+deploy each sequentially)
|
||||||
ansible-playbook playbooks/deploy.yml -i inventory/test.yml --ask-vault-pass
|
ansible-playbook playbooks/deploy.yml --ask-vault-pass
|
||||||
|
|
||||||
# Deploy to production
|
# Deploy staging only
|
||||||
ansible-playbook playbooks/deploy.yml -i inventory/production.yml --ask-vault-pass
|
ansible-playbook playbooks/deploy-staging.yml --ask-vault-pass
|
||||||
|
|
||||||
|
# Deploy test only
|
||||||
|
ansible-playbook playbooks/deploy-test.yml --ask-vault-pass
|
||||||
```
|
```
|
||||||
|
|
||||||
**What it does:**
|
**Steps executed per environment:**
|
||||||
|
|
||||||
1. Pulls the latest code from the configured branch (`repo_branch` in inventory)
|
1. Pull latest code from the configured branch (`staging`)
|
||||||
2. Runs `deploy.sh` for each environment (sequentially to save RAM), which:
|
2. Build app Docker image (bakes in `NEXT_PUBLIC_SERVER_URL` and `NEXT_PUBLIC_SITE_ID`)
|
||||||
- Builds the Docker app image with build-time env vars
|
3. Build migration image and run `npx payload migrate`
|
||||||
- Builds a migration image and runs `npx payload migrate`
|
4. Stop and remove the old container
|
||||||
- Stops the old container, starts the new one
|
5. Start the new container
|
||||||
- Prunes old Docker images
|
6. Fix upload volume permissions
|
||||||
|
7. Prune old Docker images
|
||||||
|
|
||||||
**Deploy a specific branch:**
|
**Deploy a specific branch:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ansible-playbook playbooks/deploy.yml -i inventory/test.yml --ask-vault-pass \
|
ansible-playbook playbooks/deploy.yml --ask-vault-pass -e repo_branch=feature/my-branch
|
||||||
-e repo_branch=feature/my-branch
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Deploy only one environment** (e.g., just staging):
|
> **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.
|
||||||
|
|
||||||
```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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
20
infra/ansible/playbooks/deploy-staging.yml
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
---
|
||||||
|
- 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 }}"
|
||||||
20
infra/ansible/playbooks/deploy-test.yml
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
---
|
||||||
|
- 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 }}"
|
||||||
|
|
@ -1,19 +1,37 @@
|
||||||
---
|
---
|
||||||
- name: Deploy app (rebuild + restart)
|
- name: Pull latest code (shared for both environments)
|
||||||
hosts: all
|
hosts: all
|
||||||
become: true
|
become: true
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
- name: Pull latest code
|
- name: Pull {{ repo_branch }} branch
|
||||||
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 each environment
|
- name: Deploy staging environment
|
||||||
ansible.builtin.shell: |
|
hosts: all
|
||||||
{{ scripts_dir }}/deploy.sh {{ item.name }} {{ item.port }}
|
become: true
|
||||||
loop: "{{ app_environments }}"
|
|
||||||
loop_control:
|
tasks:
|
||||||
label: "{{ item.name }}"
|
- name: Build and deploy staging
|
||||||
|
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 }}"
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
---
|
---
|
||||||
- name: Deploy each environment (sequentially to save RAM)
|
- name: Deploy each environment (sequentially to save RAM)
|
||||||
ansible.builtin.shell: |
|
ansible.builtin.include_tasks: deploy_env.yml
|
||||||
{{ scripts_dir }}/deploy.sh {{ item.name }} {{ item.port }}
|
|
||||||
loop: "{{ app_environments }}"
|
loop: "{{ app_environments }}"
|
||||||
loop_control:
|
loop_control:
|
||||||
label: "{{ item.name }}"
|
loop_var: env
|
||||||
register: deploy_result
|
label: "{{ env.name }}"
|
||||||
changed_when: true
|
|
||||||
|
|
|
||||||
98
infra/ansible/roles/app/tasks/deploy_env.yml
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
---
|
||||||
|
- 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
|
||||||
|
|
@ -4,9 +4,6 @@ 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: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
{
|
{
|
||||||
"name": "drei-koenige-v3",
|
"name": "@bennotielen/parish-website",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "A blank template to get started with Payload 3.0",
|
"description": "White-label parish website template (Next.js 15 + Payload CMS v3 + PostgreSQL/PostGIS).",
|
||||||
"license": "MIT",
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
|
"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",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,166 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { CheckboxInput, useDocumentInfo } from '@payloadcms/ui'
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
type Occurrence = {
|
||||||
|
id: string
|
||||||
|
date: string
|
||||||
|
cancelled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const LIMIT = 10
|
||||||
|
|
||||||
|
const formatDate = (iso: string): string =>
|
||||||
|
new Date(iso).toLocaleString('de-DE', {
|
||||||
|
weekday: 'short',
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const NextOccurrencesTable = () => {
|
||||||
|
const { id } = useDocumentInfo()
|
||||||
|
const [occurrences, setOccurrences] = useState<Occurrence[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [pendingIds, setPendingIds] = useState<Set<string>>(new Set())
|
||||||
|
const [errorIds, setErrorIds] = useState<Set<string>>(new Set())
|
||||||
|
const [fetchError, setFetchError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return
|
||||||
|
let cancelled = false
|
||||||
|
setLoading(true)
|
||||||
|
setFetchError(null)
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
'where[event][equals]': String(id),
|
||||||
|
'where[date][greater_than_equal]': new Date().toISOString(),
|
||||||
|
sort: 'date',
|
||||||
|
limit: String(LIMIT),
|
||||||
|
depth: '0',
|
||||||
|
})
|
||||||
|
|
||||||
|
fetch(`/api/eventOccurrence?${params.toString()}`, { credentials: 'include' })
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
return res.json()
|
||||||
|
})
|
||||||
|
.then((json: { docs: Occurrence[] }) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setOccurrences(json.docs)
|
||||||
|
})
|
||||||
|
.catch((err: Error) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setFetchError(err.message)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [id])
|
||||||
|
|
||||||
|
const toggle = useCallback(async (occ: Occurrence) => {
|
||||||
|
const next = !occ.cancelled
|
||||||
|
setOccurrences((prev) =>
|
||||||
|
prev.map((o) => (o.id === occ.id ? { ...o, cancelled: next } : o)),
|
||||||
|
)
|
||||||
|
setPendingIds((prev) => new Set(prev).add(occ.id))
|
||||||
|
setErrorIds((prev) => {
|
||||||
|
const s = new Set(prev)
|
||||||
|
s.delete(occ.id)
|
||||||
|
return s
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/eventOccurrence/${occ.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ cancelled: next }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
} catch {
|
||||||
|
setOccurrences((prev) =>
|
||||||
|
prev.map((o) => (o.id === occ.id ? { ...o, cancelled: occ.cancelled } : o)),
|
||||||
|
)
|
||||||
|
setErrorIds((prev) => new Set(prev).add(occ.id))
|
||||||
|
} finally {
|
||||||
|
setPendingIds((prev) => {
|
||||||
|
const s = new Set(prev)
|
||||||
|
s.delete(occ.id)
|
||||||
|
return s
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return (
|
||||||
|
<div className="field-description">
|
||||||
|
Veranstaltung erst speichern, um Termine zu verwalten.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="field-description">Wird geladen…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fetchError) {
|
||||||
|
return (
|
||||||
|
<div className="field-description" style={{ color: 'var(--theme-error-500)' }}>
|
||||||
|
Fehler beim Laden: {fetchError}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (occurrences.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="field-description">
|
||||||
|
Keine zukünftigen Termine vorhanden.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Datum</th>
|
||||||
|
<th>Abgesagt</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{occurrences.map((occ) => (
|
||||||
|
<tr key={occ.id}>
|
||||||
|
<td>{formatDate(occ.date)}</td>
|
||||||
|
<td>
|
||||||
|
<CheckboxInput
|
||||||
|
checked={occ.cancelled}
|
||||||
|
onToggle={() => toggle(occ)}
|
||||||
|
readOnly={pendingIds.has(occ.id)}
|
||||||
|
/>
|
||||||
|
{errorIds.has(occ.id) && (
|
||||||
|
<div
|
||||||
|
className="field-description"
|
||||||
|
style={{ color: 'var(--theme-error-500)' }}
|
||||||
|
>
|
||||||
|
Speichern fehlgeschlagen.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NextOccurrencesTable
|
||||||
|
|
@ -4,9 +4,8 @@ import { Blocks } from '@/compositions/Blocks/Blocks'
|
||||||
import { Metadata } from 'next'
|
import { Metadata } from 'next'
|
||||||
import { AdminMenu } from '@/components/AdminMenu/AdminMenu'
|
import { AdminMenu } from '@/components/AdminMenu/AdminMenu'
|
||||||
import { Section } from '@/components/Section/Section'
|
import { Section } from '@/components/Section/Section'
|
||||||
import { isAuthenticated } from '@/utils/auth'
|
import { getRequestAuth } from '@/utils/auth'
|
||||||
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave/RefreshRouteOnSave'
|
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave/RefreshRouteOnSave'
|
||||||
import { draftMode } from 'next/headers'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
params: Promise<{ slug?: string[] }>
|
params: Promise<{ slug?: string[] }>
|
||||||
|
|
@ -26,9 +25,8 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
|
|
||||||
export default async function DynamicPage({ params }: Props) {
|
export default async function DynamicPage({ params }: Props) {
|
||||||
const slug = (await params).slug
|
const slug = (await params).slug
|
||||||
const { isEnabled: isDraft } = await draftMode()
|
const { authenticated, isDraft } = await getRequestAuth()
|
||||||
const page = await fetchPageBySlug(slug?.join('/') || "", isDraft)
|
const page = await fetchPageBySlug(slug?.join('/') || "", isDraft)
|
||||||
const authenticated = await isAuthenticated()
|
|
||||||
|
|
||||||
if (!page) {
|
if (!page) {
|
||||||
notFound()
|
notFound()
|
||||||
|
|
|
||||||
|
|
@ -9,27 +9,19 @@ import { HR } from '@/components/HorizontalRule/HorizontalRule'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import styles from './styles.module.scss'
|
import styles from './styles.module.scss'
|
||||||
import { Blocks } from '@/compositions/Blocks/Blocks'
|
import { Blocks } from '@/compositions/Blocks/Blocks'
|
||||||
import { isAuthenticated } from '@/utils/auth'
|
import { canView, getRequestAuth } from '@/utils/auth'
|
||||||
import { AdminMenu } from '@/components/AdminMenu/AdminMenu'
|
import { AdminMenu } from '@/components/AdminMenu/AdminMenu'
|
||||||
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave/RefreshRouteOnSave'
|
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave/RefreshRouteOnSave'
|
||||||
import { draftMode } from 'next/headers'
|
|
||||||
import { fetchBlog } from '@/fetch/blog'
|
import { fetchBlog } from '@/fetch/blog'
|
||||||
|
|
||||||
export default async function BlogPage({ params }: { params: Promise<{id: string}>}){
|
export default async function BlogPage({ params }: { params: Promise<{id: string}>}){
|
||||||
|
|
||||||
const id = (await params).id;
|
const id = (await params).id;
|
||||||
const { isEnabled: isDraft } = await draftMode()
|
const { authenticated, isDraft } = await getRequestAuth()
|
||||||
const data = await fetchBlog(id, isDraft) as Blog;
|
const data = await fetchBlog(id, isDraft) as Blog;
|
||||||
const url = typeof data.photo === 'object' && data.photo?.sizes?.banner?.url;
|
const url = typeof data.photo === 'object' && data.photo?.sizes?.banner?.url;
|
||||||
const authenticated = await isAuthenticated();
|
|
||||||
|
|
||||||
if(!data) {
|
if (!canView(data, authenticated)) notFound()
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
if(!authenticated && data._status !== "published") {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
// determine if some margin at the bottom should be added
|
// determine if some margin at the bottom should be added
|
||||||
const length = data.content.content.length;
|
const length = data.content.content.length;
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,22 @@
|
||||||
import { notFound } from 'next/navigation'
|
import { notFound } from 'next/navigation'
|
||||||
import { Parish } from '@/pageComponents/Parish/Parish'
|
import { Parish } from '@/pageComponents/Parish/Parish'
|
||||||
import { fetchEvents } from '@/fetch/events'
|
import { fetchUpcomingOccurrences } from '@/fetch/eventOccurrences'
|
||||||
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 { isAuthenticated } from '@/utils/auth'
|
import { canView, getRequestAuth } from '@/utils/auth'
|
||||||
import { AdminMenu } from '@/components/AdminMenu/AdminMenu'
|
import { AdminMenu } from '@/components/AdminMenu/AdminMenu'
|
||||||
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave/RefreshRouteOnSave'
|
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave/RefreshRouteOnSave'
|
||||||
import { draftMode } from 'next/headers'
|
|
||||||
|
|
||||||
export default async function ParishPage ({ params }: { params: Promise<{slug: string}>}) {
|
export default async function ParishPage ({ params }: { params: Promise<{slug: string}>}) {
|
||||||
|
|
||||||
const slug = (await params).slug;
|
const slug = (await params).slug;
|
||||||
const { isEnabled: isDraft } = await draftMode()
|
const { authenticated, isDraft } = await getRequestAuth()
|
||||||
const parish = await fetchParish(slug, isDraft);
|
const parish = await fetchParish(slug, isDraft);
|
||||||
const authenticated = await isAuthenticated();
|
|
||||||
|
|
||||||
if(!parish) {
|
if (!canView(parish, authenticated)) notFound()
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
if(!authenticated && parish._status !== "published") {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
id,
|
id,
|
||||||
|
|
@ -38,7 +30,7 @@ export default async function ParishPage ({ params }: { params: Promise<{slug: s
|
||||||
gallery,
|
gallery,
|
||||||
content
|
content
|
||||||
} = parish
|
} = parish
|
||||||
const events = await fetchEvents({ parishId: id })
|
const events = await fetchUpcomingOccurrences({ 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);
|
||||||
|
|
|
||||||
|
|
@ -11,28 +11,19 @@ import { Col } from '@/components/Flex/Col'
|
||||||
import { Row } from '@/components/Flex/Row'
|
import { Row } from '@/components/Flex/Row'
|
||||||
import { Blocks } from '@/compositions/Blocks/Blocks'
|
import { Blocks } from '@/compositions/Blocks/Blocks'
|
||||||
import { getPhoto } from '@/utils/dto/gallery'
|
import { getPhoto } from '@/utils/dto/gallery'
|
||||||
import { isAuthenticated } from '@/utils/auth'
|
import { canView, getRequestAuth } from '@/utils/auth'
|
||||||
import { AdminMenu } from '@/components/AdminMenu/AdminMenu'
|
import { AdminMenu } from '@/components/AdminMenu/AdminMenu'
|
||||||
import { GroupEvents } from '@/compositions/GroupEvents/GroupEvents'
|
import { GroupEvents } from '@/compositions/GroupEvents/GroupEvents'
|
||||||
import { RichText } from '@/components/Text/RichText'
|
import { RichText } from '@/components/Text/RichText'
|
||||||
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave/RefreshRouteOnSave'
|
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave/RefreshRouteOnSave'
|
||||||
import { draftMode } from 'next/headers'
|
|
||||||
|
|
||||||
export default async function GroupPage({ params }: { params: Promise<{slug: string}>}) {
|
export default async function GroupPage({ params }: { params: Promise<{slug: string}>}) {
|
||||||
|
|
||||||
const slug = (await params).slug
|
const slug = (await params).slug
|
||||||
const { isEnabled: isDraft } = await draftMode()
|
const { authenticated, isDraft } = await getRequestAuth()
|
||||||
const group = await fetchGroup(slug, isDraft)
|
const group = await fetchGroup(slug, isDraft)
|
||||||
|
|
||||||
if(!group) {
|
if (!canView(group, authenticated)) notFound()
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
const authenticated = await isAuthenticated();
|
|
||||||
|
|
||||||
if(!authenticated && group._status !== "published") {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
const {id, shortDescription, photo, name, content, text } = group
|
const {id, shortDescription, photo, name, content, text } = group
|
||||||
const media = getPhoto("tablet", photo)
|
const media = getPhoto("tablet", photo)
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,7 @@
|
||||||
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}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 145 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 118 KiB |
|
|
@ -0,0 +1,47 @@
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
32
src/app/(home)/veranstaltungen/[eventId]/page.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
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}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { fetchEvents } from '@/fetch/events'
|
import { fetchUpcomingOccurrences } from '@/fetch/eventOccurrences'
|
||||||
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 { transformEvents } from '@/utils/dto/events'
|
import { transformOccurrences } 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 paginatedEvents = await fetchEvents(
|
const paginatedOccurrences = await fetchUpcomingOccurrences(
|
||||||
{
|
{
|
||||||
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 (!paginatedEvents) {
|
if (!paginatedOccurrences) {
|
||||||
return <Error statusCode={503} message={"Veranstaltungen konnten nicht geladen werden."}/>;
|
return <Error statusCode={503} message={"Veranstaltungen konnten nicht geladen werden."}/>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const events = transformEvents(paginatedEvents.docs)
|
const events = transformOccurrences(paginatedOccurrences.docs)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1e
|
||||||
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { NextOccurrencesTable as NextOccurrencesTable_5b561f1308e1f6674a6813c6174350fc } from '@/admin/components/NextOccurrencesTable/NextOccurrencesTable'
|
||||||
import { LinkToDoc as LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634 } from '@payloadcms/plugin-search/client'
|
import { LinkToDoc as LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634 } from '@payloadcms/plugin-search/client'
|
||||||
import { ReindexButton as ReindexButton_aead06e4cbf6b2620c5c51c9ab283634 } from '@payloadcms/plugin-search/client'
|
import { ReindexButton as ReindexButton_aead06e4cbf6b2620c5c51c9ab283634 } from '@payloadcms/plugin-search/client'
|
||||||
import { default as default_9bcae99938dc292be0063ce32055e14c } from '../../../components/Logo/Logo'
|
import { default as default_9bcae99938dc292be0063ce32055e14c } from '../../../components/Logo/Logo'
|
||||||
|
|
@ -33,6 +34,7 @@ export const importMap = {
|
||||||
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@/admin/components/NextOccurrencesTable/NextOccurrencesTable#NextOccurrencesTable": NextOccurrencesTable_5b561f1308e1f6674a6813c6174350fc,
|
||||||
"@payloadcms/plugin-search/client#LinkToDoc": LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634,
|
"@payloadcms/plugin-search/client#LinkToDoc": LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634,
|
||||||
"@payloadcms/plugin-search/client#ReindexButton": ReindexButton_aead06e4cbf6b2620c5c51c9ab283634,
|
"@payloadcms/plugin-search/client#ReindexButton": ReindexButton_aead06e4cbf6b2620c5c51c9ab283634,
|
||||||
"/components/Logo/Logo#default": default_9bcae99938dc292be0063ce32055e14c,
|
"/components/Logo/Logo#default": default_9bcae99938dc292be0063ce32055e14c,
|
||||||
|
|
|
||||||
76
src/collections/EventOccurrences.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
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(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ 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',
|
||||||
|
|
@ -15,170 +16,233 @@ export const Events: CollectionConfig = {
|
||||||
},
|
},
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
name: 'title',
|
type: 'tabs',
|
||||||
type: 'text',
|
tabs: [
|
||||||
required: true,
|
{
|
||||||
label: {
|
label: { de: 'Allgemein' },
|
||||||
de: 'Titel',
|
fields: [
|
||||||
},
|
{
|
||||||
},
|
name: 'title',
|
||||||
{
|
type: 'text',
|
||||||
name: 'date',
|
required: true,
|
||||||
type: 'date',
|
label: {
|
||||||
required: true,
|
de: 'Titel',
|
||||||
label: {
|
},
|
||||||
de: 'Datum',
|
},
|
||||||
},
|
{
|
||||||
admin: {
|
name: 'date',
|
||||||
date: {
|
type: 'date',
|
||||||
pickerAppearance: 'dayAndTime',
|
required: true,
|
||||||
timeIntervals: 15,
|
label: {
|
||||||
timeFormat: 'HH:mm'
|
de: 'Datum',
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
date: {
|
||||||
|
pickerAppearance: 'dayAndTime',
|
||||||
|
timeIntervals: 15,
|
||||||
|
timeFormat: 'HH:mm'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'location',
|
||||||
|
type: 'relationship',
|
||||||
|
relationTo: 'locations',
|
||||||
|
required: true,
|
||||||
|
label: {
|
||||||
|
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: 'location',
|
{
|
||||||
type: 'relationship',
|
name: 'parish',
|
||||||
relationTo: 'locations',
|
type: 'relationship',
|
||||||
required: true,
|
relationTo: 'parish',
|
||||||
label: {
|
hasMany: true,
|
||||||
de: 'Location'
|
label: {
|
||||||
},
|
de: 'Gemeinde',
|
||||||
},
|
},
|
||||||
{
|
validate: (value, options) => {
|
||||||
name: 'parish',
|
let user = options.req.user
|
||||||
type: 'relationship',
|
|
||||||
relationTo: 'parish',
|
|
||||||
hasMany: true,
|
|
||||||
label: {
|
|
||||||
de: 'Gemeinde',
|
|
||||||
},
|
|
||||||
validate: (value, options) => {
|
|
||||||
let user = options.req.user
|
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return 'You are not allowed to do this'
|
return 'You are not allowed to do this'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.roles === 'user' && value && value.length > 0) {
|
if (user.roles === 'user' && value && value.length > 0) {
|
||||||
return 'Sie sind nur erlaubt Veranstaltungen für Gruppen zu erstellen.'
|
return 'Sie sind nur erlaubt Veranstaltungen für Gruppen zu erstellen.'
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'group',
|
name: 'group',
|
||||||
type: 'relationship',
|
type: 'relationship',
|
||||||
relationTo: 'group',
|
relationTo: 'group',
|
||||||
hasMany: true,
|
hasMany: true,
|
||||||
label: {
|
label: {
|
||||||
de: 'Gruppe',
|
de: 'Gruppe',
|
||||||
},
|
},
|
||||||
access: {
|
access: {
|
||||||
update: ({req: { user}, data}) => {
|
update: ({req: { user}, data}) => {
|
||||||
if(user && (user.roles == "admin" || user.roles =="employee")) {
|
if(user && (user.roles == "admin" || user.roles =="employee")) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if(hasGroup(user, data)) {
|
if(hasGroup(user, data)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
validate: (value, options: { req: { user: any } }) => {
|
validate: (value, options: { req: { user: any } }) => {
|
||||||
let user = options.req.user
|
let user = options.req.user
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return 'You are not allowed to do this'
|
return 'You are not allowed to do this'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.roles === 'user') {
|
if (user.roles === 'user') {
|
||||||
if(!Array.isArray(value) || value.length === 0) {
|
if(!Array.isArray(value) || value.length === 0) {
|
||||||
return 'Sie müssen die Veranstaltung verknüpfen mit ihrer Gruppe.'
|
return 'Sie müssen die Veranstaltung verknüpfen mit ihrer Gruppe.'
|
||||||
}
|
}
|
||||||
|
|
||||||
if(!Array.isArray(user.groups) || user.groups.length === 0) {
|
if(!Array.isArray(user.groups) || user.groups.length === 0) {
|
||||||
return "Sie sind kein Mitglied einer Gruppe, und können deswegen keine Veranstaltung erstellen."
|
return "Sie sind kein Mitglied einer Gruppe, und können deswegen keine Veranstaltung erstellen."
|
||||||
}
|
}
|
||||||
|
|
||||||
if(!value.every(id => user.groups.includes(id))) {
|
if(!value.every(id => user.groups.includes(id))) {
|
||||||
return "Sie sind nur berechtigt Veranstaltungen für ihrer Gruppe zu erstellen"
|
return "Sie sind nur berechtigt Veranstaltungen für ihrer Gruppe zu erstellen"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
],
|
||||||
name: 'contact',
|
},
|
||||||
type: 'relationship',
|
{
|
||||||
relationTo: 'contactPerson',
|
label: { de: 'Medien' },
|
||||||
label: {
|
fields: [
|
||||||
de: "Ansprechperson"
|
{
|
||||||
}
|
name: 'photo',
|
||||||
},
|
label: {
|
||||||
{
|
de: 'Foto',
|
||||||
name: 'shortDescription',
|
},
|
||||||
type: 'textarea',
|
type: 'upload',
|
||||||
required: true,
|
relationTo: 'media',
|
||||||
label: {
|
},
|
||||||
de: 'Kurzumschreibung (max. 200)',
|
{
|
||||||
},
|
name: 'flyer',
|
||||||
maxLength: 200
|
label: {
|
||||||
},
|
de: "Flyer (PDF)"
|
||||||
{
|
},
|
||||||
name: 'description',
|
type: 'upload',
|
||||||
type: 'textarea',
|
relationTo: 'documents'
|
||||||
required: true,
|
},
|
||||||
label: {
|
],
|
||||||
de: 'Einladung',
|
},
|
||||||
},
|
{
|
||||||
},
|
label: { de: 'Wiederholung' },
|
||||||
{
|
fields: [
|
||||||
name: 'rsvpLink',
|
{
|
||||||
type: 'text',
|
name: 'recurrenceType',
|
||||||
required: false,
|
type: 'select',
|
||||||
label: {
|
required: true,
|
||||||
de: "Anmeldelink"
|
defaultValue: 'none',
|
||||||
}
|
label: {
|
||||||
},
|
de: 'Wiederholung',
|
||||||
{
|
},
|
||||||
name: 'photo',
|
options: [
|
||||||
label: {
|
{ label: 'Einmalig', value: 'none' },
|
||||||
de: 'Foto',
|
{ label: 'Wöchentlich', value: 'weekly' },
|
||||||
},
|
{ label: 'Alle 2 Wochen', value: 'biweekly' },
|
||||||
type: 'upload',
|
],
|
||||||
relationTo: 'media',
|
admin: {
|
||||||
},
|
description:
|
||||||
{
|
'Bei wiederkehrenden Veranstaltungen werden automatisch Termine für die nächsten Wochen erzeugt. Wochentag und Uhrzeit werden aus dem Datumsfeld übernommen.',
|
||||||
name: 'flyer',
|
},
|
||||||
label: {
|
},
|
||||||
de: "Flyer (PDF)"
|
{
|
||||||
},
|
name: 'endDate',
|
||||||
type: 'upload',
|
type: 'date',
|
||||||
relationTo: 'documents'
|
label: {
|
||||||
},
|
de: 'Enddatum',
|
||||||
{
|
},
|
||||||
name: 'cancelled',
|
admin: {
|
||||||
type: 'checkbox',
|
date: {
|
||||||
required: true,
|
pickerAppearance: 'dayOnly',
|
||||||
label: {
|
},
|
||||||
de: 'Abgesagt',
|
description: 'Optional. Nach diesem Datum werden keine weiteren Termine erzeugt.',
|
||||||
},
|
},
|
||||||
defaultValue: false,
|
},
|
||||||
},
|
{
|
||||||
{
|
name: 'cancelled',
|
||||||
name: 'isRecurring',
|
type: 'checkbox',
|
||||||
type: 'checkbox',
|
required: true,
|
||||||
required: true,
|
label: {
|
||||||
label: {
|
de: 'Abgesagt',
|
||||||
de: 'Regelmäßig',
|
},
|
||||||
},
|
defaultValue: false,
|
||||||
defaultValue: false,
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: { de: 'Nächste Termine' },
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'nextOccurrences',
|
||||||
|
type: 'ui',
|
||||||
|
admin: {
|
||||||
|
components: {
|
||||||
|
Field: {
|
||||||
|
path: '@/admin/components/NextOccurrencesTable/NextOccurrencesTable#NextOccurrencesTable',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
admin: {
|
admin: {
|
||||||
|
|
@ -215,6 +279,53 @@ 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,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -243,4 +354,3 @@ const hasGroup = (user: null | User , data: Partial<any> | undefined) => {
|
||||||
return user.groups.includes(group.id)
|
return user.groups.includes(group.id)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ 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',
|
||||||
|
|
@ -79,6 +81,7 @@ export const Groups: CollectionConfig = {
|
||||||
name: 'content',
|
name: 'content',
|
||||||
type: 'blocks',
|
type: 'blocks',
|
||||||
blocks: [
|
blocks: [
|
||||||
|
TitleBlock,
|
||||||
ParagraphBlock,
|
ParagraphBlock,
|
||||||
GalleryBlock,
|
GalleryBlock,
|
||||||
DocumentBlock,
|
DocumentBlock,
|
||||||
|
|
@ -87,6 +90,7 @@ export const Groups: CollectionConfig = {
|
||||||
ContactformBlock,
|
ContactformBlock,
|
||||||
ButtonBlock,
|
ButtonBlock,
|
||||||
ImageCardsBlock,
|
ImageCardsBlock,
|
||||||
|
CollapsiblesBlock,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,13 @@ 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 = {
|
||||||
|
|
@ -99,11 +101,13 @@ export const Pages: CollectionConfig = {
|
||||||
HorizontalRuleBlock,
|
HorizontalRuleBlock,
|
||||||
BlogSliderBlock,
|
BlogSliderBlock,
|
||||||
CollapsibleImageWithTextBlock,
|
CollapsibleImageWithTextBlock,
|
||||||
|
CollapsiblesBlock,
|
||||||
MassTimesBlock,
|
MassTimesBlock,
|
||||||
EventsBlock,
|
EventsBlock,
|
||||||
ContactPersonBlock,
|
ContactPersonBlock,
|
||||||
ImageCardsBlock,
|
ImageCardsBlock,
|
||||||
ClassifiedsBlock,
|
ClassifiedsBlock,
|
||||||
|
NextPrevButtonsBlock,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ 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 = {
|
||||||
|
|
@ -123,6 +124,7 @@ export const Parish: CollectionConfig = {
|
||||||
DonationAppeal,
|
DonationAppeal,
|
||||||
ImageCardsBlock,
|
ImageCardsBlock,
|
||||||
TitleBlock,
|
TitleBlock,
|
||||||
|
CollapsiblesBlock,
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
47
src/collections/blocks/Collapsibles.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
@ -26,5 +26,20 @@ 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.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
64
src/collections/blocks/NextPrevButtons.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
32
src/components/AdminMenu/AdminMenu.stories.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Meta, StoryObj } from '@storybook/react'
|
import { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||||
import { Arrow } from './Arrow'
|
import { Arrow } from './Arrow'
|
||||||
|
|
||||||
const meta: Meta<typeof Arrow> = {
|
const meta: Meta<typeof Arrow> = {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Meta, StoryObj } from '@storybook/react'
|
import { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||||
import { Banner } from './Banner'
|
import { Banner } from './Banner'
|
||||||
|
|
||||||
const meta: Meta<typeof Banner> = {
|
const meta: Meta<typeof Banner> = {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Meta, StoryObj } from '@storybook/react'
|
import { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||||
import { BlogExcerpt } from './BlogExcerpt'
|
import { BlogExcerpt } from './BlogExcerpt'
|
||||||
|
|
||||||
const meta: Meta<typeof BlogExcerpt> = {
|
const meta: Meta<typeof BlogExcerpt> = {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Meta, StoryObj } from "@storybook/react"
|
import { Meta, StoryObj } from "@storybook/nextjs-vite"
|
||||||
import { Button } from './Button'
|
import { Button } from './Button'
|
||||||
|
|
||||||
const meta: Meta<typeof Button> = {
|
const meta: Meta<typeof Button> = {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Meta, StoryObj } from '@storybook/react'
|
import { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||||
import { ChurchCard } from './ChurchCard'
|
import { ChurchCard } from './ChurchCard'
|
||||||
|
|
||||||
const meta: Meta<typeof ChurchCard> = {
|
const meta: Meta<typeof ChurchCard> = {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Meta, StoryObj } from '@storybook/react'
|
import { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||||
import { ChurchIcon } from './ChurchIcon'
|
import { ChurchIcon } from './ChurchIcon'
|
||||||
|
|
||||||
const meta: Meta<typeof ChurchIcon> = {
|
const meta: Meta<typeof ChurchIcon> = {
|
||||||
|
|
|
||||||
61
src/components/Classifieds/Ad.stories.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
}
|
||||||
75
src/components/Classifieds/Classifieds.stories.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
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: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
66
src/components/Collapsible/Collapsible.stories.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
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>
|
||||||
|
),
|
||||||
|
}
|
||||||
73
src/components/Collapsible/Collapsible.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
59
src/components/Collapsible/styles.module.scss
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Meta, StoryObj } from '@storybook/react'
|
import { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||||
import { CollapsibleArrow } from './CollapsibleArrow'
|
import { CollapsibleArrow } from './CollapsibleArrow'
|
||||||
|
|
||||||
const meta: Meta<typeof CollapsibleArrow> = {
|
const meta: Meta<typeof CollapsibleArrow> = {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Meta, StoryObj } from '@storybook/react'
|
import { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||||
import { ContactPerson } from './ContactPerson'
|
import { ContactPerson } from './ContactPerson'
|
||||||
|
|
||||||
const meta: Meta<typeof ContactPerson> = {
|
const meta: Meta<typeof ContactPerson> = {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Meta, StoryObj } from '@storybook/react'
|
import { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||||
import { ContactPersonList } from './ContactPersonList'
|
import { ContactPersonList } from './ContactPersonList'
|
||||||
|
|
||||||
const meta: Meta<typeof ContactPersonList> = {
|
const meta: Meta<typeof ContactPersonList> = {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Meta, StoryObj } from '@storybook/react'
|
import { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||||
import portrait from "./portrait.jpeg"
|
import portrait from "./portrait.jpeg"
|
||||||
import { ContactPerson2 } from './ContactPerson2'
|
import { ContactPerson2 } from './ContactPerson2'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.photo {
|
.photo {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Meta, StoryObj } from '@storybook/react'
|
import { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||||
import { Container } from './Container'
|
import { Container } from './Container'
|
||||||
|
|
||||||
const meta: Meta<typeof Container> = {
|
const meta: Meta<typeof Container> = {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Meta, StoryObj } from '@storybook/react'
|
import { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||||
import { Cross } from './Cross'
|
import { Cross } from './Cross'
|
||||||
|
|
||||||
const meta: Meta<typeof Cross> = {
|
const meta: Meta<typeof Cross> = {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Meta, StoryObj } from '@storybook/react'
|
import { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||||
import { DonationAppeal } from './DonationAppeal'
|
import { DonationAppeal } from './DonationAppeal'
|
||||||
|
|
||||||
const meta: Meta<typeof DonationAppeal> = {
|
const meta: Meta<typeof DonationAppeal> = {
|
||||||
|
|
|
||||||
11
src/components/DonationForm/DonationForm.stories.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
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 = {}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Meta, StoryObj } from '@storybook/react'
|
import { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||||
import { Dropdown } from './Dropdown'
|
import { Dropdown } from './Dropdown'
|
||||||
|
|
||||||
const meta: Meta<typeof Dropdown> = {
|
const meta: Meta<typeof Dropdown> = {
|
||||||
|
|
|
||||||
29
src/components/Error/Error.stories.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Meta, StoryObj } from '@storybook/react'
|
import { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||||
import { EventExcerpt, EventExcerptRow } from './EventExcerpt'
|
import { EventExcerpt, EventExcerptRow } from './EventExcerpt'
|
||||||
import { TextDiv } from '@/components/Text/TextDiv'
|
import { TextDiv } from '@/components/Text/TextDiv'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Meta, StoryObj } from '@storybook/react'
|
import { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||||
import { EventRow } from './EventRow'
|
import { EventRow } from './EventRow'
|
||||||
|
|
||||||
const meta: Meta<typeof EventRow> = {
|
const meta: Meta<typeof EventRow> = {
|
||||||
|
|
|
||||||
19
src/components/Flex/Col.stories.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
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: {},
|
||||||
|
}
|
||||||
46
src/components/Flex/Row.stories.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
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>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -53,7 +53,7 @@ export const AutoScroll = ({children, isScrolling, onTouch}: AutoScrollProps) =>
|
||||||
}, 1);
|
}, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
}}, [wrapper, step, setStep, direction, setDirection])
|
}}, [wrapper, step, setStep, direction, setDirection, isScrolling])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Meta, StoryObj } from '@storybook/react'
|
import { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||||
import { Gallery } from './Gallery'
|
import { Gallery } from './Gallery'
|
||||||
import chris from "./../../assets/christophorus.jpeg"
|
import chris from "./../../assets/christophorus.jpeg"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Meta, StoryObj } from '@storybook/react'
|
import { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||||
import { HR } from './HorizontalRule'
|
import { HR } from './HorizontalRule'
|
||||||
|
|
||||||
const meta: Meta<typeof HR> = {
|
const meta: Meta<typeof HR> = {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Meta, StoryObj } from "@storybook/react"
|
import { Meta, StoryObj } from "@storybook/nextjs-vite"
|
||||||
import { Image } from './Image'
|
import { Image } from './Image'
|
||||||
|
|
||||||
const meta: Meta<typeof Image> = {
|
const meta: Meta<typeof Image> = {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Meta, StoryObj } from '@storybook/react'
|
import { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||||
import { ImageCard } from './ImageCard'
|
import { ImageCard } from './ImageCard'
|
||||||
|
|
||||||
const meta: Meta<typeof ImageCard> = {
|
const meta: Meta<typeof ImageCard> = {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Meta, StoryObj } from '@storybook/react'
|
import { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||||
import { Input } from './Input'
|
import { Input } from './Input'
|
||||||
|
|
||||||
const meta: Meta<typeof Input> = {
|
const meta: Meta<typeof Input> = {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Meta, StoryObj } from '@storybook/react'
|
import { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||||
import { Logo } from './Logo'
|
import { Logo } from './Logo'
|
||||||
|
|
||||||
const meta: Meta<typeof Logo> = {
|
const meta: Meta<typeof Logo> = {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Meta, StoryObj } from '@storybook/react'
|
import { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||||
import { MainText } from './MainText'
|
import { MainText } from './MainText'
|
||||||
|
|
||||||
const meta: Meta<typeof MainText> = {
|
const meta: Meta<typeof MainText> = {
|
||||||
|
|
|
||||||
300
src/components/MassTable/MassGrid.stories.ts
Normal file
|
|
@ -0,0 +1,300 @@
|
||||||
|
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: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,29 @@
|
||||||
|
import { Worship } from '@/payload-types'
|
||||||
|
import { MassTable } from '@/components/MassTable/MassTable'
|
||||||
import styles from './massgrid.module.scss'
|
import styles from './massgrid.module.scss'
|
||||||
|
|
||||||
type MassGridProps = {
|
export type MassGridLocation = {
|
||||||
children: React.ReactNode
|
id: string
|
||||||
|
name: string
|
||||||
|
masses: Worship[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MassGrid = ({ children }: MassGridProps) => {
|
type MassGridProps = {
|
||||||
return <div className={styles.container}>
|
locations: MassGridLocation[]
|
||||||
<div className={styles.grid}>{children}</div>
|
}
|
||||||
</div>
|
|
||||||
|
export const MassGrid = ({ locations }: MassGridProps) => {
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.grid}>
|
||||||
|
{locations.map((location) => (
|
||||||
|
<MassTable
|
||||||
|
key={location.id}
|
||||||
|
location={location.name}
|
||||||
|
masses={location.masses}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Meta, StoryObj } from '@storybook/react'
|
import { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||||
import { MassTable } from './MassTable'
|
import { MassTable } from './MassTable'
|
||||||
|
|
||||||
const meta: Meta<typeof MassTable> = {
|
const meta: Meta<typeof MassTable> = {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Meta, StoryObj } from '@storybook/react'
|
import { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||||
import { MassTableRow } from './MassTableRow'
|
import { MassTableRow } from './MassTableRow'
|
||||||
|
|
||||||
const meta: Meta<typeof MassTableRow> = {
|
const meta: Meta<typeof MassTableRow> = {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(auto-fit, 200px);
|
flex-wrap: wrap;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
column-gap: 30px;
|
column-gap: 30px;
|
||||||
row-gap: 15px;
|
row-gap: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
max-width: 1200px
|
max-width: 1200px
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,12 @@
|
||||||
.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 {
|
||||||
|
|
@ -43,8 +49,8 @@
|
||||||
|
|
||||||
.table {
|
.table {
|
||||||
width: 200px;
|
width: 200px;
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-row: span 3;
|
flex-direction: column;
|
||||||
grid-template-rows: subgrid;
|
align-items: center;
|
||||||
justify-items: center;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Meta, StoryObj } from '@storybook/react'
|
import { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||||
import { MegaMenu } from './MegaMenu'
|
import { MegaMenu } from './MegaMenu'
|
||||||
|
|
||||||
const meta: Meta<typeof MegaMenu> = {
|
const meta: Meta<typeof MegaMenu> = {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Meta, StoryObj } from '@storybook/react'
|
import { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||||
import { Menu } from './Menu'
|
import { Menu } from './Menu'
|
||||||
|
|
||||||
const meta: Meta<typeof Menu> = {
|
const meta: Meta<typeof Menu> = {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Meta, StoryObj } from '@storybook/react'
|
import { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||||
import { NextPrevButtons } from './NextPrevButtons'
|
import { NextPrevButtons } from './NextPrevButtons'
|
||||||
|
|
||||||
const meta: Meta<typeof NextPrevButtons> = {
|
const meta: Meta<typeof NextPrevButtons> = {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Meta, StoryObj } from '@storybook/react'
|
import { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||||
import { Pill } from './Pill'
|
import { Pill } from './Pill'
|
||||||
|
|
||||||
const meta: Meta<typeof Pill> = {
|
const meta: Meta<typeof Pill> = {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Meta, StoryObj } from '@storybook/react'
|
import { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||||
import { PopupButton } from './PopupButton'
|
import { PopupButton } from './PopupButton'
|
||||||
|
|
||||||
const meta: Meta<typeof PopupButton> = {
|
const meta: Meta<typeof PopupButton> = {
|
||||||
|
|
|
||||||
26
src/components/RandomPrayer/RandomPrayer.stories.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
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.'],
|
||||||
|
},
|
||||||
|
}
|
||||||
27
src/components/RawHTML/RawHTML.stories.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
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>',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Meta, StoryObj } from '@storybook/react'
|
import { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||||
import { Section } from './Section'
|
import { Section } from './Section'
|
||||||
|
|
||||||
const meta: Meta<typeof Section> = {
|
const meta: Meta<typeof Section> = {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Meta, StoryObj } from '@storybook/react'
|
import { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||||
import { SideSlider } from './SideSlider'
|
import { SideSlider } from './SideSlider'
|
||||||
|
|
||||||
const meta: Meta<typeof SideSlider> = {
|
const meta: Meta<typeof SideSlider> = {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Meta, StoryObj } from '@storybook/react'
|
import { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||||
import { Testimony } from './Testimony'
|
import { Testimony } from './Testimony'
|
||||||
|
|
||||||
const meta: Meta<typeof Testimony> = {
|
const meta: Meta<typeof Testimony> = {
|
||||||
|
|
|
||||||
56
src/components/Text/HTMLText.stories.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}
|
||||||
25
src/components/Text/Paragraph.stories.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
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.',
|
||||||
|
},
|
||||||
|
}
|
||||||
89
src/components/Text/RichText.stories.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
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.',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
}
|
||||||