feat: customizable footer

This commit is contained in:
Benno Tielen 2026-03-06 13:29:11 +01:00
parent ac01546440
commit 0240f2df4f
10 changed files with 323 additions and 57 deletions

View file

@ -0,0 +1,38 @@
import { CollectionConfig } from 'payload'
import { revalidateTag } from 'next/cache'
import { isAdminOrEmployee } from '@/collections/access/admin'
export const Prayers: CollectionConfig = {
slug: 'prayers',
labels: {
singular: {
de: 'Stoßgebet',
},
plural: {
de: 'Stoßgebete',
},
},
fields: [
{
name: 'text',
type: 'text',
required: true,
label: {
de: 'Gebet',
},
},
],
admin: {
useAsTitle: 'text',
},
access: {
read: () => true,
create: isAdminOrEmployee(),
update: isAdminOrEmployee(),
delete: isAdminOrEmployee(),
},
hooks: {
afterChange: [() => revalidateTag('prayers')],
afterDelete: [() => revalidateTag('prayers')],
},
}

View file

@ -1,37 +1,31 @@
"use client" 'use client'
import { randomPrayer } from '@/utils/randomPrayer'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
export const RandomPrayer = () => { type RandomPrayerProps = {
const [intervalId, setIntervalId] = useState<NodeJS.Timeout | undefined>(undefined) prayers: string[]
const [prayer, setPrayer] = useState("") }
// Set interval to change the prayer const pickRandom = (prayers: string[]) =>
const newPrayerEveryInterval = useCallback(() => { prayers[Math.floor(Math.random() * prayers.length)] || ''
const i = setInterval(() => {
setPrayer(randomPrayer())
}, 60 * 1000)
setIntervalId(i) export const RandomPrayer = ({ prayers }: RandomPrayerProps) => {
}, [setPrayer, setIntervalId]) const [prayer, setPrayer] = useState('')
// Set new random prayer and reset timer interval
const newPrayer = useCallback(() => { const newPrayer = useCallback(() => {
clearInterval(intervalId) setPrayer(pickRandom(prayers))
setPrayer(randomPrayer()) }, [prayers])
newPrayerEveryInterval()
}, [intervalId, setPrayer, newPrayerEveryInterval])
// Every 30 seconds set a new prayer
useEffect(() => { useEffect(() => {
setPrayer(randomPrayer()) setPrayer(pickRandom(prayers))
newPrayerEveryInterval() const interval = setInterval(() => {
return () => clearInterval(intervalId) setPrayer(pickRandom(prayers))
}, [newPrayerEveryInterval]) }, 60 * 1000)
return () => clearInterval(interval)
}, [prayers])
return ( return (
<p onClick={newPrayer} style={{cursor: "pointer"}}> <p onClick={newPrayer} style={{ cursor: 'pointer' }}>
{prayer} {prayer}
</p> </p>
) )

View file

@ -1,24 +1,30 @@
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 { Logo } from '@/components/Logo/Logo' import { Logo } from '@/components/Logo/Logo'
import styles from "./styles.module.scss" import styles from './styles.module.scss'
import { Row } from '@/components/Flex/Row' import { Row } from '@/components/Flex/Row'
import { Col } from '@/components/Flex/Col' import { Col } from '@/components/Flex/Col'
import { RandomPrayer } from '@/components/RandomPrayer/RandomPrayer' import { RandomPrayer } from '@/components/RandomPrayer/RandomPrayer'
import Link from 'next/link' import Link from 'next/link'
import { fetchPrayers } from '@/fetch/prayers'
import { fetchFooter } from '@/fetch/footer'
export const Footer = async () => {
const [prayers, footer] = await Promise.all([
fetchPrayers(),
fetchFooter(),
])
export const Footer = () => {
return ( return (
<div className={styles.container}> <div className={styles.container}>
<Section backgroundColor="soft"> <Section backgroundColor="soft">
<Container> <Container>
<Row> <Row>
<Col> <Col>
<br/> <br />
<Logo <Logo
color={"#ffffff"} color={'#ffffff'}
textColor={"#426156"} textColor={'#426156'}
withText={true} withText={true}
height={100} height={100}
/> />
@ -26,34 +32,25 @@ export const Footer = () => {
<Col> <Col>
<Row gap={30}> <Row gap={30}>
<Col> {footer.groups?.map((group, i) => (
<strong><p>Gemeinden</p></strong> <Col key={i}>
<ul className={styles.list}> <p>
<li><Link href={'/gemeinde/st-christophorus'}>St. Christophorus</Link></li> <strong>{group.title}</strong>
<li><Link href={'/gemeinde/st-clara'}>St. Clara</Link></li> </p>
<li><Link href={'/gemeinde/st-richard'}>St. Richard</Link></li> <ul className={styles.list}>
</ul> {group.links?.map((link, j) => (
</Col> <li key={j}>
<Link href={link.href}>{link.label}</Link>
</li>
))}
</ul>
</Col>
))}
<Col> <Col>
<p> <p>
<strong>Navigation</strong>
</p>
<ul className={styles.list}>
<li><Link href={'/kontakt'}>Kontakt</Link></li>
<li><Link href={'/gottesdienst'}>Gottesdienste</Link></li>
<li><Link href={'/veranstaltungen'}>Veranstaltungen</Link></li>
<li><Link href={'/mithelfen'}>Mithelfen</Link></li>
<li><Link href={'/datenschutz'}>Datenschutz</Link></li>
<li><Link href={'/schutzkonzept'}>Schutzkonzept</Link></li>
<li><Link href={'/hinweisgeber'}>Hinweisgeber</Link></li>
<li><Link href={'/impressum'}>Impressum</Link></li>
</ul>
</Col>
<Col>
<p>
<strong>Stoßgebet</strong> <strong>Stoßgebet</strong>
</p> </p>
<RandomPrayer /> <RandomPrayer prayers={prayers} />
</Col> </Col>
</Row> </Row>
</Col> </Col>
@ -61,5 +58,5 @@ export const Footer = () => {
</Container> </Container>
</Section> </Section>
</div> </div>
); )
} }

13
src/fetch/footer.ts Normal file
View file

@ -0,0 +1,13 @@
import { Footer } from '@/payload-types'
export async function fetchFooter(): Promise<Footer> {
const res = await fetch('http://localhost:3000/api/globals/footer', {
next: { tags: ['footer'] },
})
if (!res.ok) {
throw new Error('Could not fetch footer')
}
return res.json()
}

11
src/fetch/prayers.ts Normal file
View file

@ -0,0 +1,11 @@
import { Prayer } from '@/payload-types'
export async function fetchPrayers(): Promise<string[]> {
const res = await fetch(
'http://localhost:3000/api/prayers?limit=0',
{ next: { tags: ['prayers'] } },
)
if (!res.ok) return []
const data = await res.json()
return (data.docs as Prayer[]).map((p) => p.text)
}

65
src/globals/Footer.ts Normal file
View file

@ -0,0 +1,65 @@
import { GlobalConfig } from 'payload'
import { isAdmin } from '@/collections/access/admin'
import { revalidateTag } from 'next/cache'
export const FooterGlobal: GlobalConfig = {
slug: 'footer',
label: {
de: 'Fußzeile',
},
admin: {
description:
'Hier können Sie die Linkgruppen im Fußzeile konfigurieren.',
},
fields: [
{
name: 'groups',
label: {
de: 'Linkgruppen',
},
type: 'array',
fields: [
{
name: 'title',
type: 'text',
required: true,
label: {
de: 'Titel',
},
},
{
name: 'links',
type: 'array',
label: {
de: 'Links',
},
fields: [
{
name: 'label',
type: 'text',
required: true,
label: {
de: 'Bezeichnung',
},
},
{
name: 'href',
type: 'text',
required: true,
label: {
de: 'Zieladresse',
},
},
],
},
],
},
],
access: {
read: () => true,
update: isAdmin(),
},
hooks: {
afterChange: [() => revalidateTag('footer')],
},
}

View file

@ -0,0 +1,65 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
const PRAYERS = [
'Herr, erbarme Dich meiner.',
'Herr Jesus Christus, erbarme dich meiner.',
'Ehre sei dem Vater, und dem Sohn und dem Heiligen Geist.',
'Preiset den Herrn zu aller Zeit, denn er ist gut.',
'Mein Herr und mein Gott.',
'Herr, dir in die Hände sei Anfang und Ende, sei alles gelegt.',
'Herr, du weißt alles; du weißt, dass ich dich liebe.',
'Der Herr ist mein Licht und mein Heil, vor wem sollte ich mich fürchten?',
'Herr, dein Wille geschehe.',
'Ich glaube, Herr; hilf meinem Unglauben.',
'O Gott, komm mir zu Hilfe. Herr, eile, mir zu helfen.',
'Jesus, ich vertraue auf Dich.',
'Gegrüßet seist du, Maria, voll der Gnade, der Herr ist mit dir.',
'Gelobt sei Jesus Christus - in Ewigkeit. Amen.',
'Maria mit dem Kinde lieb, uns allen deinen Segen gib.',
'Aus der Tiefe rufe ich Herr zu dir. Herr, höre meine Stimme.',
'Durch sein schmerzhaftes Leiden, habe Erbarmen mit uns und mit der ganzen Welt.',
'Heiliger Gott, habe Erbarmen mit uns und mit der ganzen Welt.',
'Heilige Maria, Mutter Gottes, bitte für uns Sünder.',
'Gepriesen seist du, Herr. Lehre mich deine Gesetze.',
]
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "prayers" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"text" varchar NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE INDEX IF NOT EXISTS "prayers_updated_at_idx" ON "prayers" USING btree ("updated_at");
CREATE INDEX IF NOT EXISTS "prayers_created_at_idx" ON "prayers" USING btree ("created_at");
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN IF NOT EXISTS "prayers_id" uuid;
DO $$ BEGIN
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_prayers_fk" FOREIGN KEY ("prayers_id") REFERENCES "public"."prayers"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
CREATE INDEX IF NOT EXISTS "payload_locked_documents_rels_prayers_id_idx" ON "payload_locked_documents_rels" USING btree ("prayers_id");
`)
for (const text of PRAYERS) {
await db.execute(sql`
INSERT INTO "prayers" ("id", "text", "updated_at", "created_at")
VALUES (gen_random_uuid(), ${text}, now(), now())
`)
}
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "prayers" DISABLE ROW LEVEL SECURITY;
DROP TABLE IF EXISTS "prayers" CASCADE;
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT IF EXISTS "payload_locked_documents_rels_prayers_fk";
DROP INDEX IF EXISTS "payload_locked_documents_rels_prayers_id_idx";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN IF EXISTS "prayers_id";
`)
}

View file

@ -17,6 +17,7 @@ import * as migration_20260106_085445_donationforms from './20260106_085445_dona
import * as migration_20260106_103529_donation_appeal from './20260106_103529_donation_appeal'; import * as migration_20260106_103529_donation_appeal from './20260106_103529_donation_appeal';
import * as migration_20260205_155735_version_bump from './20260205_155735_version_bump'; import * as migration_20260205_155735_version_bump from './20260205_155735_version_bump';
import * as migration_20260305_095426 from './20260305_095426'; import * as migration_20260305_095426 from './20260305_095426';
import * as migration_20260306_100000_prayers from './20260306_100000_prayers';
export const migrations = [ export const migrations = [
{ {
@ -112,6 +113,11 @@ export const migrations = [
{ {
up: migration_20260305_095426.up, up: migration_20260305_095426.up,
down: migration_20260305_095426.down, down: migration_20260305_095426.down,
name: '20260305_095426' name: '20260305_095426',
},
{
up: migration_20260306_100000_prayers.up,
down: migration_20260306_100000_prayers.down,
name: '20260306_100000_prayers',
}, },
]; ];

View file

@ -82,6 +82,7 @@ export interface Config {
group: Group; group: Group;
'donation-form': DonationForm; 'donation-form': DonationForm;
pages: Page; pages: Page;
prayers: Prayer;
magazine: Magazine; magazine: Magazine;
documents: Document; documents: Document;
media: Media; media: Media;
@ -108,6 +109,7 @@ export interface Config {
group: GroupSelect<false> | GroupSelect<true>; group: GroupSelect<false> | GroupSelect<true>;
'donation-form': DonationFormSelect<false> | DonationFormSelect<true>; 'donation-form': DonationFormSelect<false> | DonationFormSelect<true>;
pages: PagesSelect<false> | PagesSelect<true>; pages: PagesSelect<false> | PagesSelect<true>;
prayers: PrayersSelect<false> | PrayersSelect<true>;
magazine: MagazineSelect<false> | MagazineSelect<true>; magazine: MagazineSelect<false> | MagazineSelect<true>;
documents: DocumentsSelect<false> | DocumentsSelect<true>; documents: DocumentsSelect<false> | DocumentsSelect<true>;
media: MediaSelect<false> | MediaSelect<true>; media: MediaSelect<false> | MediaSelect<true>;
@ -123,9 +125,11 @@ export interface Config {
fallbackLocale: null; fallbackLocale: null;
globals: { globals: {
menu: Menu; menu: Menu;
footer: Footer;
}; };
globalsSelect: { globalsSelect: {
menu: MenuSelect<false> | MenuSelect<true>; menu: MenuSelect<false> | MenuSelect<true>;
footer: FooterSelect<false> | FooterSelect<true>;
}; };
locale: null; locale: null;
user: User & { user: User & {
@ -890,6 +894,16 @@ export interface Page {
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "prayers".
*/
export interface Prayer {
id: string;
text: string;
updatedAt: string;
createdAt: string;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "magazine". * via the `definition` "magazine".
@ -1013,6 +1027,10 @@ export interface PayloadLockedDocument {
relationTo: 'pages'; relationTo: 'pages';
value: string | Page; value: string | Page;
} | null) } | null)
| ({
relationTo: 'prayers';
value: string | Prayer;
} | null)
| ({ | ({
relationTo: 'magazine'; relationTo: 'magazine';
value: string | Magazine; value: string | Magazine;
@ -1613,6 +1631,15 @@ export interface PagesSelect<T extends boolean = true> {
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "prayers_select".
*/
export interface PrayersSelect<T extends boolean = true> {
text?: T;
updatedAt?: T;
createdAt?: T;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "magazine_select". * via the `definition` "magazine_select".
@ -1845,6 +1872,30 @@ export interface Menu {
updatedAt?: string | null; updatedAt?: string | null;
createdAt?: string | null; createdAt?: string | null;
} }
/**
* Hier können Sie die Linkgruppen im Fußzeile konfigurieren.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "footer".
*/
export interface Footer {
id: string;
groups?:
| {
title: string;
links?:
| {
label: string;
href: string;
id?: string | null;
}[]
| null;
id?: string | null;
}[]
| null;
updatedAt?: string | null;
createdAt?: string | null;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "menu_select". * via the `definition` "menu_select".
@ -1926,6 +1977,28 @@ export interface MenuSelect<T extends boolean = true> {
createdAt?: T; createdAt?: T;
globalType?: T; globalType?: T;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "footer_select".
*/
export interface FooterSelect<T extends boolean = true> {
groups?:
| T
| {
title?: T;
links?:
| T
| {
label?: T;
href?: T;
id?: T;
};
id?: T;
};
updatedAt?: T;
createdAt?: T;
globalType?: T;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth". * via the `definition` "auth".

View file

@ -37,9 +37,11 @@ import { PopesPrayerIntentions } from '@/collections/PopesPrayerIntentions'
import { LiturgicalCalendar } from '@/collections/LiturgicalCalendar' import { LiturgicalCalendar } from '@/collections/LiturgicalCalendar'
import { Classifieds } from '@/collections/Classifieds' import { Classifieds } from '@/collections/Classifieds'
import { MenuGlobal } from '@/globals/Menu' import { MenuGlobal } from '@/globals/Menu'
import { FooterGlobal } from '@/globals/Footer'
import { Magazine } from '@/collections/Magazine' import { Magazine } from '@/collections/Magazine'
import { DonationForms } from '@/collections/DonationForms' import { DonationForms } from '@/collections/DonationForms'
import { Pages } from '@/collections/Pages' import { Pages } from '@/collections/Pages'
import { Prayers } from '@/collections/Prayers'
const filename = fileURLToPath(import.meta.url) const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename) const dirname = path.dirname(filename)
@ -94,13 +96,15 @@ export default buildConfig({
Groups, Groups,
DonationForms, DonationForms,
Pages, Pages,
Prayers,
Magazine, Magazine,
Documents, Documents,
Media, Media,
Users, Users,
], ],
globals: [ globals: [
MenuGlobal MenuGlobal,
FooterGlobal,
], ],
graphQL: { graphQL: {
disable: true disable: true