cms.c2sgmbh/src/collections/Posts.ts
Martin Porwoll 2b097eefb3 feat: add comprehensive blogging and team features
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>
2025-12-13 21:49:13 +00:00

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
},
],
},
}