mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 23:14:12 +00:00
Blogging Collections: - Tags Collection with name, slug, description, color - Authors Collection with avatar, bio, social media links Posts Collection extended: - Tags and Author relationships - Co-Authors support - Automatic reading time calculation - Legacy author text field fallback New Blogging Blocks: - AuthorBioBlock: Display author info with various layouts - RelatedPostsBlock: Show related articles (auto/manual/category/tag) - ShareButtonsBlock: Social sharing (Facebook, Twitter, LinkedIn, etc.) - TableOfContentsBlock: Auto-generated TOC from headings Team Collection extended: - Slug field for profile pages (auto-generated) - Hierarchy fields (reportsTo, hierarchyLevel) for org charts - vCard export flag New Team API Endpoints: - GET /api/team - List with search and filters - GET /api/team/[slug]/vcard - vCard download (VCF) New Team Blocks: - TeamFilterBlock: Interactive team display with search/filter - OrgChartBlock: Hierarchical organization chart visualization 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
231 lines
5.4 KiB
TypeScript
231 lines
5.4 KiB
TypeScript
import type { CollectionConfig } from 'payload'
|
|
import { authenticatedOnly, tenantScopedPublicRead } from '../lib/tenantAccess'
|
|
|
|
/**
|
|
* Berechnet die geschätzte Lesezeit basierend auf Wortanzahl
|
|
* Durchschnitt: ~200 Wörter pro Minute
|
|
*/
|
|
function calculateReadingTime(content: unknown): number {
|
|
if (!content) return 1
|
|
|
|
// RichText zu Plain Text konvertieren (vereinfacht)
|
|
let text = ''
|
|
const extractText = (node: unknown): void => {
|
|
if (!node || typeof node !== 'object') return
|
|
const n = node as Record<string, unknown>
|
|
if (n.text && typeof n.text === 'string') {
|
|
text += n.text + ' '
|
|
}
|
|
if (Array.isArray(n.children)) {
|
|
n.children.forEach(extractText)
|
|
}
|
|
if (n.root && typeof n.root === 'object') {
|
|
extractText(n.root)
|
|
}
|
|
}
|
|
|
|
extractText(content)
|
|
|
|
const words = text.trim().split(/\s+/).filter(Boolean).length
|
|
const minutes = Math.ceil(words / 200)
|
|
return Math.max(1, minutes)
|
|
}
|
|
|
|
export const Posts: CollectionConfig = {
|
|
slug: 'posts',
|
|
admin: {
|
|
useAsTitle: 'title',
|
|
group: 'Content',
|
|
defaultColumns: ['title', 'type', 'isFeatured', 'status', 'publishedAt'],
|
|
},
|
|
access: {
|
|
read: tenantScopedPublicRead,
|
|
create: authenticatedOnly,
|
|
update: authenticatedOnly,
|
|
delete: authenticatedOnly,
|
|
},
|
|
fields: [
|
|
{
|
|
name: 'title',
|
|
type: 'text',
|
|
required: true,
|
|
localized: true,
|
|
},
|
|
{
|
|
name: 'slug',
|
|
type: 'text',
|
|
required: true,
|
|
localized: true,
|
|
unique: false, // Uniqueness per locale handled by index
|
|
admin: {
|
|
description: 'URL-Pfad (z.B. "mein-beitrag" / "my-post")',
|
|
},
|
|
},
|
|
// === NEUE FELDER ===
|
|
{
|
|
name: 'type',
|
|
type: 'select',
|
|
required: true,
|
|
defaultValue: 'blog',
|
|
options: [
|
|
{ label: 'Blog-Artikel', value: 'blog' },
|
|
{ label: 'News/Aktuelles', value: 'news' },
|
|
{ label: 'Pressemitteilung', value: 'press' },
|
|
{ label: 'Ankündigung', value: 'announcement' },
|
|
],
|
|
admin: {
|
|
position: 'sidebar',
|
|
description: 'Art des Beitrags',
|
|
},
|
|
},
|
|
{
|
|
name: 'isFeatured',
|
|
type: 'checkbox',
|
|
defaultValue: false,
|
|
label: 'Hervorgehoben',
|
|
admin: {
|
|
position: 'sidebar',
|
|
description: 'Auf Startseite/oben anzeigen',
|
|
},
|
|
},
|
|
{
|
|
name: 'excerpt',
|
|
type: 'textarea',
|
|
label: 'Kurzfassung',
|
|
maxLength: 300,
|
|
localized: true,
|
|
admin: {
|
|
description: 'Für Übersichten und SEO (max. 300 Zeichen). Wird automatisch aus Content generiert, falls leer.',
|
|
},
|
|
},
|
|
// === ENDE NEUE FELDER ===
|
|
{
|
|
name: 'featuredImage',
|
|
type: 'upload',
|
|
relationTo: 'media',
|
|
label: 'Beitragsbild',
|
|
},
|
|
{
|
|
name: 'content',
|
|
type: 'richText',
|
|
required: true,
|
|
localized: true,
|
|
},
|
|
{
|
|
name: 'categories',
|
|
type: 'relationship',
|
|
relationTo: 'categories',
|
|
hasMany: true,
|
|
label: 'Kategorien',
|
|
},
|
|
{
|
|
name: 'tags',
|
|
type: 'relationship',
|
|
relationTo: 'tags',
|
|
hasMany: true,
|
|
label: 'Tags',
|
|
admin: {
|
|
description: 'Schlagwörter für bessere Auffindbarkeit',
|
|
},
|
|
},
|
|
{
|
|
name: 'author',
|
|
type: 'relationship',
|
|
relationTo: 'authors',
|
|
label: 'Autor',
|
|
admin: {
|
|
description: 'Hauptautor des Beitrags',
|
|
},
|
|
},
|
|
{
|
|
name: 'coAuthors',
|
|
type: 'relationship',
|
|
relationTo: 'authors',
|
|
hasMany: true,
|
|
label: 'Co-Autoren',
|
|
admin: {
|
|
description: 'Weitere beteiligte Autoren',
|
|
},
|
|
},
|
|
{
|
|
name: 'authorLegacy',
|
|
type: 'text',
|
|
label: 'Autor (Legacy)',
|
|
admin: {
|
|
description: 'Freitext-Autor für ältere Beiträge ohne Autoren-Eintrag',
|
|
condition: (_, siblingData) => !siblingData?.author,
|
|
},
|
|
},
|
|
{
|
|
name: 'readingTime',
|
|
type: 'number',
|
|
label: 'Lesezeit (Minuten)',
|
|
admin: {
|
|
position: 'sidebar',
|
|
description: 'Wird automatisch berechnet',
|
|
readOnly: true,
|
|
},
|
|
},
|
|
{
|
|
name: 'status',
|
|
type: 'select',
|
|
defaultValue: 'draft',
|
|
options: [
|
|
{ label: 'Entwurf', value: 'draft' },
|
|
{ label: 'Veröffentlicht', value: 'published' },
|
|
{ label: 'Archiviert', value: 'archived' },
|
|
],
|
|
admin: {
|
|
position: 'sidebar',
|
|
},
|
|
},
|
|
{
|
|
name: 'publishedAt',
|
|
type: 'date',
|
|
label: 'Veröffentlichungsdatum',
|
|
admin: {
|
|
position: 'sidebar',
|
|
date: {
|
|
pickerAppearance: 'dayAndTime',
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: 'seo',
|
|
type: 'group',
|
|
label: 'SEO',
|
|
fields: [
|
|
{
|
|
name: 'metaTitle',
|
|
type: 'text',
|
|
label: 'Meta-Titel',
|
|
localized: true,
|
|
},
|
|
{
|
|
name: 'metaDescription',
|
|
type: 'textarea',
|
|
label: 'Meta-Beschreibung',
|
|
maxLength: 160,
|
|
localized: true,
|
|
},
|
|
{
|
|
name: 'ogImage',
|
|
type: 'upload',
|
|
relationTo: 'media',
|
|
label: 'Social Media Bild',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
hooks: {
|
|
beforeChange: [
|
|
({ data }) => {
|
|
// Automatische Lesezeit-Berechnung
|
|
if (data?.content) {
|
|
data.readingTime = calculateReadingTime(data.content)
|
|
}
|
|
return data
|
|
},
|
|
],
|
|
},
|
|
}
|